Implement GraphQL user creation, UserAuthority.Update, and make controller fully authority reliant

This commit is contained in:
Jordan Dominion
2024-09-15 17:27:47 -04:00
parent 9019396fc3
commit 0160d17d67
11 changed files with 498 additions and 390 deletions
@@ -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
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a <see cref="User"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
[TgsAuthorize]
public ValueTask<AuthorityResponse<User>> Read(CancellationToken cancellationToken);
ValueTask<AuthorityResponse<User>> Read(CancellationToken cancellationToken);
/// <summary>
/// Gets the <see cref="User"/> with a given <paramref name="id"/>.
@@ -32,7 +33,7 @@ namespace Tgstation.Server.Host.Authority
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a <see cref="User"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
[TgsAuthorize(AdministrationRights.ReadUsers)]
public ValueTask<AuthorityResponse<User>> GetId(long id, bool includeJoins, bool allowSystemUser, CancellationToken cancellationToken);
ValueTask<AuthorityResponse<User>> GetId(long id, bool includeJoins, bool allowSystemUser, CancellationToken cancellationToken);
/// <summary>
/// Gets the <see cref="OAuthConnection"/>s for the <see cref="User"/> with a given <paramref name="userId"/>.
@@ -40,7 +41,7 @@ namespace Tgstation.Server.Host.Authority
/// <param name="userId">The <see cref="EntityId.Id"/> of the <see cref="User"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in an <see cref="global::System.Array"/> of <see cref="GraphQL.Types.OAuthConnection"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
public ValueTask<AuthorityResponse<GraphQL.Types.OAuthConnection[]>> OAuthConnections(long userId, CancellationToken cancellationToken);
ValueTask<AuthorityResponse<GraphQL.Types.OAuthConnection[]>> OAuthConnections(long userId, CancellationToken cancellationToken);
/// <summary>
/// Gets all registered <see cref="User"/>s.
@@ -48,6 +49,24 @@ namespace Tgstation.Server.Host.Authority
/// <param name="includeJoins">If related entities should be loaded.</param>
/// <returns>A <see cref="IQueryable{T}"/> of <see cref="User"/>s.</returns>
[TgsAuthorize(AdministrationRights.ReadUsers)]
public IQueryable<User> Queryable(bool includeJoins);
IQueryable<User> Queryable(bool includeJoins);
/// <summary>
/// Creates a <see cref="User"/>.
/// </summary>
/// <param name="createRequest">The <see cref="UserCreateRequest"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in am <see cref="AuthorityResponse{TResult}"/> for the created <see cref="User"/>.</returns>
[TgsAuthorize(AdministrationRights.WriteUsers)]
ValueTask<AuthorityResponse<User>> Create(UserCreateRequest createRequest, CancellationToken cancellationToken);
/// <summary>
/// Updates a <see cref="User"/>.
/// </summary>
/// <param name="updateRequest">The <see cref="UserUpdateRequest"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in am <see cref="AuthorityResponse{TResult}"/> for the created <see cref="User"/>.</returns>
[TgsAuthorize(AdministrationRights.WriteUsers | AdministrationRights.EditOwnPassword | AdministrationRights.EditOwnOAuthConnections)]
ValueTask<AuthorityResponse<User>> Update(UserUpdateRequest updateRequest, CancellationToken cancellationToken);
}
}
@@ -28,7 +28,7 @@ namespace Tgstation.Server.Host.Authority
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a <see cref="User"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
[TgsAuthorize(AdministrationRights.ReadUsers)]
public ValueTask<AuthorityResponse<UserGroup>> GetId(long id, bool includeJoins, CancellationToken cancellationToken);
ValueTask<AuthorityResponse<UserGroup>> GetId(long id, bool includeJoins, CancellationToken cancellationToken);
/// <summary>
/// Gets all registered <see cref="UserGroup"/>s.
@@ -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
/// </summary>
readonly IOAuthConnectionsDataLoader oAuthConnectionsDataLoader;
/// <summary>
/// The <see cref="ISystemIdentityFactory"/> for the <see cref="UserAuthority"/>.
/// </summary>
readonly ISystemIdentityFactory systemIdentityFactory;
/// <summary>
/// The <see cref="IPermissionsUpdateNotifyee"/> for the <see cref="UserAuthority"/>.
/// </summary>
readonly IPermissionsUpdateNotifyee permissionsUpdateNotifyee;
/// <summary>
/// The <see cref="ICryptographySuite"/> for the <see cref="UserAuthority"/>.
/// </summary>
readonly ICryptographySuite cryptographySuite;
/// <summary>
/// The <see cref="IOptionsSnapshot{TOptions}"/> of <see cref="GeneralConfiguration"/> for the <see cref="UserAuthority"/>.
/// </summary>
readonly IOptionsSnapshot<GeneralConfiguration> generalConfigurationOptions;
/// <summary>
/// Implements the <see cref="usersDataLoader"/>.
/// </summary>
/// <param name="ids">The <see cref="IReadOnlyList{T}"/> of <see cref="User"/> <see cref="Api.Models.EntityId.Id"/>s to load.</param>
/// <param name="ids">The <see cref="IReadOnlyList{T}"/> of <see cref="User"/> <see cref="EntityId.Id"/>s to load.</param>
/// <param name="databaseContext">The <see cref="IDatabaseContext"/> to load from.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a <see cref="Dictionary{TKey, TValue}"/> of the requested <see cref="User"/>s.</returns>
@@ -56,7 +82,7 @@ namespace Tgstation.Server.Host.Authority
/// <summary>
/// Implements the <see cref="usersDataLoader"/>.
/// </summary>
/// <param name="userIds">The <see cref="IReadOnlyCollection{T}"/> of <see cref="User"/> <see cref="Api.Models.EntityId.Id"/>s to load the OAuthConnections for.</param>
/// <param name="userIds">The <see cref="IReadOnlyCollection{T}"/> of <see cref="User"/> <see cref="EntityId.Id"/>s to load the OAuthConnections for.</param>
/// <param name="databaseContext">The <see cref="IDatabaseContext"/> to load from.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a <see cref="Dictionary{TKey, TValue}"/> of the requested <see cref="User"/>s.</returns>
@@ -80,6 +106,24 @@ namespace Tgstation.Server.Host.Authority
x => new GraphQL.Types.OAuthConnection(x.ExternalUserId!, x.Provider));
}
/// <summary>
/// Check if a given <paramref name="model"/> has a valid <see cref="UserName.Name"/> specified.
/// </summary>
/// <param name="model">The <see cref="UserUpdateRequest"/> to check.</param>
/// <param name="newUser">If this is a new <see cref="User"/>.</param>
/// <returns><see langword="null"/> if <paramref name="model"/> is valid, an <see cref="AuthorityResponse{TResult}"/> errored otherwise.</returns>
static AuthorityResponse<User>? 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<User>(ErrorCode.UserMissingName);
model.Name = model.Name?.Trim();
if (model.Name != null && model.Name.Contains(':', StringComparison.InvariantCulture))
return BadRequest<User>(ErrorCode.UserColonInName);
return null;
}
/// <summary>
/// Initializes a new instance of the <see cref="UserAuthority"/> class.
/// </summary>
@@ -88,12 +132,20 @@ namespace Tgstation.Server.Host.Authority
/// <param name="logger">The <see cref="ILogger"/> to use.</param>
/// <param name="usersDataLoader">The value of <see cref="usersDataLoader"/>.</param>
/// <param name="oAuthConnectionsDataLoader">The value of <see cref="oAuthConnectionsDataLoader"/>.</param>
/// <param name="systemIdentityFactory">The value of <see cref="systemIdentityFactory"/>.</param>
/// <param name="permissionsUpdateNotifyee">The value of <see cref="permissionsUpdateNotifyee"/>.</param>
/// <param name="cryptographySuite">The value of <see cref="cryptographySuite"/>.</param>
/// <param name="generalConfigurationOptions">The value of <see cref="generalConfigurationOptions"/>.</param>
public UserAuthority(
IAuthenticationContext authenticationContext,
IDatabaseContext databaseContext,
ILogger<UserAuthority> logger,
IUsersDataLoader usersDataLoader,
IOAuthConnectionsDataLoader oAuthConnectionsDataLoader)
IOAuthConnectionsDataLoader oAuthConnectionsDataLoader,
ISystemIdentityFactory systemIdentityFactory,
IPermissionsUpdateNotifyee permissionsUpdateNotifyee,
ICryptographySuite cryptographySuite,
IOptionsSnapshot<GeneralConfiguration> 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));
}
/// <inheritdoc />
@@ -143,6 +199,237 @@ namespace Tgstation.Server.Host.Authority
=> new AuthorityResponse<GraphQL.Types.OAuthConnection[]>(
await oAuthConnectionsDataLoader.LoadRequiredAsync(userId, cancellationToken));
/// <inheritdoc />
public async ValueTask<AuthorityResponse<User>> Create(
UserCreateRequest createRequest,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(createRequest);
if (createRequest.OAuthConnections?.Any(x => x == null) == true)
return BadRequest<User>(ErrorCode.ModelValidationFailure);
if ((createRequest.Password != null && createRequest.SystemIdentifier != null)
|| (createRequest.Password == null && createRequest.SystemIdentifier == null && (createRequest.OAuthConnections?.Count > 0) != true))
return BadRequest<User>(ErrorCode.UserMismatchPasswordSid);
if (createRequest.Group != null && createRequest.PermissionSet != null)
return BadRequest<User>(ErrorCode.UserGroupAndPermissionSet);
createRequest.Name = createRequest.Name?.Trim();
if (createRequest.Name?.Length == 0)
createRequest.Name = null;
if (!(createRequest.Name == null ^ createRequest.SystemIdentifier == null))
return BadRequest<User>(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<User>(ErrorCode.UserLimitReached);
var dbUser = await CreateNewUserFromModel(createRequest, cancellationToken);
if (dbUser == null)
return Gone<User>();
if (createRequest.SystemIdentifier != null)
try
{
using var sysIdentity = await systemIdentityFactory.CreateSystemIdentity(dbUser, cancellationToken);
if (sysIdentity == null)
return Gone<User>();
dbUser.Name = sysIdentity.Username;
dbUser.SystemIdentifier = sysIdentity.Uid;
}
catch (NotImplementedException ex)
{
Logger.LogTrace(ex, "System identities not implemented!");
return new AuthorityResponse<User>(
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<User>(dbUser, HttpSuccessResponse.Created);
}
/// <inheritdoc />
public async ValueTask<AuthorityResponse<User>> Update(UserUpdateRequest model, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(model);
if (!model.Id.HasValue || model.OAuthConnections?.Any(x => x == null) == true)
return BadRequest<User>(ErrorCode.ModelValidationFailure);
if (model.Group != null && model.PermissionSet != null)
return BadRequest<User>(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<User>();
if (originalUser.CanonicalName == User.CanonicalizeName(User.TgsSystemUserName))
return Forbid<User>();
// 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<User>();
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<User>(ErrorCode.UserSidChange);
if (model.Password != null)
{
if (originalUserHasSid)
return BadRequest<User>(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<User>(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<User>(ErrorCode.AdminUserCannotOAuth);
if (model.OAuthConnections.Count == 0 && originalUser.PasswordHash == null && originalUser.SystemIdentifier == null)
return BadRequest<User>(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<User>();
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<User>(originalUser)
: new AuthorityResponse<User>();
}
/// <summary>
/// Gets all registered <see cref="User"/>s.
/// </summary>
@@ -170,5 +457,71 @@ namespace Tgstation.Server.Host.Authority
return queryable;
}
/// <summary>
/// Creates a new <see cref="User"/> from a given <paramref name="model"/>.
/// </summary>
/// <param name="model">The <see cref="Api.Models.Internal.UserApiBase"/> to use as a template.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a new <see cref="User"/> on success, <see langword="null"/> if the requested <see cref="UserGroup"/> did not exist.</returns>
async ValueTask<User> 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<Models.OAuthConnection>(),
};
}
/// <summary>
/// Attempt to change the password of a given <paramref name="dbUser"/>.
/// </summary>
/// <param name="dbUser">The user to update.</param>
/// <param name="newPassword">The new password.</param>
/// <param name="newUser">If this is for a new <see cref="UserResponse"/>.</param>
/// <returns><see langword="null"/> on success, an errored <see cref="AuthorityResponse{TResult}"/> if <paramref name="newPassword"/> is too short.</returns>
AuthorityResponse<User>? TrySetPassword(User dbUser, string newPassword, bool newUser)
{
newPassword ??= String.Empty;
if (newPassword.Length < generalConfigurationOptions.Value.MinimumPasswordLength)
return new AuthorityResponse<User>(
new ErrorMessageResponse(ErrorCode.UserPasswordLength)
{
AdditionalData = $"Required password length: {generalConfigurationOptions.Value.MinimumPasswordLength}",
},
HttpFailureResponse.BadRequest);
cryptographySuite.SetUserPassword(dbUser, newPassword, newUser);
return null;
}
}
}
@@ -186,17 +186,6 @@ namespace Tgstation.Server.Host.Controllers
/// <returns>A <see cref="NotFoundObjectResult"/> with an appropriate <see cref="ErrorMessageResponse"/>.</returns>
protected new NotFoundObjectResult NotFound() => NotFound(new ErrorMessageResponse(ErrorCode.ResourceNeverPresent));
/// <summary>
/// Generic 501 response.
/// </summary>
/// <param name="ex">The <see cref="NotImplementedException"/> that was thrown.</param>
/// <returns>An <see cref="ObjectResult"/> with <see cref="HttpStatusCode.NotImplemented"/>.</returns>
protected ObjectResult RequiresPosixSystemIdentity(NotImplementedException ex)
{
Logger.LogTrace(ex, "System identities not implemented!");
return this.StatusCode(HttpStatusCode.NotImplemented, new ErrorMessageResponse(ErrorCode.RequiresPosixSystemIdentity));
}
/// <summary>
/// Strongly type calls to <see cref="ControllerBase.StatusCode(int)"/>.
/// </summary>
@@ -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
{
/// <summary>
/// The <see cref="ISystemIdentityFactory"/> for the <see cref="UserController"/>.
/// </summary>
readonly ISystemIdentityFactory systemIdentityFactory;
/// <summary>
/// The <see cref="ICryptographySuite"/> for the <see cref="UserController"/>.
/// </summary>
readonly ICryptographySuite cryptographySuite;
/// <summary>
/// The <see cref="IPermissionsUpdateNotifyee"/> for the <see cref="UserController"/>.
/// </summary>
readonly IPermissionsUpdateNotifyee permissionsUpdateNotifyee;
/// <summary>
/// The <see cref="IRestAuthorityInvoker{TAuthority}"/> for the <see cref="IUserAuthority"/>.
/// </summary>
readonly IRestAuthorityInvoker<IUserAuthority> userAuthority;
/// <summary>
/// The <see cref="GeneralConfiguration"/> for the <see cref="UserController"/>.
/// </summary>
readonly GeneralConfiguration generalConfiguration;
/// <summary>
/// Initializes a new instance of the <see cref="UserController"/> class.
/// </summary>
/// <param name="databaseContext">The <see cref="IDatabaseContext"/> for the <see cref="ApiController"/>.</param>
/// <param name="authenticationContext">The <see cref="IAuthenticationContext"/> for the <see cref="ApiController"/>.</param>
/// <param name="systemIdentityFactory">The value of <see cref="systemIdentityFactory"/>.</param>
/// <param name="cryptographySuite">The value of <see cref="cryptographySuite"/>.</param>
/// <param name="userAuthority">The value of <see cref="userAuthority"/>.</param>
/// <param name="permissionsUpdateNotifyee">The value of <see cref="permissionsUpdateNotifyee"/>.</param>
/// <param name="logger">The <see cref="ILogger"/> for the <see cref="ApiController"/>.</param>
/// <param name="generalConfigurationOptions">The <see cref="IOptions{TOptions}"/> containing the value of <see cref="generalConfiguration"/>.</param>
/// <param name="apiHeaders">The <see cref="IApiHeadersProvider"/> for the <see cref="ApiController"/>.</param>
public UserController(
IDatabaseContext databaseContext,
IAuthenticationContext authenticationContext,
ISystemIdentityFactory systemIdentityFactory,
ICryptographySuite cryptographySuite,
IPermissionsUpdateNotifyee permissionsUpdateNotifyee,
IRestAuthorityInvoker<IUserAuthority> userAuthority,
ILogger<UserController> logger,
IOptions<GeneralConfiguration> 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));
}
/// <summary>
@@ -101,81 +64,15 @@ namespace Tgstation.Server.Host.Controllers
/// <response code="201"><see cref="User"/> created successfully.</response>
/// <response code="410">The requested system identifier could not be found.</response>
[HttpPut]
[TgsAuthorize(AdministrationRights.WriteUsers)]
[TgsRestAuthorize<IUserAuthority>(nameof(IUserAuthority.Create))]
[ProducesResponseType(typeof(UserResponse), 201)]
#pragma warning disable CA1502, CA1506
public async ValueTask<IActionResult> 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<IActionResult> Create([FromBody] UserCreateRequest model, CancellationToken cancellationToken)
=> userAuthority.InvokeTransformable<User, UserResponse>(this, authority => authority.Create(model, cancellationToken));
/// <summary>
/// Update a <see cref="User"/>.
/// </summary>
/// <param name="model">The <see cref="UserResponse"/> to update.</param>
/// <param name="model">The <see cref="UserUpdateRequest"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="IActionResult"/> of the operation.</returns>
/// <response code="200"><see cref="User"/> updated successfully.</response>
@@ -183,171 +80,13 @@ namespace Tgstation.Server.Host.Controllers
/// <response code="404">Requested <see cref="EntityId.Id"/> does not exist.</response>
/// <response code="410">Requested <see cref="Api.Models.Internal.UserApiBase.Group"/> does not exist.</response>
[HttpPost]
[TgsAuthorize(AdministrationRights.WriteUsers | AdministrationRights.EditOwnPassword | AdministrationRights.EditOwnOAuthConnections)]
[TgsRestAuthorize<IUserAuthority>(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<IActionResult> 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<IActionResult> Update([FromBody] UserUpdateRequest model, CancellationToken cancellationToken)
=> userAuthority.InvokeTransformable<User, UserResponse>(this, authority => authority.Update(model, cancellationToken));
/// <summary>
/// Get information about the current <see cref="User"/>.
@@ -408,87 +147,5 @@ namespace Tgstation.Server.Host.Controllers
this,
authority => authority.GetId(id, true, false, cancellationToken));
}
/// <summary>
/// Creates a new <see cref="User"/> from a given <paramref name="model"/>.
/// </summary>
/// <param name="model">The <see cref="Api.Models.Internal.UserApiBase"/> to use as a template.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a new <see cref="User"/> on success, <see langword="null"/> if the requested <see cref="UserGroup"/> did not exist.</returns>
async ValueTask<User> 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<Models.OAuthConnection>(),
};
}
/// <summary>
/// Check if a given <paramref name="model"/> has a valid <see cref="UserName.Name"/> specified.
/// </summary>
/// <param name="model">The <see cref="UserUpdateRequest"/> to check.</param>
/// <param name="newUser">If this is a new <see cref="UserResponse"/>.</param>
/// <returns><see langword="null"/> if <paramref name="model"/> is valid, a <see cref="BadRequestObjectResult"/> otherwise.</returns>
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;
}
/// <summary>
/// Attempt to change the password of a given <paramref name="dbUser"/>.
/// </summary>
/// <param name="dbUser">The user to update.</param>
/// <param name="newPassword">The new password.</param>
/// <param name="newUser">If this is for a new <see cref="UserResponse"/>.</param>
/// <returns><see langword="null"/> on success, <see cref="BadRequestObjectResult"/> if <paramref name="newPassword"/> is too short.</returns>
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;
}
}
}
@@ -30,7 +30,7 @@ namespace Tgstation.Server.Host.GraphQL
ArgumentNullException.ThrowIfNull(loginAuthority);
return loginAuthority.Invoke<LoginPayload, LoginPayload>(
authority => authority.AttemptLogin(cancellationToken))!;
authority => authority.AttemptLogin(cancellationToken));
}
}
}
@@ -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
/// <param name="name">The <see cref="NamedEntity.Name"/> of the <see cref="User"/>.</param>
/// <param name="password">The password of the <see cref="User"/>.</param>
/// <param name="enabled">If the <see cref="User"/> is <see cref="User.Enabled"/>.</param>
/// <param name="oAuthConnections">The <see cref="OAuthConnection"/>s for the user.</param>
/// <param name="permissionSet">The owned <see cref="PermissionSet"/> of the user.</param>
/// <param name="userAuthority">The <see cref="IGraphQLAuthorityInvoker{TAuthority}"/> <see cref="IUserAuthority"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask"/> resulting in the created <see cref="User"/>.</returns>
[TgsGraphQLAuthorize<IUserAuthority>(nameof(IUserAuthority.Create))]
[Error(typeof(ErrorMessageException))]
public ValueTask<User> CreateUserByPasswordAndPermissionSet(
string name,
string password,
bool enabled,
IEnumerable<OAuthConnection>? oAuthConnections,
PermissionSet permissionSet,
[Service] IGraphQLAuthorityInvoker<IUserAuthority> 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<Models.User, User, UserGraphQLTransformer>(
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));
}
/// <summary>
@@ -49,14 +77,17 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
/// </summary>
/// <param name="systemIdentifier">The <see cref="User.SystemIdentifier"/> of the <see cref="User"/>.</param>
/// <param name="enabled">If the <see cref="User"/> is <see cref="User.Enabled"/>.</param>
/// <param name="oAuthConnections">The <see cref="OAuthConnection"/>s for the user.</param>
/// <param name="permissionSet">The owned <see cref="PermissionSet"/> of the user.</param>
/// <param name="userAuthority">The <see cref="IGraphQLAuthorityInvoker{TAuthority}"/> <see cref="IUserAuthority"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask"/> resulting in the created <see cref="User"/>.</returns>
[TgsGraphQLAuthorize<IUserAuthority>(nameof(IUserAuthority.Create))]
[Error(typeof(ErrorMessageException))]
public ValueTask<User> CreateUserBySystemIDAndPermissionSet(
string systemIdentifier,
bool enabled,
IEnumerable<OAuthConnection>? oAuthConnections,
PermissionSet permissionSet,
[Service] IGraphQLAuthorityInvoker<IUserAuthority> 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<Models.User, User, UserGraphQLTransformer>(
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));
}
/// <summary>
@@ -74,15 +124,18 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
/// <param name="name">The <see cref="NamedEntity.Name"/> of the <see cref="User"/>.</param>
/// <param name="password">The password of the <see cref="User"/>.</param>
/// <param name="enabled">If the <see cref="User"/> is <see cref="User.Enabled"/>.</param>
/// <param name="oAuthConnections">The <see cref="OAuthConnection"/>s for the user.</param>
/// <param name="groupId">The <see cref="Entity.Id"/> of the <see cref="UserGroup"/> the <see cref="User"/> will belong to.</param>
/// <param name="userAuthority">The <see cref="IGraphQLAuthorityInvoker{TAuthority}"/> <see cref="IUserAuthority"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask"/> resulting in the created <see cref="User"/>.</returns>
[TgsGraphQLAuthorize<IUserAuthority>(nameof(IUserAuthority.Create))]
[Error(typeof(ErrorMessageException))]
public ValueTask<User> CreateUserByPasswordAndGroup(
string name,
string password,
bool enabled,
IEnumerable<OAuthConnection>? oAuthConnections,
[ID(nameof(UserGroup))] long groupId,
[Service] IGraphQLAuthorityInvoker<IUserAuthority> 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<Models.User, User, UserGraphQLTransformer>(
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));
}
/// <summary>
@@ -99,14 +171,17 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
/// </summary>
/// <param name="systemIdentifier">The <see cref="User.SystemIdentifier"/> of the <see cref="User"/>.</param>
/// <param name="enabled">If the <see cref="User"/> is <see cref="User.Enabled"/>.</param>
/// <param name="oAuthConnections">The <see cref="OAuthConnection"/>s for the user.</param>
/// <param name="groupId">The <see cref="Entity.Id"/> of the <see cref="UserGroup"/> the <see cref="User"/> will belong to.</param>
/// <param name="userAuthority">The <see cref="IGraphQLAuthorityInvoker{TAuthority}"/> <see cref="IUserAuthority"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask"/> resulting in the created <see cref="User"/>.</returns>
[TgsGraphQLAuthorize<IUserAuthority>(nameof(IUserAuthority.Create))]
[Error(typeof(ErrorMessageException))]
public ValueTask<User> CreateUserBySystemIDAndGroup(
string systemIdentifier,
bool enabled,
IEnumerable<OAuthConnection>? oAuthConnections,
[ID(nameof(UserGroup))] long groupId,
[Service] IGraphQLAuthorityInvoker<IUserAuthority> 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<Models.User, User, UserGraphQLTransformer>(
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));
}
}
}
@@ -64,7 +64,7 @@ namespace Tgstation.Server.Host.GraphQL.Types
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(userAuthority);
return userAuthority.InvokeTransformable<Models.User, User, UserGraphQLTransformer>(
return userAuthority.InvokeTransformableAllowMissing<Models.User, User, UserGraphQLTransformer>(
authority => authority.GetId(id, false, false, cancellationToken));
}
@@ -83,9 +83,6 @@ namespace Tgstation.Server.Host.GraphQL.Types
return null;
var user = await userAuthority.InvokeTransformable<Models.User, User, UserGraphQLTransformer>(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
/// <param name="userAuthority">The <see cref="IGraphQLAuthorityInvoker{TAuthority}"/> <see cref="IUserAuthority"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a new <see cref="Array"/> of <see cref="OAuthConnection"/>s for the <see cref="User"/> if OAuth is configured.</returns>
public async ValueTask<OAuthConnection[]> OAuthConnections(
public ValueTask<OAuthConnection[]> OAuthConnections(
[Service] IGraphQLAuthorityInvoker<IUserAuthority> userAuthority,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(userAuthority);
return (await userAuthority.Invoke<OAuthConnection[], OAuthConnection[]>(
authority => authority.OAuthConnections(Id, cancellationToken)))!;
return userAuthority.Invoke<OAuthConnection[], OAuthConnection[]>(
authority => authority.OAuthConnections(Id, cancellationToken));
}
/// <summary>
@@ -113,7 +110,7 @@ namespace Tgstation.Server.Host.GraphQL.Types
/// <param name="permissionSetAuthority">The <see cref="IGraphQLAuthorityInvoker{TAuthority}"/> <see cref="IPermissionSetAuthority"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="PermissionSet"/> associated with the <see cref="User"/>.</returns>
public async ValueTask<PermissionSet> EffectivePermissionSet(
public ValueTask<PermissionSet> EffectivePermissionSet(
[Service] IGraphQLAuthorityInvoker<IPermissionSetAuthority> permissionSetAuthority,
CancellationToken cancellationToken)
{
@@ -132,8 +129,8 @@ namespace Tgstation.Server.Host.GraphQL.Types
lookupType = PermissionSetLookupType.UserId;
}
return (await permissionSetAuthority.InvokeTransformable<Models.PermissionSet, PermissionSet, PermissionSetGraphQLTransformer>(
authority => authority.GetId(lookupId, lookupType, cancellationToken)))!;
return permissionSetAuthority.InvokeTransformable<Models.PermissionSet, PermissionSet, PermissionSetGraphQLTransformer>(
authority => authority.GetId(lookupId, lookupType, cancellationToken));
}
/// <summary>
@@ -148,7 +145,7 @@ namespace Tgstation.Server.Host.GraphQL.Types
{
ArgumentNullException.ThrowIfNull(permissionSetAuthority);
return permissionSetAuthority.InvokeTransformable<Models.PermissionSet, PermissionSet, PermissionSetGraphQLTransformer>(
return permissionSetAuthority.InvokeTransformableAllowMissing<Models.PermissionSet, PermissionSet, PermissionSetGraphQLTransformer>(
authority => authority.GetId(Id, PermissionSetLookupType.UserId, cancellationToken));
}
@@ -34,7 +34,7 @@ namespace Tgstation.Server.Host.GraphQL.Types
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(userGroupAuthority);
return userGroupAuthority.InvokeTransformable<Models.UserGroup, UserGroup, UserGroupGraphQLTransformer>(
return userGroupAuthority.InvokeTransformableAllowMissing<Models.UserGroup, UserGroup, UserGroupGraphQLTransformer>(
authority => authority.GetId(id, false, cancellationToken));
}
@@ -50,8 +50,8 @@ namespace Tgstation.Server.Host.GraphQL.Types
{
ArgumentNullException.ThrowIfNull(permissionSetAuthority);
return (await permissionSetAuthority.InvokeTransformable<Models.PermissionSet, PermissionSet, PermissionSetGraphQLTransformer>(
authority => authority.GetId(Id, PermissionSetLookupType.GroupId, cancellationToken)))!;
return await permissionSetAuthority.InvokeTransformable<Models.PermissionSet, PermissionSet, PermissionSetGraphQLTransformer>(
authority => authority.GetId(Id, PermissionSetLookupType.GroupId, cancellationToken));
}
/// <summary>
@@ -28,7 +28,7 @@ namespace Tgstation.Server.Host.GraphQL.Types
[Service] IGraphQLAuthorityInvoker<IUserGroupAuthority> userGroupAuthority)
{
ArgumentNullException.ThrowIfNull(userGroupAuthority);
return userGroupAuthority.InvokeTransformable<Models.UserGroup, UserGroup, UserGroupGraphQLTransformer>(authority => authority.Read());
return userGroupAuthority.InvokeTransformableAllowMissing<Models.UserGroup, UserGroup, UserGroupGraphQLTransformer>(authority => authority.Read());
}
/// <summary>
@@ -39,7 +39,7 @@ namespace Tgstation.Server.Host.GraphQL.Types
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(userAuthority);
return userAuthority.InvokeTransformable<Models.User, User, UserGraphQLTransformer>(authority => authority.Read(cancellationToken))!;
return userAuthority.InvokeTransformable<Models.User, User, UserGraphQLTransformer>(authority => authority.Read(cancellationToken));
}
/// <summary>