From 0160d17d67833ce9af895c0ec25dda3cb8a6c040 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 15 Sep 2024 17:27:47 -0400 Subject: [PATCH] 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 0d99ca88f2..d95d7d58a1 100644 --- a/src/Tgstation.Server.Host/Authority/IUserAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/IUserAuthority.cs @@ -3,6 +3,7 @@ using System.Threading; using System.Threading.Tasks; using Tgstation.Server.Api.Models; +using Tgstation.Server.Api.Models.Request; using Tgstation.Server.Api.Rights; using Tgstation.Server.Host.Authority.Core; using Tgstation.Server.Host.Models; @@ -21,7 +22,7 @@ namespace Tgstation.Server.Host.Authority /// 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 @@ namespace Tgstation.Server.Host.Authority /// 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 @@ namespace Tgstation.Server.Host.Authority /// 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 @@ namespace Tgstation.Server.Host.Authority /// 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 041e530a7a..1e9d516228 100644 --- a/src/Tgstation.Server.Host/Authority/IUserGroupAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/IUserGroupAuthority.cs @@ -28,7 +28,7 @@ namespace Tgstation.Server.Host.Authority /// 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 f2a8e7646f..40b48be96d 100644 --- a/src/Tgstation.Server.Host/Authority/UserAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/UserAuthority.cs @@ -8,9 +8,15 @@ using GreenDonut; 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 @@ namespace Tgstation.Server.Host.Authority /// 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 @@ namespace Tgstation.Server.Host.Authority /// /// 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 @@ namespace Tgstation.Server.Host.Authority 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 @@ namespace Tgstation.Server.Host.Authority /// 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 @@ namespace Tgstation.Server.Host.Authority { 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 @@ namespace Tgstation.Server.Host.Authority => 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 @@ namespace Tgstation.Server.Host.Authority 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 439b3e3c07..3914d69da2 100644 --- a/src/Tgstation.Server.Host/Controllers/ApiController.cs +++ b/src/Tgstation.Server.Host/Controllers/ApiController.cs @@ -186,17 +186,6 @@ namespace Tgstation.Server.Host.Controllers /// 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 2bb16eeffd..be6866f928 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.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; -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 @@ namespace Tgstation.Server.Host.Controllers 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 @@ namespace Tgstation.Server.Host.Controllers /// 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 @@ namespace Tgstation.Server.Host.Controllers /// 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 @@ namespace Tgstation.Server.Host.Controllers 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 ff178262a5..0e1bba29d9 100644 --- a/src/Tgstation.Server.Host/GraphQL/Mutation.cs +++ b/src/Tgstation.Server.Host/GraphQL/Mutation.cs @@ -30,7 +30,7 @@ namespace Tgstation.Server.Host.GraphQL 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 1c9f0707e5..9c5baac4e0 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; 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 @@ namespace Tgstation.Server.Host.GraphQL.Mutations /// 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 @@ namespace Tgstation.Server.Host.GraphQL.Mutations 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 @@ namespace Tgstation.Server.Host.GraphQL.Mutations /// /// 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 @@ namespace Tgstation.Server.Host.GraphQL.Mutations 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 @@ namespace Tgstation.Server.Host.GraphQL.Mutations /// 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 @@ namespace Tgstation.Server.Host.GraphQL.Mutations 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 @@ namespace Tgstation.Server.Host.GraphQL.Mutations /// /// 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 @@ namespace Tgstation.Server.Host.GraphQL.Mutations 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 87bde51eac..f47a3fb784 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/User.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/User.cs @@ -64,7 +64,7 @@ namespace Tgstation.Server.Host.GraphQL.Types CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(userAuthority); - return userAuthority.InvokeTransformable( + return userAuthority.InvokeTransformableAllowMissing( authority => authority.GetId(id, false, false, cancellationToken)); } @@ -83,9 +83,6 @@ namespace Tgstation.Server.Host.GraphQL.Types 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 @@ namespace Tgstation.Server.Host.GraphQL.Types /// 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 @@ namespace Tgstation.Server.Host.GraphQL.Types /// 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 @@ namespace Tgstation.Server.Host.GraphQL.Types 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 @@ namespace Tgstation.Server.Host.GraphQL.Types { 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 2aa5853d19..e8cd9cc855 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/UserGroup.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/UserGroup.cs @@ -34,7 +34,7 @@ namespace Tgstation.Server.Host.GraphQL.Types CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(userGroupAuthority); - return userGroupAuthority.InvokeTransformable( + return userGroupAuthority.InvokeTransformableAllowMissing( authority => authority.GetId(id, false, cancellationToken)); } @@ -50,8 +50,8 @@ namespace Tgstation.Server.Host.GraphQL.Types { 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 b54062e9f6..51240221c8 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/UserGroups.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/UserGroups.cs @@ -28,7 +28,7 @@ namespace Tgstation.Server.Host.GraphQL.Types [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 9052345de8..d93f4ceec8 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/Users.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/Users.cs @@ -39,7 +39,7 @@ namespace Tgstation.Server.Host.GraphQL.Types CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(userAuthority); - return userAuthority.InvokeTransformable(authority => authority.Read(cancellationToken))!; + return userAuthority.InvokeTransformable(authority => authority.Read(cancellationToken)); } ///