Way too much stuff again, mostly relating to transformers

- Improve transformer implementations of authority invokers
- LoginAuthority now just returns the token string.
- Remove non-projectable GetId implementation from UserAuthority.
- Improve Rest Paginated implementations and add transformer support.
- Made GQL `User` derive from `UserName`.
- Remove `ILegacyApiTransformable` from user models.

TODO:
- Implement `NotImplementedException`s.
- Consider removing `IApiTransformable`.
- Consider wrapping LoginResult in its own authority-based DTO. Maybe a record struct also containing the expiry.
This commit is contained in:
Jordan Dominion
2025-08-17 14:52:35 -04:00
parent db0db22f2b
commit 7b96034862
40 changed files with 529 additions and 276 deletions
@@ -85,7 +85,28 @@ namespace Tgstation.Server.Host.Authority.Core
if (result == null)
return default;
return result.ToApi();
var transformedResult = new TTransformer().CompiledExpression(result);
return transformedResult;
}
/// <inheritdoc />
async ValueTask<TApiModel?> IGraphQLAuthorityInvoker<TAuthority>.InvokeTransformableAllowMissing<TResult, TApiModel, TTransformer>(
Func<TAuthority, RequirementsGated<Projectable<TResult, TApiModel>>> authorityInvoker,
QueryContext<TApiModel>? queryContext)
where TApiModel : default
{
ArgumentNullException.ThrowIfNull(authorityInvoker);
var requirementsGate = authorityInvoker(Authority);
var projectable = await ExecuteIfRequirementsSatisfied(requirementsGate);
var authorityResponse = await projectable.Resolve(
queryable => queryable
.Select(new TTransformer().ProjectedExpression)
.With(queryContext));
ThrowGraphQLErrorIfNecessary(authorityResponse, false);
var result = authorityResponse.Result;
return result;
}
/// <inheritdoc />
@@ -102,7 +123,10 @@ namespace Tgstation.Server.Host.Authority.Core
queryable = preTransformer(queryable);
if (typeof(EntityId).IsAssignableFrom(typeof(TResult)))
queryable = queryable.OrderBy(item => ((EntityId)(object)item).Id!.Value); // order by ID to fix an EFCore warning
queryable = queryable
.Cast<EntityId>()
.OrderBy(item => item.Id!.Value) // order by ID to fix an EFCore warning
.Cast<TResult>();
var expression = new TTransformer().Expression;
return queryable
@@ -148,6 +172,13 @@ namespace Tgstation.Server.Host.Authority.Core
=> await ((IGraphQLAuthorityInvoker<TAuthority>)this).InvokeTransformableAllowMissing<TResult, TApiModel, TTransformer>(authorityInvoker)
?? throw new InvalidOperationException("Authority invocation should have returned a non-nullable result!");
/// <inheritdoc />
async ValueTask<TApiModel> IGraphQLAuthorityInvoker<TAuthority>.InvokeTransformable<TResult, TApiModel, TTransformer>(
Func<TAuthority, RequirementsGated<Projectable<TResult, TApiModel>>> authorityInvoker,
QueryContext<TApiModel>? queryContext)
=> await ((IGraphQLAuthorityInvoker<TAuthority>)this).InvokeTransformable<TResult, TApiModel, TTransformer>(authorityInvoker, queryContext)
?? throw new InvalidOperationException("Authority invocation should have returned a non-nullable result!");
/// <inheritdoc />
protected override void OnRequirementsFailure(AuthorizationFailure authFailure)
=> throw authFailure.ForbiddenGraphQLException();
@@ -129,5 +129,31 @@ namespace Tgstation.Server.Host.Authority.Core
return CreateSuccessfulActionResult(controller, result => result.ToApi(), authorityResponse!);
}
/// <inheritdoc />
async ValueTask<IActionResult> IRestAuthorityInvoker<TAuthority>.InvokeTransformable<TResult, TApiModel, TTransformer>(
ApiController controller,
Func<TAuthority, RequirementsGated<AuthorityResponse<TResult>>> authorityInvoker)
{
ArgumentNullException.ThrowIfNull(controller);
ArgumentNullException.ThrowIfNull(authorityInvoker);
var requirementsGate = authorityInvoker(Authority);
var authorityResponse = await ExecuteIfRequirementsSatisfied(requirementsGate);
var erroredResult = CreateErroredActionResult(controller, authorityResponse);
if (erroredResult != null)
return erroredResult;
return CreateSuccessfulActionResult(controller, result => new TTransformer().CompiledExpression(result), authorityResponse!);
}
/// <inheritdoc />
async ValueTask<IActionResult> IRestAuthorityInvoker<TAuthority>.InvokeTransformable<TResult, TApiModel, TTransformer>(
ApiController controller,
Func<TAuthority, RequirementsGated<Projectable<TResult, TApiModel>>> authorityInvoker)
{
await Task.Yield();
throw new NotImplementedException();
}
}
}
@@ -33,7 +33,7 @@ namespace Tgstation.Server.Host.Authority
/// <typeparam name="TResult">The <see cref="AuthorityResponse{TResult}.Result"/> <see cref="Type"/>.</typeparam>
/// <typeparam name="TApiModel">The resulting <see cref="Type"/> of the return value.</typeparam>
/// <param name="authorityInvoker">The <typeparamref name="TAuthority"/> <see cref="Func{T, TResult}"/> resulting in the <see cref="RequirementsGated{TResult}"/> <see cref="AuthorityResponse{TResult}"/>.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <typeparamref name="TApiModel"/> generated for the resulting <see cref="AuthorityResponse{TResult}"/>.</returns>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <typeparamref name="TApiModel"/> generated for the resulting <see cref="AuthorityResponse{TResult}"/> if any.</returns>
ValueTask<TApiModel?> InvokeAllowMissing<TResult, TApiModel>(Func<TAuthority, RequirementsGated<AuthorityResponse<TResult>>> authorityInvoker)
where TResult : TApiModel
where TApiModel : notnull;
@@ -45,9 +45,25 @@ namespace Tgstation.Server.Host.Authority
/// <typeparam name="TApiModel">The resulting <see cref="Type"/> of the return value.</typeparam>
/// <typeparam name="TTransformer">The <see cref="ITransformer{TInput, TOutput}"/> for converting <typeparamref name="TResult"/>s to <typeparamref name="TApiModel"/>s.</typeparam>
/// <param name="authorityInvoker">The <typeparamref name="TAuthority"/> <see cref="Func{T, TResult}"/> resulting in the <see cref="RequirementsGated{TResult}"/> <see cref="AuthorityResponse{TResult}"/>.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <typeparamref name="TApiModel"/> generated for the resulting <see cref="AuthorityResponse{TResult}"/>.</returns>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <typeparamref name="TApiModel"/> generated for the resulting <see cref="AuthorityResponse{TResult}"/> if any.</returns>
ValueTask<TApiModel?> InvokeTransformableAllowMissing<TResult, TApiModel, TTransformer>(Func<TAuthority, RequirementsGated<AuthorityResponse<TResult>>> authorityInvoker)
where TResult : notnull, IApiTransformable<TResult, TApiModel, TTransformer>
where TResult : notnull
where TApiModel : notnull
where TTransformer : ITransformer<TResult, TApiModel>, new();
/// <summary>
/// Invoke a <typeparamref name="TAuthority"/> method and get the non-nullable result.
/// </summary>
/// <typeparam name="TResult">The <see cref="AuthorityResponse{TResult}.Result"/> <see cref="Type"/>.</typeparam>
/// <typeparam name="TApiModel">The resulting <see cref="Type"/> of the return value.</typeparam>
/// <typeparam name="TTransformer">The <see cref="ITransformer{TInput, TOutput}"/> for converting <typeparamref name="TResult"/>s to <typeparamref name="TApiModel"/>s.</typeparam>
/// <param name="authorityInvoker">The <typeparamref name="TAuthority"/> <see cref="Func{T, TResult}"/> resulting in the <see cref="RequirementsGated{TResult}"/> <see cref="Projectable{TQueried, TResult}"/>.</param>
/// <param name="queryContext">The active <see cref="QueryContext{TEntity}"/>.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <typeparamref name="TApiModel"/> generated for the resulting <see cref="AuthorityResponse{TResult}"/> if any.</returns>
ValueTask<TApiModel?> InvokeTransformableAllowMissing<TResult, TApiModel, TTransformer>(
Func<TAuthority, RequirementsGated<Projectable<TResult, TApiModel>>> authorityInvoker,
QueryContext<TApiModel>? queryContext)
where TResult : EntityId
where TApiModel : notnull
where TTransformer : ITransformer<TResult, TApiModel>, new();
@@ -71,7 +87,23 @@ namespace Tgstation.Server.Host.Authority
/// <param name="authorityInvoker">The <typeparamref name="TAuthority"/> <see cref="Func{T, TResult}"/> resulting in the <see cref="RequirementsGated{TResult}"/> <see cref="AuthorityResponse{TResult}"/>.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <typeparamref name="TApiModel"/> generated for the resulting <see cref="AuthorityResponse{TResult}"/>.</returns>
ValueTask<TApiModel> InvokeTransformable<TResult, TApiModel, TTransformer>(Func<TAuthority, RequirementsGated<AuthorityResponse<TResult>>> authorityInvoker)
where TResult : notnull, IApiTransformable<TResult, TApiModel, TTransformer>
where TResult : notnull
where TApiModel : notnull
where TTransformer : ITransformer<TResult, TApiModel>, new();
/// <summary>
/// Invoke a <typeparamref name="TAuthority"/> method and get the non-nullable result.
/// </summary>
/// <typeparam name="TResult">The <see cref="AuthorityResponse{TResult}.Result"/> <see cref="Type"/>.</typeparam>
/// <typeparam name="TApiModel">The resulting <see cref="Type"/> of the return value.</typeparam>
/// <typeparam name="TTransformer">The <see cref="ITransformer{TInput, TOutput}"/> for converting <typeparamref name="TResult"/>s to <typeparamref name="TApiModel"/>s.</typeparam>
/// <param name="authorityInvoker">The <typeparamref name="TAuthority"/> <see cref="Func{T, TResult}"/> resulting in the <see cref="RequirementsGated{TResult}"/> <see cref="Projectable{TQueried, TResult}"/>.</param>
/// <param name="queryContext">The active <see cref="QueryContext{TEntity}"/>.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <typeparamref name="TApiModel"/> generated for the resulting <see cref="AuthorityResponse{TResult}"/>.</returns>
ValueTask<TApiModel> InvokeTransformable<TResult, TApiModel, TTransformer>(
Func<TAuthority, RequirementsGated<Projectable<TResult, TApiModel>>> authorityInvoker,
QueryContext<TApiModel>? queryContext)
where TResult : EntityId
where TApiModel : notnull
where TTransformer : ITransformer<TResult, TApiModel>, new();
@@ -87,7 +119,6 @@ namespace Tgstation.Server.Host.Authority
ValueTask<IQueryable<TApiModel>> InvokeTransformableQueryable<TResult, TApiModel, TTransformer>(
Func<TAuthority, RequirementsGated<IQueryable<TResult>>> authorityInvoker,
Func<IQueryable<TResult>, IQueryable<TResult>>? preTransformer = null)
where TResult : IApiTransformable<TResult, TApiModel, TTransformer>
where TApiModel : notnull
where TTransformer : ITransformer<TResult, TApiModel>, new();
@@ -105,7 +136,7 @@ namespace Tgstation.Server.Host.Authority
Func<TAuthority, long, RequirementsGated<Projectable<TResult, TApiModel>>> authorityInvoker,
IReadOnlyList<long> ids,
QueryContext<AuthorityResponse<TApiModel>>? queryContext)
where TResult : EntityId, IApiTransformable<TResult, TApiModel, TTransformer>
where TResult : EntityId
where TApiModel : Entity
where TTransformer : ITransformer<TResult, TApiModel>, new();
}
@@ -15,8 +15,8 @@ namespace Tgstation.Server.Host.Authority
/// Attempt to login to the server with the current Basic or OAuth credentials.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a <see cref="LoginResult"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
RequirementsGated<AuthorityResponse<LoginResult>> AttemptLogin(CancellationToken cancellationToken);
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in an authenticated token <see cref="AuthorityResponse{TResult}"/>.</returns>
RequirementsGated<AuthorityResponse<string>> AttemptLogin(CancellationToken cancellationToken);
/// <summary>
/// Attempt to login to an OAuth service with the current OAuth credentials.
@@ -3,6 +3,7 @@ using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Tgstation.Server.Api.Models;
using Tgstation.Server.Host.Authority.Core;
using Tgstation.Server.Host.Controllers;
using Tgstation.Server.Host.Models;
@@ -47,5 +48,33 @@ namespace Tgstation.Server.Host.Authority
ValueTask<IActionResult> InvokeTransformable<TResult, TApiModel>(ApiController controller, Func<TAuthority, RequirementsGated<AuthorityResponse<TResult>>> authorityInvoker)
where TResult : notnull, ILegacyApiTransformable<TApiModel>
where TApiModel : notnull;
/// <summary>
/// Invoke a <typeparamref name="TAuthority"/> method and get the result.
/// </summary>
/// <typeparam name="TResult">The <see cref="AuthorityResponse{TResult}.Result"/> <see cref="Type"/>.</typeparam>
/// <typeparam name="TApiModel">The returned REST <see cref="Type"/>.</typeparam>
/// <typeparam name="TTransformer">The <see cref="ITransformer{TInput, TOutput}"/> for converting <typeparamref name="TResult"/>s to <typeparamref name="TApiModel"/>s.</typeparam>
/// <param name="controller">The <see cref="ApiController"/> invoking the <typeparamref name="TAuthority"/>.</param>
/// <param name="authorityInvoker">The <typeparamref name="TAuthority"/> <see cref="Func{T, TResult}"/> resulting in the <see cref="RequirementsGated{TResult}"/> <see cref="AuthorityResponse{TResult}"/>.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="IActionResult"/> generated for the resulting <see cref="AuthorityResponse{TResult}"/>.</returns>
ValueTask<IActionResult> InvokeTransformable<TResult, TApiModel, TTransformer>(ApiController controller, Func<TAuthority, RequirementsGated<AuthorityResponse<TResult>>> authorityInvoker)
where TResult : notnull
where TApiModel : notnull
where TTransformer : ITransformer<TResult, TApiModel>, new();
/// <summary>
/// Invoke a <typeparamref name="TAuthority"/> method and get the result.
/// </summary>
/// <typeparam name="TResult">The <see cref="AuthorityResponse{TResult}.Result"/> <see cref="Type"/>.</typeparam>
/// <typeparam name="TApiModel">The returned REST <see cref="Type"/>.</typeparam>
/// <typeparam name="TTransformer">The <see cref="ITransformer{TInput, TOutput}"/> for converting <typeparamref name="TResult"/>s to <typeparamref name="TApiModel"/>s.</typeparam>
/// <param name="controller">The <see cref="ApiController"/> invoking the <typeparamref name="TAuthority"/>.</param>
/// <param name="authorityInvoker">The <typeparamref name="TAuthority"/> <see cref="Func{T, TResult}"/> resulting in the <see cref="RequirementsGated{TResult}"/> <see cref="AuthorityResponse{TResult}"/>.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="IActionResult"/> generated for the resulting <see cref="AuthorityResponse{TResult}"/>.</returns>
ValueTask<IActionResult> InvokeTransformable<TResult, TApiModel, TTransformer>(ApiController controller, Func<TAuthority, RequirementsGated<Projectable<TResult, TApiModel>>> authorityInvoker)
where TResult : EntityId
where TApiModel : notnull
where TTransformer : ITransformer<TResult, TApiModel>, new();
}
}
@@ -13,33 +13,16 @@ namespace Tgstation.Server.Host.Authority
/// </summary>
public interface IUserAuthority : IAuthority
{
/// <summary>
/// Gets the currently authenticated user.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="RequirementsGated{TResult}"/> <see cref="User"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
RequirementsGated<AuthorityResponse<User>> Read(CancellationToken cancellationToken);
/// <summary>
/// Gets the <see cref="User"/> with a given <paramref name="id"/>.
/// </summary>
/// <param name="id">The <see cref="EntityId.Id"/> of the <see cref="User"/>.</param>
/// <param name="includeJoins">If related entities should be loaded.</param>
/// <param name="allowSystemUser">If the <see cref="User.TgsSystemUserName"/> may be returned.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="RequirementsGated{TResult}"/> <see cref="User"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
RequirementsGated<AuthorityResponse<User>> GetId(long id, bool includeJoins, bool allowSystemUser, CancellationToken cancellationToken);
/// <summary>
/// Gets the <see cref="User"/> with a given <paramref name="id"/>.
/// </summary>
/// <typeparam name="TResult">The result type after projection.</typeparam>
/// <param name="id">The <see cref="EntityId.Id"/> of the <see cref="User"/>.</param>
/// <param name="allowSystemUser">If the <see cref="User.TgsSystemUserName"/> may be returned.</param>
/// <param name="allowSystemUser">If the <see cref="User.TgsSystemUserName"/> may be returned or will result in a <see cref="HttpFailureResponse.Forbidden"/> response.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="RequirementsGated{TResult}"/> <see cref="Projectable{TQueried, TResult}"/> <see cref="User"/> for <typeparamref name="TResult"/>.</returns>
RequirementsGated<Projectable<User, TResult>> GetId<TResult>(long id, bool allowSystemUser, CancellationToken cancellationToken)
where TResult : class;
where TResult : notnull;
/// <summary>
/// Gets the <see cref="Models.OAuthConnection"/>s for the <see cref="User"/> with a given <paramref name="userId"/>.
@@ -15,7 +15,6 @@ using Tgstation.Server.Host.Authority.Core;
using Tgstation.Server.Host.Configuration;
using Tgstation.Server.Host.Database;
using Tgstation.Server.Host.GraphQL.Mutations.Payloads;
using Tgstation.Server.Host.GraphQL.Transformers;
using Tgstation.Server.Host.Models;
using Tgstation.Server.Host.Security;
using Tgstation.Server.Host.Security.OAuth;
@@ -140,7 +139,7 @@ namespace Tgstation.Server.Host.Authority
}
/// <inheritdoc />
public RequirementsGated<AuthorityResponse<LoginResult>> AttemptLogin(CancellationToken cancellationToken)
public RequirementsGated<AuthorityResponse<string>> AttemptLogin(CancellationToken cancellationToken)
=> new(
() => null,
() => AttemptLoginImpl(cancellationToken),
@@ -177,19 +176,19 @@ namespace Tgstation.Server.Host.Authority
/// Login process.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="AuthorityResponse{TResult}"/> for the <see cref="LoginResult"/>.</returns>
private async ValueTask<AuthorityResponse<LoginResult>> AttemptLoginImpl(CancellationToken cancellationToken)
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="AuthorityResponse{TResult}"/> containing the authenticated bearer token.</returns>
private async ValueTask<AuthorityResponse<string>> AttemptLoginImpl(CancellationToken cancellationToken)
{
// password and oauth logins disabled
if (securityConfigurationOptions.Value.OidcStrictMode)
return Unauthorized<LoginResult>();
return Unauthorized<string>();
var headers = apiHeadersProvider.ApiHeaders;
if (headers == null)
return GenerateHeadersExceptionResponse<LoginResult>(apiHeadersProvider.HeadersException!);
return GenerateHeadersExceptionResponse<string>(apiHeadersProvider.HeadersException!);
if (headers.IsTokenAuthentication)
return BadRequest<LoginResult>(ErrorCode.TokenWithToken);
return BadRequest<string>(ErrorCode.TokenWithToken);
var oAuthLogin = headers.OAuthProvider.HasValue;
@@ -212,7 +211,7 @@ namespace Tgstation.Server.Host.Authority
if (oAuthLogin)
{
var oAuthProvider = headers.OAuthProvider!.Value;
var (errorResponse, oauthResult) = await TryOAuthenticate<LoginResult>(headers, oAuthProvider, true, cancellationToken);
var (errorResponse, oauthResult) = await TryOAuthenticate<string>(headers, oAuthProvider, true, cancellationToken);
if (errorResponse != null)
return errorResponse;
@@ -225,7 +224,7 @@ namespace Tgstation.Server.Host.Authority
{
var canonicalUserName = User.CanonicalizeName(headers.Username!);
if (canonicalUserName == User.CanonicalizeName(User.TgsSystemUserName))
return Unauthorized<LoginResult>();
return Unauthorized<string>();
if (systemIdentity == null)
query = query.Where(x => x.CanonicalName == canonicalUserName);
@@ -237,7 +236,7 @@ namespace Tgstation.Server.Host.Authority
// No user? You're not allowed
if (user == null)
return Unauthorized<LoginResult>();
return Unauthorized<string>();
// A system user may have had their name AND password changed to one in our DB...
// Or a DB user was created that had the same user/pass as a system user
@@ -252,7 +251,7 @@ namespace Tgstation.Server.Host.Authority
{
// DB User password check and update
if (!isLikelyDbUser || !cryptographySuite.CheckUserPassword(user, headers.Password!))
return Unauthorized<LoginResult>();
return Unauthorized<string>();
if (user.PasswordHash != originalHash)
{
Logger.LogDebug("User ID {userId}'s password hash needs a refresh, updating database.", user.Id);
@@ -295,22 +294,17 @@ namespace Tgstation.Server.Host.Authority
if (!user.Enabled!.Value)
{
Logger.LogTrace("Not logging in disabled user {userId}.", user.Id);
return Forbid<LoginResult>();
return Forbid<string>();
}
var token = tokenFactory.CreateToken(user, oAuthLogin);
var payload = new LoginResult
{
Bearer = token,
User = ((IApiTransformable<User, GraphQL.Types.User, UserTransformer>)user).ToApi(),
};
var (token, expiresAt) = tokenFactory.CreateToken(user, oAuthLogin);
if (usingSystemIdentity)
await CacheSystemIdentity(systemIdentity!, user, payload);
await CacheSystemIdentity(systemIdentity!, user, expiresAt);
Logger.LogDebug("Successfully logged in user {userId}!", user.Id);
return new AuthorityResponse<LoginResult>(payload);
return new AuthorityResponse<string>(token);
}
}
@@ -319,12 +313,12 @@ namespace Tgstation.Server.Host.Authority
/// </summary>
/// <param name="systemIdentity">The <see cref="ISystemIdentity"/> to cache.</param>
/// <param name="user">The <see cref="User"/> the <paramref name="systemIdentity"/> was generated for.</param>
/// <param name="loginPayload">The <see cref="LoginResult"/> for the successful login.</param>
/// <param name="validTo">When the user's session exipres.</param>
/// <returns>A <see cref="ValueTask"/> representing the running operation.</returns>
private async ValueTask CacheSystemIdentity(ISystemIdentity systemIdentity, User user, LoginResult loginPayload)
private async ValueTask CacheSystemIdentity(ISystemIdentity systemIdentity, User user, DateTimeOffset validTo)
{
// expire the identity slightly after the auth token in case of lag
var identExpiry = loginPayload.ToApi().ParseJwt().ValidTo;
var identExpiry = validTo;
identExpiry += tokenFactory.ValidationParameters.ClockSkew;
identExpiry += TimeSpan.FromSeconds(15);
await identityCache.CacheSystemIdentity(user, systemIdentity!, identExpiry);
@@ -24,7 +24,6 @@ using Tgstation.Server.Host.Authority.Core;
using Tgstation.Server.Host.Configuration;
using Tgstation.Server.Host.Database;
using Tgstation.Server.Host.Extensions;
using Tgstation.Server.Host.GraphQL.Transformers;
using Tgstation.Server.Host.Models;
using Tgstation.Server.Host.Security;
using Tgstation.Server.Host.Security.RightsEvaluation;
@@ -229,27 +228,6 @@ namespace Tgstation.Server.Host.Authority
return failResponse != null;
}
/// <inheritdoc />
public RequirementsGated<AuthorityResponse<User>> Read(CancellationToken cancellationToken)
=> new(
() => Enumerable.Empty<IAuthorizationRequirement>(),
() => GetIdImpl(claimsPrincipalAccessor.User.RequireTgsUserId(), true, false, cancellationToken));
/// <inheritdoc />
public RequirementsGated<AuthorityResponse<User>> GetId(long id, bool includeJoins, bool allowSystemUser, CancellationToken cancellationToken)
=> new(
() =>
{
if (id != claimsPrincipalAccessor.User.GetTgsUserId())
return Enumerable.Empty<IAuthorizationRequirement>();
return new List<IAuthorizationRequirement>
{
Flag(AdministrationRights.ReadUsers),
};
},
() => GetIdImpl(id, includeJoins, allowSystemUser, cancellationToken));
/// <inheritdoc />
public RequirementsGated<IQueryable<User>> Queryable(bool includeJoins)
=> new(
@@ -565,7 +543,7 @@ namespace Tgstation.Server.Host.Authority
/// <inheritdoc />
public RequirementsGated<Projectable<User, TResult>> GetId<TResult>(long id, bool allowSystemUser, CancellationToken cancellationToken)
where TResult : class
where TResult : notnull
=> new(
() =>
{
@@ -600,37 +578,6 @@ namespace Tgstation.Server.Host.Authority
},
cancellationToken)));
/// <summary>
/// Implementation of retrieving a <see cref="User"/> by ID.
/// </summary>
/// <param name="id">The <see cref="EntityId.Id"/> of the user to retrieve.</param>
/// <param name="includeJoins">If related entities should be loaded.</param>
/// <param name="allowSystemUser">If the <see cref="User.TgsSystemUserName"/> may be returned.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="User"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
async ValueTask<AuthorityResponse<User>> GetIdImpl(long id, bool includeJoins, bool allowSystemUser, CancellationToken cancellationToken)
{
User? user;
if (includeJoins)
{
var queryable = Queryable(true, true);
user = await queryable.FirstOrDefaultAsync(
dbModel => dbModel.Id == id,
cancellationToken);
}
else
user = await usersDataLoader.LoadAsync(id, cancellationToken);
if (user == default)
return NotFound<User>();
if (!allowSystemUser && user.CanonicalName == User.CanonicalizeName(User.TgsSystemUserName))
return Forbid<User>();
return new AuthorityResponse<User>(user);
}
/// <summary>
/// Create the <see cref="AuthorityResponse{TResult}"/> for an <see cref="UpdatedUser"/>.
/// </summary>
@@ -667,7 +614,7 @@ namespace Tgstation.Server.Host.Authority
user.Require(x => x.Id))
.Select(topic => topicEventSender.SendAsync(
topic,
((IApiTransformable<User, GraphQL.Types.User, UserTransformer>)user).ToApi(),
((IApiTransformable<User, GraphQL.Types.User>)user).ToApi<GraphQL.Transformers.UserTransformer>(),
CancellationToken.None))); // DCT: Operation should always run
/// <summary>
@@ -208,7 +208,6 @@ namespace Tgstation.Server.Host.Controllers
}));
}
},
null,
page,
pageSize,
cancellationToken);
@@ -243,19 +243,45 @@ namespace Tgstation.Server.Host.Controllers
/// </summary>
/// <typeparam name="TModel">The <see cref="Type"/> of model being generated and returned.</typeparam>
/// <param name="queryGenerator">A <see cref="Func{TResult}"/> resulting in a <see cref="Task{TResult}"/> resulting in the generated <see cref="PaginatableResult{TModel}"/>.</param>
/// <param name="resultTransformer">Optional <see cref="Func{T, TResult}"/> to transform the <typeparamref name="TModel"/>s after being queried.</param>
/// <param name="pageQuery">The requested page from the query.</param>
/// <param name="pageSizeQuery">The requested page size from the query.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="IActionResult"/> for the operation.</returns>
protected ValueTask<IActionResult> Paginated<TModel>(
Func<ValueTask<PaginatableResult<TModel>?>> queryGenerator,
Func<TModel, ValueTask>? resultTransformer,
int? pageQuery,
int? pageSizeQuery,
CancellationToken cancellationToken) => PaginatedImpl(
queryGenerator,
resultTransformer,
model => model,
null,
pageQuery,
pageSizeQuery,
cancellationToken);
/// <summary>
/// Generates a paginated response.
/// </summary>
/// <typeparam name="TModel">The <see cref="Type"/> of model being generated.</typeparam>
/// <typeparam name="TApiModel">The <see cref="Type"/> of model being returned.</typeparam>
/// <typeparam name="TTransformer">The <see cref="Type"/> of the <see cref="ITransformer{TInput, TOutput}"/> for <typeparamref name="TModel"/>/<typeparamref name="TApiModel"/>.</typeparam>
/// <param name="queryGenerator">A <see cref="Func{TResult}"/> resulting in a <see cref="Task{TResult}"/> resulting in the generated <see cref="PaginatableResult{TModel}"/>.</param>
/// <param name="pageQuery">The requested page from the query.</param>
/// <param name="pageSizeQuery">The requested page size from the query.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="IActionResult"/> for the operation.</returns>
protected ValueTask<IActionResult> Paginated<TModel, TApiModel, TTransformer>(
Func<ValueTask<PaginatableResult<TModel>?>> queryGenerator,
int? pageQuery,
int? pageSizeQuery,
CancellationToken cancellationToken)
where TModel : IApiTransformable<TModel, TApiModel>
where TApiModel : notnull
where TTransformer : ITransformer<TModel, TApiModel>, new()
=> PaginatedImpl(
queryGenerator,
model => model.ToApi<TTransformer>(),
null,
pageQuery,
pageSizeQuery,
cancellationToken);
@@ -266,21 +292,22 @@ namespace Tgstation.Server.Host.Controllers
/// <typeparam name="TModel">The <see cref="Type"/> of model being generated.</typeparam>
/// <typeparam name="TApiModel">The <see cref="Type"/> of model being returned.</typeparam>
/// <param name="queryGenerator">A <see cref="Func{TResult}"/> resulting in a <see cref="ValueTask{TResult}"/> resulting in the generated <see cref="PaginatableResult{TModel}"/>.</param>
/// <param name="resultTransformer">A <see cref="Func{T, TResult}"/> to transform the <typeparamref name="TApiModel"/>s after being queried.</param>
/// <param name="resultMutator">A <see cref="Func{T, TResult}"/> to mutate the <typeparamref name="TApiModel"/>s after being queried.</param>
/// <param name="pageQuery">The requested page from the query.</param>
/// <param name="pageSizeQuery">The requested page size from the query.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="IActionResult"/> for the operation.</returns>
protected ValueTask<IActionResult> Paginated<TModel, TApiModel>(
Func<ValueTask<PaginatableResult<TModel>?>> queryGenerator,
Func<TApiModel, ValueTask>? resultTransformer,
Func<TApiModel, ValueTask>? resultMutator,
int? pageQuery,
int? pageSizeQuery,
CancellationToken cancellationToken)
where TModel : ILegacyApiTransformable<TApiModel>
=> PaginatedImpl(
queryGenerator,
resultTransformer,
model => model.ToApi(),
resultMutator,
pageQuery,
pageSizeQuery,
cancellationToken);
@@ -291,14 +318,16 @@ namespace Tgstation.Server.Host.Controllers
/// <typeparam name="TModel">The <see cref="Type"/> of model being generated. If different from <typeparamref name="TResultModel"/>, must implement <see cref="ILegacyApiTransformable{TApiModel}"/> for <typeparamref name="TResultModel"/>.</typeparam>
/// <typeparam name="TResultModel">The <see cref="Type"/> of model being returned.</typeparam>
/// <param name="queryGenerator">A <see cref="Func{TResult}"/> resulting in a <see cref="ValueTask{TResult}"/> resulting in the generated <see cref="PaginatableResult{TModel}"/> or <see langword="null"/> if an authorization requirment failed.</param>
/// <param name="resultTransformer">A <see cref="Func{T, TResult}"/> to transform the <typeparamref name="TResultModel"/>s after being queried.</param>
/// <param name="resultTransformer">The conversion <see cref="Func{T, TResult}"/> from <typeparamref name="TModel"/> to <typeparamref name="TResultModel"/>.</param>
/// <param name="resultMutator">A <see cref="Func{T, TResult}"/> to mutate the <typeparamref name="TResultModel"/>s after being queried.</param>
/// <param name="pageQuery">The requested page from the query.</param>
/// <param name="pageSizeQuery">The requested page size from the query.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="IActionResult"/> for the operation.</returns>
async ValueTask<IActionResult> PaginatedImpl<TModel, TResultModel>(
Func<ValueTask<PaginatableResult<TModel>?>> queryGenerator,
Func<TResultModel, ValueTask>? resultTransformer,
Func<TModel, TResultModel> resultTransformer,
Func<TResultModel, ValueTask>? resultMutator,
int? pageQuery,
int? pageSizeQuery,
CancellationToken cancellationToken)
@@ -343,18 +372,13 @@ namespace Tgstation.Server.Host.Controllers
pagedResults = [.. queriedResults];
}
ICollection<TResultModel> finalResults;
if (typeof(TResultModel).IsAssignableFrom(typeof(TModel)))
finalResults = pagedResults.Cast<TResultModel>().ToList(); // clearly a safe cast
else
finalResults = pagedResults
.Cast<ILegacyApiTransformable<TResultModel>>()
.Select(x => x.ToApi())
.ToList();
var finalResults = pagedResults
.Select(resultTransformer)
.ToList();
if (resultTransformer != null)
if (resultMutator != null)
foreach (var finalResult in finalResults)
await resultTransformer(finalResult);
await resultMutator(finalResult);
var carryTheOne = totalResults % pageSize != 0
? 1
@@ -16,6 +16,7 @@ using Tgstation.Server.Api.Models.Response;
using Tgstation.Server.Host.Authority;
using Tgstation.Server.Host.Components.Interop;
using Tgstation.Server.Host.Configuration;
using Tgstation.Server.Host.Controllers.Transformers;
using Tgstation.Server.Host.Core;
using Tgstation.Server.Host.Database;
using Tgstation.Server.Host.GraphQL.Mutations.Payloads;
@@ -194,7 +195,7 @@ namespace Tgstation.Server.Host.Controllers
return ValueTask.FromResult(HeadersIssue(ApiHeadersProvider.HeadersException!));
}
return loginAuthority.InvokeTransformable<LoginResult, TokenResponse>(this, authority => authority.AttemptLogin(cancellationToken));
return loginAuthority.InvokeTransformable<string, TokenResponse, TokenResponseTransformer>(this, authority => authority.AttemptLogin(cancellationToken));
}
/// <summary>
@@ -204,7 +204,6 @@ namespace Tgstation.Server.Host.Controllers
}));
}
},
null,
page,
pageSize,
cancellationToken));
@@ -121,7 +121,6 @@ namespace Tgstation.Server.Host.Controllers
})
.AsQueryable()
.OrderBy(x => x.EngineVersion!.ToString()))),
null,
page,
pageSize,
cancellationToken));
@@ -0,0 +1,24 @@
using Tgstation.Server.Api.Models;
using Tgstation.Server.Api.Rights;
namespace Tgstation.Server.Host.Controllers.Transformers
{
/// <summary>
/// <see cref="Models.ITransformer{TInput, TOutput}"/> for <see cref="PermissionSet"/>s.
/// </summary>
sealed class PermissionSetTransformer : Models.TransformerBase<Models.PermissionSet, PermissionSet>
{
/// <summary>
/// Initializes a new instance of the <see cref="PermissionSetTransformer"/> class.
/// </summary>
public PermissionSetTransformer()
: base(
model => new PermissionSet
{
AdministrationRights = model.AdministrationRights ?? NotNullFallback<AdministrationRights>(),
InstanceManagerRights = model.InstanceManagerRights ?? NotNullFallback<InstanceManagerRights>(),
})
{
}
}
}
@@ -0,0 +1,23 @@
using Tgstation.Server.Api.Models.Response;
using Tgstation.Server.Host.Models;
namespace Tgstation.Server.Host.Controllers.Transformers
{
/// <summary>
/// <see cref="ITransformer{TInput, TOutput}"/> for <see cref="TokenResponse"/>s.
/// </summary>
sealed class TokenResponseTransformer : TransformerBase<string, TokenResponse>
{
/// <summary>
/// Initializes a new instance of the <see cref="TokenResponseTransformer"/> class.
/// </summary>
public TokenResponseTransformer()
: base(
token => new TokenResponse
{
Bearer = token,
})
{
}
}
}
@@ -0,0 +1,25 @@
using Tgstation.Server.Api.Models.Response;
using Tgstation.Server.Host.Models;
namespace Tgstation.Server.Host.Controllers.Transformers
{
/// <summary>
/// <see cref="ITransformer{TInput, TOutput}"/> for <see cref="UpdatedUser"/>s to <see cref="UserResponse"/>s.
/// </summary>
sealed class UpdatedUserResponseTransformer : TransformerBase<UpdatedUser, UserResponse>
{
/// <summary>
/// Initializes a new instance of the <see cref="UpdatedUserResponseTransformer"/> class.
/// </summary>
public UpdatedUserResponseTransformer()
: base(
BuildSubProjection<User, UserResponse, UserResponseTransformer>(
(model, fullUser) => fullUser ?? new UserResponse
{
Id = model.Id,
},
model => model.User))
{
}
}
}
@@ -0,0 +1,27 @@
using Tgstation.Server.Api.Models;
using Tgstation.Server.Api.Models.Internal;
namespace Tgstation.Server.Host.Controllers.Transformers
{
/// <summary>
/// <see cref="Models.ITransformer{TInput, TOutput}"/> for <see cref="UserGroup"/>s.
/// </summary>
sealed class UserGroupTransformer : Models.TransformerBase<Models.UserGroup, UserGroup>
{
/// <summary>
/// Initializes a new instance of the <see cref="UserGroupTransformer"/> class.
/// </summary>
public UserGroupTransformer()
: base(
BuildSubProjection<Models.PermissionSet, PermissionSet, PermissionSetTransformer>(
(model, permissionSet) => new UserGroup
{
Id = model.Id,
Name = model.Name,
PermissionSet = permissionSet,
},
model => model.PermissionSet))
{
}
}
}
@@ -0,0 +1,24 @@
using Tgstation.Server.Api.Models;
using Tgstation.Server.Host.Models;
namespace Tgstation.Server.Host.Controllers.Transformers
{
/// <summary>
/// <see cref="ITransformer{TInput, TOutput}"/> for <see cref="UserName"/>s.
/// </summary>
sealed class UserNameTransformer : TransformerBase<User, UserName>
{
/// <summary>
/// Initializes a new instance of the <see cref="UserNameTransformer"/> class.
/// </summary>
public UserNameTransformer()
: base(
model => new UserName
{
Id = model.Id,
Name = model.Name,
})
{
}
}
}
@@ -0,0 +1,64 @@
using System.Linq;
using Tgstation.Server.Api.Models;
using Tgstation.Server.Api.Models.Internal;
using Tgstation.Server.Api.Models.Response;
namespace Tgstation.Server.Host.Controllers.Transformers
{
/// <summary>
/// <see cref="Models.ITransformer{TInput, TOutput}"/> for <see cref="UserResponse"/>s.
/// </summary>
sealed class UserResponseTransformer : Models.TransformerBase<Models.User, UserResponse>
{
/// <summary>
/// Initializes a new instance of the <see cref="UserResponseTransformer"/> class.
/// </summary>
public UserResponseTransformer()
: base(
BuildSubProjection<
Models.UserGroup,
Models.PermissionSet,
Models.User,
UserGroup,
PermissionSet,
UserName,
UserGroupTransformer,
PermissionSetTransformer,
UserNameTransformer>(
(model, group, permissionSet, createdBy) => new UserResponse
{
Id = model.Id,
CreatedAt = model.CreatedAt,
OAuthConnections = model.OAuthConnections != null
? model.OAuthConnections.Select(
oAuthConnection => new OAuthConnection
{
ExternalUserId = oAuthConnection.ExternalUserId,
Provider = oAuthConnection.Provider,
})
.ToList()
: null,
CreatedBy = createdBy,
Enabled = model.Enabled,
Group = group,
Name = model.Name,
OidcConnections = model.OidcConnections != null
? model.OidcConnections.Select(
oAuthConnection => new OidcConnection
{
ExternalUserId = oAuthConnection.ExternalUserId,
SchemeKey = oAuthConnection.SchemeKey,
})
.ToList()
: null,
PermissionSet = permissionSet,
SystemIdentifier = model.SystemIdentifier,
},
model => model.Group,
model => model.PermissionSet,
model => model.CreatedBy))
{
}
}
}
@@ -14,7 +14,9 @@ using Tgstation.Server.Api.Models.Response;
using Tgstation.Server.Api.Rights;
using Tgstation.Server.Host.Authority;
using Tgstation.Server.Host.Controllers.Results;
using Tgstation.Server.Host.Controllers.Transformers;
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;
@@ -33,18 +35,25 @@ namespace Tgstation.Server.Host.Controllers
/// </summary>
readonly IRestAuthorityInvoker<IUserAuthority> userAuthority;
/// <summary>
/// The <see cref="IClaimsPrincipalAccessor"/> for the request.
/// </summary>
readonly IClaimsPrincipalAccessor claimsPrincipalAccessor;
/// <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="userAuthority">The value of <see cref="userAuthority"/>.</param>
/// <param name="claimsPrincipalAccessor">The value of <see cref="claimsPrincipalAccessor"/>.</param>
/// <param name="logger">The <see cref="ILogger"/> for the <see cref="ApiController"/>.</param>
/// <param name="apiHeaders">The <see cref="IApiHeadersProvider"/> for the <see cref="ApiController"/>.</param>
public UserController(
IDatabaseContext databaseContext,
IAuthenticationContext authenticationContext,
IRestAuthorityInvoker<IUserAuthority> userAuthority,
IClaimsPrincipalAccessor claimsPrincipalAccessor,
ILogger<UserController> logger,
IApiHeadersProvider apiHeaders)
: base(
@@ -55,6 +64,7 @@ namespace Tgstation.Server.Host.Controllers
true)
{
this.userAuthority = userAuthority ?? throw new ArgumentNullException(nameof(userAuthority));
this.claimsPrincipalAccessor = claimsPrincipalAccessor ?? throw new ArgumentNullException(nameof(claimsPrincipalAccessor));
}
/// <summary>
@@ -68,7 +78,7 @@ namespace Tgstation.Server.Host.Controllers
[HttpPut]
[ProducesResponseType(typeof(UserResponse), 201)]
public ValueTask<IActionResult> Create([FromBody] UserCreateRequest model, CancellationToken cancellationToken)
=> userAuthority.InvokeTransformable<UpdatedUser, UserResponse>(this, authority => authority.Create(model, null, cancellationToken));
=> userAuthority.InvokeTransformable<UpdatedUser, UserResponse, UpdatedUserResponseTransformer>(this, authority => authority.Create(model, null, cancellationToken));
/// <summary>
/// Update a <see cref="User"/>.
@@ -86,7 +96,7 @@ namespace Tgstation.Server.Host.Controllers
[ProducesResponseType(typeof(ErrorMessageResponse), 404)]
[ProducesResponseType(typeof(ErrorMessageResponse), 410)]
public ValueTask<IActionResult> Update([FromBody] UserUpdateRequest model, CancellationToken cancellationToken)
=> userAuthority.InvokeTransformable<UpdatedUser, UserResponse>(this, authority => authority.Update(model, cancellationToken));
=> userAuthority.InvokeTransformable<UpdatedUser, UserResponse, UpdatedUserResponseTransformer>(this, authority => authority.Update(model, cancellationToken));
/// <summary>
/// Get information about the current <see cref="User"/>.
@@ -98,7 +108,10 @@ namespace Tgstation.Server.Host.Controllers
[Authorize]
[ProducesResponseType(typeof(UserResponse), 200)]
public ValueTask<IActionResult> Read(CancellationToken cancellationToken)
=> userAuthority.InvokeTransformable<User, UserResponse>(this, authority => authority.Read(cancellationToken));
=> userAuthority.InvokeTransformable<User, UserResponse, UserResponseTransformer>(this, authority => authority.GetId<UserResponse>(
claimsPrincipalAccessor.User.RequireTgsUserId(),
false,
cancellationToken));
/// <summary>
/// List all <see cref="User"/>s in the server.
@@ -111,7 +124,7 @@ namespace Tgstation.Server.Host.Controllers
[HttpGet(Routes.List)]
[ProducesResponseType(typeof(PaginatedResponse<UserResponse>), 200)]
public ValueTask<IActionResult> List([FromQuery] int? page, [FromQuery] int? pageSize, CancellationToken cancellationToken)
=> Paginated<User, UserResponse>(
=> Paginated<User, UserResponse, UserResponseTransformer>(
async () =>
{
var queryable = await userAuthority.InvokeQueryable(
@@ -121,7 +134,6 @@ namespace Tgstation.Server.Host.Controllers
return new PaginatableResult<User>(queryable.OrderBy(x => x.Id));
},
null,
page,
pageSize,
cancellationToken);
@@ -145,9 +157,9 @@ namespace Tgstation.Server.Host.Controllers
if (!((AdministrationRights)AuthenticationContext.GetRight(RightsType.Administration)).HasFlag(AdministrationRights.ReadUsers))
return Forbid();
return await userAuthority.InvokeTransformable<User, UserResponse>(
return await userAuthority.InvokeTransformable<User, UserResponse, UserResponseTransformer>(
this,
authority => authority.GetId(id, true, false, cancellationToken));
authority => authority.GetId<UserResponse>(id, false, cancellationToken));
}
}
}
@@ -861,7 +861,7 @@ namespace Tgstation.Server.Host.Core
var authenticationContext = services
.GetRequiredService<IAuthenticationContext>();
context.HandleResponse();
context.HttpContext.Response.Redirect($"{config.ReturnPath}?code={HttpUtility.UrlEncode(tokenFactory.CreateToken(authenticationContext.User, true))}&state=oidc.{HttpUtility.UrlEncode(configName)}");
context.HttpContext.Response.Redirect($"{config.ReturnPath}?code={HttpUtility.UrlEncode(tokenFactory.CreateToken(authenticationContext.User, true).Token)}&state=oidc.{HttpUtility.UrlEncode(configName)}");
return Task.CompletedTask;
},
};
@@ -8,6 +8,7 @@ using HotChocolate.Types;
using Tgstation.Server.Host.Authority;
using Tgstation.Server.Host.GraphQL.Mutations.Payloads;
using Tgstation.Server.Host.GraphQL.Transformers;
namespace Tgstation.Server.Host.GraphQL
{
@@ -38,7 +39,7 @@ namespace Tgstation.Server.Host.GraphQL
{
ArgumentNullException.ThrowIfNull(loginAuthority);
return loginAuthority.Invoke<LoginResult, LoginResult>(
return loginAuthority.InvokeTransformable<string, LoginResult, LoginResultTransformer>(
authority => authority.AttemptLogin(cancellationToken));
}
@@ -1,14 +1,12 @@
using HotChocolate;
using Tgstation.Server.Api.Models.Response;
using Tgstation.Server.Host.GraphQL.Scalars;
using Tgstation.Server.Host.Models;
namespace Tgstation.Server.Host.GraphQL.Mutations.Payloads
{
/// <summary>
/// Success response for a login attempt.
/// </summary>
public sealed class LoginResult : ILegacyApiTransformable<TokenResponse>
public sealed class LoginResult
{
/// <summary>
/// The JSON Web Token (JWT) to use as a Bearer token for accessing the server at non-login endpoints. Contains an expiry time.
@@ -16,18 +14,5 @@ namespace Tgstation.Server.Host.GraphQL.Mutations.Payloads
[GraphQLType<JwtType>]
[GraphQLNonNullType]
public required string Bearer { get; init; }
/// <summary>
/// The <see cref="User"/> that was logged in.
/// </summary>
public required Types.User User { get; init; }
/// <inheritdoc />
[GraphQLIgnore]
public TokenResponse ToApi()
=> new()
{
Bearer = Bearer,
};
}
}
@@ -0,0 +1,23 @@
using Tgstation.Server.Host.GraphQL.Mutations.Payloads;
using Tgstation.Server.Host.Models;
namespace Tgstation.Server.Host.GraphQL.Transformers
{
/// <summary>
/// <see cref="Models.ITransformer{TInput, TOutput}"/> for <see cref="LoginResult"/>s.
/// </summary>
sealed class LoginResultTransformer : TransformerBase<string, LoginResult>
{
/// <summary>
/// Initializes a new instance of the <see cref="LoginResultTransformer"/> class.
/// </summary>
public LoginResultTransformer()
: base(
token => new LoginResult
{
Bearer = token,
})
{
}
}
}
@@ -32,17 +32,5 @@ namespace Tgstation.Server.Host.GraphQL.Types
{
Name = copy.Name;
}
/// <summary>
/// Initializes a new instance of the <see cref="NamedEntity"/> class.
/// </summary>
/// <param name="id">The ID for the <see cref="Entity"/>.</param>
/// <param name="name">The value of <see cref="Name"/>.</param>
[SetsRequiredMembers]
protected NamedEntity(long id, string name)
: base(id)
{
Name = name ?? throw new ArgumentNullException(nameof(name));
}
}
}
@@ -25,7 +25,7 @@ namespace Tgstation.Server.Host.GraphQL.Types
/// A user registered in the server.
/// </summary>
[Node]
public sealed class User : NamedEntity, IUserName
public sealed class User : UserName
{
/// <inheritdoc />
[IsProjected(true)]
@@ -79,7 +79,7 @@ namespace Tgstation.Server.Host.GraphQL.Types
/// <summary>
/// Implements the <see cref="IUserGroupsDataLoader"/>.
/// </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="Api.Models.EntityId.Id"/>s to load paired with if the system user should be allowed.</param>
/// <param name="userAuthority">The <see cref="IGraphQLAuthorityInvoker{TAuthority}"/> for the <see cref="IUserAuthority"/>.</param>
/// <param name="queryContext">The <see cref="QueryContext{TEntity}"/> for <see cref="User"/> mapped to an <see cref="AuthorityResponse{TResult}"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
@@ -108,31 +108,43 @@ namespace Tgstation.Server.Host.GraphQL.Types
/// <param name="queryContext">The <see cref="QueryContext{TEntity}"/> for the operation.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask"/> resulting in the queried <see cref="User"/>, if present.</returns>
public static ValueTask<User?> GetUser(
public static async ValueTask<User?> GetUser(
long id,
[Service] IUsersDataLoader usersDataLoader,
QueryContext<User>? queryContext,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(usersDataLoader);
return usersDataLoader.LoadAuthorityResponse(queryContext, id, cancellationToken);
var user = await usersDataLoader.LoadAuthorityResponse(queryContext, id, cancellationToken);
if (user?.CanonicalName == Models.User.CanonicalizeName(Models.User.TgsSystemUserName))
throw new NotImplementedException();
return user;
}
/// <summary>
/// The <see cref="User"/> who created this <see cref="User"/>.
/// </summary>
/// <param name="userAuthority">The <see cref="IGraphQLAuthorityInvoker{TAuthority}"/> for the <see cref="IUserAuthority"/>.</param>
/// <param name="queryContext">The <see cref="QueryContext{TEntity}"/> for the operation.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>The <see cref="IUserName"/> that created this <see cref="User"/>, if any.</returns>
public async ValueTask<IUserName?> CreatedBy(
[Service] IGraphQLAuthorityInvoker<IUserAuthority> userAuthority,
QueryContext<User>? queryContext,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(userAuthority);
if (!CreatedById.HasValue)
return null;
var user = await userAuthority.InvokeTransformable<Models.User, User, UserTransformer>(authority => authority.GetId(CreatedById.Value, false, true, cancellationToken));
// This one is particular and cannot be data-loaded due to necessitating a different parameter
var user = await userAuthority.InvokeTransformable<Models.User, User, UserTransformer>(
authority => authority.GetId<User>(CreatedById.Value, true, cancellationToken),
queryContext);
if (user == null)
throw new InvalidOperationException($"Query for created by of user ID {CreatedById.Value} returned null!");
if (user.CanonicalName == Models.User.CanonicalizeName(Models.User.TgsSystemUserName))
return new UserName(user);
@@ -1,47 +1,21 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using System.Diagnostics.CodeAnalysis;
using HotChocolate;
using HotChocolate.Types.Relay;
using Tgstation.Server.Host.Authority;
using Tgstation.Server.Host.GraphQL.Interfaces;
using Tgstation.Server.Host.GraphQL.Transformers;
namespace Tgstation.Server.Host.GraphQL.Types
{
/// <summary>
/// A <see cref="User"/> with limited fields.
/// </summary>
[Node]
public sealed class UserName : NamedEntity, IUserName
public class UserName : NamedEntity, IUserName
{
/// <summary>
/// Node resolver for <see cref="UserName"/>s.
/// </summary>
/// <param name="id">The <see cref="Entity.Id"/> to lookup.</param>
/// <param name="userAuthority">The <see cref="IGraphQLAuthorityInvoker{TAuthority}"/> for the <see cref="IUserAuthority"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask"/> resulting in the queried <see cref="UserName"/>, if present.</returns>
public static ValueTask<UserName?> GetUserName(
long id,
[Service] IGraphQLAuthorityInvoker<IUserAuthority> userAuthority,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(userAuthority);
return userAuthority.InvokeTransformableAllowMissing<Models.User, UserName, UserNameTransformer>(
authority => authority.GetId(id, false, true, cancellationToken));
}
/// <summary>
/// Initializes a new instance of the <see cref="UserName"/> class.
/// </summary>
/// <param name="copy">The <see cref="NamedEntity"/> to copy.</param>
/// <param name="user">The <see cref="User"/> to copy.</param>
[SetsRequiredMembers]
public UserName(NamedEntity copy)
: base(copy)
public UserName(User user)
: base(user)
{
}
@@ -14,7 +14,9 @@ using Microsoft.Extensions.Options;
using Tgstation.Server.Host.Authority;
using Tgstation.Server.Host.Configuration;
using Tgstation.Server.Host.Extensions;
using Tgstation.Server.Host.GraphQL.Transformers;
using Tgstation.Server.Host.Security;
namespace Tgstation.Server.Host.GraphQL.Types
{
@@ -38,15 +40,27 @@ namespace Tgstation.Server.Host.GraphQL.Types
/// <summary>
/// Gets the current <see cref="User"/>.
/// </summary>
/// <param name="userAuthority">The <see cref="IGraphQLAuthorityInvoker{TAuthority}"/> for the <see cref="IUserAuthority"/>.</param>
/// <param name="claimsPrincipalAccessor">The <see cref="IClaimsPrincipalAccessor"/> for getting the current user ID.</param>
/// <param name="usersDataLoader">The <see cref="IUsersDataLoader"/> to use.</param>
/// <param name="queryContext">The active <see cref="QueryContext{TEntity}"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the current <see cref="User"/>.</returns>
public ValueTask<User> Current(
[Service] IGraphQLAuthorityInvoker<IUserAuthority> userAuthority,
[Error(typeof(ErrorMessageException))]
public async ValueTask<User> Current(
[Service] IClaimsPrincipalAccessor claimsPrincipalAccessor,
[Service] IUsersDataLoader usersDataLoader,
QueryContext<User>? queryContext,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(userAuthority);
return userAuthority.InvokeTransformable<Models.User, User, UserTransformer>(authority => authority.Read(cancellationToken));
var user = await ById(
(claimsPrincipalAccessor ?? throw new ArgumentNullException(nameof(claimsPrincipalAccessor))).User.RequireTgsUserId(),
usersDataLoader,
queryContext,
cancellationToken);
if (user == null)
throw new InvalidOperationException("Reading the current user returned null!");
return user;
}
/// <summary>
@@ -1,7 +1,5 @@
using System;
#pragma warning disable CA1005
namespace Tgstation.Server.Host.Models
{
/// <summary>
@@ -9,17 +7,17 @@ namespace Tgstation.Server.Host.Models
/// </summary>
/// <typeparam name="TModel">The internal model <see cref="Type"/>.</typeparam>
/// <typeparam name="TApiModel">The API model <see cref="Type"/>.</typeparam>
/// <typeparam name="TTransformer">The <see cref="ITransformer{TModel, TApiModel}"/> <see cref="Type"/>.</typeparam>
public interface IApiTransformable<TModel, TApiModel, TTransformer>
public interface IApiTransformable<TModel, TApiModel>
where TApiModel : notnull
where TModel : IApiTransformable<TModel, TApiModel, TTransformer>
where TTransformer : ITransformer<TModel, TApiModel>, new()
where TModel : IApiTransformable<TModel, TApiModel>
{
/// <summary>
/// Convert the <see cref="IApiTransformable{TModel, TApiModel, TApiTransformer}"/> to it's <typeparamref name="TApiModel"/>.
/// Convert the <see cref="IApiTransformable{TModel, TApiModel}"/> to it's <typeparamref name="TApiModel"/>.
/// </summary>
/// <returns>A new <typeparamref name="TApiModel"/> based on the <see cref="IApiTransformable{TModel, TApiModel, TApiTransformer}"/>.</returns>
TApiModel ToApi()
/// <typeparam name="TTransformer">The <see cref="ITransformer{TModel, TApiModel}"/> <see cref="Type"/>.</typeparam>
/// <returns>A new <typeparamref name="TApiModel"/> based on the <see cref="IApiTransformable{TModel, TApiModel}"/>.</returns>
TApiModel ToApi<TTransformer>()
where TTransformer : ITransformer<TModel, TApiModel>, new()
=> new TTransformer()
.CompiledExpression((TModel)this);
}
@@ -1,13 +1,11 @@
using System.ComponentModel.DataAnnotations;
using Tgstation.Server.Host.GraphQL.Transformers;
namespace Tgstation.Server.Host.Models
{
/// <inheritdoc cref="Api.Models.OAuthConnection" />
public sealed class OAuthConnection : Api.Models.OAuthConnection,
ILegacyApiTransformable<Api.Models.OAuthConnection>,
IApiTransformable<OAuthConnection, GraphQL.Types.OAuth.OAuthConnection, OAuthConnectionTransformer>
IApiTransformable<OAuthConnection, GraphQL.Types.OAuth.OAuthConnection>
{
/// <summary>
/// The row Id.
@@ -1,13 +1,11 @@
using System.ComponentModel.DataAnnotations;
using Tgstation.Server.Host.GraphQL.Transformers;
namespace Tgstation.Server.Host.Models
{
/// <inheritdoc cref="Api.Models.OidcConnection" />
public sealed class OidcConnection : Api.Models.OidcConnection,
ILegacyApiTransformable<Api.Models.OidcConnection>,
IApiTransformable<OidcConnection, GraphQL.Types.OAuth.OidcConnection, OidcConnectionTransformer>
IApiTransformable<OidcConnection, GraphQL.Types.OAuth.OidcConnection>
{
/// <summary>
/// The row Id.
@@ -1,11 +1,9 @@
using System.Collections.Generic;
using Tgstation.Server.Host.GraphQL.Transformers;
namespace Tgstation.Server.Host.Models
{
/// <inheritdoc />
public sealed class PermissionSet : Api.Models.PermissionSet, IApiTransformable<PermissionSet, GraphQL.Types.PermissionSet, PermissionSetTransformer>
public sealed class PermissionSet : Api.Models.PermissionSet, IApiTransformable<PermissionSet, GraphQL.Types.PermissionSet>
{
/// <summary>
/// The <see cref="Api.Models.EntityId.Id"/> of <see cref="User"/>.
@@ -137,6 +137,47 @@ namespace Tgstation.Server.Host.Models
return global::System.Linq.Expressions.Expression.Lambda<Func<TInput, TOutput>>(outputExpression, primaryInput);
}
/// <summary>
/// Build an <see cref="Expression{TDelegate}"/> for <typeparamref name="TInput"/> to <typeparamref name="TOutput"/> when <typeparamref name="TInput"/> contains three sub-inputs with their own <see cref="ITransformer{TInput, TOutput}"/>s.
/// </summary>
/// <typeparam name="TSubInput1">The first field <see cref="Type"/> in <typeparamref name="TInput"/> that needs transforming.</typeparam>
/// <typeparam name="TSubInput2">The second field <see cref="Type"/> in <typeparamref name="TInput"/> that needs transforming.</typeparam>
/// <typeparam name="TSubInput3">The third field <see cref="Type"/> in <typeparamref name="TInput"/> that needs transforming.</typeparam>
/// <typeparam name="TSubOutput1">The first transformed <see cref="Type"/> of <typeparamref name="TSubInput1"/>.</typeparam>
/// <typeparam name="TSubOutput2">The second transformed <see cref="Type"/> of <typeparamref name="TSubInput2"/>.</typeparam>
/// <typeparam name="TSubOutput3">The third transformed <see cref="Type"/> of <typeparamref name="TSubInput2"/>.</typeparam>
/// <typeparam name="TTransformer1">The <see cref="ITransformer{TInput, TOutput}"/> for <typeparamref name="TSubInput1"/>/<typeparamref name="TSubOutput2"/>.</typeparam>
/// <typeparam name="TTransformer2">The <see cref="ITransformer{TInput, TOutput}"/> for <typeparamref name="TSubInput2"/>/<typeparamref name="TSubOutput2"/>.</typeparam>
/// <typeparam name="TTransformer3">The <see cref="ITransformer{TInput, TOutput}"/> for <typeparamref name="TSubInput3"/>/<typeparamref name="TSubOutput3"/>.</typeparam>
/// <param name="transformerExpression">The <see cref="Expression{TDelegate}"/> to take a <typeparamref name="TInput"/>, <typeparamref name="TSubOutput1"/>, and <typeparamref name="TSubOutput1"/> and produce a <typeparamref name="TOutput"/>.</param>
/// <param name="subInput1SelectionExpression">The <see cref="Expression{TDelegate}"/> to select <typeparamref name="TSubInput1"/> from <typeparamref name="TInput"/>.</param>
/// <param name="subInput2SelectionExpression">The <see cref="Expression{TDelegate}"/> to select <typeparamref name="TSubInput2"/> from <typeparamref name="TInput"/>.</param>
/// <param name="subInput3SelectionExpression">The <see cref="Expression{TDelegate}"/> to select <typeparamref name="TSubInput3"/> from <typeparamref name="TInput"/>.</param>
/// <returns>An expression converting <typeparamref name="TInput"/> into <typeparamref name="TOutput"/> based on <paramref name="transformerExpression"/> with its other arguments generated from the transformation result of <paramref name="subInput1SelectionExpression"/>, <paramref name="subInput2SelectionExpression"/>, <paramref name="subInput3SelectionExpression"/>.</returns>
protected static Expression<Func<TInput, TOutput>> BuildSubProjection<
TSubInput1,
TSubInput2,
TSubInput3,
TSubOutput1,
TSubOutput2,
TSubOutput3,
TTransformer1,
TTransformer2,
TTransformer3>(
Expression<Func<TInput, TSubOutput1?, TSubOutput2?, TSubOutput3?, TOutput>> transformerExpression,
Expression<Func<TInput, TSubInput1?>> subInput1SelectionExpression,
Expression<Func<TInput, TSubInput2?>> subInput2SelectionExpression,
Expression<Func<TInput, TSubInput3?>> subInput3SelectionExpression)
where TSubOutput1 : class
where TSubOutput2 : class
where TSubOutput3 : class
where TTransformer1 : ITransformer<TSubInput1, TSubOutput1>, new()
where TTransformer2 : ITransformer<TSubInput2, TSubOutput2>, new()
where TTransformer3 : ITransformer<TSubInput3, TSubOutput3>, new()
{
throw new NotImplementedException();
}
/// <summary>
/// Initializes a new instance of the <see cref="TransformerBase{TInput, TOutput}"/> class.
/// </summary>
@@ -1,7 +1,6 @@
using System;
using Tgstation.Server.Api.Models.Response;
using Tgstation.Server.Host.GraphQL.Transformers;
namespace Tgstation.Server.Host.Models
{
@@ -9,8 +8,8 @@ namespace Tgstation.Server.Host.Models
/// Represents a <see cref="User"/> that has been updated.
/// </summary>
public sealed class UpdatedUser :
ILegacyApiTransformable<UserResponse>,
IApiTransformable<UpdatedUser, GraphQL.Types.UpdatedUser, UpdatedUserTransformer>
IApiTransformable<UpdatedUser, UserResponse>,
IApiTransformable<UpdatedUser, GraphQL.Types.UpdatedUser>
{
/// <summary>
/// The <see cref="User"/>'s <see cref="Api.Models.EntityId.Id"/>.
@@ -40,12 +39,5 @@ namespace Tgstation.Server.Host.Models
{
Id = id;
}
/// <inheritdoc />
public UserResponse ToApi()
=> User?.ToApi() ?? new UserResponse
{
Id = Id,
};
}
}
+3 -33
View File
@@ -1,19 +1,17 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using Tgstation.Server.Api.Models;
using Tgstation.Server.Api.Models.Response;
using Tgstation.Server.Host.GraphQL.Transformers;
namespace Tgstation.Server.Host.Models
{
/// <inheritdoc cref="Api.Models.Internal.UserModelBase" />
public sealed class User : Api.Models.Internal.UserModelBase,
ILegacyApiTransformable<UserResponse>,
IApiTransformable<User, GraphQL.Types.User, UserTransformer>,
IApiTransformable<User, GraphQL.Types.UserName, UserNameTransformer>
IApiTransformable<User, UserResponse>,
IApiTransformable<User, GraphQL.Types.User>,
IApiTransformable<User, GraphQL.Types.UserName>
{
/// <summary>
/// Username used when creating jobs automatically.
@@ -88,33 +86,5 @@ namespace Tgstation.Server.Host.Models
/// <param name="name">The <see cref="UserName.Name"/>.</param>
/// <returns>The <see cref="CanonicalName"/>.</returns>
public static string CanonicalizeName(string name) => name?.ToUpperInvariant() ?? throw new ArgumentNullException(nameof(name));
/// <inheritdoc />
public UserResponse ToApi() => CreateUserResponse(true);
/// <summary>
/// Generate a <see cref="UserResponse"/> from <see langword="this"/>.
/// </summary>
/// <param name="recursive">If we should recurse on <see cref="CreatedBy"/>.</param>
/// <returns>A new <see cref="UserResponse"/>.</returns>
UserResponse CreateUserResponse(bool recursive)
{
var result = CreateUserName<UserResponse>();
if (recursive)
result.CreatedBy = CreatedBy?.CreateUserName<UserName>();
result.CreatedAt = CreatedAt;
result.Enabled = Enabled;
result.SystemIdentifier = SystemIdentifier;
result.OAuthConnections = OAuthConnections
?.Select(x => x.ToApi())
.ToList();
result.OidcConnections = OidcConnections
?.Select(x => x.ToApi())
.ToList();
result.Group = Group?.ToApi(false);
result.PermissionSet = PermissionSet?.ToApi();
return result;
}
}
}
@@ -5,14 +5,13 @@ using System.Linq;
using Tgstation.Server.Api.Models;
using Tgstation.Server.Api.Models.Response;
using Tgstation.Server.Host.GraphQL.Transformers;
namespace Tgstation.Server.Host.Models
{
/// <summary>
/// Represents a group of <see cref="User"/>s.
/// </summary>
public sealed class UserGroup : NamedEntity, ILegacyApiTransformable<UserGroupResponse>, IApiTransformable<UserGroup, GraphQL.Types.UserGroup, UserGroupTransformer>
public sealed class UserGroup : NamedEntity, ILegacyApiTransformable<UserGroupResponse>, IApiTransformable<UserGroup, GraphQL.Types.UserGroup>
{
/// <summary>
/// The <see cref="Models.PermissionSet"/> the <see cref="UserGroup"/> has.
@@ -5,7 +5,7 @@ namespace Tgstation.Server.Host.Security
/// <summary>
/// Interface for accessing the current request's <see cref="ClaimsPrincipal"/>.
/// </summary>
interface IClaimsPrincipalAccessor
public interface IClaimsPrincipalAccessor
{
/// <summary>
/// Get the current <see cref="ClaimsPrincipal"/>.
@@ -26,7 +26,7 @@ namespace Tgstation.Server.Host.Security
/// </summary>
/// <param name="user">The <see cref="Models.User"/> to create the token for. Must have the <see cref="Api.Models.EntityId.Id"/> field available.</param>
/// <param name="serviceLogin">Whether or not this is an external service login.</param>
/// <returns>A new token <see cref="string"/>.</returns>
string CreateToken(Models.User user, bool serviceLogin);
/// <returns>A new token <see cref="string"/> and the <see cref="DateTimeOffset"/> that it expires.</returns>
(string Token, DateTimeOffset Expiry) CreateToken(Models.User user, bool serviceLogin);
}
}
@@ -101,7 +101,7 @@ namespace Tgstation.Server.Host.Security
}
/// <inheritdoc />
public string CreateToken(User user, bool serviceLogin)
public (string Token, DateTimeOffset Expiry) CreateToken(User user, bool serviceLogin)
{
ArgumentNullException.ThrowIfNull(user);
@@ -141,7 +141,7 @@ namespace Tgstation.Server.Host.Security
var tokenResponse = tokenHandler.WriteToken(securityToken);
return tokenResponse;
return (Token: tokenResponse, Expiry: expiry);
}
}
}
@@ -89,7 +89,7 @@ namespace Tgstation.Server.Host.Swarm.Tests
public TokenValidationParameters ValidationParameters => throw new NotSupportedException();
public string CreateToken(User user, bool serviceLogin)
public (string, DateTimeOffset) CreateToken(User user, bool serviceLogin)
{
throw new NotSupportedException();
}