From 9ac92d1abc604e017066618ad2dbdec7153456e4 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Tue, 10 Sep 2024 21:35:04 -0400 Subject: [PATCH 001/107] Further GraphQL API Implementation - Introduce the concept of `IAuthority`s to handle business logic for both REST and GraphQL. - Add new authorize attributes so roles can be defined on `IAuthority`s and inherited on REST/GraphQL API surface. - Implement `ILoginAuthority`, converting REST methods to use it. - Partially implement `IUserAuthority`, converting REST methods to use it. - Setup `ErrorMessageResponse` filtering for GraphQL fields. --- .../Authority/Core/AuthorityBase.cs | 91 ++++++ .../Core/AuthorityInvoker{TAuthority}.cs | 161 ++++++++++ .../Authority/Core/AuthorityResponse.cs | 48 +++ .../Core/AuthorityResponse{TResult}.cs | 51 +++ .../Authority/Core/HttpFailureResponse.cs | 63 ++++ .../Authority/Core/HttpSuccessResponse.cs | 23 ++ .../Authority/Core/IAuthority.cs | 11 + .../IGraphQLAuthorityInvoker{TAuthority}.cs | 44 +++ .../Core/IRestAuthorityInvoker{TAuthority}.cs | 49 +++ .../Authority/ILoginAuthority.cs | 21 ++ .../Authority/IUserAuthority.cs | 45 +++ .../Authority/LoginAuthority.cs | 293 ++++++++++++++++++ .../Authority/UserAuthority.cs | 83 +++++ .../Controllers/ApiController.cs | 26 +- .../Controllers/ApiRootController.cs | 209 +------------ .../Controllers/UserController.cs | 39 ++- src/Tgstation.Server.Host/Core/Application.cs | 21 +- .../GraphQL/ErrorMessageException.cs | 37 +++ .../GraphQL/ErrorMessageFilter.cs | 79 +++++ src/Tgstation.Server.Host/GraphQL/Mutation.cs | 34 +- .../GraphQL/Types/Entity.cs | 5 +- .../GraphQL/Types/ServerSwarm.cs | 7 + .../GraphQL/Types/User.cs | 22 +- .../GraphQL/Types/Users.cs | 55 ++++ src/Tgstation.Server.Host/Models/User.cs | 21 +- ...gsGraphQLAuthorizeAttribute{TAuthority}.cs | 40 +++ .../TgsRestAuthorizeAttribute{TAuthority}.cs | 44 +++ .../Tgstation.Server.Host.csproj | 2 +- 28 files changed, 1384 insertions(+), 240 deletions(-) create mode 100644 src/Tgstation.Server.Host/Authority/Core/AuthorityBase.cs create mode 100644 src/Tgstation.Server.Host/Authority/Core/AuthorityInvoker{TAuthority}.cs create mode 100644 src/Tgstation.Server.Host/Authority/Core/AuthorityResponse.cs create mode 100644 src/Tgstation.Server.Host/Authority/Core/AuthorityResponse{TResult}.cs create mode 100644 src/Tgstation.Server.Host/Authority/Core/HttpFailureResponse.cs create mode 100644 src/Tgstation.Server.Host/Authority/Core/HttpSuccessResponse.cs create mode 100644 src/Tgstation.Server.Host/Authority/Core/IAuthority.cs create mode 100644 src/Tgstation.Server.Host/Authority/Core/IGraphQLAuthorityInvoker{TAuthority}.cs create mode 100644 src/Tgstation.Server.Host/Authority/Core/IRestAuthorityInvoker{TAuthority}.cs create mode 100644 src/Tgstation.Server.Host/Authority/ILoginAuthority.cs create mode 100644 src/Tgstation.Server.Host/Authority/IUserAuthority.cs create mode 100644 src/Tgstation.Server.Host/Authority/LoginAuthority.cs create mode 100644 src/Tgstation.Server.Host/Authority/UserAuthority.cs create mode 100644 src/Tgstation.Server.Host/GraphQL/ErrorMessageException.cs create mode 100644 src/Tgstation.Server.Host/GraphQL/ErrorMessageFilter.cs create mode 100644 src/Tgstation.Server.Host/GraphQL/Types/Users.cs create mode 100644 src/Tgstation.Server.Host/Security/TgsGraphQLAuthorizeAttribute{TAuthority}.cs create mode 100644 src/Tgstation.Server.Host/Security/TgsRestAuthorizeAttribute{TAuthority}.cs 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..45121dd8ffc --- /dev/null +++ b/src/Tgstation.Server.Host/Authority/Core/AuthorityBase.cs @@ -0,0 +1,91 @@ +using System; +using System.Globalization; + +using Microsoft.Extensions.Logging; + +using Octokit; + +using Tgstation.Server.Api.Models; +using Tgstation.Server.Api.Models.Response; + +namespace Tgstation.Server.Host.Authority.Core +{ + /// + /// Base implementation of . + /// + abstract class AuthorityBase : IAuthority + { + /// + /// 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 Forbid() + => new( + new ErrorMessageResponse(), + HttpFailureResponse.Forbidden); + + /// + /// Generates a type . + /// + /// The of the . + /// A new, errored . + protected static AuthorityResponse NotFound() + => new( + new ErrorMessageResponse(), + HttpFailureResponse.NotFound); + + /// + /// Initializes a new instance of the class. + /// + /// The value of . + protected AuthorityBase(ILogger logger) + { + 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/AuthorityInvoker{TAuthority}.cs b/src/Tgstation.Server.Host/Authority/Core/AuthorityInvoker{TAuthority}.cs new file mode 100644 index 00000000000..1756c62cf83 --- /dev/null +++ b/src/Tgstation.Server.Host/Authority/Core/AuthorityInvoker{TAuthority}.cs @@ -0,0 +1,161 @@ +using System; +using System.Net; +using System.Threading.Tasks; + +using HotChocolate.Execution; + +using Microsoft.AspNetCore.Mvc; +using Tgstation.Server.Host.Controllers; +using Tgstation.Server.Host.Extensions; +using Tgstation.Server.Host.GraphQL; +using Tgstation.Server.Host.Models; + +namespace Tgstation.Server.Host.Authority.Core +{ + /// + /// Invokes s. + /// + /// The invoked. + sealed class AuthorityInvoker : IRestAuthorityInvoker, IGraphQLAuthorityInvoker + where TAuthority : IAuthority + { + /// + /// The being invoked. + /// + readonly TAuthority authority; + + /// + /// Throws a for errored s. + /// + /// The potentially errored . + static void ThrowGraphQLErrorIfNecessary(AuthorityResponse authorityResponse) + { + if (authorityResponse.Success) + return; + + var fallbackString = authorityResponse.FailureResponse.ToString()!; + throw new ErrorMessageException(authorityResponse.ErrorMessage, fallbackString); + } + + /// + /// Initializes a new instance of the class. + /// + /// The value of . + public AuthorityInvoker(TAuthority authority) + { + this.authority = authority ?? throw new ArgumentNullException(nameof(authority)); + } + + /// + public async ValueTask Invoke(ApiController controller, Func> authorityInvoker) + { + var authorityResponse = await authorityInvoker(authority); + return CreateErroredActionResult(controller, authorityResponse) ?? controller.NoContent(); + } + + /// + public async ValueTask InvokeTransformable(ApiController controller, Func>> authorityInvoker) + + where TResult : notnull, IApiTransformable + where TApiModel : notnull + { + var authorityResponse = await authorityInvoker(authority); + var erroredResult = CreateErroredActionResult(controller, authorityResponse); + if (erroredResult != null) + return erroredResult; + + var result = authorityResponse.Result!; + var apiModel = result.ToApi(); + return CreateSuccessfulActionResult(controller, apiModel, authorityResponse); + } + + /// + async ValueTask IRestAuthorityInvoker.Invoke(ApiController controller, Func>> authorityInvoker) + { + var authorityResponse = await authorityInvoker(authority); + var erroredResult = CreateErroredActionResult(controller, authorityResponse); + if (erroredResult != null) + return erroredResult; + + var result = authorityResponse.Result!; + return CreateSuccessfulActionResult(controller, result, authorityResponse); + } + + /// + async ValueTask IGraphQLAuthorityInvoker.Invoke(Func> authorityInvoker) + { + var authorityResponse = await authorityInvoker(authority); + ThrowGraphQLErrorIfNecessary(authorityResponse); + } + + /// + public async ValueTask Invoke(Func>> authorityInvoker) + where TResult : TApiModel + where TApiModel : notnull + { + var authorityResponse = await authorityInvoker(authority); + ThrowGraphQLErrorIfNecessary(authorityResponse); + return authorityResponse.Result!; + } + + /// + async ValueTask IGraphQLAuthorityInvoker.InvokeTransformable(Func>> authorityInvoker) + { + var authorityResponse = await authorityInvoker(authority); + ThrowGraphQLErrorIfNecessary(authorityResponse); + return authorityResponse.Result!.ToApi(); + } + + /// + /// Create an for a given if it is erroring. + /// + /// The to use. + /// The . + /// An if the is not successful, otherwise. + 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}"), + }; + } + + /// + /// Create an for a given successfuly API . + /// + /// The to use. + /// The resulting from the . + /// The . + /// An for the . + /// The result returned in the . + /// The REST API result model built from . + IActionResult CreateSuccessfulActionResult(ApiController controller, TApiModel result, AuthorityResponse authorityResponse) + where TApiModel : notnull + { + var successResponse = authorityResponse.SuccessResponse; + 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}"), + }; + } + } +} 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..44376f488ce --- /dev/null +++ b/src/Tgstation.Server.Host/Authority/Core/AuthorityResponse{TResult}.cs @@ -0,0 +1,51 @@ +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(Result))] + [MemberNotNullWhen(true, nameof(SuccessResponse))] + public override bool Success => base.Success; + + /// + /// 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; + } + } +} 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/IGraphQLAuthorityInvoker{TAuthority}.cs b/src/Tgstation.Server.Host/Authority/Core/IGraphQLAuthorityInvoker{TAuthority}.cs new file mode 100644 index 00000000000..7ce01fa6372 --- /dev/null +++ b/src/Tgstation.Server.Host/Authority/Core/IGraphQLAuthorityInvoker{TAuthority}.cs @@ -0,0 +1,44 @@ +using System; +using System.Threading.Tasks; + +using Tgstation.Server.Host.Models; + +namespace Tgstation.Server.Host.Authority.Core +{ + /// + /// Invokes s from GraphQL endpoints. + /// + /// The invoked. + public interface IGraphQLAuthorityInvoker + 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 Invoke(Func>> authorityInvoker) + where TResult : TApiModel + where TApiModel : notnull; + + /// + /// 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 InvokeTransformable(Func>> authorityInvoker) + where TResult : notnull, IApiTransformable + where TApiModel : notnull; + } +} diff --git a/src/Tgstation.Server.Host/Authority/Core/IRestAuthorityInvoker{TAuthority}.cs b/src/Tgstation.Server.Host/Authority/Core/IRestAuthorityInvoker{TAuthority}.cs new file mode 100644 index 00000000000..55e61f34cff --- /dev/null +++ b/src/Tgstation.Server.Host/Authority/Core/IRestAuthorityInvoker{TAuthority}.cs @@ -0,0 +1,49 @@ +using System; +using System.Threading.Tasks; + +using Microsoft.AspNetCore.Mvc; +using Tgstation.Server.Host.Controllers; +using Tgstation.Server.Host.Models; + +namespace Tgstation.Server.Host.Authority.Core +{ + /// + /// Invokes methods and generates responses. + /// + /// The type of . + public interface IRestAuthorityInvoker + 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, IApiTransformable + where TApiModel : notnull; + } +} diff --git a/src/Tgstation.Server.Host/Authority/ILoginAuthority.cs b/src/Tgstation.Server.Host/Authority/ILoginAuthority.cs new file mode 100644 index 00000000000..bdb799c5e75 --- /dev/null +++ b/src/Tgstation.Server.Host/Authority/ILoginAuthority.cs @@ -0,0 +1,21 @@ +using System.Threading; +using System.Threading.Tasks; + +using Tgstation.Server.Api.Models.Response; +using Tgstation.Server.Host.Authority.Core; + +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 . + ValueTask> AttemptLogin(CancellationToken cancellationToken); + } +} diff --git a/src/Tgstation.Server.Host/Authority/IUserAuthority.cs b/src/Tgstation.Server.Host/Authority/IUserAuthority.cs new file mode 100644 index 00000000000..29eebfab5b3 --- /dev/null +++ b/src/Tgstation.Server.Host/Authority/IUserAuthority.cs @@ -0,0 +1,45 @@ +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using Tgstation.Server.Api.Models; +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] + public ValueTask> Read(CancellationToken cancellationToken); + + /// + /// Gets the with a given . + /// + /// The of the . + /// If relevant entities should be loaded. + /// The for the operation. + /// A resulting in a . + [TgsAuthorize(AdministrationRights.ReadUsers)] + public ValueTask> GetId(long id, bool includeJoins, CancellationToken cancellationToken); + + /// + /// Gets all registered s. + /// + /// If relevant entities should be loaded. + /// The for the operation. + /// A resulting in a . + [TgsAuthorize(AdministrationRights.ReadUsers)] + public ValueTask>> List(bool includeJoins, 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..57f331a21cc --- /dev/null +++ b/src/Tgstation.Server.Host/Authority/LoginAuthority.cs @@ -0,0 +1,293 @@ +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.Models; +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 IDatabaseContext databaseContext; + + /// + /// The for the . + /// + readonly IOAuthProviders oAuthProviders; + + /// + /// The for the . + /// + readonly ITokenFactory tokenFactory; + + /// + /// The for the . + /// + readonly ICryptographySuite cryptographySuite; + + /// + /// The for the . + /// + readonly IIdentityCache identityCache; + + /// + /// 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 + .Select(x => new 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(); + + return user; + } + + /// + /// Initializes a new instance of the class. + /// + /// 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( + ILogger logger, + IApiHeadersProvider apiHeadersProvider, + ISystemIdentityFactory systemIdentityFactory, + IDatabaseContext databaseContext, + IOAuthProviders oAuthProviders, + ITokenFactory tokenFactory, + ICryptographySuite cryptographySuite, + IIdentityCache identityCache) + : base(logger) + { + this.apiHeadersProvider = apiHeadersProvider ?? throw new ArgumentNullException(nameof(apiHeadersProvider)); + this.systemIdentityFactory = systemIdentityFactory ?? throw new ArgumentNullException(nameof(systemIdentityFactory)); + this.databaseContext = databaseContext ?? throw new ArgumentNullException(nameof(databaseContext)); + 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)); + } + + /// + 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 (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 = 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) + await CacheSystemIdentity(systemIdentity!, user, token); + + Logger.LogDebug("Successfully logged in user {userId}!", user.Id); + + return new AuthorityResponse(token); + } + } + + /// + /// Add a given to the . + /// + /// The to cache. + /// The the was generated for. + /// The for the . + /// A representing the running operation. + private async ValueTask CacheSystemIdentity(ISystemIdentity systemIdentity, User user, TokenResponse token) + { + // 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); + } + } +} diff --git a/src/Tgstation.Server.Host/Authority/UserAuthority.cs b/src/Tgstation.Server.Host/Authority/UserAuthority.cs new file mode 100644 index 00000000000..a1d09a2afd2 --- /dev/null +++ b/src/Tgstation.Server.Host/Authority/UserAuthority.cs @@ -0,0 +1,83 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +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 UserAuthority : AuthorityBase, IUserAuthority + { + /// + /// The for the . + /// + readonly IDatabaseContext databaseContext; + + /// + /// The for the . + /// + readonly IAuthenticationContext authenticationContext; + + /// + /// Initializes a new instance of the class. + /// + /// The to use. + /// The value of . + /// The value of . + public UserAuthority( + ILogger logger, + IDatabaseContext databaseContext, + IAuthenticationContext authenticationContext) + : base(logger) + { + this.databaseContext = databaseContext ?? throw new ArgumentNullException(nameof(databaseContext)); + this.authenticationContext = authenticationContext ?? throw new ArgumentNullException(nameof(authenticationContext)); + } + + /// + public ValueTask> Read(CancellationToken cancellationToken) + => ValueTask.FromResult(new AuthorityResponse(authenticationContext.User)); + + /// + public async ValueTask> GetId(long id, bool includeJoins, CancellationToken cancellationToken) + { + var queryable = databaseContext + .Users + .AsQueryable(); + + if (includeJoins) + queryable = queryable + .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); + + var user = await queryable.FirstOrDefaultAsync( + dbModel => dbModel.Id == id, + cancellationToken); + if (user == default) + return NotFound(); + + if (user.CanonicalName == User.CanonicalizeName(User.TgsSystemUserName)) + return Forbid(); + + return new AuthorityResponse(user); + } + + /// + public ValueTask>> List(bool includeJoins, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/Tgstation.Server.Host/Controllers/ApiController.cs b/src/Tgstation.Server.Host/Controllers/ApiController.cs index bad2e6c10f1..60eb6b9e54d 100644 --- a/src/Tgstation.Server.Host/Controllers/ApiController.cs +++ b/src/Tgstation.Server.Host/Controllers/ApiController.cs @@ -104,6 +104,19 @@ protected ApiController( this.requireHeaders = requireHeaders; } + /// + /// Generic 201 response with a given . + /// + /// The accompanying API payload. + /// A with the given . + public ObjectResult Created(object payload) => StatusCode((int)HttpStatusCode.Created, payload); + + /// + /// Generic 401 response. + /// + /// An with . + public new ObjectResult Unauthorized() => this.StatusCode(HttpStatusCode.Unauthorized, null); + /// #pragma warning disable CA1506 // TODO: Decomplexify protected override async ValueTask HookExecuteAction(Func executeAction, CancellationToken cancellationToken) @@ -186,12 +199,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. /// @@ -210,13 +217,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 . /// diff --git a/src/Tgstation.Server.Host/Controllers/ApiRootController.cs b/src/Tgstation.Server.Host/Controllers/ApiRootController.cs index 626a0a71b11..2ac52cf28c7 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,8 +13,9 @@ 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.Authority.Core; using Tgstation.Server.Host.Components.Interop; using Tgstation.Server.Host.Configuration; using Tgstation.Server.Host.Core; @@ -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.Invoke(this, authority => authority.AttemptLogin(cancellationToken)); } -#pragma warning restore CA1506 } } diff --git a/src/Tgstation.Server.Host/Controllers/UserController.cs b/src/Tgstation.Server.Host/Controllers/UserController.cs index 68bccfa1692..35c5021e5e8 100644 --- a/src/Tgstation.Server.Host/Controllers/UserController.cs +++ b/src/Tgstation.Server.Host/Controllers/UserController.cs @@ -14,6 +14,8 @@ using Tgstation.Server.Api.Models.Request; using Tgstation.Server.Api.Models.Response; using Tgstation.Server.Api.Rights; +using Tgstation.Server.Host.Authority; +using Tgstation.Server.Host.Authority.Core; using Tgstation.Server.Host.Configuration; using Tgstation.Server.Host.Controllers.Results; using Tgstation.Server.Host.Database; @@ -45,6 +47,11 @@ public sealed class UserController : ApiController /// readonly IPermissionsUpdateNotifyee permissionsUpdateNotifyee; + /// + /// The for the . + /// + readonly IRestAuthorityInvoker userAuthority; + /// /// The for the . /// @@ -57,6 +64,7 @@ public sealed class UserController : ApiController /// The for the . /// The value of . /// The value of . + /// The value of . /// The value of . /// The for the . /// The containing the value of . @@ -67,6 +75,7 @@ public UserController( ISystemIdentityFactory systemIdentityFactory, ICryptographySuite cryptographySuite, IPermissionsUpdateNotifyee permissionsUpdateNotifyee, + IRestAuthorityInvoker userAuthority, ILogger logger, IOptions generalConfigurationOptions, IApiHeadersProvider apiHeaders) @@ -80,6 +89,7 @@ public UserController( this.systemIdentityFactory = systemIdentityFactory ?? throw new ArgumentNullException(nameof(systemIdentityFactory)); this.cryptographySuite = cryptographySuite ?? throw new ArgumentNullException(nameof(cryptographySuite)); this.permissionsUpdateNotifyee = permissionsUpdateNotifyee ?? throw new ArgumentNullException(nameof(permissionsUpdateNotifyee)); + this.userAuthority = userAuthority ?? throw new ArgumentNullException(nameof(userAuthority)); generalConfiguration = generalConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(generalConfigurationOptions)); } @@ -343,12 +353,14 @@ public async ValueTask Update([FromBody] UserUpdateRequest model, /// /// 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. @@ -395,27 +407,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()); + return await userAuthority.InvokeTransformable( + this, + authority => authority.GetId(id, true, cancellationToken)); } /// diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs index 0730eabd060..33e10e3d0d5 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; @@ -292,9 +295,12 @@ void ConfigureNewtonsoftJsonSerializerSettingsForApi(JsonSerializerSettings sett services .AddGraphQLServer() .AddAuthorization() + .AddMutationConventions() + .AddErrorFilter() .AddType() .BindRuntimeType() - .AddQueryType(); + .AddQueryType() + .AddMutationType(); void AddTypedContext() where TContext : DatabaseContext @@ -431,6 +437,12 @@ void AddTypedContext() services.AddSingleton(); services.AddSingleton(); + // configure authorities + services.AddScoped(typeof(IRestAuthorityInvoker<>), typeof(AuthorityInvoker<>)); + services.AddScoped(typeof(IGraphQLAuthorityInvoker<>), typeof(AuthorityInvoker<>)); + services.AddScoped(); + services.AddScoped(); + // configure misc services services.AddSingleton(); services.AddSingleton(); @@ -616,7 +628,12 @@ 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); + endpoints + .MapGraphQL(Routes.GraphQL) + .WithOptions(new GraphQLServerOptions + { + EnableBatching = true, + }); } }); diff --git a/src/Tgstation.Server.Host/GraphQL/ErrorMessageException.cs b/src/Tgstation.Server.Host/GraphQL/ErrorMessageException.cs new file mode 100644 index 00000000000..20c8550b6ba --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/ErrorMessageException.cs @@ -0,0 +1,37 @@ +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; + } + } +} 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/Mutation.cs b/src/Tgstation.Server.Host/GraphQL/Mutation.cs index 7988affc6ba..972612f885d 100644 --- a/src/Tgstation.Server.Host/GraphQL/Mutation.cs +++ b/src/Tgstation.Server.Host/GraphQL/Mutation.cs @@ -1,11 +1,39 @@ -namespace Tgstation.Server.Host.GraphQL +using System; +using System.Threading; +using System.Threading.Tasks; + +using HotChocolate; +using HotChocolate.Types; + +using Tgstation.Server.Api.Models.Response; +using Tgstation.Server.Host.Authority; +using Tgstation.Server.Host.Authority.Core; + +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 JWT for authenticating with server. + /// + /// The . + /// The for the operation. + /// A Bearer token to be used with further communication with the server. + [Error(typeof(ErrorMessageException))] + public async ValueTask Login( + [Service] IGraphQLAuthorityInvoker loginAuthority, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(loginAuthority); + + var tokenResponse = await loginAuthority.Invoke( + authority => authority.AttemptLogin(cancellationToken)); + + return tokenResponse.Bearer!; + } } } diff --git a/src/Tgstation.Server.Host/GraphQL/Types/Entity.cs b/src/Tgstation.Server.Host/GraphQL/Types/Entity.cs index 20c8a08b80a..ab51d61a6f8 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/Entity.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/Entity.cs @@ -1,4 +1,6 @@ -namespace Tgstation.Server.Host.GraphQL.Types +using HotChocolate.Types.Relay; + +namespace Tgstation.Server.Host.GraphQL.Types { /// /// Represents a database entity. @@ -8,6 +10,7 @@ public abstract class Entity /// /// The ID of the . /// + [ID] public long Id { get; } /// diff --git a/src/Tgstation.Server.Host/GraphQL/Types/ServerSwarm.cs b/src/Tgstation.Server.Host/GraphQL/Types/ServerSwarm.cs index e4ddf59dd2f..f2ec6c61513 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/ServerSwarm.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/ServerSwarm.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using HotChocolate; + using Tgstation.Server.Api.Models.Internal; using Tgstation.Server.Host.Core; using Tgstation.Server.Host.Swarm; @@ -35,6 +36,12 @@ public SwarmMetadata Metadata( /// A new . public LocalServer LocalServer() => new(); + /// + /// Gets the swarm's . + /// + /// A new . + public Users Users() => new(); + /// /// Gets the for all servers in a swarm. /// diff --git a/src/Tgstation.Server.Host/GraphQL/Types/User.cs b/src/Tgstation.Server.Host/GraphQL/Types/User.cs index 93badc81655..f32c6ff2cd9 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/User.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/User.cs @@ -14,6 +14,11 @@ public sealed class User : NamedEntity /// public bool Enabled { get; } + /// + /// The user's canonical (Uppercase) name. + /// + public string CanonicalName { get; } + /// /// When the was created. /// @@ -27,30 +32,41 @@ public sealed class User : NamedEntity /// /// The of the . /// - readonly long createdById; + readonly long? createdById; + + /// + /// The of the . + /// + readonly long? groupId; /// /// Initializes a new instance of the class. /// /// The . /// The . + /// The value of . /// The value of . - /// The value of . /// The value of . + /// The value of . + /// The value of . /// The value of . public User( long id, string name, + string canonicalName, string? systemIdentifier, DateTimeOffset createdAt, - long createdById, + long? createdById, + long? groupId, bool enabled) : base(id, name) { SystemIdentifier = systemIdentifier; + CanonicalName = canonicalName ?? throw new ArgumentNullException(nameof(canonicalName)); CreatedAt = createdAt; this.createdById = createdById; Enabled = enabled; + this.groupId = groupId; } /// 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..30d683201c7 --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Types/Users.cs @@ -0,0 +1,55 @@ +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.Authority.Core; +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 current . + /// + /// 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 user by . + /// + /// The of the . + /// The . + /// The for the operation. + /// A resulting in a . + [Error(typeof(ErrorMessageException))] + [TgsGraphQLAuthorize(nameof(IUserAuthority.GetId))] + public async ValueTask ById( + [ID(nameof(User))] long id, + [Service] IGraphQLAuthorityInvoker userAuthority, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(userAuthority); + return await userAuthority.InvokeTransformable(authority => authority.GetId(id, false, cancellationToken)); + } + } +} diff --git a/src/Tgstation.Server.Host/Models/User.cs b/src/Tgstation.Server.Host/Models/User.cs index 0939dd8042c..d45d579d600 100644 --- a/src/Tgstation.Server.Host/Models/User.cs +++ b/src/Tgstation.Server.Host/Models/User.cs @@ -9,7 +9,7 @@ namespace Tgstation.Server.Host.Models { /// - public sealed class User : Api.Models.Internal.UserModelBase, IApiTransformable + public sealed class User : Api.Models.Internal.UserModelBase, IApiTransformable, IApiTransformable { /// /// Username used when creating jobs automatically. @@ -26,13 +26,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; } @@ -78,6 +83,18 @@ public sealed class User : Api.Models.Internal.UserModelBase, IApiTransformable< /// public UserResponse ToApi() => CreateUserResponse(true); + /// + GraphQL.Types.User IApiTransformable.ToApi() + => new( + Id!.Value, + Name!, + CanonicalName!, + SystemIdentifier, + CreatedAt!.Value, + CreatedById, + GroupId, + Enabled!.Value); + /// /// Generate a from . /// 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..f8e64ec4487 --- /dev/null +++ b/src/Tgstation.Server.Host/Security/TgsGraphQLAuthorizeAttribute{TAuthority}.cs @@ -0,0 +1,40 @@ +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(','); + } + } +} 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..989de21e8ef --- /dev/null +++ b/src/Tgstation.Server.Host/Security/TgsRestAuthorizeAttribute{TAuthority}.cs @@ -0,0 +1,44 @@ +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); + if (authorityMethod == null) + throw new InvalidOperationException($"Could not find method {methodName} on {authorityType}!"); + + var authorizeAttribute = authorityMethod.GetCustomAttribute(); + if (authorizeAttribute == null) + 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/Tgstation.Server.Host.csproj b/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj index 9d413440407..3192e8faa95 100644 --- a/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj +++ b/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj @@ -180,8 +180,8 @@ - + From ed2de22f798e665761c0969e87914a24d9b76342 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Tue, 10 Sep 2024 23:12:39 -0400 Subject: [PATCH 002/107] More GraphQL development - Setup query structure for the potential for `RemoteGateway`s. - Add new `ErrorCode` indicating they are not implemented. - Rename `ServerInformationBase` to `GatewayInformationBase`. - Fix `TgsRestAuthorizeAttribute` lacking an `IAuthorizationFilter`. --- build/Version.props | 6 +-- src/Tgstation.Server.Api/Models/ErrorCode.cs | 8 +++- ...erInformation.cs => GatewayInformation.cs} | 2 +- ...ationBase.cs => GatewayInformationBase.cs} | 2 +- .../Models/Internal/SwarmServer.cs | 9 +--- .../Models/Internal/SwarmServerInformation.cs | 11 +---- .../Response/ServerInformationResponse.cs | 2 +- .../GQL/Queries/ServerInformation.graphql | 38 +++++++++++++++ .../Queries/ServerInformationQuery.graphql | 34 -------------- .../Configuration/GeneralConfiguration.cs | 10 ++-- src/Tgstation.Server.Host/Core/Application.cs | 3 ++ .../GraphQL/ErrorMessageException.cs | 9 ++++ src/Tgstation.Server.Host/GraphQL/Query.cs | 8 ++-- .../GraphQL/Types/IGateway.cs | 31 ++++++++++++ .../Types/{LocalServer.cs => LocalGateway.cs} | 19 +++----- .../GraphQL/Types/Node.cs | 47 +++++++++++++++++++ .../GraphQL/Types/NodeInformation.cs | 47 +++++++++++++++++++ .../GraphQL/Types/RemoteGateway.cs | 34 ++++++++++++++ .../Types/{ServerSwarm.cs => Swarm.cs} | 41 +++++++++++----- .../GraphQL/Types/SwarmMetadata.cs | 2 +- .../Security/TgsAuthorizeAttribute.cs | 45 +++++++++++------- .../TgsRestAuthorizeAttribute{TAuthority}.cs | 7 ++- .../Live/TestLiveServer.cs | 28 +++++------ 23 files changed, 316 insertions(+), 127 deletions(-) rename src/Tgstation.Server.Api/Models/Internal/{LocalServerInformation.cs => GatewayInformation.cs} (89%) rename src/Tgstation.Server.Api/Models/Internal/{ServerInformationBase.cs => GatewayInformationBase.cs} (95%) create mode 100644 src/Tgstation.Server.Client.GraphQL/GQL/Queries/ServerInformation.graphql delete mode 100644 src/Tgstation.Server.Client.GraphQL/GQL/Queries/ServerInformationQuery.graphql create mode 100644 src/Tgstation.Server.Host/GraphQL/Types/IGateway.cs rename src/Tgstation.Server.Host/GraphQL/Types/{LocalServer.cs => LocalGateway.cs} (62%) create mode 100644 src/Tgstation.Server.Host/GraphQL/Types/Node.cs create mode 100644 src/Tgstation.Server.Host/GraphQL/Types/NodeInformation.cs create mode 100644 src/Tgstation.Server.Host/GraphQL/Types/RemoteGateway.cs rename src/Tgstation.Server.Host/GraphQL/Types/{ServerSwarm.cs => Swarm.cs} (53%) diff --git a/build/Version.props b/build/Version.props index 26c9fcf515a..d0a850c6de3 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/src/Tgstation.Server.Api/Models/ErrorCode.cs b/src/Tgstation.Server.Api/Models/ErrorCode.cs index 052941d9222..7fe2f24992f 100644 --- a/src/Tgstation.Server.Api/Models/ErrorCode.cs +++ b/src/Tgstation.Server.Api/Models/ErrorCode.cs @@ -461,7 +461,7 @@ public enum ErrorCode : uint RepoTestMergeConflict, /// - /// Attempted to create an instance outside of the . + /// Attempted to create an instance outside of the . /// [Description("The new instance's path is not under a white-listed path.")] InstanceNotAtWhitelistedPath, @@ -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/GatewayInformation.cs similarity index 89% rename from src/Tgstation.Server.Api/Models/Internal/LocalServerInformation.cs rename to src/Tgstation.Server.Api/Models/Internal/GatewayInformation.cs index ae8d5ca34e8..48939aba6fd 100644 --- a/src/Tgstation.Server.Api/Models/Internal/LocalServerInformation.cs +++ b/src/Tgstation.Server.Api/Models/Internal/GatewayInformation.cs @@ -5,7 +5,7 @@ namespace Tgstation.Server.Api.Models.Internal /// /// Information about the local tgstation-server. /// - public class LocalServerInformation : ServerInformationBase + public class GatewayInformation : GatewayInformationBase { /// /// If the server is running on a windows operating system. diff --git a/src/Tgstation.Server.Api/Models/Internal/ServerInformationBase.cs b/src/Tgstation.Server.Api/Models/Internal/GatewayInformationBase.cs similarity index 95% rename from src/Tgstation.Server.Api/Models/Internal/ServerInformationBase.cs rename to src/Tgstation.Server.Api/Models/Internal/GatewayInformationBase.cs index 30d379a3230..f1f53b26bb5 100644 --- a/src/Tgstation.Server.Api/Models/Internal/ServerInformationBase.cs +++ b/src/Tgstation.Server.Api/Models/Internal/GatewayInformationBase.cs @@ -5,7 +5,7 @@ namespace Tgstation.Server.Api.Models.Internal /// /// Base class for . /// - public abstract class ServerInformationBase + public abstract class GatewayInformationBase { /// /// Minimum length of database user passwords. 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..a912fa127e5 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.GatewayInformation { /// /// The version of the host. diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Queries/ServerInformation.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Queries/ServerInformation.graphql new file mode 100644 index 00000000000..5e04dc4a667 --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Queries/ServerInformation.graphql @@ -0,0 +1,38 @@ +query ServerInformation { + swarm { + metadata { + apiVersion + dmApiVersion + updateInProgress + version + } + currentNode { + gateway { + information { + instanceLimit + minimumPasswordLength + userGroupLimit + userLimit + validInstancePaths + windowsHost + oAuthProviderInfos { + value { + clientId + redirectUri + serverUrl + } + key + } + } + } + } + nodes { + info { + address + controller + identifier + publicAddress + } + } + } +} 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.Host/Configuration/GeneralConfiguration.cs b/src/Tgstation.Server.Host/Configuration/GeneralConfiguration.cs index 1790316e370..3a5ccd8ca2f 100644 --- a/src/Tgstation.Server.Host/Configuration/GeneralConfiguration.cs +++ b/src/Tgstation.Server.Host/Configuration/GeneralConfiguration.cs @@ -16,7 +16,7 @@ namespace Tgstation.Server.Host.Configuration /// /// General configuration options. /// - public sealed class GeneralConfiguration : ServerInformationBase + public sealed class GeneralConfiguration : GatewayInformationBase { /// /// The key for the the resides in. @@ -29,22 +29,22 @@ public sealed class GeneralConfiguration : ServerInformationBase public const ushort DefaultApiPort = 5000; /// - /// The default value for . + /// The default value for . /// const uint DefaultMinimumPasswordLength = 15; /// - /// The default value for . + /// The default value for . /// const uint DefaultInstanceLimit = 10; /// - /// The default value for . + /// The default value for . /// const uint DefaultUserLimit = 100; /// - /// The default value for . + /// The default value for . /// const uint DefaultUserGroupLimit = 25; diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs index 33e10e3d0d5..2ef310428e7 100644 --- a/src/Tgstation.Server.Host/Core/Application.cs +++ b/src/Tgstation.Server.Host/Core/Application.cs @@ -56,6 +56,7 @@ using Tgstation.Server.Host.Database; using Tgstation.Server.Host.Extensions; using Tgstation.Server.Host.GraphQL; +using Tgstation.Server.Host.GraphQL.Types; using Tgstation.Server.Host.GraphQL.Types.Scalars; using Tgstation.Server.Host.IO; using Tgstation.Server.Host.Jobs; @@ -297,6 +298,8 @@ void ConfigureNewtonsoftJsonSerializerSettingsForApi(JsonSerializerSettings sett .AddAuthorization() .AddMutationConventions() .AddErrorFilter() + .AddType() + .AddType() .AddType() .BindRuntimeType() .AddQueryType() diff --git a/src/Tgstation.Server.Host/GraphQL/ErrorMessageException.cs b/src/Tgstation.Server.Host/GraphQL/ErrorMessageException.cs index 20c8550b6ba..38315ca79e3 100644 --- a/src/Tgstation.Server.Host/GraphQL/ErrorMessageException.cs +++ b/src/Tgstation.Server.Host/GraphQL/ErrorMessageException.cs @@ -33,5 +33,14 @@ public ErrorMessageException(ErrorMessageResponse errorMessage, string fallbackM 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/Query.cs b/src/Tgstation.Server.Host/GraphQL/Query.cs index 3b57958392b..1b5e581eadb 100644 --- a/src/Tgstation.Server.Host/GraphQL/Query.cs +++ b/src/Tgstation.Server.Host/GraphQL/Query.cs @@ -1,7 +1,5 @@ #pragma warning disable CA1724 -using Tgstation.Server.Host.GraphQL.Types; - 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.Swarm Swarm() => new(); } } diff --git a/src/Tgstation.Server.Host/GraphQL/Types/IGateway.cs b/src/Tgstation.Server.Host/GraphQL/Types/IGateway.cs new file mode 100644 index 00000000000..45b83f87c7e --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Types/IGateway.cs @@ -0,0 +1,31 @@ +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 +{ + /// + /// Management interface for the parent . + /// + public interface IGateway + { + /// + /// Gets . + /// + /// The to use. + /// The to use. + /// The containing the to use. + /// A new . + [AllowAnonymous] + GatewayInformation Information( + [Service] IOAuthProviders oAuthProviders, + [Service] IPlatformIdentifier platformIdentifier, + [Service] IOptionsSnapshot generalConfigurationOptions); + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Types/LocalServer.cs b/src/Tgstation.Server.Host/GraphQL/Types/LocalGateway.cs similarity index 62% rename from src/Tgstation.Server.Host/GraphQL/Types/LocalServer.cs rename to src/Tgstation.Server.Host/GraphQL/Types/LocalGateway.cs index fab7784385f..5892a49f015 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/LocalServer.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/LocalGateway.cs @@ -1,9 +1,9 @@ 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; @@ -12,19 +12,12 @@ namespace Tgstation.Server.Host.GraphQL.Types { /// - /// Represents the local tgstation-server. + /// for the this query is executing on. /// - public sealed class LocalServer + public sealed class LocalGateway : IGateway { - /// - /// Gets . - /// - /// The to use. - /// The to use. - /// The containing the to use. - /// A new . - [AllowAnonymous] - public LocalServerInformation Information( + /// + public GatewayInformation Information( [Service] IOAuthProviders oAuthProviders, [Service] IPlatformIdentifier platformIdentifier, [Service] IOptionsSnapshot generalConfigurationOptions) @@ -34,7 +27,7 @@ public LocalServerInformation Information( ArgumentNullException.ThrowIfNull(generalConfigurationOptions); var generalConfiguration = generalConfigurationOptions.Value; - return new LocalServerInformation + return new GatewayInformation { MinimumPasswordLength = generalConfiguration.MinimumPasswordLength, InstanceLimit = generalConfiguration.InstanceLimit, diff --git a/src/Tgstation.Server.Host/GraphQL/Types/Node.cs b/src/Tgstation.Server.Host/GraphQL/Types/Node.cs new file mode 100644 index 00000000000..dac5cd5a843 --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Types/Node.cs @@ -0,0 +1,47 @@ +using System; + +using HotChocolate; + +using Microsoft.Extensions.Options; + +using Tgstation.Server.Host.Configuration; + +namespace Tgstation.Server.Host.GraphQL.Types +{ + /// + /// Represents a node server in a swarm. + /// + public sealed class Node + { + /// + /// Gets the . + /// + public NodeInformation? Info { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The value of . + public Node(NodeInformation? info) + { + Info = info; + } + + /// + /// Gets the 's . + /// + /// The containing the current . + /// A new . + /// The 's . + public IGateway? Gateway([Service] IOptionsSnapshot swarmConfigurationOptions) + { + ArgumentNullException.ThrowIfNull(swarmConfigurationOptions); + + bool local = Info == null || Info.Identifier == swarmConfigurationOptions.Value.Identifier; + if (local) + return new LocalGateway(); + + return new RemoteGateway(); + } + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Types/NodeInformation.cs b/src/Tgstation.Server.Host/GraphQL/Types/NodeInformation.cs new file mode 100644 index 00000000000..0661bf92c91 --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Types/NodeInformation.cs @@ -0,0 +1,47 @@ +using System; + +using HotChocolate.Types.Relay; + +namespace Tgstation.Server.Host.GraphQL.Types +{ + /// + /// Represent a server in the TGS server swarm. + /// + public sealed class NodeInformation + { + /// + /// The swarm server ID. + /// + [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; } + + /// + /// Initializes a new instance of the class. + /// + /// The to build from. + public NodeInformation(Api.Models.Internal.SwarmServerInformation swarmServerInformation) + { + ArgumentNullException.ThrowIfNull(swarmServerInformation); + + Identifier = swarmServerInformation.Identifier!; + Address = swarmServerInformation.Address!; + PublicAddress = swarmServerInformation.PublicAddress; + Controller = swarmServerInformation.Controller; + } + } +} 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..ffe7731895d --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Types/RemoteGateway.cs @@ -0,0 +1,34 @@ +using System; + +using HotChocolate; + +using Microsoft.Extensions.Options; + +using Tgstation.Server.Api.Models; +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 +{ + /// + /// for accessing remote s. + /// + /// This is currently unimplemented. + public sealed class RemoteGateway : IGateway + { + /// + public GatewayInformation Information( + [Service] IOAuthProviders oAuthProviders, + [Service] IPlatformIdentifier platformIdentifier, + [Service] IOptionsSnapshot generalConfigurationOptions) + { + ArgumentNullException.ThrowIfNull(oAuthProviders); + ArgumentNullException.ThrowIfNull(platformIdentifier); + ArgumentNullException.ThrowIfNull(generalConfigurationOptions); + + throw new ErrorMessageException(ErrorCode.RemoteGatewaysNotImplemented); + } + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Types/ServerSwarm.cs b/src/Tgstation.Server.Host/GraphQL/Types/Swarm.cs similarity index 53% rename from src/Tgstation.Server.Host/GraphQL/Types/ServerSwarm.cs rename to src/Tgstation.Server.Host/GraphQL/Types/Swarm.cs index f2ec6c61513..896d5f538eb 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/ServerSwarm.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/Swarm.cs @@ -1,9 +1,12 @@ 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.Swarm; using Tgstation.Server.Host.System; @@ -13,7 +16,7 @@ namespace Tgstation.Server.Host.GraphQL.Types /// /// Represents a tgstation-server swarm. /// - public sealed class ServerSwarm + public sealed class Swarm { /// /// Gets the for the swarm. @@ -30,12 +33,6 @@ public SwarmMetadata Metadata( return new SwarmMetadata(assemblyInformationProvider, serverControl.UpdateInProgress); } - /// - /// Gets the local . - /// - /// A new . - public LocalServer LocalServer() => new(); - /// /// Gets the swarm's . /// @@ -43,15 +40,35 @@ public SwarmMetadata Metadata( public Users Users() => new(); /// - /// Gets the for all servers in a swarm. + /// Gets the connected server. + /// + /// The to use. + /// The containing the current . + /// A new . + public Node CurrentNode( + [Service] ISwarmService swarmService, + [Service] IOptionsSnapshot swarmConfigurationOptions) + { + ArgumentNullException.ThrowIfNull(swarmService); + ArgumentNullException.ThrowIfNull(swarmConfigurationOptions); + + var nodeInfos = Nodes(swarmService); + if (nodeInfos != null) + return nodeInfos.First(x => x.Info!.Identifier == swarmConfigurationOptions.Value.Identifier); + + return new Node(null); + } + + /// + /// 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 server is part of a swarm, otherwise. + public List? Nodes( [Service] ISwarmService swarmService) { ArgumentNullException.ThrowIfNull(swarmService); - return swarmService.GetSwarmServers(); + return swarmService.GetSwarmServers()?.Select(x => new Node(new NodeInformation(x))).ToList(); } } } diff --git a/src/Tgstation.Server.Host/GraphQL/Types/SwarmMetadata.cs b/src/Tgstation.Server.Host/GraphQL/Types/SwarmMetadata.cs index 19fe192b238..f0ee0bbdf05 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/SwarmMetadata.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/SwarmMetadata.cs @@ -7,7 +7,7 @@ namespace Tgstation.Server.Host.GraphQL.Types { /// - /// Represents information that is constant across all servers in a . + /// Represents information that is constant across all servers in a . /// public sealed class SwarmMetadata { diff --git a/src/Tgstation.Server.Host/Security/TgsAuthorizeAttribute.cs b/src/Tgstation.Server.Host/Security/TgsAuthorizeAttribute.cs index 0dda24b4723..6619f3f1219 100644 --- a/src/Tgstation.Server.Host/Security/TgsAuthorizeAttribute.cs +++ b/src/Tgstation.Server.Host/Security/TgsAuthorizeAttribute.cs @@ -23,6 +23,32 @@ sealed class TgsAuthorizeAttribute : AuthorizeAttribute, IAuthorizationFilter /// public RightsType? RightsType { get; } + /// + /// Implementation of . + /// + /// The . + public static void OnAuthorizationHelper(AuthorizationFilterContext context) + { + ArgumentNullException.ThrowIfNull(context); + + 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(); + } + /// /// Initializes a new instance of the class. /// @@ -122,23 +148,6 @@ public TgsAuthorizeAttribute(InstancePermissionSetRights requiredRights) /// public void OnAuthorization(AuthorizationFilterContext context) - { - 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(); - } + => OnAuthorizationHelper(context); } } diff --git a/src/Tgstation.Server.Host/Security/TgsRestAuthorizeAttribute{TAuthority}.cs b/src/Tgstation.Server.Host/Security/TgsRestAuthorizeAttribute{TAuthority}.cs index 989de21e8ef..7a3ac6cdf55 100644 --- a/src/Tgstation.Server.Host/Security/TgsRestAuthorizeAttribute{TAuthority}.cs +++ b/src/Tgstation.Server.Host/Security/TgsRestAuthorizeAttribute{TAuthority}.cs @@ -2,6 +2,7 @@ using System.Reflection; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc.Filters; using Tgstation.Server.Host.Authority.Core; @@ -12,7 +13,7 @@ namespace Tgstation.Server.Host.Security /// /// The being wrapped. [AttributeUsage(AttributeTargets.Method, AllowMultiple = true, Inherited = true)] - public sealed class TgsRestAuthorizeAttribute : AuthorizeAttribute + public sealed class TgsRestAuthorizeAttribute : AuthorizeAttribute, IAuthorizationFilter where TAuthority : IAuthority { /// @@ -40,5 +41,9 @@ public TgsRestAuthorizeAttribute(string methodName) MethodName = methodName; Roles = authorizeAttribute.Roles; } + + /// + public void OnAuthorization(AuthorizationFilterContext context) + => TgsAuthorizeAttribute.OnAuthorizationHelper(context); } } diff --git a/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs b/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs index 9dcb363e638..f101d2cd81d 100644 --- a/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs +++ b/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs @@ -1380,27 +1380,27 @@ async Task TestTgsInternal(bool openDreamOnly, CancellationToken hardCancellatio await multiClient.ExecuteReadOnlyConfirmEquivalence( restClient => restClient.ServerInformation(cancellationToken), - async gqlClient => (await gqlClient.ServerInformationQuery.ExecuteAsync(cancellationToken)).Data, + async gqlClient => (await gqlClient.ServerInformation.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 + && restServerInfo.InstanceLimit == gqlServerInfo.Swarm.CurrentNode.Gateway.Information.InstanceLimit + && restServerInfo.UserGroupLimit == gqlServerInfo.Swarm.CurrentNode.Gateway.Information.UserGroupLimit + && restServerInfo.ValidInstancePaths.SequenceEqual(gqlServerInfo.Swarm.CurrentNode.Gateway.Information.ValidInstancePaths) + && restServerInfo.UserLimit == gqlServerInfo.Swarm.CurrentNode.Gateway.Information.UserLimit + && restServerInfo.MinimumPasswordLength == gqlServerInfo.Swarm.CurrentNode.Gateway.Information.MinimumPasswordLength + && ((object)restServerInfo.SwarmServers == gqlServerInfo.Swarm.Nodes + || restServerInfo.SwarmServers.SequenceEqual(gqlServerInfo.Swarm.Nodes.Select(x => new SwarmServerResponse(new Api.Models.Internal.SwarmServerInformation { - Address = x.Address, - PublicAddress = x.PublicAddress, - Controller = x.Controller, - Identifier = x.Identifier, + Address = x.Info.Address, + PublicAddress = x.Info.PublicAddress, + Controller = x.Info.Controller, + Identifier = x.Info.Identifier, })))) - && (restServerInfo.OAuthProviderInfos == gqlServerInfo.Swarm.LocalServer.Information.OAuthProviderInfos + && (restServerInfo.OAuthProviderInfos == gqlServerInfo.Swarm.CurrentNode.Gateway.Information.OAuthProviderInfos || restServerInfo.OAuthProviderInfos.All(kvp => { - var info = gqlServerInfo.Swarm.LocalServer.Information.OAuthProviderInfos.FirstOrDefault(x => (int)x.Key == (int)kvp.Key); + 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 From 74fa26fe023f65ad160c82b74de4d82100afa486 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Tue, 10 Sep 2024 23:34:10 -0400 Subject: [PATCH 003/107] Implement baby's first pagination on `User`s --- .../Authority/IUserAuthority.cs | 7 ++- .../Authority/UserAuthority.cs | 43 +++++++++++++------ .../GraphQL/Types/Users.cs | 21 ++++++++- 3 files changed, 52 insertions(+), 19 deletions(-) diff --git a/src/Tgstation.Server.Host/Authority/IUserAuthority.cs b/src/Tgstation.Server.Host/Authority/IUserAuthority.cs index 29eebfab5b3..0279483f5cc 100644 --- a/src/Tgstation.Server.Host/Authority/IUserAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/IUserAuthority.cs @@ -27,7 +27,7 @@ public interface IUserAuthority : IAuthority /// Gets the with a given . /// /// The of the . - /// If relevant entities should be loaded. + /// If related entities should be loaded. /// The for the operation. /// A resulting in a . [TgsAuthorize(AdministrationRights.ReadUsers)] @@ -36,10 +36,9 @@ public interface IUserAuthority : IAuthority /// /// Gets all registered s. /// - /// If relevant entities should be loaded. - /// The for the operation. + /// If related entities should be loaded. /// A resulting in a . [TgsAuthorize(AdministrationRights.ReadUsers)] - public ValueTask>> List(bool includeJoins, CancellationToken cancellationToken); + public ValueTask>> List(bool includeJoins); } } diff --git a/src/Tgstation.Server.Host/Authority/UserAuthority.cs b/src/Tgstation.Server.Host/Authority/UserAuthority.cs index a1d09a2afd2..2d113a7abb1 100644 --- a/src/Tgstation.Server.Host/Authority/UserAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/UserAuthority.cs @@ -49,18 +49,7 @@ public ValueTask> Read(CancellationToken cancellationTok /// public async ValueTask> GetId(long id, bool includeJoins, CancellationToken cancellationToken) { - var queryable = databaseContext - .Users - .AsQueryable(); - - if (includeJoins) - queryable = queryable - .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); + var queryable = ListCore(includeJoins); var user = await queryable.FirstOrDefaultAsync( dbModel => dbModel.Id == id, @@ -75,9 +64,35 @@ public async ValueTask> GetId(long id, bool includeJoins } /// - public ValueTask>> List(bool includeJoins, CancellationToken cancellationToken) + public ValueTask>> List(bool includeJoins) + { + var systemUserCanonicalName = User.CanonicalizeName(User.TgsSystemUserName); + return ValueTask.FromResult( + new AuthorityResponse>( + ListCore(includeJoins) + .Where(x => x.CanonicalName != systemUserCanonicalName))); + } + + /// + /// Generates an for listing s. + /// + /// If related entities should be loaded. + /// A new of s. + private IQueryable ListCore(bool includeJoins) { - throw new NotImplementedException(); + var queryable = databaseContext + .Users + .AsQueryable(); + + 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; } } } diff --git a/src/Tgstation.Server.Host/GraphQL/Types/Users.cs b/src/Tgstation.Server.Host/GraphQL/Types/Users.cs index 30d683201c7..9ff2e2b256d 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/Users.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/Users.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -8,6 +9,7 @@ using Tgstation.Server.Host.Authority; using Tgstation.Server.Host.Authority.Core; +using Tgstation.Server.Host.Models; using Tgstation.Server.Host.Security; #pragma warning disable CA1724 // conflict with GitLabApiClient.Models.Users. They can fuck off @@ -40,7 +42,7 @@ public ValueTask Current( /// The of the . /// The . /// The for the operation. - /// A resulting in a . + /// The represented by , if any. [Error(typeof(ErrorMessageException))] [TgsGraphQLAuthorize(nameof(IUserAuthority.GetId))] public async ValueTask ById( @@ -51,5 +53,22 @@ public ValueTask Current( ArgumentNullException.ThrowIfNull(userAuthority); return await userAuthority.InvokeTransformable(authority => authority.GetId(id, false, cancellationToken)); } + + /// + /// Lists all registered s. + /// + /// The . + /// A list of all registered s. + [UsePaging(IncludeTotalCount = true)] + [TgsGraphQLAuthorize(nameof(IUserAuthority.List))] + public async ValueTask> List( + [Service] IGraphQLAuthorityInvoker userAuthority) + { + ArgumentNullException.ThrowIfNull(userAuthority); + var dtoQueryable = await userAuthority.Invoke, IQueryable>(authority => authority.List(false)); + return dtoQueryable + .Cast>() + .Select(dto => dto.ToApi()); + } } } From b2fa1c035ff7365eabb71a43e294568f6218b905 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Tue, 10 Sep 2024 23:34:27 -0400 Subject: [PATCH 004/107] Fix VS complaining about a duplicate type name --- src/Tgstation.Server.Host/GraphQL/Query.cs | 6 +++--- .../GraphQL/Types/{Swarm.cs => ServerSwarm.cs} | 2 +- src/Tgstation.Server.Host/GraphQL/Types/SwarmMetadata.cs | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) rename src/Tgstation.Server.Host/GraphQL/Types/{Swarm.cs => ServerSwarm.cs} (98%) diff --git a/src/Tgstation.Server.Host/GraphQL/Query.cs b/src/Tgstation.Server.Host/GraphQL/Query.cs index 1b5e581eadb..ddfa8e84775 100644 --- a/src/Tgstation.Server.Host/GraphQL/Query.cs +++ b/src/Tgstation.Server.Host/GraphQL/Query.cs @@ -8,9 +8,9 @@ namespace Tgstation.Server.Host.GraphQL public sealed class Query { /// - /// Gets the . + /// Gets the . /// - /// A new . - public Types.Swarm Swarm() => new(); + /// A new . + public Types.ServerSwarm Swarm() => new(); } } diff --git a/src/Tgstation.Server.Host/GraphQL/Types/Swarm.cs b/src/Tgstation.Server.Host/GraphQL/Types/ServerSwarm.cs similarity index 98% rename from src/Tgstation.Server.Host/GraphQL/Types/Swarm.cs rename to src/Tgstation.Server.Host/GraphQL/Types/ServerSwarm.cs index 896d5f538eb..e43d9c5b2ed 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/Swarm.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/ServerSwarm.cs @@ -16,7 +16,7 @@ namespace Tgstation.Server.Host.GraphQL.Types /// /// Represents a tgstation-server swarm. /// - public sealed class Swarm + public sealed class ServerSwarm { /// /// Gets the for the swarm. diff --git a/src/Tgstation.Server.Host/GraphQL/Types/SwarmMetadata.cs b/src/Tgstation.Server.Host/GraphQL/Types/SwarmMetadata.cs index f0ee0bbdf05..19fe192b238 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/SwarmMetadata.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/SwarmMetadata.cs @@ -7,7 +7,7 @@ namespace Tgstation.Server.Host.GraphQL.Types { /// - /// Represents information that is constant across all servers in a . + /// Represents information that is constant across all servers in a . /// public sealed class SwarmMetadata { From 99e6220de64598f9061d04aa4ec457fc4b22dd1c Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Tue, 10 Sep 2024 23:59:56 -0400 Subject: [PATCH 005/107] Fix issues with Swagger generation --- .../Controllers/ApiController.cs | 13 ------------- .../Controllers/ConfigurationController.cs | 3 ++- .../Controllers/InstanceController.cs | 2 +- .../InstancePermissionSetController.cs | 2 +- .../Controllers/RepositoryController.cs | 4 ++-- .../Controllers/UserController.cs | 2 +- .../Controllers/UserGroupController.cs | 2 +- .../Extensions/ControllerBaseExtensions.cs | 15 +++++++++++++++ 8 files changed, 23 insertions(+), 20 deletions(-) diff --git a/src/Tgstation.Server.Host/Controllers/ApiController.cs b/src/Tgstation.Server.Host/Controllers/ApiController.cs index 60eb6b9e54d..709dfa70872 100644 --- a/src/Tgstation.Server.Host/Controllers/ApiController.cs +++ b/src/Tgstation.Server.Host/Controllers/ApiController.cs @@ -104,19 +104,6 @@ protected ApiController( this.requireHeaders = requireHeaders; } - /// - /// Generic 201 response with a given . - /// - /// The accompanying API payload. - /// A with the given . - public ObjectResult Created(object payload) => StatusCode((int)HttpStatusCode.Created, payload); - - /// - /// Generic 401 response. - /// - /// An with . - public new ObjectResult Unauthorized() => this.StatusCode(HttpStatusCode.Unauthorized, null); - /// #pragma warning disable CA1506 // TODO: Decomplexify protected override async ValueTask HookExecuteAction(Func executeAction, CancellationToken cancellationToken) 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/UserController.cs b/src/Tgstation.Server.Host/Controllers/UserController.cs index 35c5021e5e8..507eb1978c6 100644 --- a/src/Tgstation.Server.Host/Controllers/UserController.cs +++ b/src/Tgstation.Server.Host/Controllers/UserController.cs @@ -169,7 +169,7 @@ public async ValueTask Create([FromBody] UserCreateRequest model, Logger.LogInformation("Created new user {name} ({id})", dbUser.Name, dbUser.Id); - return Created(dbUser.ToApi()); + return this.Created(dbUser.ToApi()); } #pragma warning restore CA1502, CA1506 diff --git a/src/Tgstation.Server.Host/Controllers/UserGroupController.cs b/src/Tgstation.Server.Host/Controllers/UserGroupController.cs index 838b7a1bcf2..fce1c12e1d5 100644 --- a/src/Tgstation.Server.Host/Controllers/UserGroupController.cs +++ b/src/Tgstation.Server.Host/Controllers/UserGroupController.cs @@ -98,7 +98,7 @@ public async ValueTask Create([FromBody] UserGroupCreateRequest m await DatabaseContext.Save(cancellationToken); Logger.LogInformation("Created new user group {groupName} ({groupId})", dbGroup.Name, dbGroup.Id); - return Created(dbGroup.ToApi(true)); + return this.Created(dbGroup.ToApi(true)); } /// 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. /// From a5b4afbde6f6c4368e3e5f690ce0b99fe5835b94 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Tue, 10 Sep 2024 23:59:57 -0400 Subject: [PATCH 006/107] Add a basic GQL errored login test --- .../GQL/Mutations/Login.graphql | 12 +++++ .../Live/RawRequestTests.cs | 45 ++++++++++++++----- .../Live/TestLiveServer.cs | 14 +++--- 3 files changed, 53 insertions(+), 18 deletions(-) create mode 100644 src/Tgstation.Server.Client.GraphQL/GQL/Mutations/Login.graphql 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..1ed6c053eea --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/Login.graphql @@ -0,0 +1,12 @@ +mutation Login { + login { + string + errors { + ... on ErrorMessageError { + message + errorCode + additionalData + } + } + } +} diff --git a/tests/Tgstation.Server.Tests/Live/RawRequestTests.cs b/tests/Tgstation.Server.Tests/Live/RawRequestTests.cs index 5083ff6956c..d8e6dbb890e 100644 --- a/tests/Tgstation.Server.Tests/Live/RawRequestTests.cs +++ b/tests/Tgstation.Server.Tests/Live/RawRequestTests.cs @@ -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"))) @@ -457,9 +460,29 @@ 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); + IOperationResult result = null; + await gqlClient.RunQuery(async client => + { + result = await client.Login.ExecuteAsync(cancellationToken); + }); + + Assert.IsNull(result.Data.Login); + Assert.AreEqual(1, result.Data.Login.Errors.Count); + var castResult = result.Data.Login.Errors.First() is ILogin_Login_Errors_ErrorMessageError loginError; + Assert.IsTrue(castResult); + loginError = (ILogin_Login_Errors_ErrorMessageError)result.Data.Login.Errors.First(); + 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 f101d2cd81d..c0063a1a1eb 100644 --- a/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs +++ b/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs @@ -327,7 +327,7 @@ async ValueTask TestWithoutAndWithPermission(Func(content); - Assert.AreEqual(ErrorCode.OAuthProviderDisabled, message.ErrorCode); + Assert.AreEqual(Api.Models.ErrorCode.OAuthProviderDisabled, message.ErrorCode); } //attempt to update to stable @@ -457,7 +457,7 @@ await ApiAssert.ThrowsException( }, null, cancellationToken), - ErrorCode.ResourceNotPresent); + Api.Models.ErrorCode.ResourceNotPresent); } finally { @@ -698,8 +698,8 @@ await Task.WhenAny( Assert.AreEqual(controllerInstance.Id, controllerInstanceList[0].Id); Assert.IsNotNull(await controllerClient.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.Instances.GetId(node2Instance, cancellationToken), Api.Models.ErrorCode.ResourceNotPresent); + await ApiAssert.ThrowsException(() => node1Client.Instances.GetId(controllerInstance, cancellationToken), Api.Models.ErrorCode.ResourceNotPresent); // test update await node1Client.Administration.Update( @@ -749,7 +749,7 @@ await ApiAssert.ThrowsException(() = NewVersion = TestUpdateVersion }, null, - cancellationToken), ErrorCode.SwarmIntegrityCheckFailed); + cancellationToken), Api.Models.ErrorCode.SwarmIntegrityCheckFailed); // regression: test updating also works from the controller serverTask = Task.WhenAll( @@ -986,7 +986,7 @@ await ApiAssert.ThrowsException( }, null, cancellationToken), - ErrorCode.SwarmIntegrityCheckFailed); + Api.Models.ErrorCode.SwarmIntegrityCheckFailed); node2Task = node2.Run(cancellationToken).AsTask(); await using var node2Client2 = await CreateAdminClient(node2.ApiUrl, cancellationToken); @@ -1517,7 +1517,7 @@ async Task ODCompatTests() server.OpenDreamUrl, cancellationToken).AsTask()); - Assert.AreEqual(ErrorCode.OpenDreamTooOld, ex.ErrorCode); + Assert.AreEqual(Api.Models.ErrorCode.OpenDreamTooOld, ex.ErrorCode); await instanceTest .RunCompatTests( From edd41aab6221bcd86f2e42ca1d94c2b87704d243 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Wed, 11 Sep 2024 16:17:13 -0400 Subject: [PATCH 007/107] Apply code suggestion --- src/Tgstation.Server.Api/Models/UserName.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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, From 84f5ee7e7fa44bb3114e1ea07b63f1362e9ef430 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Wed, 11 Sep 2024 16:21:08 -0400 Subject: [PATCH 008/107] Properly setup GraphQL `IUserName` for TGS user --- .../Authority/IUserAuthority.cs | 3 +- .../Authority/UserAuthority.cs | 4 +-- .../Controllers/UserController.cs | 2 +- src/Tgstation.Server.Host/Core/Application.cs | 1 + .../GraphQL/{Types => Interfaces}/IGateway.cs | 4 +-- .../GraphQL/Interfaces/IUserName.cs | 21 ++++++++++++++ .../GraphQL/Types/LocalGateway.cs | 1 + .../GraphQL/Types/NamedEntity.cs | 12 ++++++++ .../GraphQL/Types/Node.cs | 1 + .../GraphQL/Types/RemoteGateway.cs | 1 + .../GraphQL/Types/User.cs | 29 ++++++++++++++++--- .../GraphQL/Types/UserName.cs | 19 ++++++++++++ .../GraphQL/Types/Users.cs | 2 +- 13 files changed, 89 insertions(+), 11 deletions(-) rename src/Tgstation.Server.Host/GraphQL/{Types => Interfaces}/IGateway.cs (92%) create mode 100644 src/Tgstation.Server.Host/GraphQL/Interfaces/IUserName.cs create mode 100644 src/Tgstation.Server.Host/GraphQL/Types/UserName.cs diff --git a/src/Tgstation.Server.Host/Authority/IUserAuthority.cs b/src/Tgstation.Server.Host/Authority/IUserAuthority.cs index 0279483f5cc..71f807371a4 100644 --- a/src/Tgstation.Server.Host/Authority/IUserAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/IUserAuthority.cs @@ -28,10 +28,11 @@ public interface IUserAuthority : IAuthority /// /// 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)] - public ValueTask> GetId(long id, bool includeJoins, CancellationToken cancellationToken); + public ValueTask> GetId(long id, bool includeJoins, bool allowSystemUser, CancellationToken cancellationToken); /// /// Gets all registered s. diff --git a/src/Tgstation.Server.Host/Authority/UserAuthority.cs b/src/Tgstation.Server.Host/Authority/UserAuthority.cs index 2d113a7abb1..fe15593a1bc 100644 --- a/src/Tgstation.Server.Host/Authority/UserAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/UserAuthority.cs @@ -47,7 +47,7 @@ public ValueTask> Read(CancellationToken cancellationTok => ValueTask.FromResult(new AuthorityResponse(authenticationContext.User)); /// - public async ValueTask> GetId(long id, bool includeJoins, CancellationToken cancellationToken) + public async ValueTask> GetId(long id, bool includeJoins, bool allowSystemUser, CancellationToken cancellationToken) { var queryable = ListCore(includeJoins); @@ -57,7 +57,7 @@ public async ValueTask> GetId(long id, bool includeJoins if (user == default) return NotFound(); - if (user.CanonicalName == User.CanonicalizeName(User.TgsSystemUserName)) + if (!allowSystemUser && user.CanonicalName == User.CanonicalizeName(User.TgsSystemUserName)) return Forbid(); return new AuthorityResponse(user); diff --git a/src/Tgstation.Server.Host/Controllers/UserController.cs b/src/Tgstation.Server.Host/Controllers/UserController.cs index 507eb1978c6..e59df1f8896 100644 --- a/src/Tgstation.Server.Host/Controllers/UserController.cs +++ b/src/Tgstation.Server.Host/Controllers/UserController.cs @@ -414,7 +414,7 @@ public async ValueTask GetId(long id, CancellationToken cancellat return await userAuthority.InvokeTransformable( this, - authority => authority.GetId(id, true, cancellationToken)); + authority => authority.GetId(id, true, false, cancellationToken)); } /// diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs index 2ef310428e7..5da177e23e7 100644 --- a/src/Tgstation.Server.Host/Core/Application.cs +++ b/src/Tgstation.Server.Host/Core/Application.cs @@ -300,6 +300,7 @@ void ConfigureNewtonsoftJsonSerializerSettingsForApi(JsonSerializerSettings sett .AddErrorFilter() .AddType() .AddType() + .AddType() .AddType() .BindRuntimeType() .AddQueryType() diff --git a/src/Tgstation.Server.Host/GraphQL/Types/IGateway.cs b/src/Tgstation.Server.Host/GraphQL/Interfaces/IGateway.cs similarity index 92% rename from src/Tgstation.Server.Host/GraphQL/Types/IGateway.cs rename to src/Tgstation.Server.Host/GraphQL/Interfaces/IGateway.cs index 45b83f87c7e..89acbc10cc1 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/IGateway.cs +++ b/src/Tgstation.Server.Host/GraphQL/Interfaces/IGateway.cs @@ -2,13 +2,13 @@ using HotChocolate.Authorization; using Microsoft.Extensions.Options; - using Tgstation.Server.Api.Models.Internal; using Tgstation.Server.Host.Configuration; +using Tgstation.Server.Host.GraphQL.Types; using Tgstation.Server.Host.Security.OAuth; using Tgstation.Server.Host.System; -namespace Tgstation.Server.Host.GraphQL.Types +namespace Tgstation.Server.Host.GraphQL.Interfaces { /// /// Management interface for the parent . 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/Types/LocalGateway.cs b/src/Tgstation.Server.Host/GraphQL/Types/LocalGateway.cs index 5892a49f015..1774bc6596d 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/LocalGateway.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/LocalGateway.cs @@ -6,6 +6,7 @@ using Tgstation.Server.Api.Models.Internal; using Tgstation.Server.Host.Configuration; +using Tgstation.Server.Host.GraphQL.Interfaces; using Tgstation.Server.Host.Security.OAuth; using Tgstation.Server.Host.System; diff --git a/src/Tgstation.Server.Host/GraphQL/Types/NamedEntity.cs b/src/Tgstation.Server.Host/GraphQL/Types/NamedEntity.cs index e48c18ce14d..a52677b8345 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/NamedEntity.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/NamedEntity.cs @@ -1,5 +1,7 @@ using System; +using Tgstation.Server.Host.GraphQL.Interfaces; + namespace Tgstation.Server.Host.GraphQL.Types { /// @@ -12,6 +14,16 @@ public abstract class NamedEntity : Entity /// public string Name { get; } + /// + /// Initializes a new instance of the class. + /// + /// The to copy. + protected NamedEntity(NamedEntity copy) + : base(copy?.Id ?? throw new ArgumentNullException(nameof(copy))) + { + Name = copy.Name; + } + /// /// Initializes a new instance of the class. /// diff --git a/src/Tgstation.Server.Host/GraphQL/Types/Node.cs b/src/Tgstation.Server.Host/GraphQL/Types/Node.cs index dac5cd5a843..f42183950db 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/Node.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/Node.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Options; using Tgstation.Server.Host.Configuration; +using Tgstation.Server.Host.GraphQL.Interfaces; namespace Tgstation.Server.Host.GraphQL.Types { diff --git a/src/Tgstation.Server.Host/GraphQL/Types/RemoteGateway.cs b/src/Tgstation.Server.Host/GraphQL/Types/RemoteGateway.cs index ffe7731895d..0a008490572 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/RemoteGateway.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/RemoteGateway.cs @@ -7,6 +7,7 @@ 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.OAuth; using Tgstation.Server.Host.System; diff --git a/src/Tgstation.Server.Host/GraphQL/Types/User.cs b/src/Tgstation.Server.Host/GraphQL/Types/User.cs index f32c6ff2cd9..4ab07675df9 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/User.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/User.cs @@ -1,13 +1,20 @@ using System; using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; +using HotChocolate; + +using Tgstation.Server.Host.Authority; +using Tgstation.Server.Host.Authority.Core; +using Tgstation.Server.Host.GraphQL.Interfaces; + namespace Tgstation.Server.Host.GraphQL.Types { /// /// A user registered in the server. /// - public sealed class User : NamedEntity + public sealed class User : NamedEntity, IUserName { /// /// If the is enabled since users cannot be deleted. System users cannot be disabled. @@ -72,9 +79,23 @@ public User( /// /// The who created this . /// - /// A resulting in the who created this , if any. - public ValueTask CreatedBy() - => throw new NotImplementedException(); + /// 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. 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..f16379debc0 --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Types/UserName.cs @@ -0,0 +1,19 @@ +using Tgstation.Server.Host.GraphQL.Interfaces; + +namespace Tgstation.Server.Host.GraphQL.Types +{ + /// + /// A with limited fields. + /// + public sealed class UserName : NamedEntity, IUserName + { + /// + /// Initializes a new instance of the class. + /// + /// The to copy. + public UserName(NamedEntity copy) + : base(copy) + { + } + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Types/Users.cs b/src/Tgstation.Server.Host/GraphQL/Types/Users.cs index 9ff2e2b256d..754771e9172 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/Users.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/Users.cs @@ -51,7 +51,7 @@ public ValueTask Current( CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(userAuthority); - return await userAuthority.InvokeTransformable(authority => authority.GetId(id, false, cancellationToken)); + return await userAuthority.InvokeTransformable(authority => authority.GetId(id, false, false, cancellationToken)); } /// From ede12a619bb20267cda6ceb17de9f5915a02dc14 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Wed, 11 Sep 2024 16:31:01 -0400 Subject: [PATCH 009/107] Enable @defer --- src/Tgstation.Server.Host/Core/Application.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs index 5da177e23e7..41d20aba8e1 100644 --- a/src/Tgstation.Server.Host/Core/Application.cs +++ b/src/Tgstation.Server.Host/Core/Application.cs @@ -297,6 +297,10 @@ void ConfigureNewtonsoftJsonSerializerSettingsForApi(JsonSerializerSettings sett .AddGraphQLServer() .AddAuthorization() .AddMutationConventions() + .ModifyOptions(options => + { + options.EnableDefer = true; + }) .AddErrorFilter() .AddType() .AddType() From 673bfcb31b5ca143dba992c9f9df98abde0af4f8 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Wed, 11 Sep 2024 22:50:36 -0400 Subject: [PATCH 010/107] Upgrade to HotChocolate 14 RC. Setup DataLoaders, Filtering, and Sorting. CBT with new API transformer procedure --- .../Core/AuthorityInvoker{TAuthority}.cs | 49 ++++++++++--- .../Core/IAuthorityInvoker{TAuthority}.cs | 36 ++++++++++ .../IGraphQLAuthorityInvoker{TAuthority}.cs | 10 +-- .../Core/IRestAuthorityInvoker{TAuthority}.cs | 4 +- .../Authority/IUserAuthority.cs | 4 +- .../Authority/UserAuthority.cs | 71 +++++++++++++++---- .../Controllers/ApiController.cs | 6 +- .../Controllers/UserController.cs | 13 +--- src/Tgstation.Server.Host/Core/Application.cs | 9 +++ .../GraphQL/Interfaces/IGateway.cs | 2 +- .../GraphQL/Types/Entity.cs | 14 +++- .../GraphQL/Types/LocalGateway.cs | 2 +- .../GraphQL/Types/NamedEntity.cs | 12 +++- .../GraphQL/Types/NodeInformation.cs | 20 ++++++ .../GraphQL/Types/PermissionSet.cs | 8 ++- .../GraphQL/Types/RemoteGateway.cs | 2 +- .../GraphQL/Types/ServerSwarm.cs | 16 ++--- .../GraphQL/Types/{Node.cs => SwarmNode.cs} | 10 +-- .../GraphQL/Types/User.cs | 53 +++++--------- .../GraphQL/Types/UserGroup.cs | 2 + .../GraphQL/Types/UserName.cs | 27 ++++++- .../GraphQL/Types/Users.cs | 24 +++---- src/Tgstation.Server.Host/Models/ChatBot.cs | 2 +- .../Models/CompileJob.cs | 2 +- .../Models/DreamMakerSettings.cs | 2 +- ...formable{TModel,TApiModel,TTransformer}.cs | 25 +++++++ ... => ILegacyApiTransformable{TApiModel}.cs} | 6 +- .../Models/ITransformer{TInput,TOutput}.cs | 23 ++++++ src/Tgstation.Server.Host/Models/Instance.cs | 2 +- .../Models/InstancePermissionSet.cs | 2 +- src/Tgstation.Server.Host/Models/Job.cs | 2 +- .../Models/OAuthConnection.cs | 2 +- .../Models/RepositorySettings.cs | 2 +- .../Models/RevisionInformation.cs | 2 +- src/Tgstation.Server.Host/Models/TestMerge.cs | 2 +- .../TransformerBase{TModel,TApiModel}.cs | 32 +++++++++ .../Transformers/UserGraphQLTransformer.cs | 26 +++++++ src/Tgstation.Server.Host/Models/User.cs | 17 ++--- src/Tgstation.Server.Host/Models/UserGroup.cs | 2 +- .../Properties/AssemblyInfo.cs | 4 ++ .../Tgstation.Server.Host.csproj | 10 ++- 41 files changed, 415 insertions(+), 144 deletions(-) create mode 100644 src/Tgstation.Server.Host/Authority/Core/IAuthorityInvoker{TAuthority}.cs rename src/Tgstation.Server.Host/GraphQL/Types/{Node.cs => SwarmNode.cs} (79%) create mode 100644 src/Tgstation.Server.Host/Models/IApiTransformable{TModel,TApiModel,TTransformer}.cs rename src/Tgstation.Server.Host/Models/{IApiTransformable.cs => ILegacyApiTransformable{TApiModel}.cs} (61%) create mode 100644 src/Tgstation.Server.Host/Models/ITransformer{TInput,TOutput}.cs create mode 100644 src/Tgstation.Server.Host/Models/Transformers/TransformerBase{TModel,TApiModel}.cs create mode 100644 src/Tgstation.Server.Host/Models/Transformers/UserGraphQLTransformer.cs diff --git a/src/Tgstation.Server.Host/Authority/Core/AuthorityInvoker{TAuthority}.cs b/src/Tgstation.Server.Host/Authority/Core/AuthorityInvoker{TAuthority}.cs index 1756c62cf83..db3f06c803f 100644 --- a/src/Tgstation.Server.Host/Authority/Core/AuthorityInvoker{TAuthority}.cs +++ b/src/Tgstation.Server.Host/Authority/Core/AuthorityInvoker{TAuthority}.cs @@ -1,9 +1,8 @@ using System; +using System.Linq; using System.Net; using System.Threading.Tasks; -using HotChocolate.Execution; - using Microsoft.AspNetCore.Mvc; using Tgstation.Server.Host.Controllers; using Tgstation.Server.Host.Extensions; @@ -12,10 +11,7 @@ namespace Tgstation.Server.Host.Authority.Core { - /// - /// Invokes s. - /// - /// The invoked. + /// sealed class AuthorityInvoker : IRestAuthorityInvoker, IGraphQLAuthorityInvoker where TAuthority : IAuthority { @@ -25,7 +21,7 @@ sealed class AuthorityInvoker : IRestAuthorityInvoker, I readonly TAuthority authority; /// - /// Throws a for errored s. + /// Throws a for errored s. /// /// The potentially errored . static void ThrowGraphQLErrorIfNecessary(AuthorityResponse authorityResponse) @@ -49,16 +45,21 @@ public AuthorityInvoker(TAuthority authority) /// public async ValueTask Invoke(ApiController controller, Func> authorityInvoker) { + ArgumentNullException.ThrowIfNull(controller); + ArgumentNullException.ThrowIfNull(authorityInvoker); + var authorityResponse = await authorityInvoker(authority); return CreateErroredActionResult(controller, authorityResponse) ?? controller.NoContent(); } /// public async ValueTask InvokeTransformable(ApiController controller, Func>> authorityInvoker) - - where TResult : notnull, IApiTransformable + where TResult : notnull, ILegacyApiTransformable where TApiModel : notnull { + ArgumentNullException.ThrowIfNull(controller); + ArgumentNullException.ThrowIfNull(authorityInvoker); + var authorityResponse = await authorityInvoker(authority); var erroredResult = CreateErroredActionResult(controller, authorityResponse); if (erroredResult != null) @@ -72,6 +73,9 @@ public async ValueTask InvokeTransformable(Ap /// 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) @@ -84,6 +88,8 @@ async ValueTask IRestAuthorityInvoker.Invoke async ValueTask IGraphQLAuthorityInvoker.Invoke(Func> authorityInvoker) { + ArgumentNullException.ThrowIfNull(authorityInvoker); + var authorityResponse = await authorityInvoker(authority); ThrowGraphQLErrorIfNecessary(authorityResponse); } @@ -93,19 +99,42 @@ public async ValueTask Invoke(Func - async ValueTask IGraphQLAuthorityInvoker.InvokeTransformable(Func>> authorityInvoker) + async ValueTask IGraphQLAuthorityInvoker.InvokeTransformable(Func>> authorityInvoker) { + ArgumentNullException.ThrowIfNull(authorityInvoker); + var authorityResponse = await authorityInvoker(authority); ThrowGraphQLErrorIfNecessary(authorityResponse); return authorityResponse.Result!.ToApi(); } + /// + public IQueryable InvokeQueryable(Func> authorityInvoker) + { + ArgumentNullException.ThrowIfNull(authorityInvoker); + return authorityInvoker(authority); + } + + /// + public IQueryable InvokeTransformableQueryable(Func> authorityInvoker) + where TResult : IApiTransformable + where TApiModel : notnull + where TTransformer : ITransformer, new() + { + ArgumentNullException.ThrowIfNull(authorityInvoker); + var expression = new TTransformer().Expression; + return authorityInvoker(authority) + .Select(expression); + } + /// /// Create an for a given if it is erroring. /// 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/IGraphQLAuthorityInvoker{TAuthority}.cs b/src/Tgstation.Server.Host/Authority/Core/IGraphQLAuthorityInvoker{TAuthority}.cs index 7ce01fa6372..1c5031edc3d 100644 --- a/src/Tgstation.Server.Host/Authority/Core/IGraphQLAuthorityInvoker{TAuthority}.cs +++ b/src/Tgstation.Server.Host/Authority/Core/IGraphQLAuthorityInvoker{TAuthority}.cs @@ -9,7 +9,7 @@ namespace Tgstation.Server.Host.Authority.Core /// Invokes s from GraphQL endpoints. /// /// The invoked. - public interface IGraphQLAuthorityInvoker + public interface IGraphQLAuthorityInvoker : IAuthorityInvoker where TAuthority : IAuthority { /// @@ -35,10 +35,12 @@ ValueTask Invoke(Func /// 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; + ValueTask InvokeTransformable(Func>> authorityInvoker) + where TResult : notnull, IApiTransformable + where TApiModel : notnull + where TTransformer : ITransformer, new(); } } diff --git a/src/Tgstation.Server.Host/Authority/Core/IRestAuthorityInvoker{TAuthority}.cs b/src/Tgstation.Server.Host/Authority/Core/IRestAuthorityInvoker{TAuthority}.cs index 55e61f34cff..564de05c2ac 100644 --- a/src/Tgstation.Server.Host/Authority/Core/IRestAuthorityInvoker{TAuthority}.cs +++ b/src/Tgstation.Server.Host/Authority/Core/IRestAuthorityInvoker{TAuthority}.cs @@ -11,7 +11,7 @@ namespace Tgstation.Server.Host.Authority.Core /// Invokes methods and generates responses. /// /// The type of . - public interface IRestAuthorityInvoker + public interface IRestAuthorityInvoker : IAuthorityInvoker where TAuthority : IAuthority { /// @@ -43,7 +43,7 @@ ValueTask Invoke(ApiController controller, Fu /// The returning a resulting in the . /// A resulting in the generated for the resulting . ValueTask InvokeTransformable(ApiController controller, Func>> authorityInvoker) - where TResult : notnull, IApiTransformable + where TResult : notnull, ILegacyApiTransformable where TApiModel : notnull; } } diff --git a/src/Tgstation.Server.Host/Authority/IUserAuthority.cs b/src/Tgstation.Server.Host/Authority/IUserAuthority.cs index 71f807371a4..dfc9cdda9d7 100644 --- a/src/Tgstation.Server.Host/Authority/IUserAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/IUserAuthority.cs @@ -38,8 +38,8 @@ public interface IUserAuthority : IAuthority /// Gets all registered s. /// /// If related entities should be loaded. - /// A resulting in a . + /// A of s. [TgsAuthorize(AdministrationRights.ReadUsers)] - public ValueTask>> List(bool includeJoins); + public IQueryable Queryable(bool includeJoins); } } diff --git a/src/Tgstation.Server.Host/Authority/UserAuthority.cs b/src/Tgstation.Server.Host/Authority/UserAuthority.cs index fe15593a1bc..ddb20d41086 100644 --- a/src/Tgstation.Server.Host/Authority/UserAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/UserAuthority.cs @@ -1,8 +1,11 @@ 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; @@ -21,24 +24,55 @@ sealed class UserAuthority : AuthorityBase, IUserAuthority /// readonly IDatabaseContext databaseContext; + /// + /// The for the . + /// + readonly IUsersDataLoader dataLoader; + /// /// The for the . /// readonly IAuthenticationContext authenticationContext; + /// + /// 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 async ValueTask> GetUsers( + IReadOnlyList ids, + IDatabaseContext databaseContext, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(ids); + ArgumentNullException.ThrowIfNull(databaseContext); + + return await databaseContext + .Users + .AsQueryable() + .Where(x => ids.Contains(x.Id!.Value)) + .ToDictionaryAsync(user => user.Id!.Value, cancellationToken); + } + /// /// Initializes a new instance of the class. /// /// The to use. /// The value of . + /// The value of . /// The value of . public UserAuthority( ILogger logger, IDatabaseContext databaseContext, + IUsersDataLoader dataLoader, IAuthenticationContext authenticationContext) : base(logger) { this.databaseContext = databaseContext ?? throw new ArgumentNullException(nameof(databaseContext)); + this.dataLoader = dataLoader ?? throw new ArgumentNullException(nameof(dataLoader)); this.authenticationContext = authenticationContext ?? throw new ArgumentNullException(nameof(authenticationContext)); } @@ -49,11 +83,18 @@ public ValueTask> Read(CancellationToken cancellationTok /// public async ValueTask> GetId(long id, bool includeJoins, bool allowSystemUser, CancellationToken cancellationToken) { - var queryable = ListCore(includeJoins); + User? user; + if (includeJoins) + { + var queryable = Queryable(true, true); + + user = await queryable.FirstOrDefaultAsync( + dbModel => dbModel.Id == id, + cancellationToken); + } + else + user = await dataLoader.LoadAsync(id, cancellationToken); - var user = await queryable.FirstOrDefaultAsync( - dbModel => dbModel.Id == id, - cancellationToken); if (user == default) return NotFound(); @@ -64,26 +105,26 @@ public async ValueTask> GetId(long id, bool includeJoins } /// - public ValueTask>> List(bool includeJoins) - { - var systemUserCanonicalName = User.CanonicalizeName(User.TgsSystemUserName); - return ValueTask.FromResult( - new AuthorityResponse>( - ListCore(includeJoins) - .Where(x => x.CanonicalName != systemUserCanonicalName))); - } + public IQueryable Queryable(bool includeJoins) + => Queryable(includeJoins, false); /// - /// Generates an for listing s. + /// Gets all registered s. /// /// If related entities should be loaded. - /// A new of s. - private IQueryable ListCore(bool includeJoins) + /// 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) diff --git a/src/Tgstation.Server.Host/Controllers/ApiController.cs b/src/Tgstation.Server.Host/Controllers/ApiController.cs index 709dfa70872..439b3e3c079 100644 --- a/src/Tgstation.Server.Host/Controllers/ApiController.cs +++ b/src/Tgstation.Server.Host/Controllers/ApiController.cs @@ -288,7 +288,7 @@ protected ValueTask Paginated( int? pageQuery, int? pageSizeQuery, CancellationToken cancellationToken) - where TModel : IApiTransformable + where TModel : ILegacyApiTransformable => PaginatedImpl( queryGenerator, resultTransformer, @@ -299,7 +299,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. @@ -356,7 +356,7 @@ async ValueTask PaginatedImpl( finalResults = (List)(object)pagedResults; // clearly a safe cast else finalResults = pagedResults - .OfType>() + .OfType>() .Select(x => x.ToApi()) .ToList(); diff --git a/src/Tgstation.Server.Host/Controllers/UserController.cs b/src/Tgstation.Server.Host/Controllers/UserController.cs index e59df1f8896..0965ad26b5a 100644 --- a/src/Tgstation.Server.Host/Controllers/UserController.cs +++ b/src/Tgstation.Server.Host/Controllers/UserController.cs @@ -371,21 +371,14 @@ public ValueTask Read(CancellationToken cancellationToken) /// 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, diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs index 41d20aba8e1..1b07bbb9c5c 100644 --- a/src/Tgstation.Server.Host/Core/Application.cs +++ b/src/Tgstation.Server.Host/Core/Application.cs @@ -297,10 +297,19 @@ void ConfigureNewtonsoftJsonSerializerSettingsForApi(JsonSerializerSettings sett .AddGraphQLServer() .AddAuthorization() .AddMutationConventions() + .AddGlobalObjectIdentification() .ModifyOptions(options => { options.EnableDefer = true; }) + .ModifyPagingOptions(pagingOptions => + { + pagingOptions.IncludeTotalCount = true; + pagingOptions.RequirePagingBoundaries = false; + }) + .AddFiltering() + .AddSorting() + .AddHostTypes() .AddErrorFilter() .AddType() .AddType() diff --git a/src/Tgstation.Server.Host/GraphQL/Interfaces/IGateway.cs b/src/Tgstation.Server.Host/GraphQL/Interfaces/IGateway.cs index 89acbc10cc1..e8846abbea4 100644 --- a/src/Tgstation.Server.Host/GraphQL/Interfaces/IGateway.cs +++ b/src/Tgstation.Server.Host/GraphQL/Interfaces/IGateway.cs @@ -11,7 +11,7 @@ namespace Tgstation.Server.Host.GraphQL.Interfaces { /// - /// Management interface for the parent . + /// Management interface for the parent . /// public interface IGateway { diff --git a/src/Tgstation.Server.Host/GraphQL/Types/Entity.cs b/src/Tgstation.Server.Host/GraphQL/Types/Entity.cs index ab51d61a6f8..4f45c223061 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/Entity.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/Entity.cs @@ -1,4 +1,6 @@ -using HotChocolate.Types.Relay; +using System.Diagnostics.CodeAnalysis; + +using HotChocolate.Types.Relay; namespace Tgstation.Server.Host.GraphQL.Types { @@ -11,12 +13,20 @@ public abstract class Entity /// The ID of the . /// [ID] - public long Id { get; } + 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/LocalGateway.cs b/src/Tgstation.Server.Host/GraphQL/Types/LocalGateway.cs index 1774bc6596d..107df473e15 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/LocalGateway.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/LocalGateway.cs @@ -13,7 +13,7 @@ namespace Tgstation.Server.Host.GraphQL.Types { /// - /// for the this query is executing on. + /// for the this query is executing on. /// public sealed class LocalGateway : IGateway { diff --git a/src/Tgstation.Server.Host/GraphQL/Types/NamedEntity.cs b/src/Tgstation.Server.Host/GraphQL/Types/NamedEntity.cs index a52677b8345..d0a36221cc1 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/NamedEntity.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/NamedEntity.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics.CodeAnalysis; using Tgstation.Server.Host.GraphQL.Interfaces; @@ -12,12 +13,20 @@ 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))) { @@ -29,6 +38,7 @@ protected NamedEntity(NamedEntity copy) /// /// 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/NodeInformation.cs b/src/Tgstation.Server.Host/GraphQL/Types/NodeInformation.cs index 0661bf92c91..546d77d9f22 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/NodeInformation.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/NodeInformation.cs @@ -1,14 +1,34 @@ using System; +using System.Linq; +using HotChocolate; using HotChocolate.Types.Relay; +using Tgstation.Server.Host.Swarm; + namespace Tgstation.Server.Host.GraphQL.Types { /// /// Represent a server in the TGS server swarm. /// + [Node] public sealed class NodeInformation { + public NodeInformation? GetNodeInformation( + string identifier, + [Service] ISwarmService swarmService) + { + ArgumentNullException.ThrowIfNull(identifier); + ArgumentNullException.ThrowIfNull(swarmService); + + var node = swarmService.GetSwarmServers() + ?.FirstOrDefault(node => node.Identifier == identifier); + if (node == null) + return null; + + return new NodeInformation(node); + } + /// /// The swarm server ID. /// diff --git a/src/Tgstation.Server.Host/GraphQL/Types/PermissionSet.cs b/src/Tgstation.Server.Host/GraphQL/Types/PermissionSet.cs index 6d7b0d6b5f3..9f0734f3169 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/PermissionSet.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/PermissionSet.cs @@ -1,10 +1,15 @@ -using Tgstation.Server.Api.Rights; +using System.Diagnostics.CodeAnalysis; + +using HotChocolate.Types.Relay; + +using Tgstation.Server.Api.Rights; namespace Tgstation.Server.Host.GraphQL.Types { /// /// Represents a set of permissions for the server. /// + [Node] public sealed class PermissionSet : Entity { /// @@ -23,6 +28,7 @@ public sealed class PermissionSet : Entity /// The . /// The value of . /// The value of . + [SetsRequiredMembers] public PermissionSet(long id, AdministrationRights administrationRights, InstanceManagerRights instanceManagerRights) : base(id) { diff --git a/src/Tgstation.Server.Host/GraphQL/Types/RemoteGateway.cs b/src/Tgstation.Server.Host/GraphQL/Types/RemoteGateway.cs index 0a008490572..5c3305e56dc 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/RemoteGateway.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/RemoteGateway.cs @@ -14,7 +14,7 @@ namespace Tgstation.Server.Host.GraphQL.Types { /// - /// for accessing remote s. + /// for accessing remote s. /// /// This is currently unimplemented. public sealed class RemoteGateway : IGateway diff --git a/src/Tgstation.Server.Host/GraphQL/Types/ServerSwarm.cs b/src/Tgstation.Server.Host/GraphQL/Types/ServerSwarm.cs index e43d9c5b2ed..1b874c13b18 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/ServerSwarm.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/ServerSwarm.cs @@ -40,12 +40,12 @@ public SwarmMetadata Metadata( public Users Users() => new(); /// - /// Gets the connected server. + /// Gets the connected server. /// /// The to use. /// The containing the current . - /// A new . - public Node CurrentNode( + /// A new . + public SwarmNode CurrentNode( [Service] ISwarmService swarmService, [Service] IOptionsSnapshot swarmConfigurationOptions) { @@ -56,19 +56,19 @@ public Node CurrentNode( if (nodeInfos != null) return nodeInfos.First(x => x.Info!.Identifier == swarmConfigurationOptions.Value.Identifier); - return new Node(null); + return new SwarmNode(null); } /// - /// Gets all servers in the 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? Nodes( + /// A of s if the local server is part of a swarm, otherwise. + public List? Nodes( [Service] ISwarmService swarmService) { ArgumentNullException.ThrowIfNull(swarmService); - return swarmService.GetSwarmServers()?.Select(x => new Node(new NodeInformation(x))).ToList(); + return swarmService.GetSwarmServers()?.Select(x => new SwarmNode(new NodeInformation(x))).ToList(); } } } diff --git a/src/Tgstation.Server.Host/GraphQL/Types/Node.cs b/src/Tgstation.Server.Host/GraphQL/Types/SwarmNode.cs similarity index 79% rename from src/Tgstation.Server.Host/GraphQL/Types/Node.cs rename to src/Tgstation.Server.Host/GraphQL/Types/SwarmNode.cs index f42183950db..85582562471 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/Node.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/SwarmNode.cs @@ -12,7 +12,7 @@ namespace Tgstation.Server.Host.GraphQL.Types /// /// Represents a node server in a swarm. /// - public sealed class Node + public sealed class SwarmNode { /// /// Gets the . @@ -20,20 +20,20 @@ public sealed class Node public NodeInformation? Info { get; } /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The value of . - public Node(NodeInformation? info) + public SwarmNode(NodeInformation? info) { Info = info; } /// - /// Gets the 's . + /// Gets the 's . /// /// The containing the current . /// A new . - /// The 's . + /// The 's . public IGateway? Gateway([Service] IOptionsSnapshot swarmConfigurationOptions) { ArgumentNullException.ThrowIfNull(swarmConfigurationOptions); diff --git a/src/Tgstation.Server.Host/GraphQL/Types/User.cs b/src/Tgstation.Server.Host/GraphQL/Types/User.cs index 4ab07675df9..efacb5525e6 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/User.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/User.cs @@ -4,76 +4,61 @@ using System.Threading.Tasks; using HotChocolate; +using HotChocolate.Types.Relay; using Tgstation.Server.Host.Authority; using Tgstation.Server.Host.Authority.Core; using Tgstation.Server.Host.GraphQL.Interfaces; +using Tgstation.Server.Host.Models.Transformers; namespace Tgstation.Server.Host.GraphQL.Types { /// /// A user registered in the server. /// + [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 string CanonicalName { get; } + 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 . /// - readonly long? groupId; + [GraphQLIgnore] + public required long? GroupId { get; init; } - /// - /// Initializes a new instance of the class. - /// - /// The . - /// The . - /// The value of . - /// The value of . - /// The value of . - /// The value of . - /// The value of . - /// The value of . - public User( + public static ValueTask GetUser( long id, - string name, - string canonicalName, - string? systemIdentifier, - DateTimeOffset createdAt, - long? createdById, - long? groupId, - bool enabled) - : base(id, name) + [Service] IGraphQLAuthorityInvoker authorityInvoker, + CancellationToken cancellationToken) { - SystemIdentifier = systemIdentifier; - CanonicalName = canonicalName ?? throw new ArgumentNullException(nameof(canonicalName)); - CreatedAt = createdAt; - this.createdById = createdById; - Enabled = enabled; - this.groupId = groupId; + ArgumentNullException.ThrowIfNull(authorityInvoker); + return authorityInvoker.InvokeTransformable( + authority => authority.GetId(id, false, false, cancellationToken)); } /// @@ -87,10 +72,10 @@ public User( CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(userAuthority); - if (!createdById.HasValue) + if (!CreatedById.HasValue) return null; - var user = await userAuthority.InvokeTransformable(authority => authority.GetId(createdById.Value, false, true, cancellationToken)); + 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); diff --git a/src/Tgstation.Server.Host/GraphQL/Types/UserGroup.cs b/src/Tgstation.Server.Host/GraphQL/Types/UserGroup.cs index 82b62422216..7a647e0b883 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/UserGroup.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/UserGroup.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks; using HotChocolate.Types; @@ -22,6 +23,7 @@ public sealed class UserGroup : NamedEntity /// The . /// The . /// The value of . + [SetsRequiredMembers] public UserGroup( long id, string name, diff --git a/src/Tgstation.Server.Host/GraphQL/Types/UserName.cs b/src/Tgstation.Server.Host/GraphQL/Types/UserName.cs index f16379debc0..3379741f91e 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/UserName.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/UserName.cs @@ -1,16 +1,41 @@ -using Tgstation.Server.Host.GraphQL.Interfaces; +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.Authority.Core; +using Tgstation.Server.Host.GraphQL.Interfaces; +using Tgstation.Server.Host.Models.Transformers; namespace Tgstation.Server.Host.GraphQL.Types { /// /// A with limited fields. /// + [Node] public sealed class UserName : NamedEntity, IUserName { + public static async ValueTask GetUserName( + long id, + [Service] IGraphQLAuthorityInvoker authorityInvoker, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(authorityInvoker); + var user = await authorityInvoker.InvokeTransformable( + authority => authority.GetId(id, false, true, cancellationToken)); + + return new UserName(user); + } + /// /// Initializes a new instance of the class. /// /// The to copy. + [SetsRequiredMembers] public UserName(NamedEntity copy) : base(copy) { diff --git a/src/Tgstation.Server.Host/GraphQL/Types/Users.cs b/src/Tgstation.Server.Host/GraphQL/Types/Users.cs index 754771e9172..fcba0f00eae 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/Users.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/Users.cs @@ -4,12 +4,13 @@ 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.Authority.Core; -using Tgstation.Server.Host.Models; +using Tgstation.Server.Host.Models.Transformers; using Tgstation.Server.Host.Security; #pragma warning disable CA1724 // conflict with GitLabApiClient.Models.Users. They can fuck off @@ -33,7 +34,7 @@ public ValueTask Current( CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(userAuthority); - return userAuthority.InvokeTransformable(authority => authority.Read(cancellationToken)); + return userAuthority.InvokeTransformable(authority => authority.Read(cancellationToken)); } /// @@ -49,26 +50,23 @@ public ValueTask Current( [ID(nameof(User))] long id, [Service] IGraphQLAuthorityInvoker userAuthority, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(userAuthority); - return await userAuthority.InvokeTransformable(authority => authority.GetId(id, false, false, cancellationToken)); - } + => await User.GetUser(id, userAuthority, cancellationToken); /// /// Lists all registered s. /// /// The . /// A list of all registered s. - [UsePaging(IncludeTotalCount = true)] - [TgsGraphQLAuthorize(nameof(IUserAuthority.List))] - public async ValueTask> List( + [UsePaging] + [UseFiltering] + [UseSorting] + [TgsGraphQLAuthorize(nameof(IUserAuthority.Queryable))] + public IQueryable? Queryable( [Service] IGraphQLAuthorityInvoker userAuthority) { ArgumentNullException.ThrowIfNull(userAuthority); - var dtoQueryable = await userAuthority.Invoke, IQueryable>(authority => authority.List(false)); - return dtoQueryable - .Cast>() - .Select(dto => dto.ToApi()); + 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..df45b7d7e82 --- /dev/null +++ b/src/Tgstation.Server.Host/Models/IApiTransformable{TModel,TApiModel,TTransformer}.cs @@ -0,0 +1,25 @@ +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 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..64b1702d8c3 100644 --- a/src/Tgstation.Server.Host/Models/OAuthConnection.cs +++ b/src/Tgstation.Server.Host/Models/OAuthConnection.cs @@ -1,7 +1,7 @@ namespace Tgstation.Server.Host.Models { /// - public sealed class OAuthConnection : Api.Models.OAuthConnection, IApiTransformable + public sealed class OAuthConnection : Api.Models.OAuthConnection, ILegacyApiTransformable { /// /// The row Id. 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/TransformerBase{TModel,TApiModel}.cs b/src/Tgstation.Server.Host/Models/Transformers/TransformerBase{TModel,TApiModel}.cs new file mode 100644 index 00000000000..e3d16441ccc --- /dev/null +++ b/src/Tgstation.Server.Host/Models/Transformers/TransformerBase{TModel,TApiModel}.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/User.cs b/src/Tgstation.Server.Host/Models/User.cs index d45d579d600..46c5530da1a 100644 --- a/src/Tgstation.Server.Host/Models/User.cs +++ b/src/Tgstation.Server.Host/Models/User.cs @@ -5,11 +5,14 @@ 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, IApiTransformable + public sealed class User : Api.Models.Internal.UserModelBase, + ILegacyApiTransformable, + IApiTransformable { /// /// Username used when creating jobs automatically. @@ -83,18 +86,6 @@ public sealed class User : Api.Models.Internal.UserModelBase, IApiTransformable< /// public UserResponse ToApi() => CreateUserResponse(true); - /// - GraphQL.Types.User IApiTransformable.ToApi() - => new( - Id!.Value, - Name!, - CanonicalName!, - SystemIdentifier, - CreatedAt!.Value, - CreatedById, - GroupId, - Enabled!.Value); - /// /// Generate a from . /// diff --git a/src/Tgstation.Server.Host/Models/UserGroup.cs b/src/Tgstation.Server.Host/Models/UserGroup.cs index 4451c1a0732..c8fa3efa799 100644 --- a/src/Tgstation.Server.Host/Models/UserGroup.cs +++ b/src/Tgstation.Server.Host/Models/UserGroup.cs @@ -11,7 +11,7 @@ namespace Tgstation.Server.Host.Models /// /// Represents a group of s. /// - public sealed class UserGroup : NamedEntity, IApiTransformable + public sealed class UserGroup : NamedEntity, ILegacyApiTransformable { /// /// The the has. 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/Tgstation.Server.Host.csproj b/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj index 3192e8faa95..72b62df1f31 100644 --- a/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj +++ b/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj @@ -100,11 +100,15 @@ - + - + + + + + - + From a2ed58086cca782e1ce5c800e467be1763d35489 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Wed, 11 Sep 2024 22:50:44 -0400 Subject: [PATCH 011/107] Add missing dependency comment --- src/Tgstation.Server.Host/Tgstation.Server.Host.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj b/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj index 72b62df1f31..55103163283 100644 --- a/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj +++ b/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj @@ -129,6 +129,7 @@ + From ba5d5e4cd79a9eaf2b02728df051a3e063a0c00b Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Wed, 11 Sep 2024 23:10:16 -0400 Subject: [PATCH 012/107] Fix build warnings --- .../Core/AuthorityInvoker{TAuthority}.cs | 21 ++++++++++++------- .../IGraphQLAuthorityInvoker{TAuthority}.cs | 4 ++-- .../GraphQL/Types/NodeInformation.cs | 6 ++++++ .../GraphQL/Types/User.cs | 18 ++++++++++++---- .../GraphQL/Types/UserName.cs | 18 ++++++++++++---- .../GraphQL/Types/Users.cs | 2 +- ...formable{TModel,TApiModel,TTransformer}.cs | 1 + ....cs => TransformerBase{TInput,TOutput}.cs} | 0 8 files changed, 52 insertions(+), 18 deletions(-) rename src/Tgstation.Server.Host/Models/Transformers/{TransformerBase{TModel,TApiModel}.cs => TransformerBase{TInput,TOutput}.cs} (100%) diff --git a/src/Tgstation.Server.Host/Authority/Core/AuthorityInvoker{TAuthority}.cs b/src/Tgstation.Server.Host/Authority/Core/AuthorityInvoker{TAuthority}.cs index db3f06c803f..3b6eb396b9f 100644 --- a/src/Tgstation.Server.Host/Authority/Core/AuthorityInvoker{TAuthority}.cs +++ b/src/Tgstation.Server.Host/Authority/Core/AuthorityInvoker{TAuthority}.cs @@ -86,34 +86,41 @@ async ValueTask IRestAuthorityInvoker.Invoke - async ValueTask IGraphQLAuthorityInvoker.Invoke(Func> authorityInvoker) + public async ValueTask InvokeTransformable(Func>> authorityInvoker) + where TResult : notnull, IApiTransformable + where TApiModel : notnull + where TTransformer : ITransformer, new() { ArgumentNullException.ThrowIfNull(authorityInvoker); var authorityResponse = await authorityInvoker(authority); ThrowGraphQLErrorIfNecessary(authorityResponse); + var result = authorityResponse.Result; + if (result == null) + return default; + + return result.ToApi(); } /// - public async ValueTask Invoke(Func>> authorityInvoker) - where TResult : TApiModel - where TApiModel : notnull + async ValueTask IGraphQLAuthorityInvoker.Invoke(Func> authorityInvoker) { ArgumentNullException.ThrowIfNull(authorityInvoker); var authorityResponse = await authorityInvoker(authority); ThrowGraphQLErrorIfNecessary(authorityResponse); - return authorityResponse.Result!; } /// - async ValueTask IGraphQLAuthorityInvoker.InvokeTransformable(Func>> authorityInvoker) + public async ValueTask Invoke(Func>> authorityInvoker) + where TResult : TApiModel + where TApiModel : notnull { ArgumentNullException.ThrowIfNull(authorityInvoker); var authorityResponse = await authorityInvoker(authority); ThrowGraphQLErrorIfNecessary(authorityResponse); - return authorityResponse.Result!.ToApi(); + return authorityResponse.Result; } /// diff --git a/src/Tgstation.Server.Host/Authority/Core/IGraphQLAuthorityInvoker{TAuthority}.cs b/src/Tgstation.Server.Host/Authority/Core/IGraphQLAuthorityInvoker{TAuthority}.cs index 1c5031edc3d..24102d8a018 100644 --- a/src/Tgstation.Server.Host/Authority/Core/IGraphQLAuthorityInvoker{TAuthority}.cs +++ b/src/Tgstation.Server.Host/Authority/Core/IGraphQLAuthorityInvoker{TAuthority}.cs @@ -26,7 +26,7 @@ public interface IGraphQLAuthorityInvoker : IAuthorityInvokerThe resulting of the return value. /// The returning a resulting in the . /// A resulting in the generated for the resulting . - ValueTask Invoke(Func>> authorityInvoker) + ValueTask Invoke(Func>> authorityInvoker) where TResult : TApiModel where TApiModel : notnull; @@ -38,7 +38,7 @@ ValueTask Invoke(FuncThe for converting s to s. /// The returning a resulting in the . /// A resulting in the generated for the resulting . - ValueTask InvokeTransformable(Func>> authorityInvoker) + ValueTask InvokeTransformable(Func>> authorityInvoker) where TResult : notnull, IApiTransformable where TApiModel : notnull where TTransformer : ITransformer, new(); diff --git a/src/Tgstation.Server.Host/GraphQL/Types/NodeInformation.cs b/src/Tgstation.Server.Host/GraphQL/Types/NodeInformation.cs index 546d77d9f22..42af3c81769 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/NodeInformation.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/NodeInformation.cs @@ -14,6 +14,12 @@ namespace Tgstation.Server.Host.GraphQL.Types [Node] public sealed class NodeInformation { + /// + /// Node resolver for s. + /// + /// The to lookup. + /// The to use. + /// The queried , if present. public NodeInformation? GetNodeInformation( string identifier, [Service] ISwarmService swarmService) diff --git a/src/Tgstation.Server.Host/GraphQL/Types/User.cs b/src/Tgstation.Server.Host/GraphQL/Types/User.cs index efacb5525e6..794f68c60a5 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/User.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/User.cs @@ -51,13 +51,20 @@ public sealed class User : NamedEntity, IUserName [GraphQLIgnore] public required long? GroupId { get; init; } - public static ValueTask GetUser( + /// + /// Node resolver for s. + /// + /// The to lookup. + /// The . + /// The for the operation. + /// A resulting in the queried , if present. + public static ValueTask GetUser( long id, - [Service] IGraphQLAuthorityInvoker authorityInvoker, + [Service] IGraphQLAuthorityInvoker userAuthority, CancellationToken cancellationToken) { - ArgumentNullException.ThrowIfNull(authorityInvoker); - return authorityInvoker.InvokeTransformable( + ArgumentNullException.ThrowIfNull(userAuthority); + return userAuthority.InvokeTransformable( authority => authority.GetId(id, false, false, cancellationToken)); } @@ -76,6 +83,9 @@ public static ValueTask GetUser( return null; var user = await userAuthority.InvokeTransformable(authority => authority.GetId(CreatedById.Value, false, true, cancellationToken)); + if (user == null) + return null; + if (user.CanonicalName == Models.User.CanonicalizeName(Models.User.TgsSystemUserName)) return new UserName(user); diff --git a/src/Tgstation.Server.Host/GraphQL/Types/UserName.cs b/src/Tgstation.Server.Host/GraphQL/Types/UserName.cs index 3379741f91e..555fbc4ad83 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/UserName.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/UserName.cs @@ -19,15 +19,25 @@ namespace Tgstation.Server.Host.GraphQL.Types [Node] public sealed class UserName : NamedEntity, IUserName { - public static async ValueTask GetUserName( + /// + /// Node resolver for s. + /// + /// The to lookup. + /// The . + /// The for the operation. + /// A resulting in the queried , if present. + public static async ValueTask GetUserName( long id, - [Service] IGraphQLAuthorityInvoker authorityInvoker, + [Service] IGraphQLAuthorityInvoker userAuthority, CancellationToken cancellationToken) { - ArgumentNullException.ThrowIfNull(authorityInvoker); - var user = await authorityInvoker.InvokeTransformable( + ArgumentNullException.ThrowIfNull(userAuthority); + var user = await userAuthority.InvokeTransformable( authority => authority.GetId(id, false, true, cancellationToken)); + if (user == null) + return null; + return new UserName(user); } diff --git a/src/Tgstation.Server.Host/GraphQL/Types/Users.cs b/src/Tgstation.Server.Host/GraphQL/Types/Users.cs index fcba0f00eae..f4d718e43ff 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/Users.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/Users.cs @@ -34,7 +34,7 @@ public ValueTask Current( CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(userAuthority); - return userAuthority.InvokeTransformable(authority => authority.Read(cancellationToken)); + return userAuthority.InvokeTransformable(authority => authority.Read(cancellationToken))!; } /// diff --git a/src/Tgstation.Server.Host/Models/IApiTransformable{TModel,TApiModel,TTransformer}.cs b/src/Tgstation.Server.Host/Models/IApiTransformable{TModel,TApiModel,TTransformer}.cs index df45b7d7e82..0fff10e2298 100644 --- a/src/Tgstation.Server.Host/Models/IApiTransformable{TModel,TApiModel,TTransformer}.cs +++ b/src/Tgstation.Server.Host/Models/IApiTransformable{TModel,TApiModel,TTransformer}.cs @@ -11,6 +11,7 @@ namespace Tgstation.Server.Host.Models /// The API model . /// The . public interface IApiTransformable + where TApiModel : notnull where TModel : IApiTransformable where TTransformer : ITransformer, new() { diff --git a/src/Tgstation.Server.Host/Models/Transformers/TransformerBase{TModel,TApiModel}.cs b/src/Tgstation.Server.Host/Models/Transformers/TransformerBase{TInput,TOutput}.cs similarity index 100% rename from src/Tgstation.Server.Host/Models/Transformers/TransformerBase{TModel,TApiModel}.cs rename to src/Tgstation.Server.Host/Models/Transformers/TransformerBase{TInput,TOutput}.cs From d3168f9a2c3cb17767e7ab2b2b1f29886fd1da1d Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Wed, 11 Sep 2024 23:12:00 -0400 Subject: [PATCH 013/107] NotFound/Gone don't throw for GraphQL AuthorityInvokers --- .../Authority/Core/AuthorityInvoker{TAuthority}.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Tgstation.Server.Host/Authority/Core/AuthorityInvoker{TAuthority}.cs b/src/Tgstation.Server.Host/Authority/Core/AuthorityInvoker{TAuthority}.cs index 3b6eb396b9f..cf04a0068f0 100644 --- a/src/Tgstation.Server.Host/Authority/Core/AuthorityInvoker{TAuthority}.cs +++ b/src/Tgstation.Server.Host/Authority/Core/AuthorityInvoker{TAuthority}.cs @@ -26,7 +26,9 @@ sealed class AuthorityInvoker : IRestAuthorityInvoker, I /// The potentially errored . static void ThrowGraphQLErrorIfNecessary(AuthorityResponse authorityResponse) { - if (authorityResponse.Success) + if (authorityResponse.Success + || authorityResponse.FailureResponse.Value == HttpFailureResponse.NotFound + || authorityResponse.FailureResponse.Value == HttpFailureResponse.Gone) return; var fallbackString = authorityResponse.FailureResponse.ToString()!; From 27e668d5f7c30a3940e8a63329a6ad1cbc2d237e Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Wed, 11 Sep 2024 23:19:13 -0400 Subject: [PATCH 014/107] Move API specific `IAuthorityInvoker`s out of Core namespace --- .../{Core => }/IGraphQLAuthorityInvoker{TAuthority}.cs | 3 ++- .../Authority/{Core => }/IRestAuthorityInvoker{TAuthority}.cs | 4 +++- src/Tgstation.Server.Host/Controllers/ApiRootController.cs | 1 - src/Tgstation.Server.Host/Controllers/UserController.cs | 1 - src/Tgstation.Server.Host/GraphQL/Mutation.cs | 3 +-- src/Tgstation.Server.Host/GraphQL/Types/User.cs | 1 - src/Tgstation.Server.Host/GraphQL/Types/UserName.cs | 1 - src/Tgstation.Server.Host/GraphQL/Types/Users.cs | 1 - 8 files changed, 6 insertions(+), 9 deletions(-) rename src/Tgstation.Server.Host/Authority/{Core => }/IGraphQLAuthorityInvoker{TAuthority}.cs (97%) rename src/Tgstation.Server.Host/Authority/{Core => }/IRestAuthorityInvoker{TAuthority}.cs (97%) diff --git a/src/Tgstation.Server.Host/Authority/Core/IGraphQLAuthorityInvoker{TAuthority}.cs b/src/Tgstation.Server.Host/Authority/IGraphQLAuthorityInvoker{TAuthority}.cs similarity index 97% rename from src/Tgstation.Server.Host/Authority/Core/IGraphQLAuthorityInvoker{TAuthority}.cs rename to src/Tgstation.Server.Host/Authority/IGraphQLAuthorityInvoker{TAuthority}.cs index 24102d8a018..eb179caf9be 100644 --- a/src/Tgstation.Server.Host/Authority/Core/IGraphQLAuthorityInvoker{TAuthority}.cs +++ b/src/Tgstation.Server.Host/Authority/IGraphQLAuthorityInvoker{TAuthority}.cs @@ -1,9 +1,10 @@ using System; using System.Threading.Tasks; +using Tgstation.Server.Host.Authority.Core; using Tgstation.Server.Host.Models; -namespace Tgstation.Server.Host.Authority.Core +namespace Tgstation.Server.Host.Authority { /// /// Invokes s from GraphQL endpoints. diff --git a/src/Tgstation.Server.Host/Authority/Core/IRestAuthorityInvoker{TAuthority}.cs b/src/Tgstation.Server.Host/Authority/IRestAuthorityInvoker{TAuthority}.cs similarity index 97% rename from src/Tgstation.Server.Host/Authority/Core/IRestAuthorityInvoker{TAuthority}.cs rename to src/Tgstation.Server.Host/Authority/IRestAuthorityInvoker{TAuthority}.cs index 564de05c2ac..2cb05a2b9a6 100644 --- a/src/Tgstation.Server.Host/Authority/Core/IRestAuthorityInvoker{TAuthority}.cs +++ b/src/Tgstation.Server.Host/Authority/IRestAuthorityInvoker{TAuthority}.cs @@ -2,10 +2,12 @@ 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.Core +namespace Tgstation.Server.Host.Authority { /// /// Invokes methods and generates responses. diff --git a/src/Tgstation.Server.Host/Controllers/ApiRootController.cs b/src/Tgstation.Server.Host/Controllers/ApiRootController.cs index 2ac52cf28c7..893357064b0 100644 --- a/src/Tgstation.Server.Host/Controllers/ApiRootController.cs +++ b/src/Tgstation.Server.Host/Controllers/ApiRootController.cs @@ -15,7 +15,6 @@ using Tgstation.Server.Api; using Tgstation.Server.Api.Models.Response; using Tgstation.Server.Host.Authority; -using Tgstation.Server.Host.Authority.Core; using Tgstation.Server.Host.Components.Interop; using Tgstation.Server.Host.Configuration; using Tgstation.Server.Host.Core; diff --git a/src/Tgstation.Server.Host/Controllers/UserController.cs b/src/Tgstation.Server.Host/Controllers/UserController.cs index 0965ad26b5a..2bb16eeffda 100644 --- a/src/Tgstation.Server.Host/Controllers/UserController.cs +++ b/src/Tgstation.Server.Host/Controllers/UserController.cs @@ -15,7 +15,6 @@ using Tgstation.Server.Api.Models.Response; using Tgstation.Server.Api.Rights; using Tgstation.Server.Host.Authority; -using Tgstation.Server.Host.Authority.Core; using Tgstation.Server.Host.Configuration; using Tgstation.Server.Host.Controllers.Results; using Tgstation.Server.Host.Database; diff --git a/src/Tgstation.Server.Host/GraphQL/Mutation.cs b/src/Tgstation.Server.Host/GraphQL/Mutation.cs index 972612f885d..eabb2f2ed69 100644 --- a/src/Tgstation.Server.Host/GraphQL/Mutation.cs +++ b/src/Tgstation.Server.Host/GraphQL/Mutation.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Threading; using System.Threading.Tasks; @@ -7,7 +7,6 @@ using Tgstation.Server.Api.Models.Response; using Tgstation.Server.Host.Authority; -using Tgstation.Server.Host.Authority.Core; namespace Tgstation.Server.Host.GraphQL { diff --git a/src/Tgstation.Server.Host/GraphQL/Types/User.cs b/src/Tgstation.Server.Host/GraphQL/Types/User.cs index 794f68c60a5..ab5df09a79c 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/User.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/User.cs @@ -7,7 +7,6 @@ using HotChocolate.Types.Relay; using Tgstation.Server.Host.Authority; -using Tgstation.Server.Host.Authority.Core; using Tgstation.Server.Host.GraphQL.Interfaces; using Tgstation.Server.Host.Models.Transformers; diff --git a/src/Tgstation.Server.Host/GraphQL/Types/UserName.cs b/src/Tgstation.Server.Host/GraphQL/Types/UserName.cs index 555fbc4ad83..3d02b0f42f9 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/UserName.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/UserName.cs @@ -7,7 +7,6 @@ using HotChocolate.Types.Relay; using Tgstation.Server.Host.Authority; -using Tgstation.Server.Host.Authority.Core; using Tgstation.Server.Host.GraphQL.Interfaces; using Tgstation.Server.Host.Models.Transformers; diff --git a/src/Tgstation.Server.Host/GraphQL/Types/Users.cs b/src/Tgstation.Server.Host/GraphQL/Types/Users.cs index f4d718e43ff..db0a333f530 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/Users.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/Users.cs @@ -9,7 +9,6 @@ using HotChocolate.Types.Relay; using Tgstation.Server.Host.Authority; -using Tgstation.Server.Host.Authority.Core; using Tgstation.Server.Host.Models.Transformers; using Tgstation.Server.Host.Security; From e2abe415ead17ce240fd474f2bfd7e0bc72b4fe6 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Wed, 11 Sep 2024 23:19:22 -0400 Subject: [PATCH 015/107] Fix a null referencing issue --- src/Tgstation.Server.Host/GraphQL/Mutation.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Tgstation.Server.Host/GraphQL/Mutation.cs b/src/Tgstation.Server.Host/GraphQL/Mutation.cs index eabb2f2ed69..31b896e4fc9 100644 --- a/src/Tgstation.Server.Host/GraphQL/Mutation.cs +++ b/src/Tgstation.Server.Host/GraphQL/Mutation.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Threading; using System.Threading.Tasks; @@ -32,7 +32,7 @@ public async ValueTask Login( var tokenResponse = await loginAuthority.Invoke( authority => authority.AttemptLogin(cancellationToken)); - return tokenResponse.Bearer!; + return tokenResponse!.Bearer!; } } } From a96c6dc999fa3884742dccd234d0a18383657f72 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Wed, 11 Sep 2024 23:34:28 -0400 Subject: [PATCH 016/107] Split `AuthorityInvoker` into GraphQL and REST --- .../Core/AuthorityInvokerBase{TAuthority}.cs | 40 ++++ .../GraphQLAuthorityInvoker{TAuthority}.cs | 71 +++++++ ...cs => RestAuthorityInvoker{TAuthority}.cs} | 199 ++++++------------ src/Tgstation.Server.Host/Core/Application.cs | 4 +- 4 files changed, 172 insertions(+), 142 deletions(-) create mode 100644 src/Tgstation.Server.Host/Authority/Core/AuthorityInvokerBase{TAuthority}.cs create mode 100644 src/Tgstation.Server.Host/Authority/Core/GraphQLAuthorityInvoker{TAuthority}.cs rename src/Tgstation.Server.Host/Authority/Core/{AuthorityInvoker{TAuthority}.cs => RestAuthorityInvoker{TAuthority}.cs} (51%) 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..0fe86fa1469 --- /dev/null +++ b/src/Tgstation.Server.Host/Authority/Core/AuthorityInvokerBase{TAuthority}.cs @@ -0,0 +1,40 @@ +using System; +using System.Linq; + +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 expression = new TTransformer().Expression; + return authorityInvoker(Authority) + .Select(expression); + } + } +} 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..794bd467efe --- /dev/null +++ b/src/Tgstation.Server.Host/Authority/Core/GraphQLAuthorityInvoker{TAuthority}.cs @@ -0,0 +1,71 @@ +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 . + static void ThrowGraphQLErrorIfNecessary(AuthorityResponse authorityResponse) + { + if (authorityResponse.Success + || authorityResponse.FailureResponse.Value == HttpFailureResponse.NotFound + || authorityResponse.FailureResponse.Value == HttpFailureResponse.Gone) + 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); + } + + /// + async ValueTask IGraphQLAuthorityInvoker.Invoke(Func>> authorityInvoker) + where TApiModel : default + { + ArgumentNullException.ThrowIfNull(authorityInvoker); + + var authorityResponse = await authorityInvoker(Authority); + ThrowGraphQLErrorIfNecessary(authorityResponse); + return authorityResponse.Result; + } + + /// + async ValueTask IGraphQLAuthorityInvoker.InvokeTransformable(Func>> authorityInvoker) + where TApiModel : default + { + ArgumentNullException.ThrowIfNull(authorityInvoker); + + var authorityResponse = await authorityInvoker(Authority); + ThrowGraphQLErrorIfNecessary(authorityResponse); + var result = authorityResponse.Result; + if (result == null) + return default; + + return result.ToApi(); + } + } +} diff --git a/src/Tgstation.Server.Host/Authority/Core/AuthorityInvoker{TAuthority}.cs b/src/Tgstation.Server.Host/Authority/Core/RestAuthorityInvoker{TAuthority}.cs similarity index 51% rename from src/Tgstation.Server.Host/Authority/Core/AuthorityInvoker{TAuthority}.cs rename to src/Tgstation.Server.Host/Authority/Core/RestAuthorityInvoker{TAuthority}.cs index cf04a0068f0..83911ecb56a 100644 --- a/src/Tgstation.Server.Host/Authority/Core/AuthorityInvoker{TAuthority}.cs +++ b/src/Tgstation.Server.Host/Authority/Core/RestAuthorityInvoker{TAuthority}.cs @@ -1,199 +1,118 @@ using System; -using System.Linq; using System.Net; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; + using Tgstation.Server.Host.Controllers; using Tgstation.Server.Host.Extensions; -using Tgstation.Server.Host.GraphQL; -using Tgstation.Server.Host.Models; namespace Tgstation.Server.Host.Authority.Core { - /// - sealed class AuthorityInvoker : IRestAuthorityInvoker, IGraphQLAuthorityInvoker + /// + sealed class RestAuthorityInvoker : AuthorityInvokerBase, IRestAuthorityInvoker where TAuthority : IAuthority { /// - /// The being invoked. + /// Create an for a given successfuly API . /// - readonly TAuthority authority; + /// The to use. + /// The resulting from the . + /// The . + /// An for the . + /// The result returned in the . + /// The REST API result model built from . + static IActionResult CreateSuccessfulActionResult(ApiController controller, TApiModel result, AuthorityResponse authorityResponse) + where TApiModel : notnull + { + var successResponse = authorityResponse.SuccessResponse; + 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}"), + }; + } /// - /// Throws a for errored s. + /// Create an for a given if it is erroring. /// - /// The potentially errored . - static void ThrowGraphQLErrorIfNecessary(AuthorityResponse authorityResponse) + /// The to use. + /// The . + /// An if the is not successful, otherwise. + static IActionResult? CreateErroredActionResult(ApiController controller, AuthorityResponse authorityResponse) { - if (authorityResponse.Success - || authorityResponse.FailureResponse.Value == HttpFailureResponse.NotFound - || authorityResponse.FailureResponse.Value == HttpFailureResponse.Gone) - return; + if (authorityResponse.Success) + return null; - var fallbackString = authorityResponse.FailureResponse.ToString()!; - throw new ErrorMessageException(authorityResponse.ErrorMessage, fallbackString); + 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. + /// Initializes a new instance of the class. /// - /// The value of . - public AuthorityInvoker(TAuthority authority) + /// The . + public RestAuthorityInvoker(TAuthority authority) + : base(authority) { - this.authority = authority ?? throw new ArgumentNullException(nameof(authority)); } /// - public async ValueTask Invoke(ApiController controller, Func> authorityInvoker) + async ValueTask IRestAuthorityInvoker.Invoke(ApiController controller, Func> authorityInvoker) { ArgumentNullException.ThrowIfNull(controller); ArgumentNullException.ThrowIfNull(authorityInvoker); - var authorityResponse = await authorityInvoker(authority); + var authorityResponse = await authorityInvoker(Authority); return CreateErroredActionResult(controller, authorityResponse) ?? controller.NoContent(); } /// - public async ValueTask InvokeTransformable(ApiController controller, Func>> authorityInvoker) - where TResult : notnull, ILegacyApiTransformable - where TApiModel : notnull + async ValueTask IRestAuthorityInvoker.Invoke(ApiController controller, Func>> authorityInvoker) { ArgumentNullException.ThrowIfNull(controller); ArgumentNullException.ThrowIfNull(authorityInvoker); - var authorityResponse = await authorityInvoker(authority); + var authorityResponse = await authorityInvoker(Authority); var erroredResult = CreateErroredActionResult(controller, authorityResponse); if (erroredResult != null) return erroredResult; var result = authorityResponse.Result!; - var apiModel = result.ToApi(); - return CreateSuccessfulActionResult(controller, apiModel, authorityResponse); + return CreateSuccessfulActionResult(controller, result, authorityResponse); } /// - async ValueTask IRestAuthorityInvoker.Invoke(ApiController controller, Func>> authorityInvoker) + async ValueTask IRestAuthorityInvoker.InvokeTransformable(ApiController controller, Func>> authorityInvoker) { ArgumentNullException.ThrowIfNull(controller); ArgumentNullException.ThrowIfNull(authorityInvoker); - var authorityResponse = await authorityInvoker(authority); + var authorityResponse = await authorityInvoker(Authority); var erroredResult = CreateErroredActionResult(controller, authorityResponse); if (erroredResult != null) return erroredResult; var result = authorityResponse.Result!; - return CreateSuccessfulActionResult(controller, result, authorityResponse); - } - - /// - public async ValueTask InvokeTransformable(Func>> authorityInvoker) - where TResult : notnull, IApiTransformable - where TApiModel : notnull - where TTransformer : ITransformer, new() - { - ArgumentNullException.ThrowIfNull(authorityInvoker); - - var authorityResponse = await authorityInvoker(authority); - ThrowGraphQLErrorIfNecessary(authorityResponse); - var result = authorityResponse.Result; - if (result == null) - return default; - - return result.ToApi(); - } - - /// - async ValueTask IGraphQLAuthorityInvoker.Invoke(Func> authorityInvoker) - { - ArgumentNullException.ThrowIfNull(authorityInvoker); - - var authorityResponse = await authorityInvoker(authority); - ThrowGraphQLErrorIfNecessary(authorityResponse); - } - - /// - public async ValueTask Invoke(Func>> authorityInvoker) - where TResult : TApiModel - where TApiModel : notnull - { - ArgumentNullException.ThrowIfNull(authorityInvoker); - - var authorityResponse = await authorityInvoker(authority); - ThrowGraphQLErrorIfNecessary(authorityResponse); - return authorityResponse.Result; - } - - /// - public IQueryable InvokeQueryable(Func> authorityInvoker) - { - ArgumentNullException.ThrowIfNull(authorityInvoker); - return authorityInvoker(authority); - } - - /// - public IQueryable InvokeTransformableQueryable(Func> authorityInvoker) - where TResult : IApiTransformable - where TApiModel : notnull - where TTransformer : ITransformer, new() - { - ArgumentNullException.ThrowIfNull(authorityInvoker); - var expression = new TTransformer().Expression; - return authorityInvoker(authority) - .Select(expression); - } - - /// - /// Create an for a given if it is erroring. - /// - /// The to use. - /// The . - /// An if the is not successful, otherwise. - 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}"), - }; - } - - /// - /// Create an for a given successfuly API . - /// - /// The to use. - /// The resulting from the . - /// The . - /// An for the . - /// The result returned in the . - /// The REST API result model built from . - IActionResult CreateSuccessfulActionResult(ApiController controller, TApiModel result, AuthorityResponse authorityResponse) - where TApiModel : notnull - { - var successResponse = authorityResponse.SuccessResponse; - 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}"), - }; + var apiModel = result.ToApi(); + return CreateSuccessfulActionResult(controller, apiModel, authorityResponse); } } } diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs index 1b07bbb9c5c..45dfd40b4fe 100644 --- a/src/Tgstation.Server.Host/Core/Application.cs +++ b/src/Tgstation.Server.Host/Core/Application.cs @@ -455,8 +455,8 @@ void AddTypedContext() services.AddSingleton(); // configure authorities - services.AddScoped(typeof(IRestAuthorityInvoker<>), typeof(AuthorityInvoker<>)); - services.AddScoped(typeof(IGraphQLAuthorityInvoker<>), typeof(AuthorityInvoker<>)); + services.AddScoped(typeof(IRestAuthorityInvoker<>), typeof(RestAuthorityInvoker<>)); + services.AddScoped(typeof(IGraphQLAuthorityInvoker<>), typeof(GraphQLAuthorityInvoker<>)); services.AddScoped(); services.AddScoped(); From 194f53d9d5df444caa55ef93109b42a945c28cbb Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Wed, 11 Sep 2024 23:37:13 -0400 Subject: [PATCH 017/107] Document a warning suppression --- src/Tgstation.Server.Host/GraphQL/Query.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Tgstation.Server.Host/GraphQL/Query.cs b/src/Tgstation.Server.Host/GraphQL/Query.cs index ddfa8e84775..ca9fed764f6 100644 --- a/src/Tgstation.Server.Host/GraphQL/Query.cs +++ b/src/Tgstation.Server.Host/GraphQL/Query.cs @@ -1,4 +1,4 @@ -#pragma warning disable CA1724 +#pragma warning disable CA1724 // Dumb conflict with Microsoft.EntityFrameworkCore.Query namespace Tgstation.Server.Host.GraphQL { From 12ba7d46085e359991e4eaec56e69ff04423e080 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Thu, 12 Sep 2024 15:04:52 -0400 Subject: [PATCH 018/107] Cleanup where the RemoteGateway exception is thrown --- src/Tgstation.Server.Host/GraphQL/Types/SwarmNode.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Tgstation.Server.Host/GraphQL/Types/SwarmNode.cs b/src/Tgstation.Server.Host/GraphQL/Types/SwarmNode.cs index 85582562471..f864287f3e3 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/SwarmNode.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/SwarmNode.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Options; +using Tgstation.Server.Api.Models; using Tgstation.Server.Host.Configuration; using Tgstation.Server.Host.GraphQL.Interfaces; @@ -42,7 +43,9 @@ public SwarmNode(NodeInformation? info) if (local) return new LocalGateway(); - return new RemoteGateway(); + throw new ErrorMessageException(ErrorCode.RemoteGatewaysNotImplemented); + + // return new RemoteGateway(); } } } From d94892c9c477a42ef69a6f8ae2d53ef53d99dd08 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Fri, 13 Sep 2024 00:16:46 -0400 Subject: [PATCH 019/107] `AddQueryFieldToMutationPayloads` --- src/Tgstation.Server.Host/Core/Application.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs index 45dfd40b4fe..cce8ac3769b 100644 --- a/src/Tgstation.Server.Host/Core/Application.cs +++ b/src/Tgstation.Server.Host/Core/Application.cs @@ -298,6 +298,7 @@ void ConfigureNewtonsoftJsonSerializerSettingsForApi(JsonSerializerSettings sett .AddAuthorization() .AddMutationConventions() .AddGlobalObjectIdentification() + .AddQueryFieldToMutationPayloads() .ModifyOptions(options => { options.EnableDefer = true; From 6494f3595882999c77de84854a104be30666eb71 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Fri, 13 Sep 2024 00:55:58 -0400 Subject: [PATCH 020/107] Simplify a `new` expression --- src/Tgstation.Server.Host/Swarm/SwarmService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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, From 0744c88e0e86f24650be604c12eb5f2fa6489f88 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Fri, 13 Sep 2024 16:50:19 -0400 Subject: [PATCH 021/107] Fix semver type specification --- src/Tgstation.Server.Host/GraphQL/Types/Scalars/SemverType.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Tgstation.Server.Host/GraphQL/Types/Scalars/SemverType.cs b/src/Tgstation.Server.Host/GraphQL/Types/Scalars/SemverType.cs index 76fbb55eedc..bd07aad71f5 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"); } /// From 68c6ba3109df9c5d1c1051e74528fd207ed6eef9 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Fri, 13 Sep 2024 18:15:23 -0400 Subject: [PATCH 022/107] Fix @authorize directives not showing up. Upstream documentation PR: https://github.com/ChilliCream/graphql-platform/pull/7458 --- src/Tgstation.Server.Host/Core/Application.cs | 4 +++- ...PublicAuthorizeDirectiveTypeInterceptor.cs | 23 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 src/Tgstation.Server.Host/GraphQL/Types/Interceptors/PublicAuthorizeDirectiveTypeInterceptor.cs diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs index cce8ac3769b..434ca285cff 100644 --- a/src/Tgstation.Server.Host/Core/Application.cs +++ b/src/Tgstation.Server.Host/Core/Application.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Frozen; using System.Collections.Generic; using System.Globalization; @@ -57,6 +57,7 @@ using Tgstation.Server.Host.Extensions; using Tgstation.Server.Host.GraphQL; 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; @@ -296,6 +297,7 @@ void ConfigureNewtonsoftJsonSerializerSettingsForApi(JsonSerializerSettings sett services .AddGraphQLServer() .AddAuthorization() + .TryAddTypeInterceptor() .AddMutationConventions() .AddGlobalObjectIdentification() .AddQueryFieldToMutationPayloads() diff --git a/src/Tgstation.Server.Host/GraphQL/Types/Interceptors/PublicAuthorizeDirectiveTypeInterceptor.cs b/src/Tgstation.Server.Host/GraphQL/Types/Interceptors/PublicAuthorizeDirectiveTypeInterceptor.cs new file mode 100644 index 00000000000..d4cb7407bd4 --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Types/Interceptors/PublicAuthorizeDirectiveTypeInterceptor.cs @@ -0,0 +1,23 @@ +using HotChocolate.Configuration; +using HotChocolate.Types.Descriptors.Definitions; + +namespace Tgstation.Server.Host.GraphQL.Types.Interceptors +{ + /// + /// Makes the @authorize directive public (It is internal by default). + /// + public sealed class PublicAuthorizeDirectiveTypeInterceptor : TypeInterceptor + { + /// + public override void OnBeforeRegisterDependencies(ITypeDiscoveryContext discoveryContext, DefinitionBase definition) + { + if (definition is DirectiveTypeDefinition dtd + && dtd.Name == "authorize") + { + dtd.IsPublic = true; + } + + base.OnBeforeRegisterDependencies(discoveryContext, definition); + } + } +} From 69a107e7d85cd4662e08fc26a4b149925fefddcb Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Fri, 13 Sep 2024 18:15:32 -0400 Subject: [PATCH 023/107] .csproj cleanup --- src/Tgstation.Server.Host/Tgstation.Server.Host.csproj | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj b/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj index 8317ed2e640..8c8ee498c68 100644 --- a/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj +++ b/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj @@ -186,7 +186,6 @@ - From d1434112f69cb43dc64d02d116d8a3f919582794 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Fri, 13 Sep 2024 23:06:03 -0400 Subject: [PATCH 024/107] Disable the cost analyzer --- src/Tgstation.Server.Host/Core/Application.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs index 434ca285cff..8c6d33e8b45 100644 --- a/src/Tgstation.Server.Host/Core/Application.cs +++ b/src/Tgstation.Server.Host/Core/Application.cs @@ -295,7 +295,7 @@ void ConfigureNewtonsoftJsonSerializerSettingsForApi(JsonSerializerSettings sett // configure graphql if (postSetupServices.InternalConfiguration.EnableGraphQL) services - .AddGraphQLServer() + .AddGraphQLServer(disableCostAnalyzer: true) .AddAuthorization() .TryAddTypeInterceptor() .AddMutationConventions() From 30f3bd58f2d2bd282d7a66b42a58256a71c5f13e Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Fri, 13 Sep 2024 23:57:36 -0400 Subject: [PATCH 025/107] API surface restructuring for GraphQL. Lockdown some API information that wasn't present previously. Validate nodes can be resolved --- src/Tgstation.Server.Api/Models/ErrorCode.cs | 2 +- .../Models/Internal/GatewayInformation.cs | 20 --- ...mationBase.cs => ServerInformationBase.cs} | 4 +- .../Response/ServerInformationResponse.cs | 12 +- .../GQL/Queries/ServerInformation.graphql | 38 ----- .../UnauthenticatedServerInformation.graphql | 19 +++ .../Configuration/GeneralConfiguration.cs | 10 +- src/Tgstation.Server.Host/Core/Application.cs | 9 +- .../GraphQL/Interfaces/IGateway.cs | 21 +-- .../GraphQL/Interfaces/IServerNode.cs | 21 +++ .../GraphQL/Types/GatewayInformation.cs | 153 ++++++++++++++++++ ...PublicAuthorizeDirectiveTypeInterceptor.cs | 23 --- .../GraphQL/Types/LocalGateway.cs | 34 +--- .../GraphQL/Types/NodeInformation.cs | 73 --------- .../GraphQL/Types/PermissionSet.cs | 3 - .../GraphQL/Types/RemoteGateway.cs | 21 +-- .../GraphQL/Types/ServerSwarm.cs | 48 +++--- .../GraphQL/Types/StandaloneNode.cs | 19 +++ .../GraphQL/Types/SwarmMetadata.cs | 46 ------ .../GraphQL/Types/SwarmNode.cs | 64 +++++++- .../Security/TgsGraphQLAuthorizeAttribute.cs | 118 ++++++++++++++ .../Live/TestLiveServer.cs | 19 +-- 22 files changed, 447 insertions(+), 330 deletions(-) delete mode 100644 src/Tgstation.Server.Api/Models/Internal/GatewayInformation.cs rename src/Tgstation.Server.Api/Models/Internal/{GatewayInformationBase.cs => ServerInformationBase.cs} (88%) delete mode 100644 src/Tgstation.Server.Client.GraphQL/GQL/Queries/ServerInformation.graphql create mode 100644 src/Tgstation.Server.Client.GraphQL/GQL/Queries/UnauthenticatedServerInformation.graphql create mode 100644 src/Tgstation.Server.Host/GraphQL/Interfaces/IServerNode.cs create mode 100644 src/Tgstation.Server.Host/GraphQL/Types/GatewayInformation.cs delete mode 100644 src/Tgstation.Server.Host/GraphQL/Types/Interceptors/PublicAuthorizeDirectiveTypeInterceptor.cs delete mode 100644 src/Tgstation.Server.Host/GraphQL/Types/NodeInformation.cs create mode 100644 src/Tgstation.Server.Host/GraphQL/Types/StandaloneNode.cs delete mode 100644 src/Tgstation.Server.Host/GraphQL/Types/SwarmMetadata.cs create mode 100644 src/Tgstation.Server.Host/Security/TgsGraphQLAuthorizeAttribute.cs diff --git a/src/Tgstation.Server.Api/Models/ErrorCode.cs b/src/Tgstation.Server.Api/Models/ErrorCode.cs index 7fe2f24992f..37a61e55fbb 100644 --- a/src/Tgstation.Server.Api/Models/ErrorCode.cs +++ b/src/Tgstation.Server.Api/Models/ErrorCode.cs @@ -461,7 +461,7 @@ public enum ErrorCode : uint RepoTestMergeConflict, /// - /// Attempted to create an instance outside of the . + /// Attempted to create an instance outside of the . /// [Description("The new instance's path is not under a white-listed path.")] InstanceNotAtWhitelistedPath, diff --git a/src/Tgstation.Server.Api/Models/Internal/GatewayInformation.cs b/src/Tgstation.Server.Api/Models/Internal/GatewayInformation.cs deleted file mode 100644 index 48939aba6fd..00000000000 --- a/src/Tgstation.Server.Api/Models/Internal/GatewayInformation.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 GatewayInformation : GatewayInformationBase - { - /// - /// 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/GatewayInformationBase.cs b/src/Tgstation.Server.Api/Models/Internal/ServerInformationBase.cs similarity index 88% rename from src/Tgstation.Server.Api/Models/Internal/GatewayInformationBase.cs rename to src/Tgstation.Server.Api/Models/Internal/ServerInformationBase.cs index f1f53b26bb5..15fafe90b1d 100644 --- a/src/Tgstation.Server.Api/Models/Internal/GatewayInformationBase.cs +++ b/src/Tgstation.Server.Api/Models/Internal/ServerInformationBase.cs @@ -5,7 +5,7 @@ namespace Tgstation.Server.Api.Models.Internal /// /// Base class for . /// - public abstract class GatewayInformationBase + public abstract class ServerInformationBase { /// /// Minimum length of database user passwords. @@ -31,6 +31,6 @@ public abstract class GatewayInformationBase /// 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/Response/ServerInformationResponse.cs b/src/Tgstation.Server.Api/Models/Response/ServerInformationResponse.cs index a912fa127e5..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.GatewayInformation + public sealed class ServerInformationResponse : Internal.ServerInformationBase { /// /// The version of the host. @@ -23,6 +23,16 @@ public sealed class ServerInformationResponse : Internal.GatewayInformation /// 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.Client.GraphQL/GQL/Queries/ServerInformation.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Queries/ServerInformation.graphql deleted file mode 100644 index 5e04dc4a667..00000000000 --- a/src/Tgstation.Server.Client.GraphQL/GQL/Queries/ServerInformation.graphql +++ /dev/null @@ -1,38 +0,0 @@ -query ServerInformation { - swarm { - metadata { - apiVersion - dmApiVersion - updateInProgress - version - } - currentNode { - gateway { - information { - instanceLimit - minimumPasswordLength - userGroupLimit - userLimit - validInstancePaths - windowsHost - oAuthProviderInfos { - value { - clientId - redirectUri - serverUrl - } - key - } - } - } - } - nodes { - info { - address - controller - identifier - publicAddress - } - } - } -} 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.Host/Configuration/GeneralConfiguration.cs b/src/Tgstation.Server.Host/Configuration/GeneralConfiguration.cs index 3a5ccd8ca2f..1790316e370 100644 --- a/src/Tgstation.Server.Host/Configuration/GeneralConfiguration.cs +++ b/src/Tgstation.Server.Host/Configuration/GeneralConfiguration.cs @@ -16,7 +16,7 @@ namespace Tgstation.Server.Host.Configuration /// /// General configuration options. /// - public sealed class GeneralConfiguration : GatewayInformationBase + public sealed class GeneralConfiguration : ServerInformationBase { /// /// The key for the the resides in. @@ -29,22 +29,22 @@ public sealed class GeneralConfiguration : GatewayInformationBase public const ushort DefaultApiPort = 5000; /// - /// The default value for . + /// The default value for . /// const uint DefaultMinimumPasswordLength = 15; /// - /// The default value for . + /// The default value for . /// const uint DefaultInstanceLimit = 10; /// - /// The default value for . + /// The default value for . /// const uint DefaultUserLimit = 100; /// - /// The default value for . + /// The default value for . /// const uint DefaultUserGroupLimit = 25; diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs index 8c6d33e8b45..de1599b484c 100644 --- a/src/Tgstation.Server.Host/Core/Application.cs +++ b/src/Tgstation.Server.Host/Core/Application.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Frozen; using System.Collections.Generic; using System.Globalization; @@ -57,7 +57,6 @@ using Tgstation.Server.Host.Extensions; using Tgstation.Server.Host.GraphQL; 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; @@ -297,7 +296,10 @@ void ConfigureNewtonsoftJsonSerializerSettingsForApi(JsonSerializerSettings sett services .AddGraphQLServer(disableCostAnalyzer: true) .AddAuthorization() - .TryAddTypeInterceptor() + .ModifyOptions(options => + { + options.EnsureAllNodesCanBeResolved = true; + }) .AddMutationConventions() .AddGlobalObjectIdentification() .AddQueryFieldToMutationPayloads() @@ -314,6 +316,7 @@ void ConfigureNewtonsoftJsonSerializerSettingsForApi(JsonSerializerSettings sett .AddSorting() .AddHostTypes() .AddErrorFilter() + .AddType() .AddType() .AddType() .AddType() diff --git a/src/Tgstation.Server.Host/GraphQL/Interfaces/IGateway.cs b/src/Tgstation.Server.Host/GraphQL/Interfaces/IGateway.cs index e8846abbea4..56722119d2d 100644 --- a/src/Tgstation.Server.Host/GraphQL/Interfaces/IGateway.cs +++ b/src/Tgstation.Server.Host/GraphQL/Interfaces/IGateway.cs @@ -1,12 +1,4 @@ -using HotChocolate; -using HotChocolate.Authorization; - -using Microsoft.Extensions.Options; -using Tgstation.Server.Api.Models.Internal; -using Tgstation.Server.Host.Configuration; -using Tgstation.Server.Host.GraphQL.Types; -using Tgstation.Server.Host.Security.OAuth; -using Tgstation.Server.Host.System; +using Tgstation.Server.Host.GraphQL.Types; namespace Tgstation.Server.Host.GraphQL.Interfaces { @@ -18,14 +10,7 @@ public interface IGateway /// /// Gets . /// - /// The to use. - /// The to use. - /// The containing the to use. - /// A new . - [AllowAnonymous] - GatewayInformation Information( - [Service] IOAuthProviders oAuthProviders, - [Service] IPlatformIdentifier platformIdentifier, - [Service] IOptionsSnapshot generalConfigurationOptions); + /// The for the . + GatewayInformation Information(); } } 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/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/Interceptors/PublicAuthorizeDirectiveTypeInterceptor.cs b/src/Tgstation.Server.Host/GraphQL/Types/Interceptors/PublicAuthorizeDirectiveTypeInterceptor.cs deleted file mode 100644 index d4cb7407bd4..00000000000 --- a/src/Tgstation.Server.Host/GraphQL/Types/Interceptors/PublicAuthorizeDirectiveTypeInterceptor.cs +++ /dev/null @@ -1,23 +0,0 @@ -using HotChocolate.Configuration; -using HotChocolate.Types.Descriptors.Definitions; - -namespace Tgstation.Server.Host.GraphQL.Types.Interceptors -{ - /// - /// Makes the @authorize directive public (It is internal by default). - /// - public sealed class PublicAuthorizeDirectiveTypeInterceptor : TypeInterceptor - { - /// - public override void OnBeforeRegisterDependencies(ITypeDiscoveryContext discoveryContext, DefinitionBase definition) - { - if (definition is DirectiveTypeDefinition dtd - && dtd.Name == "authorize") - { - dtd.IsPublic = true; - } - - base.OnBeforeRegisterDependencies(discoveryContext, definition); - } - } -} diff --git a/src/Tgstation.Server.Host/GraphQL/Types/LocalGateway.cs b/src/Tgstation.Server.Host/GraphQL/Types/LocalGateway.cs index 107df473e15..09d21f7f2dc 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/LocalGateway.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/LocalGateway.cs @@ -1,14 +1,4 @@ -using System; - -using HotChocolate; - -using Microsoft.Extensions.Options; - -using Tgstation.Server.Api.Models.Internal; -using Tgstation.Server.Host.Configuration; -using Tgstation.Server.Host.GraphQL.Interfaces; -using Tgstation.Server.Host.Security.OAuth; -using Tgstation.Server.Host.System; +using Tgstation.Server.Host.GraphQL.Interfaces; namespace Tgstation.Server.Host.GraphQL.Types { @@ -18,26 +8,6 @@ namespace Tgstation.Server.Host.GraphQL.Types public sealed class LocalGateway : IGateway { /// - public GatewayInformation 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 GatewayInformation - { - MinimumPasswordLength = generalConfiguration.MinimumPasswordLength, - InstanceLimit = generalConfiguration.InstanceLimit, - UserLimit = generalConfiguration.UserLimit, - UserGroupLimit = generalConfiguration.UserGroupLimit, - ValidInstancePaths = generalConfiguration.ValidInstancePaths, - WindowsHost = platformIdentifier.IsWindows, - OAuthProviderInfos = oAuthProviders.ProviderInfos(), - }; - } + public GatewayInformation Information() => new(); } } diff --git a/src/Tgstation.Server.Host/GraphQL/Types/NodeInformation.cs b/src/Tgstation.Server.Host/GraphQL/Types/NodeInformation.cs deleted file mode 100644 index 42af3c81769..00000000000 --- a/src/Tgstation.Server.Host/GraphQL/Types/NodeInformation.cs +++ /dev/null @@ -1,73 +0,0 @@ -using System; -using System.Linq; - -using HotChocolate; -using HotChocolate.Types.Relay; - -using Tgstation.Server.Host.Swarm; - -namespace Tgstation.Server.Host.GraphQL.Types -{ - /// - /// Represent a server in the TGS server swarm. - /// - [Node] - public sealed class NodeInformation - { - /// - /// Node resolver for s. - /// - /// The to lookup. - /// The to use. - /// The queried , if present. - public NodeInformation? GetNodeInformation( - string identifier, - [Service] ISwarmService swarmService) - { - ArgumentNullException.ThrowIfNull(identifier); - ArgumentNullException.ThrowIfNull(swarmService); - - var node = swarmService.GetSwarmServers() - ?.FirstOrDefault(node => node.Identifier == identifier); - if (node == null) - return null; - - return new NodeInformation(node); - } - - /// - /// The swarm server ID. - /// - [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; } - - /// - /// Initializes a new instance of the class. - /// - /// The to build from. - public NodeInformation(Api.Models.Internal.SwarmServerInformation swarmServerInformation) - { - ArgumentNullException.ThrowIfNull(swarmServerInformation); - - Identifier = swarmServerInformation.Identifier!; - Address = swarmServerInformation.Address!; - PublicAddress = swarmServerInformation.PublicAddress; - Controller = swarmServerInformation.Controller; - } - } -} diff --git a/src/Tgstation.Server.Host/GraphQL/Types/PermissionSet.cs b/src/Tgstation.Server.Host/GraphQL/Types/PermissionSet.cs index 9f0734f3169..ba0c070582c 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/PermissionSet.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/PermissionSet.cs @@ -1,7 +1,5 @@ using System.Diagnostics.CodeAnalysis; -using HotChocolate.Types.Relay; - using Tgstation.Server.Api.Rights; namespace Tgstation.Server.Host.GraphQL.Types @@ -9,7 +7,6 @@ namespace Tgstation.Server.Host.GraphQL.Types /// /// Represents a set of permissions for the server. /// - [Node] public sealed class PermissionSet : Entity { /// diff --git a/src/Tgstation.Server.Host/GraphQL/Types/RemoteGateway.cs b/src/Tgstation.Server.Host/GraphQL/Types/RemoteGateway.cs index 5c3305e56dc..1a17b636e9f 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/RemoteGateway.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/RemoteGateway.cs @@ -1,15 +1,6 @@ using System; -using HotChocolate; - -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.OAuth; -using Tgstation.Server.Host.System; namespace Tgstation.Server.Host.GraphQL.Types { @@ -20,16 +11,6 @@ namespace Tgstation.Server.Host.GraphQL.Types public sealed class RemoteGateway : IGateway { /// - public GatewayInformation Information( - [Service] IOAuthProviders oAuthProviders, - [Service] IPlatformIdentifier platformIdentifier, - [Service] IOptionsSnapshot generalConfigurationOptions) - { - ArgumentNullException.ThrowIfNull(oAuthProviders); - ArgumentNullException.ThrowIfNull(platformIdentifier); - ArgumentNullException.ThrowIfNull(generalConfigurationOptions); - - throw new ErrorMessageException(ErrorCode.RemoteGatewaysNotImplemented); - } + public GatewayInformation Information() => throw new NotImplementedException(); } } diff --git a/src/Tgstation.Server.Host/GraphQL/Types/ServerSwarm.cs b/src/Tgstation.Server.Host/GraphQL/Types/ServerSwarm.cs index 1b874c13b18..286aad2adfc 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/ServerSwarm.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/ServerSwarm.cs @@ -8,8 +8,10 @@ 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 { @@ -19,56 +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 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. /// - /// The to use. /// The containing the current . - /// A new . - public SwarmNode CurrentNode( - [Service] ISwarmService swarmService, - [Service] IOptionsSnapshot swarmConfigurationOptions) + /// 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(swarmService); ArgumentNullException.ThrowIfNull(swarmConfigurationOptions); + ArgumentNullException.ThrowIfNull(swarmService); - var nodeInfos = Nodes(swarmService); - if (nodeInfos != null) - return nodeInfos.First(x => x.Info!.Identifier == swarmConfigurationOptions.Value.Identifier); + var ourIdentifier = swarmConfigurationOptions.Value.Identifier; + if (ourIdentifier == null) + return new StandaloneNode(); - return new SwarmNode(null); + return (IServerNode?)SwarmNode.GetSwarmNode(ourIdentifier, swarmService) ?? new StandaloneNode(); } /// /// Gets all servers in the swarm. /// /// The to use. - /// A of s if the local server is part of a swarm, otherwise. + /// 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()?.Select(x => new SwarmNode(new NodeInformation(x))).ToList(); + 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 index f864287f3e3..2701e828b51 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/SwarmNode.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/SwarmNode.cs @@ -1,32 +1,80 @@ 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.Swarm; namespace Tgstation.Server.Host.GraphQL.Types { /// /// Represents a node server in a swarm. /// - public sealed class SwarmNode + [Node(IdField = nameof(Identifier))] + public sealed class SwarmNode : IServerNode { /// - /// Gets the . + /// The swarm server ID. /// - public NodeInformation? Info { get; } + [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. + 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 value of . - public SwarmNode(NodeInformation? info) + /// The to use for initialization. + public SwarmNode(SwarmServerInformation? nodeInformation) { - Info = info; + ArgumentNullException.ThrowIfNull(nodeInformation); + + Identifier = nodeInformation.Identifier!; + Address = nodeInformation.Address!; + PublicAddress = nodeInformation.PublicAddress; + Controller = nodeInformation.Controller; } /// @@ -35,11 +83,11 @@ public SwarmNode(NodeInformation? info) /// The containing the current . /// A new . /// The 's . - public IGateway? Gateway([Service] IOptionsSnapshot swarmConfigurationOptions) + public IGateway Gateway([Service] IOptionsSnapshot swarmConfigurationOptions) { ArgumentNullException.ThrowIfNull(swarmConfigurationOptions); - bool local = Info == null || Info.Identifier == swarmConfigurationOptions.Value.Identifier; + bool local = Identifier == swarmConfigurationOptions.Value.Identifier; if (local) return new LocalGateway(); diff --git a/src/Tgstation.Server.Host/Security/TgsGraphQLAuthorizeAttribute.cs b/src/Tgstation.Server.Host/Security/TgsGraphQLAuthorizeAttribute.cs new file mode 100644 index 00000000000..e064440e185 --- /dev/null +++ b/src/Tgstation.Server.Host/Security/TgsGraphQLAuthorizeAttribute.cs @@ -0,0 +1,118 @@ +using System; + +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() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The required. + public TgsGraphQLAuthorizeAttribute(AdministrationRights requiredRights) + { + Roles = RightsHelper.RoleNames(requiredRights).Split(','); + RightsType = Api.Rights.RightsType.Administration; + } + + /// + /// Initializes a new instance of the class. + /// + /// The required. + public TgsGraphQLAuthorizeAttribute(InstanceManagerRights requiredRights) + { + Roles = RightsHelper.RoleNames(requiredRights).Split(','); + RightsType = Api.Rights.RightsType.InstanceManager; + } + + /// + /// Initializes a new instance of the class. + /// + /// The required. + public TgsGraphQLAuthorizeAttribute(RepositoryRights requiredRights) + { + Roles = RightsHelper.RoleNames(requiredRights).Split(','); + RightsType = Api.Rights.RightsType.Repository; + } + + /// + /// Initializes a new instance of the class. + /// + /// The required. + public TgsGraphQLAuthorizeAttribute(EngineRights requiredRights) + { + Roles = RightsHelper.RoleNames(requiredRights).Split(','); + RightsType = Api.Rights.RightsType.Engine; + } + + /// + /// Initializes a new instance of the class. + /// + /// The required. + public TgsGraphQLAuthorizeAttribute(DreamMakerRights requiredRights) + { + Roles = RightsHelper.RoleNames(requiredRights).Split(','); + RightsType = Api.Rights.RightsType.DreamMaker; + } + + /// + /// Initializes a new instance of the class. + /// + /// The required. + public TgsGraphQLAuthorizeAttribute(DreamDaemonRights requiredRights) + { + Roles = RightsHelper.RoleNames(requiredRights).Split(','); + RightsType = Api.Rights.RightsType.DreamDaemon; + } + + /// + /// Initializes a new instance of the class. + /// + /// The required. + public TgsGraphQLAuthorizeAttribute(ChatBotRights requiredRights) + { + Roles = RightsHelper.RoleNames(requiredRights).Split(','); + RightsType = Api.Rights.RightsType.ChatBots; + } + + /// + /// Initializes a new instance of the class. + /// + /// The required. + public TgsGraphQLAuthorizeAttribute(ConfigurationRights requiredRights) + { + Roles = RightsHelper.RoleNames(requiredRights).Split(','); + RightsType = Api.Rights.RightsType.Configuration; + } + + /// + /// Initializes a new instance of the class. + /// + /// The required. + public TgsGraphQLAuthorizeAttribute(InstancePermissionSetRights requiredRights) + { + Roles = RightsHelper.RoleNames(requiredRights).Split(','); + RightsType = Api.Rights.RightsType.InstancePermissionSet; + } + } +} diff --git a/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs b/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs index bc55b330772..1c17f587c09 100644 --- a/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs +++ b/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs @@ -1380,23 +1380,8 @@ async Task TestTgsInternal(bool openDreamOnly, CancellationToken hardCancellatio await multiClient.ExecuteReadOnlyConfirmEquivalence( restClient => restClient.ServerInformation(cancellationToken), - async gqlClient => (await gqlClient.ServerInformation.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.CurrentNode.Gateway.Information.InstanceLimit - && restServerInfo.UserGroupLimit == gqlServerInfo.Swarm.CurrentNode.Gateway.Information.UserGroupLimit - && restServerInfo.ValidInstancePaths.SequenceEqual(gqlServerInfo.Swarm.CurrentNode.Gateway.Information.ValidInstancePaths) - && restServerInfo.UserLimit == gqlServerInfo.Swarm.CurrentNode.Gateway.Information.UserLimit - && restServerInfo.MinimumPasswordLength == gqlServerInfo.Swarm.CurrentNode.Gateway.Information.MinimumPasswordLength - && ((object)restServerInfo.SwarmServers == gqlServerInfo.Swarm.Nodes - || restServerInfo.SwarmServers.SequenceEqual(gqlServerInfo.Swarm.Nodes.Select(x => new SwarmServerResponse(new Api.Models.Internal.SwarmServerInformation - { - Address = x.Info.Address, - PublicAddress = x.Info.PublicAddress, - Controller = x.Info.Controller, - Identifier = x.Info.Identifier, - })))) + async gqlClient => (await gqlClient.UnauthenticatedServerInformation.ExecuteAsync(cancellationToken)).Data, + (restServerInfo, gqlServerInfo) => restServerInfo.ApiVersion.Major == gqlServerInfo.Swarm.CurrentNode.Gateway.Information.MajorApiVersion && (restServerInfo.OAuthProviderInfos == gqlServerInfo.Swarm.CurrentNode.Gateway.Information.OAuthProviderInfos || restServerInfo.OAuthProviderInfos.All(kvp => { From dcedf3a24a459c9cb64da317d0580d7962dfbb46 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sat, 14 Sep 2024 00:57:59 -0400 Subject: [PATCH 026/107] Implement OAuthConnections loading for GraphQL --- .../Authority/IUserAuthority.cs | 8 +++ .../Authority/UserAuthority.cs | 52 ++++++++++++++++--- .../GraphQL/Types/User.cs | 15 ++++-- .../Models/OAuthConnection.cs | 5 ++ 4 files changed, 70 insertions(+), 10 deletions(-) diff --git a/src/Tgstation.Server.Host/Authority/IUserAuthority.cs b/src/Tgstation.Server.Host/Authority/IUserAuthority.cs index dfc9cdda9d7..0d99ca88f28 100644 --- a/src/Tgstation.Server.Host/Authority/IUserAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/IUserAuthority.cs @@ -34,6 +34,14 @@ public interface IUserAuthority : IAuthority [TgsAuthorize(AdministrationRights.ReadUsers)] public 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 . + public ValueTask> OAuthConnections(long userId, CancellationToken cancellationToken); + /// /// Gets all registered s. /// diff --git a/src/Tgstation.Server.Host/Authority/UserAuthority.cs b/src/Tgstation.Server.Host/Authority/UserAuthority.cs index ddb20d41086..5a6094b0ecd 100644 --- a/src/Tgstation.Server.Host/Authority/UserAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/UserAuthority.cs @@ -27,7 +27,12 @@ sealed class UserAuthority : AuthorityBase, IUserAuthority /// /// The for the . /// - readonly IUsersDataLoader dataLoader; + readonly IUsersDataLoader usersDataLoader; + + /// + /// The for the . + /// + readonly IOAuthConnectionsDataLoader oAuthConnectionsDataLoader; /// /// The for the . @@ -35,7 +40,7 @@ sealed class UserAuthority : AuthorityBase, IUserAuthority readonly IAuthenticationContext authenticationContext; /// - /// Implements the . + /// Implements the . /// /// The of s to load. /// The to load from. @@ -57,22 +62,52 @@ public static async ValueTask> GetUsers( .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)); + } + /// /// Initializes a new instance of the class. /// /// The to use. /// The value of . - /// The value of . + /// The value of . + /// The value of . /// The value of . public UserAuthority( ILogger logger, IDatabaseContext databaseContext, - IUsersDataLoader dataLoader, + IUsersDataLoader usersDataLoader, + IOAuthConnectionsDataLoader oAuthConnectionsDataLoader, IAuthenticationContext authenticationContext) : base(logger) { this.databaseContext = databaseContext ?? throw new ArgumentNullException(nameof(databaseContext)); - this.dataLoader = dataLoader ?? throw new ArgumentNullException(nameof(dataLoader)); + this.usersDataLoader = usersDataLoader ?? throw new ArgumentNullException(nameof(usersDataLoader)); + this.oAuthConnectionsDataLoader = oAuthConnectionsDataLoader ?? throw new ArgumentNullException(nameof(oAuthConnectionsDataLoader)); this.authenticationContext = authenticationContext ?? throw new ArgumentNullException(nameof(authenticationContext)); } @@ -93,7 +128,7 @@ public async ValueTask> GetId(long id, bool includeJoins cancellationToken); } else - user = await dataLoader.LoadAsync(id, cancellationToken); + user = await usersDataLoader.LoadAsync(id, cancellationToken); if (user == default) return NotFound(); @@ -108,6 +143,11 @@ public async ValueTask> GetId(long id, bool includeJoins public IQueryable Queryable(bool includeJoins) => Queryable(includeJoins, false); + /// + public async ValueTask> OAuthConnections(long userId, CancellationToken cancellationToken) + => new AuthorityResponse( + await oAuthConnectionsDataLoader.LoadRequiredAsync(userId, cancellationToken)); + /// /// Gets all registered s. /// diff --git a/src/Tgstation.Server.Host/GraphQL/Types/User.cs b/src/Tgstation.Server.Host/GraphQL/Types/User.cs index ab5df09a79c..baedcfaa525 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/User.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/User.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -94,9 +93,17 @@ public sealed class User : NamedEntity, IUserName /// /// 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 . + /// The for the operation. + /// A resulting in a new of s for the if OAuth is configured. + public async ValueTask OAuthConnections( + [Service] IGraphQLAuthorityInvoker userAuthority, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(userAuthority); + return (await userAuthority.Invoke( + authority => authority.OAuthConnections(Id, cancellationToken)))!; + } /// /// The directly associated with the , if any. diff --git a/src/Tgstation.Server.Host/Models/OAuthConnection.cs b/src/Tgstation.Server.Host/Models/OAuthConnection.cs index 64b1702d8c3..7c871b672a7 100644 --- a/src/Tgstation.Server.Host/Models/OAuthConnection.cs +++ b/src/Tgstation.Server.Host/Models/OAuthConnection.cs @@ -8,6 +8,11 @@ public sealed class OAuthConnection : Api.Models.OAuthConnection, ILegacyApiTran /// public long Id { get; set; } + /// + /// The of . + /// + public long UserId { get; set; } + /// /// The owning . /// From 886a166e561ccd83949f8a730767f7196a7b32a9 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sat, 14 Sep 2024 08:55:49 -0400 Subject: [PATCH 027/107] Give SwarmNode a specific `NodeId` field and unmangle `Identifier` --- src/Tgstation.Server.Host/GraphQL/Types/SwarmNode.cs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/Tgstation.Server.Host/GraphQL/Types/SwarmNode.cs b/src/Tgstation.Server.Host/GraphQL/Types/SwarmNode.cs index 2701e828b51..21979f2b360 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/SwarmNode.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/SwarmNode.cs @@ -17,13 +17,18 @@ namespace Tgstation.Server.Host.GraphQL.Types /// /// Represents a node server in a swarm. /// - [Node(IdField = nameof(Identifier))] + [Node] public sealed class SwarmNode : IServerNode { /// - /// The swarm server ID. + /// The node ID. /// [ID] + public string NodeId => Identifier; + + /// + /// The swarm server ID. + /// public string Identifier { get; } /// From 614a2998204d140acb8558043855c9e74e188589 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sat, 14 Sep 2024 09:07:38 -0400 Subject: [PATCH 028/107] Cleanup `Program`. Add bypass for update path/host watchdog args --- src/Tgstation.Server.Host/Program.cs | 53 +++++++++++++++++----------- 1 file changed, 33 insertions(+), 20 deletions(-) diff --git a/src/Tgstation.Server.Host/Program.cs b/src/Tgstation.Server.Host/Program.cs index b23633b7dff..b3ce0a6330a 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.ToArray(); + return potentialUpdatePath; } /// From 0f5841a2fb45f2d3bbcbd3bf583137ba7cbd41f5 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sat, 14 Sep 2024 09:13:24 -0400 Subject: [PATCH 029/107] Fix RawRequestTests assertions --- tests/Tgstation.Server.Tests/Live/RawRequestTests.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/Tgstation.Server.Tests/Live/RawRequestTests.cs b/tests/Tgstation.Server.Tests/Live/RawRequestTests.cs index d8e6dbb890e..7b05a0d379c 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; @@ -469,7 +469,9 @@ await gqlClient.RunQuery(async client => result = await client.Login.ExecuteAsync(cancellationToken); }); - Assert.IsNull(result.Data.Login); + Assert.IsNotNull(result.Data); + Assert.IsNull(result.Data.Login.String); + Assert.IsNotNull(result.Data.Login.Errors); Assert.AreEqual(1, result.Data.Login.Errors.Count); var castResult = result.Data.Login.Errors.First() is ILogin_Login_Errors_ErrorMessageError loginError; Assert.IsTrue(castResult); From 0c34ff1dbcd8abcc732e2a4b6b1a9abe4cea8bfa Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sat, 14 Sep 2024 09:13:30 -0400 Subject: [PATCH 030/107] Apply code suggestions --- src/Tgstation.Server.Host/Database/DatabaseContext.cs | 4 ++-- src/Tgstation.Server.Host/Program.cs | 2 +- tests/Tgstation.Server.Tests/Live/RawRequestTests.cs | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Tgstation.Server.Host/Database/DatabaseContext.cs b/src/Tgstation.Server.Host/Database/DatabaseContext.cs index d79bb709f93..9a44a0599e8 100644 --- a/src/Tgstation.Server.Host/Database/DatabaseContext.cs +++ b/src/Tgstation.Server.Host/Database/DatabaseContext.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Globalization; using System.Linq; using System.Reflection; @@ -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/Program.cs b/src/Tgstation.Server.Host/Program.cs index b3ce0a6330a..96f1e4b50c8 100644 --- a/src/Tgstation.Server.Host/Program.cs +++ b/src/Tgstation.Server.Host/Program.cs @@ -80,7 +80,7 @@ public static async Task Main(string[] args) if (listArgs.Remove("--attach-debugger")) Debugger.Launch(); - args = listArgs.ToArray(); + args = [.. listArgs]; return potentialUpdatePath; } diff --git a/tests/Tgstation.Server.Tests/Live/RawRequestTests.cs b/tests/Tgstation.Server.Tests/Live/RawRequestTests.cs index 7b05a0d379c..5c320270e18 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; @@ -473,9 +473,9 @@ await gqlClient.RunQuery(async client => Assert.IsNull(result.Data.Login.String); Assert.IsNotNull(result.Data.Login.Errors); Assert.AreEqual(1, result.Data.Login.Errors.Count); - var castResult = result.Data.Login.Errors.First() is ILogin_Login_Errors_ErrorMessageError loginError; + 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.First(); + 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); From a8d8ee8c0447df2bb1ac4a838b838d94b52775f9 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sat, 14 Sep 2024 10:53:54 -0400 Subject: [PATCH 031/107] Node auth cleanup + some user group implementation --- .../Authority/Core/AuthorityBase.cs | 12 ++- .../Authority/IUserGroupAuthority.cs | 25 ++++++ .../Authority/LoginAuthority.cs | 6 +- .../Authority/UserAuthority.cs | 26 +++---- .../Authority/UserGroupAuthority.cs | 78 +++++++++++++++++++ .../Controllers/UserGroupController.cs | 19 +++-- src/Tgstation.Server.Host/Core/Application.cs | 1 + .../GraphQL/Types/SwarmNode.cs | 2 + .../GraphQL/Types/User.cs | 17 +++- .../GraphQL/Types/UserGroup.cs | 36 +++++---- .../GraphQL/Types/UserName.cs | 2 + .../UserGroupGraphQLTransformer.cs | 20 +++++ src/Tgstation.Server.Host/Models/UserGroup.cs | 3 +- 13 files changed, 206 insertions(+), 41 deletions(-) create mode 100644 src/Tgstation.Server.Host/Authority/IUserGroupAuthority.cs create mode 100644 src/Tgstation.Server.Host/Authority/UserGroupAuthority.cs create mode 100644 src/Tgstation.Server.Host/Models/Transformers/UserGroupGraphQLTransformer.cs diff --git a/src/Tgstation.Server.Host/Authority/Core/AuthorityBase.cs b/src/Tgstation.Server.Host/Authority/Core/AuthorityBase.cs index 45121dd8ffc..eff31f3c41b 100644 --- a/src/Tgstation.Server.Host/Authority/Core/AuthorityBase.cs +++ b/src/Tgstation.Server.Host/Authority/Core/AuthorityBase.cs @@ -7,6 +7,7 @@ using Tgstation.Server.Api.Models; using Tgstation.Server.Api.Models.Response; +using Tgstation.Server.Host.Security; namespace Tgstation.Server.Host.Authority.Core { @@ -15,6 +16,11 @@ namespace Tgstation.Server.Host.Authority.Core /// abstract class AuthorityBase : IAuthority { + /// + /// Gets the for the . + /// + protected IAuthenticationContext AuthenticationContext { get; } + /// /// Gets the for the . /// @@ -64,9 +70,13 @@ protected static AuthorityResponse NotFound() /// /// Initializes a new instance of the class. /// + /// The value of . /// The value of . - protected AuthorityBase(ILogger logger) + protected AuthorityBase( + IAuthenticationContext authenticationContext, + ILogger logger) { + AuthenticationContext = authenticationContext ?? throw new ArgumentNullException(nameof(authenticationContext)); Logger = logger ?? throw new ArgumentNullException(nameof(logger)); } diff --git a/src/Tgstation.Server.Host/Authority/IUserGroupAuthority.cs b/src/Tgstation.Server.Host/Authority/IUserGroupAuthority.cs new file mode 100644 index 00000000000..3afd0c57772 --- /dev/null +++ b/src/Tgstation.Server.Host/Authority/IUserGroupAuthority.cs @@ -0,0 +1,25 @@ +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 with a given . + /// + /// The of the . + /// The for the operation. + /// A resulting in a . + [TgsAuthorize(AdministrationRights.ReadUsers)] + public ValueTask> GetId(long id, CancellationToken cancellationToken); + } +} diff --git a/src/Tgstation.Server.Host/Authority/LoginAuthority.cs b/src/Tgstation.Server.Host/Authority/LoginAuthority.cs index 57f331a21cc..d32e0664781 100644 --- a/src/Tgstation.Server.Host/Authority/LoginAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/LoginAuthority.cs @@ -18,7 +18,7 @@ namespace Tgstation.Server.Host.Authority { - /// + /// sealed class LoginAuthority : AuthorityBase, ILoginAuthority { /// @@ -101,6 +101,7 @@ static AuthorityResponse GenerateHeadersExceptionResponse(Headers /// /// Initializes a new instance of the class. /// + /// The to use. /// The to use. /// The value of . /// The value of . @@ -110,6 +111,7 @@ static AuthorityResponse GenerateHeadersExceptionResponse(Headers /// The value of . /// The value of . public LoginAuthority( + IAuthenticationContext authenticationContext, ILogger logger, IApiHeadersProvider apiHeadersProvider, ISystemIdentityFactory systemIdentityFactory, @@ -118,7 +120,7 @@ public LoginAuthority( ITokenFactory tokenFactory, ICryptographySuite cryptographySuite, IIdentityCache identityCache) - : base(logger) + : base(authenticationContext, logger) { this.apiHeadersProvider = apiHeadersProvider ?? throw new ArgumentNullException(nameof(apiHeadersProvider)); this.systemIdentityFactory = systemIdentityFactory ?? throw new ArgumentNullException(nameof(systemIdentityFactory)); diff --git a/src/Tgstation.Server.Host/Authority/UserAuthority.cs b/src/Tgstation.Server.Host/Authority/UserAuthority.cs index 5a6094b0ecd..a46e244ce24 100644 --- a/src/Tgstation.Server.Host/Authority/UserAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/UserAuthority.cs @@ -9,6 +9,7 @@ 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; @@ -16,7 +17,7 @@ namespace Tgstation.Server.Host.Authority { - /// + /// sealed class UserAuthority : AuthorityBase, IUserAuthority { /// @@ -34,11 +35,6 @@ sealed class UserAuthority : AuthorityBase, IUserAuthority /// readonly IOAuthConnectionsDataLoader oAuthConnectionsDataLoader; - /// - /// The for the . - /// - readonly IAuthenticationContext authenticationContext; - /// /// Implements the . /// @@ -47,7 +43,7 @@ sealed class UserAuthority : AuthorityBase, IUserAuthority /// The for the operation. /// A resulting in a of the requested s. [DataLoader] - public static async ValueTask> GetUsers( + public static Task> GetUsers( IReadOnlyList ids, IDatabaseContext databaseContext, CancellationToken cancellationToken) @@ -55,7 +51,7 @@ public static async ValueTask> GetUsers( ArgumentNullException.ThrowIfNull(ids); ArgumentNullException.ThrowIfNull(databaseContext); - return await databaseContext + return databaseContext .Users .AsQueryable() .Where(x => ids.Contains(x.Id!.Value)) @@ -96,28 +92,30 @@ public static async ValueTask> GetUsers( /// The value of . /// The value of . /// The value of . - /// The value of . + /// The value of . public UserAuthority( + IAuthenticationContext authenticationContext, ILogger logger, IDatabaseContext databaseContext, IUsersDataLoader usersDataLoader, - IOAuthConnectionsDataLoader oAuthConnectionsDataLoader, - IAuthenticationContext authenticationContext) - : base(logger) + IOAuthConnectionsDataLoader oAuthConnectionsDataLoader) + : base(authenticationContext, logger) { this.databaseContext = databaseContext ?? throw new ArgumentNullException(nameof(databaseContext)); this.usersDataLoader = usersDataLoader ?? throw new ArgumentNullException(nameof(usersDataLoader)); this.oAuthConnectionsDataLoader = oAuthConnectionsDataLoader ?? throw new ArgumentNullException(nameof(oAuthConnectionsDataLoader)); - this.authenticationContext = authenticationContext ?? throw new ArgumentNullException(nameof(authenticationContext)); } /// public ValueTask> Read(CancellationToken cancellationToken) - => ValueTask.FromResult(new AuthorityResponse(authenticationContext.User)); + => 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) { diff --git a/src/Tgstation.Server.Host/Authority/UserGroupAuthority.cs b/src/Tgstation.Server.Host/Authority/UserGroupAuthority.cs new file mode 100644 index 00000000000..c15b7c4c2ee --- /dev/null +++ b/src/Tgstation.Server.Host/Authority/UserGroupAuthority.cs @@ -0,0 +1,78 @@ +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 UserGroupAuthority : AuthorityBase, IUserGroupAuthority + { + /// + /// The for the . + /// + readonly IUserGroupsDataLoader userGroupsDataLoader; + + /// + /// 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 value of . + public UserGroupAuthority( + IAuthenticationContext authenticationContext, + ILogger logger, + IUserGroupsDataLoader userGroupsDataLoader) + : base(authenticationContext, logger) + { + this.userGroupsDataLoader = userGroupsDataLoader ?? throw new ArgumentNullException(nameof(userGroupsDataLoader)); + } + + /// + public async ValueTask> GetId(long id, CancellationToken cancellationToken) + { + if (id != AuthenticationContext.User.GroupId && !((AdministrationRights)AuthenticationContext.GetRight(RightsType.Administration)).HasFlag(AdministrationRights.ReadUsers)) + return Forbid(); + + var userGroup = await userGroupsDataLoader.LoadAsync(id, cancellationToken); + if (userGroup == null) + return NotFound(); + + return new AuthorityResponse(userGroup); + } + } +} diff --git a/src/Tgstation.Server.Host/Controllers/UserGroupController.cs b/src/Tgstation.Server.Host/Controllers/UserGroupController.cs index fce1c12e1d5..ae3dc2b0a87 100644 --- a/src/Tgstation.Server.Host/Controllers/UserGroupController.cs +++ b/src/Tgstation.Server.Host/Controllers/UserGroupController.cs @@ -13,6 +13,7 @@ using Tgstation.Server.Api.Models.Request; using Tgstation.Server.Api.Models.Response; using Tgstation.Server.Api.Rights; +using Tgstation.Server.Host.Authority; using Tgstation.Server.Host.Configuration; using Tgstation.Server.Host.Controllers.Results; using Tgstation.Server.Host.Database; @@ -29,6 +30,11 @@ namespace Tgstation.Server.Host.Controllers [Route(Routes.UserGroup)] public class UserGroupController : ApiController { + /// + /// The for the . + /// + readonly IRestAuthorityInvoker userGroupAuthority; + /// /// The for the . /// @@ -39,15 +45,17 @@ public class UserGroupController : ApiController /// /// The for the . /// The for the . - /// The containing the value of . - /// The for the . /// The for the . + /// The for the . + /// The value of . + /// The containing the value of . public UserGroupController( IDatabaseContext databaseContext, IAuthenticationContext authenticationContext, - IOptions generalConfigurationOptions, + IApiHeadersProvider apiHeaders, ILogger logger, - IApiHeadersProvider apiHeaders) + IRestAuthorityInvoker userGroupAuthority, + IOptions generalConfigurationOptions) : base( databaseContext, authenticationContext, @@ -55,6 +63,7 @@ public UserGroupController( logger, true) { + this.userGroupAuthority = userGroupAuthority ?? throw new ArgumentNullException(nameof(userGroupAuthority)); generalConfiguration = generalConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(generalConfigurationOptions)); } @@ -155,7 +164,7 @@ 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) diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs index de1599b484c..cb5b2bec4a7 100644 --- a/src/Tgstation.Server.Host/Core/Application.cs +++ b/src/Tgstation.Server.Host/Core/Application.cs @@ -465,6 +465,7 @@ void AddTypedContext() services.AddScoped(typeof(IGraphQLAuthorityInvoker<>), typeof(GraphQLAuthorityInvoker<>)); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // configure misc services services.AddSingleton(); diff --git a/src/Tgstation.Server.Host/GraphQL/Types/SwarmNode.cs b/src/Tgstation.Server.Host/GraphQL/Types/SwarmNode.cs index 21979f2b360..520f8bd6c7c 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/SwarmNode.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/SwarmNode.cs @@ -10,6 +10,7 @@ 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 @@ -52,6 +53,7 @@ public sealed class SwarmNode : IServerNode /// The . /// The to load from. /// A new with the matching if found, otherwise. + [TgsGraphQLAuthorize] public static SwarmNode? GetSwarmNode( string identifier, [Service] ISwarmService swarmService) diff --git a/src/Tgstation.Server.Host/GraphQL/Types/User.cs b/src/Tgstation.Server.Host/GraphQL/Types/User.cs index baedcfaa525..0a9aa5c6922 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/User.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/User.cs @@ -8,6 +8,7 @@ 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 { @@ -56,6 +57,7 @@ public sealed class User : NamedEntity, IUserName /// The . /// The for the operation. /// A resulting in the queried , if present. + [TgsGraphQLAuthorize] public static ValueTask GetUser( long id, [Service] IGraphQLAuthorityInvoker userAuthority, @@ -115,8 +117,19 @@ public async ValueTask OAuthConnections( /// /// The asociated with the user, if any. /// + /// 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, cancellationToken)); + } } } diff --git a/src/Tgstation.Server.Host/GraphQL/Types/UserGroup.cs b/src/Tgstation.Server.Host/GraphQL/Types/UserGroup.cs index 7a647e0b883..1d457be2a8c 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/UserGroup.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/UserGroup.cs @@ -1,36 +1,40 @@ using System; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; +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.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 . - /// - readonly long permissionSetId; - - /// - /// Initializes a new instance of the class. + /// Node resolver for s. /// - /// The . - /// The . - /// The value of . - [SetsRequiredMembers] - public UserGroup( + /// The to lookup. + /// 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.InvokeTransformable( + authority => authority.GetId(id, cancellationToken)); } /// diff --git a/src/Tgstation.Server.Host/GraphQL/Types/UserName.cs b/src/Tgstation.Server.Host/GraphQL/Types/UserName.cs index 3d02b0f42f9..832aaa17981 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/UserName.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/UserName.cs @@ -9,6 +9,7 @@ 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 { @@ -25,6 +26,7 @@ public sealed class UserName : NamedEntity, IUserName /// The . /// The for the operation. /// A resulting in the queried , if present. + [TgsGraphQLAuthorize] public static async ValueTask GetUserName( long id, [Service] IGraphQLAuthorityInvoker userAuthority, 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/UserGroup.cs b/src/Tgstation.Server.Host/Models/UserGroup.cs index c8fa3efa799..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, ILegacyApiTransformable + public sealed class UserGroup : NamedEntity, ILegacyApiTransformable, IApiTransformable { /// /// The the has. From 308a08d051d66813e00215a4e4b6a5c8066147aa Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sat, 14 Sep 2024 11:16:53 -0400 Subject: [PATCH 032/107] More UserGroup querying work --- .../Authority/Core/AuthorityBase.cs | 9 +++ .../Authority/IUserGroupAuthority.cs | 16 ++++- .../Authority/LoginAuthority.cs | 25 ++++--- .../Authority/UserAuthority.cs | 19 +++--- .../Authority/UserGroupAuthority.cs | 23 ++++++- .../GraphQL/Types/UserGroups.cs | 65 +++++++++++++++++++ .../GraphQL/Types/Users.cs | 16 +++-- 7 files changed, 141 insertions(+), 32 deletions(-) create mode 100644 src/Tgstation.Server.Host/GraphQL/Types/UserGroups.cs diff --git a/src/Tgstation.Server.Host/Authority/Core/AuthorityBase.cs b/src/Tgstation.Server.Host/Authority/Core/AuthorityBase.cs index eff31f3c41b..898c701eb80 100644 --- a/src/Tgstation.Server.Host/Authority/Core/AuthorityBase.cs +++ b/src/Tgstation.Server.Host/Authority/Core/AuthorityBase.cs @@ -7,6 +7,7 @@ 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 @@ -21,6 +22,11 @@ abstract class AuthorityBase : IAuthority /// protected IAuthenticationContext AuthenticationContext { get; } + /// + /// Gets the for the . + /// + protected IDatabaseContext DatabaseContext { get; } + /// /// Gets the for the . /// @@ -71,12 +77,15 @@ protected static AuthorityResponse NotFound() /// Initializes a new instance of the class. /// /// The value of . + /// The value of . /// The value of . protected AuthorityBase( IAuthenticationContext authenticationContext, + IDatabaseContext databaseContext, ILogger logger) { AuthenticationContext = authenticationContext ?? throw new ArgumentNullException(nameof(authenticationContext)); + DatabaseContext = databaseContext ?? throw new ArgumentNullException(nameof(databaseContext)); Logger = logger ?? throw new ArgumentNullException(nameof(logger)); } diff --git a/src/Tgstation.Server.Host/Authority/IUserGroupAuthority.cs b/src/Tgstation.Server.Host/Authority/IUserGroupAuthority.cs index 3afd0c57772..cd4845a5e5d 100644 --- a/src/Tgstation.Server.Host/Authority/IUserGroupAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/IUserGroupAuthority.cs @@ -1,4 +1,5 @@ -using System.Threading; +using System.Linq; +using System.Threading; using System.Threading.Tasks; using Tgstation.Server.Api.Rights; @@ -13,6 +14,12 @@ namespace Tgstation.Server.Host.Authority /// public interface IUserGroupAuthority : IAuthority { + /// + /// Gets the current . + /// + /// A resulting in a . + ValueTask> Read(); + /// /// Gets the with a given . /// @@ -21,5 +28,12 @@ public interface IUserGroupAuthority : IAuthority /// A resulting in a . [TgsAuthorize(AdministrationRights.ReadUsers)] public ValueTask> GetId(long id, CancellationToken cancellationToken); + + /// + /// Gets all registered s. + /// + /// A of s. + [TgsAuthorize(AdministrationRights.ReadUsers)] + IQueryable Queryable(); } } diff --git a/src/Tgstation.Server.Host/Authority/LoginAuthority.cs b/src/Tgstation.Server.Host/Authority/LoginAuthority.cs index d32e0664781..3ed1e1315d0 100644 --- a/src/Tgstation.Server.Host/Authority/LoginAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/LoginAuthority.cs @@ -31,11 +31,6 @@ sealed class LoginAuthority : AuthorityBase, ILoginAuthority /// readonly ISystemIdentityFactory systemIdentityFactory; - /// - /// The for the . - /// - readonly IDatabaseContext databaseContext; - /// /// The for the . /// @@ -102,29 +97,31 @@ static AuthorityResponse GenerateHeadersExceptionResponse(Headers /// 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, - IDatabaseContext databaseContext, IOAuthProviders oAuthProviders, ITokenFactory tokenFactory, ICryptographySuite cryptographySuite, IIdentityCache identityCache) - : base(authenticationContext, logger) + : base( + authenticationContext, + databaseContext, + logger) { this.apiHeadersProvider = apiHeadersProvider ?? throw new ArgumentNullException(nameof(apiHeadersProvider)); this.systemIdentityFactory = systemIdentityFactory ?? throw new ArgumentNullException(nameof(systemIdentityFactory)); - this.databaseContext = databaseContext ?? throw new ArgumentNullException(nameof(databaseContext)); this.oAuthProviders = oAuthProviders ?? throw new ArgumentNullException(nameof(oAuthProviders)); this.tokenFactory = tokenFactory ?? throw new ArgumentNullException(nameof(tokenFactory)); this.cryptographySuite = cryptographySuite ?? throw new ArgumentNullException(nameof(cryptographySuite)); @@ -158,7 +155,7 @@ public async ValueTask> AttemptLogin(Cancellati using (systemIdentity) { // Get the user from the database - IQueryable query = databaseContext.Users.AsQueryable(); + IQueryable query = DatabaseContext.Users.AsQueryable(); if (oAuthLogin) { var oAuthProvider = headers.OAuthProvider!.Value; @@ -228,9 +225,9 @@ public async ValueTask> AttemptLogin(Cancellati { Id = user.Id, }; - databaseContext.Users.Attach(updatedUser); + DatabaseContext.Users.Attach(updatedUser); updatedUser.PasswordHash = user.PasswordHash; - await databaseContext.Save(cancellationToken); + await DatabaseContext.Save(cancellationToken); } } else @@ -238,7 +235,7 @@ public async ValueTask> AttemptLogin(Cancellati var usernameMismatch = systemIdentity!.Username != user.Name; if (isLikelyDbUser || usernameMismatch) { - databaseContext.Users.Attach(user); + DatabaseContext.Users.Attach(user); if (isLikelyDbUser) { // cleanup from https://github.com/tgstation/tgstation-server/issues/1528 @@ -255,7 +252,7 @@ public async ValueTask> AttemptLogin(Cancellati user.CanonicalName = User.CanonicalizeName(user.Name); } - await databaseContext.Save(cancellationToken); + await DatabaseContext.Save(cancellationToken); } } diff --git a/src/Tgstation.Server.Host/Authority/UserAuthority.cs b/src/Tgstation.Server.Host/Authority/UserAuthority.cs index a46e244ce24..6cb8ba430d2 100644 --- a/src/Tgstation.Server.Host/Authority/UserAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/UserAuthority.cs @@ -20,11 +20,6 @@ namespace Tgstation.Server.Host.Authority /// sealed class UserAuthority : AuthorityBase, IUserAuthority { - /// - /// The for the . - /// - readonly IDatabaseContext databaseContext; - /// /// The for the . /// @@ -88,20 +83,22 @@ public static Task> GetUsers( /// /// 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 . public UserAuthority( IAuthenticationContext authenticationContext, - ILogger logger, IDatabaseContext databaseContext, + ILogger logger, IUsersDataLoader usersDataLoader, IOAuthConnectionsDataLoader oAuthConnectionsDataLoader) - : base(authenticationContext, logger) + : base( + authenticationContext, + databaseContext, + logger) { - this.databaseContext = databaseContext ?? throw new ArgumentNullException(nameof(databaseContext)); this.usersDataLoader = usersDataLoader ?? throw new ArgumentNullException(nameof(usersDataLoader)); this.oAuthConnectionsDataLoader = oAuthConnectionsDataLoader ?? throw new ArgumentNullException(nameof(oAuthConnectionsDataLoader)); } @@ -155,7 +152,7 @@ public IQueryable Queryable(bool includeJoins) IQueryable Queryable(bool includeJoins, bool allowSystemUser) { var tgsUserCanonicalName = User.CanonicalizeName(User.TgsSystemUserName); - var queryable = databaseContext + var queryable = DatabaseContext .Users .AsQueryable(); diff --git a/src/Tgstation.Server.Host/Authority/UserGroupAuthority.cs b/src/Tgstation.Server.Host/Authority/UserGroupAuthority.cs index c15b7c4c2ee..b6268906fcc 100644 --- a/src/Tgstation.Server.Host/Authority/UserGroupAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/UserGroupAuthority.cs @@ -51,13 +51,18 @@ public static Task> GetUserGroups( /// Initializes a new instance of the class. /// /// The to use. + /// The to use. /// The to use. /// The value of . public UserGroupAuthority( IAuthenticationContext authenticationContext, + IDatabaseContext databaseContext, ILogger logger, IUserGroupsDataLoader userGroupsDataLoader) - : base(authenticationContext, logger) + : base( + authenticationContext, + databaseContext, + logger) { this.userGroupsDataLoader = userGroupsDataLoader ?? throw new ArgumentNullException(nameof(userGroupsDataLoader)); } @@ -74,5 +79,21 @@ public async ValueTask> GetId(long id, Cancellation return new AuthorityResponse(userGroup); } + + /// + public ValueTask> Read() + { + var group = AuthenticationContext.User!.Group; + if (group == null) + return ValueTask.FromResult(NotFound()); + + return ValueTask.FromResult(new AuthorityResponse(group)); + } + + /// + public IQueryable Queryable() + => DatabaseContext + .Groups + .AsQueryable(); } } 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..9cf6a9a58bd --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Types/UserGroups.cs @@ -0,0 +1,65 @@ +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 . + /// A resulting in the current 's . + public ValueTask Current( + [Service] IGraphQLAuthorityInvoker userGroupAuthority) + { + ArgumentNullException.ThrowIfNull(userGroupAuthority); + return userGroupAuthority.InvokeTransformable(authority => authority.Read()); + } + + /// + /// Gets a by . + /// + /// The of the . + /// 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 . + /// A of all registered s. + [UsePaging] + [UseFiltering] + [UseSorting] + [TgsGraphQLAuthorize(nameof(IUserGroupAuthority.Queryable))] + public IQueryable? Queryable( + [Service] IGraphQLAuthorityInvoker userGroupAuthority) + { + ArgumentNullException.ThrowIfNull(userGroupAuthority); + var dtoQueryable = userGroupAuthority.InvokeTransformableQueryable(authority => authority.Queryable()); + return dtoQueryable; + } + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Types/Users.cs b/src/Tgstation.Server.Host/GraphQL/Types/Users.cs index db0a333f530..95d8173efd8 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/Users.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/Users.cs @@ -21,6 +21,12 @@ namespace Tgstation.Server.Host.GraphQL.Types /// public sealed class Users { + /// + /// Gets the swarm's . + /// + /// A new . + public UserGroups Groups() => new(); + /// /// Gets the current . /// @@ -37,7 +43,7 @@ public ValueTask Current( } /// - /// Gets a user by . + /// Gets a by . /// /// The of the . /// The . @@ -45,17 +51,17 @@ public ValueTask Current( /// The represented by , if any. [Error(typeof(ErrorMessageException))] [TgsGraphQLAuthorize(nameof(IUserAuthority.GetId))] - public async ValueTask ById( + public ValueTask ById( [ID(nameof(User))] long id, [Service] IGraphQLAuthorityInvoker userAuthority, CancellationToken cancellationToken) - => await User.GetUser(id, userAuthority, cancellationToken); + => User.GetUser(id, userAuthority, cancellationToken); /// - /// Lists all registered s. + /// Queries all registered s. /// /// The . - /// A list of all registered s. + /// A of all registered s. [UsePaging] [UseFiltering] [UseSorting] From ff262b25846e693d7a813f6ab3524580010a1535 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sat, 14 Sep 2024 11:41:56 -0400 Subject: [PATCH 033/107] Improve `LoginPayload` --- .../GQL/Mutations/Login.graphql | 2 +- .../Authority/ILoginAuthority.cs | 6 +-- .../Authority/LoginAuthority.cs | 48 +++++++++---------- .../Controllers/ApiRootController.cs | 3 +- src/Tgstation.Server.Host/GraphQL/Mutation.cs | 10 ++-- .../GraphQL/Mutations/LoginPayload.cs | 31 ++++++++++++ .../Security/ITokenFactory.cs | 4 +- .../Security/TokenFactory.cs | 7 +-- .../Tgstation.Server.Host.csproj | 1 - .../Swarm/TestableSwarmNode.cs | 2 +- .../Live/RawRequestTests.cs | 2 +- 11 files changed, 71 insertions(+), 45 deletions(-) create mode 100644 src/Tgstation.Server.Host/GraphQL/Mutations/LoginPayload.cs diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/Login.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/Login.graphql index 1ed6c053eea..2d99ea65337 100644 --- a/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/Login.graphql +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/Login.graphql @@ -1,6 +1,6 @@ mutation Login { login { - string + bearer errors { ... on ErrorMessageError { message diff --git a/src/Tgstation.Server.Host/Authority/ILoginAuthority.cs b/src/Tgstation.Server.Host/Authority/ILoginAuthority.cs index bdb799c5e75..911cc7033e6 100644 --- a/src/Tgstation.Server.Host/Authority/ILoginAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/ILoginAuthority.cs @@ -1,8 +1,8 @@ using System.Threading; using System.Threading.Tasks; -using Tgstation.Server.Api.Models.Response; using Tgstation.Server.Host.Authority.Core; +using Tgstation.Server.Host.GraphQL.Mutations; namespace Tgstation.Server.Host.Authority { @@ -15,7 +15,7 @@ public interface ILoginAuthority : IAuthority /// Attempt to login to the server with the current crentials. /// /// The for the operation. - /// A resulting in a . - ValueTask> AttemptLogin(CancellationToken cancellationToken); + /// A resulting in a and . + ValueTask> AttemptLogin(CancellationToken cancellationToken); } } diff --git a/src/Tgstation.Server.Host/Authority/LoginAuthority.cs b/src/Tgstation.Server.Host/Authority/LoginAuthority.cs index 3ed1e1315d0..8dbd4827a72 100644 --- a/src/Tgstation.Server.Host/Authority/LoginAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/LoginAuthority.cs @@ -11,7 +11,9 @@ using Tgstation.Server.Api.Models.Response; using Tgstation.Server.Host.Authority.Core; using Tgstation.Server.Host.Database; +using Tgstation.Server.Host.GraphQL.Mutations; 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; @@ -55,8 +57,8 @@ sealed class LoginAuthority : AuthorityBase, ILoginAuthority /// Generate an for a given . /// /// The to generate a response for. - /// A new, errored . - static AuthorityResponse GenerateHeadersExceptionResponse(HeadersException headersException) + /// A new, errored . + static AuthorityResponse GenerateHeadersExceptionResponse(HeadersException headersException) => new( new ErrorMessageResponse(ErrorCode.BadHeaders) { @@ -75,14 +77,6 @@ static AuthorityResponse GenerateHeadersExceptionResponse(Headers static async ValueTask SelectUserInfoFromQuery(IQueryable query, CancellationToken cancellationToken) { var users = await query - .Select(x => new User - { - Id = x.Id, - PasswordHash = x.PasswordHash, - Enabled = x.Enabled, - Name = x.Name, - SystemIdentifier = x.SystemIdentifier, - }) .ToListAsync(cancellationToken); // Pick the DB user first @@ -129,14 +123,14 @@ public LoginAuthority( } /// - public async ValueTask> AttemptLogin(CancellationToken cancellationToken) + public async ValueTask> AttemptLogin(CancellationToken cancellationToken) { var headers = apiHeadersProvider.ApiHeaders; if (headers == null) return GenerateHeadersExceptionResponse(apiHeadersProvider.HeadersException!); if (headers.IsTokenAuthentication) - return BadRequest(ErrorCode.TokenWithToken); + return BadRequest(ErrorCode.TokenWithToken); var oAuthLogin = headers.OAuthProvider.HasValue; @@ -166,7 +160,7 @@ public async ValueTask> AttemptLogin(Cancellati .GetValidator(oAuthProvider); if (validator == null) - return BadRequest(ErrorCode.OAuthProviderDisabled); + return BadRequest(ErrorCode.OAuthProviderDisabled); externalUserId = await validator .ValidateResponseCode(headers.OAuthCode!, cancellationToken); @@ -175,11 +169,11 @@ public async ValueTask> AttemptLogin(Cancellati } catch (Octokit.RateLimitExceededException ex) { - return RateLimit(ex); + return RateLimit(ex); } if (externalUserId == null) - return Unauthorized(); + return Unauthorized(); query = query.Where( x => x.OAuthConnections!.Any( @@ -190,7 +184,7 @@ public async ValueTask> AttemptLogin(Cancellati { var canonicalUserName = User.CanonicalizeName(headers.Username!); if (canonicalUserName == User.CanonicalizeName(User.TgsSystemUserName)) - return Unauthorized(); + return Unauthorized(); if (systemIdentity == null) query = query.Where(x => x.CanonicalName == canonicalUserName); @@ -202,7 +196,7 @@ public async ValueTask> AttemptLogin(Cancellati // No user? You're not allowed if (user == null) - return Unauthorized(); + 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 @@ -217,7 +211,7 @@ public async ValueTask> AttemptLogin(Cancellati { // DB User password check and update if (!isLikelyDbUser || !cryptographySuite.CheckUserPassword(user, headers.Password!)) - return Unauthorized(); + return Unauthorized(); if (user.PasswordHash != originalHash) { Logger.LogDebug("User ID {userId}'s password hash needs a refresh, updating database.", user.Id); @@ -260,16 +254,22 @@ public async ValueTask> AttemptLogin(Cancellati if (!user.Enabled!.Value) { Logger.LogTrace("Not logging in disabled user {userId}.", user.Id); - return Forbid(); + return Forbid(); } var token = tokenFactory.CreateToken(user, oAuthLogin); + var payload = new LoginPayload + { + Bearer = token, + User = ((IApiTransformable)user).ToApi(), + }; + if (usingSystemIdentity) - await CacheSystemIdentity(systemIdentity!, user, token); + await CacheSystemIdentity(systemIdentity!, user, payload); Logger.LogDebug("Successfully logged in user {userId}!", user.Id); - return new AuthorityResponse(token); + return new AuthorityResponse(payload); } } @@ -278,12 +278,12 @@ public async ValueTask> AttemptLogin(Cancellati /// /// The to cache. /// The the was generated for. - /// The for the . + /// The for the successful login. /// A representing the running operation. - private async ValueTask CacheSystemIdentity(ISystemIdentity systemIdentity, User user, TokenResponse token) + private async ValueTask CacheSystemIdentity(ISystemIdentity systemIdentity, User user, LoginPayload loginPayload) { // expire the identity slightly after the auth token in case of lag - var identExpiry = token.ParseJwt().ValidTo; + 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/Controllers/ApiRootController.cs b/src/Tgstation.Server.Host/Controllers/ApiRootController.cs index 893357064b0..7a90d23f17d 100644 --- a/src/Tgstation.Server.Host/Controllers/ApiRootController.cs +++ b/src/Tgstation.Server.Host/Controllers/ApiRootController.cs @@ -19,6 +19,7 @@ using Tgstation.Server.Host.Configuration; using Tgstation.Server.Host.Core; using Tgstation.Server.Host.Database; +using Tgstation.Server.Host.GraphQL.Mutations; using Tgstation.Server.Host.Models; using Tgstation.Server.Host.Security; using Tgstation.Server.Host.Security.OAuth; @@ -185,7 +186,7 @@ public ValueTask CreateToken(CancellationToken cancellationToken) return ValueTask.FromResult(HeadersIssue(ApiHeadersProvider.HeadersException!)); } - return loginAuthority.Invoke(this, authority => authority.AttemptLogin(cancellationToken)); + return loginAuthority.InvokeTransformable(this, authority => authority.AttemptLogin(cancellationToken)); } } } diff --git a/src/Tgstation.Server.Host/GraphQL/Mutation.cs b/src/Tgstation.Server.Host/GraphQL/Mutation.cs index 31b896e4fc9..f9b8d29d520 100644 --- a/src/Tgstation.Server.Host/GraphQL/Mutation.cs +++ b/src/Tgstation.Server.Host/GraphQL/Mutation.cs @@ -5,8 +5,8 @@ using HotChocolate; using HotChocolate.Types; -using Tgstation.Server.Api.Models.Response; using Tgstation.Server.Host.Authority; +using Tgstation.Server.Host.GraphQL.Mutations; namespace Tgstation.Server.Host.GraphQL { @@ -23,16 +23,14 @@ public sealed class Mutation /// The for the operation. /// A Bearer token to be used with further communication with the server. [Error(typeof(ErrorMessageException))] - public async ValueTask Login( + public ValueTask Login( [Service] IGraphQLAuthorityInvoker loginAuthority, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(loginAuthority); - var tokenResponse = await loginAuthority.Invoke( - authority => authority.AttemptLogin(cancellationToken)); - - return tokenResponse!.Bearer!; + return loginAuthority.Invoke( + authority => authority.AttemptLogin(cancellationToken))!; } } } diff --git a/src/Tgstation.Server.Host/GraphQL/Mutations/LoginPayload.cs b/src/Tgstation.Server.Host/GraphQL/Mutations/LoginPayload.cs new file mode 100644 index 00000000000..31b9b2e0b37 --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Mutations/LoginPayload.cs @@ -0,0 +1,31 @@ +using HotChocolate; + +using Tgstation.Server.Api.Models.Response; +using Tgstation.Server.Host.Models; + +namespace Tgstation.Server.Host.GraphQL.Mutations +{ + /// + /// 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. + /// + 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/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/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/Tgstation.Server.Host.csproj b/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj index 8c8ee498c68..134257db9a8 100644 --- a/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj +++ b/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj @@ -186,7 +186,6 @@ - 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/RawRequestTests.cs b/tests/Tgstation.Server.Tests/Live/RawRequestTests.cs index 5c320270e18..bac0433d4fa 100644 --- a/tests/Tgstation.Server.Tests/Live/RawRequestTests.cs +++ b/tests/Tgstation.Server.Tests/Live/RawRequestTests.cs @@ -470,7 +470,7 @@ await gqlClient.RunQuery(async client => }); Assert.IsNotNull(result.Data); - Assert.IsNull(result.Data.Login.String); + 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; From f6c09292b00640dded693c168370d98ed34fadab Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sat, 14 Sep 2024 14:55:31 -0400 Subject: [PATCH 034/107] Fix UTF file BOM thingy --- src/Tgstation.Server.Host/Database/DatabaseContext.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Tgstation.Server.Host/Database/DatabaseContext.cs b/src/Tgstation.Server.Host/Database/DatabaseContext.cs index 9a44a0599e8..3d4bd73f0f8 100644 --- a/src/Tgstation.Server.Host/Database/DatabaseContext.cs +++ b/src/Tgstation.Server.Host/Database/DatabaseContext.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Globalization; using System.Linq; using System.Reflection; From 6e4aa507af48242bb077a24eeca4cadac2740965 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sat, 14 Sep 2024 15:19:40 -0400 Subject: [PATCH 035/107] Implement `UserGroup.UsersQueryable` --- .../GraphQL/Types/UserGroups.cs | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/Tgstation.Server.Host/GraphQL/Types/UserGroups.cs b/src/Tgstation.Server.Host/GraphQL/Types/UserGroups.cs index 9cf6a9a58bd..779a1423392 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/UserGroups.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/UserGroups.cs @@ -61,5 +61,27 @@ public sealed class UserGroups var dtoQueryable = userGroupAuthority.InvokeTransformableQueryable(authority => authority.Queryable()); return dtoQueryable; } + + /// + /// Queries all registered s in a indicated by . + /// + /// The . + /// The . + /// A of all registered s in the indicated by . + [UsePaging] + [UseFiltering] + [UseSorting] + [TgsGraphQLAuthorize(nameof(IUserAuthority.Queryable))] + public IQueryable? UsersQueryable( + [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; + } } } From a131fea124fec7b5b2a950183f3940c28edce6ab Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sat, 14 Sep 2024 16:49:15 -0400 Subject: [PATCH 036/107] Changing port allocation tactics for the millionth time --- .../Live/TestLiveServer.cs | 36 +++---------------- 1 file changed, 5 insertions(+), 31 deletions(-) diff --git a/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs b/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs index 1c17f587c09..597889e131b 100644 --- a/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs +++ b/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs @@ -43,7 +43,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,6 +52,7 @@ 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()); @@ -64,6 +64,7 @@ public sealed class TestLiveServer static void InitializePorts() { + tcpPortCounter = InitialPort; _ = odDMPort.Value; _ = odDDPort.Value; _ = compatDMPort.Value; @@ -156,38 +157,11 @@ static bool TerminateAllEngineServers() return result; } + static ushort tcpPortCounter = InitialPort; + static ushort FreeTcpPort(params ushort[] usedPorts) { - ushort result; - var listeners = new List(); - - 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(); - } + ushort result = tcpPortCounter++; Console.WriteLine($"Allocated port: {result}"); return result; From 0166adc53f57a2c321e146d4f1a94488f8ff16d4 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 15 Sep 2024 09:50:28 -0400 Subject: [PATCH 037/107] Do not catch `ArgumentException`s in this retry loop --- src/Tgstation.Server.Host/Extensions/TopicClientExtensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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); From 0c0bf2891c0a90a7ac29af27b8aa6c392f7af3b2 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 15 Sep 2024 11:06:01 -0400 Subject: [PATCH 038/107] Do not enable BananaCakePop without `HostApiDocumentation` --- src/Tgstation.Server.Host/Core/Application.cs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs index cb5b2bec4a7..3667364722c 100644 --- a/src/Tgstation.Server.Host/Core/Application.cs +++ b/src/Tgstation.Server.Host/Core/Application.cs @@ -652,12 +652,16 @@ public void Configure( if (internalConfiguration.EnableGraphQL) { logger.LogWarning("Enabling GraphQL. This API is experimental and breaking changes may occur at any time!"); + var gqlOptions = new GraphQLServerOptions + { + EnableBatching = true, + }; + + gqlOptions.Tool.Enable = generalConfiguration.HostApiDocumentation; + endpoints .MapGraphQL(Routes.GraphQL) - .WithOptions(new GraphQLServerOptions - { - EnableBatching = true, - }); + .WithOptions(gqlOptions); } }); From 90d0d09c5741da0183d1241357591f6841c3a360 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 15 Sep 2024 11:13:31 -0400 Subject: [PATCH 039/107] Link GraphQL API documentation --- .../Controllers/RootController.cs | 38 +++++++++++++------ 1 file changed, 27 insertions(+), 11 deletions(-) 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; From 269476cdd99e2a19ea7d7d1ef91d657248f917e6 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 15 Sep 2024 11:35:33 -0400 Subject: [PATCH 040/107] Revert "Disable the cost analyzer" This reverts commit d1434112f69cb43dc64d02d116d8a3f919582794. --- src/Tgstation.Server.Host/Core/Application.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs index 3667364722c..353d0063a90 100644 --- a/src/Tgstation.Server.Host/Core/Application.cs +++ b/src/Tgstation.Server.Host/Core/Application.cs @@ -294,7 +294,7 @@ void ConfigureNewtonsoftJsonSerializerSettingsForApi(JsonSerializerSettings sett // configure graphql if (postSetupServices.InternalConfiguration.EnableGraphQL) services - .AddGraphQLServer(disableCostAnalyzer: true) + .AddGraphQLServer() .AddAuthorization() .ModifyOptions(options => { From bca2a225c336e0bbc888cb6446f89ae94075e7c5 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 15 Sep 2024 11:46:11 -0400 Subject: [PATCH 041/107] Apply code suggestion --- .../Components/Engine/OpenDreamInstaller.cs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) 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( From 73b038cc4b28fe0fbc211721c2b4f2763ce7ac14 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 15 Sep 2024 11:52:19 -0400 Subject: [PATCH 042/107] Correct some documentation comments --- src/Tgstation.Server.Host/Authority/UserAuthority.cs | 2 +- src/Tgstation.Server.Host/Authority/UserGroupAuthority.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Tgstation.Server.Host/Authority/UserAuthority.cs b/src/Tgstation.Server.Host/Authority/UserAuthority.cs index 6cb8ba430d2..f2a8e7646ff 100644 --- a/src/Tgstation.Server.Host/Authority/UserAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/UserAuthority.cs @@ -33,7 +33,7 @@ sealed class UserAuthority : AuthorityBase, IUserAuthority /// /// Implements the . /// - /// The of s to load. + /// The of s to load. /// The to load from. /// The for the operation. /// A resulting in a of the requested s. diff --git a/src/Tgstation.Server.Host/Authority/UserGroupAuthority.cs b/src/Tgstation.Server.Host/Authority/UserGroupAuthority.cs index b6268906fcc..12faf9cc405 100644 --- a/src/Tgstation.Server.Host/Authority/UserGroupAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/UserGroupAuthority.cs @@ -28,7 +28,7 @@ sealed class UserGroupAuthority : AuthorityBase, IUserGroupAuthority /// /// Implements the . /// - /// The of s to load. + /// The of s to load. /// The to load from. /// The for the operation. /// A resulting in a of the requested s. From c420b6356301e9df97c05eee9ca61bfc0cd5b8a2 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 15 Sep 2024 13:07:52 -0400 Subject: [PATCH 043/107] Implement permission set querying and authority --- .../Authority/IPermissionSetAuthority.cs | 26 ++++ .../Authority/PermissionSetAuthority.cs | 117 ++++++++++++++++++ .../Authority/PermissionSetLookupType.cs | 23 ++++ src/Tgstation.Server.Host/Core/Application.cs | 5 +- .../GraphQL/Types/PermissionSet.cs | 45 ++++--- .../GraphQL/Types/User.cs | 46 ++++++- .../GraphQL/Types/UserGroup.cs | 17 ++- .../Models/PermissionSet.cs | 4 +- .../PermissionSetGraphQLTransformer.cs | 21 ++++ 9 files changed, 278 insertions(+), 26 deletions(-) create mode 100644 src/Tgstation.Server.Host/Authority/IPermissionSetAuthority.cs create mode 100644 src/Tgstation.Server.Host/Authority/PermissionSetAuthority.cs create mode 100644 src/Tgstation.Server.Host/Authority/PermissionSetLookupType.cs create mode 100644 src/Tgstation.Server.Host/Models/Transformers/PermissionSetGraphQLTransformer.cs 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/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/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs index 353d0063a90..0cc7d0fb2b8 100644 --- a/src/Tgstation.Server.Host/Core/Application.cs +++ b/src/Tgstation.Server.Host/Core/Application.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Frozen; using System.Collections.Generic; using System.Globalization; @@ -299,6 +299,8 @@ void ConfigureNewtonsoftJsonSerializerSettingsForApi(JsonSerializerSettings sett .ModifyOptions(options => { options.EnsureAllNodesCanBeResolved = true; + options.EnableFlagEnums = true; + }) }) .AddMutationConventions() .AddGlobalObjectIdentification() @@ -466,6 +468,7 @@ void AddTypedContext() services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // configure misc services services.AddSingleton(); diff --git a/src/Tgstation.Server.Host/GraphQL/Types/PermissionSet.cs b/src/Tgstation.Server.Host/GraphQL/Types/PermissionSet.cs index ba0c070582c..018fa26f476 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/PermissionSet.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/PermissionSet.cs @@ -1,36 +1,49 @@ -using System.Diagnostics.CodeAnalysis; +using System; +using System.Threading; +using System.Threading.Tasks; + +using HotChocolate; +using HotChocolate.Types.Relay; using Tgstation.Server.Api.Rights; +using Tgstation.Server.Host.Authority; +using Tgstation.Server.Host.Models.Transformers; +using Tgstation.Server.Host.Security; namespace Tgstation.Server.Host.GraphQL.Types { /// /// Represents a set of permissions for the server. /// + [Node] public sealed class PermissionSet : Entity { /// - /// The for the . + /// Node resolver for s. /// - public AdministrationRights AdministrationRights { get; } + /// The to lookup. + /// The . + /// The for the operation. + /// A resulting in the queried , if present. + [TgsGraphQLAuthorize] + public static ValueTask GetPermissionSet( + long id, + [Service] IGraphQLAuthorityInvoker userAuthority, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(userAuthority); + return userAuthority.InvokeTransformable( + authority => authority.GetId(id, PermissionSetLookupType.Id, cancellationToken)); + } /// - /// The for the . + /// The for the . /// - public InstanceManagerRights InstanceManagerRights { get; } + public required AdministrationRights AdministrationRights { get; init; } /// - /// Initializes a new instance of the class. + /// The for the . /// - /// The . - /// The value of . - /// The value of . - [SetsRequiredMembers] - 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/User.cs b/src/Tgstation.Server.Host/GraphQL/Types/User.cs index 0a9aa5c6922..6d9f062898b 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/User.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/User.cs @@ -108,11 +108,49 @@ public async ValueTask OAuthConnections( } /// - /// 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 . + /// The for the operation. + /// A resulting in the associated with the . + public async 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 (await permissionSetAuthority.InvokeTransformable( + authority => authority.GetId(lookupId, lookupType, cancellationToken)))!; + } + + /// + /// The owned by the , if any. + /// + /// 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.InvokeTransformable( + authority => authority.GetId(Id, PermissionSetLookupType.UserId, cancellationToken)); + } /// /// The asociated with the user, if any. diff --git a/src/Tgstation.Server.Host/GraphQL/Types/UserGroup.cs b/src/Tgstation.Server.Host/GraphQL/Types/UserGroup.cs index 1d457be2a8c..80dec4b65cd 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/UserGroup.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/UserGroup.cs @@ -38,11 +38,20 @@ public sealed class UserGroup : NamedEntity } /// - /// The of the . + /// The owned by the . /// - /// A resulting in the for the . - public ValueTask PermissionSet() - => throw new NotImplementedException(); + /// 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 . 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/Transformers/PermissionSetGraphQLTransformer.cs b/src/Tgstation.Server.Host/Models/Transformers/PermissionSetGraphQLTransformer.cs new file mode 100644 index 00000000000..1575f04df99 --- /dev/null +++ b/src/Tgstation.Server.Host/Models/Transformers/PermissionSetGraphQLTransformer.cs @@ -0,0 +1,21 @@ +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 + { + Id = model.Id!.Value, + AdministrationRights = model.AdministrationRights!.Value, + InstanceManagerRights = model.InstanceManagerRights!.Value, + }) + { + } + } +} From bfe12f0399d400ce58ef83748f71abb6cf2c4502 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 15 Sep 2024 13:08:03 -0400 Subject: [PATCH 044/107] Do not enforce cost limits when debugging --- src/Tgstation.Server.Host/Core/Application.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs index 0cc7d0fb2b8..df797a55184 100644 --- a/src/Tgstation.Server.Host/Core/Application.cs +++ b/src/Tgstation.Server.Host/Core/Application.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Frozen; using System.Collections.Generic; using System.Globalization; @@ -301,7 +301,12 @@ void ConfigureNewtonsoftJsonSerializerSettingsForApi(JsonSerializerSettings sett options.EnsureAllNodesCanBeResolved = true; options.EnableFlagEnums = true; }) +#if DEBUG + .ModifyCostOptions(options => + { + options.EnforceCostLimits = false; }) +#endif .AddMutationConventions() .AddGlobalObjectIdentification() .AddQueryFieldToMutationPayloads() From 547fb9de7db9921e4d1d7d4d67ecfdd5ad6c1348 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 15 Sep 2024 13:18:25 -0400 Subject: [PATCH 045/107] Use IUserGroupAuthority functions in the controller --- .../Authority/IUserGroupAuthority.cs | 6 +++-- .../Authority/UserGroupAuthority.cs | 24 +++++++++++++++---- .../Controllers/UserGroupController.cs | 23 ++++-------------- .../GraphQL/Types/User.cs | 2 +- .../GraphQL/Types/UserGroup.cs | 2 +- .../GraphQL/Types/UserGroups.cs | 2 +- 6 files changed, 31 insertions(+), 28 deletions(-) diff --git a/src/Tgstation.Server.Host/Authority/IUserGroupAuthority.cs b/src/Tgstation.Server.Host/Authority/IUserGroupAuthority.cs index cd4845a5e5d..041e530a7a1 100644 --- a/src/Tgstation.Server.Host/Authority/IUserGroupAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/IUserGroupAuthority.cs @@ -24,16 +24,18 @@ public interface IUserGroupAuthority : IAuthority /// 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)] - public ValueTask> GetId(long id, CancellationToken cancellationToken); + public 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(); + IQueryable Queryable(bool includeJoins); } } diff --git a/src/Tgstation.Server.Host/Authority/UserGroupAuthority.cs b/src/Tgstation.Server.Host/Authority/UserGroupAuthority.cs index 12faf9cc405..12005ff3702 100644 --- a/src/Tgstation.Server.Host/Authority/UserGroupAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/UserGroupAuthority.cs @@ -68,12 +68,19 @@ public UserGroupAuthority( } /// - public async ValueTask> GetId(long id, CancellationToken cancellationToken) + 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(); - var userGroup = await userGroupsDataLoader.LoadAsync(id, cancellationToken); + 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 NotFound(); @@ -91,9 +98,18 @@ public ValueTask> Read() } /// - public IQueryable Queryable() - => DatabaseContext + public IQueryable Queryable(bool includeJoins) + { + var queryable = DatabaseContext .Groups .AsQueryable(); + + if (includeJoins) + queryable = queryable + .Include(x => x.Users) + .Include(x => x.PermissionSet); + + return queryable; + } } } diff --git a/src/Tgstation.Server.Host/Controllers/UserGroupController.cs b/src/Tgstation.Server.Host/Controllers/UserGroupController.cs index ae3dc2b0a87..2dd74490d0b 100644 --- a/src/Tgstation.Server.Host/Controllers/UserGroupController.cs +++ b/src/Tgstation.Server.Host/Controllers/UserGroupController.cs @@ -167,20 +167,8 @@ public async ValueTask Update([FromBody] UserGroupUpdateRequest m [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. @@ -197,11 +185,8 @@ public ValueTask List([FromQuery] int? page, [FromQuery] int? pag => 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, diff --git a/src/Tgstation.Server.Host/GraphQL/Types/User.cs b/src/Tgstation.Server.Host/GraphQL/Types/User.cs index 6d9f062898b..87bde51eac0 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/User.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/User.cs @@ -167,7 +167,7 @@ public async ValueTask EffectivePermissionSet( return null; return await userGroupAuthority.InvokeTransformable( - authority => authority.GetId(GroupId.Value, cancellationToken)); + 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 80dec4b65cd..325d552b85f 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/UserGroup.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/UserGroup.cs @@ -34,7 +34,7 @@ public sealed class UserGroup : NamedEntity { ArgumentNullException.ThrowIfNull(userGroupAuthority); return userGroupAuthority.InvokeTransformable( - authority => authority.GetId(id, cancellationToken)); + authority => authority.GetId(id, false, cancellationToken)); } /// diff --git a/src/Tgstation.Server.Host/GraphQL/Types/UserGroups.cs b/src/Tgstation.Server.Host/GraphQL/Types/UserGroups.cs index 779a1423392..78978421a85 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/UserGroups.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/UserGroups.cs @@ -58,7 +58,7 @@ public sealed class UserGroups [Service] IGraphQLAuthorityInvoker userGroupAuthority) { ArgumentNullException.ThrowIfNull(userGroupAuthority); - var dtoQueryable = userGroupAuthority.InvokeTransformableQueryable(authority => authority.Queryable()); + var dtoQueryable = userGroupAuthority.InvokeTransformableQueryable(authority => authority.Queryable(false)); return dtoQueryable; } From f9587f4a25322471ec58c2ce6f63d62a13bd1ece Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 15 Sep 2024 13:31:06 -0400 Subject: [PATCH 046/107] Fix UserGroupController permissions declaration --- src/Tgstation.Server.Host/Controllers/UserGroupController.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Tgstation.Server.Host/Controllers/UserGroupController.cs b/src/Tgstation.Server.Host/Controllers/UserGroupController.cs index 2dd74490d0b..20cd0c914c7 100644 --- a/src/Tgstation.Server.Host/Controllers/UserGroupController.cs +++ b/src/Tgstation.Server.Host/Controllers/UserGroupController.cs @@ -179,7 +179,7 @@ public ValueTask GetId(long id, CancellationToken cancellationTok /// 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( From 3c85d7dda16b202cf67da93795e285564d91c6ac Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 15 Sep 2024 13:35:44 -0400 Subject: [PATCH 047/107] Remove unused GQL field --- src/Tgstation.Server.Host/GraphQL/Types/UserGroup.cs | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/Tgstation.Server.Host/GraphQL/Types/UserGroup.cs b/src/Tgstation.Server.Host/GraphQL/Types/UserGroup.cs index 325d552b85f..b555f203e0d 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/UserGroup.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/UserGroup.cs @@ -1,10 +1,8 @@ using System; -using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using HotChocolate; -using HotChocolate.Types; using HotChocolate.Types.Relay; using Tgstation.Server.Host.Authority; @@ -52,13 +50,5 @@ public async ValueTask PermissionSet( return (await permissionSetAuthority.InvokeTransformable( authority => authority.GetId(Id, PermissionSetLookupType.GroupId, cancellationToken)))!; } - - /// - /// Gets the s in the . - /// - /// A resulting in a new of s in the . - [UsePaging(IncludeTotalCount = true)] - public List Users() - => throw new NotImplementedException(); } } From 1bd6286bce9587149a661d7703134e61a7f56048 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 15 Sep 2024 14:22:47 -0400 Subject: [PATCH 048/107] Fix some naming issues with Connections --- src/Tgstation.Server.Host/GraphQL/Types/UserGroups.cs | 4 ++-- src/Tgstation.Server.Host/GraphQL/Types/Users.cs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Tgstation.Server.Host/GraphQL/Types/UserGroups.cs b/src/Tgstation.Server.Host/GraphQL/Types/UserGroups.cs index 78978421a85..b54062e9f64 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/UserGroups.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/UserGroups.cs @@ -54,7 +54,7 @@ public sealed class UserGroups [UseFiltering] [UseSorting] [TgsGraphQLAuthorize(nameof(IUserGroupAuthority.Queryable))] - public IQueryable? Queryable( + public IQueryable QueryableGroups( [Service] IGraphQLAuthorityInvoker userGroupAuthority) { ArgumentNullException.ThrowIfNull(userGroupAuthority); @@ -72,7 +72,7 @@ public sealed class UserGroups [UseFiltering] [UseSorting] [TgsGraphQLAuthorize(nameof(IUserAuthority.Queryable))] - public IQueryable? UsersQueryable( + public IQueryable QueryableUsersByGroupId( [ID(nameof(UserGroup))]long groupId, [Service] IGraphQLAuthorityInvoker userAuthority) { diff --git a/src/Tgstation.Server.Host/GraphQL/Types/Users.cs b/src/Tgstation.Server.Host/GraphQL/Types/Users.cs index 95d8173efd8..9052345de85 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/Users.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/Users.cs @@ -66,7 +66,7 @@ public ValueTask Current( [UseFiltering] [UseSorting] [TgsGraphQLAuthorize(nameof(IUserAuthority.Queryable))] - public IQueryable? Queryable( + public IQueryable QueryableUsers( [Service] IGraphQLAuthorityInvoker userAuthority) { ArgumentNullException.ThrowIfNull(userAuthority); From 5b135fbc25db35ebef311e58722f755f5effbdd5 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 15 Sep 2024 14:23:08 -0400 Subject: [PATCH 049/107] Makes sense to have recursive lookups here. Cost analyzer should be protecting us --- .../GraphQL/Types/UserGroup.cs | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/Tgstation.Server.Host/GraphQL/Types/UserGroup.cs b/src/Tgstation.Server.Host/GraphQL/Types/UserGroup.cs index b555f203e0d..c9c5fcede24 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/UserGroup.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/UserGroup.cs @@ -1,8 +1,11 @@ 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; @@ -50,5 +53,25 @@ public async ValueTask PermissionSet( return (await permissionSetAuthority.InvokeTransformable( authority => authority.GetId(Id, PermissionSetLookupType.GroupId, cancellationToken)))!; } + + /// + /// Queries all registered s in the . + /// + /// The . + /// A of all registered s in the indicated by . + [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; + } } } From 998a63594cbab9d41a31e47a693d7b5b88611142 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 15 Sep 2024 14:42:08 -0400 Subject: [PATCH 050/107] Having `PermissionSet` as a `Node` causes complex issues with mutations --- .../GraphQL/Types/PermissionSet.cs | 33 ++----------------- .../PermissionSetGraphQLTransformer.cs | 1 - 2 files changed, 2 insertions(+), 32 deletions(-) diff --git a/src/Tgstation.Server.Host/GraphQL/Types/PermissionSet.cs b/src/Tgstation.Server.Host/GraphQL/Types/PermissionSet.cs index 018fa26f476..684beafdb85 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/PermissionSet.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/PermissionSet.cs @@ -1,41 +1,12 @@ -using System; -using System.Threading; -using System.Threading.Tasks; - -using HotChocolate; -using HotChocolate.Types.Relay; - -using Tgstation.Server.Api.Rights; -using Tgstation.Server.Host.Authority; -using Tgstation.Server.Host.Models.Transformers; -using Tgstation.Server.Host.Security; +using Tgstation.Server.Api.Rights; namespace Tgstation.Server.Host.GraphQL.Types { /// /// Represents a set of permissions for the server. /// - [Node] - public sealed class PermissionSet : Entity + public sealed class PermissionSet { - /// - /// Node resolver for s. - /// - /// The to lookup. - /// The . - /// The for the operation. - /// A resulting in the queried , if present. - [TgsGraphQLAuthorize] - public static ValueTask GetPermissionSet( - long id, - [Service] IGraphQLAuthorityInvoker userAuthority, - CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(userAuthority); - return userAuthority.InvokeTransformable( - authority => authority.GetId(id, PermissionSetLookupType.Id, cancellationToken)); - } - /// /// The for the . /// diff --git a/src/Tgstation.Server.Host/Models/Transformers/PermissionSetGraphQLTransformer.cs b/src/Tgstation.Server.Host/Models/Transformers/PermissionSetGraphQLTransformer.cs index 1575f04df99..49f70f462f8 100644 --- a/src/Tgstation.Server.Host/Models/Transformers/PermissionSetGraphQLTransformer.cs +++ b/src/Tgstation.Server.Host/Models/Transformers/PermissionSetGraphQLTransformer.cs @@ -11,7 +11,6 @@ sealed class PermissionSetGraphQLTransformer : TransformerBase new GraphQL.Types.PermissionSet { - Id = model.Id!.Value, AdministrationRights = model.AdministrationRights!.Value, InstanceManagerRights = model.InstanceManagerRights!.Value, }) From 8c9904f5327f281dde2bd0b7563679c4ee9ff2d6 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 15 Sep 2024 14:51:38 -0400 Subject: [PATCH 051/107] Correct a documentation comment --- src/Tgstation.Server.Host/GraphQL/Types/UserGroup.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Tgstation.Server.Host/GraphQL/Types/UserGroup.cs b/src/Tgstation.Server.Host/GraphQL/Types/UserGroup.cs index c9c5fcede24..2aa5853d19b 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/UserGroup.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/UserGroup.cs @@ -58,7 +58,7 @@ public async ValueTask PermissionSet( /// Queries all registered s in the . /// /// The . - /// A of all registered s in the indicated by . + /// A of all registered s in the . [UsePaging] [UseFiltering] [UseSorting] From f7969691ce9f3cf4c8ea231a0b1b199186292b8c Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 15 Sep 2024 14:51:55 -0400 Subject: [PATCH 052/107] Setup skeleton create user mutations --- .../Authority/ILoginAuthority.cs | 2 +- .../Authority/LoginAuthority.cs | 2 +- .../Controllers/ApiRootController.cs | 2 +- src/Tgstation.Server.Host/GraphQL/Mutation.cs | 4 +- .../Mutations/{ => Payloads}/LoginPayload.cs | 3 +- .../GraphQL/Mutations/UserMutations.cs | 120 ++++++++++++++++++ 6 files changed, 126 insertions(+), 7 deletions(-) rename src/Tgstation.Server.Host/GraphQL/Mutations/{ => Payloads}/LoginPayload.cs (92%) create mode 100644 src/Tgstation.Server.Host/GraphQL/Mutations/UserMutations.cs diff --git a/src/Tgstation.Server.Host/Authority/ILoginAuthority.cs b/src/Tgstation.Server.Host/Authority/ILoginAuthority.cs index 911cc7033e6..de0258db404 100644 --- a/src/Tgstation.Server.Host/Authority/ILoginAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/ILoginAuthority.cs @@ -2,7 +2,7 @@ using System.Threading.Tasks; using Tgstation.Server.Host.Authority.Core; -using Tgstation.Server.Host.GraphQL.Mutations; +using Tgstation.Server.Host.GraphQL.Mutations.Payloads; namespace Tgstation.Server.Host.Authority { diff --git a/src/Tgstation.Server.Host/Authority/LoginAuthority.cs b/src/Tgstation.Server.Host/Authority/LoginAuthority.cs index 8dbd4827a72..4c5d2d6a771 100644 --- a/src/Tgstation.Server.Host/Authority/LoginAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/LoginAuthority.cs @@ -11,7 +11,7 @@ using Tgstation.Server.Api.Models.Response; using Tgstation.Server.Host.Authority.Core; using Tgstation.Server.Host.Database; -using Tgstation.Server.Host.GraphQL.Mutations; +using Tgstation.Server.Host.GraphQL.Mutations.Payloads; using Tgstation.Server.Host.Models; using Tgstation.Server.Host.Models.Transformers; using Tgstation.Server.Host.Security; diff --git a/src/Tgstation.Server.Host/Controllers/ApiRootController.cs b/src/Tgstation.Server.Host/Controllers/ApiRootController.cs index 7a90d23f17d..08e499f233c 100644 --- a/src/Tgstation.Server.Host/Controllers/ApiRootController.cs +++ b/src/Tgstation.Server.Host/Controllers/ApiRootController.cs @@ -19,7 +19,7 @@ using Tgstation.Server.Host.Configuration; using Tgstation.Server.Host.Core; using Tgstation.Server.Host.Database; -using Tgstation.Server.Host.GraphQL.Mutations; +using Tgstation.Server.Host.GraphQL.Mutations.Payloads; using Tgstation.Server.Host.Models; using Tgstation.Server.Host.Security; using Tgstation.Server.Host.Security.OAuth; diff --git a/src/Tgstation.Server.Host/GraphQL/Mutation.cs b/src/Tgstation.Server.Host/GraphQL/Mutation.cs index f9b8d29d520..ff178262a51 100644 --- a/src/Tgstation.Server.Host/GraphQL/Mutation.cs +++ b/src/Tgstation.Server.Host/GraphQL/Mutation.cs @@ -6,7 +6,7 @@ using HotChocolate.Types; using Tgstation.Server.Host.Authority; -using Tgstation.Server.Host.GraphQL.Mutations; +using Tgstation.Server.Host.GraphQL.Mutations.Payloads; namespace Tgstation.Server.Host.GraphQL { @@ -17,7 +17,7 @@ namespace Tgstation.Server.Host.GraphQL public sealed class Mutation { /// - /// Generate JWT for authenticating with server. + /// Generate a JWT for authenticating with server. This is the only operation that accepts and required basic authentication. /// /// The . /// The for the operation. diff --git a/src/Tgstation.Server.Host/GraphQL/Mutations/LoginPayload.cs b/src/Tgstation.Server.Host/GraphQL/Mutations/Payloads/LoginPayload.cs similarity index 92% rename from src/Tgstation.Server.Host/GraphQL/Mutations/LoginPayload.cs rename to src/Tgstation.Server.Host/GraphQL/Mutations/Payloads/LoginPayload.cs index 31b9b2e0b37..5a055b6fcb3 100644 --- a/src/Tgstation.Server.Host/GraphQL/Mutations/LoginPayload.cs +++ b/src/Tgstation.Server.Host/GraphQL/Mutations/Payloads/LoginPayload.cs @@ -1,9 +1,8 @@ using HotChocolate; - using Tgstation.Server.Api.Models.Response; using Tgstation.Server.Host.Models; -namespace Tgstation.Server.Host.GraphQL.Mutations +namespace Tgstation.Server.Host.GraphQL.Mutations.Payloads { /// /// Success response for a login attempt. 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..1c9f0707e54 --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Mutations/UserMutations.cs @@ -0,0 +1,120 @@ +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.Types; + +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 owned of the user. + /// The . + /// The for the operation. + /// A resulting in the created . + [Error(typeof(ErrorMessageException))] + public ValueTask CreateUserByPasswordAndPermissionSet( + string name, + string password, + bool enabled, + PermissionSet permissionSet, + [Service] IGraphQLAuthorityInvoker userAuthority, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentException.ThrowIfNullOrEmpty(password); + ArgumentNullException.ThrowIfNull(permissionSet); + ArgumentNullException.ThrowIfNull(userAuthority); + + throw new NotImplementedException(); + } + + /// + /// Creates a system user specifying a personal . + /// + /// The of the . + /// If the is . + /// The owned of the user. + /// The . + /// The for the operation. + /// A resulting in the created . + [Error(typeof(ErrorMessageException))] + public ValueTask CreateUserBySystemIDAndPermissionSet( + string systemIdentifier, + bool enabled, + PermissionSet permissionSet, + [Service] IGraphQLAuthorityInvoker userAuthority, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(systemIdentifier); + ArgumentNullException.ThrowIfNull(permissionSet); + ArgumentNullException.ThrowIfNull(userAuthority); + + throw new NotImplementedException(); + } + + /// + /// Creates a TGS user specifying the they will belong to. + /// + /// The of the . + /// The password of the . + /// If the is . + /// The of the the will belong to. + /// The . + /// The for the operation. + /// A resulting in the created . + [Error(typeof(ErrorMessageException))] + public ValueTask CreateUserByPasswordAndGroup( + string name, + string password, + bool enabled, + [ID(nameof(UserGroup))] long groupId, + [Service] IGraphQLAuthorityInvoker userAuthority, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentException.ThrowIfNullOrEmpty(password); + ArgumentNullException.ThrowIfNull(userAuthority); + + throw new NotImplementedException(); + } + + /// + /// Creates a system user specifying the they will belong to. + /// + /// The of the . + /// If the is . + /// The of the the will belong to. + /// The . + /// The for the operation. + /// A resulting in the created . + [Error(typeof(ErrorMessageException))] + public ValueTask CreateUserBySystemIDAndGroup( + string systemIdentifier, + bool enabled, + [ID(nameof(UserGroup))] long groupId, + [Service] IGraphQLAuthorityInvoker userAuthority, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(systemIdentifier); + ArgumentNullException.ThrowIfNull(userAuthority); + + throw new NotImplementedException(); + } + } +} From 3ffbb74ceaa7be616f6e252681a168d451dab790 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 15 Sep 2024 17:17:35 -0400 Subject: [PATCH 053/107] Better support NoContent results in authorities --- .../Authority/Core/AuthorityBase.cs | 21 ++++++++++++++ .../Core/AuthorityResponse{TResult}.cs | 18 ++++++++++-- .../GraphQLAuthorityInvoker{TAuthority}.cs | 25 +++++++++++------ .../Core/RestAuthorityInvoker{TAuthority}.cs | 17 +++++------ .../IGraphQLAuthorityInvoker{TAuthority}.cs | 28 +++++++++++++++++-- 5 files changed, 89 insertions(+), 20 deletions(-) diff --git a/src/Tgstation.Server.Host/Authority/Core/AuthorityBase.cs b/src/Tgstation.Server.Host/Authority/Core/AuthorityBase.cs index 898c701eb80..f0e39151a39 100644 --- a/src/Tgstation.Server.Host/Authority/Core/AuthorityBase.cs +++ b/src/Tgstation.Server.Host/Authority/Core/AuthorityBase.cs @@ -53,6 +53,16 @@ protected static AuthorityResponse Unauthorized() 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 . /// @@ -73,6 +83,17 @@ protected static AuthorityResponse NotFound() 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. /// diff --git a/src/Tgstation.Server.Host/Authority/Core/AuthorityResponse{TResult}.cs b/src/Tgstation.Server.Host/Authority/Core/AuthorityResponse{TResult}.cs index 44376f488ce..9c2cfd295cd 100644 --- a/src/Tgstation.Server.Host/Authority/Core/AuthorityResponse{TResult}.cs +++ b/src/Tgstation.Server.Host/Authority/Core/AuthorityResponse{TResult}.cs @@ -13,10 +13,16 @@ namespace Tgstation.Server.Host.Authority.Core public sealed class AuthorityResponse : AuthorityResponse { /// - [MemberNotNullWhen(true, nameof(Result))] - [MemberNotNullWhen(true, nameof(SuccessResponse))] + [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 . /// @@ -47,5 +53,13 @@ public AuthorityResponse(TResult result, HttpSuccessResponse httpResponse = Http 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 index 794bd467efe..a25a04e2789 100644 --- a/src/Tgstation.Server.Host/Authority/Core/GraphQLAuthorityInvoker{TAuthority}.cs +++ b/src/Tgstation.Server.Host/Authority/Core/GraphQLAuthorityInvoker{TAuthority}.cs @@ -13,11 +13,12 @@ sealed class GraphQLAuthorityInvoker : AuthorityInvokerBase for errored s. /// /// The potentially errored . - static void ThrowGraphQLErrorIfNecessary(AuthorityResponse authorityResponse) + /// 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) + || ((authorityResponse.FailureResponse.Value == HttpFailureResponse.NotFound + || authorityResponse.FailureResponse.Value == HttpFailureResponse.Gone) && !errorOnMissing)) return; var fallbackString = authorityResponse.FailureResponse.ToString()!; @@ -39,33 +40,41 @@ async ValueTask IGraphQLAuthorityInvoker.Invoke(Func - async ValueTask IGraphQLAuthorityInvoker.Invoke(Func>> authorityInvoker) + async ValueTask IGraphQLAuthorityInvoker.InvokeAllowMissing(Func>> authorityInvoker) where TApiModel : default { ArgumentNullException.ThrowIfNull(authorityInvoker); var authorityResponse = await authorityInvoker(Authority); - ThrowGraphQLErrorIfNecessary(authorityResponse); + ThrowGraphQLErrorIfNecessary(authorityResponse, false); return authorityResponse.Result; } /// - async ValueTask IGraphQLAuthorityInvoker.InvokeTransformable(Func>> authorityInvoker) + async ValueTask IGraphQLAuthorityInvoker.InvokeTransformableAllowMissing(Func>> authorityInvoker) where TApiModel : default { ArgumentNullException.ThrowIfNull(authorityInvoker); var authorityResponse = await authorityInvoker(Authority); - ThrowGraphQLErrorIfNecessary(authorityResponse); + 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/RestAuthorityInvoker{TAuthority}.cs b/src/Tgstation.Server.Host/Authority/Core/RestAuthorityInvoker{TAuthority}.cs index 83911ecb56a..680dc98a4ef 100644 --- a/src/Tgstation.Server.Host/Authority/Core/RestAuthorityInvoker{TAuthority}.cs +++ b/src/Tgstation.Server.Host/Authority/Core/RestAuthorityInvoker{TAuthority}.cs @@ -14,18 +14,22 @@ sealed class RestAuthorityInvoker : AuthorityInvokerBase where TAuthority : IAuthority { /// - /// Create an for a given successfuly API . + /// Create an for a given successfuly. /// /// The to use. - /// The resulting from the . + /// 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, TApiModel result, AuthorityResponse authorityResponse) + 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), @@ -95,8 +99,7 @@ async ValueTask IRestAuthorityInvoker.Invoke result, authorityResponse); } /// @@ -110,9 +113,7 @@ async ValueTask IRestAuthorityInvoker.InvokeTransform if (erroredResult != null) return erroredResult; - var result = authorityResponse.Result!; - var apiModel = result.ToApi(); - return CreateSuccessfulActionResult(controller, apiModel, authorityResponse); + 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 index eb179caf9be..edface219a8 100644 --- a/src/Tgstation.Server.Host/Authority/IGraphQLAuthorityInvoker{TAuthority}.cs +++ b/src/Tgstation.Server.Host/Authority/IGraphQLAuthorityInvoker{TAuthority}.cs @@ -27,7 +27,7 @@ public interface IGraphQLAuthorityInvoker : IAuthorityInvokerThe resulting of the return value. /// The returning a resulting in the . /// A resulting in the generated for the resulting . - ValueTask Invoke(Func>> authorityInvoker) + ValueTask InvokeAllowMissing(Func>> authorityInvoker) where TResult : TApiModel where TApiModel : notnull; @@ -39,7 +39,31 @@ public interface IGraphQLAuthorityInvoker : IAuthorityInvokerThe for converting s to s. /// The returning a resulting in the . /// A resulting in the generated for the resulting . - ValueTask InvokeTransformable(Func>> authorityInvoker) + 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(); From 9019396fc3a715e96e45922249bd5f17096efb8a Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 15 Sep 2024 17:19:12 -0400 Subject: [PATCH 054/107] Clean up GraphQL username retrieval --- .../GraphQL/Types/UserName.cs | 16 ++++++++------- .../UserNameGraphQLTransformer.cs | 20 +++++++++++++++++++ src/Tgstation.Server.Host/Models/User.cs | 3 ++- 3 files changed, 31 insertions(+), 8 deletions(-) create mode 100644 src/Tgstation.Server.Host/Models/Transformers/UserNameGraphQLTransformer.cs diff --git a/src/Tgstation.Server.Host/GraphQL/Types/UserName.cs b/src/Tgstation.Server.Host/GraphQL/Types/UserName.cs index 832aaa17981..016a1bb2f4e 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/UserName.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/UserName.cs @@ -27,19 +27,14 @@ public sealed class UserName : NamedEntity, IUserName /// The for the operation. /// A resulting in the queried , if present. [TgsGraphQLAuthorize] - public static async ValueTask GetUserName( + public static ValueTask GetUserName( long id, [Service] IGraphQLAuthorityInvoker userAuthority, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(userAuthority); - var user = await userAuthority.InvokeTransformable( + return userAuthority.InvokeTransformableAllowMissing( authority => authority.GetId(id, false, true, cancellationToken)); - - if (user == null) - return null; - - return new UserName(user); } /// @@ -51,5 +46,12 @@ public UserName(NamedEntity copy) : base(copy) { } + + /// + /// Initializes a new instance of the class. + /// + public UserName() + { + } } } 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 46c5530da1a..c573368deb0 100644 --- a/src/Tgstation.Server.Host/Models/User.cs +++ b/src/Tgstation.Server.Host/Models/User.cs @@ -12,7 +12,8 @@ namespace Tgstation.Server.Host.Models /// public sealed class User : Api.Models.Internal.UserModelBase, ILegacyApiTransformable, - IApiTransformable + IApiTransformable, + IApiTransformable { /// /// Username used when creating jobs automatically. From 0160d17d67833ce9af895c0ec25dda3cb8a6c040 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 15 Sep 2024 17:27:47 -0400 Subject: [PATCH 055/107] Implement GraphQL user creation, `UserAuthority.Update`, and make controller fully authority reliant --- .../Authority/IUserAuthority.cs | 27 +- .../Authority/IUserGroupAuthority.cs | 2 +- .../Authority/UserAuthority.cs | 359 +++++++++++++++++- .../Controllers/ApiController.cs | 11 - .../Controllers/UserController.cs | 357 +---------------- src/Tgstation.Server.Host/GraphQL/Mutation.cs | 2 +- .../GraphQL/Mutations/UserMutations.cs | 101 ++++- .../GraphQL/Types/User.cs | 19 +- .../GraphQL/Types/UserGroup.cs | 6 +- .../GraphQL/Types/UserGroups.cs | 2 +- .../GraphQL/Types/Users.cs | 2 +- 11 files changed, 498 insertions(+), 390 deletions(-) diff --git a/src/Tgstation.Server.Host/Authority/IUserAuthority.cs b/src/Tgstation.Server.Host/Authority/IUserAuthority.cs index 0d99ca88f28..d95d7d58a1d 100644 --- a/src/Tgstation.Server.Host/Authority/IUserAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/IUserAuthority.cs @@ -3,6 +3,7 @@ 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; @@ -21,7 +22,7 @@ public interface IUserAuthority : IAuthority /// The for the operation. /// A resulting in a . [TgsAuthorize] - public ValueTask> Read(CancellationToken cancellationToken); + ValueTask> Read(CancellationToken cancellationToken); /// /// Gets the with a given . @@ -32,7 +33,7 @@ public interface IUserAuthority : IAuthority /// The for the operation. /// A resulting in a . [TgsAuthorize(AdministrationRights.ReadUsers)] - public ValueTask> GetId(long id, bool includeJoins, bool allowSystemUser, CancellationToken cancellationToken); + ValueTask> GetId(long id, bool includeJoins, bool allowSystemUser, CancellationToken cancellationToken); /// /// Gets the s for the with a given . @@ -40,7 +41,7 @@ public interface IUserAuthority : IAuthority /// The of the . /// The for the operation. /// A resulting in an of . - public ValueTask> OAuthConnections(long userId, CancellationToken cancellationToken); + ValueTask> OAuthConnections(long userId, CancellationToken cancellationToken); /// /// Gets all registered s. @@ -48,6 +49,24 @@ public interface IUserAuthority : IAuthority /// If related entities should be loaded. /// A of s. [TgsAuthorize(AdministrationRights.ReadUsers)] - public IQueryable Queryable(bool includeJoins); + IQueryable Queryable(bool includeJoins); + + /// + /// Creates a . + /// + /// The . + /// The for the operation. + /// A resulting in am for the created . + [TgsAuthorize(AdministrationRights.WriteUsers)] + ValueTask> Create(UserCreateRequest createRequest, 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 index 041e530a7a1..1e9d516228e 100644 --- a/src/Tgstation.Server.Host/Authority/IUserGroupAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/IUserGroupAuthority.cs @@ -28,7 +28,7 @@ public interface IUserGroupAuthority : IAuthority /// The for the operation. /// A resulting in a . [TgsAuthorize(AdministrationRights.ReadUsers)] - public ValueTask> GetId(long id, bool includeJoins, CancellationToken cancellationToken); + ValueTask> GetId(long id, bool includeJoins, CancellationToken cancellationToken); /// /// Gets all registered s. diff --git a/src/Tgstation.Server.Host/Authority/UserAuthority.cs b/src/Tgstation.Server.Host/Authority/UserAuthority.cs index f2a8e7646ff..40b48be96de 100644 --- a/src/Tgstation.Server.Host/Authority/UserAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/UserAuthority.cs @@ -8,9 +8,15 @@ 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.Authority.Core; +using Tgstation.Server.Host.Configuration; using Tgstation.Server.Host.Database; using Tgstation.Server.Host.Models; using Tgstation.Server.Host.Security; @@ -30,10 +36,30 @@ sealed class UserAuthority : AuthorityBase, IUserAuthority /// readonly IOAuthConnectionsDataLoader oAuthConnectionsDataLoader; + /// + /// The for the . + /// + readonly ISystemIdentityFactory systemIdentityFactory; + + /// + /// The for the . + /// + readonly IPermissionsUpdateNotifyee permissionsUpdateNotifyee; + + /// + /// The for the . + /// + readonly ICryptographySuite cryptographySuite; + + /// + /// The of for the . + /// + readonly IOptionsSnapshot generalConfigurationOptions; + /// /// Implements the . /// - /// The of s to load. + /// The of s to load. /// The to load from. /// The for the operation. /// A resulting in a of the requested s. @@ -56,7 +82,7 @@ public static Task> GetUsers( /// /// Implements the . /// - /// The of s to load the OAuthConnections for. + /// The of s to load the OAuthConnections for. /// The to load from. /// The for the operation. /// A resulting in a of the requested s. @@ -80,6 +106,24 @@ public static Task> GetUsers( 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. /// @@ -88,12 +132,20 @@ public static Task> GetUsers( /// The to use. /// 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) + IOAuthConnectionsDataLoader oAuthConnectionsDataLoader, + ISystemIdentityFactory systemIdentityFactory, + IPermissionsUpdateNotifyee permissionsUpdateNotifyee, + ICryptographySuite cryptographySuite, + IOptionsSnapshot generalConfigurationOptions) : base( authenticationContext, databaseContext, @@ -101,6 +153,10 @@ public UserAuthority( { 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.generalConfigurationOptions = generalConfigurationOptions ?? throw new ArgumentNullException(nameof(generalConfigurationOptions)); } /// @@ -143,6 +199,237 @@ public IQueryable Queryable(bool includeJoins) => new AuthorityResponse( await oAuthConnectionsDataLoader.LoadRequiredAsync(userId, cancellationToken)); + /// + public async ValueTask> Create( + UserCreateRequest createRequest, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(createRequest); + + if (createRequest.OAuthConnections?.Any(x => x == null) == true) + return BadRequest(ErrorCode.ModelValidationFailure); + + if ((createRequest.Password != null && createRequest.SystemIdentifier != null) + || (createRequest.Password == null && createRequest.SystemIdentifier == null && (createRequest.OAuthConnections?.Count > 0) != true)) + return BadRequest(ErrorCode.UserMismatchPasswordSid); + + if (createRequest.Group != null && createRequest.PermissionSet != null) + return BadRequest(ErrorCode.UserGroupAndPermissionSet); + + createRequest.Name = createRequest.Name?.Trim(); + if (createRequest.Name?.Length == 0) + createRequest.Name = null; + + if (!(createRequest.Name == null ^ createRequest.SystemIdentifier == null)) + return BadRequest(ErrorCode.UserMismatchNameSid); + + var fail = CheckValidName(createRequest, true); + if (fail != null) + return fail; + + 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 if (!(createRequest.Password?.Length == 0 && (createRequest.OAuthConnections?.Count > 0) == true)) + { + 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); + + return new AuthorityResponse(dbUser, HttpSuccessResponse.Created); + } + + /// + public async ValueTask> Update(UserUpdateRequest model, CancellationToken cancellationToken) + { + 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; + 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(ErrorCode.UserSidChange); + + if (model.Password != null) + { + if (originalUserHasSid) + return BadRequest(ErrorCode.UserMismatchPasswordSid); + + var result = TrySetPassword(originalUser, model.Password, false); + if (result != null) + return result; + } + + if (model.Name != null && User.CanonicalizeName(model.Name) != originalUser.CanonicalName) + return BadRequest(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 == 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; + + 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 + ? new AuthorityResponse(originalUser) + : new AuthorityResponse(); + } + /// /// Gets all registered s. /// @@ -170,5 +457,71 @@ IQueryable Queryable(bool includeJoins, bool allowSystemUser) 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/Controllers/ApiController.cs b/src/Tgstation.Server.Host/Controllers/ApiController.cs index 439b3e3c079..3914d69da2e 100644 --- a/src/Tgstation.Server.Host/Controllers/ApiController.cs +++ b/src/Tgstation.Server.Host/Controllers/ApiController.cs @@ -186,17 +186,6 @@ protected ApiController( /// A with an appropriate . protected new NotFoundObjectResult NotFound() => NotFound(new ErrorMessageResponse(ErrorCode.ResourceNeverPresent)); - /// - /// 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 . /// diff --git a/src/Tgstation.Server.Host/Controllers/UserController.cs b/src/Tgstation.Server.Host/Controllers/UserController.cs index 2bb16eeffda..be6866f928c 100644 --- a/src/Tgstation.Server.Host/Controllers/UserController.cs +++ b/src/Tgstation.Server.Host/Controllers/UserController.cs @@ -1,13 +1,10 @@ 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; @@ -15,10 +12,8 @@ using Tgstation.Server.Api.Models.Response; using Tgstation.Server.Api.Rights; using Tgstation.Server.Host.Authority; -using Tgstation.Server.Host.Configuration; 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,52 +26,24 @@ namespace Tgstation.Server.Host.Controllers [Route(Routes.User)] public sealed class UserController : ApiController { - /// - /// The for the . - /// - readonly ISystemIdentityFactory systemIdentityFactory; - - /// - /// The for the . - /// - readonly ICryptographySuite cryptographySuite; - - /// - /// The for the . - /// - readonly IPermissionsUpdateNotifyee permissionsUpdateNotifyee; - /// /// The for the . /// readonly IRestAuthorityInvoker userAuthority; - /// - /// The for the . - /// - readonly GeneralConfiguration generalConfiguration; - /// /// 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, @@ -85,11 +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)); this.userAuthority = userAuthority ?? throw new ArgumentNullException(nameof(userAuthority)); - generalConfiguration = generalConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(generalConfigurationOptions)); } /// @@ -101,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 this.Created(dbUser.ToApi()); - } -#pragma warning restore CA1502, CA1506 + public ValueTask Create([FromBody] UserCreateRequest model, CancellationToken cancellationToken) + => userAuthority.InvokeTransformable(this, authority => authority.Create(model, cancellationToken)); /// /// Update a . /// - /// The to update. + /// The . /// The for the operation. /// A resulting in the of the operation. /// updated successfully. @@ -183,171 +80,13 @@ 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 . @@ -408,87 +147,5 @@ public async ValueTask GetId(long id, CancellationToken cancellat this, authority => authority.GetId(id, true, false, cancellationToken)); } - - /// - /// 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; - } } } diff --git a/src/Tgstation.Server.Host/GraphQL/Mutation.cs b/src/Tgstation.Server.Host/GraphQL/Mutation.cs index ff178262a51..0e1bba29d91 100644 --- a/src/Tgstation.Server.Host/GraphQL/Mutation.cs +++ b/src/Tgstation.Server.Host/GraphQL/Mutation.cs @@ -30,7 +30,7 @@ public ValueTask Login( ArgumentNullException.ThrowIfNull(loginAuthority); return loginAuthority.Invoke( - authority => authority.AttemptLogin(cancellationToken))!; + authority => authority.AttemptLogin(cancellationToken)); } } } diff --git a/src/Tgstation.Server.Host/GraphQL/Mutations/UserMutations.cs b/src/Tgstation.Server.Host/GraphQL/Mutations/UserMutations.cs index 1c9f0707e54..9c5baac4e0b 100644 --- a/src/Tgstation.Server.Host/GraphQL/Mutations/UserMutations.cs +++ b/src/Tgstation.Server.Host/GraphQL/Mutations/UserMutations.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -6,8 +8,11 @@ using HotChocolate.Types; using HotChocolate.Types.Relay; +using Tgstation.Server.Api.Models.Request; using Tgstation.Server.Host.Authority; using Tgstation.Server.Host.GraphQL.Types; +using Tgstation.Server.Host.Models.Transformers; +using Tgstation.Server.Host.Security; namespace Tgstation.Server.Host.GraphQL.Mutations { @@ -23,15 +28,18 @@ public sealed class UserMutations /// The of the . /// The password of the . /// If the is . + /// The s for the user. /// The owned of the user. /// The . /// The for the operation. /// A resulting in the created . + [TgsGraphQLAuthorize(nameof(IUserAuthority.Create))] [Error(typeof(ErrorMessageException))] public ValueTask CreateUserByPasswordAndPermissionSet( string name, string password, bool enabled, + IEnumerable? oAuthConnections, PermissionSet permissionSet, [Service] IGraphQLAuthorityInvoker userAuthority, CancellationToken cancellationToken) @@ -41,7 +49,27 @@ public ValueTask CreateUserByPasswordAndPermissionSet( ArgumentNullException.ThrowIfNull(permissionSet); ArgumentNullException.ThrowIfNull(userAuthority); - throw new NotImplementedException(); + return userAuthority.InvokeTransformable( + authority => authority.Create( + new UserCreateRequest + { + Name = name, + Password = password, + Enabled = enabled, + PermissionSet = new Api.Models.PermissionSet + { + AdministrationRights = permissionSet.AdministrationRights, + InstanceManagerRights = permissionSet.InstanceManagerRights, + }, + OAuthConnections = oAuthConnections + ?.Select(oAuthConnection => new Api.Models.OAuthConnection + { + ExternalUserId = oAuthConnection.ExternalUserId, + Provider = oAuthConnection.Provider, + }) + .ToList(), + }, + cancellationToken)); } /// @@ -49,14 +77,17 @@ public ValueTask CreateUserByPasswordAndPermissionSet( /// /// The of the . /// If the is . + /// The s for the user. /// The owned of the user. /// The . /// The for the operation. /// A resulting in the created . + [TgsGraphQLAuthorize(nameof(IUserAuthority.Create))] [Error(typeof(ErrorMessageException))] public ValueTask CreateUserBySystemIDAndPermissionSet( string systemIdentifier, bool enabled, + IEnumerable? oAuthConnections, PermissionSet permissionSet, [Service] IGraphQLAuthorityInvoker userAuthority, CancellationToken cancellationToken) @@ -65,7 +96,26 @@ public ValueTask CreateUserBySystemIDAndPermissionSet( ArgumentNullException.ThrowIfNull(permissionSet); ArgumentNullException.ThrowIfNull(userAuthority); - throw new NotImplementedException(); + return userAuthority.InvokeTransformable( + authority => authority.Create( + new UserCreateRequest + { + SystemIdentifier = systemIdentifier, + Enabled = enabled, + PermissionSet = new Api.Models.PermissionSet + { + AdministrationRights = permissionSet.AdministrationRights, + InstanceManagerRights = permissionSet.InstanceManagerRights, + }, + OAuthConnections = oAuthConnections + ?.Select(oAuthConnection => new Api.Models.OAuthConnection + { + ExternalUserId = oAuthConnection.ExternalUserId, + Provider = oAuthConnection.Provider, + }) + .ToList(), + }, + cancellationToken)); } /// @@ -74,15 +124,18 @@ public ValueTask CreateUserBySystemIDAndPermissionSet( /// The of the . /// The password of the . /// If the is . + /// The s for the user. /// The of the the will belong to. /// The . /// The for the operation. /// A resulting in 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) @@ -91,7 +144,26 @@ public ValueTask CreateUserByPasswordAndGroup( ArgumentException.ThrowIfNullOrEmpty(password); ArgumentNullException.ThrowIfNull(userAuthority); - throw new NotImplementedException(); + 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(), + }, + cancellationToken)); } /// @@ -99,14 +171,17 @@ public ValueTask CreateUserByPasswordAndGroup( /// /// The of the . /// If the is . + /// The s for the user. /// The of the the will belong to. /// The . /// The for the operation. /// A resulting in the created . + [TgsGraphQLAuthorize(nameof(IUserAuthority.Create))] [Error(typeof(ErrorMessageException))] public ValueTask CreateUserBySystemIDAndGroup( string systemIdentifier, bool enabled, + IEnumerable? oAuthConnections, [ID(nameof(UserGroup))] long groupId, [Service] IGraphQLAuthorityInvoker userAuthority, CancellationToken cancellationToken) @@ -114,7 +189,25 @@ public ValueTask CreateUserBySystemIDAndGroup( ArgumentException.ThrowIfNullOrWhiteSpace(systemIdentifier); ArgumentNullException.ThrowIfNull(userAuthority); - throw new NotImplementedException(); + 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(), + }, + cancellationToken)); } } } diff --git a/src/Tgstation.Server.Host/GraphQL/Types/User.cs b/src/Tgstation.Server.Host/GraphQL/Types/User.cs index 87bde51eac0..f47a3fb7849 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/User.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/User.cs @@ -64,7 +64,7 @@ public sealed class User : NamedEntity, IUserName CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(userAuthority); - return userAuthority.InvokeTransformable( + return userAuthority.InvokeTransformableAllowMissing( authority => authority.GetId(id, false, false, cancellationToken)); } @@ -83,9 +83,6 @@ public sealed class User : NamedEntity, IUserName return null; var user = await userAuthority.InvokeTransformable(authority => authority.GetId(CreatedById.Value, false, true, cancellationToken)); - if (user == null) - return null; - if (user.CanonicalName == Models.User.CanonicalizeName(Models.User.TgsSystemUserName)) return new UserName(user); @@ -98,13 +95,13 @@ public sealed class User : NamedEntity, IUserName /// The . /// The for the operation. /// A resulting in a new of s for the if OAuth is configured. - public async ValueTask OAuthConnections( + public ValueTask OAuthConnections( [Service] IGraphQLAuthorityInvoker userAuthority, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(userAuthority); - return (await userAuthority.Invoke( - authority => authority.OAuthConnections(Id, cancellationToken)))!; + return userAuthority.Invoke( + authority => authority.OAuthConnections(Id, cancellationToken)); } /// @@ -113,7 +110,7 @@ public async ValueTask OAuthConnections( /// The . /// The for the operation. /// A resulting in the associated with the . - public async ValueTask EffectivePermissionSet( + public ValueTask EffectivePermissionSet( [Service] IGraphQLAuthorityInvoker permissionSetAuthority, CancellationToken cancellationToken) { @@ -132,8 +129,8 @@ public async ValueTask EffectivePermissionSet( lookupType = PermissionSetLookupType.UserId; } - return (await permissionSetAuthority.InvokeTransformable( - authority => authority.GetId(lookupId, lookupType, cancellationToken)))!; + return permissionSetAuthority.InvokeTransformable( + authority => authority.GetId(lookupId, lookupType, cancellationToken)); } /// @@ -148,7 +145,7 @@ public async ValueTask EffectivePermissionSet( { ArgumentNullException.ThrowIfNull(permissionSetAuthority); - return permissionSetAuthority.InvokeTransformable( + return permissionSetAuthority.InvokeTransformableAllowMissing( authority => authority.GetId(Id, PermissionSetLookupType.UserId, cancellationToken)); } diff --git a/src/Tgstation.Server.Host/GraphQL/Types/UserGroup.cs b/src/Tgstation.Server.Host/GraphQL/Types/UserGroup.cs index 2aa5853d19b..e8cd9cc855e 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/UserGroup.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/UserGroup.cs @@ -34,7 +34,7 @@ public sealed class UserGroup : NamedEntity CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(userGroupAuthority); - return userGroupAuthority.InvokeTransformable( + return userGroupAuthority.InvokeTransformableAllowMissing( authority => authority.GetId(id, false, cancellationToken)); } @@ -50,8 +50,8 @@ public async ValueTask PermissionSet( { ArgumentNullException.ThrowIfNull(permissionSetAuthority); - return (await permissionSetAuthority.InvokeTransformable( - authority => authority.GetId(Id, PermissionSetLookupType.GroupId, cancellationToken)))!; + return await permissionSetAuthority.InvokeTransformable( + authority => authority.GetId(Id, PermissionSetLookupType.GroupId, cancellationToken)); } /// diff --git a/src/Tgstation.Server.Host/GraphQL/Types/UserGroups.cs b/src/Tgstation.Server.Host/GraphQL/Types/UserGroups.cs index b54062e9f64..51240221c84 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/UserGroups.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/UserGroups.cs @@ -28,7 +28,7 @@ public sealed class UserGroups [Service] IGraphQLAuthorityInvoker userGroupAuthority) { ArgumentNullException.ThrowIfNull(userGroupAuthority); - return userGroupAuthority.InvokeTransformable(authority => authority.Read()); + return userGroupAuthority.InvokeTransformableAllowMissing(authority => authority.Read()); } /// diff --git a/src/Tgstation.Server.Host/GraphQL/Types/Users.cs b/src/Tgstation.Server.Host/GraphQL/Types/Users.cs index 9052345de85..d93f4ceec89 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/Users.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/Users.cs @@ -39,7 +39,7 @@ public ValueTask Current( CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(userAuthority); - return userAuthority.InvokeTransformable(authority => authority.Read(cancellationToken))!; + return userAuthority.InvokeTransformable(authority => authority.Read(cancellationToken)); } /// From b8025963758be7f99bf96c4d2b9f88536b846df3 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 15 Sep 2024 17:29:28 -0400 Subject: [PATCH 056/107] Apply a bunch of code suggestions --- src/Tgstation.Server.Api/ApiHeaders.cs | 2 +- src/Tgstation.Server.Host/Controllers/ApiController.cs | 2 +- src/Tgstation.Server.Host/Utils/SwaggerConfiguration.cs | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) 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.Host/Controllers/ApiController.cs b/src/Tgstation.Server.Host/Controllers/ApiController.cs index 3914d69da2e..8a86e895b74 100644 --- a/src/Tgstation.Server.Host/Controllers/ApiController.cs +++ b/src/Tgstation.Server.Host/Controllers/ApiController.cs @@ -337,7 +337,7 @@ async ValueTask PaginatedImpl( else { totalResults = paginationResult.Results.Count(); - pagedResults = queriedResults.ToList(); + pagedResults = [.. queriedResults]; } ICollection finalResults; 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, From 8e030eda38969ac22cb948bbc48fa08c2cb3b069 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 15 Sep 2024 17:49:59 -0400 Subject: [PATCH 057/107] Some code cleanup regarding port allocation --- .../Live/TestLiveServer.cs | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs b/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs index 597889e131b..8576950da56 100644 --- a/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs +++ b/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; @@ -56,11 +56,11 @@ public sealed class TestLiveServer 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() { @@ -157,14 +157,14 @@ static bool TerminateAllEngineServers() return result; } - static ushort tcpPortCounter = InitialPort; + static int tcpPortCounter = InitialPort; - static ushort FreeTcpPort(params ushort[] usedPorts) + static ushort FreeTcpPort() { - ushort result = tcpPortCounter++; + var result = Interlocked.Increment(ref tcpPortCounter); Console.WriteLine($"Allocated port: {result}"); - return result; + return (ushort)result; } [ClassInitialize] From c9c24a5dbf0d35d7a246fa235c9a7f9bc2d904b5 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Tue, 17 Sep 2024 20:12:25 -0400 Subject: [PATCH 058/107] Flesh out GraphQL client --- .../AuthenticatedGraphQLServerClient.cs | 97 +++++++ .../AuthenticationException.cs | 51 ++++ .../AuthorizationMessageHandler.cs | 42 ++++ .../GQL/Queries/OAuthInformation.graphql | 18 ++ .../GQL/Queries/ServerVersion.graphql | 11 + .../GraphQLServerClient.cs | 237 ++++++++++++++++- .../GraphQLServerClientFactory.cs | 173 +++++++++++-- .../IGraphQLServerClient.cs | 26 +- .../IGraphQLServerClientFactory.cs | 8 +- .../LoginResultExtensions.cs | 74 ++++++ .../Serializers/JwtSerializer.cs | 35 +++ .../Serializers/SemverSerializer.cs | 4 +- .../Serializers/UnsignedIntSerializer.cs | 2 +- .../Tgstation.Server.Client.GraphQL.csproj | 2 + .../schema.extensions.graphql | 1 + .../Authority/UserAuthority.cs | 4 + .../Mutations/Payloads/LoginPayload.cs | 2 + .../GraphQL/Types/Scalars/JwtType.cs | 38 +++ .../GraphQL/Types/Scalars/SemverType.cs | 2 +- .../Live/MultiServerClient.cs | 45 ++-- .../Live/RawRequestTests.cs | 6 +- .../Live/TestLiveServer.cs | 238 ++++++++++++------ 22 files changed, 980 insertions(+), 136 deletions(-) create mode 100644 src/Tgstation.Server.Client.GraphQL/AuthenticatedGraphQLServerClient.cs create mode 100644 src/Tgstation.Server.Client.GraphQL/AuthenticationException.cs create mode 100644 src/Tgstation.Server.Client.GraphQL/AuthorizationMessageHandler.cs create mode 100644 src/Tgstation.Server.Client.GraphQL/GQL/Queries/OAuthInformation.graphql create mode 100644 src/Tgstation.Server.Client.GraphQL/GQL/Queries/ServerVersion.graphql create mode 100644 src/Tgstation.Server.Client.GraphQL/LoginResultExtensions.cs create mode 100644 src/Tgstation.Server.Client.GraphQL/Serializers/JwtSerializer.cs create mode 100644 src/Tgstation.Server.Host/GraphQL/Types/Scalars/JwtType.cs 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/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/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/GraphQLServerClient.cs b/src/Tgstation.Server.Client.GraphQL/GraphQLServerClient.cs index b359139b693..60004ce1fd6 100644 --- a/src/Tgstation.Server.Client.GraphQL/GraphQLServerClient.cs +++ b/src/Tgstation.Server.Client.GraphQL/GraphQLServerClient.cs @@ -1,11 +1,35 @@ using System; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +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 +40,232 @@ 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!"); + + /// + /// Checks if a given errored out with authentication errors. + /// + /// The . + /// if errored due to authentication issues, otherwise. + static bool IsAuthenticationError(IOperationResult operationResult) + => operationResult.Data == null + && operationResult.Errors.Any( + error => error.Extensions?.TryGetValue( + "code", + out object? codeExtension) == true + && codeExtension is string codeExtensionString + && codeExtensionString == "AUTH_NOT_AUTHENTICATED"); + /// /// 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 DisposeAsync() => serviceProvider.DisposeAsync(); + public ValueTask> RunOperationAsync(Func>> queryExector, CancellationToken cancellationToken) + where TResultData : class + { + ArgumentNullException.ThrowIfNull(queryExector); + return WrapAuthentication(queryExector, cancellationToken); + } /// - public virtual ValueTask RunQuery(Func queryExector) + public ValueTask> RunOperation(Func>> queryExector, CancellationToken cancellationToken) + where TResultData : class { ArgumentNullException.ThrowIfNull(queryExector); - return queryExector(graphQLClient); + return WrapAuthentication(async localClient => await queryExector(localClient), cancellationToken); + } + + /// + /// 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(); + + async ValueTask Reauthenticate(AuthenticationHeaderValue currentToken, CancellationToken cancellationToken) + { + 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; + } + } + + var (currentAuthHeader, expires) = tuple.Value; + if (expires <= DateTimeOffset.UtcNow) + currentAuthHeader = await Reauthenticate(currentAuthHeader, cancellationToken).ConfigureAwait(false); + + setAuthenticationHeader(currentAuthHeader); + + var operationResult = await operationExecutor(graphQLClient); + + if (IsAuthenticationError(operationResult)) + { + currentAuthHeader = await Reauthenticate(currentAuthHeader, cancellationToken).ConfigureAwait(false); + setAuthenticationHeader(currentAuthHeader); + return await operationExecutor(graphQLClient); + } + + return operationResult; + } + + /// + /// 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..c20a606fa09 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,25 @@ 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. /// - /// 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 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; } } 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/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.Host/Authority/UserAuthority.cs b/src/Tgstation.Server.Host/Authority/UserAuthority.cs index 40b48be96de..ed7dba09977 100644 --- a/src/Tgstation.Server.Host/Authority/UserAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/UserAuthority.cs @@ -273,7 +273,11 @@ public async ValueTask> Create( } /// +#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); diff --git a/src/Tgstation.Server.Host/GraphQL/Mutations/Payloads/LoginPayload.cs b/src/Tgstation.Server.Host/GraphQL/Mutations/Payloads/LoginPayload.cs index 5a055b6fcb3..8d5cd5e6831 100644 --- a/src/Tgstation.Server.Host/GraphQL/Mutations/Payloads/LoginPayload.cs +++ b/src/Tgstation.Server.Host/GraphQL/Mutations/Payloads/LoginPayload.cs @@ -1,5 +1,6 @@ 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 @@ -12,6 +13,7 @@ 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; } /// 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 bd07aad71f5..dce31b47514 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/Scalars/SemverType.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/Scalars/SemverType.cs @@ -62,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/tests/Tgstation.Server.Tests/Live/MultiServerClient.cs b/tests/Tgstation.Server.Tests/Live/MultiServerClient.cs index 801f4451bdc..3fd2b4b8f6c 100644 --- a/tests/Tgstation.Server.Tests/Live/MultiServerClient.cs +++ b/tests/Tgstation.Server.Tests/Live/MultiServerClient.cs @@ -1,48 +1,57 @@ 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 : 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; + RestClient = restServerClient ?? throw new ArgumentNullException(nameof(restServerClient)); + GraphQLClient = graphQLServerClient ?? throw new ArgumentNullException(nameof(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( 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); var restResult = await restTask; - Assert.IsTrue(comparison(restResult, graphQLResult), "REST/GraphQL results differ!"); + Assert.IsTrue(comparison(restResult, graphQLResult.Data), "REST/GraphQL results differ!"); } } } diff --git a/tests/Tgstation.Server.Tests/Live/RawRequestTests.cs b/tests/Tgstation.Server.Tests/Live/RawRequestTests.cs index bac0433d4fa..240a89c245f 100644 --- a/tests/Tgstation.Server.Tests/Live/RawRequestTests.cs +++ b/tests/Tgstation.Server.Tests/Live/RawRequestTests.cs @@ -463,11 +463,7 @@ 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); - IOperationResult result = null; - await gqlClient.RunQuery(async client => - { - result = await client.Login.ExecuteAsync(cancellationToken); - }); + var result = await gqlClient.RunOperation(client => client.Login.ExecuteAsync(cancellationToken), cancellationToken); Assert.IsNotNull(result.Data); Assert.IsNull(result.Data.Login.Bearer); diff --git a/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs b/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs index 8576950da56..e47fd8c822a 100644 --- a/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs +++ b/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; @@ -27,6 +27,8 @@ using Npgsql; +using StrawberryShake; + using Tgstation.Server.Api; using Tgstation.Server.Api.Extensions; using Tgstation.Server.Api.Models; @@ -73,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) { @@ -295,7 +304,7 @@ async ValueTask TestWithoutAndWithPermission(Func TestWithoutAndWithPermission(Func adminClient.Administration.Update( + () => adminClient.RestClient.Administration.Update( new ServerUpdateRequest { NewVersion = TestUpdateVersion, @@ -314,7 +323,7 @@ async ValueTask TestWithoutAndWithPermission(Func TestWithoutAndWithPermission(Func adminClient.Administration.Update( + () => adminClient.RestClient.Administration.Update( new ServerUpdateRequest { NewVersion = TestUpdateVersion, @@ -384,7 +393,7 @@ async Task CheckUpdate() }, downloadStream, cancellationToken), - adminClient, + adminClient.RestClient, AdministrationRights.UploadVersion); Assert.IsNotNull(responseModel); @@ -424,7 +433,7 @@ 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 @@ -474,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) { @@ -489,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 @@ -577,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() { @@ -585,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); } @@ -618,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", @@ -635,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", @@ -656,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), Api.Models.ErrorCode.ResourceNotPresent); - await ApiAssert.ThrowsException(() => node1Client.Instances.GetId(controllerInstance, cancellationToken), Api.Models.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 @@ -717,7 +726,7 @@ 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 @@ -738,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); } @@ -747,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); @@ -765,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, @@ -848,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) { @@ -889,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); @@ -905,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)); @@ -930,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")); @@ -941,20 +950,20 @@ 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 }, @@ -967,10 +976,10 @@ await ApiAssert.ThrowsException( // 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")); } @@ -1021,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 @@ -1327,34 +1336,48 @@ 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 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); + } + + 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()); } // 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.UnauthenticatedServerInformation.ExecuteAsync(cancellationToken)).Data, + gqlClient => gqlClient.UnauthenticatedServerInformation.ExecuteAsync(cancellationToken), (restServerInfo, gqlServerInfo) => restServerInfo.ApiVersion.Major == gqlServerInfo.Swarm.CurrentNode.Gateway.Information.MajorApiVersion && (restServerInfo.OAuthProviderInfos == gqlServerInfo.Swarm.CurrentNode.Gateway.Information.OAuthProviderInfos || restServerInfo.OAuthProviderInfos.All(kvp => @@ -1364,7 +1387,8 @@ await multiClient.ExecuteReadOnlyConfirmEquivalence( && info.Value.ServerUrl == kvp.Value.ServerUrl && info.Value.ClientId == kvp.Value.ClientId && info.Value.RedirectUri == kvp.Value.RedirectUri; - }))); + })), + cancellationToken); } async ValueTask CreateUserWithNoInstancePerms() @@ -1380,13 +1404,13 @@ 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 restClientFactory.CreateFromLogin(server.RootUrl, createRequest.Name, createRequest.Password, cancellationToken: cancellationToken); } - var jobsHubTest = new JobsHubTests(firstAdminClient, await CreateUserWithNoInstancePerms()); + var jobsHubTest = new JobsHubTests(firstAdminRestClient, await CreateUserWithNoInstancePerms()); Task jobsHubTestTask; { if (server.DumpOpenApiSpecpath) @@ -1422,11 +1446,11 @@ async Task FailFast(Task task) if (!openDreamOnly) { 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(firstAdminRestClient).Run(cancellationToken)); - 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); @@ -1436,7 +1460,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); @@ -1447,13 +1471,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); @@ -1482,7 +1506,7 @@ await instanceTest .RunCompatTests( await edgeODVersionTask, server.OpenDreamUrl, - firstAdminClient.Instances.CreateClient(odInstance), + firstAdminRestClient.Instances.CreateClient(odInstance), odDMPort.Value, odDDPort.Value, server.HighPriorityDreamDaemon, @@ -1509,7 +1533,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, @@ -1550,7 +1574,7 @@ await FailFast( initialSessionId = dd.SessionId.Value; jobsHubTest.ExpectShutdown(); - await firstAdminClient.Administration.Restart(cancellationToken); + await firstAdminRestClient.Administration.Restart(cancellationToken); } await Task.WhenAny(serverTask, Task.Delay(TimeSpan.FromMinutes(1), cancellationToken)); @@ -1599,8 +1623,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); @@ -1704,8 +1730,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); @@ -1737,7 +1764,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)); @@ -1747,8 +1774,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); @@ -1763,7 +1791,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; @@ -1772,7 +1800,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) @@ -1807,20 +1835,64 @@ await instanceClient.DreamDaemon.Update(new DreamDaemonRequest await serverTask; } - async Task CreateAdminClient(Uri url, CancellationToken cancellationToken) + async ValueTask CreateAdminClient(Uri url, CancellationToken cancellationToken) { 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, cancellationToken: cancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + graphQLClientTask = graphQLClientFactory.CreateFromLogin( + url, + DefaultCredentials.AdminUserName, + DefaultCredentials.DefaultAdminUserPassword, + 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) { From a31379739ad1a74f5d74d6dd94ff2d5379e113c0 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Tue, 17 Sep 2024 20:15:39 -0400 Subject: [PATCH 059/107] Apply code suggestions --- tests/Tgstation.Server.Tests/Live/TestLiveServer.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs b/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs index e47fd8c822a..56f79be166d 100644 --- a/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs +++ b/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs @@ -1056,7 +1056,7 @@ async ValueTask TestTgstation(bool interactive) if (!String.IsNullOrWhiteSpace(localRepoPath)) { await ioManager.CopyDirectory( - Enumerable.Empty(), + [], (src, dest) => { if (postWriteHandler.NeedsPostWrite(src)) @@ -1124,7 +1124,7 @@ async ValueTask RunGitCommand(string args) cancellationToken); var scriptsCopyTask = ioManager.CopyDirectory( - Enumerable.Empty(), + [], (src, dest) => { if (postWriteHandler.NeedsPostWrite(src)) From 55c1795a7ca01d07d697d7de41ae51393694fb82 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Tue, 17 Sep 2024 20:51:54 -0400 Subject: [PATCH 060/107] Implement remaining user mutations --- .../Mutations/Payloads/PermissionSetInput.cs | 20 +++ .../GraphQL/Mutations/UserMutations.cs | 136 +++++++++++++++++- 2 files changed, 150 insertions(+), 6 deletions(-) create mode 100644 src/Tgstation.Server.Host/GraphQL/Mutations/Payloads/PermissionSetInput.cs 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/UserMutations.cs b/src/Tgstation.Server.Host/GraphQL/Mutations/UserMutations.cs index 9c5baac4e0b..68613801693 100644 --- a/src/Tgstation.Server.Host/GraphQL/Mutations/UserMutations.cs +++ b/src/Tgstation.Server.Host/GraphQL/Mutations/UserMutations.cs @@ -9,7 +9,9 @@ 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; @@ -32,7 +34,7 @@ public sealed class UserMutations /// The owned of the user. /// The . /// The for the operation. - /// A resulting in the created . + /// The created . [TgsGraphQLAuthorize(nameof(IUserAuthority.Create))] [Error(typeof(ErrorMessageException))] public ValueTask CreateUserByPasswordAndPermissionSet( @@ -40,7 +42,7 @@ public ValueTask CreateUserByPasswordAndPermissionSet( string password, bool enabled, IEnumerable? oAuthConnections, - PermissionSet permissionSet, + PermissionSetInput permissionSet, [Service] IGraphQLAuthorityInvoker userAuthority, CancellationToken cancellationToken) { @@ -81,14 +83,14 @@ public ValueTask CreateUserByPasswordAndPermissionSet( /// The owned of the user. /// The . /// The for the operation. - /// A resulting in the created . + /// The created . [TgsGraphQLAuthorize(nameof(IUserAuthority.Create))] [Error(typeof(ErrorMessageException))] public ValueTask CreateUserBySystemIDAndPermissionSet( string systemIdentifier, bool enabled, IEnumerable? oAuthConnections, - PermissionSet permissionSet, + PermissionSetInput permissionSet, [Service] IGraphQLAuthorityInvoker userAuthority, CancellationToken cancellationToken) { @@ -128,7 +130,7 @@ public ValueTask CreateUserBySystemIDAndPermissionSet( /// The of the the will belong to. /// The . /// The for the operation. - /// A resulting in the created . + /// The created . [TgsGraphQLAuthorize(nameof(IUserAuthority.Create))] [Error(typeof(ErrorMessageException))] public ValueTask CreateUserByPasswordAndGroup( @@ -175,7 +177,7 @@ public ValueTask CreateUserByPasswordAndGroup( /// The of the the will belong to. /// The . /// The for the operation. - /// A resulting in the created . + /// The created . [TgsGraphQLAuthorize(nameof(IUserAuthority.Create))] [Error(typeof(ErrorMessageException))] public ValueTask CreateUserBySystemIDAndGroup( @@ -209,5 +211,127 @@ public ValueTask CreateUserBySystemIDAndGroup( }, cancellationToken)); } + + /// + /// Sets the current user's password. + /// + /// The new password for the current user. + /// The to get the of the user. + /// 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) + { + ArgumentException.ThrowIfNullOrEmpty(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 . + /// 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 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 new owned for the user. + /// Optional of the to move the to. + /// Optional new s for the . + /// 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, + PermissionSetInput? newPermissionSet, + [ID(nameof(UserGroup))] long? newGroupId, + IEnumerable? newOAuthConnections, + [Service] IGraphQLAuthorityInvoker userAuthority, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(newOAuthConnections); + ArgumentNullException.ThrowIfNull(userAuthority); + return 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)); + } } } From fa2fb000432fd1a9359d3e4d41a392fc859b4b6a Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Wed, 18 Sep 2024 07:06:00 -0400 Subject: [PATCH 061/107] Skeleton UserGroup mutations --- .../GraphQL/Mutations/UserGroupMutations.cs | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 src/Tgstation.Server.Host/GraphQL/Mutations/UserGroupMutations.cs 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..ff98da6f375 --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Mutations/UserGroupMutations.cs @@ -0,0 +1,48 @@ +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; + +namespace Tgstation.Server.Host.GraphQL.Mutations +{ + /// + /// related s. + /// + [ExtendObjectType(typeof(Mutation))] + public sealed class UserGroupMutations + { + public ValueTask CreateUserGroup( + string name, + PermissionSetInput permissionSet, + [Service] IUserGroupAuthority userGroupAuthority, + CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public ValueTask UpdateUserGroup( + [ID(nameof(UserGroup))] long id, + string? newName, + PermissionSetInput newPermissionSet, + [Service] IUserGroupAuthority userGroupAuthority, + CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public ValueTask DeleteEmptyUserGroup( + [ID(nameof(UserGroup))] long id, + [Service] IUserGroupAuthority userGroupAuthority, + CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + } +} From cc5ddc26e31a9b5f406289fc613ef8a77397fda1 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Wed, 18 Sep 2024 22:49:11 -0400 Subject: [PATCH 062/107] Allow null permission sets and enabled status on user/group creation --- .../GraphQL/Mutations/UserGroupMutations.cs | 4 +-- .../GraphQL/Mutations/UserMutations.cs | 36 ++++++++++--------- 2 files changed, 21 insertions(+), 19 deletions(-) diff --git a/src/Tgstation.Server.Host/GraphQL/Mutations/UserGroupMutations.cs b/src/Tgstation.Server.Host/GraphQL/Mutations/UserGroupMutations.cs index ff98da6f375..09af618a059 100644 --- a/src/Tgstation.Server.Host/GraphQL/Mutations/UserGroupMutations.cs +++ b/src/Tgstation.Server.Host/GraphQL/Mutations/UserGroupMutations.cs @@ -20,7 +20,7 @@ public sealed class UserGroupMutations { public ValueTask CreateUserGroup( string name, - PermissionSetInput permissionSet, + PermissionSetInput? permissionSet, [Service] IUserGroupAuthority userGroupAuthority, CancellationToken cancellationToken) { @@ -30,7 +30,7 @@ public ValueTask CreateUserGroup( public ValueTask UpdateUserGroup( [ID(nameof(UserGroup))] long id, string? newName, - PermissionSetInput newPermissionSet, + PermissionSetInput? newPermissionSet, [Service] IUserGroupAuthority userGroupAuthority, CancellationToken cancellationToken) { diff --git a/src/Tgstation.Server.Host/GraphQL/Mutations/UserMutations.cs b/src/Tgstation.Server.Host/GraphQL/Mutations/UserMutations.cs index 68613801693..91cf35a3d43 100644 --- a/src/Tgstation.Server.Host/GraphQL/Mutations/UserMutations.cs +++ b/src/Tgstation.Server.Host/GraphQL/Mutations/UserMutations.cs @@ -40,15 +40,14 @@ public sealed class UserMutations public ValueTask CreateUserByPasswordAndPermissionSet( string name, string password, - bool enabled, + bool? enabled, IEnumerable? oAuthConnections, - PermissionSetInput permissionSet, + PermissionSetInput? permissionSet, [Service] IGraphQLAuthorityInvoker userAuthority, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrWhiteSpace(name); ArgumentException.ThrowIfNullOrEmpty(password); - ArgumentNullException.ThrowIfNull(permissionSet); ArgumentNullException.ThrowIfNull(userAuthority); return userAuthority.InvokeTransformable( @@ -58,11 +57,13 @@ public ValueTask CreateUserByPasswordAndPermissionSet( Name = name, Password = password, Enabled = enabled, - PermissionSet = new Api.Models.PermissionSet - { - AdministrationRights = permissionSet.AdministrationRights, - InstanceManagerRights = permissionSet.InstanceManagerRights, - }, + PermissionSet = permissionSet != null + ? new Api.Models.PermissionSet + { + AdministrationRights = permissionSet.AdministrationRights, + InstanceManagerRights = permissionSet.InstanceManagerRights, + } + : null, OAuthConnections = oAuthConnections ?.Select(oAuthConnection => new Api.Models.OAuthConnection { @@ -88,14 +89,13 @@ public ValueTask CreateUserByPasswordAndPermissionSet( [Error(typeof(ErrorMessageException))] public ValueTask CreateUserBySystemIDAndPermissionSet( string systemIdentifier, - bool enabled, + bool? enabled, IEnumerable? oAuthConnections, PermissionSetInput permissionSet, [Service] IGraphQLAuthorityInvoker userAuthority, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrWhiteSpace(systemIdentifier); - ArgumentNullException.ThrowIfNull(permissionSet); ArgumentNullException.ThrowIfNull(userAuthority); return userAuthority.InvokeTransformable( @@ -104,11 +104,13 @@ public ValueTask CreateUserBySystemIDAndPermissionSet( { SystemIdentifier = systemIdentifier, Enabled = enabled, - PermissionSet = new Api.Models.PermissionSet - { - AdministrationRights = permissionSet.AdministrationRights, - InstanceManagerRights = permissionSet.InstanceManagerRights, - }, + PermissionSet = permissionSet != null + ? new Api.Models.PermissionSet + { + AdministrationRights = permissionSet.AdministrationRights, + InstanceManagerRights = permissionSet.InstanceManagerRights, + } + : null, OAuthConnections = oAuthConnections ?.Select(oAuthConnection => new Api.Models.OAuthConnection { @@ -136,7 +138,7 @@ public ValueTask CreateUserBySystemIDAndPermissionSet( public ValueTask CreateUserByPasswordAndGroup( string name, string password, - bool enabled, + bool? enabled, IEnumerable? oAuthConnections, [ID(nameof(UserGroup))] long groupId, [Service] IGraphQLAuthorityInvoker userAuthority, @@ -182,7 +184,7 @@ public ValueTask CreateUserByPasswordAndGroup( [Error(typeof(ErrorMessageException))] public ValueTask CreateUserBySystemIDAndGroup( string systemIdentifier, - bool enabled, + bool? enabled, IEnumerable? oAuthConnections, [ID(nameof(UserGroup))] long groupId, [Service] IGraphQLAuthorityInvoker userAuthority, From 5f0fa339d1afb49632c1a9178c9e965a231cac83 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Wed, 18 Sep 2024 22:50:01 -0400 Subject: [PATCH 063/107] Fix invalid AuthenticationContexts being able to be dispatched to GQL resolvers --- ...thenticationContextClaimsTransformation.cs | 9 ++++++ .../Security/TgsGraphQLAuthorizeAttribute.cs | 28 +++++++++++++------ ...gsGraphQLAuthorizeAttribute{TAuthority}.cs | 2 +- 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/src/Tgstation.Server.Host/Security/AuthenticationContextClaimsTransformation.cs b/src/Tgstation.Server.Host/Security/AuthenticationContextClaimsTransformation.cs index d649e2f7fff..db8b85b92cb 100644 --- a/src/Tgstation.Server.Host/Security/AuthenticationContextClaimsTransformation.cs +++ b/src/Tgstation.Server.Host/Security/AuthenticationContextClaimsTransformation.cs @@ -85,6 +85,15 @@ public async Task TransformAsync(ClaimsPrincipal principal) var enumerator = Enum.GetValues(typeof(RightsType)); var claims = new List(); + + if (authenticationContext.Valid) + { + claims.Add( + new( + ClaimTypes.Role, + TgsGraphQLAuthorizeAttribute.CoreAccessRole)); + } + foreach (RightsType rightType in enumerator) { // if there's a bad condition, do a weird thing and add all the roles diff --git a/src/Tgstation.Server.Host/Security/TgsGraphQLAuthorizeAttribute.cs b/src/Tgstation.Server.Host/Security/TgsGraphQLAuthorizeAttribute.cs index e064440e185..d2a1f33b472 100644 --- a/src/Tgstation.Server.Host/Security/TgsGraphQLAuthorizeAttribute.cs +++ b/src/Tgstation.Server.Host/Security/TgsGraphQLAuthorizeAttribute.cs @@ -2,6 +2,8 @@ using HotChocolate.Authorization; +using Microsoft.AspNetCore.Mvc.Filters; + using Tgstation.Server.Api.Rights; namespace Tgstation.Server.Host.Security @@ -13,6 +15,8 @@ namespace Tgstation.Server.Host.Security [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Property, AllowMultiple = true, Inherited = true)] sealed class TgsGraphQLAuthorizeAttribute : AuthorizeAttribute { + public const string CoreAccessRole = "GRAPH_QL_CORE_ACCESS"; + /// /// Gets the associated with the if any. /// @@ -23,6 +27,7 @@ sealed class TgsGraphQLAuthorizeAttribute : AuthorizeAttribute /// public TgsGraphQLAuthorizeAttribute() { + Roles = [CoreAccessRole]; } /// @@ -30,8 +35,8 @@ public TgsGraphQLAuthorizeAttribute() /// /// The required. public TgsGraphQLAuthorizeAttribute(AdministrationRights requiredRights) + : this(RightsHelper.RoleNames(requiredRights)) { - Roles = RightsHelper.RoleNames(requiredRights).Split(','); RightsType = Api.Rights.RightsType.Administration; } @@ -40,8 +45,8 @@ public TgsGraphQLAuthorizeAttribute(AdministrationRights requiredRights) /// /// The required. public TgsGraphQLAuthorizeAttribute(InstanceManagerRights requiredRights) + : this(RightsHelper.RoleNames(requiredRights)) { - Roles = RightsHelper.RoleNames(requiredRights).Split(','); RightsType = Api.Rights.RightsType.InstanceManager; } @@ -50,8 +55,8 @@ public TgsGraphQLAuthorizeAttribute(InstanceManagerRights requiredRights) /// /// The required. public TgsGraphQLAuthorizeAttribute(RepositoryRights requiredRights) + : this(RightsHelper.RoleNames(requiredRights)) { - Roles = RightsHelper.RoleNames(requiredRights).Split(','); RightsType = Api.Rights.RightsType.Repository; } @@ -60,8 +65,8 @@ public TgsGraphQLAuthorizeAttribute(RepositoryRights requiredRights) /// /// The required. public TgsGraphQLAuthorizeAttribute(EngineRights requiredRights) + : this(RightsHelper.RoleNames(requiredRights)) { - Roles = RightsHelper.RoleNames(requiredRights).Split(','); RightsType = Api.Rights.RightsType.Engine; } @@ -70,8 +75,8 @@ public TgsGraphQLAuthorizeAttribute(EngineRights requiredRights) /// /// The required. public TgsGraphQLAuthorizeAttribute(DreamMakerRights requiredRights) + : this(RightsHelper.RoleNames(requiredRights)) { - Roles = RightsHelper.RoleNames(requiredRights).Split(','); RightsType = Api.Rights.RightsType.DreamMaker; } @@ -80,8 +85,8 @@ public TgsGraphQLAuthorizeAttribute(DreamMakerRights requiredRights) /// /// The required. public TgsGraphQLAuthorizeAttribute(DreamDaemonRights requiredRights) + : this(RightsHelper.RoleNames(requiredRights)) { - Roles = RightsHelper.RoleNames(requiredRights).Split(','); RightsType = Api.Rights.RightsType.DreamDaemon; } @@ -90,8 +95,8 @@ public TgsGraphQLAuthorizeAttribute(DreamDaemonRights requiredRights) /// /// The required. public TgsGraphQLAuthorizeAttribute(ChatBotRights requiredRights) + : this(RightsHelper.RoleNames(requiredRights)) { - Roles = RightsHelper.RoleNames(requiredRights).Split(','); RightsType = Api.Rights.RightsType.ChatBots; } @@ -100,8 +105,8 @@ public TgsGraphQLAuthorizeAttribute(ChatBotRights requiredRights) /// /// The required. public TgsGraphQLAuthorizeAttribute(ConfigurationRights requiredRights) + : this(RightsHelper.RoleNames(requiredRights)) { - Roles = RightsHelper.RoleNames(requiredRights).Split(','); RightsType = Api.Rights.RightsType.Configuration; } @@ -110,9 +115,14 @@ public TgsGraphQLAuthorizeAttribute(ConfigurationRights requiredRights) /// /// The required. public TgsGraphQLAuthorizeAttribute(InstancePermissionSetRights requiredRights) + : this(RightsHelper.RoleNames(requiredRights)) { - Roles = RightsHelper.RoleNames(requiredRights).Split(','); RightsType = Api.Rights.RightsType.InstancePermissionSet; } + + private TgsGraphQLAuthorizeAttribute(string roleNames) + { + Roles = $"{CoreAccessRole},{roleNames}".Split(','); + } } } diff --git a/src/Tgstation.Server.Host/Security/TgsGraphQLAuthorizeAttribute{TAuthority}.cs b/src/Tgstation.Server.Host/Security/TgsGraphQLAuthorizeAttribute{TAuthority}.cs index f8e64ec4487..b87b472dfec 100644 --- a/src/Tgstation.Server.Host/Security/TgsGraphQLAuthorizeAttribute{TAuthority}.cs +++ b/src/Tgstation.Server.Host/Security/TgsGraphQLAuthorizeAttribute{TAuthority}.cs @@ -34,7 +34,7 @@ public TgsGraphQLAuthorizeAttribute(string methodName) var authorizeAttribute = authorityMethod.GetCustomAttribute() ?? throw new InvalidOperationException($"Could not find method {authorityType}.{methodName}() has no {nameof(TgsAuthorizeAttribute)}!"); MethodName = methodName; - Roles = authorizeAttribute.Roles?.Split(','); + Roles = $"{TgsGraphQLAuthorizeAttribute.CoreAccessRole},{authorizeAttribute.Roles}"?.Split(',', StringSplitOptions.RemoveEmptyEntries); } } } From f37883a438ae420c4cf88191778847d20b76c4cb Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Thu, 19 Sep 2024 22:17:53 -0400 Subject: [PATCH 064/107] Hopefully fix issues with auth pipeline for good --- .../Rights/RightsHelper.cs | 16 +--- src/Tgstation.Server.Client/ApiClient.cs | 81 +++++++++++------ src/Tgstation.Server.Host/Core/Application.cs | 10 ++ ...thenticationContextClaimsTransformation.cs | 78 +++------------- .../Security/ITokenValidator.cs | 21 +++++ .../Security/TgsAuthorizeAttribute.cs | 70 ++++++-------- .../Security/TgsGraphQLAuthorizeAttribute.cs | 17 ++-- ...gsGraphQLAuthorizeAttribute{TAuthority}.cs | 2 +- .../TgsRestAuthorizeAttribute{TAuthority}.cs | 7 +- .../Security/TokenValidator.cs | 91 +++++++++++++++++++ .../Live/RawRequestTests.cs | 9 +- .../Live/TestLiveServer.cs | 17 ++-- 12 files changed, 243 insertions(+), 176 deletions(-) create mode 100644 src/Tgstation.Server.Host/Security/ITokenValidator.cs create mode 100644 src/Tgstation.Server.Host/Security/TokenValidator.cs 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/ApiClient.cs b/src/Tgstation.Server.Client/ApiClient.cs index e9824a433db..26aac71d13f 100644 --- a/src/Tgstation.Server.Client/ApiClient.cs +++ b/src/Tgstation.Server.Client/ApiClient.cs @@ -405,50 +405,73 @@ 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; + try + { + return await AttemptConnect(); } - catch + catch (HttpRequestException ex) { - bool needsDispose; - lock (hubConnections) - needsDispose = hubConnections.Remove(hubConnection); + // 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; + } - if (needsDispose) - await hubConnection.DisposeAsync(); - throw; + await RefreshToken(cancellationToken); + + return await AttemptConnect(); } } diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs index df797a55184..9dbf79cc2d1 100644 --- a/src/Tgstation.Server.Host/Core/Application.cs +++ b/src/Tgstation.Server.Host/Core/Application.cs @@ -697,6 +697,7 @@ void ConfigureAuthenticationPipeline(IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(provider => provider.GetRequiredService()); + services.AddScoped(); // what if you // wanted to just do this: @@ -737,6 +738,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/Security/AuthenticationContextClaimsTransformation.cs b/src/Tgstation.Server.Host/Security/AuthenticationContextClaimsTransformation.cs index db8b85b92cb..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,85 +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.Valid) - { + if (authenticationContext.User.Require(x => x.Enabled)) claims.Add( - new( + new Claim( ClaimTypes.Role, - TgsGraphQLAuthorizeAttribute.CoreAccessRole)); - } + 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); @@ -114,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/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/TgsAuthorizeAttribute.cs b/src/Tgstation.Server.Host/Security/TgsAuthorizeAttribute.cs index 6619f3f1219..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,43 +13,23 @@ 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 { /// - /// Gets the associated with the if any. + /// Role used to indicate access to the server is allowed. /// - public RightsType? RightsType { get; } + public const string UserEnabledRole = "Core.UserEnabled"; /// - /// Implementation of . + /// Gets the associated with the if any. /// - /// The . - public static void OnAuthorizationHelper(AuthorizationFilterContext context) - { - ArgumentNullException.ThrowIfNull(context); - - 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(); - } + public RightsType? RightsType { get; } /// /// Initializes a new instance of the class. /// public TgsAuthorizeAttribute() + : this(Enumerable.Empty()) { } @@ -61,8 +38,8 @@ public TgsAuthorizeAttribute() /// /// The required. public TgsAuthorizeAttribute(AdministrationRights requiredRights) + : this(RightsHelper.RoleNames(requiredRights)) { - Roles = RightsHelper.RoleNames(requiredRights); RightsType = Api.Rights.RightsType.Administration; } @@ -71,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; } @@ -81,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; } @@ -91,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; } @@ -101,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; } @@ -111,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; } @@ -121,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; } @@ -131,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; } @@ -141,13 +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) - => OnAuthorizationHelper(context); + /// + /// Initializes a new instance of the class. + /// + /// An of roles to be required alongside the . + private TgsAuthorizeAttribute(IEnumerable roles) + { + 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 index d2a1f33b472..4ec87673f39 100644 --- a/src/Tgstation.Server.Host/Security/TgsGraphQLAuthorizeAttribute.cs +++ b/src/Tgstation.Server.Host/Security/TgsGraphQLAuthorizeAttribute.cs @@ -1,9 +1,9 @@ using System; +using System.Collections.Generic; +using System.Linq; using HotChocolate.Authorization; -using Microsoft.AspNetCore.Mvc.Filters; - using Tgstation.Server.Api.Rights; namespace Tgstation.Server.Host.Security @@ -15,8 +15,6 @@ namespace Tgstation.Server.Host.Security [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Property, AllowMultiple = true, Inherited = true)] sealed class TgsGraphQLAuthorizeAttribute : AuthorizeAttribute { - public const string CoreAccessRole = "GRAPH_QL_CORE_ACCESS"; - /// /// Gets the associated with the if any. /// @@ -27,7 +25,6 @@ sealed class TgsGraphQLAuthorizeAttribute : AuthorizeAttribute /// public TgsGraphQLAuthorizeAttribute() { - Roles = [CoreAccessRole]; } /// @@ -120,9 +117,15 @@ public TgsGraphQLAuthorizeAttribute(InstancePermissionSetRights requiredRights) RightsType = Api.Rights.RightsType.InstancePermissionSet; } - private TgsGraphQLAuthorizeAttribute(string roleNames) + /// + /// Initializes a new instance of the class. + /// + /// of role names. + private TgsGraphQLAuthorizeAttribute(IEnumerable roleNames) { - Roles = $"{CoreAccessRole},{roleNames}".Split(','); + var listRoles = roleNames.ToList(); + listRoles.Add(TgsAuthorizeAttribute.UserEnabledRole); + Roles = listRoles.ToArray(); } } } diff --git a/src/Tgstation.Server.Host/Security/TgsGraphQLAuthorizeAttribute{TAuthority}.cs b/src/Tgstation.Server.Host/Security/TgsGraphQLAuthorizeAttribute{TAuthority}.cs index b87b472dfec..fb890f7e922 100644 --- a/src/Tgstation.Server.Host/Security/TgsGraphQLAuthorizeAttribute{TAuthority}.cs +++ b/src/Tgstation.Server.Host/Security/TgsGraphQLAuthorizeAttribute{TAuthority}.cs @@ -34,7 +34,7 @@ public TgsGraphQLAuthorizeAttribute(string methodName) var authorizeAttribute = authorityMethod.GetCustomAttribute() ?? throw new InvalidOperationException($"Could not find method {authorityType}.{methodName}() has no {nameof(TgsAuthorizeAttribute)}!"); MethodName = methodName; - Roles = $"{TgsGraphQLAuthorizeAttribute.CoreAccessRole},{authorizeAttribute.Roles}"?.Split(',', StringSplitOptions.RemoveEmptyEntries); + Roles = authorizeAttribute.Roles?.Split(',', StringSplitOptions.RemoveEmptyEntries); } } } diff --git a/src/Tgstation.Server.Host/Security/TgsRestAuthorizeAttribute{TAuthority}.cs b/src/Tgstation.Server.Host/Security/TgsRestAuthorizeAttribute{TAuthority}.cs index 7a3ac6cdf55..989de21e8ef 100644 --- a/src/Tgstation.Server.Host/Security/TgsRestAuthorizeAttribute{TAuthority}.cs +++ b/src/Tgstation.Server.Host/Security/TgsRestAuthorizeAttribute{TAuthority}.cs @@ -2,7 +2,6 @@ using System.Reflection; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc.Filters; using Tgstation.Server.Host.Authority.Core; @@ -13,7 +12,7 @@ namespace Tgstation.Server.Host.Security /// /// The being wrapped. [AttributeUsage(AttributeTargets.Method, AllowMultiple = true, Inherited = true)] - public sealed class TgsRestAuthorizeAttribute : AuthorizeAttribute, IAuthorizationFilter + public sealed class TgsRestAuthorizeAttribute : AuthorizeAttribute where TAuthority : IAuthority { /// @@ -41,9 +40,5 @@ public TgsRestAuthorizeAttribute(string methodName) MethodName = methodName; Roles = authorizeAttribute.Roles; } - - /// - public void OnAuthorization(AuthorizationFilterContext context) - => TgsAuthorizeAttribute.OnAuthorizationHelper(context); } } diff --git a/src/Tgstation.Server.Host/Security/TokenValidator.cs b/src/Tgstation.Server.Host/Security/TokenValidator.cs new file mode 100644 index 00000000000..89e478df17a --- /dev/null +++ b/src/Tgstation.Server.Host/Security/TokenValidator.cs @@ -0,0 +1,91 @@ +using System; +using System.Globalization; +using System.Security.Claims; +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; + +using Tgstation.Server.Api; +using Tgstation.Server.Host.Utils; + +namespace Tgstation.Server.Host.Security +{ + /// + public class TokenValidator : ITokenValidator + { + /// + /// The for the . + /// + readonly IAuthenticationContextFactory authenticationContextFactory; + + /// + /// The for the . + /// + readonly ApiHeaders? apiHeaders; + + /// + /// Initializes a new instance of the class. + /// + /// The value of . + /// The containing the value of . + public TokenValidator(IAuthenticationContextFactory authenticationContextFactory, IApiHeadersProvider apiHeadersProvider) + { + this.authenticationContextFactory = authenticationContextFactory ?? throw new ArgumentNullException(nameof(authenticationContextFactory)); + ArgumentNullException.ThrowIfNull(apiHeadersProvider); + apiHeaders = apiHeadersProvider.ApiHeaders; + } + + /// + public async Task ValidateToken(TokenValidatedContext tokenValidatedContext, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(tokenValidatedContext); + + if (tokenValidatedContext.SecurityToken is not JsonWebToken jwt) + throw new ArgumentException($"Expected {nameof(tokenValidatedContext)} to contain a {nameof(JsonWebToken)}!", nameof(tokenValidatedContext)); + + 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); + } + + 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); + + if (!authenticationContext.Valid) + tokenValidatedContext.Fail("Authentication context could not be created!"); + } + } +} diff --git a/tests/Tgstation.Server.Tests/Live/RawRequestTests.cs b/tests/Tgstation.Server.Tests/Live/RawRequestTests.cs index 240a89c245f..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; @@ -447,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 { diff --git a/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs b/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs index 56f79be166d..66f8817a4cf 100644 --- a/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs +++ b/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; @@ -1345,6 +1345,14 @@ async Task TestTgsInternal(bool openDreamOnly, CancellationToken hardCancellatio 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 @@ -1358,13 +1366,6 @@ async Task TestTgsInternal(bool openDreamOnly, CancellationToken hardCancellatio await ApiAssert.ThrowsException(() => tokenOnlyRestClient.Users.Read(cancellationToken), null); } - 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()); - } - // basic graphql test, to be used everywhere eventually await using (var unauthenticatedGraphQLClient = graphQLClientFactory.CreateUnauthenticated(server.RootUrl)) { From c6f30f7f7ed9f73d74ffdd1fa64b5921d10a1c48 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sat, 21 Sep 2024 12:21:08 -0400 Subject: [PATCH 065/107] Fix a CA1506 warning --- src/Tgstation.Server.Client/ApiClient.cs | 51 ++++++++++++++---------- 1 file changed, 31 insertions(+), 20 deletions(-) diff --git a/src/Tgstation.Server.Client/ApiClient.cs b/src/Tgstation.Server.Client/ApiClient.cs index 26aac71d13f..ffffd1bf255 100644 --- a/src/Tgstation.Server.Client/ApiClient.cs +++ b/src/Tgstation.Server.Client/ApiClient.cs @@ -405,7 +405,7 @@ public async ValueTask CreateHubConnection if (loggingConfigureAction != null) hubConnectionBuilder.ConfigureLogging(loggingConfigureAction); - async ValueTask AttemptConnect() + async ValueTask AttemptConnect() { hubConnection = hubConnectionBuilder.Build(); try @@ -454,25 +454,7 @@ async ValueTask AttemptConnect() } } - try - { - return await AttemptConnect(); - } - 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 AttemptConnect(); - } + return await WrapHubInitialConnectAuthRefresh(AttemptConnect, cancellationToken); } /// @@ -594,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. /// From 2223063b81b6b3fea83c334aa7f0bd9172139205 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sat, 21 Sep 2024 13:15:00 -0400 Subject: [PATCH 066/107] Apply code suggestion --- .../Security/TgsGraphQLAuthorizeAttribute.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Tgstation.Server.Host/Security/TgsGraphQLAuthorizeAttribute.cs b/src/Tgstation.Server.Host/Security/TgsGraphQLAuthorizeAttribute.cs index 4ec87673f39..5580b27a7bf 100644 --- a/src/Tgstation.Server.Host/Security/TgsGraphQLAuthorizeAttribute.cs +++ b/src/Tgstation.Server.Host/Security/TgsGraphQLAuthorizeAttribute.cs @@ -125,7 +125,7 @@ private TgsGraphQLAuthorizeAttribute(IEnumerable roleNames) { var listRoles = roleNames.ToList(); listRoles.Add(TgsAuthorizeAttribute.UserEnabledRole); - Roles = listRoles.ToArray(); + Roles = [.. listRoles]; } } } From f9218e66326bfe1d4c39ad1e9c27ce50f3b2314e Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sat, 21 Sep 2024 13:15:18 -0400 Subject: [PATCH 067/107] Fix naming of rights flags fields --- src/Tgstation.Server.Host/Core/Application.cs | 2 + .../Interceptors/RightsTypeInterceptor.cs | 91 +++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 src/Tgstation.Server.Host/GraphQL/Types/Interceptors/RightsTypeInterceptor.cs diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs index 9dbf79cc2d1..0b0b091a6ee 100644 --- a/src/Tgstation.Server.Host/Core/Application.cs +++ b/src/Tgstation.Server.Host/Core/Application.cs @@ -57,6 +57,7 @@ using Tgstation.Server.Host.Extensions; using Tgstation.Server.Host.GraphQL; 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; @@ -329,6 +330,7 @@ void ConfigureNewtonsoftJsonSerializerSettingsForApi(JsonSerializerSettings sett .AddType() .AddType() .BindRuntimeType() + .TryAddTypeInterceptor() .AddQueryType() .AddMutationType(); 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..405a5915106 --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Types/Interceptors/RightsTypeInterceptor.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; + +using HotChocolate.Configuration; +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 + { + /// + /// 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() + { + objectNames = new HashSet(); + inputNames = new HashSet(); + + foreach (var rightType in Enum.GetValues()) + { + var flagName = $"{rightType}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; + + const string NoneFieldName = "isNone"; + foreach (var field in fields) + { + var fieldName = field.Name; + if (fieldName == NoneFieldName) + { + noneField = field; + continue; + } + + const string IsPrefix = "is"; + 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); + } + + /// + public override void OnBeforeRegisterDependencies(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) + if (inputNames.Contains(inputTypeDef.Name)) + FixFields(inputTypeDef.Fields); + } + } +} From 07a317ed8121584aec56b38b21554d251b29989b Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sat, 21 Sep 2024 16:39:17 -0400 Subject: [PATCH 068/107] Apply some code suggestions --- src/Tgstation.Server.Client/ApiClient.cs | 2 +- .../Security/TgsRestAuthorizeAttribute{TAuthority}.cs | 10 ++++------ 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/Tgstation.Server.Client/ApiClient.cs b/src/Tgstation.Server.Client/ApiClient.cs index ffffd1bf255..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(); } diff --git a/src/Tgstation.Server.Host/Security/TgsRestAuthorizeAttribute{TAuthority}.cs b/src/Tgstation.Server.Host/Security/TgsRestAuthorizeAttribute{TAuthority}.cs index 989de21e8ef..6103eca33b2 100644 --- a/src/Tgstation.Server.Host/Security/TgsRestAuthorizeAttribute{TAuthority}.cs +++ b/src/Tgstation.Server.Host/Security/TgsRestAuthorizeAttribute{TAuthority}.cs @@ -29,13 +29,11 @@ public TgsRestAuthorizeAttribute(string methodName) ArgumentNullException.ThrowIfNull(methodName); var authorityType = typeof(TAuthority); - var authorityMethod = authorityType.GetMethod(methodName); - if (authorityMethod == null) - throw new InvalidOperationException($"Could not find method {methodName} on {authorityType}!"); + var authorityMethod = authorityType.GetMethod(methodName) + ?? throw new InvalidOperationException($"Could not find method {methodName} on {authorityType}!"); - var authorizeAttribute = authorityMethod.GetCustomAttribute(); - if (authorizeAttribute == null) - throw new InvalidOperationException($"Could not find method {authorityType}.{methodName}() has no {nameof(TgsAuthorizeAttribute)}!"); + var authorizeAttribute = authorityMethod.GetCustomAttribute() + ?? throw new InvalidOperationException($"Could not find method {authorityType}.{methodName}() has no {nameof(TgsAuthorizeAttribute)}!"); MethodName = methodName; Roles = authorizeAttribute.Roles; From 8139422fe8829edf9e3d9783fc46b67775169ef1 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sat, 21 Sep 2024 17:23:56 -0400 Subject: [PATCH 069/107] Fix documentation comments for `IGraphQLAuthorityInvoker` params --- src/Tgstation.Server.Host/GraphQL/Mutation.cs | 2 +- .../GraphQL/Mutations/UserMutations.cs | 14 +++++++------- src/Tgstation.Server.Host/GraphQL/Types/User.cs | 12 ++++++------ .../GraphQL/Types/UserGroup.cs | 6 +++--- .../GraphQL/Types/UserGroups.cs | 8 ++++---- .../GraphQL/Types/UserName.cs | 2 +- src/Tgstation.Server.Host/GraphQL/Types/Users.cs | 6 +++--- 7 files changed, 25 insertions(+), 25 deletions(-) diff --git a/src/Tgstation.Server.Host/GraphQL/Mutation.cs b/src/Tgstation.Server.Host/GraphQL/Mutation.cs index 0e1bba29d91..4b68aa9b89c 100644 --- a/src/Tgstation.Server.Host/GraphQL/Mutation.cs +++ b/src/Tgstation.Server.Host/GraphQL/Mutation.cs @@ -19,7 +19,7 @@ public sealed class Mutation /// /// Generate a JWT for authenticating with server. This is the only operation that accepts and required basic authentication. /// - /// The . + /// The for the . /// The for the operation. /// A Bearer token to be used with further communication with the server. [Error(typeof(ErrorMessageException))] diff --git a/src/Tgstation.Server.Host/GraphQL/Mutations/UserMutations.cs b/src/Tgstation.Server.Host/GraphQL/Mutations/UserMutations.cs index 91cf35a3d43..bb19506d0da 100644 --- a/src/Tgstation.Server.Host/GraphQL/Mutations/UserMutations.cs +++ b/src/Tgstation.Server.Host/GraphQL/Mutations/UserMutations.cs @@ -32,7 +32,7 @@ public sealed class UserMutations /// If the is . /// The s for the user. /// The owned of the user. - /// The . + /// The for the . /// The for the operation. /// The created . [TgsGraphQLAuthorize(nameof(IUserAuthority.Create))] @@ -82,7 +82,7 @@ public ValueTask CreateUserByPasswordAndPermissionSet( /// If the is . /// The s for the user. /// The owned of the user. - /// The . + /// The for the . /// The for the operation. /// The created . [TgsGraphQLAuthorize(nameof(IUserAuthority.Create))] @@ -130,7 +130,7 @@ public ValueTask CreateUserBySystemIDAndPermissionSet( /// If the is . /// The s for the user. /// The of the the will belong to. - /// The . + /// The for the . /// The for the operation. /// The created . [TgsGraphQLAuthorize(nameof(IUserAuthority.Create))] @@ -177,7 +177,7 @@ public ValueTask CreateUserByPasswordAndGroup( /// If the is . /// The s for the user. /// The of the the will belong to. - /// The . + /// The for the . /// The for the operation. /// The created . [TgsGraphQLAuthorize(nameof(IUserAuthority.Create))] @@ -219,7 +219,7 @@ public ValueTask CreateUserBySystemIDAndGroup( /// /// The new password for the current user. /// The to get the of the user. - /// The . + /// The for the . /// The for the operation. /// The updated current . [TgsGraphQLAuthorize(AdministrationRights.WriteUsers | AdministrationRights.EditOwnPassword)] @@ -247,7 +247,7 @@ public ValueTask SetCurrentUserPassword( /// /// The new s for the current user. /// The to get the of the user. - /// The . + /// The for the . /// The for the operation. /// The updated current . [TgsGraphQLAuthorize(AdministrationRights.WriteUsers | AdministrationRights.EditOwnOAuthConnections)] @@ -286,7 +286,7 @@ public ValueTask SetCurrentOAuthConnections( /// Optional new owned for the user. /// Optional of the to move the to. /// Optional new s for the . - /// The . + /// The for the . /// The for the operation. /// The updated . [TgsGraphQLAuthorize(AdministrationRights.WriteUsers)] diff --git a/src/Tgstation.Server.Host/GraphQL/Types/User.cs b/src/Tgstation.Server.Host/GraphQL/Types/User.cs index f47a3fb7849..7e993fd358e 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/User.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/User.cs @@ -54,7 +54,7 @@ public sealed class User : NamedEntity, IUserName /// Node resolver for s. /// /// The to lookup. - /// The . + /// The for the . /// The for the operation. /// A resulting in the queried , if present. [TgsGraphQLAuthorize] @@ -71,7 +71,7 @@ public sealed class User : NamedEntity, IUserName /// /// The who created this . /// - /// The . + /// The for the . /// The for the operation. /// The that created this , if any. public async ValueTask CreatedBy( @@ -92,7 +92,7 @@ public sealed class User : NamedEntity, IUserName /// /// List of s associated with the user if OAuth is configured. /// - /// The . + /// The for the . /// The for the operation. /// A resulting in a new of s for the if OAuth is configured. public ValueTask OAuthConnections( @@ -107,7 +107,7 @@ public ValueTask OAuthConnections( /// /// The associated with the . /// - /// The . + /// The for the . /// The for the operation. /// A resulting in the associated with the . public ValueTask EffectivePermissionSet( @@ -136,7 +136,7 @@ public ValueTask EffectivePermissionSet( /// /// The owned by the , if any. /// - /// The . + /// The for the . /// The for the operation. /// A resulting in the owned by the , if any. public ValueTask OwnedPermissionSet( @@ -152,7 +152,7 @@ public ValueTask EffectivePermissionSet( /// /// The asociated with the user, if any. /// - /// The . + /// The for the . /// The for the operation. /// A resulting in the associated with the , if any. public async ValueTask Group( diff --git a/src/Tgstation.Server.Host/GraphQL/Types/UserGroup.cs b/src/Tgstation.Server.Host/GraphQL/Types/UserGroup.cs index e8cd9cc855e..2511ab438af 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/UserGroup.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/UserGroup.cs @@ -24,7 +24,7 @@ public sealed class UserGroup : NamedEntity /// Node resolver for s. /// /// The to lookup. - /// The . + /// The for the . /// The for the operation. /// A resulting in the queried , if present. [TgsGraphQLAuthorize] @@ -41,7 +41,7 @@ public sealed class UserGroup : NamedEntity /// /// The owned by the . /// - /// The . + /// The for the . /// The for the operation. /// A resulting in the owned by the . public async ValueTask PermissionSet( @@ -57,7 +57,7 @@ public async ValueTask PermissionSet( /// /// Queries all registered s in the . /// - /// The . + /// The for the . /// A of all registered s in the . [UsePaging] [UseFiltering] diff --git a/src/Tgstation.Server.Host/GraphQL/Types/UserGroups.cs b/src/Tgstation.Server.Host/GraphQL/Types/UserGroups.cs index 51240221c84..eb9fc04a526 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/UserGroups.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/UserGroups.cs @@ -22,7 +22,7 @@ public sealed class UserGroups /// /// Gets the current . /// - /// The . + /// The for the . /// A resulting in the current 's . public ValueTask Current( [Service] IGraphQLAuthorityInvoker userGroupAuthority) @@ -35,7 +35,7 @@ public sealed class UserGroups /// Gets a by . /// /// The of the . - /// The . + /// The for the . /// The for the operation. /// The represented by , if any. [TgsGraphQLAuthorize(nameof(IUserGroupAuthority.GetId))] @@ -48,7 +48,7 @@ public sealed class UserGroups /// /// Queries all registered s. /// - /// The . + /// The for the . /// A of all registered s. [UsePaging] [UseFiltering] @@ -66,7 +66,7 @@ public IQueryable QueryableGroups( /// Queries all registered s in a indicated by . /// /// The . - /// The . + /// The for the . /// A of all registered s in the indicated by . [UsePaging] [UseFiltering] diff --git a/src/Tgstation.Server.Host/GraphQL/Types/UserName.cs b/src/Tgstation.Server.Host/GraphQL/Types/UserName.cs index 016a1bb2f4e..7175c2935ec 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/UserName.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/UserName.cs @@ -23,7 +23,7 @@ public sealed class UserName : NamedEntity, IUserName /// Node resolver for s. /// /// The to lookup. - /// The . + /// The for the . /// The for the operation. /// A resulting in the queried , if present. [TgsGraphQLAuthorize] diff --git a/src/Tgstation.Server.Host/GraphQL/Types/Users.cs b/src/Tgstation.Server.Host/GraphQL/Types/Users.cs index d93f4ceec89..04ac751b257 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/Users.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/Users.cs @@ -30,7 +30,7 @@ public sealed class Users /// /// Gets the current . /// - /// The . + /// The for the . /// The for the operation. /// A resulting in the current . [TgsGraphQLAuthorize(nameof(IUserAuthority.Read))] @@ -46,7 +46,7 @@ public ValueTask Current( /// Gets a by . /// /// The of the . - /// The . + /// The for the . /// The for the operation. /// The represented by , if any. [Error(typeof(ErrorMessageException))] @@ -60,7 +60,7 @@ public ValueTask Current( /// /// Queries all registered s. /// - /// The . + /// The for the . /// A of all registered s. [UsePaging] [UseFiltering] From 8ccfd68f215e49a7739cb3fdac9a6f25e14e5471 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sat, 21 Sep 2024 17:25:44 -0400 Subject: [PATCH 070/107] Note about how `updateUsers` can remove users from groups --- src/Tgstation.Server.Host/GraphQL/Mutations/UserMutations.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Tgstation.Server.Host/GraphQL/Mutations/UserMutations.cs b/src/Tgstation.Server.Host/GraphQL/Mutations/UserMutations.cs index bb19506d0da..177ddbd1f11 100644 --- a/src/Tgstation.Server.Host/GraphQL/Mutations/UserMutations.cs +++ b/src/Tgstation.Server.Host/GraphQL/Mutations/UserMutations.cs @@ -283,7 +283,7 @@ public ValueTask SetCurrentOAuthConnections( /// 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 owned for the user. + /// Optional 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 . From 25a0e49e94e9649641b83d81160a2cfa6de9a695 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sat, 21 Sep 2024 17:30:22 -0400 Subject: [PATCH 071/107] Document user group mutations --- .../GraphQL/Mutations/UserGroupMutations.cs | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/src/Tgstation.Server.Host/GraphQL/Mutations/UserGroupMutations.cs b/src/Tgstation.Server.Host/GraphQL/Mutations/UserGroupMutations.cs index 09af618a059..f27346fe725 100644 --- a/src/Tgstation.Server.Host/GraphQL/Mutations/UserGroupMutations.cs +++ b/src/Tgstation.Server.Host/GraphQL/Mutations/UserGroupMutations.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Threading; using System.Threading.Tasks; @@ -18,6 +18,14 @@ namespace Tgstation.Server.Host.GraphQL.Mutations [ExtendObjectType(typeof(Mutation))] public sealed class UserGroupMutations { + /// + /// Creates a . + /// + /// The of the . + /// The initial permission set for the . + /// The for the . + /// The for the operation. + /// The created . public ValueTask CreateUserGroup( string name, PermissionSetInput? permissionSet, @@ -27,6 +35,15 @@ public ValueTask CreateUserGroup( throw new NotImplementedException(); } + /// + /// 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 . public ValueTask UpdateUserGroup( [ID(nameof(UserGroup))] long id, string? newName, @@ -37,7 +54,14 @@ public ValueTask UpdateUserGroup( throw new NotImplementedException(); } - public ValueTask DeleteEmptyUserGroup( + /// + /// Deletes a . + /// + /// The of the to update. + /// The for the . + /// The for the operation. + /// The root. + public ValueTask DeleteEmptyUserGroup( [ID(nameof(UserGroup))] long id, [Service] IUserGroupAuthority userGroupAuthority, CancellationToken cancellationToken) From 2c3542a82a3de0df136d6df3bc3ce1cddb938f78 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sat, 21 Sep 2024 17:59:58 -0400 Subject: [PATCH 072/107] Remember the basics: Gone for entities that can be deleted NotFound for entities that can't --- src/Tgstation.Server.Host/Authority/UserGroupAuthority.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Tgstation.Server.Host/Authority/UserGroupAuthority.cs b/src/Tgstation.Server.Host/Authority/UserGroupAuthority.cs index 12005ff3702..df7572562b6 100644 --- a/src/Tgstation.Server.Host/Authority/UserGroupAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/UserGroupAuthority.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Threading; @@ -82,7 +82,7 @@ public async ValueTask> GetId(long id, bool include userGroup = await userGroupsDataLoader.LoadAsync(id, cancellationToken); if (userGroup == null) - return NotFound(); + return Gone(); return new AuthorityResponse(userGroup); } From af21ba5bcf6796ee9b50b09ea69e48b21a6038c9 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sat, 21 Sep 2024 18:42:57 -0400 Subject: [PATCH 073/107] Implement user group mutations --- .../Authority/IUserGroupAuthority.cs | 30 +++++ .../Authority/UserGroupAuthority.cs | 107 +++++++++++++++- .../Controllers/UserGroupController.cs | 121 +++++------------- .../GraphQL/Mutations/UserGroupMutations.cs | 48 +++++-- 4 files changed, 206 insertions(+), 100 deletions(-) diff --git a/src/Tgstation.Server.Host/Authority/IUserGroupAuthority.cs b/src/Tgstation.Server.Host/Authority/IUserGroupAuthority.cs index 1e9d516228e..28113b95f5e 100644 --- a/src/Tgstation.Server.Host/Authority/IUserGroupAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/IUserGroupAuthority.cs @@ -37,5 +37,35 @@ public interface IUserGroupAuthority : IAuthority /// 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/UserGroupAuthority.cs b/src/Tgstation.Server.Host/Authority/UserGroupAuthority.cs index df7572562b6..4b9fcdb4014 100644 --- a/src/Tgstation.Server.Host/Authority/UserGroupAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/UserGroupAuthority.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Threading; @@ -8,9 +8,13 @@ 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; @@ -25,10 +29,15 @@ sealed class UserGroupAuthority : AuthorityBase, IUserGroupAuthority /// readonly IUserGroupsDataLoader userGroupsDataLoader; + /// + /// The of the . + /// + readonly IOptionsSnapshot generalConfigurationOptions; + /// /// Implements the . /// - /// The of s to load. + /// The of s to load. /// The to load from. /// The for the operation. /// A resulting in a of the requested s. @@ -54,17 +63,20 @@ public static Task> GetUserGroups( /// The to use. /// The to use. /// The value of . + /// The value of . public UserGroupAuthority( IAuthenticationContext authenticationContext, IDatabaseContext databaseContext, ILogger logger, - IUserGroupsDataLoader userGroupsDataLoader) + IUserGroupsDataLoader userGroupsDataLoader, + IOptionsSnapshot generalConfigurationOptions) : base( authenticationContext, databaseContext, logger) { this.userGroupsDataLoader = userGroupsDataLoader ?? throw new ArgumentNullException(nameof(userGroupsDataLoader)); + this.generalConfigurationOptions = generalConfigurationOptions ?? throw new ArgumentNullException(nameof(generalConfigurationOptions)); } /// @@ -92,7 +104,7 @@ public ValueTask> Read() { var group = AuthenticationContext.User!.Group; if (group == null) - return ValueTask.FromResult(NotFound()); + return ValueTask.FromResult(Gone()); return ValueTask.FromResult(new AuthorityResponse(group)); } @@ -111,5 +123,92 @@ public IQueryable Queryable(bool includeJoins) 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/Controllers/UserGroupController.cs b/src/Tgstation.Server.Host/Controllers/UserGroupController.cs index 20cd0c914c7..3ff0f0bcac6 100644 --- a/src/Tgstation.Server.Host/Controllers/UserGroupController.cs +++ b/src/Tgstation.Server.Host/Controllers/UserGroupController.cs @@ -4,9 +4,7 @@ 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; @@ -14,10 +12,8 @@ using Tgstation.Server.Api.Models.Response; using Tgstation.Server.Api.Rights; using Tgstation.Server.Host.Authority; -using Tgstation.Server.Host.Configuration; 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; @@ -35,11 +31,6 @@ public class UserGroupController : ApiController /// readonly IRestAuthorityInvoker userGroupAuthority; - /// - /// The for the . - /// - readonly GeneralConfiguration generalConfiguration; - /// /// Initializes a new instance of the class. /// @@ -48,14 +39,12 @@ public class UserGroupController : ApiController /// The for the . /// The for the . /// The value of . - /// The containing the value of . public UserGroupController( IDatabaseContext databaseContext, IAuthenticationContext authenticationContext, IApiHeadersProvider apiHeaders, ILogger logger, - IRestAuthorityInvoker userGroupAuthority, - IOptions generalConfigurationOptions) + IRestAuthorityInvoker userGroupAuthority) : base( databaseContext, authenticationContext, @@ -64,9 +53,22 @@ public UserGroupController( true) { this.userGroupAuthority = userGroupAuthority ?? throw new ArgumentNullException(nameof(userGroupAuthority)); - generalConfiguration = generalConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(generalConfigurationOptions)); } + /// + /// 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 . /// @@ -84,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 this.Created(dbGroup.ToApi(true)); + return await userGroupAuthority.InvokeTransformable( + this, + authority => authority.Create( + model.Name, + TransformApiPermissionSet(model.PermissionSet), + cancellationToken)); } /// @@ -121,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)); } /// @@ -207,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/GraphQL/Mutations/UserGroupMutations.cs b/src/Tgstation.Server.Host/GraphQL/Mutations/UserGroupMutations.cs index f27346fe725..92ef77d288c 100644 --- a/src/Tgstation.Server.Host/GraphQL/Mutations/UserGroupMutations.cs +++ b/src/Tgstation.Server.Host/GraphQL/Mutations/UserGroupMutations.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Threading; using System.Threading.Tasks; @@ -9,6 +9,8 @@ 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 { @@ -18,6 +20,20 @@ namespace Tgstation.Server.Host.GraphQL.Mutations [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 . /// @@ -26,13 +42,19 @@ public sealed class UserGroupMutations /// The for the . /// The for the operation. /// The created . + [TgsGraphQLAuthorize(nameof(IUserGroupAuthority.Create))] + [Error(typeof(ErrorMessageException))] public ValueTask CreateUserGroup( string name, PermissionSetInput? permissionSet, - [Service] IUserGroupAuthority userGroupAuthority, + [Service] IGraphQLAuthorityInvoker userGroupAuthority, CancellationToken cancellationToken) { - throw new NotImplementedException(); + ArgumentNullException.ThrowIfNull(name); + ArgumentNullException.ThrowIfNull(userGroupAuthority); + + return userGroupAuthority.InvokeTransformable( + authority => authority.Create(name, TransformApiPermissionSet(permissionSet), cancellationToken)); } /// @@ -44,14 +66,18 @@ public ValueTask CreateUserGroup( /// 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] IUserGroupAuthority userGroupAuthority, + [Service] IGraphQLAuthorityInvoker userGroupAuthority, CancellationToken cancellationToken) { - throw new NotImplementedException(); + ArgumentNullException.ThrowIfNull(userGroupAuthority); + return userGroupAuthority.InvokeTransformable( + authority => authority.Update(id, newName, TransformApiPermissionSet(newPermissionSet), cancellationToken)); } /// @@ -61,12 +87,18 @@ public ValueTask UpdateUserGroup( /// The for the . /// The for the operation. /// The root. - public ValueTask DeleteEmptyUserGroup( + [TgsGraphQLAuthorize(nameof(IUserGroupAuthority.DeleteEmpty))] + [Error(typeof(ErrorMessageException))] + public async ValueTask DeleteEmptyUserGroup( [ID(nameof(UserGroup))] long id, - [Service] IUserGroupAuthority userGroupAuthority, + [Service] IGraphQLAuthorityInvoker userGroupAuthority, CancellationToken cancellationToken) { - throw new NotImplementedException(); + ArgumentNullException.ThrowIfNull(userGroupAuthority); + await userGroupAuthority.Invoke( + authority => authority.DeleteEmpty(id, cancellationToken)); + + return new Query(); } } } From 36694ece7aaa57975d27bafcbe3f42c5994cbfd7 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sat, 21 Sep 2024 18:52:41 -0400 Subject: [PATCH 074/107] Simplify REST paginated result transformations --- src/Tgstation.Server.Host/Controllers/ApiController.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Tgstation.Server.Host/Controllers/ApiController.cs b/src/Tgstation.Server.Host/Controllers/ApiController.cs index 8a86e895b74..9739363db3b 100644 --- a/src/Tgstation.Server.Host/Controllers/ApiController.cs +++ b/src/Tgstation.Server.Host/Controllers/ApiController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Globalization; using System.Linq; @@ -341,11 +341,11 @@ async ValueTask PaginatedImpl( } 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(); From dc039644d995c6d094e3e51ff55f5881084871c4 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sat, 21 Sep 2024 19:56:03 -0400 Subject: [PATCH 075/107] Fix some logging placeholders --- .../System/WindowsNetworkPromptReaper.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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) From 2ba4740fe95c1f5033764b25bdcac7eb2767a9c0 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sat, 21 Sep 2024 21:35:43 -0400 Subject: [PATCH 076/107] Fix the EFCore warning for unordered queryables. Will need to revisit this --- .../Authority/Core/AuthorityInvokerBase{TAuthority}.cs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/Tgstation.Server.Host/Authority/Core/AuthorityInvokerBase{TAuthority}.cs b/src/Tgstation.Server.Host/Authority/Core/AuthorityInvokerBase{TAuthority}.cs index 0fe86fa1469..f04293aecc1 100644 --- a/src/Tgstation.Server.Host/Authority/Core/AuthorityInvokerBase{TAuthority}.cs +++ b/src/Tgstation.Server.Host/Authority/Core/AuthorityInvokerBase{TAuthority}.cs @@ -1,6 +1,8 @@ using System; using System.Linq; +using Tgstation.Server.Api.Models; + namespace Tgstation.Server.Host.Authority.Core { /// @@ -32,8 +34,14 @@ IQueryable IAuthorityInvoker.InvokeQueryable(Func< 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 authorityInvoker(Authority) + return queryable .Select(expression); } } From 4ca97d2635e5ab04f4a9eb60ab7d0e0fb4c74a4d Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 22 Sep 2024 00:41:57 -0400 Subject: [PATCH 077/107] Rework the 0-length password on create with OAuth scenario to be a little bit saner --- .../Authority/IUserAuthority.cs | 6 +- .../Authority/UserAuthority.cs | 34 ++++- .../Controllers/UserController.cs | 2 +- .../GraphQL/Mutations/UserMutations.cs | 139 +++++++++++++++--- 4 files changed, 154 insertions(+), 27 deletions(-) diff --git a/src/Tgstation.Server.Host/Authority/IUserAuthority.cs b/src/Tgstation.Server.Host/Authority/IUserAuthority.cs index d95d7d58a1d..b0a67681cd2 100644 --- a/src/Tgstation.Server.Host/Authority/IUserAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/IUserAuthority.cs @@ -55,10 +55,14 @@ public interface IUserAuthority : IAuthority /// 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, CancellationToken cancellationToken); + ValueTask> Create( + UserCreateRequest createRequest, + bool? needZeroLengthPasswordWithOAuthConnections, + CancellationToken cancellationToken); /// /// Updates a . diff --git a/src/Tgstation.Server.Host/Authority/UserAuthority.cs b/src/Tgstation.Server.Host/Authority/UserAuthority.cs index ed7dba09977..b670c012439 100644 --- a/src/Tgstation.Server.Host/Authority/UserAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/UserAuthority.cs @@ -202,6 +202,7 @@ public IQueryable Queryable(bool includeJoins) /// public async ValueTask> Create( UserCreateRequest createRequest, + bool? needZeroLengthPasswordWithOAuthConnections, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(createRequest); @@ -209,10 +210,28 @@ public async ValueTask> Create( if (createRequest.OAuthConnections?.Any(x => x == null) == true) return BadRequest(ErrorCode.ModelValidationFailure); - if ((createRequest.Password != null && createRequest.SystemIdentifier != null) - || (createRequest.Password == null && createRequest.SystemIdentifier == null && (createRequest.OAuthConnections?.Count > 0) != true)) + var hasNonNullPassword = createRequest.Password != null; + var hasNonNullSystemIdentifier = createRequest.SystemIdentifier != null; + var hasOAuthConnections = (createRequest.OAuthConnections?.Count > 0) == true; + if ((hasNonNullPassword && hasNonNullSystemIdentifier) + || (!hasNonNullPassword && !hasNonNullSystemIdentifier && !hasOAuthConnections)) return BadRequest(ErrorCode.UserMismatchPasswordSid); + 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) + return BadRequest(ErrorCode.ModelValidationFailure); + } + else if (hasZeroLengthPassword) + return BadRequest(ErrorCode.ModelValidationFailure); + } + if (createRequest.Group != null && createRequest.PermissionSet != null) return BadRequest(ErrorCode.UserGroupAndPermissionSet); @@ -254,11 +273,14 @@ public async ValueTask> Create( new ErrorMessageResponse(ErrorCode.RequiresPosixSystemIdentity), HttpFailureResponse.NotImplemented); } - else if (!(createRequest.Password?.Length == 0 && (createRequest.OAuthConnections?.Count > 0) == true)) + else { - var result = TrySetPassword(dbUser, createRequest.Password!, true); - if (result != null) - return result; + if (!(needZeroLengthPasswordWithOAuthConnections != false && hasZeroLengthPassword && hasOAuthConnections)) // special case allow PasswordHash to be null by setting Password to "" if OAuthConnections are set + { + var result = TrySetPassword(dbUser, createRequest.Password!, true); + if (result != null) + return result; + } } dbUser.CanonicalName = User.CanonicalizeName(dbUser.Name!); diff --git a/src/Tgstation.Server.Host/Controllers/UserController.cs b/src/Tgstation.Server.Host/Controllers/UserController.cs index be6866f928c..f55218ef647 100644 --- a/src/Tgstation.Server.Host/Controllers/UserController.cs +++ b/src/Tgstation.Server.Host/Controllers/UserController.cs @@ -67,7 +67,7 @@ public UserController( [TgsRestAuthorize(nameof(IUserAuthority.Create))] [ProducesResponseType(typeof(UserResponse), 201)] public ValueTask Create([FromBody] UserCreateRequest model, CancellationToken cancellationToken) - => userAuthority.InvokeTransformable(this, authority => authority.Create(model, cancellationToken)); + => userAuthority.InvokeTransformable(this, authority => authority.Create(model, null, cancellationToken)); /// /// Update a . diff --git a/src/Tgstation.Server.Host/GraphQL/Mutations/UserMutations.cs b/src/Tgstation.Server.Host/GraphQL/Mutations/UserMutations.cs index 177ddbd1f11..50c8587e0a9 100644 --- a/src/Tgstation.Server.Host/GraphQL/Mutations/UserMutations.cs +++ b/src/Tgstation.Server.Host/GraphQL/Mutations/UserMutations.cs @@ -72,37 +72,89 @@ public ValueTask CreateUserByPasswordAndPermissionSet( }) .ToList(), }, + false, cancellationToken)); } /// - /// Creates a system user specifying a personal . + /// Creates a TGS user specifying the they will belong to. /// - /// The of the . + /// The of the . + /// The password of the . /// If the is . /// The s for the user. - /// The owned of 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 CreateUserBySystemIDAndPermissionSet( - string systemIdentifier, + public ValueTask CreateUserByPasswordAndGroup( + string name, + string password, bool? enabled, IEnumerable? oAuthConnections, - PermissionSetInput permissionSet, + [ID(nameof(UserGroup))] long groupId, [Service] IGraphQLAuthorityInvoker userAuthority, CancellationToken cancellationToken) { - ArgumentException.ThrowIfNullOrWhiteSpace(systemIdentifier); + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentException.ThrowIfNullOrEmpty(password); ArgumentNullException.ThrowIfNull(userAuthority); return userAuthority.InvokeTransformable( authority => authority.Create( new UserCreateRequest { - SystemIdentifier = systemIdentifier, + 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) + { + ArgumentException.ThrowIfNullOrWhiteSpace(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 @@ -112,13 +164,14 @@ public ValueTask CreateUserBySystemIDAndPermissionSet( } : null, OAuthConnections = oAuthConnections - ?.Select(oAuthConnection => new Api.Models.OAuthConnection + .Select(oAuthConnection => new Api.Models.OAuthConnection { ExternalUserId = oAuthConnection.ExternalUserId, Provider = oAuthConnection.Provider, }) .ToList(), }, + true, cancellationToken)); } @@ -126,26 +179,24 @@ public ValueTask CreateUserBySystemIDAndPermissionSet( /// 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. + /// If the is . /// The for the . /// The for the operation. /// The created . [TgsGraphQLAuthorize(nameof(IUserAuthority.Create))] [Error(typeof(ErrorMessageException))] - public ValueTask CreateUserByPasswordAndGroup( + public ValueTask CreateUserByOAuthAndGroup( string name, - string password, - bool? enabled, - IEnumerable? oAuthConnections, + IEnumerable oAuthConnections, [ID(nameof(UserGroup))] long groupId, + bool? enabled, [Service] IGraphQLAuthorityInvoker userAuthority, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrWhiteSpace(name); - ArgumentException.ThrowIfNullOrEmpty(password); + ArgumentNullException.ThrowIfNull(oAuthConnections); ArgumentNullException.ThrowIfNull(userAuthority); return userAuthority.InvokeTransformable( @@ -153,12 +204,60 @@ public ValueTask CreateUserByPasswordAndGroup( new UserCreateRequest { Name = name, - Password = password, + 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) + { + ArgumentException.ThrowIfNullOrWhiteSpace(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 { @@ -167,6 +266,7 @@ public ValueTask CreateUserByPasswordAndGroup( }) .ToList(), }, + false, cancellationToken)); } @@ -175,8 +275,8 @@ public ValueTask CreateUserByPasswordAndGroup( /// /// The of the . /// If the is . - /// The s for the user. /// The of the the will belong to. + /// The s for the user. /// The for the . /// The for the operation. /// The created . @@ -185,8 +285,8 @@ public ValueTask CreateUserByPasswordAndGroup( public ValueTask CreateUserBySystemIDAndGroup( string systemIdentifier, bool? enabled, - IEnumerable? oAuthConnections, [ID(nameof(UserGroup))] long groupId, + IEnumerable? oAuthConnections, [Service] IGraphQLAuthorityInvoker userAuthority, CancellationToken cancellationToken) { @@ -211,6 +311,7 @@ public ValueTask CreateUserBySystemIDAndGroup( }) .ToList(), }, + false, cancellationToken)); } From 1d7e12d87b24cf698bd2df66f3cdb566b4dd1972 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 22 Sep 2024 01:06:30 -0400 Subject: [PATCH 078/107] Fix BOM --- src/Tgstation.Server.Host/Controllers/ApiController.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Tgstation.Server.Host/Controllers/ApiController.cs b/src/Tgstation.Server.Host/Controllers/ApiController.cs index 9739363db3b..005f61ca997 100644 --- a/src/Tgstation.Server.Host/Controllers/ApiController.cs +++ b/src/Tgstation.Server.Host/Controllers/ApiController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Globalization; using System.Linq; From eb2a5c080790de588156c7ee58bd79422c13a0db Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 22 Sep 2024 01:07:01 -0400 Subject: [PATCH 079/107] Apply code suggestion --- .../Live/MultiServerClient.cs | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/tests/Tgstation.Server.Tests/Live/MultiServerClient.cs b/tests/Tgstation.Server.Tests/Live/MultiServerClient.cs index 3fd2b4b8f6c..8e5f4480a1a 100644 --- a/tests/Tgstation.Server.Tests/Live/MultiServerClient.cs +++ b/tests/Tgstation.Server.Tests/Live/MultiServerClient.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Threading; using System.Threading.Tasks; @@ -12,16 +12,10 @@ namespace Tgstation.Server.Tests.Live { - sealed class MultiServerClient : IAsyncDisposable + sealed class MultiServerClient(IRestServerClient restServerClient, IGraphQLServerClient graphQLServerClient) : IAsyncDisposable { - public IRestServerClient RestClient { get; } - public IGraphQLServerClient GraphQLClient { get; } - - public MultiServerClient(IRestServerClient restServerClient, IGraphQLServerClient graphQLServerClient) - { - RestClient = restServerClient ?? throw new ArgumentNullException(nameof(restServerClient)); - GraphQLClient = graphQLServerClient ?? throw new ArgumentNullException(nameof(graphQLServerClient)); - } + public IRestServerClient RestClient { get; } = restServerClient ?? throw new ArgumentNullException(nameof(restServerClient)); + public IGraphQLServerClient GraphQLClient { get; } = graphQLServerClient ?? throw new ArgumentNullException(nameof(graphQLServerClient)); public static bool UseGraphQL => Boolean.TryParse(Environment.GetEnvironmentVariable("TGS_TEST_GRAPHQL"), out var result) && result; From 3fc08ccb510be22b38f7acd42e42bfc6f2084132 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 22 Sep 2024 01:14:43 -0400 Subject: [PATCH 080/107] Fix cyclomatic complexity warning --- .../Authority/UserAuthority.cs | 112 ++++++++++++------ 1 file changed, 74 insertions(+), 38 deletions(-) diff --git a/src/Tgstation.Server.Host/Authority/UserAuthority.cs b/src/Tgstation.Server.Host/Authority/UserAuthority.cs index b670c012439..c3002ed2886 100644 --- a/src/Tgstation.Server.Host/Authority/UserAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/UserAuthority.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -159,6 +160,75 @@ public UserAuthority( 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)); @@ -207,44 +277,8 @@ public async ValueTask> Create( { ArgumentNullException.ThrowIfNull(createRequest); - if (createRequest.OAuthConnections?.Any(x => x == null) == true) - return BadRequest(ErrorCode.ModelValidationFailure); - - var hasNonNullPassword = createRequest.Password != null; - var hasNonNullSystemIdentifier = createRequest.SystemIdentifier != null; - var hasOAuthConnections = (createRequest.OAuthConnections?.Count > 0) == true; - if ((hasNonNullPassword && hasNonNullSystemIdentifier) - || (!hasNonNullPassword && !hasNonNullSystemIdentifier && !hasOAuthConnections)) - return BadRequest(ErrorCode.UserMismatchPasswordSid); - - 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) - return BadRequest(ErrorCode.ModelValidationFailure); - } - else if (hasZeroLengthPassword) - return BadRequest(ErrorCode.ModelValidationFailure); - } - - if (createRequest.Group != null && createRequest.PermissionSet != null) - return BadRequest(ErrorCode.UserGroupAndPermissionSet); - - createRequest.Name = createRequest.Name?.Trim(); - if (createRequest.Name?.Length == 0) - createRequest.Name = null; - - if (!(createRequest.Name == null ^ createRequest.SystemIdentifier == null)) - return BadRequest(ErrorCode.UserMismatchNameSid); - - var fail = CheckValidName(createRequest, true); - if (fail != null) - return fail; + if (BadCreateRequestChecks(createRequest, needZeroLengthPasswordWithOAuthConnections, out var failResponse)) + return failResponse; var totalUsers = await DatabaseContext .Users @@ -275,6 +309,8 @@ public async ValueTask> Create( } else { + var hasZeroLengthPassword = createRequest.Password?.Length == 0; + var hasOAuthConnections = (createRequest.OAuthConnections?.Count > 0) == true; if (!(needZeroLengthPasswordWithOAuthConnections != false && hasZeroLengthPassword && hasOAuthConnections)) // special case allow PasswordHash to be null by setting Password to "" if OAuthConnections are set { var result = TrySetPassword(dbUser, createRequest.Password!, true); From 6b276f6216135fd49c386efab5e35dd95c0ac409 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 22 Sep 2024 01:15:16 -0400 Subject: [PATCH 081/107] Breakup `UserMutations` more to mitigate potential bad requests --- .../GraphQL/Mutations/UserMutations.cs | 121 ++++++++++++++++-- 1 file changed, 112 insertions(+), 9 deletions(-) diff --git a/src/Tgstation.Server.Host/GraphQL/Mutations/UserMutations.cs b/src/Tgstation.Server.Host/GraphQL/Mutations/UserMutations.cs index 50c8587e0a9..943116ed79b 100644 --- a/src/Tgstation.Server.Host/GraphQL/Mutations/UserMutations.cs +++ b/src/Tgstation.Server.Host/GraphQL/Mutations/UserMutations.cs @@ -378,14 +378,12 @@ public ValueTask SetCurrentOAuthConnections( } /// - /// Updates a user. + /// 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 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. @@ -397,15 +395,121 @@ public ValueTask UpdateUser( string? casingOnlyNameChange, string? newPassword, bool? enabled, - PermissionSetInput? newPermissionSet, - [ID(nameof(UserGroup))] long? newGroupId, IEnumerable? newOAuthConnections, [Service] IGraphQLAuthorityInvoker userAuthority, CancellationToken cancellationToken) { - ArgumentNullException.ThrowIfNull(newOAuthConnections); ArgumentNullException.ThrowIfNull(userAuthority); - return userAuthority.InvokeTransformable( + 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 { @@ -427,7 +531,7 @@ public ValueTask UpdateUser( } : null, OAuthConnections = newOAuthConnections - .Select(oAuthConnection => new Api.Models.OAuthConnection + ?.Select(oAuthConnection => new Api.Models.OAuthConnection { ExternalUserId = oAuthConnection.ExternalUserId, Provider = oAuthConnection.Provider, @@ -435,6 +539,5 @@ public ValueTask UpdateUser( .ToList(), }, cancellationToken)); - } } } From 00467fd77e6d7484827f321e6971589a0e9ada91 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 22 Sep 2024 13:37:16 -0400 Subject: [PATCH 082/107] Primary constructors cannot have readonly parameters. Not worth using. --- build/analyzers.ruleset | 7 ++----- .../Tgstation.Server.Tests/Live/MultiServerClient.cs | 12 +++++++++--- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/build/analyzers.ruleset b/build/analyzers.ruleset index 72be43fd431..b726c217166 100644 --- a/build/analyzers.ruleset +++ b/build/analyzers.ruleset @@ -1,4 +1,4 @@ - + @@ -6,7 +6,6 @@ - @@ -669,7 +668,6 @@ - @@ -756,7 +754,6 @@ - @@ -1048,4 +1045,4 @@ - + \ No newline at end of file diff --git a/tests/Tgstation.Server.Tests/Live/MultiServerClient.cs b/tests/Tgstation.Server.Tests/Live/MultiServerClient.cs index 8e5f4480a1a..270be6f8f70 100644 --- a/tests/Tgstation.Server.Tests/Live/MultiServerClient.cs +++ b/tests/Tgstation.Server.Tests/Live/MultiServerClient.cs @@ -12,10 +12,16 @@ namespace Tgstation.Server.Tests.Live { - sealed class MultiServerClient(IRestServerClient restServerClient, IGraphQLServerClient graphQLServerClient) : IAsyncDisposable + sealed class MultiServerClient : IAsyncDisposable { - public IRestServerClient RestClient { get; } = restServerClient ?? throw new ArgumentNullException(nameof(restServerClient)); - public IGraphQLServerClient GraphQLClient { get; } = graphQLServerClient ?? throw new ArgumentNullException(nameof(graphQLServerClient)); + public IRestServerClient RestClient { get; } + public IGraphQLServerClient GraphQLClient { get; } + + public MultiServerClient(IRestServerClient restServerClient, IGraphQLServerClient graphQLServerClient) + { + this.RestClient = restServerClient; + this.GraphQLClient = graphQLServerClient; + } public static bool UseGraphQL => Boolean.TryParse(Environment.GetEnvironmentVariable("TGS_TEST_GRAPHQL"), out var result) && result; From b92af3f68aae838fd0ad3ef2c1bf7f503abf195f Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 22 Sep 2024 14:24:08 -0400 Subject: [PATCH 083/107] Make page sizes consistent across REST/GQL --- src/Tgstation.Server.Host/Controllers/ApiController.cs | 4 ++-- src/Tgstation.Server.Host/Core/Application.cs | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Tgstation.Server.Host/Controllers/ApiController.cs b/src/Tgstation.Server.Host/Controllers/ApiController.cs index 005f61ca997..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. diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs index 0b0b091a6ee..d20e90aa56a 100644 --- a/src/Tgstation.Server.Host/Core/Application.cs +++ b/src/Tgstation.Server.Host/Core/Application.cs @@ -319,6 +319,8 @@ void ConfigureNewtonsoftJsonSerializerSettingsForApi(JsonSerializerSettings sett { pagingOptions.IncludeTotalCount = true; pagingOptions.RequirePagingBoundaries = false; + pagingOptions.DefaultPageSize = ApiController.DefaultPageSize; + pagingOptions.MaxPageSize = ApiController.MaximumPageSize; }) .AddFiltering() .AddSorting() From da15190ba8e76072ccb76a33f806d9e560cef129 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 22 Sep 2024 18:54:46 -0400 Subject: [PATCH 084/107] Fix issues with `RightsTypeInterceptor` --- .../GraphQL/Types/Instance.cs | 11 ++++ .../GraphQL/Types/InstancePermissionSet.cs | 42 ++++++++++++ .../Interceptors/RightsTypeInterceptor.cs | 66 ++++++++++++++++--- .../GraphQL/Types/LocalGateway.cs | 8 ++- 4 files changed, 118 insertions(+), 9 deletions(-) create mode 100644 src/Tgstation.Server.Host/GraphQL/Types/Instance.cs create mode 100644 src/Tgstation.Server.Host/GraphQL/Types/InstancePermissionSet.cs 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..ea532baa9f9 --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Types/Instance.cs @@ -0,0 +1,11 @@ +using System; +using System.Linq; + +namespace Tgstation.Server.Host.GraphQL.Types +{ + public sealed class Instance : Entity + { + 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..4730c21aff4 --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Types/InstancePermissionSet.cs @@ -0,0 +1,42 @@ +using Tgstation.Server.Api.Rights; + +namespace Tgstation.Server.Host.GraphQL.Types +{ + 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 index 405a5915106..538c32b3ad3 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/Interceptors/RightsTypeInterceptor.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/Interceptors/RightsTypeInterceptor.cs @@ -1,7 +1,10 @@ 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; @@ -13,6 +16,16 @@ namespace Tgstation.Server.Host.GraphQL.Types.Interceptors /// 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. /// @@ -28,12 +41,14 @@ sealed class RightsTypeInterceptor : TypeInterceptor /// public RightsTypeInterceptor() { - objectNames = new HashSet(); - inputNames = new HashSet(); + var rightTypes = Enum.GetValues(); + objectNames = new HashSet(rightTypes.Length); + inputNames = new HashSet(rightTypes.Length); - foreach (var rightType in Enum.GetValues()) + foreach (var rightType in rightTypes) { - var flagName = $"{rightType}RightsFlags"; + var rightName = rightType.ToString(); + var flagName = $"{rightName}RightsFlags"; objectNames.Add(flagName); inputNames.Add($"{flagName}Input"); @@ -50,7 +65,6 @@ static void FixFields(IBindableList fields) { TField? noneField = null; - const string NoneFieldName = "isNone"; foreach (var field in fields) { var fieldName = field.Name; @@ -60,7 +74,6 @@ static void FixFields(IBindableList fields) continue; } - const string IsPrefix = "is"; if (!fieldName.StartsWith(IsPrefix)) throw new InvalidOperationException("Expected flags enum type field to start with \"is\"!"); @@ -73,8 +86,36 @@ static void FixFields(IBindableList fields) 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 OnBeforeRegisterDependencies(ITypeDiscoveryContext discoveryContext, DefinitionBase definition) + public override void OnAfterRegisterDependencies(ITypeDiscoveryContext discoveryContext, DefinitionBase definition) { ArgumentNullException.ThrowIfNull(definition); @@ -84,8 +125,17 @@ public override void OnBeforeRegisterDependencies(ITypeDiscoveryContext discover FixFields(objectTypeDef.Fields); } else if (definition is InputObjectTypeDefinition inputTypeDef) - if (inputNames.Contains(inputTypeDef.Name)) + { + 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 index 09d21f7f2dc..1428d2c2f2b 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/LocalGateway.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/LocalGateway.cs @@ -1,4 +1,7 @@ -using Tgstation.Server.Host.GraphQL.Interfaces; +using System; +using System.Linq; + +using Tgstation.Server.Host.GraphQL.Interfaces; namespace Tgstation.Server.Host.GraphQL.Types { @@ -9,5 +12,8 @@ public sealed class LocalGateway : IGateway { /// public GatewayInformation Information() => new(); + + public IQueryable Instances() + => throw new NotImplementedException(); } } From 92b007dc4d3de41d68ec79104da433d8a3391df0 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 22 Sep 2024 19:01:19 -0400 Subject: [PATCH 085/107] Correct bad argument assertions --- .../GraphQL/Mutations/UserMutations.cs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/Tgstation.Server.Host/GraphQL/Mutations/UserMutations.cs b/src/Tgstation.Server.Host/GraphQL/Mutations/UserMutations.cs index 943116ed79b..64c671a7cbe 100644 --- a/src/Tgstation.Server.Host/GraphQL/Mutations/UserMutations.cs +++ b/src/Tgstation.Server.Host/GraphQL/Mutations/UserMutations.cs @@ -46,8 +46,8 @@ public ValueTask CreateUserByPasswordAndPermissionSet( [Service] IGraphQLAuthorityInvoker userAuthority, CancellationToken cancellationToken) { - ArgumentException.ThrowIfNullOrWhiteSpace(name); - ArgumentException.ThrowIfNullOrEmpty(password); + ArgumentNullException.ThrowIfNull(name); + ArgumentNullException.ThrowIfNull(password); ArgumentNullException.ThrowIfNull(userAuthority); return userAuthority.InvokeTransformable( @@ -98,8 +98,8 @@ public ValueTask CreateUserByPasswordAndGroup( [Service] IGraphQLAuthorityInvoker userAuthority, CancellationToken cancellationToken) { - ArgumentException.ThrowIfNullOrWhiteSpace(name); - ArgumentException.ThrowIfNullOrEmpty(password); + ArgumentNullException.ThrowIfNull(name); + ArgumentNullException.ThrowIfNull(password); ArgumentNullException.ThrowIfNull(userAuthority); return userAuthority.InvokeTransformable( @@ -145,7 +145,7 @@ public ValueTask CreateUserByOAuthAndPermissionSet( [Service] IGraphQLAuthorityInvoker userAuthority, CancellationToken cancellationToken) { - ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(name); ArgumentNullException.ThrowIfNull(oAuthConnections); ArgumentNullException.ThrowIfNull(userAuthority); @@ -195,7 +195,7 @@ public ValueTask CreateUserByOAuthAndGroup( [Service] IGraphQLAuthorityInvoker userAuthority, CancellationToken cancellationToken) { - ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(name); ArgumentNullException.ThrowIfNull(oAuthConnections); ArgumentNullException.ThrowIfNull(userAuthority); @@ -242,7 +242,7 @@ public ValueTask CreateUserBySystemIDAndPermissionSet( [Service] IGraphQLAuthorityInvoker userAuthority, CancellationToken cancellationToken) { - ArgumentException.ThrowIfNullOrWhiteSpace(systemIdentifier); + ArgumentNullException.ThrowIfNull(systemIdentifier); ArgumentNullException.ThrowIfNull(userAuthority); return userAuthority.InvokeTransformable( @@ -290,7 +290,7 @@ public ValueTask CreateUserBySystemIDAndGroup( [Service] IGraphQLAuthorityInvoker userAuthority, CancellationToken cancellationToken) { - ArgumentException.ThrowIfNullOrWhiteSpace(systemIdentifier); + ArgumentNullException.ThrowIfNull(systemIdentifier); ArgumentNullException.ThrowIfNull(userAuthority); return userAuthority.InvokeTransformable( @@ -331,7 +331,7 @@ public ValueTask SetCurrentUserPassword( [Service] IGraphQLAuthorityInvoker userAuthority, CancellationToken cancellationToken) { - ArgumentException.ThrowIfNullOrEmpty(newPassword); + ArgumentNullException.ThrowIfNull(newPassword); ArgumentNullException.ThrowIfNull(userAuthority); return userAuthority.InvokeTransformable( async authority => await authority.Update( From 8b09eb7fb5a44cd86f3d8493f9d795ec9fcc6035 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 22 Sep 2024 19:14:02 -0400 Subject: [PATCH 086/107] Implement SxS GraphQL users tests --- .../CreateSystemUserWithPermissionSet.graphql | 16 + .../CreateUserFromOAuthConnection.graphql | 14 + .../GQL/Mutations/CreateUserGroup.graphql | 40 + ...reateUserGroupWithInstanceListPerm.graphql | 40 + .../Mutations/CreateUserWithPassword.graphql | 14 + ...WithPasswordSelectOAuthConnections.graphql | 18 + .../GQL/Mutations/DeleteUserGroup.graphql | 11 + .../Mutations/SetFullPermsOnUserGroup.graphql | 69 ++ .../GQL/Mutations/SetUserGroup.graphql | 41 + .../Mutations/SetUserOAuthConnections.graphql | 25 + .../Mutations/SetUserPermissionSet.graphql | 66 ++ .../UpdateUserOAuthConnections.graphql | 18 + .../GQL/Queries/GetSomeGroupInfo.graphql | 41 + .../GQL/Queries/GetUserById.graphql | 14 + .../GQL/Queries/GetUserNameByNodeId.graphql | 8 + .../GQL/Queries/ListUserGroups.graphql | 14 + .../GQL/Queries/ListUsers.graphql | 12 + .../GQL/Queries/PageUserIds.graphql | 16 + .../GQL/Queries/ReadCurrentUser.graphql | 51 ++ .../Tgstation.Server.Tests/Live/ApiAssert.cs | 36 +- .../Live/GraphQLServerClientExtensions.cs | 47 + .../Live/IMultiServerClient.cs | 23 + .../Live/Instance/JobsHubTests.cs | 6 +- .../Live/MultiServerClient.cs | 13 +- .../Live/TestLiveServer.cs | 57 +- .../Tgstation.Server.Tests/Live/UsersTest.cs | 849 +++++++++++++----- 26 files changed, 1295 insertions(+), 264 deletions(-) create mode 100644 src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateSystemUserWithPermissionSet.graphql create mode 100644 src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateUserFromOAuthConnection.graphql create mode 100644 src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateUserGroup.graphql create mode 100644 src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateUserGroupWithInstanceListPerm.graphql create mode 100644 src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateUserWithPassword.graphql create mode 100644 src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateUserWithPasswordSelectOAuthConnections.graphql create mode 100644 src/Tgstation.Server.Client.GraphQL/GQL/Mutations/DeleteUserGroup.graphql create mode 100644 src/Tgstation.Server.Client.GraphQL/GQL/Mutations/SetFullPermsOnUserGroup.graphql create mode 100644 src/Tgstation.Server.Client.GraphQL/GQL/Mutations/SetUserGroup.graphql create mode 100644 src/Tgstation.Server.Client.GraphQL/GQL/Mutations/SetUserOAuthConnections.graphql create mode 100644 src/Tgstation.Server.Client.GraphQL/GQL/Mutations/SetUserPermissionSet.graphql create mode 100644 src/Tgstation.Server.Client.GraphQL/GQL/Mutations/UpdateUserOAuthConnections.graphql create mode 100644 src/Tgstation.Server.Client.GraphQL/GQL/Queries/GetSomeGroupInfo.graphql create mode 100644 src/Tgstation.Server.Client.GraphQL/GQL/Queries/GetUserById.graphql create mode 100644 src/Tgstation.Server.Client.GraphQL/GQL/Queries/GetUserNameByNodeId.graphql create mode 100644 src/Tgstation.Server.Client.GraphQL/GQL/Queries/ListUserGroups.graphql create mode 100644 src/Tgstation.Server.Client.GraphQL/GQL/Queries/ListUsers.graphql create mode 100644 src/Tgstation.Server.Client.GraphQL/GQL/Queries/PageUserIds.graphql create mode 100644 src/Tgstation.Server.Client.GraphQL/GQL/Queries/ReadCurrentUser.graphql create mode 100644 tests/Tgstation.Server.Tests/Live/GraphQLServerClientExtensions.cs create mode 100644 tests/Tgstation.Server.Tests/Live/IMultiServerClient.cs 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/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/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/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/IMultiServerClient.cs b/tests/Tgstation.Server.Tests/Live/IMultiServerClient.cs new file mode 100644 index 00000000000..d617c63288e --- /dev/null +++ b/tests/Tgstation.Server.Tests/Live/IMultiServerClient.cs @@ -0,0 +1,23 @@ +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; + } +} 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 270be6f8f70..451ae08c472 100644 --- a/tests/Tgstation.Server.Tests/Live/MultiServerClient.cs +++ b/tests/Tgstation.Server.Tests/Live/MultiServerClient.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Threading; using System.Threading.Tasks; @@ -12,7 +12,7 @@ namespace Tgstation.Server.Tests.Live { - sealed class MultiServerClient : IAsyncDisposable + sealed class MultiServerClient : IMultiServerClient, IAsyncDisposable { public IRestServerClient RestClient { get; } public IGraphQLServerClient GraphQLClient { get; } @@ -40,7 +40,7 @@ public ValueTask Execute( return restAction(RestClient); } - public async ValueTask ExecuteReadOnlyConfirmEquivalence( + public async ValueTask<(TRestResult, TGraphQLResult)> ExecuteReadOnlyConfirmEquivalence( Func> restAction, Func>> graphQLAction, Func comparison, @@ -50,8 +50,13 @@ public async ValueTask ExecuteReadOnlyConfirmEquivalence restClient.ServerInformation(cancellationToken), gqlClient => gqlClient.UnauthenticatedServerInformation.ExecuteAsync(cancellationToken), - (restServerInfo, gqlServerInfo) => 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; - })), + (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); } - async ValueTask CreateUserWithNoInstancePerms() + async ValueTask CreateUserWithNoInstancePerms() { var createRequest = new UserCreateRequest() { @@ -1408,10 +1413,10 @@ async ValueTask CreateUserWithNoInstancePerms() var user = await firstAdminRestClient.Users.Create(createRequest, cancellationToken); Assert.IsTrue(user.Enabled); - return await restClientFactory.CreateFromLogin(server.RootUrl, createRequest.Name, createRequest.Password, cancellationToken: cancellationToken); + return await CreateClient(server.RootUrl, createRequest.Name, createRequest.Password, false, cancellationToken); } - var jobsHubTest = new JobsHubTests(firstAdminRestClient, await CreateUserWithNoInstancePerms()); + var jobsHubTest = new JobsHubTests(firstAdminMultiClient, await CreateUserWithNoInstancePerms()); Task jobsHubTestTask; { if (server.DumpOpenApiSpecpath) @@ -1449,7 +1454,7 @@ async Task FailFast(Task task) jobsHubTestTask = FailFast(await jobsHubTest.Run(cancellationToken)); // returns Task var rootTest = FailFast(RawRequestTests.Run(restClientFactory, firstAdminRestClient, cancellationToken)); var adminTest = FailFast(new AdministrationTest(firstAdminRestClient.Administration).Run(cancellationToken)); - var usersTest = FailFast(new UsersTest(firstAdminRestClient).Run(cancellationToken)); + var usersTest = FailFast(new UsersTest(firstAdminMultiClient).Run(cancellationToken).AsTask()); var instanceManagerTest = new InstanceManagerTest(firstAdminRestClient, server.Directory); var compatInstanceTask = instanceManagerTest.CreateTestInstance("CompatTestsInstance", cancellationToken); @@ -1836,7 +1841,15 @@ await instanceClient.DreamDaemon.Update(new DreamDaemonRequest await serverTask; } - async ValueTask 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); @@ -1850,14 +1863,14 @@ async ValueTask CreateAdminClient(Uri url, CancellationToken restClientTask = restClientFactory.CreateFromLogin( url, - DefaultCredentials.AdminUserName, - DefaultCredentials.DefaultAdminUserPassword, + username, + password, cancellationToken: cancellationToken); using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); graphQLClientTask = graphQLClientFactory.CreateFromLogin( url, - DefaultCredentials.AdminUserName, - DefaultCredentials.DefaultAdminUserPassword, + username, + password, cancellationToken: cts.Token); IRestServerClient restClient; @@ -1898,14 +1911,14 @@ async ValueTask CreateAdminClient(Uri url, CancellationToken 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..3336490681f 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,23 +16,25 @@ 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( + await ValueTaskExtensions.WhenAll( BasicTests(cancellationToken), TestCreateSysUser(cancellationToken), TestSpamCreation(cancellationToken)); @@ -34,214 +42,525 @@ await Task.WhenAll( await TestPagination(cancellationToken); } - 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 + var (restUser, gqlUser) = await serverClient.ExecuteReadOnlyConfirmEquivalence( + client => client.Users.Read(cancellationToken), + client => client.ReadCurrentUser.ExecuteAsync(cancellationToken), + (restResult, graphQLResult) => { - 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 - { - 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 - { - 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 + async client => { - 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 +570,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 + const int ExpectedCount = 106; + return serverClient.Execute( + async restClient => { - }, 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 - { - 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)); + }); } } } From 3c2ba817d39f60d60c52f07dbb566ffe2fa9a883 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 22 Sep 2024 19:15:14 -0400 Subject: [PATCH 087/107] Add missing newline --- build/analyzers.ruleset | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build/analyzers.ruleset b/build/analyzers.ruleset index b726c217166..d9ee572d87d 100644 --- a/build/analyzers.ruleset +++ b/build/analyzers.ruleset @@ -1045,4 +1045,4 @@ - \ No newline at end of file + From d7c5374a9f794335d5820f679b90008063b1b4c1 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Mon, 23 Sep 2024 17:27:39 -0400 Subject: [PATCH 088/107] Fold `TokenValidator` into `AuthenticationContextFactory` --- src/Tgstation.Server.Host/Core/Application.cs | 5 +- .../Security/AuthenticationContextFactory.cs | 68 ++++++++++++-- .../Security/IAuthenticationContext.cs | 2 +- .../Security/IAuthenticationContextFactory.cs | 22 ----- .../Security/TokenValidator.cs | 91 ------------------- 5 files changed, 64 insertions(+), 124 deletions(-) delete mode 100644 src/Tgstation.Server.Host/Security/IAuthenticationContextFactory.cs delete mode 100644 src/Tgstation.Server.Host/Security/TokenValidator.cs diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs index d20e90aa56a..f6be54edfb3 100644 --- a/src/Tgstation.Server.Host/Core/Application.cs +++ b/src/Tgstation.Server.Host/Core/Application.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Frozen; using System.Collections.Generic; using System.Globalization; @@ -700,8 +700,7 @@ void ConfigureAuthenticationPipeline(IServiceCollection services) services.AddHttpContextAccessor(); services.AddScoped(); services.AddScoped(); - services.AddScoped(provider => provider.GetRequiredService()); - services.AddScoped(); + services.AddScoped(provider => provider.GetRequiredService()); // what if you // wanted to just do this: diff --git a/src/Tgstation.Server.Host/Security/AuthenticationContextFactory.cs b/src/Tgstation.Server.Host/Security/AuthenticationContextFactory.cs index 00df5df54fa..1d861ccea1f 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,48 @@ public AuthenticationContextFactory( public void Dispose() => currentAuthenticationContext.Dispose(); /// - public async ValueTask CreateAuthenticationContext(long userId, long? instanceId, DateTimeOffset notBefore, CancellationToken cancellationToken) + public async Task ValidateToken(TokenValidatedContext tokenValidatedContext, CancellationToken cancellationToken) { + 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); + } + + var nbfClaim = principal.FindFirst(JwtRegisteredClaimNames.Nbf); + if (nbfClaim == default) + throw new InvalidOperationException($"Missing '{JwtRegisteredClaimNames.Nbf}' claim!"); + + DateTimeOffset notBefore; + try + { + notBefore = new DateTimeOffset( + EpochTime.DateTime( + Int64.Parse(nbfClaim.Value, CultureInfo.InvariantCulture))); + } + catch (Exception ex) + { + throw new InvalidOperationException("Failed to parse nbf!", ex); + } + var user = await databaseContext .Users .AsQueryable() @@ -93,8 +147,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 +158,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 +169,7 @@ public async ValueTask CreateAuthenticationContext(long try { InstancePermissionSet? instancePermissionSet = null; + var instanceId = apiHeaders?.InstanceId; if (instanceId.HasValue) { instancePermissionSet = await databaseContext.InstancePermissionSets @@ -131,7 +186,6 @@ public async ValueTask CreateAuthenticationContext(long systemIdentity, user, instancePermissionSet); - return currentAuthenticationContext; } catch { diff --git a/src/Tgstation.Server.Host/Security/IAuthenticationContext.cs b/src/Tgstation.Server.Host/Security/IAuthenticationContext.cs index 8277a6601d2..6aa4888f0b3 100644 --- a/src/Tgstation.Server.Host/Security/IAuthenticationContext.cs +++ b/src/Tgstation.Server.Host/Security/IAuthenticationContext.cs @@ -4,7 +4,7 @@ namespace Tgstation.Server.Host.Security { /// - /// Represents the currently authenticated . + /// For creating and accessing authentication contexts. /// public interface IAuthenticationContext { 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/TokenValidator.cs b/src/Tgstation.Server.Host/Security/TokenValidator.cs deleted file mode 100644 index 89e478df17a..00000000000 --- a/src/Tgstation.Server.Host/Security/TokenValidator.cs +++ /dev/null @@ -1,91 +0,0 @@ -using System; -using System.Globalization; -using System.Security.Claims; -using System.Threading; -using System.Threading.Tasks; - -using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.IdentityModel.JsonWebTokens; -using Microsoft.IdentityModel.Tokens; - -using Tgstation.Server.Api; -using Tgstation.Server.Host.Utils; - -namespace Tgstation.Server.Host.Security -{ - /// - public class TokenValidator : ITokenValidator - { - /// - /// The for the . - /// - readonly IAuthenticationContextFactory authenticationContextFactory; - - /// - /// The for the . - /// - readonly ApiHeaders? apiHeaders; - - /// - /// Initializes a new instance of the class. - /// - /// The value of . - /// The containing the value of . - public TokenValidator(IAuthenticationContextFactory authenticationContextFactory, IApiHeadersProvider apiHeadersProvider) - { - this.authenticationContextFactory = authenticationContextFactory ?? throw new ArgumentNullException(nameof(authenticationContextFactory)); - ArgumentNullException.ThrowIfNull(apiHeadersProvider); - apiHeaders = apiHeadersProvider.ApiHeaders; - } - - /// - public async Task ValidateToken(TokenValidatedContext tokenValidatedContext, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(tokenValidatedContext); - - if (tokenValidatedContext.SecurityToken is not JsonWebToken jwt) - throw new ArgumentException($"Expected {nameof(tokenValidatedContext)} to contain a {nameof(JsonWebToken)}!", nameof(tokenValidatedContext)); - - 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); - } - - 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); - - if (!authenticationContext.Valid) - tokenValidatedContext.Fail("Authentication context could not be created!"); - } - } -} From d0d2db5f046ee0741217816e46be1e050d9ae348 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Mon, 23 Sep 2024 20:04:29 -0400 Subject: [PATCH 089/107] Implement basic session invalidation subscriptions --- .../Authority/LoginAuthority.cs | 26 ++-- .../Authority/UserAuthority.cs | 37 +++-- src/Tgstation.Server.Host/Core/Application.cs | 7 +- .../GraphQL/Subscription.cs | 61 ++++++++ .../Types/SessionInvalidationReason.cs | 23 +++ .../Security/AuthenticationContext.cs | 47 +++++- .../Security/AuthenticationContextFactory.cs | 38 +++-- .../Security/IAuthenticationContext.cs | 16 +- .../Security/ISessionInvalidationTracker.cs | 22 +++ .../Security/SessionInvalidationTracker.cs | 140 ++++++++++++++++++ 10 files changed, 369 insertions(+), 48 deletions(-) create mode 100644 src/Tgstation.Server.Host/GraphQL/Subscription.cs create mode 100644 src/Tgstation.Server.Host/GraphQL/Types/SessionInvalidationReason.cs create mode 100644 src/Tgstation.Server.Host/Security/ISessionInvalidationTracker.cs create mode 100644 src/Tgstation.Server.Host/Security/SessionInvalidationTracker.cs diff --git a/src/Tgstation.Server.Host/Authority/LoginAuthority.cs b/src/Tgstation.Server.Host/Authority/LoginAuthority.cs index 4c5d2d6a771..7559ac023ae 100644 --- a/src/Tgstation.Server.Host/Authority/LoginAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/LoginAuthority.cs @@ -53,6 +53,11 @@ sealed class LoginAuthority : AuthorityBase, ILoginAuthority /// readonly IIdentityCache identityCache; + /// + /// The for the . + /// + readonly ISessionInvalidationTracker sessionInvalidationTracker; + /// /// Generate an for a given . /// @@ -99,6 +104,7 @@ static AuthorityResponse GenerateHeadersExceptionResponse(HeadersE /// The value of . /// The value of . /// The value of . + /// The value of . public LoginAuthority( IAuthenticationContext authenticationContext, IDatabaseContext databaseContext, @@ -108,7 +114,8 @@ public LoginAuthority( IOAuthProviders oAuthProviders, ITokenFactory tokenFactory, ICryptographySuite cryptographySuite, - IIdentityCache identityCache) + IIdentityCache identityCache, + ISessionInvalidationTracker sessionInvalidationTracker) : base( authenticationContext, databaseContext, @@ -120,6 +127,7 @@ public LoginAuthority( 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)); } /// @@ -230,14 +238,6 @@ public async ValueTask> AttemptLogin(Cancellatio 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 @@ -246,6 +246,14 @@ public async ValueTask> AttemptLogin(Cancellatio 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); } } diff --git a/src/Tgstation.Server.Host/Authority/UserAuthority.cs b/src/Tgstation.Server.Host/Authority/UserAuthority.cs index c3002ed2886..e740d7e40bb 100644 --- a/src/Tgstation.Server.Host/Authority/UserAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/UserAuthority.cs @@ -52,6 +52,11 @@ sealed class UserAuthority : AuthorityBase, IUserAuthority /// readonly ICryptographySuite cryptographySuite; + /// + /// The for the . + /// + readonly ISessionInvalidationTracker sessionInvalidationTracker; + /// /// The of for the . /// @@ -136,6 +141,7 @@ public static Task> GetUsers( /// The value of . /// The value of . /// The value of . + /// The value of . /// The value of . public UserAuthority( IAuthenticationContext authenticationContext, @@ -146,6 +152,7 @@ public UserAuthority( ISystemIdentityFactory systemIdentityFactory, IPermissionsUpdateNotifyee permissionsUpdateNotifyee, ICryptographySuite cryptographySuite, + ISessionInvalidationTracker sessionInvalidationTracker, IOptionsSnapshot generalConfigurationOptions) : base( authenticationContext, @@ -157,6 +164,7 @@ public UserAuthority( 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.generalConfigurationOptions = generalConfigurationOptions ?? throw new ArgumentNullException(nameof(generalConfigurationOptions)); } @@ -381,12 +389,14 @@ public async ValueTask> Update(UserUpdateRequest model, 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; - originalUser.LastPasswordUpdate = DateTimeOffset.UtcNow; + + invalidateSessions = true; } if (model.SystemIdentifier != null && model.SystemIdentifier != originalUser.SystemIdentifier) @@ -400,23 +410,13 @@ public async ValueTask> Update(UserUpdateRequest model, 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); - 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)))) @@ -477,11 +477,20 @@ public async ValueTask> Update(UserUpdateRequest model, 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 (userWasDisabled) + if (invalidateSessions) await permissionsUpdateNotifyee.UserDisabled(originalUser, cancellationToken); // return id only if not a self update and cannot read users diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs index f6be54edfb3..9e3a599bd6d 100644 --- a/src/Tgstation.Server.Host/Core/Application.cs +++ b/src/Tgstation.Server.Host/Core/Application.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Frozen; using System.Collections.Generic; using System.Globalization; @@ -309,6 +309,7 @@ void ConfigureNewtonsoftJsonSerializerSettingsForApi(JsonSerializerSettings sett }) #endif .AddMutationConventions() + .AddInMemorySubscriptions() .AddGlobalObjectIdentification() .AddQueryFieldToMutationPayloads() .ModifyOptions(options => @@ -334,7 +335,8 @@ void ConfigureNewtonsoftJsonSerializerSettingsForApi(JsonSerializerSettings sett .BindRuntimeType() .TryAddTypeInterceptor() .AddQueryType() - .AddMutationType(); + .AddMutationType() + .AddSubscriptionType(); void AddTypedContext() where TContext : DatabaseContext @@ -383,6 +385,7 @@ void AddTypedContext() services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton, PasswordHasher>(); // configure platform specific services diff --git a/src/Tgstation.Server.Host/GraphQL/Subscription.cs b/src/Tgstation.Server.Host/GraphQL/Subscription.cs new file mode 100644 index 00000000000..ba39c3d13bc --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Subscription.cs @@ -0,0 +1,61 @@ +using System; +using System.Threading.Tasks; + +using HotChocolate; +using HotChocolate.Execution; +using HotChocolate.Subscriptions; +using HotChocolate.Types; + +using Tgstation.Server.Host.GraphQL.Types; +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. + /// A resulting in a of the for the . + public ValueTask> SessionInvalidatedStream( + [Service] ITopicEventReceiver receiver, + [Service] ISessionInvalidationTracker invalidationTracker, + [Service] IAuthenticationContext authenticationContext) + { + ArgumentNullException.ThrowIfNull(receiver); + ArgumentNullException.ThrowIfNull(invalidationTracker); + + var subscription = receiver.SubscribeAsync(SessionInvalidatedTopic(authenticationContext)); + 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/Types/SessionInvalidationReason.cs b/src/Tgstation.Server.Host/GraphQL/Types/SessionInvalidationReason.cs new file mode 100644 index 00000000000..1c0e7ebe4ac --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Types/SessionInvalidationReason.cs @@ -0,0 +1,23 @@ +namespace Tgstation.Server.Host.GraphQL.Types +{ + /// + /// 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/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/AuthenticationContextFactory.cs b/src/Tgstation.Server.Host/Security/AuthenticationContextFactory.cs index 1d861ccea1f..a8e0b59ff9c 100644 --- a/src/Tgstation.Server.Host/Security/AuthenticationContextFactory.cs +++ b/src/Tgstation.Server.Host/Security/AuthenticationContextFactory.cs @@ -119,22 +119,27 @@ public async Task ValidateToken(TokenValidatedContext tokenValidatedContext, Can 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 notBefore; - try + DateTimeOffset ParseTime(string key) { - notBefore = new DateTimeOffset( - EpochTime.DateTime( - Int64.Parse(nbfClaim.Value, CultureInfo.InvariantCulture))); - } - catch (Exception ex) - { - throw new InvalidOperationException("Failed to parse nbf!", ex); + 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() @@ -183,9 +188,12 @@ public async Task ValidateToken(TokenValidatedContext tokenValidatedContext, Can } currentAuthenticationContext.Initialize( - systemIdentity, user, - instancePermissionSet); + expires, + // signature is enough to uniquely identify the session as it is composite of all the inputs + jwt.EncodedSignature, + instancePermissionSet, + systemIdentity); } catch { diff --git a/src/Tgstation.Server.Host/Security/IAuthenticationContext.cs b/src/Tgstation.Server.Host/Security/IAuthenticationContext.cs index 6aa4888f0b3..d827185dd27 100644 --- a/src/Tgstation.Server.Host/Security/IAuthenticationContext.cs +++ b/src/Tgstation.Server.Host/Security/IAuthenticationContext.cs @@ -1,4 +1,6 @@ -using Tgstation.Server.Api.Rights; +using System; + +using Tgstation.Server.Api.Rights; using Tgstation.Server.Host.Models; namespace Tgstation.Server.Host.Security @@ -11,7 +13,17 @@ 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/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/SessionInvalidationTracker.cs b/src/Tgstation.Server.Host/Security/SessionInvalidationTracker.cs new file mode 100644 index 00000000000..e25287210f3 --- /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.Types; +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); + } + } +} From af60f4f778fb60cff03c718526800d2b7de2bcf0 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Mon, 23 Sep 2024 20:11:26 -0400 Subject: [PATCH 090/107] Fix warnings, test build errors, and disable IDE0290 --- build/analyzers.ruleset | 2 ++ .../GraphQL/Interfaces/IGateway.cs | 10 ++++++- .../GraphQL/Types/Instance.cs | 7 +++++ .../GraphQL/Types/InstancePermissionSet.cs | 3 ++ .../GraphQL/Types/LocalGateway.cs | 1 + .../GraphQL/Types/RemoteGateway.cs | 4 +++ .../Security/AuthenticationContextFactory.cs | 5 ++-- .../Security/TestAuthenticationContext.cs | 28 +------------------ 8 files changed, 30 insertions(+), 30 deletions(-) diff --git a/build/analyzers.ruleset b/build/analyzers.ruleset index d9ee572d87d..15ba71dc89e 100644 --- a/build/analyzers.ruleset +++ b/build/analyzers.ruleset @@ -668,6 +668,7 @@ + @@ -754,6 +755,7 @@ + diff --git a/src/Tgstation.Server.Host/GraphQL/Interfaces/IGateway.cs b/src/Tgstation.Server.Host/GraphQL/Interfaces/IGateway.cs index 56722119d2d..1de582c06f7 100644 --- a/src/Tgstation.Server.Host/GraphQL/Interfaces/IGateway.cs +++ b/src/Tgstation.Server.Host/GraphQL/Interfaces/IGateway.cs @@ -1,4 +1,6 @@ -using Tgstation.Server.Host.GraphQL.Types; +using System.Linq; + +using Tgstation.Server.Host.GraphQL.Types; namespace Tgstation.Server.Host.GraphQL.Interfaces { @@ -12,5 +14,11 @@ public interface IGateway /// /// The for the . GatewayInformation Information(); + + /// + /// Queries all s in the . + /// + /// Queryable s. + IQueryable Instances(); } } diff --git a/src/Tgstation.Server.Host/GraphQL/Types/Instance.cs b/src/Tgstation.Server.Host/GraphQL/Types/Instance.cs index ea532baa9f9..192a5b377b1 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/Instance.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/Instance.cs @@ -3,8 +3,15 @@ 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 index 4730c21aff4..76bcabcac68 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/InstancePermissionSet.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/InstancePermissionSet.cs @@ -2,6 +2,9 @@ namespace Tgstation.Server.Host.GraphQL.Types { + /// + /// Represents a set of permissions for an . + /// public sealed class InstancePermissionSet { /// diff --git a/src/Tgstation.Server.Host/GraphQL/Types/LocalGateway.cs b/src/Tgstation.Server.Host/GraphQL/Types/LocalGateway.cs index 1428d2c2f2b..8b6c329d941 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/LocalGateway.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/LocalGateway.cs @@ -13,6 +13,7 @@ public sealed class LocalGateway : IGateway /// public GatewayInformation Information() => new(); + /// public IQueryable Instances() => throw new NotImplementedException(); } diff --git a/src/Tgstation.Server.Host/GraphQL/Types/RemoteGateway.cs b/src/Tgstation.Server.Host/GraphQL/Types/RemoteGateway.cs index 1a17b636e9f..fcb9f088f45 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/RemoteGateway.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/RemoteGateway.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using Tgstation.Server.Host.GraphQL.Interfaces; @@ -12,5 +13,8 @@ public sealed class RemoteGateway : IGateway { /// public GatewayInformation Information() => throw new NotImplementedException(); + + /// + public IQueryable Instances() => throw new NotImplementedException(); } } diff --git a/src/Tgstation.Server.Host/Security/AuthenticationContextFactory.cs b/src/Tgstation.Server.Host/Security/AuthenticationContextFactory.cs index a8e0b59ff9c..ba47b47a44e 100644 --- a/src/Tgstation.Server.Host/Security/AuthenticationContextFactory.cs +++ b/src/Tgstation.Server.Host/Security/AuthenticationContextFactory.cs @@ -93,7 +93,9 @@ public AuthenticationContextFactory( public void Dispose() => currentAuthenticationContext.Dispose(); /// + #pragma warning disable CA1506 // TODO: Decomplexify public async Task ValidateToken(TokenValidatedContext tokenValidatedContext, CancellationToken cancellationToken) + #pragma warning restore CA1506 { ArgumentNullException.ThrowIfNull(tokenValidatedContext); @@ -190,8 +192,7 @@ DateTimeOffset ParseTime(string key) currentAuthenticationContext.Initialize( user, expires, - // signature is enough to uniquely identify the session as it is composite of all the inputs - jwt.EncodedSignature, + jwt.EncodedSignature, // signature is enough to uniquely identify the session as it is composite of all the inputs instancePermissionSet, systemIdentity); } 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; From 0b87147385e56fe1ae0c7ff8746efb81ca7959e4 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Mon, 23 Sep 2024 20:46:31 -0400 Subject: [PATCH 091/107] Fix issue with GQL auth and no roles --- .../Security/TgsGraphQLAuthorizeAttribute.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Tgstation.Server.Host/Security/TgsGraphQLAuthorizeAttribute.cs b/src/Tgstation.Server.Host/Security/TgsGraphQLAuthorizeAttribute.cs index 5580b27a7bf..41f7ab7f37e 100644 --- a/src/Tgstation.Server.Host/Security/TgsGraphQLAuthorizeAttribute.cs +++ b/src/Tgstation.Server.Host/Security/TgsGraphQLAuthorizeAttribute.cs @@ -24,6 +24,7 @@ sealed class TgsGraphQLAuthorizeAttribute : AuthorizeAttribute /// Initializes a new instance of the class. /// public TgsGraphQLAuthorizeAttribute() + : this(Enumerable.Empty()) { } From e14d99823c73e8ef0956cb39cb9c0f247a4b828b Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Mon, 23 Sep 2024 21:21:22 -0400 Subject: [PATCH 092/107] Correct GQL Authorize policies --- .../Security/TgsGraphQLAuthorizeAttribute.cs | 1 + .../Security/TgsGraphQLAuthorizeAttribute{TAuthority}.cs | 1 + 2 files changed, 2 insertions(+) diff --git a/src/Tgstation.Server.Host/Security/TgsGraphQLAuthorizeAttribute.cs b/src/Tgstation.Server.Host/Security/TgsGraphQLAuthorizeAttribute.cs index 41f7ab7f37e..e03e7e60814 100644 --- a/src/Tgstation.Server.Host/Security/TgsGraphQLAuthorizeAttribute.cs +++ b/src/Tgstation.Server.Host/Security/TgsGraphQLAuthorizeAttribute.cs @@ -127,6 +127,7 @@ 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 index fb890f7e922..495424073af 100644 --- a/src/Tgstation.Server.Host/Security/TgsGraphQLAuthorizeAttribute{TAuthority}.cs +++ b/src/Tgstation.Server.Host/Security/TgsGraphQLAuthorizeAttribute{TAuthority}.cs @@ -35,6 +35,7 @@ public TgsGraphQLAuthorizeAttribute(string methodName) ?? throw new InvalidOperationException($"Could not find method {authorityType}.{methodName}() has no {nameof(TgsAuthorizeAttribute)}!"); MethodName = methodName; Roles = authorizeAttribute.Roles?.Split(',', StringSplitOptions.RemoveEmptyEntries); + Apply = ApplyPolicy.Validation; } } } From 1931b25d1209e22195e3c210c0b25d27931c4e67 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Mon, 23 Sep 2024 21:24:14 -0400 Subject: [PATCH 093/107] Fix build warning --- src/Tgstation.Server.Host/Authority/UserAuthority.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Tgstation.Server.Host/Authority/UserAuthority.cs b/src/Tgstation.Server.Host/Authority/UserAuthority.cs index e740d7e40bb..9bfa2b5f0fe 100644 --- a/src/Tgstation.Server.Host/Authority/UserAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/UserAuthority.cs @@ -319,7 +319,9 @@ public async ValueTask> Create( { var hasZeroLengthPassword = createRequest.Password?.Length == 0; var hasOAuthConnections = (createRequest.OAuthConnections?.Count > 0) == true; - if (!(needZeroLengthPasswordWithOAuthConnections != false && hasZeroLengthPassword && hasOAuthConnections)) // special case allow PasswordHash to be null by setting Password to "" if OAuthConnections are set + + // 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) From f38a21f42eddd01efb76c0d4df4e57625bc1bba0 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Mon, 23 Sep 2024 21:26:51 -0400 Subject: [PATCH 094/107] Move to saner namespace --- src/Tgstation.Server.Host/GraphQL/Subscription.cs | 2 +- .../{Types => Subscriptions}/SessionInvalidationReason.cs | 2 +- .../Security/SessionInvalidationTracker.cs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) rename src/Tgstation.Server.Host/GraphQL/{Types => Subscriptions}/SessionInvalidationReason.cs (91%) diff --git a/src/Tgstation.Server.Host/GraphQL/Subscription.cs b/src/Tgstation.Server.Host/GraphQL/Subscription.cs index ba39c3d13bc..d47d83a8d6f 100644 --- a/src/Tgstation.Server.Host/GraphQL/Subscription.cs +++ b/src/Tgstation.Server.Host/GraphQL/Subscription.cs @@ -6,7 +6,7 @@ using HotChocolate.Subscriptions; using HotChocolate.Types; -using Tgstation.Server.Host.GraphQL.Types; +using Tgstation.Server.Host.GraphQL.Subscriptions; using Tgstation.Server.Host.Security; namespace Tgstation.Server.Host.GraphQL diff --git a/src/Tgstation.Server.Host/GraphQL/Types/SessionInvalidationReason.cs b/src/Tgstation.Server.Host/GraphQL/Subscriptions/SessionInvalidationReason.cs similarity index 91% rename from src/Tgstation.Server.Host/GraphQL/Types/SessionInvalidationReason.cs rename to src/Tgstation.Server.Host/GraphQL/Subscriptions/SessionInvalidationReason.cs index 1c0e7ebe4ac..37e98678138 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/SessionInvalidationReason.cs +++ b/src/Tgstation.Server.Host/GraphQL/Subscriptions/SessionInvalidationReason.cs @@ -1,4 +1,4 @@ -namespace Tgstation.Server.Host.GraphQL.Types +namespace Tgstation.Server.Host.GraphQL.Subscriptions { /// /// Reasons TGS may invalidate a user's login session. diff --git a/src/Tgstation.Server.Host/Security/SessionInvalidationTracker.cs b/src/Tgstation.Server.Host/Security/SessionInvalidationTracker.cs index e25287210f3..5f5157e3619 100644 --- a/src/Tgstation.Server.Host/Security/SessionInvalidationTracker.cs +++ b/src/Tgstation.Server.Host/Security/SessionInvalidationTracker.cs @@ -10,7 +10,7 @@ using Microsoft.Extensions.Logging; using Tgstation.Server.Host.GraphQL; -using Tgstation.Server.Host.GraphQL.Types; +using Tgstation.Server.Host.GraphQL.Subscriptions; using Tgstation.Server.Host.Models; using Tgstation.Server.Host.Utils; From 14bc1aca59532291a72f7435dda9d55c180f6510 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Tue, 24 Sep 2024 06:38:57 -0400 Subject: [PATCH 095/107] Fix Linux live tests user count being off --- tests/Tgstation.Server.Tests/Live/UsersTest.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/Tgstation.Server.Tests/Live/UsersTest.cs b/tests/Tgstation.Server.Tests/Live/UsersTest.cs index 3336490681f..a309e906ec6 100644 --- a/tests/Tgstation.Server.Tests/Live/UsersTest.cs +++ b/tests/Tgstation.Server.Tests/Live/UsersTest.cs @@ -575,7 +575,7 @@ ValueTask CreateSpamUser() ValueTask TestPagination(CancellationToken cancellationToken) { - const int ExpectedCount = 106; + var expectedCount = new PlatformIdentifier().IsWindows ? 106 : 105; // system user return serverClient.Execute( async restClient => { @@ -587,7 +587,7 @@ ValueTask TestPagination(CancellationToken cancellationToken) }, cancellationToken); Assert.AreEqual(nullSettings.Count, emptySettings.Count); - Assert.AreEqual(ExpectedCount, nullSettings.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( @@ -638,8 +638,8 @@ async ValueTask TestPageSize(int? inputPageSize) string cursor = null; - var exactMatch = (ExpectedCount % outputPageSize) == 0; - var expectedIterations = (ExpectedCount / outputPageSize) + (exactMatch ? 0 : 1); + var exactMatch = (expectedCount % outputPageSize) == 0; + var expectedIterations = (expectedCount / outputPageSize) + (exactMatch ? 0 : 1); for (int i = 0; i < expectedIterations; ++i) { var isLastIteration = i == expectedIterations - 1; @@ -653,7 +653,7 @@ async ValueTask TestPageSize(int? inputPageSize) if (!isLastIteration || exactMatch) Assert.AreEqual(outputPageSize, queryable.Nodes.Count); else - Assert.AreEqual(ExpectedCount % outputPageSize, queryable.Nodes.Count); + Assert.AreEqual(expectedCount % outputPageSize, queryable.Nodes.Count); Assert.AreEqual(!isLastIteration, queryable.PageInfo.HasNextPage); Assert.IsNotNull(queryable.PageInfo.EndCursor); From e5f08c3ed96eb639604f4caa9dcbe38ae3475ed1 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Tue, 24 Sep 2024 06:41:21 -0400 Subject: [PATCH 096/107] Split up which jobs `TGS_TEST_GRAPHQL` is set in more --- .github/workflows/ci-pipeline.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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' }} From 5faf6eee9cb32332a3af8582474ee3b1f7e540d6 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Tue, 24 Sep 2024 07:06:36 -0400 Subject: [PATCH 097/107] Add missing subscription `CancellationToken` --- src/Tgstation.Server.Host/GraphQL/Subscription.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Tgstation.Server.Host/GraphQL/Subscription.cs b/src/Tgstation.Server.Host/GraphQL/Subscription.cs index d47d83a8d6f..c7c9c80669c 100644 --- a/src/Tgstation.Server.Host/GraphQL/Subscription.cs +++ b/src/Tgstation.Server.Host/GraphQL/Subscription.cs @@ -1,4 +1,5 @@ using System; +using System.Threading; using System.Threading.Tasks; using HotChocolate; @@ -34,16 +35,18 @@ public static string SessionInvalidatedTopic(IAuthenticationContext authenticati /// The . /// The . /// The for the request. + /// The for the operation. /// A resulting in a of the for the . public ValueTask> SessionInvalidatedStream( [Service] ITopicEventReceiver receiver, [Service] ISessionInvalidationTracker invalidationTracker, - [Service] IAuthenticationContext authenticationContext) + [Service] IAuthenticationContext authenticationContext, + CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(receiver); ArgumentNullException.ThrowIfNull(invalidationTracker); - var subscription = receiver.SubscribeAsync(SessionInvalidatedTopic(authenticationContext)); + var subscription = receiver.SubscribeAsync(SessionInvalidatedTopic(authenticationContext), cancellationToken); invalidationTracker.TrackSession(authenticationContext); return subscription; } From 1cb77e102f9eb471e01f9f5c038dcd51e5b16cab Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Tue, 24 Sep 2024 07:06:48 -0400 Subject: [PATCH 098/107] Add `User` subscriptions without event senders --- .../Subscriptions/UserSubscriptions.cs | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 src/Tgstation.Server.Host/GraphQL/Subscriptions/UserSubscriptions.cs 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..b5a8c149988 --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Subscriptions/UserSubscriptions.cs @@ -0,0 +1,93 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +using HotChocolate; +using HotChocolate.Execution; +using HotChocolate.Subscriptions; +using HotChocolate.Types; + +using Tgstation.Server.Api.Rights; +using Tgstation.Server.Host.GraphQL.Types; +using Tgstation.Server.Host.Models.Transformers; +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}"; + + /// + /// Receive an update for all changes. + /// + /// The received from the publisher. + /// The updated . + [Subscribe] + [TgsGraphQLAuthorize(AdministrationRights.ReadUsers)] + public User UserUpdated([EventMessage] Models.User user) + { + ArgumentNullException.ThrowIfNull(user); + + return ((Models.IApiTransformable)user).ToApi(); + } + + /// + /// for . + /// + /// The . + /// The for the request. + /// The for the operation. + /// A resulting in a of the for the . + 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] + [TgsGraphQLAuthorize] + public User CurrentUserUpdated([EventMessage] Models.User user) + { + ArgumentNullException.ThrowIfNull(user); + + return ((Models.IApiTransformable)user).ToApi(); + } + } +} From 89770a6121e5cce613e5e6b5de39b1423ede112f Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Tue, 24 Sep 2024 18:07:56 -0400 Subject: [PATCH 099/107] Don't bring database models into the GraphQL type system --- .../GraphQL/Subscriptions/UserSubscriptions.cs | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/Tgstation.Server.Host/GraphQL/Subscriptions/UserSubscriptions.cs b/src/Tgstation.Server.Host/GraphQL/Subscriptions/UserSubscriptions.cs index b5a8c149988..c8dfb253f8d 100644 --- a/src/Tgstation.Server.Host/GraphQL/Subscriptions/UserSubscriptions.cs +++ b/src/Tgstation.Server.Host/GraphQL/Subscriptions/UserSubscriptions.cs @@ -10,7 +10,6 @@ using Tgstation.Server.Api.Rights; using Tgstation.Server.Host.GraphQL.Types; -using Tgstation.Server.Host.Models.Transformers; using Tgstation.Server.Host.Security; namespace Tgstation.Server.Host.GraphQL.Subscriptions @@ -52,28 +51,27 @@ static string SpecificUserUpdatedTopic(long userId) /// The updated . [Subscribe] [TgsGraphQLAuthorize(AdministrationRights.ReadUsers)] - public User UserUpdated([EventMessage] Models.User user) + public User UserUpdated([EventMessage] User user) { ArgumentNullException.ThrowIfNull(user); - - return ((Models.IApiTransformable)user).ToApi(); + return user; } /// - /// for . + /// for . /// /// The . /// The for the request. /// The for the operation. /// A resulting in a of the for the . - public ValueTask> CurrentUserUpdatedStream( + 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); + return receiver.SubscribeAsync(SpecificUserUpdatedTopic(Models.ModelExtensions.Require(authenticationContext.User, user => user.Id)), cancellationToken); } /// @@ -83,11 +81,11 @@ public User UserUpdated([EventMessage] Models.User user) /// The updated . [Subscribe] [TgsGraphQLAuthorize] - public User CurrentUserUpdated([EventMessage] Models.User user) + public User CurrentUserUpdated([EventMessage] User user) { ArgumentNullException.ThrowIfNull(user); - return ((Models.IApiTransformable)user).ToApi(); + return user; } } } From 4ccc773e1b2edd0eeca6074ecbd4f38ecfd080da Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Tue, 24 Sep 2024 18:14:29 -0400 Subject: [PATCH 100/107] Make extension method for authentication errors --- .../GraphQLServerClient.cs | 17 +---------- .../OperationResultExtensions.cs | 30 +++++++++++++++++++ 2 files changed, 31 insertions(+), 16 deletions(-) create mode 100644 src/Tgstation.Server.Client.GraphQL/OperationResultExtensions.cs diff --git a/src/Tgstation.Server.Client.GraphQL/GraphQLServerClient.cs b/src/Tgstation.Server.Client.GraphQL/GraphQLServerClient.cs index 60004ce1fd6..178c44e7136 100644 --- a/src/Tgstation.Server.Client.GraphQL/GraphQLServerClient.cs +++ b/src/Tgstation.Server.Client.GraphQL/GraphQLServerClient.cs @@ -1,6 +1,5 @@ using System; using System.Diagnostics.CodeAnalysis; -using System.Linq; using System.Net.Http.Headers; using System.Threading; using System.Threading.Tasks; @@ -73,20 +72,6 @@ class GraphQLServerClient : IGraphQLServerClient static void ThrowOtherCallerFailedAuthException() => throw new AuthenticationException("Another caller failed to authenticate!"); - /// - /// Checks if a given errored out with authentication errors. - /// - /// The . - /// if errored due to authentication issues, otherwise. - static bool IsAuthenticationError(IOperationResult operationResult) - => operationResult.Data == null - && operationResult.Errors.Any( - error => error.Extensions?.TryGetValue( - "code", - out object? codeExtension) == true - && codeExtension is string codeExtensionString - && codeExtensionString == "AUTH_NOT_AUTHENTICATED"); - /// /// Initializes a new instance of the class. /// @@ -243,7 +228,7 @@ async ValueTask Reauthenticate(AuthenticationHeaderVa var operationResult = await operationExecutor(graphQLClient); - if (IsAuthenticationError(operationResult)) + if (operationResult.IsAuthenticationError()) { currentAuthHeader = await Reauthenticate(currentAuthHeader, cancellationToken).ConfigureAwait(false); setAuthenticationHeader(currentAuthHeader); 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"); + } + } +} From 6de91eb964fadcf137191c3703b6bb6232de0611 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Tue, 24 Sep 2024 19:05:59 -0400 Subject: [PATCH 101/107] Very basic subscription tests and client support --- .../.graphqlrc.json | 2 +- .../Subscriptions/SessionInvalidation.graphql | 3 + .../GraphQLServerClient.cs | 152 +++++++++++------- .../IGraphQLServerClient.cs | 11 ++ .../Live/HoldLastObserver.cs | 34 ++++ .../Live/TestLiveServer.cs | 56 ++++++- 6 files changed, 194 insertions(+), 64 deletions(-) create mode 100644 src/Tgstation.Server.Client.GraphQL/GQL/Subscriptions/SessionInvalidation.graphql create mode 100644 tests/Tgstation.Server.Tests/Live/HoldLastObserver.cs 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/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/GraphQLServerClient.cs b/src/Tgstation.Server.Client.GraphQL/GraphQLServerClient.cs index 178c44e7136..0f46608df1a 100644 --- a/src/Tgstation.Server.Client.GraphQL/GraphQLServerClient.cs +++ b/src/Tgstation.Server.Client.GraphQL/GraphQLServerClient.cs @@ -124,19 +124,47 @@ protected GraphQLServerClient( public virtual ValueTask DisposeAsync() => serviceProvider.DisposeAsync(); /// - public ValueTask> RunOperationAsync(Func>> queryExector, CancellationToken cancellationToken) + public ValueTask> RunOperationAsync(Func>> operationExecutor, CancellationToken cancellationToken) where TResultData : class { - ArgumentNullException.ThrowIfNull(queryExector); - return WrapAuthentication(queryExector, cancellationToken); + ArgumentNullException.ThrowIfNull(operationExecutor); + return WrapAuthentication(operationExecutor, cancellationToken); } /// - public ValueTask> RunOperation(Func>> queryExector, CancellationToken cancellationToken) + public ValueTask> RunOperation(Func>> operationExecutor, CancellationToken cancellationToken) where TResultData : class { - ArgumentNullException.ThrowIfNull(queryExector); - return WrapAuthentication(async localClient => await queryExector(localClient), cancellationToken); + ArgumentNullException.ThrowIfNull(operationExecutor); + return WrapAuthentication(async localClient => await operationExecutor(localClient), cancellationToken); + } + + /// + 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); } /// @@ -167,59 +195,6 @@ async ValueTask> WrapAuthentication(F if (!tuple.HasValue) ThrowOtherCallerFailedAuthException(); - async ValueTask Reauthenticate(AuthenticationHeaderValue currentToken, CancellationToken cancellationToken) - { - 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; - } - } - var (currentAuthHeader, expires) = tuple.Value; if (expires <= DateTimeOffset.UtcNow) currentAuthHeader = await Reauthenticate(currentAuthHeader, cancellationToken).ConfigureAwait(false); @@ -238,6 +213,65 @@ async ValueTask Reauthenticate(AuthenticationHeaderVa 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) + { + 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 . /// diff --git a/src/Tgstation.Server.Client.GraphQL/IGraphQLServerClient.cs b/src/Tgstation.Server.Client.GraphQL/IGraphQLServerClient.cs index c20a606fa09..bddfeae618b 100644 --- a/src/Tgstation.Server.Client.GraphQL/IGraphQLServerClient.cs +++ b/src/Tgstation.Server.Client.GraphQL/IGraphQLServerClient.cs @@ -32,5 +32,16 @@ ValueTask> RunOperationAsync(FuncThrown when automatic reauthentication fails. ValueTask> RunOperation(Func>> operationExecutor, CancellationToken cancellationToken) where TResultData : 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/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/TestLiveServer.cs b/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs index 93776764e61..6071e51d489 100644 --- a/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs +++ b/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs @@ -1395,6 +1395,19 @@ await unAuthedMultiClient.ExecuteReadOnlyConfirmEquivalence( 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() @@ -1416,6 +1429,8 @@ async ValueTask CreateUserWithNoInstancePerms() return await CreateClient(server.RootUrl, createRequest.Name, createRequest.Password, false, cancellationToken); } + var restartObserver = new HoldLastObserver>(); + IDisposable restartSubscription; var jobsHubTest = new JobsHubTests(firstAdminMultiClient, await CreateUserWithNoInstancePerms()); Task jobsHubTestTask; { @@ -1579,12 +1594,45 @@ await FailFast( initialStaged = dd.StagedCompileJob.Id.Value; initialSessionId = dd.SessionId.Value; - jobsHubTest.ExpectShutdown(); - await firstAdminRestClient.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... From 0b966c69af822ecda7bd9655f17ecdd7f1d6b0a7 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Tue, 24 Sep 2024 21:18:00 -0400 Subject: [PATCH 102/107] Fixup `UserSubscriptions` --- .../Subscriptions/UserSubscriptions.cs | 28 +++++++++++++++++-- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/src/Tgstation.Server.Host/GraphQL/Subscriptions/UserSubscriptions.cs b/src/Tgstation.Server.Host/GraphQL/Subscriptions/UserSubscriptions.cs index c8dfb253f8d..c5b0656e6bb 100644 --- a/src/Tgstation.Server.Host/GraphQL/Subscriptions/UserSubscriptions.cs +++ b/src/Tgstation.Server.Host/GraphQL/Subscriptions/UserSubscriptions.cs @@ -7,6 +7,9 @@ using HotChocolate.Execution; using HotChocolate.Subscriptions; using HotChocolate.Types; +using HotChocolate.Types.Relay; + +using Microsoft.Extensions.Hosting; using Tgstation.Server.Api.Rights; using Tgstation.Server.Host.GraphQL.Types; @@ -44,12 +47,31 @@ public static IEnumerable UserUpdatedTopics(long userId) 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, + [Service] IHostApplicationLifetime applicationLifetime, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(receiver); + var topic = userId.HasValue ? SpecificUserUpdatedTopic(userId.Value) : UserUpdatedTopic; + var cts = CancellationTokenSource.CreateLinkedTokenSource(applicationLifetime.ApplicationStopping, cancellationToken); + return receiver.SubscribeAsync(topic, cts.Token); + } + /// /// Receive an update for all changes. /// /// The received from the publisher. /// The updated . - [Subscribe] + [Subscribe(With = nameof(UserUpdatedStream))] [TgsGraphQLAuthorize(AdministrationRights.ReadUsers)] public User UserUpdated([EventMessage] User user) { @@ -63,7 +85,7 @@ public User UserUpdated([EventMessage] User user) /// The . /// The for the request. /// The for the operation. - /// A resulting in a of the for the . + /// A resulting in updates for the current . public ValueTask> CurrentUserUpdatedStream( [Service] ITopicEventReceiver receiver, [Service] IAuthenticationContext authenticationContext, @@ -79,7 +101,7 @@ public ValueTask> CurrentUserUpdatedStream( /// /// The received from the publisher. /// The updated . - [Subscribe] + [Subscribe(With = nameof(CurrentUserUpdatedStream))] [TgsGraphQLAuthorize] public User CurrentUserUpdated([EventMessage] User user) { From 36ee7d7d499c95d272d76c2afc355e4581a96628 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Tue, 24 Sep 2024 21:18:20 -0400 Subject: [PATCH 103/107] Implement user subscription topic sending --- .../Authority/UserAuthority.cs | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/Tgstation.Server.Host/Authority/UserAuthority.cs b/src/Tgstation.Server.Host/Authority/UserAuthority.cs index 9bfa2b5f0fe..753a180d301 100644 --- a/src/Tgstation.Server.Host/Authority/UserAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/UserAuthority.cs @@ -7,6 +7,8 @@ using GreenDonut; +using HotChocolate.Subscriptions; + using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -16,10 +18,12 @@ 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 @@ -57,6 +61,11 @@ sealed class UserAuthority : AuthorityBase, IUserAuthority /// readonly ISessionInvalidationTracker sessionInvalidationTracker; + /// + /// The for the . + /// + readonly ITopicEventSender topicEventSender; + /// /// The of for the . /// @@ -142,6 +151,7 @@ public static Task> GetUsers( /// The value of . /// The value of . /// The value of . + /// The value of . /// The value of . public UserAuthority( IAuthenticationContext authenticationContext, @@ -153,6 +163,7 @@ public UserAuthority( IPermissionsUpdateNotifyee permissionsUpdateNotifyee, ICryptographySuite cryptographySuite, ISessionInvalidationTracker sessionInvalidationTracker, + ITopicEventSender topicEventSender, IOptionsSnapshot generalConfigurationOptions) : base( authenticationContext, @@ -165,6 +176,7 @@ public UserAuthority( 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)); } @@ -337,6 +349,8 @@ public async ValueTask> Create( Logger.LogInformation("Created new user {name} ({id})", dbUser.Name, dbUser.Id); + await SendUserUpdatedTopics(dbUser); + return new AuthorityResponse(dbUser, HttpSuccessResponse.Created); } @@ -495,6 +509,8 @@ public async ValueTask> Update(UserUpdateRequest model, 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); @@ -503,6 +519,20 @@ public async ValueTask> Update(UserUpdateRequest model, : 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. /// From 33c2e534b394eb3e3959e1d404fea31124b28667 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Tue, 24 Sep 2024 21:18:39 -0400 Subject: [PATCH 104/107] Update HotChocolate --- src/Tgstation.Server.Host/Tgstation.Server.Host.csproj | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj b/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj index 134257db9a8..5e1b0deb062 100644 --- a/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj +++ b/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj @@ -101,15 +101,15 @@ - + - + - + - + - + From 333e896ef8d10de61e075bac403c6e094d0916da Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Tue, 24 Sep 2024 21:30:02 -0400 Subject: [PATCH 105/107] User subscription tests --- .../GQL/Subscriptions/SubscribeUsers.graphql | 5 +++++ .../GraphQL/Subscriptions/UserSubscriptions.cs | 6 +----- .../Tgstation.Server.Tests/Live/IMultiServerClient.cs | 11 +++++++++++ .../Tgstation.Server.Tests/Live/MultiServerClient.cs | 3 +++ tests/Tgstation.Server.Tests/Live/TestLiveServer.cs | 5 +++++ tests/Tgstation.Server.Tests/Live/UsersTest.cs | 11 +++++++++++ 6 files changed, 36 insertions(+), 5 deletions(-) create mode 100644 src/Tgstation.Server.Client.GraphQL/GQL/Subscriptions/SubscribeUsers.graphql 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.Host/GraphQL/Subscriptions/UserSubscriptions.cs b/src/Tgstation.Server.Host/GraphQL/Subscriptions/UserSubscriptions.cs index c5b0656e6bb..e8f22cc9383 100644 --- a/src/Tgstation.Server.Host/GraphQL/Subscriptions/UserSubscriptions.cs +++ b/src/Tgstation.Server.Host/GraphQL/Subscriptions/UserSubscriptions.cs @@ -9,8 +9,6 @@ using HotChocolate.Types; using HotChocolate.Types.Relay; -using Microsoft.Extensions.Hosting; - using Tgstation.Server.Api.Rights; using Tgstation.Server.Host.GraphQL.Types; using Tgstation.Server.Host.Security; @@ -57,13 +55,11 @@ static string SpecificUserUpdatedTopic(long userId) public ValueTask> UserUpdatedStream( [ID(nameof(User))] long? userId, [Service] ITopicEventReceiver receiver, - [Service] IHostApplicationLifetime applicationLifetime, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(receiver); var topic = userId.HasValue ? SpecificUserUpdatedTopic(userId.Value) : UserUpdatedTopic; - var cts = CancellationTokenSource.CreateLinkedTokenSource(applicationLifetime.ApplicationStopping, cancellationToken); - return receiver.SubscribeAsync(topic, cts.Token); + return receiver.SubscribeAsync(topic, cancellationToken); } /// diff --git a/tests/Tgstation.Server.Tests/Live/IMultiServerClient.cs b/tests/Tgstation.Server.Tests/Live/IMultiServerClient.cs index d617c63288e..315052e1a26 100644 --- a/tests/Tgstation.Server.Tests/Live/IMultiServerClient.cs +++ b/tests/Tgstation.Server.Tests/Live/IMultiServerClient.cs @@ -19,5 +19,16 @@ ValueTask Execute( 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/MultiServerClient.cs b/tests/Tgstation.Server.Tests/Live/MultiServerClient.cs index 451ae08c472..5dbf153d784 100644 --- a/tests/Tgstation.Server.Tests/Live/MultiServerClient.cs +++ b/tests/Tgstation.Server.Tests/Live/MultiServerClient.cs @@ -58,5 +58,8 @@ public ValueTask Execute( 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/TestLiveServer.cs b/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs index 6071e51d489..0dccd54a29f 100644 --- a/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs +++ b/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs @@ -1466,6 +1466,11 @@ 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(restClientFactory, firstAdminRestClient, cancellationToken)); var adminTest = FailFast(new AdministrationTest(firstAdminRestClient.Administration).Run(cancellationToken)); diff --git a/tests/Tgstation.Server.Tests/Live/UsersTest.cs b/tests/Tgstation.Server.Tests/Live/UsersTest.cs index a309e906ec6..1ef0a26ef11 100644 --- a/tests/Tgstation.Server.Tests/Live/UsersTest.cs +++ b/tests/Tgstation.Server.Tests/Live/UsersTest.cs @@ -34,12 +34,23 @@ public UsersTest(IMultiServerClient serverClient) public async ValueTask Run(CancellationToken cancellationToken) { + var observer = new HoldLastObserver>(); + using var subscription = await serverClient.Subscribe( + gql => gql.SubscribeUsers.Watch(), + observer, + cancellationToken); + await ValueTaskExtensions.WhenAll( BasicTests(cancellationToken), 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 ValueTask BasicTests(CancellationToken cancellationToken) From b36ede9a96bbf11c7117607953e94d9fd27353fd Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Wed, 25 Sep 2024 07:05:08 -0400 Subject: [PATCH 106/107] Work around a race condition --- tests/Tgstation.Server.Tests/Live/UsersTest.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/Tgstation.Server.Tests/Live/UsersTest.cs b/tests/Tgstation.Server.Tests/Live/UsersTest.cs index 1ef0a26ef11..b5a821a9951 100644 --- a/tests/Tgstation.Server.Tests/Live/UsersTest.cs +++ b/tests/Tgstation.Server.Tests/Live/UsersTest.cs @@ -40,8 +40,9 @@ public async ValueTask Run(CancellationToken cancellationToken) observer, cancellationToken); + await BasicTests(cancellationToken); + await ValueTaskExtensions.WhenAll( - BasicTests(cancellationToken), TestCreateSysUser(cancellationToken), TestSpamCreation(cancellationToken)); From a979c49ef96096bb2065040d030b79035b208b94 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Wed, 25 Sep 2024 20:43:57 -0400 Subject: [PATCH 107/107] Workaround for https://github.com/ChilliCream/graphql-platform/issues/6698 --- src/Tgstation.Server.Host/Core/Application.cs | 2 + .../GraphQL/Subscription.cs | 2 +- .../Subscriptions/ITopicEventReceiver.cs | 9 ++ .../ShutdownAwareTopicEventReceiver.cs | 89 +++++++++++++++++++ .../Subscriptions/UserSubscriptions.cs | 1 - 5 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 src/Tgstation.Server.Host/GraphQL/Subscriptions/ITopicEventReceiver.cs create mode 100644 src/Tgstation.Server.Host/GraphQL/Subscriptions/ShutdownAwareTopicEventReceiver.cs diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs index 9e3a599bd6d..fc201f72cca 100644 --- a/src/Tgstation.Server.Host/Core/Application.cs +++ b/src/Tgstation.Server.Host/Core/Application.cs @@ -56,6 +56,7 @@ 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; @@ -295,6 +296,7 @@ void ConfigureNewtonsoftJsonSerializerSettingsForApi(JsonSerializerSettings sett // configure graphql if (postSetupServices.InternalConfiguration.EnableGraphQL) services + .AddScoped() .AddGraphQLServer() .AddAuthorization() .ModifyOptions(options => diff --git a/src/Tgstation.Server.Host/GraphQL/Subscription.cs b/src/Tgstation.Server.Host/GraphQL/Subscription.cs index c7c9c80669c..114a7622ada 100644 --- a/src/Tgstation.Server.Host/GraphQL/Subscription.cs +++ b/src/Tgstation.Server.Host/GraphQL/Subscription.cs @@ -38,7 +38,7 @@ public static string SessionInvalidatedTopic(IAuthenticationContext authenticati /// The for the operation. /// A resulting in a of the for the . public ValueTask> SessionInvalidatedStream( - [Service] ITopicEventReceiver receiver, + [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) 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/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 index e8f22cc9383..2369df8ab4a 100644 --- a/src/Tgstation.Server.Host/GraphQL/Subscriptions/UserSubscriptions.cs +++ b/src/Tgstation.Server.Host/GraphQL/Subscriptions/UserSubscriptions.cs @@ -5,7 +5,6 @@ using HotChocolate; using HotChocolate.Execution; -using HotChocolate.Subscriptions; using HotChocolate.Types; using HotChocolate.Types.Relay;