This commit is contained in:
Jordan Dominion
2025-04-20 14:09:29 -04:00
parent 918fe7a364
commit fb0323cc5a
46 changed files with 1250 additions and 920 deletions
@@ -90,154 +90,163 @@ namespace Tgstation.Server.Host.Authority
}
/// <inheritdoc />
public async ValueTask<AuthorityResponse<AdministrationResponse>> GetUpdateInformation(bool forceFresh, CancellationToken cancellationToken)
{
try
{
async Task<AdministrationResponse> CacheFactory()
public RequirementsGated<AuthorityResponse<AdministrationResponse>> GetUpdateInformation(bool forceFresh, CancellationToken cancellationToken)
=> new(
() => Flag(AdministrationRights.ChangeVersion),
async () =>
{
Version? greatestVersion = null;
Uri? repoUrl = null;
var scopeCancellationToken = CancellationToken.None; // DCT: None available
try
{
var gitHubService = await gitHubServiceFactory.CreateService(scopeCancellationToken);
var repositoryUrlTask = gitHubService.GetUpdatesRepositoryUrl(scopeCancellationToken);
var releases = await gitHubService.GetTgsReleases(scopeCancellationToken);
foreach (var kvp in releases)
async Task<AdministrationResponse> CacheFactory()
{
var version = kvp.Key;
var release = kvp.Value;
if (version.Major > 3 // Forward/backward compatible but not before TGS4
&& (greatestVersion == null || version > greatestVersion))
greatestVersion = version;
Version? greatestVersion = null;
Uri? repoUrl = null;
var scopeCancellationToken = CancellationToken.None; // DCT: None available
try
{
var gitHubService = await gitHubServiceFactory.CreateService(scopeCancellationToken);
var repositoryUrlTask = gitHubService.GetUpdatesRepositoryUrl(scopeCancellationToken);
var releases = await gitHubService.GetTgsReleases(scopeCancellationToken);
foreach (var kvp in releases)
{
var version = kvp.Key;
var release = kvp.Value;
if (version.Major > 3 // Forward/backward compatible but not before TGS4
&& (greatestVersion == null || version > greatestVersion))
greatestVersion = version;
}
repoUrl = await repositoryUrlTask;
}
catch (NotFoundException e)
{
Logger.LogWarning(e, "Not found exception while retrieving upstream repository info!");
}
return new AdministrationResponse
{
LatestVersion = greatestVersion,
TrackedRepositoryUrl = repoUrl,
GeneratedAt = DateTimeOffset.UtcNow,
};
}
repoUrl = await repositoryUrlTask;
var ttl = TimeSpan.FromMinutes(30);
Task<AdministrationResponse> task;
if (forceFresh || !cacheService.TryGetValue(ReadCacheKey, out var rawCacheObject))
{
using var entry = cacheService.CreateEntry(ReadCacheKey);
entry.AbsoluteExpirationRelativeToNow = ttl;
entry.Value = task = CacheFactory();
}
else
task = (Task<AdministrationResponse>)rawCacheObject!;
var result = await task.WaitAsync(cancellationToken);
return new AuthorityResponse<AdministrationResponse>(result);
}
catch (NotFoundException e)
catch (RateLimitExceededException e)
{
Logger.LogWarning(e, "Not found exception while retrieving upstream repository info!");
return RateLimit<AdministrationResponse>(e);
}
return new AdministrationResponse
catch (ApiException e)
{
LatestVersion = greatestVersion,
TrackedRepositoryUrl = repoUrl,
GeneratedAt = DateTimeOffset.UtcNow,
};
}
var ttl = TimeSpan.FromMinutes(30);
Task<AdministrationResponse> task;
if (forceFresh || !cacheService.TryGetValue(ReadCacheKey, out var rawCacheObject))
{
using var entry = cacheService.CreateEntry(ReadCacheKey);
entry.AbsoluteExpirationRelativeToNow = ttl;
entry.Value = task = CacheFactory();
}
else
task = (Task<AdministrationResponse>)rawCacheObject!;
var result = await task.WaitAsync(cancellationToken);
return new AuthorityResponse<AdministrationResponse>(result);
}
catch (RateLimitExceededException e)
{
return RateLimit<AdministrationResponse>(e);
}
catch (ApiException e)
{
Logger.LogWarning(e, OctokitException);
return new AuthorityResponse<AdministrationResponse>(
new ErrorMessageResponse(ErrorCode.RemoteApiError)
{
AdditionalData = e.Message,
},
HttpFailureResponse.FailedDependency);
}
}
Logger.LogWarning(e, OctokitException);
return new AuthorityResponse<AdministrationResponse>(
new ErrorMessageResponse(ErrorCode.RemoteApiError)
{
AdditionalData = e.Message,
},
HttpFailureResponse.FailedDependency);
}
});
/// <inheritdoc />
public async ValueTask<AuthorityResponse<ServerUpdateResponse>> TriggerServerVersionChange(Version targetVersion, bool uploadZip, CancellationToken cancellationToken)
public RequirementsGated<AuthorityResponse<ServerUpdateResponse>> TriggerServerVersionChange(Version targetVersion, bool uploadZip, CancellationToken cancellationToken)
{
var attemptingUpload = uploadZip == true;
if (attemptingUpload)
{
if (!AuthenticationContext.PermissionSet.AdministrationRights!.Value.HasFlag(AdministrationRights.UploadVersion))
return Forbid<ServerUpdateResponse>();
}
else if (!AuthenticationContext.PermissionSet.AdministrationRights!.Value.HasFlag(AdministrationRights.ChangeVersion))
return Forbid<ServerUpdateResponse>();
if (targetVersion.Major < 4)
return BadRequest<ServerUpdateResponse>(ErrorCode.CannotChangeServerSuite);
if (!serverControl.WatchdogPresent)
return new AuthorityResponse<ServerUpdateResponse>(
new ErrorMessageResponse(ErrorCode.MissingHostWatchdog),
HttpFailureResponse.UnprocessableEntity);
IFileUploadTicket? uploadTicket = attemptingUpload
? fileTransferService.CreateUpload(FileUploadStreamKind.None)
: null;
ServerUpdateResult updateResult;
try
{
try
{
updateResult = await serverUpdateInitiator.InitiateUpdate(uploadTicket, targetVersion, cancellationToken);
}
catch
return new(
() =>
{
if (attemptingUpload)
await uploadTicket!.DisposeAsync();
return Flag(AdministrationRights.UploadVersion);
throw;
}
}
catch (RateLimitExceededException ex)
{
return RateLimit<ServerUpdateResponse>(ex);
}
catch (ApiException e)
{
Logger.LogWarning(e, OctokitException);
return new AuthorityResponse<ServerUpdateResponse>(
new ErrorMessageResponse(ErrorCode.RemoteApiError)
return Flag(AdministrationRights.ChangeVersion);
},
async () =>
{
if (targetVersion.Major < 4)
return BadRequest<ServerUpdateResponse>(ErrorCode.CannotChangeServerSuite);
if (!serverControl.WatchdogPresent)
return new AuthorityResponse<ServerUpdateResponse>(
new ErrorMessageResponse(ErrorCode.MissingHostWatchdog),
HttpFailureResponse.UnprocessableEntity);
IFileUploadTicket? uploadTicket = attemptingUpload
? fileTransferService.CreateUpload(FileUploadStreamKind.None)
: null;
ServerUpdateResult updateResult;
try
{
AdditionalData = e.Message,
},
HttpFailureResponse.FailedDependency);
}
try
{
updateResult = await serverUpdateInitiator.InitiateUpdate(uploadTicket, targetVersion, cancellationToken);
}
catch
{
if (attemptingUpload)
await uploadTicket!.DisposeAsync();
return updateResult switch
{
ServerUpdateResult.Started => new AuthorityResponse<ServerUpdateResponse>(new ServerUpdateResponse(targetVersion, uploadTicket?.Ticket.FileTicket), HttpSuccessResponse.Accepted),
ServerUpdateResult.ReleaseMissing => Gone<ServerUpdateResponse>(),
ServerUpdateResult.UpdateInProgress => BadRequest<ServerUpdateResponse>(ErrorCode.ServerUpdateInProgress),
ServerUpdateResult.SwarmIntegrityCheckFailed => new AuthorityResponse<ServerUpdateResponse>(
new ErrorMessageResponse(ErrorCode.SwarmIntegrityCheckFailed),
HttpFailureResponse.FailedDependency),
_ => throw new InvalidOperationException($"Unexpected ServerUpdateResult: {updateResult}"),
};
throw;
}
}
catch (RateLimitExceededException ex)
{
return RateLimit<ServerUpdateResponse>(ex);
}
catch (ApiException e)
{
Logger.LogWarning(e, OctokitException);
return new AuthorityResponse<ServerUpdateResponse>(
new ErrorMessageResponse(ErrorCode.RemoteApiError)
{
AdditionalData = e.Message,
},
HttpFailureResponse.FailedDependency);
}
return updateResult switch
{
ServerUpdateResult.Started => new AuthorityResponse<ServerUpdateResponse>(new ServerUpdateResponse(targetVersion, uploadTicket?.Ticket.FileTicket), HttpSuccessResponse.Accepted),
ServerUpdateResult.ReleaseMissing => Gone<ServerUpdateResponse>(),
ServerUpdateResult.UpdateInProgress => BadRequest<ServerUpdateResponse>(ErrorCode.ServerUpdateInProgress),
ServerUpdateResult.SwarmIntegrityCheckFailed => new AuthorityResponse<ServerUpdateResponse>(
new ErrorMessageResponse(ErrorCode.SwarmIntegrityCheckFailed),
HttpFailureResponse.FailedDependency),
_ => throw new InvalidOperationException($"Unexpected ServerUpdateResult: {updateResult}"),
};
});
}
/// <inheritdoc />
public async ValueTask<AuthorityResponse> TriggerServerRestart()
{
if (!serverControl.WatchdogPresent)
{
Logger.LogDebug("Restart request failed due to lack of host watchdog!");
return new AuthorityResponse(
new ErrorMessageResponse(ErrorCode.MissingHostWatchdog),
HttpFailureResponse.UnprocessableEntity);
}
public RequirementsGated<AuthorityResponse> TriggerServerRestart()
=> new(
() => Flag(AdministrationRights.RestartHost),
async () =>
{
if (!serverControl.WatchdogPresent)
{
Logger.LogDebug("Restart request failed due to lack of host watchdog!");
return new AuthorityResponse(
new ErrorMessageResponse(ErrorCode.MissingHostWatchdog),
HttpFailureResponse.UnprocessableEntity);
}
await serverControl.Restart();
return new AuthorityResponse();
}
await serverControl.Restart();
return new AuthorityResponse();
});
}
}
@@ -8,7 +8,7 @@ using Octokit;
using Tgstation.Server.Api.Models;
using Tgstation.Server.Api.Models.Response;
using Tgstation.Server.Host.Database;
using Tgstation.Server.Host.Security;
using Tgstation.Server.Host.Security.RightsEvaluation;
namespace Tgstation.Server.Host.Authority.Core
{
@@ -17,11 +17,6 @@ namespace Tgstation.Server.Host.Authority.Core
/// </summary>
abstract class AuthorityBase : IAuthority
{
/// <summary>
/// Gets the <see cref="IAuthenticationContext"/> for the <see cref="AuthorityBase"/>.
/// </summary>
protected IAuthenticationContext AuthenticationContext { get; }
/// <summary>
/// Gets the <see cref="IDatabaseContext"/> for the <see cref="AuthorityBase"/>.
/// </summary>
@@ -94,18 +89,47 @@ namespace Tgstation.Server.Host.Authority.Core
new ErrorMessageResponse(errorCode),
HttpFailureResponse.Conflict);
/// <summary>
/// Helper to quickly construct a <see cref="FlagRightsConditional{TRights}"/>.
/// </summary>
/// <typeparam name="TRights">The <typeparamref name="TRights"/> to evaluate.</typeparam>
/// <param name="flag">The single bit flag of the <typeparamref name="TRights"/>.</param>
/// <returns>A new <see cref="FlagRightsConditional{TRights}"/>.</returns>
protected static FlagRightsConditional<TRights> Flag<TRights>(TRights flag)
where TRights : Enum
=> new(flag);
/// <summary>
/// Helper to quickly construct an <see cref="OrRightsConditional{TRights}"/>.
/// </summary>
/// <typeparam name="TRights">The <typeparamref name="TRights"/> to evaluate.</typeparam>
/// <param name="lhs">The left hand side operand.</param>
/// <param name="rhs">The right hand side operand.</param>
/// <returns>A new <see cref="OrRightsConditional{TRights}"/>.</returns>
protected static OrRightsConditional<TRights> Or<TRights>(RightsConditional<TRights> lhs, RightsConditional<TRights> rhs)
where TRights : Enum
=> new(lhs, rhs);
/// <summary>
/// Helper to quickly construct an <see cref="AndRightsConditional{TRights}"/>.
/// </summary>
/// <typeparam name="TRights">The <typeparamref name="TRights"/> to evaluate.</typeparam>
/// <param name="lhs">The left hand side operand.</param>
/// <param name="rhs">The right hand side operand.</param>
/// <returns>A new <see cref="AndRightsConditional{TRights}"/>.</returns>
protected static AndRightsConditional<TRights> And<TRights>(RightsConditional<TRights> lhs, RightsConditional<TRights> rhs)
where TRights : Enum
=> new(lhs, rhs);
/// <summary>
/// Initializes a new instance of the <see cref="AuthorityBase"/> class.
/// </summary>
/// <param name="authenticationContext">The value of <see cref="AuthenticationContext"/>.</param>
/// <param name="databaseContext">The value of <see cref="DatabaseContext"/>.</param>
/// <param name="logger">The value of <see cref="Logger"/>.</param>
protected AuthorityBase(
IAuthenticationContext authenticationContext,
IDatabaseContext databaseContext,
ILogger<AuthorityBase> logger)
{
AuthenticationContext = authenticationContext ?? throw new ArgumentNullException(nameof(authenticationContext));
DatabaseContext = databaseContext ?? throw new ArgumentNullException(nameof(databaseContext));
Logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
@@ -1,7 +1,8 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Tgstation.Server.Api.Models;
using Tgstation.Server.Host.Security;
namespace Tgstation.Server.Host.Authority.Core
{
@@ -14,35 +15,49 @@ namespace Tgstation.Server.Host.Authority.Core
/// </summary>
protected TAuthority Authority { get; }
/// <summary>
/// The <see cref="Microsoft.AspNetCore.Authorization.IAuthorizationService"/> for the <see cref="AuthorityInvokerBase{TAuthority}"/>.
/// </summary>
readonly IAuthorizationService authorizationService;
/// <summary>
/// Initializes a new instance of the <see cref="AuthorityInvokerBase{TAuthority}"/> class.
/// </summary>
/// <param name="authority">The value of <see cref="Authority"/>.</param>
public AuthorityInvokerBase(TAuthority authority)
/// <param name="authorizationService">The value of <see cref="authorizationService"/>.</param>
public AuthorityInvokerBase(
TAuthority authority,
IAuthorizationService authorizationService)
{
Authority = authority ?? throw new ArgumentNullException(nameof(authority));
this.authorizationService = authorizationService ?? throw new ArgumentNullException(nameof(authorizationService));
}
/// <inheritdoc />
IQueryable<TResult> IAuthorityInvoker<TAuthority>.InvokeQueryable<TResult>(Func<TAuthority, IQueryable<TResult>> authorityInvoker)
async ValueTask<IQueryable<TResult>?> IAuthorityInvoker<TAuthority>.InvokeQueryable<TResult>(Func<TAuthority, RequirementsGated<IQueryable<TResult>>> authorityInvoker)
{
ArgumentNullException.ThrowIfNull(authorityInvoker);
return authorityInvoker(Authority);
var requirementsGate = authorityInvoker(Authority);
return await ExecuteIfRequirementsSatisfied(requirementsGate);
}
/// <inheritdoc />
IQueryable<TApiModel> IAuthorityInvoker<TAuthority>.InvokeTransformableQueryable<TResult, TApiModel, TTransformer>(Func<TAuthority, IQueryable<TResult>> authorityInvoker)
/// <summary>
/// Unwrap a <see cref="RequirementsGated{TResult}"/> result, returning <see langword="null"/> if the requirements weren't satisfied.
/// </summary>
/// <typeparam name="TResult">The <see cref="Type"/> contained by the <paramref name="requirementsGate"/>.</typeparam>
/// <param name="requirementsGate">The <see cref="RequirementsGated{TResult}"/> result.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <typeparamref name="TResult"/> if the requirements were met, <see langword="null"/> if the requirments weren't met.</returns>
protected async ValueTask<TResult?> ExecuteIfRequirementsSatisfied<TResult>(RequirementsGated<TResult> requirementsGate)
where TResult : class
{
ArgumentNullException.ThrowIfNull(authorityInvoker);
var requirements = await requirementsGate.GetRequirements();
var authorizationResult = await authorizationService.AuthorizeAsync(requirements);
var queryable = authorityInvoker(Authority);
if (!authorizationResult)
return null;
if (typeof(EntityId).IsAssignableFrom(typeof(TResult)))
queryable = queryable.OrderBy(item => ((EntityId)(object)item).Id!.Value); // order by ID to fix an EFCore warning
var expression = new TTransformer().Expression;
return queryable
.Select(expression);
return await requirementsGate.Execute(authorizationService);
}
}
}
@@ -1,7 +1,11 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Tgstation.Server.Api.Models;
using Tgstation.Server.Api.Models.Response;
using Tgstation.Server.Host.GraphQL;
using Tgstation.Server.Host.Security;
namespace Tgstation.Server.Host.Authority.Core
{
@@ -9,17 +13,30 @@ namespace Tgstation.Server.Host.Authority.Core
sealed class GraphQLAuthorityInvoker<TAuthority> : AuthorityInvokerBase<TAuthority>, IGraphQLAuthorityInvoker<TAuthority>
where TAuthority : IAuthority
{
/// <summary>
/// Create a new <see cref="ErrorMessageException"/> to be thrown when a forbidden error occurs.
/// </summary>
/// <returns>A new <see cref="ErrorMessageException"/>.</returns>
static ErrorMessageException ForbiddenGraphQLError()
=> new(new ErrorMessageResponse(), HttpFailureResponse.Forbidden.ToString());
/// <summary>
/// Throws a <see cref="ErrorMessageException"/> for errored <paramref name="authorityResponse"/>s.
/// </summary>
/// <param name="authorityResponse">The potentially errored <paramref name="authorityResponse"/>.</param>
/// <typeparam name="TAuthorityResponse">The <see cref="AuthorityResponse"/> <see cref="Type"/> being checked.</typeparam>
/// <param name="authorityResponse">The potentially errored <paramref name="authorityResponse"/> or <see langword="null"/> if requirements evaluation failed.</param>
/// <param name="errorOnMissing">If an error should be raised for <see cref="HttpFailureResponse.NotFound"/> and <see cref="HttpFailureResponse.Gone"/> failures.</param>
static void ThrowGraphQLErrorIfNecessary(AuthorityResponse authorityResponse, bool errorOnMissing)
/// <returns><paramref name="authorityResponse"/> if an <see cref="ErrorMessageException"/> wasn't thrown.</returns>
static TAuthorityResponse ThrowGraphQLErrorIfNecessary<TAuthorityResponse>(TAuthorityResponse? authorityResponse, bool errorOnMissing)
where TAuthorityResponse : AuthorityResponse
{
if (authorityResponse == null)
throw ForbiddenGraphQLError();
if (authorityResponse.Success
|| ((authorityResponse.FailureResponse.Value == HttpFailureResponse.NotFound
|| authorityResponse.FailureResponse.Value == HttpFailureResponse.Gone) && !errorOnMissing))
return;
return authorityResponse;
var fallbackString = authorityResponse.FailureResponse.ToString()!;
throw new ErrorMessageException(authorityResponse.ErrorMessage, fallbackString);
@@ -29,40 +46,42 @@ namespace Tgstation.Server.Host.Authority.Core
/// Initializes a new instance of the <see cref="GraphQLAuthorityInvoker{TAuthority}"/> class.
/// </summary>
/// <param name="authority">The <typeparamref name="TAuthority"/>.</param>
public GraphQLAuthorityInvoker(TAuthority authority)
: base(authority)
/// <param name="authorizationService">the <see cref="IAuthorizationService"/> to use.</param>
public GraphQLAuthorityInvoker(TAuthority authority, IAuthorizationService authorizationService)
: base(authority, authorizationService)
{
}
/// <inheritdoc />
async ValueTask IGraphQLAuthorityInvoker<TAuthority>.Invoke(Func<TAuthority, ValueTask<AuthorityResponse>> authorityInvoker)
async ValueTask IGraphQLAuthorityInvoker<TAuthority>.Invoke(Func<TAuthority, RequirementsGated<AuthorityResponse>> authorityInvoker)
{
ArgumentNullException.ThrowIfNull(authorityInvoker);
var authorityResponse = await authorityInvoker(Authority);
var requirementsGate = authorityInvoker(Authority);
var authorityResponse = await ExecuteIfRequirementsSatisfied(requirementsGate);
ThrowGraphQLErrorIfNecessary(authorityResponse, true);
}
/// <inheritdoc />
async ValueTask<TApiModel?> IGraphQLAuthorityInvoker<TAuthority>.InvokeAllowMissing<TResult, TApiModel>(Func<TAuthority, ValueTask<AuthorityResponse<TResult>>> authorityInvoker)
async ValueTask<TApiModel?> IGraphQLAuthorityInvoker<TAuthority>.InvokeAllowMissing<TResult, TApiModel>(Func<TAuthority, RequirementsGated<AuthorityResponse<TResult>>> authorityInvoker)
where TApiModel : default
{
ArgumentNullException.ThrowIfNull(authorityInvoker);
var authorityResponse = await authorityInvoker(Authority);
ThrowGraphQLErrorIfNecessary(authorityResponse, false);
return authorityResponse.Result;
var requirementsGate = authorityInvoker(Authority);
var authorityResponse = await ExecuteIfRequirementsSatisfied(requirementsGate);
return ThrowGraphQLErrorIfNecessary(authorityResponse, false).Result;
}
/// <inheritdoc />
async ValueTask<TApiModel?> IGraphQLAuthorityInvoker<TAuthority>.InvokeTransformableAllowMissing<TResult, TApiModel, TTransformer>(Func<TAuthority, ValueTask<AuthorityResponse<TResult>>> authorityInvoker)
async ValueTask<TApiModel?> IGraphQLAuthorityInvoker<TAuthority>.InvokeTransformableAllowMissing<TResult, TApiModel, TTransformer>(Func<TAuthority, RequirementsGated<AuthorityResponse<TResult>>> authorityInvoker)
where TApiModel : default
{
ArgumentNullException.ThrowIfNull(authorityInvoker);
var authorityResponse = await authorityInvoker(Authority);
ThrowGraphQLErrorIfNecessary(authorityResponse, false);
var result = authorityResponse.Result;
var requirementsGate = authorityInvoker(Authority);
var authorityResponse = await ExecuteIfRequirementsSatisfied(requirementsGate);
var result = ThrowGraphQLErrorIfNecessary(authorityResponse, false).Result;
if (result == null)
return default;
@@ -70,11 +89,33 @@ namespace Tgstation.Server.Host.Authority.Core
}
/// <inheritdoc />
ValueTask<TApiModel> IGraphQLAuthorityInvoker<TAuthority>.Invoke<TResult, TApiModel>(Func<TAuthority, ValueTask<AuthorityResponse<TResult>>> authorityInvoker)
async ValueTask<IQueryable<TApiModel>> IGraphQLAuthorityInvoker<TAuthority>.InvokeTransformableQueryable<TResult, TApiModel, TTransformer>(
Func<TAuthority, RequirementsGated<IQueryable<TResult>>> authorityInvoker,
Func<IQueryable<TResult>, IQueryable<TResult>>? preTransformer)
{
ArgumentNullException.ThrowIfNull(authorityInvoker);
var requirementsGate = authorityInvoker(Authority);
var queryable = await ExecuteIfRequirementsSatisfied(requirementsGate)
?? throw ForbiddenGraphQLError();
if (preTransformer != null)
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
var expression = new TTransformer().Expression;
return queryable
.Select(expression);
}
/// <inheritdoc />
ValueTask<TApiModel> IGraphQLAuthorityInvoker<TAuthority>.Invoke<TResult, TApiModel>(Func<TAuthority, RequirementsGated<AuthorityResponse<TResult>>> authorityInvoker)
=> ((IGraphQLAuthorityInvoker<TAuthority>)this).InvokeAllowMissing<TResult, TApiModel>(authorityInvoker)!;
/// <inheritdoc />
ValueTask<TApiModel> IGraphQLAuthorityInvoker<TAuthority>.InvokeTransformable<TResult, TApiModel, TTransformer>(Func<TAuthority, ValueTask<AuthorityResponse<TResult>>> authorityInvoker)
ValueTask<TApiModel> IGraphQLAuthorityInvoker<TAuthority>.InvokeTransformable<TResult, TApiModel, TTransformer>(Func<TAuthority, RequirementsGated<AuthorityResponse<TResult>>> authorityInvoker)
=> ((IGraphQLAuthorityInvoker<TAuthority>)this).InvokeTransformableAllowMissing<TResult, TApiModel, TTransformer>(authorityInvoker)!;
}
}
@@ -1,7 +1,6 @@
using System;
using System.Linq;
using Tgstation.Server.Host.Models;
using System.Threading.Tasks;
namespace Tgstation.Server.Host.Authority.Core
{
@@ -16,21 +15,8 @@ namespace Tgstation.Server.Host.Authority.Core
/// Invoke a <typeparamref name="TAuthority"/> method and get the result.
/// </summary>
/// <typeparam name="TResult">The returned <see cref="Type"/>.</typeparam>
/// <param name="authorityInvoker">The <typeparamref name="TAuthority"/> <see cref="Func{T, TResult}"/> returning a <see cref="IQueryable{T}"/> <typeparamref name="TResult"/>.</param>
/// <returns>A <see cref="IQueryable{T}"/> <typeparamref name="TResult"/> returned.</returns>
IQueryable<TResult> InvokeQueryable<TResult>(Func<TAuthority, IQueryable<TResult>> authorityInvoker);
/// <summary>
/// Invoke a <typeparamref name="TAuthority"/> method and get the transformed result.
/// </summary>
/// <typeparam name="TResult">The <see cref="Type"/> returned by the <typeparamref name="TAuthority"/>.</typeparam>
/// <typeparam name="TApiModel">The returned <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="authorityInvoker">The <typeparamref name="TAuthority"/> <see cref="Func{T, TResult}"/> returning a <see cref="IQueryable{T}"/> <typeparamref name="TResult"/>.</param>
/// <returns>A <see cref="IQueryable{T}"/> <typeparamref name="TResult"/> returned.</returns>
IQueryable<TApiModel> InvokeTransformableQueryable<TResult, TApiModel, TTransformer>(Func<TAuthority, IQueryable<TResult>> authorityInvoker)
where TResult : IApiTransformable<TResult, TApiModel, TTransformer>
where TApiModel : notnull
where TTransformer : ITransformer<TResult, TApiModel>, new();
/// <param name="authorityInvoker">The authority invocation returning a <see cref="IQueryable{T}"/> <typeparamref name="TResult"/>.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="IQueryable{T}"/> <typeparamref name="TResult"/> returned on success or <see langword="null"/> if the requirements weren't satisfied.</returns>
ValueTask<IQueryable<TResult>?> InvokeQueryable<TResult>(Func<TAuthority, RequirementsGated<IQueryable<TResult>>> authorityInvoker);
}
}
@@ -0,0 +1,148 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Tgstation.Server.Host.Security;
namespace Tgstation.Server.Host.Authority.Core
{
/// <summary>
/// Evaluates a set of <see cref="IAuthorizationRequirement"/>s to be checked before executing a response.
/// </summary>
/// <typeparam name="TResult">The <see cref="Type"/> of object the response generates.</typeparam>
public sealed class RequirementsGated<TResult>
{
/// <summary>
/// The <see cref="IAuthorizationRequirement"/> retrieval function. <see cref="UserSessionValidRequirement"/> is included automatically.
/// </summary>
readonly Func<ValueTask<IEnumerable<IAuthorizationRequirement>>> getRequirements;
/// <summary>
/// The response generation function.
/// </summary>
readonly Func<Security.IAuthorizationService, ValueTask<TResult>> getResponse;
/// <summary>
/// Convert a given <paramref name="result"/> into a <see cref="RequirementsGated{TResult}"/>.
/// </summary>
/// <param name="result">The <typeparamref name="TResult"/> to convert.</param>
/// <returns>A new <see cref="RequirementsGated{TResult}"/> based on <paramref name="result"/>.</returns>
#pragma warning disable CA1000 // Do not declare static members on generic types
public static RequirementsGated<TResult> FromResult(TResult result)
#pragma warning restore CA1000 // Do not declare static members on generic types
=> new(
() => (IAuthorizationRequirement?)null,
() => ValueTask.FromResult(result));
/// <summary>
/// Initializes a new instance of the <see cref="RequirementsGated{TResult}"/> class.
/// </summary>
/// <param name="getRequirement">The value of <see cref="getRequirements"/>. Resulting in a <see langword="null"/> value is eqivalent to returning an empty <see cref="IEnumerable{T}"/> of <see cref="IAuthorizationRequirement"/>s.</param>
/// <param name="getResponse">The value of <see cref="getResponse"/>.</param>
public RequirementsGated(
Func<ValueTask<IAuthorizationRequirement?>> getRequirement,
Func<ValueTask<TResult>> getResponse)
{
ArgumentNullException.ThrowIfNull(getRequirement);
ArgumentNullException.ThrowIfNull(getResponse);
getRequirements = async () =>
{
var requirement = await getRequirement();
if (requirement == null)
return Enumerable.Empty<IAuthorizationRequirement>();
return new List<IAuthorizationRequirement>
{
requirement,
};
};
this.getResponse = _ => getResponse();
}
/// <summary>
/// Initializes a new instance of the <see cref="RequirementsGated{TResult}"/> class.
/// </summary>
/// <param name="getRequirements">The value of <see cref="getRequirements"/>.</param>
/// <param name="getResponse">The value of <see cref="getResponse"/>.</param>
public RequirementsGated(
Func<IEnumerable<IAuthorizationRequirement>> getRequirements,
Func<ValueTask<TResult>> getResponse)
{
ArgumentNullException.ThrowIfNull(getRequirements);
ArgumentNullException.ThrowIfNull(getResponse);
this.getRequirements = () => ValueTask.FromResult(getRequirements());
this.getResponse = _ => getResponse();
}
/// <summary>
/// Initializes a new instance of the <see cref="RequirementsGated{TResult}"/> class.
/// </summary>
/// <param name="getRequirement">The value of <see cref="getRequirements"/>. Resulting in a <see langword="null"/> value is eqivalent to returning an empty <see cref="IEnumerable{T}"/> of <see cref="IAuthorizationRequirement"/>s.</param>
/// <param name="getResponse">The value of <see cref="getResponse"/>.</param>
public RequirementsGated(
Func<IAuthorizationRequirement?> getRequirement,
Func<ValueTask<TResult>> getResponse)
{
ArgumentNullException.ThrowIfNull(getRequirement);
ArgumentNullException.ThrowIfNull(getResponse);
getRequirements = () =>
{
var requirement = getRequirement();
if (requirement == null)
return ValueTask.FromResult(Enumerable.Empty<IAuthorizationRequirement>());
return ValueTask.FromResult<IEnumerable<IAuthorizationRequirement>>(
new List<IAuthorizationRequirement>
{
requirement,
});
};
this.getResponse = _ => getResponse();
}
/// <summary>
/// Initializes a new instance of the <see cref="RequirementsGated{TResult}"/> class.
/// </summary>
/// <param name="getRequirement">The value of <see cref="getRequirements"/>. Resulting in a <see langword="null"/> value is eqivalent to returning an empty <see cref="IEnumerable{T}"/> of <see cref="IAuthorizationRequirement"/>s.</param>
/// <param name="getResponse">The value of <see cref="getResponse"/>.</param>
public RequirementsGated(
Func<IAuthorizationRequirement?> getRequirement,
Func<Security.IAuthorizationService, ValueTask<TResult>> getResponse)
{
ArgumentNullException.ThrowIfNull(getRequirement);
getRequirements = () =>
{
var requirement = getRequirement();
if (requirement == null)
return ValueTask.FromResult(Enumerable.Empty<IAuthorizationRequirement>());
return ValueTask.FromResult<IEnumerable<IAuthorizationRequirement>>(
new List<IAuthorizationRequirement>
{
requirement,
});
};
this.getResponse = getResponse ?? throw new ArgumentNullException(nameof(getResponse));
}
/// <summary>
/// Evaluates the <see cref="IAuthorizationRequirement"/>s of the request.
/// </summary>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="IAuthorizationRequirement"/>s for the request.</returns>
public async ValueTask<IEnumerable<IAuthorizationRequirement>> GetRequirements()
=> (await getRequirements()).Concat([new UserSessionValidRequirement()]);
/// <summary>
/// Executes the request.
/// </summary>
/// <param name="authorizationService">The authorization service to use.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the request <typeparamref name="TResult"/>.</returns>
public ValueTask<TResult> Execute(Security.IAuthorizationService authorizationService)
=> getResponse(authorizationService);
}
}
@@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Mvc;
using Tgstation.Server.Host.Controllers;
using Tgstation.Server.Host.Extensions;
using Tgstation.Server.Host.Security;
namespace Tgstation.Server.Host.Authority.Core
{
@@ -22,7 +23,10 @@ namespace Tgstation.Server.Host.Authority.Core
/// <returns>An <see cref="IActionResult"/> for the <paramref name="authorityResponse"/>.</returns>
/// <typeparam name="TResult">The result <see cref="Type"/> returned in the <paramref name="authorityResponse"/>.</typeparam>
/// <typeparam name="TApiModel">The REST API result model built from <paramref name="authorityResponse"/>.</typeparam>
static IActionResult CreateSuccessfulActionResult<TResult, TApiModel>(ApiController controller, Func<TResult, TApiModel> resultTransformer, AuthorityResponse<TResult> authorityResponse)
static IActionResult CreateSuccessfulActionResult<TResult, TApiModel>(
ApiController controller,
Func<TResult, TApiModel> resultTransformer,
AuthorityResponse<TResult> authorityResponse)
where TApiModel : notnull
{
if (authorityResponse.IsNoContent!.Value)
@@ -44,9 +48,14 @@ namespace Tgstation.Server.Host.Authority.Core
/// </summary>
/// <param name="controller">The <see cref="ApiController"/> to use.</param>
/// <param name="authorityResponse">The <see cref="AuthorityResponse"/>.</param>
/// <returns>An <see cref="IActionResult"/> if the <paramref name="authorityResponse"/> is not successful, <see langword="null"/> otherwise.</returns>
static IActionResult? CreateErroredActionResult(ApiController controller, AuthorityResponse authorityResponse)
/// <returns>An <see cref="IActionResult"/> if the <paramref name="authorityResponse"/> is not successful, <see langword="null"/> otherwise. If <see langword="null"/> is returned, <paramref name="authorityResponse"/> is not <see langword="null"/>.</returns>
static IActionResult? CreateErroredActionResult(
ApiController controller,
AuthorityResponse? authorityResponse)
{
if (authorityResponse == null)
return controller.Forbid();
if (authorityResponse.Success)
return null;
@@ -74,47 +83,51 @@ namespace Tgstation.Server.Host.Authority.Core
/// Initializes a new instance of the <see cref="RestAuthorityInvoker{TAuthority}"/> class.
/// </summary>
/// <param name="authority">The <typeparamref name="TAuthority"/>.</param>
public RestAuthorityInvoker(TAuthority authority)
: base(authority)
/// <param name="authorizationService">The <see cref="IAuthorizationService"/> to use.</param>
public RestAuthorityInvoker(TAuthority authority, IAuthorizationService authorizationService)
: base(authority, authorizationService)
{
}
/// <inheritdoc />
async ValueTask<IActionResult> IRestAuthorityInvoker<TAuthority>.Invoke(ApiController controller, Func<TAuthority, ValueTask<AuthorityResponse>> authorityInvoker)
async ValueTask<IActionResult> IRestAuthorityInvoker<TAuthority>.Invoke(ApiController controller, Func<TAuthority, RequirementsGated<AuthorityResponse>> authorityInvoker)
{
ArgumentNullException.ThrowIfNull(controller);
ArgumentNullException.ThrowIfNull(authorityInvoker);
var authorityResponse = await authorityInvoker(Authority);
var requirementsGate = authorityInvoker(Authority);
var authorityResponse = await ExecuteIfRequirementsSatisfied(requirementsGate);
return CreateErroredActionResult(controller, authorityResponse) ?? controller.NoContent();
}
/// <inheritdoc />
async ValueTask<IActionResult> IRestAuthorityInvoker<TAuthority>.Invoke<TResult, TApiModel>(ApiController controller, Func<TAuthority, ValueTask<AuthorityResponse<TResult>>> authorityInvoker)
async ValueTask<IActionResult> IRestAuthorityInvoker<TAuthority>.Invoke<TResult, TApiModel>(ApiController controller, Func<TAuthority, RequirementsGated<AuthorityResponse<TResult>>> authorityInvoker)
{
ArgumentNullException.ThrowIfNull(controller);
ArgumentNullException.ThrowIfNull(authorityInvoker);
var authorityResponse = await authorityInvoker(Authority);
var requirementsGate = authorityInvoker(Authority);
var authorityResponse = await ExecuteIfRequirementsSatisfied(requirementsGate);
var erroredResult = CreateErroredActionResult(controller, authorityResponse);
if (erroredResult != null)
return erroredResult;
return CreateSuccessfulActionResult(controller, result => result, authorityResponse);
return CreateSuccessfulActionResult(controller, result => result, authorityResponse!);
}
/// <inheritdoc />
async ValueTask<IActionResult> IRestAuthorityInvoker<TAuthority>.InvokeTransformable<TResult, TApiModel>(ApiController controller, Func<TAuthority, ValueTask<AuthorityResponse<TResult>>> authorityInvoker)
async ValueTask<IActionResult> IRestAuthorityInvoker<TAuthority>.InvokeTransformable<TResult, TApiModel>(ApiController controller, Func<TAuthority, RequirementsGated<AuthorityResponse<TResult>>> authorityInvoker)
{
ArgumentNullException.ThrowIfNull(controller);
ArgumentNullException.ThrowIfNull(authorityInvoker);
var authorityResponse = await authorityInvoker(Authority);
var requirementsGate = authorityInvoker(Authority);
var authorityResponse = await ExecuteIfRequirementsSatisfied(requirementsGate);
var erroredResult = CreateErroredActionResult(controller, authorityResponse);
if (erroredResult != null)
return erroredResult;
return CreateSuccessfulActionResult(controller, result => result.ToApi(), authorityResponse);
return CreateSuccessfulActionResult(controller, result => result.ToApi(), authorityResponse!);
}
}
}
@@ -1,6 +1,5 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Tgstation.Server.Api.Models.Response;
using Tgstation.Server.Api.Rights;
@@ -19,9 +18,9 @@ namespace Tgstation.Server.Host.Authority
/// </summary>
/// <param name="forceFresh">Bypass the caching that the authority performs for this request, forcing it to contact GitHub.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="AdministrationResponse"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
/// <returns>A <see cref="RequirementsGated{TResult}"/> <see cref="AdministrationResponse"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
[TgsAuthorize(AdministrationRights.ChangeVersion)]
ValueTask<AuthorityResponse<AdministrationResponse>> GetUpdateInformation(bool forceFresh, CancellationToken cancellationToken);
RequirementsGated<AuthorityResponse<AdministrationResponse>> GetUpdateInformation(bool forceFresh, CancellationToken cancellationToken);
/// <summary>
/// Triggers a restart of tgstation-server without terminating running game instances, setting its version to a given <paramref name="targetVersion"/>.
@@ -29,15 +28,15 @@ namespace Tgstation.Server.Host.Authority
/// <param name="targetVersion">The <see cref="Version"/> TGS will switch to upon reboot.</param>
/// <param name="uploadZip">If <see langword="true"/> a <see cref="FileTicketResponse.FileTicket"/> will be returned and the call must provide an uploaded zip file containing the update data to the file transfer service.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="ServerUpdateResponse"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
/// <returns>A <see cref="RequirementsGated{TResult}"/> <see cref="ServerUpdateResponse"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
[TgsAuthorize(AdministrationRights.ChangeVersion | AdministrationRights.UploadVersion)]
ValueTask<AuthorityResponse<ServerUpdateResponse>> TriggerServerVersionChange(Version targetVersion, bool uploadZip, CancellationToken cancellationToken);
RequirementsGated<AuthorityResponse<ServerUpdateResponse>> TriggerServerVersionChange(Version targetVersion, bool uploadZip, CancellationToken cancellationToken);
/// <summary>
/// Triggers a restart of tgstation-server without terminating running game instances.
/// </summary>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="AuthorityResponse"/>.</returns>
/// <returns>A <see cref="RequirementsGated{TResult}"/> <see cref="AuthorityResponse"/>.</returns>
[TgsAuthorize(AdministrationRights.RestartHost)]
ValueTask<AuthorityResponse> TriggerServerRestart();
RequirementsGated<AuthorityResponse> TriggerServerRestart();
}
}
@@ -1,4 +1,5 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Tgstation.Server.Host.Authority.Core;
@@ -10,24 +11,25 @@ namespace Tgstation.Server.Host.Authority
/// Invokes <typeparamref name="TAuthority"/>s from GraphQL endpoints.
/// </summary>
/// <typeparam name="TAuthority">The <see cref="IAuthority"/> invoked.</typeparam>
/// <remarks>We take the approach that fields should be non-nullable if that is the case under ideal circumstances. Authorization issues should throw.</remarks>
public interface IGraphQLAuthorityInvoker<TAuthority> : IAuthorityInvoker<TAuthority>
where TAuthority : IAuthority
{
/// <summary>
/// Invoke a <typeparamref name="TAuthority"/> method with no success result.
/// </summary>
/// <param name="authorityInvoker">The <typeparamref name="TAuthority"/> <see cref="Func{T, TResult}"/> returning a <see cref="ValueTask{TResult}"/> resulting in the <see cref="AuthorityResponse"/>.</param>
/// <param name="authorityInvoker">The <typeparamref name="TAuthority"/> <see cref="Func{T, TResult}"/> resulting in the <see cref="RequirementsGated{TResult}"/> <see cref="AuthorityResponse"/>.</param>
/// <returns>A <see cref="ValueTask"/> representing the running operation.</returns>
ValueTask Invoke(Func<TAuthority, ValueTask<AuthorityResponse>> authorityInvoker);
ValueTask Invoke(Func<TAuthority, RequirementsGated<AuthorityResponse>> authorityInvoker);
/// <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 resulting <see cref="Type"/> of the return value.</typeparam>
/// <param name="authorityInvoker">The <typeparamref name="TAuthority"/> <see cref="Func{T, TResult}"/> returning a <see cref="ValueTask{TResult}"/> resulting in the <see cref="AuthorityResponse{TResult}"/>.</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 <typeparamref name="TApiModel"/> generated for the resulting <see cref="AuthorityResponse{TResult}"/>.</returns>
ValueTask<TApiModel?> InvokeAllowMissing<TResult, TApiModel>(Func<TAuthority, ValueTask<AuthorityResponse<TResult>>> authorityInvoker)
ValueTask<TApiModel?> InvokeAllowMissing<TResult, TApiModel>(Func<TAuthority, RequirementsGated<AuthorityResponse<TResult>>> authorityInvoker)
where TResult : TApiModel
where TApiModel : notnull;
@@ -37,9 +39,9 @@ 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>
/// <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}"/> returning a <see cref="ValueTask{TResult}"/> resulting in the <see cref="AuthorityResponse{TResult}"/>.</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 <typeparamref name="TApiModel"/> generated for the resulting <see cref="AuthorityResponse{TResult}"/>.</returns>
ValueTask<TApiModel?> InvokeTransformableAllowMissing<TResult, TApiModel, TTransformer>(Func<TAuthority, ValueTask<AuthorityResponse<TResult>>> authorityInvoker)
ValueTask<TApiModel?> InvokeTransformableAllowMissing<TResult, TApiModel, TTransformer>(Func<TAuthority, RequirementsGated<AuthorityResponse<TResult>>> authorityInvoker)
where TResult : notnull, IApiTransformable<TResult, TApiModel, TTransformer>
where TApiModel : notnull
where TTransformer : ITransformer<TResult, TApiModel>, new();
@@ -49,9 +51,9 @@ namespace Tgstation.Server.Host.Authority
/// </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>
/// <param name="authorityInvoker">The <typeparamref name="TAuthority"/> <see cref="Func{T, TResult}"/> returning a <see cref="ValueTask{TResult}"/> resulting in the <see cref="AuthorityResponse{TResult}"/>.</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 <typeparamref name="TApiModel"/> generated for the resulting <see cref="AuthorityResponse{TResult}"/>.</returns>
ValueTask<TApiModel> Invoke<TResult, TApiModel>(Func<TAuthority, ValueTask<AuthorityResponse<TResult>>> authorityInvoker)
ValueTask<TApiModel> Invoke<TResult, TApiModel>(Func<TAuthority, RequirementsGated<AuthorityResponse<TResult>>> authorityInvoker)
where TResult : TApiModel
where TApiModel : notnull;
@@ -61,11 +63,27 @@ 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>
/// <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}"/> returning a <see cref="ValueTask{TResult}"/> resulting in the <see cref="AuthorityResponse{TResult}"/>.</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 <typeparamref name="TApiModel"/> generated for the resulting <see cref="AuthorityResponse{TResult}"/>.</returns>
ValueTask<TApiModel> InvokeTransformable<TResult, TApiModel, TTransformer>(Func<TAuthority, ValueTask<AuthorityResponse<TResult>>> authorityInvoker)
ValueTask<TApiModel> InvokeTransformable<TResult, TApiModel, TTransformer>(Func<TAuthority, RequirementsGated<AuthorityResponse<TResult>>> authorityInvoker)
where TResult : notnull, IApiTransformable<TResult, TApiModel, TTransformer>
where TApiModel : notnull
where TTransformer : ITransformer<TResult, TApiModel>, new();
/// <summary>
/// Invoke a <typeparamref name="TAuthority"/> method and get the transformed result.
/// </summary>
/// <typeparam name="TResult">The <see cref="Type"/> returned by the <typeparamref name="TAuthority"/>.</typeparam>
/// <typeparam name="TApiModel">The returned <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="authorityInvoker">The <typeparamref name="TAuthority"/> <see cref="Func{T, TResult}"/> returning a <see cref="IQueryable{T}"/> <typeparamref name="TResult"/>.</param>
/// <param name="preTransformer">Optional transformer for the <see cref="IQueryable{T}"/> run once it has been acquired.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="IQueryable{T}"/> <typeparamref name="TResult"/> returned on success or <see langword="null"/> if the requirements weren't satisfied.</returns>
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();
}
}
@@ -16,13 +16,13 @@ namespace Tgstation.Server.Host.Authority
/// </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>
ValueTask<AuthorityResponse<LoginResult>> AttemptLogin(CancellationToken cancellationToken);
RequirementsGated<AuthorityResponse<LoginResult>> AttemptLogin(CancellationToken cancellationToken);
/// <summary>
/// Attempt to login to an OAuth service with the current OAuth credentials.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in an <see cref="OAuthGatewayLoginResult"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
ValueTask<AuthorityResponse<OAuthGatewayLoginResult>> AttemptOAuthGatewayLogin(CancellationToken cancellationToken);
RequirementsGated<AuthorityResponse<OAuthGatewayLoginResult>> AttemptOAuthGatewayLogin(CancellationToken cancellationToken);
}
}
@@ -1,10 +1,8 @@
using System.Threading;
using System.Threading.Tasks;
using Tgstation.Server.Api.Rights;
using Tgstation.Server.Host.Authority.Core;
using Tgstation.Server.Host.Models;
using Tgstation.Server.Host.Security;
namespace Tgstation.Server.Host.Authority
{
@@ -20,7 +18,6 @@ namespace Tgstation.Server.Host.Authority
/// <param name="lookupType">The <see cref="PermissionSetLookupType"/> of <paramref name="id"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a <see cref="PermissionSet"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
[TgsAuthorize(AdministrationRights.ReadUsers)]
ValueTask<AuthorityResponse<PermissionSet>> GetId(long id, PermissionSetLookupType lookupType, CancellationToken cancellationToken);
RequirementsGated<AuthorityResponse<PermissionSet>> GetId(long id, PermissionSetLookupType lookupType, CancellationToken cancellationToken);
}
}
@@ -20,9 +20,9 @@ namespace Tgstation.Server.Host.Authority
/// Invoke a <typeparamref name="TAuthority"/> method with no success result.
/// </summary>
/// <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}"/> returning a <see cref="ValueTask{TResult}"/> resulting in the <see cref="AuthorityResponse"/>.</param>
/// <param name="authorityInvoker">The <typeparamref name="TAuthority"/> <see cref="Func{T, TResult}"/> resulting in the <see cref="RequirementsGated{TResult}"/> <see cref="AuthorityResponse"/>.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="IActionResult"/> generated for the resulting <see cref="AuthorityResponse"/>.</returns>
ValueTask<IActionResult> Invoke(ApiController controller, Func<TAuthority, ValueTask<AuthorityResponse>> authorityInvoker);
ValueTask<IActionResult> Invoke(ApiController controller, Func<TAuthority, RequirementsGated<AuthorityResponse>> authorityInvoker);
/// <summary>
/// Invoke a <typeparamref name="TAuthority"/> method and get the result.
@@ -30,9 +30,9 @@ 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 <see cref="IActionResult"/>.</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}"/> returning a <see cref="ValueTask{TResult}"/> resulting in the <see cref="AuthorityResponse{TResult}"/>.</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> Invoke<TResult, TApiModel>(ApiController controller, Func<TAuthority, ValueTask<AuthorityResponse<TResult>>> authorityInvoker)
ValueTask<IActionResult> Invoke<TResult, TApiModel>(ApiController controller, Func<TAuthority, RequirementsGated<AuthorityResponse<TResult>>> authorityInvoker)
where TResult : TApiModel
where TApiModel : notnull;
@@ -42,9 +42,9 @@ namespace Tgstation.Server.Host.Authority
/// <typeparam name="TResult">The <see cref="AuthorityResponse{TResult}.Result"/> <see cref="Type"/>.</typeparam>
/// <typeparam name="TApiModel">The returned REST <see cref="Type"/>.</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}"/> returning a <see cref="ValueTask{TResult}"/> resulting in the <see cref="AuthorityResponse{TResult}"/>.</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>(ApiController controller, Func<TAuthority, ValueTask<AuthorityResponse<TResult>>> authorityInvoker)
ValueTask<IActionResult> InvokeTransformable<TResult, TApiModel>(ApiController controller, Func<TAuthority, RequirementsGated<AuthorityResponse<TResult>>> authorityInvoker)
where TResult : notnull, ILegacyApiTransformable<TApiModel>
where TApiModel : notnull;
}
@@ -1,6 +1,5 @@
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Tgstation.Server.Api.Models;
using Tgstation.Server.Api.Models.Request;
@@ -20,9 +19,9 @@ namespace Tgstation.Server.Host.Authority
/// Gets the currently authenticated user.
/// </summary>
/// <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>
/// <returns>A <see cref="RequirementsGated{TResult}"/> <see cref="User"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
[TgsAuthorize]
ValueTask<AuthorityResponse<User>> Read(CancellationToken cancellationToken);
RequirementsGated<AuthorityResponse<User>> Read(CancellationToken cancellationToken);
/// <summary>
/// Gets the <see cref="User"/> with a given <paramref name="id"/>.
@@ -31,33 +30,33 @@ namespace Tgstation.Server.Host.Authority
/// <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="ValueTask{TResult}"/> resulting in a <see cref="User"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
/// <returns>A <see cref="RequirementsGated{TResult}"/> <see cref="User"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
[TgsAuthorize(AdministrationRights.ReadUsers)]
ValueTask<AuthorityResponse<User>> GetId(long id, bool includeJoins, bool allowSystemUser, CancellationToken cancellationToken);
RequirementsGated<AuthorityResponse<User>> GetId(long id, bool includeJoins, bool allowSystemUser, CancellationToken cancellationToken);
/// <summary>
/// Gets the <see cref="GraphQL.Types.OAuth.OAuthConnection"/>s for the <see cref="User"/> with a given <paramref name="userId"/>.
/// </summary>
/// <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.OAuth.OAuthConnection"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
ValueTask<AuthorityResponse<GraphQL.Types.OAuth.OAuthConnection[]>> OAuthConnections(long userId, CancellationToken cancellationToken);
/// <returns>A <see cref="RequirementsGated{TResult}"/> <see cref="global::System.Array"/> of <see cref="GraphQL.Types.OAuth.OAuthConnection"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
RequirementsGated<AuthorityResponse<GraphQL.Types.OAuth.OAuthConnection[]>> OAuthConnections(long userId, CancellationToken cancellationToken);
/// <summary>
/// Gets the <see cref="GraphQL.Types.OAuth.OidcConnection"/>s for the <see cref="User"/> with a given <paramref name="userId"/>.
/// </summary>
/// <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.OAuth.OidcConnection"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
ValueTask<AuthorityResponse<GraphQL.Types.OAuth.OidcConnection[]>> OidcConnections(long userId, CancellationToken cancellationToken);
/// <returns>A <see cref="RequirementsGated{TResult}"/> <see cref="global::System.Array"/> of <see cref="GraphQL.Types.OAuth.OidcConnection"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
RequirementsGated<AuthorityResponse<GraphQL.Types.OAuth.OidcConnection[]>> OidcConnections(long userId, CancellationToken cancellationToken);
/// <summary>
/// Gets all registered <see cref="User"/>s.
/// </summary>
/// <param name="includeJoins">If related entities should be loaded.</param>
/// <returns>A <see cref="IQueryable{T}"/> of <see cref="User"/>s.</returns>
/// <returns>A <see cref="RequirementsGated{TResult}"/> <see cref="IQueryable{T}"/> of <see cref="User"/>s.</returns>
[TgsAuthorize(AdministrationRights.ReadUsers)]
IQueryable<User> Queryable(bool includeJoins);
RequirementsGated<IQueryable<User>> Queryable(bool includeJoins);
/// <summary>
/// Creates a <see cref="User"/>.
@@ -65,9 +64,9 @@ namespace Tgstation.Server.Host.Authority
/// <param name="createRequest">The <see cref="UserCreateRequest"/>.</param>
/// <param name="needZeroLengthPasswordWithOAuthConnections">If a zero-length <see cref="UserUpdateRequest.Password"/> indicates and OAuth only user.</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>
/// <returns>A <see cref="RequirementsGated{TResult}"/> <see cref="AuthorityResponse{TResult}"/> for the created <see cref="User"/>.</returns>
[TgsAuthorize(AdministrationRights.WriteUsers)]
ValueTask<AuthorityResponse<User>> Create(
RequirementsGated<AuthorityResponse<User>> Create(
UserCreateRequest createRequest,
bool? needZeroLengthPasswordWithOAuthConnections,
CancellationToken cancellationToken);
@@ -77,8 +76,8 @@ namespace Tgstation.Server.Host.Authority
/// </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>
/// <returns>A <see cref="RequirementsGated{TResult}"/> <see cref="AuthorityResponse{TResult}"/> for the created <see cref="User"/>.</returns>
[TgsAuthorize(AdministrationRights.WriteUsers | AdministrationRights.EditOwnPassword | AdministrationRights.EditOwnServiceConnections)]
ValueTask<AuthorityResponse<User>> Update(UserUpdateRequest updateRequest, CancellationToken cancellationToken);
RequirementsGated<AuthorityResponse<User>> Update(UserUpdateRequest updateRequest, CancellationToken cancellationToken);
}
}
@@ -17,8 +17,9 @@ namespace Tgstation.Server.Host.Authority
/// <summary>
/// Gets the current <see cref="UserGroup"/>.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a <see cref="UserGroup"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
ValueTask<AuthorityResponse<UserGroup>> Read();
RequirementsGated<AuthorityResponse<UserGroup>> Read(CancellationToken cancellationToken);
/// <summary>
/// Gets the <see cref="UserGroup"/> with a given <paramref name="id"/>.
@@ -26,17 +27,17 @@ namespace Tgstation.Server.Host.Authority
/// <param name="id">The <see cref="Api.Models.EntityId.Id"/> of the <see cref="UserGroup"/>.</param>
/// <param name="includeJoins">If related entities should be loaded.</param>
/// <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>
/// <returns>A <see cref="RequirementsGated{TResult}"/> <see cref="User"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
[TgsAuthorize(AdministrationRights.ReadUsers)]
ValueTask<AuthorityResponse<UserGroup>> GetId(long id, bool includeJoins, CancellationToken cancellationToken);
RequirementsGated<AuthorityResponse<UserGroup>> GetId(long id, bool includeJoins, CancellationToken cancellationToken);
/// <summary>
/// Gets all registered <see cref="UserGroup"/>s.
/// </summary>
/// <param name="includeJoins">If related entities should be loaded.</param>
/// <returns>A <see cref="IQueryable{T}"/> of <see cref="UserGroup"/>s.</returns>
/// <returns>A <see cref="RequirementsGated{TResult}"/> <see cref="IQueryable{T}"/> of <see cref="UserGroup"/>s.</returns>
[TgsAuthorize(AdministrationRights.ReadUsers)]
IQueryable<UserGroup> Queryable(bool includeJoins);
RequirementsGated<IQueryable<UserGroup>> Queryable(bool includeJoins);
/// <summary>
/// Create a <see cref="UserGroup"/>.
@@ -44,9 +45,9 @@ namespace Tgstation.Server.Host.Authority
/// <param name="name">The created <see cref="UserGroup"/>'s <see cref="Api.Models.NamedEntity.Name"/>.</param>
/// <param name="permissionSet">The created <see cref="UserGroup"/>'s <see cref="UserGroup.PermissionSet"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a <see cref="UserGroup"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
/// <returns>A <see cref="RequirementsGated{TResult}"/> <see cref="UserGroup"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
[TgsAuthorize(AdministrationRights.WriteUsers)]
ValueTask<AuthorityResponse<UserGroup>> Create(string name, PermissionSet? permissionSet, CancellationToken cancellationToken);
RequirementsGated<AuthorityResponse<UserGroup>> Create(string name, PermissionSet? permissionSet, CancellationToken cancellationToken);
/// <summary>
/// Updates a <see cref="UserGroup"/>.
@@ -55,17 +56,17 @@ namespace Tgstation.Server.Host.Authority
/// <param name="newName">The optional new <see cref="Api.Models.NamedEntity.Name"/> for the <see cref="UserGroup"/>.</param>
/// <param name="newPermissionSet">The optional new <see cref="UserGroup.PermissionSet"/> for the <see cref="UserGroup"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a <see cref="UserGroup"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a <see cref="RequirementsGated{TResult}"/> <see cref="UserGroup"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
[TgsAuthorize(AdministrationRights.WriteUsers)]
ValueTask<AuthorityResponse<UserGroup>> Update(long id, string? newName, PermissionSet? newPermissionSet, CancellationToken cancellationToken);
RequirementsGated<AuthorityResponse<UserGroup>> Update(long id, string? newName, PermissionSet? newPermissionSet, CancellationToken cancellationToken);
/// <summary>
/// Deletes an empty <see cref="UserGroup"/>.
/// </summary>
/// <param name="id">The <see cref="Api.Models.EntityId.Id"/> of the <see cref="UserGroup"/> to delete.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask"/> representing the running operation.</returns>
/// <returns>A <see cref="RequirementsGated{TResult}"/> <see cref="AuthorityResponse"/> representing the running operation.</returns>
[TgsAuthorize(AdministrationRights.WriteUsers)]
ValueTask<AuthorityResponse> DeleteEmpty(long id, CancellationToken cancellationToken);
RequirementsGated<AuthorityResponse> DeleteEmpty(long id, CancellationToken cancellationToken);
}
}
@@ -3,6 +3,7 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
@@ -103,7 +104,6 @@ namespace Tgstation.Server.Host.Authority
/// <summary>
/// Initializes a new instance of the <see cref="LoginAuthority"/> class.
/// </summary>
/// <param name="authenticationContext">The <see cref="IAuthenticationContext"/> to use.</param>
/// <param name="databaseContext">The <see cref="IDatabaseContext"/> to use.</param>
/// <param name="logger">The <see cref="ILogger"/> to use.</param>
/// <param name="apiHeadersProvider">The value of <see cref="apiHeadersProvider"/>.</param>
@@ -115,7 +115,6 @@ namespace Tgstation.Server.Host.Authority
/// <param name="sessionInvalidationTracker">The value of <see cref="sessionInvalidationTracker"/>.</param>
/// <param name="securityConfigurationOptions">The <see cref="IOptions{TOptions}"/> containing the value of <see cref="securityConfiguration"/>.</param>
public LoginAuthority(
IAuthenticationContext authenticationContext,
IDatabaseContext databaseContext,
ILogger<LoginAuthority> logger,
IApiHeadersProvider apiHeadersProvider,
@@ -127,7 +126,6 @@ namespace Tgstation.Server.Host.Authority
ISessionInvalidationTracker sessionInvalidationTracker,
IOptions<SecurityConfiguration> securityConfigurationOptions)
: base(
authenticationContext,
databaseContext,
logger)
{
@@ -142,7 +140,44 @@ namespace Tgstation.Server.Host.Authority
}
/// <inheritdoc />
public async ValueTask<AuthorityResponse<LoginResult>> AttemptLogin(CancellationToken cancellationToken)
public RequirementsGated<AuthorityResponse<LoginResult>> AttemptLogin(CancellationToken cancellationToken)
=> new(
() => (IAuthorizationRequirement?)null,
() => AttemptLoginImpl(cancellationToken));
/// <inheritdoc />
public RequirementsGated<AuthorityResponse<OAuthGatewayLoginResult>> AttemptOAuthGatewayLogin(CancellationToken cancellationToken)
=> new(
() => (IAuthorizationRequirement?)null,
async () =>
{
var headers = apiHeadersProvider.ApiHeaders;
if (headers == null)
return GenerateHeadersExceptionResponse<OAuthGatewayLoginResult>(apiHeadersProvider.HeadersException!);
var oAuthProvider = headers.OAuthProvider;
if (!oAuthProvider.HasValue)
return BadRequest<OAuthGatewayLoginResult>(ErrorCode.BadHeaders);
var (errorResponse, oAuthResult) = await TryOAuthenticate<OAuthGatewayLoginResult>(headers, oAuthProvider.Value, false, cancellationToken);
if (errorResponse != null)
return errorResponse;
Logger.LogDebug("Generated {provider} OAuth AccessCode", oAuthProvider.Value);
return new(
new OAuthGatewayLoginResult
{
AccessCode = oAuthResult!.Value.AccessCode,
});
});
/// <summary>
/// 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)
{
// password and oauth logins disabled
if (securityConfiguration.OidcStrictMode)
@@ -278,30 +313,6 @@ namespace Tgstation.Server.Host.Authority
}
}
/// <inheritdoc />
public async ValueTask<AuthorityResponse<OAuthGatewayLoginResult>> AttemptOAuthGatewayLogin(CancellationToken cancellationToken)
{
var headers = apiHeadersProvider.ApiHeaders;
if (headers == null)
return GenerateHeadersExceptionResponse<OAuthGatewayLoginResult>(apiHeadersProvider.HeadersException!);
var oAuthProvider = headers.OAuthProvider;
if (!oAuthProvider.HasValue)
return BadRequest<OAuthGatewayLoginResult>(ErrorCode.BadHeaders);
var (errorResponse, oAuthResult) = await TryOAuthenticate<OAuthGatewayLoginResult>(headers, oAuthProvider.Value, false, cancellationToken);
if (errorResponse != null)
return errorResponse;
Logger.LogDebug("Generated {provider} OAuth AccessCode", oAuthProvider.Value);
return new AuthorityResponse<OAuthGatewayLoginResult>(
new OAuthGatewayLoginResult
{
AccessCode = oAuthResult!.Value.AccessCode,
});
}
/// <summary>
/// Add a given <paramref name="systemIdentity"/> to the <see cref="identityCache"/>.
/// </summary>
@@ -12,6 +12,7 @@ using Microsoft.Extensions.Logging;
using Tgstation.Server.Api.Rights;
using Tgstation.Server.Host.Authority.Core;
using Tgstation.Server.Host.Database;
using Tgstation.Server.Host.Extensions;
using Tgstation.Server.Host.Models;
using Tgstation.Server.Host.Security;
@@ -25,6 +26,11 @@ namespace Tgstation.Server.Host.Authority
/// </summary>
readonly IPermissionSetsDataLoader permissionSetsDataLoader;
/// <summary>
/// The <see cref="IClaimsPrincipalAccessor"/> for the <see cref="PermissionSetAuthority"/>.
/// </summary>
readonly IClaimsPrincipalAccessor claimsPrincipalAccessor;
/// <summary>
/// Implements <see cref="permissionSetsDataLoader"/>.
/// </summary>
@@ -84,34 +90,59 @@ namespace Tgstation.Server.Host.Authority
/// <summary>
/// Initializes a new instance of the <see cref="PermissionSetAuthority"/> class.
/// </summary>
/// <param name="authenticationContext">The <see cref="IAuthenticationContext"/> to use.</param>
/// <param name="databaseContext">The <see cref="IDatabaseContext"/> to use.</param>
/// <param name="logger">The <see cref="ILogger"/> to use.</param>
/// <param name="permissionSetsDataLoader">The value of <see cref="permissionSetsDataLoader"/>.</param>
/// <param name="claimsPrincipalAccessor">The value of <see cref="claimsPrincipalAccessor"/>.</param>
public PermissionSetAuthority(
IAuthenticationContext authenticationContext,
IDatabaseContext databaseContext,
ILogger<AuthorityBase> logger,
IPermissionSetsDataLoader permissionSetsDataLoader)
IPermissionSetsDataLoader permissionSetsDataLoader,
IClaimsPrincipalAccessor claimsPrincipalAccessor)
: base(
authenticationContext,
databaseContext,
logger)
{
this.permissionSetsDataLoader = permissionSetsDataLoader ?? throw new ArgumentNullException(nameof(permissionSetsDataLoader));
this.claimsPrincipalAccessor = claimsPrincipalAccessor ?? throw new ArgumentNullException(nameof(claimsPrincipalAccessor));
}
/// <inheritdoc />
public async ValueTask<AuthorityResponse<PermissionSet>> GetId(long id, PermissionSetLookupType lookupType, CancellationToken cancellationToken)
public RequirementsGated<AuthorityResponse<PermissionSet>> GetId(long id, PermissionSetLookupType lookupType, CancellationToken cancellationToken)
{
if (id != AuthenticationContext.PermissionSet.Id && !((AdministrationRights)AuthenticationContext.GetRight(RightsType.Administration)).HasFlag(AdministrationRights.ReadUsers))
return Forbid<PermissionSet>();
var permissionSetTask = permissionSetsDataLoader.LoadAsync((Id: id, LookupType: lookupType), cancellationToken);
return new(
async () =>
{
var userId = claimsPrincipalAccessor.User.GetTgsUserId();
var permissionSet = await permissionSetsDataLoader.LoadAsync((Id: id, LookupType: lookupType), cancellationToken);
if (permissionSet == null)
return NotFound<PermissionSet>();
var groupIdQuery = DatabaseContext
.Users
.AsQueryable()
.Where(user => user.Id == userId)
.Select(user => user.GroupId);
return new AuthorityResponse<PermissionSet>(permissionSet);
var permissionSetId = await DatabaseContext
.PermissionSets
.Where(permissionSet => permissionSet.UserId == userId
|| groupIdQuery.Contains(permissionSet.GroupId))
.Select(permissionSet => permissionSet.Id!.Value)
.FirstAsync(cancellationToken);
if (permissionSetId == id)
return null;
return Flag(AdministrationRights.ReadUsers);
},
async () =>
{
var permissionSet = await permissionSetTask;
if (permissionSet == null)
return NotFound<PermissionSet>();
return new AuthorityResponse<PermissionSet>(permissionSet);
});
}
}
}
@@ -9,6 +9,7 @@ using GreenDonut;
using HotChocolate.Subscriptions;
using Microsoft.AspNetCore.Authorization;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
@@ -22,9 +23,11 @@ using Tgstation.Server.Common.Extensions;
using Tgstation.Server.Host.Authority.Core;
using Tgstation.Server.Host.Configuration;
using Tgstation.Server.Host.Database;
using Tgstation.Server.Host.Extensions;
using Tgstation.Server.Host.Models;
using Tgstation.Server.Host.Models.Transformers;
using Tgstation.Server.Host.Security;
using Tgstation.Server.Host.Security.RightsEvaluation;
namespace Tgstation.Server.Host.Authority
{
@@ -71,6 +74,11 @@ namespace Tgstation.Server.Host.Authority
/// </summary>
readonly ITopicEventSender topicEventSender;
/// <summary>
/// The <see cref="IClaimsPrincipalAccessor"/> for the <see cref="UserAuthority"/>.
/// </summary>
readonly IClaimsPrincipalAccessor claimsPrincipalAccessor;
/// <summary>
/// The <see cref="IOptionsSnapshot{TOptions}"/> of <see cref="GeneralConfiguration"/> for the <see cref="UserAuthority"/>.
/// </summary>
@@ -179,7 +187,6 @@ namespace Tgstation.Server.Host.Authority
/// <summary>
/// Initializes a new instance of the <see cref="UserAuthority"/> class.
/// </summary>
/// <param name="authenticationContext">The <see cref="IAuthenticationContext"/> to use.</param>
/// <param name="databaseContext">The <see cref="IDatabaseContext"/> to use.</param>
/// <param name="logger">The <see cref="ILogger"/> to use.</param>
/// <param name="usersDataLoader">The value of <see cref="usersDataLoader"/>.</param>
@@ -190,10 +197,10 @@ namespace Tgstation.Server.Host.Authority
/// <param name="cryptographySuite">The value of <see cref="cryptographySuite"/>.</param>
/// <param name="sessionInvalidationTracker">The value of <see cref="sessionInvalidationTracker"/>.</param>
/// <param name="topicEventSender">The value of <see cref="topicEventSender"/>.</param>
/// <param name="claimsPrincipalAccessor">The value of <see cref="claimsPrincipalAccessor"/>.</param>
/// <param name="generalConfigurationOptions">The value of <see cref="generalConfigurationOptions"/>.</param>
/// <param name="securityConfigurationOptions">The value of <see cref="securityConfigurationOptions"/>.</param>
public UserAuthority(
IAuthenticationContext authenticationContext,
IDatabaseContext databaseContext,
ILogger<UserAuthority> logger,
IUsersDataLoader usersDataLoader,
@@ -204,10 +211,10 @@ namespace Tgstation.Server.Host.Authority
ICryptographySuite cryptographySuite,
ISessionInvalidationTracker sessionInvalidationTracker,
ITopicEventSender topicEventSender,
IClaimsPrincipalAccessor claimsPrincipalAccessor,
IOptionsSnapshot<GeneralConfiguration> generalConfigurationOptions,
IOptions<SecurityConfiguration> securityConfigurationOptions)
: base(
authenticationContext,
databaseContext,
logger)
{
@@ -219,6 +226,7 @@ namespace Tgstation.Server.Host.Authority
this.cryptographySuite = cryptographySuite ?? throw new ArgumentNullException(nameof(cryptographySuite));
this.sessionInvalidationTracker = sessionInvalidationTracker ?? throw new ArgumentNullException(nameof(sessionInvalidationTracker));
this.topicEventSender = topicEventSender ?? throw new ArgumentNullException(nameof(topicEventSender));
this.claimsPrincipalAccessor = claimsPrincipalAccessor ?? throw new ArgumentNullException(nameof(claimsPrincipalAccessor));
this.generalConfigurationOptions = generalConfigurationOptions ?? throw new ArgumentNullException(nameof(generalConfigurationOptions));
this.securityConfigurationOptions = securityConfigurationOptions ?? throw new ArgumentNullException(nameof(securityConfigurationOptions));
}
@@ -293,316 +301,359 @@ namespace Tgstation.Server.Host.Authority
}
/// <inheritdoc />
public ValueTask<AuthorityResponse<User>> Read(CancellationToken cancellationToken)
=> ValueTask.FromResult(new AuthorityResponse<User>(AuthenticationContext.User));
public RequirementsGated<AuthorityResponse<User>> Read(CancellationToken cancellationToken)
=> GetId(
claimsPrincipalAccessor.User.GetTgsUserId(),
false,
false,
cancellationToken);
/// <inheritdoc />
public async ValueTask<AuthorityResponse<User>> GetId(long id, bool includeJoins, bool allowSystemUser, CancellationToken cancellationToken)
{
if (id != AuthenticationContext.User.Id && !((AdministrationRights)AuthenticationContext.GetRight(RightsType.Administration)).HasFlag(AdministrationRights.ReadUsers))
return Forbid<User>();
public RequirementsGated<AuthorityResponse<User>> GetId(long id, bool includeJoins, bool allowSystemUser, CancellationToken cancellationToken)
=> new(
() =>
{
if (id != claimsPrincipalAccessor.User.GetTgsUserId())
return Enumerable.Empty<IAuthorizationRequirement>();
User? user;
if (includeJoins)
{
var queryable = Queryable(true, true);
return new List<IAuthorizationRequirement>
{
Flag(AdministrationRights.ReadUsers),
};
},
async () =>
{
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);
user = await queryable.FirstOrDefaultAsync(
dbModel => dbModel.Id == id,
cancellationToken);
}
else
user = await usersDataLoader.LoadAsync(id, cancellationToken);
if (user == default)
return NotFound<User>();
if (user == default)
return NotFound<User>();
if (!allowSystemUser && user.CanonicalName == User.CanonicalizeName(User.TgsSystemUserName))
return Forbid<User>();
if (!allowSystemUser && user.CanonicalName == User.CanonicalizeName(User.TgsSystemUserName))
return Forbid<User>();
return new AuthorityResponse<User>(user);
}
return new AuthorityResponse<User>(user);
});
/// <inheritdoc />
public IQueryable<User> Queryable(bool includeJoins)
=> Queryable(includeJoins, false);
public RequirementsGated<IQueryable<User>> Queryable(bool includeJoins)
=> new(
() => Flag(AdministrationRights.ReadUsers),
() => ValueTask.FromResult(Queryable(includeJoins, false)));
/// <inheritdoc />
public async ValueTask<AuthorityResponse<GraphQL.Types.OAuth.OAuthConnection[]>> OAuthConnections(long userId, CancellationToken cancellationToken)
=> new AuthorityResponse<GraphQL.Types.OAuth.OAuthConnection[]>(
await oAuthConnectionsDataLoader.LoadRequiredAsync(userId, cancellationToken));
public RequirementsGated<AuthorityResponse<GraphQL.Types.OAuth.OAuthConnection[]>> OAuthConnections(long userId, CancellationToken cancellationToken)
=> new(
() => claimsPrincipalAccessor.User.GetTgsUserId() != userId
? Flag(AdministrationRights.ReadUsers)
: null,
async () => new AuthorityResponse<GraphQL.Types.OAuth.OAuthConnection[]>(
await oAuthConnectionsDataLoader.LoadRequiredAsync(userId, cancellationToken)));
/// <inheritdoc />
public async ValueTask<AuthorityResponse<GraphQL.Types.OAuth.OidcConnection[]>> OidcConnections(long userId, CancellationToken cancellationToken)
=> new AuthorityResponse<GraphQL.Types.OAuth.OidcConnection[]>(
await oidcConnectionsDataLoader.LoadRequiredAsync(userId, cancellationToken));
public RequirementsGated<AuthorityResponse<GraphQL.Types.OAuth.OidcConnection[]>> OidcConnections(long userId, CancellationToken cancellationToken)
=> new(
() => claimsPrincipalAccessor.User.GetTgsUserId() != userId
? Flag(AdministrationRights.ReadUsers)
: null,
async () => new AuthorityResponse<GraphQL.Types.OAuth.OidcConnection[]>(
await oidcConnectionsDataLoader.LoadRequiredAsync(userId, cancellationToken)));
/// <inheritdoc />
public async ValueTask<AuthorityResponse<User>> Create(
public RequirementsGated<AuthorityResponse<User>> Create(
UserCreateRequest createRequest,
bool? needZeroLengthPasswordWithOAuthConnections,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(createRequest);
if (BadCreateRequestChecks(createRequest, needZeroLengthPasswordWithOAuthConnections, out var failResponse))
return failResponse;
var totalUsers = await DatabaseContext
.Users
.AsQueryable()
.CountAsync(cancellationToken);
if (totalUsers >= generalConfigurationOptions.Value.UserLimit)
return Conflict<User>(ErrorCode.UserLimitReached);
var dbUser = await CreateNewUserFromModel(createRequest, cancellationToken);
if (dbUser == null)
return Gone<User>();
if (createRequest.SystemIdentifier != null)
try
=> new(
() => Flag(AdministrationRights.WriteUsers),
async () =>
{
using var sysIdentity = await systemIdentityFactory.CreateSystemIdentity(dbUser, cancellationToken);
if (sysIdentity == null)
ArgumentNullException.ThrowIfNull(createRequest);
if (BadCreateRequestChecks(createRequest, needZeroLengthPasswordWithOAuthConnections, out var failResponse))
return failResponse;
var totalUsers = await DatabaseContext
.Users
.AsQueryable()
.CountAsync(cancellationToken);
if (totalUsers >= generalConfigurationOptions.Value.UserLimit)
return Conflict<User>(ErrorCode.UserLimitReached);
var dbUser = await CreateNewUserFromModel(
createRequest,
cancellationToken);
if (dbUser == 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
{
var hasZeroLengthPassword = createRequest.Password?.Length == 0;
var hasOAuthConnections = (createRequest.OAuthConnections?.Count > 0) == true;
// special case allow PasswordHash to be null by setting Password to "" if OAuthConnections are set
if (!(needZeroLengthPasswordWithOAuthConnections != false && hasZeroLengthPassword && hasOAuthConnections))
{
var result = TrySetPassword(dbUser, createRequest.Password!, true);
if (result != null)
return result;
}
}
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
{
var hasZeroLengthPassword = createRequest.Password?.Length == 0;
var hasOAuthConnections = (createRequest.OAuthConnections?.Count > 0) == true;
dbUser.CanonicalName = User.CanonicalizeName(dbUser.Name!);
// special case allow PasswordHash to be null by setting Password to "" if OAuthConnections are set
if (!(needZeroLengthPasswordWithOAuthConnections != false && hasZeroLengthPassword && hasOAuthConnections))
{
var result = TrySetPassword(dbUser, createRequest.Password!, true);
if (result != null)
return result;
}
}
DatabaseContext.Users.Add(dbUser);
dbUser.CanonicalName = User.CanonicalizeName(dbUser.Name!);
await DatabaseContext.Save(cancellationToken);
DatabaseContext.Users.Add(dbUser);
Logger.LogInformation("Created new user {name} ({id})", dbUser.Name, dbUser.Id);
await DatabaseContext.Save(cancellationToken);
await SendUserUpdatedTopics(dbUser);
Logger.LogInformation("Created new user {name} ({id})", dbUser.Name, dbUser.Id);
return new AuthorityResponse<User>(dbUser, HttpSuccessResponse.Created);
}
await SendUserUpdatedTopics(dbUser);
return new AuthorityResponse<User>(dbUser, HttpSuccessResponse.Created);
});
/// <inheritdoc />
#pragma warning disable CA1502
#pragma warning disable CA1506 // TODO: Decomplexify
public async ValueTask<AuthorityResponse<User>> Update(UserUpdateRequest model, CancellationToken cancellationToken)
public RequirementsGated<AuthorityResponse<User>> Update(UserUpdateRequest model, CancellationToken cancellationToken)
#pragma warning restore CA1502
#pragma warning restore CA1506
{
ArgumentNullException.ThrowIfNull(model);
if (!model.Id.HasValue || model.OAuthConnections?.Any(x => x == null) == true)
return BadRequest<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.EditOwnServiceConnections);
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.OidcConnections)
.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;
var invalidateSessions = false;
if (originalUserHasSid && originalUser.PasswordHash != null)
{
// cleanup from https://github.com/tgstation/tgstation-server/issues/1528
Logger.LogDebug("System user ID {userId}'s PasswordHash is polluted, updating database.", originalUser.Id);
originalUser.PasswordHash = null;
invalidateSessions = true;
}
if (model.SystemIdentifier != null && model.SystemIdentifier != originalUser.SystemIdentifier)
return BadRequest<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;
invalidateSessions = true;
}
if (model.Name != null && User.CanonicalizeName(model.Name) != originalUser.CanonicalName)
return BadRequest<User>(ErrorCode.UserNameChange);
if (model.OAuthConnections != null
&& (model.OAuthConnections.Count != originalUser.OAuthConnections!.Count
|| !model.OAuthConnections.All(x => originalUser.OAuthConnections.Any(y => y.Provider == x.Provider && y.ExternalUserId == x.ExternalUserId))))
{
if (securityConfigurationOptions.Value.OidcStrictMode)
return BadRequest<User>(ErrorCode.BadUserEditDueToOidcStrictMode);
if (originalUser.CanonicalName == User.CanonicalizeName(DefaultCredentials.AdminUserName))
return BadRequest<User>(ErrorCode.AdminUserCannotHaveServiceConnection);
if (model.OAuthConnections.Count == 0 && originalUser.PasswordHash == null && originalUser.SystemIdentifier == null)
return BadRequest<User>(ErrorCode.CannotRemoveLastAuthenticationOption);
DatabaseContext.OAuthConnections.RemoveRange(originalUser.OAuthConnections);
originalUser.OAuthConnections.Clear();
foreach (var updatedConnection in model.OAuthConnections)
originalUser.OAuthConnections.Add(new Models.OAuthConnection
{
Provider = updatedConnection.Provider,
ExternalUserId = updatedConnection.ExternalUserId,
});
}
if (model.OidcConnections != null
&& (model.OidcConnections.Count != originalUser.OidcConnections!.Count
|| !model.OidcConnections.All(x => originalUser.OidcConnections.Any(y => y.SchemeKey == x.SchemeKey && y.ExternalUserId == x.ExternalUserId))))
{
if (securityConfigurationOptions.Value.OidcStrictMode)
return BadRequest<User>(ErrorCode.BadUserEditDueToOidcStrictMode);
if (originalUser.CanonicalName == User.CanonicalizeName(DefaultCredentials.AdminUserName))
return BadRequest<User>(ErrorCode.AdminUserCannotHaveServiceConnection);
if (model.OidcConnections.Count == 0 && originalUser.PasswordHash == null && originalUser.SystemIdentifier == null)
return BadRequest<User>(ErrorCode.CannotRemoveLastAuthenticationOption);
DatabaseContext.OidcConnections.RemoveRange(originalUser.OidcConnections);
originalUser.OidcConnections.Clear();
foreach (var updatedConnection in model.OidcConnections)
originalUser.OidcConnections.Add(new Models.OidcConnection
{
SchemeKey = updatedConnection.SchemeKey,
ExternalUserId = updatedConnection.ExternalUserId,
});
}
if (model.Group != null)
{
if (securityConfigurationOptions.Value.OidcStrictMode)
return BadRequest<User>(ErrorCode.BadUserEditDueToOidcStrictMode);
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)
var userQuery = DatabaseContext
.Users
.AsQueryable()
.Where(x => x.Id == model.Id)
.Include(x => x.CreatedBy)
.Include(x => x.OAuthConnections)
.Include(x => x.OidcConnections)
.Include(x => x.Group!)
.ThenInclude(x => x.PermissionSet)
.Include(x => x.PermissionSet)
.FirstOrDefaultAsync(cancellationToken);
return new(
() =>
{
Logger.LogInformation("Deleting permission set {permissionSetId}...", originalUser.PermissionSet.Id);
DatabaseContext.PermissionSets.Remove(originalUser.PermissionSet);
originalUser.PermissionSet = null;
}
}
else if (model.PermissionSet != null)
{
if (securityConfigurationOptions.Value.OidcStrictMode)
return BadRequest<User>(ErrorCode.BadUserEditDueToOidcStrictMode);
RightsConditional<AdministrationRights>? conditional = null;
if (originalUser.PermissionSet == null)
// Ensure they are only trying to edit things they have perms for (system identity change will trigger a bad request)
if (model.OidcConnections != null || model.OAuthConnections != null)
conditional = Flag(AdministrationRights.EditOwnServiceConnections);
if (model.Password != null && model.Id == claimsPrincipalAccessor.User.GetTgsUserId())
{
var newFlag = Flag(AdministrationRights.EditOwnPassword);
if (conditional != null)
conditional = And(conditional, newFlag);
else
conditional = newFlag;
}
if (conditional != null)
conditional = Or(conditional, Flag(AdministrationRights.WriteUsers));
else if (model.Enabled.HasValue
|| model.Group != null
|| model.Name != null
|| model.PermissionSet != null)
conditional = Flag(AdministrationRights.WriteUsers);
return conditional;
},
async authorizationService =>
{
Logger.LogTrace("Creating new permission set...");
originalUser.PermissionSet = new Models.PermissionSet();
}
ArgumentNullException.ThrowIfNull(model);
originalUser.PermissionSet.AdministrationRights = model.PermissionSet.AdministrationRights ?? AdministrationRights.None;
originalUser.PermissionSet.InstanceManagerRights = model.PermissionSet.InstanceManagerRights ?? InstanceManagerRights.None;
if (!model.Id.HasValue || model.OAuthConnections?.Any(x => x == null) == true)
return BadRequest<User>(ErrorCode.ModelValidationFailure);
originalUser.Group = null;
originalUser.GroupId = null;
}
if (model.Group != null && model.PermissionSet != null)
return BadRequest<User>(ErrorCode.UserGroupAndPermissionSet);
var fail = CheckValidName(model, false);
if (fail != null)
return fail;
var originalUser = await userQuery;
originalUser.Name = model.Name ?? originalUser.Name;
if (originalUser == default)
return NotFound<User>();
if (model.Enabled.HasValue)
{
if (securityConfigurationOptions.Value.OidcStrictMode)
return BadRequest<User>(ErrorCode.BadUserEditDueToOidcStrictMode);
if (originalUser.CanonicalName == User.CanonicalizeName(User.TgsSystemUserName))
return Forbid<User>();
invalidateSessions = originalUser.Require(x => x.Enabled) && !model.Enabled.Value;
originalUser.Enabled = model.Enabled.Value;
}
var originalUserHasSid = originalUser.SystemIdentifier != null;
var invalidateSessions = false;
if (originalUserHasSid && originalUser.PasswordHash != null)
{
// cleanup from https://github.com/tgstation/tgstation-server/issues/1528
Logger.LogDebug("System user ID {userId}'s PasswordHash is polluted, updating database.", originalUser.Id);
originalUser.PasswordHash = null;
if (invalidateSessions)
sessionInvalidationTracker.UserModifiedInvalidateSessions(originalUser);
invalidateSessions = true;
}
await DatabaseContext.Save(cancellationToken);
if (model.SystemIdentifier != null && model.SystemIdentifier != originalUser.SystemIdentifier)
return BadRequest<User>(ErrorCode.UserSidChange);
Logger.LogInformation("Updated user {userName} ({userId})", originalUser.Name, originalUser.Id);
if (model.Password != null)
{
if (originalUserHasSid)
return BadRequest<User>(ErrorCode.UserMismatchPasswordSid);
if (invalidateSessions)
await permissionsUpdateNotifyee.UserDisabled(originalUser, cancellationToken);
var result = TrySetPassword(originalUser, model.Password, false);
if (result != null)
return result;
await SendUserUpdatedTopics(originalUser);
invalidateSessions = true;
}
// 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>();
if (model.Name != null && User.CanonicalizeName(model.Name) != originalUser.CanonicalName)
return BadRequest<User>(ErrorCode.UserNameChange);
if (model.OAuthConnections != null
&& (model.OAuthConnections.Count != originalUser.OAuthConnections!.Count
|| !model.OAuthConnections.All(x => originalUser.OAuthConnections.Any(y => y.Provider == x.Provider && y.ExternalUserId == x.ExternalUserId))))
{
if (securityConfigurationOptions.Value.OidcStrictMode)
return BadRequest<User>(ErrorCode.BadUserEditDueToOidcStrictMode);
if (originalUser.CanonicalName == User.CanonicalizeName(DefaultCredentials.AdminUserName))
return BadRequest<User>(ErrorCode.AdminUserCannotHaveServiceConnection);
if (model.OAuthConnections.Count == 0 && originalUser.PasswordHash == null && originalUser.SystemIdentifier == null)
return BadRequest<User>(ErrorCode.CannotRemoveLastAuthenticationOption);
DatabaseContext.OAuthConnections.RemoveRange(originalUser.OAuthConnections);
originalUser.OAuthConnections.Clear();
foreach (var updatedConnection in model.OAuthConnections)
originalUser.OAuthConnections.Add(new Models.OAuthConnection
{
Provider = updatedConnection.Provider,
ExternalUserId = updatedConnection.ExternalUserId,
});
}
if (model.OidcConnections != null
&& (model.OidcConnections.Count != originalUser.OidcConnections!.Count
|| !model.OidcConnections.All(x => originalUser.OidcConnections.Any(y => y.SchemeKey == x.SchemeKey && y.ExternalUserId == x.ExternalUserId))))
{
if (securityConfigurationOptions.Value.OidcStrictMode)
return BadRequest<User>(ErrorCode.BadUserEditDueToOidcStrictMode);
if (originalUser.CanonicalName == User.CanonicalizeName(DefaultCredentials.AdminUserName))
return BadRequest<User>(ErrorCode.AdminUserCannotHaveServiceConnection);
if (model.OidcConnections.Count == 0 && originalUser.PasswordHash == null && originalUser.SystemIdentifier == null)
return BadRequest<User>(ErrorCode.CannotRemoveLastAuthenticationOption);
DatabaseContext.OidcConnections.RemoveRange(originalUser.OidcConnections);
originalUser.OidcConnections.Clear();
foreach (var updatedConnection in model.OidcConnections)
originalUser.OidcConnections.Add(new Models.OidcConnection
{
SchemeKey = updatedConnection.SchemeKey,
ExternalUserId = updatedConnection.ExternalUserId,
});
}
if (model.Group != null)
{
if (securityConfigurationOptions.Value.OidcStrictMode)
return BadRequest<User>(ErrorCode.BadUserEditDueToOidcStrictMode);
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 (securityConfigurationOptions.Value.OidcStrictMode)
return BadRequest<User>(ErrorCode.BadUserEditDueToOidcStrictMode);
if (originalUser.PermissionSet == null)
{
Logger.LogTrace("Creating new permission set...");
originalUser.PermissionSet = new Models.PermissionSet();
}
originalUser.PermissionSet.AdministrationRights = model.PermissionSet.AdministrationRights ?? AdministrationRights.None;
originalUser.PermissionSet.InstanceManagerRights = model.PermissionSet.InstanceManagerRights ?? InstanceManagerRights.None;
originalUser.Group = null;
originalUser.GroupId = null;
}
var fail = CheckValidName(model, false);
if (fail != null)
return fail;
originalUser.Name = model.Name ?? originalUser.Name;
if (model.Enabled.HasValue)
{
if (securityConfigurationOptions.Value.OidcStrictMode)
return BadRequest<User>(ErrorCode.BadUserEditDueToOidcStrictMode);
invalidateSessions = originalUser.Require(x => x.Enabled) && !model.Enabled.Value;
originalUser.Enabled = model.Enabled.Value;
}
if (invalidateSessions)
sessionInvalidationTracker.UserModifiedInvalidateSessions(originalUser);
await DatabaseContext.Save(cancellationToken);
Logger.LogInformation("Updated user {userName} ({userId})", originalUser.Name, originalUser.Id);
if (invalidateSessions)
await permissionsUpdateNotifyee.UserDisabled(originalUser, cancellationToken);
await SendUserUpdatedTopics(originalUser);
// return id only if not a self update and cannot read users
var canReadBack = claimsPrincipalAccessor.User.GetTgsUserId() == originalUser.Id
|| await authorizationService.AuthorizeAsync(
[Flag(AdministrationRights.ReadUsers)]);
return canReadBack
? new AuthorityResponse<User>(originalUser)
: new AuthorityResponse<User>();
});
}
/// <summary>
@@ -672,10 +723,17 @@ namespace Tgstation.Server.Host.Authority
InstanceManagerRights = model.PermissionSet?.InstanceManagerRights ?? InstanceManagerRights.None,
};
var currentUser = new User
{
Id = claimsPrincipalAccessor.User.GetTgsUserId(),
};
DatabaseContext.Users.Attach(currentUser);
return new User
{
CreatedAt = DateTimeOffset.UtcNow,
CreatedBy = AuthenticationContext.User,
CreatedBy = currentUser,
Enabled = model.Enabled ?? false,
PermissionSet = permissionSet,
Group = group,
@@ -6,6 +6,7 @@ using System.Threading.Tasks;
using GreenDonut;
using Microsoft.AspNetCore.Authorization;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
@@ -16,6 +17,7 @@ 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.Extensions;
using Tgstation.Server.Host.Models;
using Tgstation.Server.Host.Security;
@@ -29,6 +31,11 @@ namespace Tgstation.Server.Host.Authority
/// </summary>
readonly IUserGroupsDataLoader userGroupsDataLoader;
/// <summary>
/// The <see cref="IClaimsPrincipalAccessor"/> for the <see cref="UserGroupAuthority"/>.
/// </summary>
readonly IClaimsPrincipalAccessor claimsPrincipalAccessor;
/// <summary>
/// The <see cref="IOptionsSnapshot{TOptions}"/> of the <see cref="GeneralConfiguration"/>.
/// </summary>
@@ -59,58 +66,181 @@ namespace Tgstation.Server.Host.Authority
/// <summary>
/// Initializes a new instance of the <see cref="UserGroupAuthority"/> class.
/// </summary>
/// <param name="authenticationContext">The <see cref="IAuthenticationContext"/> to use.</param>
/// <param name="databaseContext">The <see cref="IDatabaseContext"/> to use.</param>
/// <param name="logger">The <see cref="ILogger"/> to use.</param>
/// <param name="claimsPrincipalAccessor">The value of <see cref="claimsPrincipalAccessor"/>.</param>
/// <param name="userGroupsDataLoader">The value of <see cref="userGroupsDataLoader"/>.</param>
/// <param name="generalConfigurationOptions">The value of <see cref="generalConfigurationOptions"/>.</param>
public UserGroupAuthority(
IAuthenticationContext authenticationContext,
IDatabaseContext databaseContext,
ILogger<UserGroupAuthority> logger,
IUserGroupsDataLoader userGroupsDataLoader,
IClaimsPrincipalAccessor claimsPrincipalAccessor,
IOptionsSnapshot<GeneralConfiguration> generalConfigurationOptions)
: base(
authenticationContext,
databaseContext,
logger)
{
this.userGroupsDataLoader = userGroupsDataLoader ?? throw new ArgumentNullException(nameof(userGroupsDataLoader));
this.claimsPrincipalAccessor = claimsPrincipalAccessor ?? throw new ArgumentNullException(nameof(claimsPrincipalAccessor));
this.generalConfigurationOptions = generalConfigurationOptions ?? throw new ArgumentNullException(nameof(generalConfigurationOptions));
}
/// <inheritdoc />
public async ValueTask<AuthorityResponse<UserGroup>> GetId(long id, bool includeJoins, CancellationToken cancellationToken)
public RequirementsGated<AuthorityResponse<UserGroup>> GetId(long id, bool includeJoins, CancellationToken cancellationToken)
=> new(
() =>
{
if (id != claimsPrincipalAccessor.User.GetTgsUserId())
return Flag(AdministrationRights.ReadUsers);
return null;
},
async () =>
{
UserGroup? userGroup;
if (includeJoins)
userGroup = await QueryableImpl(true)
.Where(x => x.Id == id)
.FirstOrDefaultAsync(cancellationToken);
else
userGroup = await userGroupsDataLoader.LoadAsync(id, cancellationToken);
if (userGroup == null)
return Gone<UserGroup>();
return new AuthorityResponse<UserGroup>(userGroup);
});
/// <inheritdoc />
public RequirementsGated<AuthorityResponse<UserGroup>> Read(CancellationToken cancellationToken)
=> new(
() => (IAuthorizationRequirement?)null,
async () =>
{
var userId = claimsPrincipalAccessor.User.GetTgsUserId();
var group = await DatabaseContext
.Users
.AsQueryable()
.Where(user => user.Id == userId)
.Select(user => user.Group)
.FirstOrDefaultAsync(cancellationToken);
if (group == null)
return Gone<UserGroup>();
return new AuthorityResponse<UserGroup>(group);
});
/// <inheritdoc />
public RequirementsGated<IQueryable<UserGroup>> Queryable(bool includeJoins)
=> new(
() => Flag(AdministrationRights.ReadUsers),
() => ValueTask.FromResult(QueryableImpl(includeJoins)));
/// <inheritdoc />
public RequirementsGated<AuthorityResponse<UserGroup>> Create(string name, Models.PermissionSet? permissionSet, CancellationToken cancellationToken)
{
if (id != AuthenticationContext.User.GroupId && !((AdministrationRights)AuthenticationContext.GetRight(RightsType.Administration)).HasFlag(AdministrationRights.ReadUsers))
return Forbid<UserGroup>();
ArgumentNullException.ThrowIfNull(name);
return new(
() => Flag(AdministrationRights.WriteUsers),
async () =>
{
var totalGroups = await DatabaseContext
.Groups
.AsQueryable()
.CountAsync(cancellationToken);
if (totalGroups >= generalConfigurationOptions.Value.UserGroupLimit)
return Conflict<UserGroup>(ErrorCode.UserGroupLimitReached);
UserGroup? userGroup;
if (includeJoins)
userGroup = await Queryable(true)
.Where(x => x.Id == id)
.FirstOrDefaultAsync(cancellationToken);
else
userGroup = await userGroupsDataLoader.LoadAsync(id, cancellationToken);
var modelPermissionSet = new Models.PermissionSet
{
AdministrationRights = permissionSet?.AdministrationRights ?? AdministrationRights.None,
InstanceManagerRights = permissionSet?.InstanceManagerRights ?? InstanceManagerRights.None,
};
if (userGroup == null)
return Gone<UserGroup>();
var dbGroup = new UserGroup
{
Name = name,
PermissionSet = modelPermissionSet,
};
return new AuthorityResponse<UserGroup>(userGroup);
DatabaseContext.Groups.Add(dbGroup);
await DatabaseContext.Save(cancellationToken);
Logger.LogInformation("Created new user group {groupName} ({groupId})", dbGroup.Name, dbGroup.Id);
return new AuthorityResponse<UserGroup>(
dbGroup,
HttpSuccessResponse.Created);
});
}
/// <inheritdoc />
public ValueTask<AuthorityResponse<UserGroup>> Read()
{
var group = AuthenticationContext.User!.Group;
if (group == null)
return ValueTask.FromResult(Gone<UserGroup>());
public RequirementsGated<AuthorityResponse<UserGroup>> Update(long id, string? newName, Models.PermissionSet? newPermissionSet, CancellationToken cancellationToken)
=> new(
() => Flag(AdministrationRights.WriteUsers),
async () =>
{
var currentGroup = await DatabaseContext
.Groups
.AsQueryable()
.Where(x => x.Id == id)
.Include(x => x.PermissionSet)
.FirstOrDefaultAsync(cancellationToken);
return ValueTask.FromResult(new AuthorityResponse<UserGroup>(group));
}
if (currentGroup == default)
return Gone<UserGroup>();
if (newPermissionSet != null)
{
currentGroup.PermissionSet!.AdministrationRights = newPermissionSet.AdministrationRights ?? currentGroup.PermissionSet.AdministrationRights;
currentGroup.PermissionSet.InstanceManagerRights = newPermissionSet.InstanceManagerRights ?? currentGroup.PermissionSet.InstanceManagerRights;
}
currentGroup.Name = newName ?? currentGroup.Name;
await DatabaseContext.Save(cancellationToken);
return new AuthorityResponse<UserGroup>(currentGroup);
});
/// <inheritdoc />
public IQueryable<UserGroup> Queryable(bool includeJoins)
public RequirementsGated<AuthorityResponse> DeleteEmpty(long id, CancellationToken cancellationToken)
=> new(
() => Flag(AdministrationRights.WriteUsers),
async () =>
{
var numDeleted = await DatabaseContext
.Groups
.AsQueryable()
.Where(x => x.Id == id && x.Users!.Count == 0)
.ExecuteDeleteAsync(cancellationToken);
if (numDeleted > 0)
return new();
// find out how we failed
var groupExists = await DatabaseContext
.Groups
.AsQueryable()
.Where(x => x.Id == id)
.AnyAsync(cancellationToken);
return new(
groupExists
? new ErrorMessageResponse(ErrorCode.UserGroupNotEmpty)
: new ErrorMessageResponse(),
groupExists
? HttpFailureResponse.Conflict
: HttpFailureResponse.Gone);
});
/// <summary>
/// Get the <see cref="IQueryable{T}"/> <see cref="UserGroup"/>s.
/// </summary>
/// <param name="includeJoins">If <see cref="UserGroup.Users"/> and <see cref="UserGroup.PermissionSet"/> should be included.</param>
/// <returns>An <see cref="IQueryable{T}"/> of <see cref="UserGroup"/>s.</returns>
IQueryable<UserGroup> QueryableImpl(bool includeJoins)
{
var queryable = DatabaseContext
.Groups
@@ -123,92 +253,5 @@ namespace Tgstation.Server.Host.Authority
return queryable;
}
/// <inheritdoc />
public async ValueTask<AuthorityResponse<UserGroup>> Create(string name, Models.PermissionSet? permissionSet, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(name);
var totalGroups = await DatabaseContext
.Groups
.AsQueryable()
.CountAsync(cancellationToken);
if (totalGroups >= generalConfigurationOptions.Value.UserGroupLimit)
return Conflict<UserGroup>(ErrorCode.UserGroupLimitReached);
var modelPermissionSet = new Models.PermissionSet
{
AdministrationRights = permissionSet?.AdministrationRights ?? AdministrationRights.None,
InstanceManagerRights = permissionSet?.InstanceManagerRights ?? InstanceManagerRights.None,
};
var dbGroup = new UserGroup
{
Name = name,
PermissionSet = modelPermissionSet,
};
DatabaseContext.Groups.Add(dbGroup);
await DatabaseContext.Save(cancellationToken);
Logger.LogInformation("Created new user group {groupName} ({groupId})", dbGroup.Name, dbGroup.Id);
return new AuthorityResponse<UserGroup>(
dbGroup,
HttpSuccessResponse.Created);
}
/// <inheritdoc />
public async ValueTask<AuthorityResponse<UserGroup>> Update(long id, string? newName, Models.PermissionSet? newPermissionSet, CancellationToken cancellationToken)
{
var currentGroup = await DatabaseContext
.Groups
.AsQueryable()
.Where(x => x.Id == id)
.Include(x => x.PermissionSet)
.FirstOrDefaultAsync(cancellationToken);
if (currentGroup == default)
return Gone<UserGroup>();
if (newPermissionSet != null)
{
currentGroup.PermissionSet!.AdministrationRights = newPermissionSet.AdministrationRights ?? currentGroup.PermissionSet.AdministrationRights;
currentGroup.PermissionSet.InstanceManagerRights = newPermissionSet.InstanceManagerRights ?? currentGroup.PermissionSet.InstanceManagerRights;
}
currentGroup.Name = newName ?? currentGroup.Name;
await DatabaseContext.Save(cancellationToken);
return new AuthorityResponse<UserGroup>(currentGroup);
}
/// <inheritdoc />
public async ValueTask<AuthorityResponse> DeleteEmpty(long id, CancellationToken cancellationToken)
{
var numDeleted = await DatabaseContext
.Groups
.AsQueryable()
.Where(x => x.Id == id && x.Users!.Count == 0)
.ExecuteDeleteAsync(cancellationToken);
if (numDeleted > 0)
return new();
// find out how we failed
var groupExists = await DatabaseContext
.Groups
.AsQueryable()
.Where(x => x.Id == id)
.AnyAsync(cancellationToken);
return new(
groupExists
? new ErrorMessageResponse(ErrorCode.UserGroupNotEmpty)
: new ErrorMessageResponse(),
groupExists
? HttpFailureResponse.Conflict
: HttpFailureResponse.Gone);
}
}
}
@@ -249,7 +249,7 @@ namespace Tgstation.Server.Host.Controllers
/// <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<ValueTask<PaginatableResult<TModel>?>> queryGenerator,
Func<TModel, ValueTask>? resultTransformer,
int? pageQuery,
int? pageSizeQuery,
@@ -272,7 +272,7 @@ namespace Tgstation.Server.Host.Controllers
/// <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<ValueTask<PaginatableResult<TModel>?>> queryGenerator,
Func<TApiModel, ValueTask>? resultTransformer,
int? pageQuery,
int? pageSizeQuery,
@@ -290,14 +290,14 @@ namespace Tgstation.Server.Host.Controllers
/// </summary>
/// <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}"/>.</param>
/// <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="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<ValueTask<PaginatableResult<TModel>?>> queryGenerator,
Func<TResultModel, ValueTask>? resultTransformer,
int? pageQuery,
int? pageSizeQuery,
@@ -318,6 +318,9 @@ namespace Tgstation.Server.Host.Controllers
var page = pageQuery ?? 1;
var paginationResult = await queryGenerator();
if (paginationResult == null)
return Forbid();
if (!paginationResult.Valid)
return paginationResult.EarlyOut;
@@ -208,7 +208,7 @@ namespace Tgstation.Server.Host.Controllers
{
var connectionStrings = (AuthenticationContext.GetRight(RightsType.ChatBots) & (ulong)ChatBotRights.ReadConnectionString) != 0;
return Paginated<ChatBot, ChatBotResponse>(
() => ValueTask.FromResult(
() => ValueTask.FromResult<PaginatableResult<ChatBot>?>(
new PaginatableResult<ChatBot>(
DatabaseContext
.ChatBots
@@ -126,7 +126,7 @@ namespace Tgstation.Server.Host.Controllers
[ProducesResponseType(typeof(PaginatedResponse<CompileJobResponse>), 200)]
public ValueTask<IActionResult> List([FromQuery] int? page, [FromQuery] int? pageSize, CancellationToken cancellationToken)
=> Paginated<CompileJob, CompileJobResponse>(
() => ValueTask.FromResult(
() => ValueTask.FromResult<PaginatableResult<CompileJob>?>(
new PaginatableResult<CompileJob>(
BaseCompileJobsQuery()
.OrderByDescending(x => x.Job.StoppedAt))),
@@ -110,7 +110,7 @@ namespace Tgstation.Server.Host.Controllers
public ValueTask<IActionResult> List([FromQuery] int? page, [FromQuery] int? pageSize, CancellationToken cancellationToken)
=> WithComponentInstance(
instance => Paginated(
() => ValueTask.FromResult(
() => ValueTask.FromResult<PaginatableResult<EngineResponse>?>(
new PaginatableResult<EngineResponse>(
instance
.EngineManager
@@ -612,7 +612,7 @@ namespace Tgstation.Server.Host.Controllers
var needsUpdate = false;
var result = await Paginated<Models.Instance, InstanceResponse>(
() => ValueTask.FromResult(
() => ValueTask.FromResult<PaginatableResult<Models.Instance>?>(
new PaginatableResult<Models.Instance>(
GetBaseQuery()
.OrderBy(x => x.Id))),
@@ -197,7 +197,7 @@ namespace Tgstation.Server.Host.Controllers
[ProducesResponseType(typeof(PaginatedResponse<InstancePermissionSetResponse>), 200)]
public ValueTask<IActionResult> List([FromQuery] int? page, [FromQuery] int? pageSize, CancellationToken cancellationToken)
=> Paginated<InstancePermissionSet, InstancePermissionSetResponse>(
() => ValueTask.FromResult(
() => ValueTask.FromResult<PaginatableResult<InstancePermissionSet>?>(
new PaginatableResult<InstancePermissionSet>(
DatabaseContext
.Instances
@@ -71,7 +71,7 @@ namespace Tgstation.Server.Host.Controllers
[ProducesResponseType(typeof(PaginatedResponse<JobResponse>), 200)]
public ValueTask<IActionResult> Read([FromQuery] int? page, [FromQuery] int? pageSize, CancellationToken cancellationToken)
=> Paginated<Job, JobResponse>(
() => ValueTask.FromResult(
() => ValueTask.FromResult<PaginatableResult<Job>?>(
new PaginatableResult<Job>(
DatabaseContext
.Jobs
@@ -99,7 +99,7 @@ namespace Tgstation.Server.Host.Controllers
[ProducesResponseType(typeof(PaginatedResponse<JobResponse>), 200)]
public ValueTask<IActionResult> List([FromQuery] int? page, [FromQuery] int? pageSize, CancellationToken cancellationToken)
=> Paginated<Job, JobResponse>(
() => ValueTask.FromResult(
() => ValueTask.FromResult<PaginatableResult<Job>?>(
new PaginatableResult<Job>(
DatabaseContext
.Jobs
@@ -113,11 +113,15 @@ namespace Tgstation.Server.Host.Controllers
[ProducesResponseType(typeof(PaginatedResponse<UserResponse>), 200)]
public ValueTask<IActionResult> List([FromQuery] int? page, [FromQuery] int? pageSize, CancellationToken cancellationToken)
=> Paginated<User, UserResponse>(
() => ValueTask.FromResult(
new PaginatableResult<User>(
userAuthority.InvokeQueryable(
authority => authority.Queryable(true))
.OrderBy(x => x.Id))),
async () =>
{
var queryable = await userAuthority.InvokeQueryable(
authority => authority.Queryable(true));
if (queryable == null)
return null;
return new PaginatableResult<User>(queryable.OrderBy(x => x.Id));
},
null,
page,
pageSize,
@@ -146,11 +146,15 @@ namespace Tgstation.Server.Host.Controllers
[ProducesResponseType(typeof(PaginatedResponse<UserGroupResponse>), 200)]
public ValueTask<IActionResult> List([FromQuery] int? page, [FromQuery] int? pageSize, CancellationToken cancellationToken)
=> Paginated<UserGroup, UserGroupResponse>(
() => ValueTask.FromResult(
new PaginatableResult<UserGroup>(
userGroupAuthority
.InvokeQueryable(authority => authority.Queryable(true))
.OrderBy(x => x.Id))),
async () =>
{
var queryable = await userGroupAuthority
.InvokeQueryable(authority => authority.Queryable(true));
if (queryable == null)
return null;
return new PaginatableResult<UserGroup>(queryable.OrderBy(x => x.Id));
},
null,
page,
pageSize,
+11 -9
View File
@@ -330,15 +330,6 @@ namespace Tgstation.Server.Host.Core
services
.AddScoped<GraphQL.Subscriptions.ITopicEventReceiver, ShutdownAwareTopicEventReceiver>()
.AddGraphQLServer()
.AddAuthorization(
options =>
{
options.AddPolicy(
TgsAuthorizeAttribute.PolicyName,
builder => builder
.RequireAuthenticatedUser()
.RequireRole(TgsAuthorizeAttribute.UserEnabledRole));
})
.ModifyOptions(options =>
{
options.EnsureAllNodesCanBeResolved = true;
@@ -864,6 +855,17 @@ namespace Tgstation.Server.Host.Core
};
});
services.AddAuthorization(options =>
{
options.AddPolicy(
TgsAuthorizeAttribute.PolicyName,
builder => builder
.RequireAuthenticatedUser()
.RequireRole(TgsAuthorizeAttribute.UserEnabledRole));
options.DefaultPolicy = options.GetPolicy(TgsAuthorizeAttribute.PolicyName)!;
});
var oidcConfig = securityConfiguration.OpenIDConnect;
if (oidcConfig == null || oidcConfig.Count == 0)
return;
@@ -6,10 +6,8 @@ using HotChocolate;
using HotChocolate.Types;
using Tgstation.Server.Api.Models.Response;
using Tgstation.Server.Api.Rights;
using Tgstation.Server.Host.Authority;
using Tgstation.Server.Host.GraphQL.Scalars;
using Tgstation.Server.Host.Security;
namespace Tgstation.Server.Host.GraphQL.Mutations
{
@@ -25,7 +23,6 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
/// </summary>
/// <param name="administrationAuthority">The <see cref="IGraphQLAuthorityInvoker{TAuthority}"/> for the <see cref="IAdministrationAuthority"/>.</param>
/// <returns>A <see cref="ValueTask"/> representing the running operation.</returns>
[TgsGraphQLAuthorize<IAdministrationAuthority>(nameof(IAdministrationAuthority.TriggerServerRestart))]
[Error(typeof(ErrorMessageException))]
public async ValueTask<Query> RestartServerNode(
[Service] IGraphQLAuthorityInvoker<IAdministrationAuthority> administrationAuthority)
@@ -44,7 +41,6 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
/// <param name="administrationAuthority">The <see cref="IGraphQLAuthorityInvoker{TAuthority}"/> for the <see cref="IAdministrationAuthority"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask"/> representing the running operation.</returns>
[TgsGraphQLAuthorize(AdministrationRights.ChangeVersion)]
[Error(typeof(ErrorMessageException))]
public async ValueTask<Query> ChangeServerNodeVersionViaTrackedRepository(
Version targetVersion,
@@ -65,7 +61,6 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
/// <param name="administrationAuthority">The <see cref="IGraphQLAuthorityInvoker{TAuthority}"/> for the <see cref="IAdministrationAuthority"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A FileTicket that should be used to upload a zip containing the update data to the file transfer service.</returns>
[TgsGraphQLAuthorize(AdministrationRights.UploadVersion)]
[Error(typeof(ErrorMessageException))]
[GraphQLType<FileUploadTicketType>]
public async ValueTask<string> ChangeServerNodeVersionViaUpload(
@@ -10,7 +10,6 @@ using Tgstation.Server.Host.Authority;
using Tgstation.Server.Host.GraphQL.Mutations.Payloads;
using Tgstation.Server.Host.GraphQL.Types;
using Tgstation.Server.Host.Models.Transformers;
using Tgstation.Server.Host.Security;
namespace Tgstation.Server.Host.GraphQL.Mutations
{
@@ -43,7 +42,6 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
/// <param name="userGroupAuthority">The <see cref="IGraphQLAuthorityInvoker{TAuthority}"/> for the <see cref="IUserGroupAuthority"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>The created <see cref="UserGroup"/>.</returns>
[TgsGraphQLAuthorize<IUserGroupAuthority>(nameof(IUserGroupAuthority.Create))]
[Error(typeof(ErrorMessageException))]
public ValueTask<UserGroup> CreateUserGroup(
string name,
@@ -67,7 +65,6 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
/// <param name="userGroupAuthority">The <see cref="IGraphQLAuthorityInvoker{TAuthority}"/> for the <see cref="IUserGroupAuthority"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>The updated <see cref="UserGroup"/>.</returns>
[TgsGraphQLAuthorize<IUserGroupAuthority>(nameof(IUserGroupAuthority.Update))]
[Error(typeof(ErrorMessageException))]
public ValueTask<UserGroup> UpdateUserGroup(
[ID(nameof(UserGroup))] long id,
@@ -88,7 +85,6 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
/// <param name="userGroupAuthority">The <see cref="IGraphQLAuthorityInvoker{TAuthority}"/> for the <see cref="IUserGroupAuthority"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>The <see cref="Query"/> root.</returns>
[TgsGraphQLAuthorize<IUserGroupAuthority>(nameof(IUserGroupAuthority.DeleteEmpty))]
[Error(typeof(ErrorMessageException))]
public async ValueTask<Query> DeleteEmptyUserGroup(
[ID(nameof(UserGroup))] long id,
@@ -9,7 +9,6 @@ using HotChocolate.Types;
using HotChocolate.Types.Relay;
using Tgstation.Server.Api.Models.Request;
using Tgstation.Server.Api.Rights;
using Tgstation.Server.Host.Authority;
using Tgstation.Server.Host.GraphQL.Mutations.Payloads;
using Tgstation.Server.Host.GraphQL.Types;
@@ -38,7 +37,6 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
/// <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>The created <see cref="User"/>.</returns>
[TgsGraphQLAuthorize<IUserAuthority>(nameof(IUserAuthority.Create))]
[Error(typeof(ErrorMessageException))]
public ValueTask<User> CreateUserByPasswordAndPermissionSet(
string name,
@@ -99,7 +97,6 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
/// <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>The created <see cref="User"/>.</returns>
[TgsGraphQLAuthorize<IUserAuthority>(nameof(IUserAuthority.Create))]
[Error(typeof(ErrorMessageException))]
public ValueTask<User> CreateUserByPasswordAndGroup(
string name,
@@ -156,7 +153,6 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
/// <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>The created <see cref="User"/>.</returns>
[TgsGraphQLAuthorize<IUserAuthority>(nameof(IUserAuthority.Create))]
[Error(typeof(ErrorMessageException))]
public ValueTask<User> CreateUserByServiceConnectionAndPermissionSet(
string name,
@@ -215,7 +211,6 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
/// <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>The created <see cref="User"/>.</returns>
[TgsGraphQLAuthorize<IUserAuthority>(nameof(IUserAuthority.Create))]
[Error(typeof(ErrorMessageException))]
public ValueTask<User> CreateUserByServiceConnectionAndGroup(
string name,
@@ -271,7 +266,6 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
/// <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>The created <see cref="User"/>.</returns>
[TgsGraphQLAuthorize<IUserAuthority>(nameof(IUserAuthority.Create))]
[Error(typeof(ErrorMessageException))]
public ValueTask<User> CreateUserBySystemIDAndPermissionSet(
string systemIdentifier,
@@ -328,7 +322,6 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
/// <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>The created <see cref="User"/>.</returns>
[TgsGraphQLAuthorize<IUserAuthority>(nameof(IUserAuthority.Create))]
[Error(typeof(ErrorMessageException))]
public ValueTask<User> CreateUserBySystemIDAndGroup(
string systemIdentifier,
@@ -379,7 +372,6 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
/// <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>The updated current <see cref="User"/>.</returns>
[TgsGraphQLAuthorize(AdministrationRights.WriteUsers | AdministrationRights.EditOwnPassword)]
[Error(typeof(ErrorMessageException))]
public ValueTask<User> SetCurrentUserPassword(
string newPassword,
@@ -390,7 +382,7 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
ArgumentNullException.ThrowIfNull(newPassword);
ArgumentNullException.ThrowIfNull(userAuthority);
return userAuthority.InvokeTransformable<Models.User, User, UserGraphQLTransformer>(
async authority => await authority.Update(
authority => authority.Update(
new UserUpdateRequest
{
Id = authenticationContext.User.Id,
@@ -408,7 +400,6 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
/// <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>The updated current <see cref="User"/>.</returns>
[TgsGraphQLAuthorize(AdministrationRights.WriteUsers | AdministrationRights.EditOwnServiceConnections)]
[Error(typeof(ErrorMessageException))]
public ValueTask<User> SetCurrentServiceConnections(
IEnumerable<OAuthConnection>? newOAuthConnections,
@@ -420,7 +411,7 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
ArgumentNullException.ThrowIfNull(newOAuthConnections);
ArgumentNullException.ThrowIfNull(userAuthority);
return userAuthority.InvokeTransformable<Models.User, User, UserGraphQLTransformer>(
async authority => await authority.Update(
authority => authority.Update(
new UserUpdateRequest
{
Id = authenticationContext.User.Id,
@@ -454,7 +445,6 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
/// <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>The updated <see cref="User"/>.</returns>
[TgsGraphQLAuthorize(AdministrationRights.WriteUsers)]
[Error(typeof(ErrorMessageException))]
public ValueTask<User> UpdateUser(
[ID(nameof(User))] long id,
@@ -493,7 +483,6 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
/// <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>The updated <see cref="User"/>.</returns>
[TgsGraphQLAuthorize(AdministrationRights.WriteUsers)]
[Error(typeof(ErrorMessageException))]
public ValueTask<User> UpdateUserSetOwnedPermissionSet(
[ID(nameof(User))] long id,
@@ -533,7 +522,6 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
/// <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>The updated <see cref="User"/>.</returns>
[TgsGraphQLAuthorize(AdministrationRights.WriteUsers)]
[Error(typeof(ErrorMessageException))]
public ValueTask<User> UpdateUserSetGroup(
[ID(nameof(User))] long id,
@@ -586,7 +574,7 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
IGraphQLAuthorityInvoker<IUserAuthority> userAuthority,
CancellationToken cancellationToken)
=> userAuthority.InvokeTransformable<Models.User, User, UserGraphQLTransformer>(
async authority => await authority.Update(
authority => authority.Update(
new UserUpdateRequest
{
Id = id,
@@ -63,7 +63,6 @@ namespace Tgstation.Server.Host.GraphQL
/// <param name="sessionInvalidationReason">The <see cref="SessionInvalidationReason"/> received from the publisher.</param>
/// <returns>The <see cref="SessionInvalidationReason"/>.</returns>
[Subscribe(With = nameof(SessionInvalidatedStream))]
[TgsGraphQLAuthorize]
public SessionInvalidationReason SessionInvalidated([EventMessage] SessionInvalidationReason sessionInvalidationReason)
=> sessionInvalidationReason;
}
@@ -8,7 +8,6 @@ using HotChocolate.Execution;
using HotChocolate.Types;
using HotChocolate.Types.Relay;
using Tgstation.Server.Api.Rights;
using Tgstation.Server.Host.GraphQL.Types;
using Tgstation.Server.Host.Security;
@@ -68,7 +67,6 @@ namespace Tgstation.Server.Host.GraphQL.Subscriptions
/// <param name="user">The <see cref="Models.User"/> received from the publisher.</param>
/// <returns>The updated <see cref="User"/>.</returns>
[Subscribe(With = nameof(UserUpdatedStream))]
[TgsGraphQLAuthorize(AdministrationRights.ReadUsers)]
public User UserUpdated([EventMessage] User user)
{
ArgumentNullException.ThrowIfNull(user);
@@ -98,7 +96,6 @@ namespace Tgstation.Server.Host.GraphQL.Subscriptions
/// <param name="user">The <see cref="Models.User"/> received from the publisher.</param>
/// <returns>The updated <see cref="User"/>.</returns>
[Subscribe(With = nameof(CurrentUserUpdatedStream))]
[TgsGraphQLAuthorize]
public User CurrentUserUpdated([EventMessage] User user)
{
ArgumentNullException.ThrowIfNull(user);
@@ -30,8 +30,10 @@ namespace Tgstation.Server.Host.GraphQL.Types
/// <returns>A <see cref="uint"/> specifying the minimumn valid password length for TGS users.</returns>
[TgsGraphQLAuthorize(AdministrationRights.WriteUsers | AdministrationRights.EditOwnPassword)]
public uint MinimumPasswordLength(
[Service] IAuthorizationService authorizationService,
[Service] IOptionsSnapshot<GeneralConfiguration> generalConfigurationOptions)
{
ArgumentNullException.ThrowIfNull(authorizationService);
ArgumentNullException.ThrowIfNull(generalConfigurationOptions);
return generalConfigurationOptions.Value.MinimumPasswordLength;
}
@@ -9,7 +9,6 @@ using Tgstation.Server.Host.Authority;
using Tgstation.Server.Host.GraphQL.Interfaces;
using Tgstation.Server.Host.GraphQL.Types.OAuth;
using Tgstation.Server.Host.Models.Transformers;
using Tgstation.Server.Host.Security;
namespace Tgstation.Server.Host.GraphQL.Types
{
@@ -58,7 +57,6 @@ namespace Tgstation.Server.Host.GraphQL.Types
/// <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="User"/>, if present.</returns>
[TgsGraphQLAuthorize]
public static ValueTask<User?> GetUser(
long id,
[Service] IGraphQLAuthorityInvoker<IUserAuthority> userAuthority,
@@ -63,14 +63,14 @@ namespace Tgstation.Server.Host.GraphQL.Types
[UseFiltering]
[UseSorting]
[TgsGraphQLAuthorize<IUserGroupAuthority>(nameof(IUserAuthority.Queryable))]
public IQueryable<User> QueryableUsersByGroup(
public async ValueTask<IQueryable<User>> QueryableUsersByGroup(
[Service] IGraphQLAuthorityInvoker<IUserAuthority> userAuthority)
{
ArgumentNullException.ThrowIfNull(userAuthority);
var dtoQueryable = userAuthority.InvokeTransformableQueryable<Models.User, User, UserGraphQLTransformer>(
var dtoQueryable = await userAuthority.InvokeTransformableQueryable<Models.User, User, UserGraphQLTransformer>(
authority => authority
.Queryable(false)
.Where(user => user.GroupId == Id));
.Queryable(false),
queryable => queryable.Where(user => user.GroupId == Id));
return dtoQueryable;
}
}
@@ -23,12 +23,14 @@ namespace Tgstation.Server.Host.GraphQL.Types
/// Gets the current <see cref="User"/>.
/// </summary>
/// <param name="userGroupAuthority">The <see cref="IGraphQLAuthorityInvoker{TAuthority}"/> for the <see cref="IUserGroupAuthority"/>.</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"/>'s <see cref="UserGroup"/>.</returns>
public ValueTask<UserGroup?> Current(
[Service] IGraphQLAuthorityInvoker<IUserGroupAuthority> userGroupAuthority)
[Service] IGraphQLAuthorityInvoker<IUserGroupAuthority> userGroupAuthority,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(userGroupAuthority);
return userGroupAuthority.InvokeTransformableAllowMissing<Models.UserGroup, UserGroup, UserGroupGraphQLTransformer>(authority => authority.Read());
return userGroupAuthority.InvokeTransformableAllowMissing<Models.UserGroup, UserGroup, UserGroupGraphQLTransformer>(authority => authority.Read(cancellationToken));
}
/// <summary>
@@ -54,11 +56,12 @@ namespace Tgstation.Server.Host.GraphQL.Types
[UseFiltering]
[UseSorting]
[TgsGraphQLAuthorize<IUserGroupAuthority>(nameof(IUserGroupAuthority.Queryable))]
public IQueryable<UserGroup> QueryableGroups(
public async ValueTask<IQueryable<UserGroup>> QueryableGroups(
[Service] IGraphQLAuthorityInvoker<IUserGroupAuthority> userGroupAuthority)
{
ArgumentNullException.ThrowIfNull(userGroupAuthority);
var dtoQueryable = userGroupAuthority.InvokeTransformableQueryable<Models.UserGroup, UserGroup, UserGroupGraphQLTransformer>(authority => authority.Queryable(false));
var dtoQueryable = await userGroupAuthority.InvokeTransformableQueryable<Models.UserGroup, UserGroup, UserGroupGraphQLTransformer>(
authority => authority.Queryable(false));
return dtoQueryable;
}
@@ -72,15 +75,15 @@ namespace Tgstation.Server.Host.GraphQL.Types
[UseFiltering]
[UseSorting]
[TgsGraphQLAuthorize<IUserGroupAuthority>(nameof(IUserAuthority.Queryable))]
public IQueryable<User> QueryableUsersByGroupId(
public async ValueTask<IQueryable<User>> QueryableUsersByGroupId(
[ID(nameof(UserGroup))]long groupId,
[Service] IGraphQLAuthorityInvoker<IUserAuthority> userAuthority)
{
ArgumentNullException.ThrowIfNull(userAuthority);
var dtoQueryable = userAuthority.InvokeTransformableQueryable<Models.User, User, UserGraphQLTransformer>(
var dtoQueryable = await userAuthority.InvokeTransformableQueryable<Models.User, User, UserGraphQLTransformer>(
authority => authority
.Queryable(false)
.Where(user => user.GroupId == groupId));
.Queryable(false),
queryable => queryable.Where(user => user.GroupId == groupId));
return dtoQueryable;
}
}
@@ -81,11 +81,12 @@ namespace Tgstation.Server.Host.GraphQL.Types
[UseFiltering]
[UseSorting]
[TgsGraphQLAuthorize<IUserAuthority>(nameof(IUserAuthority.Queryable))]
public IQueryable<User> QueryableUsers(
public async ValueTask<IQueryable<User>> QueryableUsers(
[Service] IGraphQLAuthorityInvoker<IUserAuthority> userAuthority)
{
ArgumentNullException.ThrowIfNull(userAuthority);
var dtoQueryable = userAuthority.InvokeTransformableQueryable<Models.User, User, UserGraphQLTransformer>(authority => authority.Queryable(false));
var dtoQueryable = await userAuthority.InvokeTransformableQueryable<Models.User, User, UserGraphQLTransformer>(
authority => authority.Queryable(false));
return dtoQueryable;
}
}
@@ -0,0 +1,47 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
namespace Tgstation.Server.Host.Security
{
/// <inheritdoc />
sealed class AuthorizationService : IAuthorizationService
{
/// <summary>
/// The <see cref="IHttpContextAccessor"/> for the <see cref="AuthorizationService"/>.
/// </summary>
readonly IClaimsPrincipalAccessor claimsPrincipalAccessor;
/// <summary>
/// The <see cref="Microsoft.AspNetCore.Authorization.IAuthorizationService"/> for the <see cref="AuthorizationService"/>.
/// </summary>
readonly Microsoft.AspNetCore.Authorization.IAuthorizationService aspNetCoreAuthorizationService;
/// <summary>
/// Initializes a new instance of the <see cref="AuthorizationService"/> class.
/// </summary>
/// <param name="claimsPrincipalAccessor">The value of <see cref="claimsPrincipalAccessor"/>.</param>
/// <param name="aspNetCoreAuthorizationService">The value of <see cref="aspNetCoreAuthorizationService"/>.</param>
public AuthorizationService(
IClaimsPrincipalAccessor claimsPrincipalAccessor,
Microsoft.AspNetCore.Authorization.IAuthorizationService aspNetCoreAuthorizationService)
{
this.claimsPrincipalAccessor = claimsPrincipalAccessor ?? throw new ArgumentNullException(nameof(claimsPrincipalAccessor));
this.aspNetCoreAuthorizationService = aspNetCoreAuthorizationService ?? throw new ArgumentNullException(nameof(aspNetCoreAuthorizationService));
}
/// <inheritdoc />
public async ValueTask<bool> AuthorizeAsync(IEnumerable<Microsoft.AspNetCore.Authorization.IAuthorizationRequirement> requirements)
{
ArgumentNullException.ThrowIfNull(requirements);
var result = await aspNetCoreAuthorizationService.AuthorizeAsync(
claimsPrincipalAccessor.User,
null,
requirements);
return result.Succeeded;
}
}
}
@@ -0,0 +1,30 @@
using System;
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
namespace Tgstation.Server.Host.Security
{
/// <inheritdoc />
sealed class ClaimsPrincipalAccessor : IClaimsPrincipalAccessor
{
/// <inheritdoc />
public ClaimsPrincipal User => httpContextAccessor.HttpContext?.User
?? throw new InvalidOperationException("HTTP context was not present!");
/// <summary>
/// The <see cref="IHttpContextAccessor"/> for the <see cref="AuthorizationService"/>.
/// </summary>
readonly IHttpContextAccessor httpContextAccessor;
/// <summary>
/// Initializes a new instance of the <see cref="ClaimsPrincipalAccessor"/> class.
/// </summary>
/// <param name="httpContextAccessor">The value of <see cref="httpContextAccessor"/>.</param>
public ClaimsPrincipalAccessor(
IHttpContextAccessor httpContextAccessor)
{
this.httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor));
}
}
}
@@ -0,0 +1,20 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
namespace Tgstation.Server.Host.Security
{
/// <summary>
/// Interface for evaluating <see cref="IAuthorizationRequirement"/>s.
/// </summary>
public interface IAuthorizationService
{
/// <summary>
/// Attempt to authorize the current context with a given <paramref name="requirement"/>.
/// </summary>
/// <param name="requirement">The <see cref="IAuthorizationRequirement"/> to authorize.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in <see langword="true"/> if authorization succeeded. <see langword="false"/> otherwise.</returns>
ValueTask<bool> AuthorizeAsync(IEnumerable<IAuthorizationRequirement> requirement);
}
}
@@ -0,0 +1,15 @@
using System.Security.Claims;
namespace Tgstation.Server.Host.Security
{
/// <summary>
/// Interface for accessing the current request's <see cref="ClaimsPrincipal"/>.
/// </summary>
interface IClaimsPrincipalAccessor
{
/// <summary>
/// Get the current <see cref="ClaimsPrincipal"/>.
/// </summary>
ClaimsPrincipal User { get; }
}
}
@@ -10,7 +10,7 @@ namespace Tgstation.Server.Host.Security.RightsEvaluation
where TRights : Enum
{
/// <summary>
/// The single bit flag of the.
/// The single bit flag of the <typeparamref name="TRights"/>.
/// </summary>
readonly TRights flag;
@@ -1,137 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using HotChocolate.Authorization;
using Tgstation.Server.Api.Rights;
namespace Tgstation.Server.Host.Security
{
/// <summary>
/// Helper for using the <see cref="AuthorizeAttribute"/> with the <see cref="Api.Rights"/> system.
/// </summary>
#pragma warning disable CA1019
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Property, AllowMultiple = true, Inherited = true)]
sealed class TgsGraphQLAuthorizeAttribute : AuthorizeAttribute
{
/// <summary>
/// Gets the <see cref="Api.Rights.RightsType"/> associated with the <see cref="TgsAuthorizeAttribute"/> if any.
/// </summary>
public RightsType? RightsType { get; }
/// <summary>
/// Initializes a new instance of the <see cref="TgsGraphQLAuthorizeAttribute"/> class.
/// </summary>
public TgsGraphQLAuthorizeAttribute()
: this(Enumerable.Empty<string>())
{
}
/// <summary>
/// Initializes a new instance of the <see cref="TgsGraphQLAuthorizeAttribute"/> class.
/// </summary>
/// <param name="requiredRights">The <see cref="AdministrationRights"/> required.</param>
public TgsGraphQLAuthorizeAttribute(AdministrationRights requiredRights)
: this(RightsHelper.RoleNames(requiredRights))
{
RightsType = Api.Rights.RightsType.Administration;
}
/// <summary>
/// Initializes a new instance of the <see cref="TgsGraphQLAuthorizeAttribute"/> class.
/// </summary>
/// <param name="requiredRights">The <see cref="InstanceManagerRights"/> required.</param>
public TgsGraphQLAuthorizeAttribute(InstanceManagerRights requiredRights)
: this(RightsHelper.RoleNames(requiredRights))
{
RightsType = Api.Rights.RightsType.InstanceManager;
}
/// <summary>
/// Initializes a new instance of the <see cref="TgsGraphQLAuthorizeAttribute"/> class.
/// </summary>
/// <param name="requiredRights">The <see cref="RepositoryRights"/> required.</param>
public TgsGraphQLAuthorizeAttribute(RepositoryRights requiredRights)
: this(RightsHelper.RoleNames(requiredRights))
{
RightsType = Api.Rights.RightsType.Repository;
}
/// <summary>
/// Initializes a new instance of the <see cref="TgsGraphQLAuthorizeAttribute"/> class.
/// </summary>
/// <param name="requiredRights">The <see cref="EngineRights"/> required.</param>
public TgsGraphQLAuthorizeAttribute(EngineRights requiredRights)
: this(RightsHelper.RoleNames(requiredRights))
{
RightsType = Api.Rights.RightsType.Engine;
}
/// <summary>
/// Initializes a new instance of the <see cref="TgsGraphQLAuthorizeAttribute"/> class.
/// </summary>
/// <param name="requiredRights">The <see cref="DreamMakerRights"/> required.</param>
public TgsGraphQLAuthorizeAttribute(DreamMakerRights requiredRights)
: this(RightsHelper.RoleNames(requiredRights))
{
RightsType = Api.Rights.RightsType.DreamMaker;
}
/// <summary>
/// Initializes a new instance of the <see cref="TgsGraphQLAuthorizeAttribute"/> class.
/// </summary>
/// <param name="requiredRights">The <see cref="DreamDaemonRights"/> required.</param>
public TgsGraphQLAuthorizeAttribute(DreamDaemonRights requiredRights)
: this(RightsHelper.RoleNames(requiredRights))
{
RightsType = Api.Rights.RightsType.DreamDaemon;
}
/// <summary>
/// Initializes a new instance of the <see cref="TgsGraphQLAuthorizeAttribute"/> class.
/// </summary>
/// <param name="requiredRights">The <see cref="ChatBotRights"/> required.</param>
public TgsGraphQLAuthorizeAttribute(ChatBotRights requiredRights)
: this(RightsHelper.RoleNames(requiredRights))
{
RightsType = Api.Rights.RightsType.ChatBots;
}
/// <summary>
/// Initializes a new instance of the <see cref="TgsGraphQLAuthorizeAttribute"/> class.
/// </summary>
/// <param name="requiredRights">The <see cref="ConfigurationRights"/> required.</param>
public TgsGraphQLAuthorizeAttribute(ConfigurationRights requiredRights)
: this(RightsHelper.RoleNames(requiredRights))
{
RightsType = Api.Rights.RightsType.Configuration;
}
/// <summary>
/// Initializes a new instance of the <see cref="TgsGraphQLAuthorizeAttribute"/> class.
/// </summary>
/// <param name="requiredRights">The <see cref="InstancePermissionSetRights"/> required.</param>
public TgsGraphQLAuthorizeAttribute(InstancePermissionSetRights requiredRights)
: this(RightsHelper.RoleNames(requiredRights))
{
RightsType = Api.Rights.RightsType.InstancePermissionSet;
}
/// <summary>
/// Initializes a new instance of the <see cref="TgsGraphQLAuthorizeAttribute"/> class.
/// </summary>
/// <param name="roleNames"><see cref="IEnumerable{T}"/> of role names.</param>
private TgsGraphQLAuthorizeAttribute(IEnumerable<string> roleNames)
{
var listRoles = roleNames.ToList();
if (listRoles.Count != 0)
{
Roles = [.. listRoles];
}
Policy = TgsAuthorizeAttribute.PolicyName;
Apply = ApplyPolicy.Validation;
}
}
}
@@ -1,41 +0,0 @@
using System;
using System.Reflection;
using HotChocolate.Authorization;
using Tgstation.Server.Host.Authority.Core;
namespace Tgstation.Server.Host.Security
{
/// <summary>
/// Inherits the roles of <see cref="TgsAuthorizeAttribute"/>s for GraphQL endpoints.
/// </summary>
/// <typeparam name="TAuthority">The <see cref="IAuthority"/> being wrapped.</typeparam>
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true, Inherited = true)]
public sealed class TgsGraphQLAuthorizeAttribute<TAuthority> : AuthorizeAttribute
where TAuthority : IAuthority
{
/// <summary>
/// The name of the method targeted.
/// </summary>
public string MethodName { get; }
/// <summary>
/// Initializes a new instance of the <see cref="TgsGraphQLAuthorizeAttribute{TAuthority}"/> class.
/// </summary>
/// <param name="methodName">The <typeparamref name="TAuthority"/> method name to inherit roles from.</param>
public TgsGraphQLAuthorizeAttribute(string methodName)
{
ArgumentNullException.ThrowIfNull(methodName);
var authorityType = typeof(TAuthority);
var authorityMethod = authorityType.GetMethod(methodName)
?? throw new InvalidOperationException($"Could not find method {methodName} on {authorityType}!");
var authorizeAttribute = authorityMethod.GetCustomAttribute<TgsAuthorizeAttribute>()
?? throw new InvalidOperationException($"Could not find method {authorityType}.{methodName}() has no {nameof(TgsAuthorizeAttribute)}!");
MethodName = methodName;
Roles = authorizeAttribute.Roles?.Split(',', StringSplitOptions.RemoveEmptyEntries);
Apply = ApplyPolicy.Validation;
}
}
}
@@ -0,0 +1,11 @@
using Microsoft.AspNetCore.Authorization;
namespace Tgstation.Server.Host.Security
{
/// <summary>
/// <see cref="IAuthorizationRequirement"/> for testing if a user is enabled and their session is valid.
/// </summary>
sealed class UserSessionValidRequirement : IAuthorizationRequirement
{
}
}