From fb0323cc5a4b8c799cf201a4998e28d459d723de Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 20 Apr 2025 14:09:29 -0400 Subject: [PATCH] WIP --- .../Authority/AdministrationAuthority.cs | 261 ++++---- .../Authority/Core/AuthorityBase.cs | 42 +- .../Core/AuthorityInvokerBase{TAuthority}.cs | 43 +- .../GraphQLAuthorityInvoker{TAuthority}.cs | 75 ++- .../Core/IAuthorityInvoker{TAuthority}.cs | 22 +- .../Core/RequirementsGated{TResult}.cs | 148 +++++ .../Core/RestAuthorityInvoker{TAuthority}.cs | 39 +- .../Authority/IAdministrationAuthority.cs | 13 +- .../IGraphQLAuthorityInvoker{TAuthority}.cs | 38 +- .../Authority/ILoginAuthority.cs | 4 +- .../Authority/IPermissionSetAuthority.cs | 5 +- .../IRestAuthorityInvoker{TAuthority}.cs | 12 +- .../Authority/IUserAuthority.cs | 29 +- .../Authority/IUserGroupAuthority.cs | 23 +- .../Authority/LoginAuthority.cs | 67 +- .../Authority/PermissionSetAuthority.cs | 53 +- .../Authority/UserAuthority.cs | 588 ++++++++++-------- .../Authority/UserGroupAuthority.cs | 265 ++++---- .../Controllers/ApiController.cs | 11 +- .../Controllers/ChatController.cs | 2 +- .../Controllers/DreamMakerController.cs | 2 +- .../Controllers/EngineController.cs | 2 +- .../Controllers/InstanceController.cs | 2 +- .../InstancePermissionSetController.cs | 2 +- .../Controllers/JobController.cs | 4 +- .../Controllers/UserController.cs | 14 +- .../Controllers/UserGroupController.cs | 14 +- src/Tgstation.Server.Host/Core/Application.cs | 20 +- .../Mutations/AdministrationMutations.cs | 5 - .../GraphQL/Mutations/UserGroupMutations.cs | 4 - .../GraphQL/Mutations/UserMutations.cs | 18 +- .../GraphQL/Subscription.cs | 1 - .../Subscriptions/UserSubscriptions.cs | 3 - .../GraphQL/Types/GatewayInformation.cs | 2 + .../GraphQL/Types/User.cs | 2 - .../GraphQL/Types/UserGroup.cs | 8 +- .../GraphQL/Types/UserGroups.cs | 19 +- .../GraphQL/Types/Users.cs | 5 +- .../Security/AuthorizationService.cs | 47 ++ .../Security/ClaimsPrincipalAccessor.cs | 30 + .../Security/IAuthorizationService.cs | 20 + .../Security/IClaimsPrincipalAccessor.cs | 15 + .../FlagRightsConditional{TRights}.cs | 2 +- .../Security/TgsGraphQLAuthorizeAttribute.cs | 137 ---- ...gsGraphQLAuthorizeAttribute{TAuthority}.cs | 41 -- .../Security/UserSessionValidRequirement.cs | 11 + 46 files changed, 1250 insertions(+), 920 deletions(-) create mode 100644 src/Tgstation.Server.Host/Authority/Core/RequirementsGated{TResult}.cs create mode 100644 src/Tgstation.Server.Host/Security/AuthorizationService.cs create mode 100644 src/Tgstation.Server.Host/Security/ClaimsPrincipalAccessor.cs create mode 100644 src/Tgstation.Server.Host/Security/IAuthorizationService.cs create mode 100644 src/Tgstation.Server.Host/Security/IClaimsPrincipalAccessor.cs delete mode 100644 src/Tgstation.Server.Host/Security/TgsGraphQLAuthorizeAttribute.cs delete mode 100644 src/Tgstation.Server.Host/Security/TgsGraphQLAuthorizeAttribute{TAuthority}.cs create mode 100644 src/Tgstation.Server.Host/Security/UserSessionValidRequirement.cs diff --git a/src/Tgstation.Server.Host/Authority/AdministrationAuthority.cs b/src/Tgstation.Server.Host/Authority/AdministrationAuthority.cs index 76c7fc937b..e8fa0d681b 100644 --- a/src/Tgstation.Server.Host/Authority/AdministrationAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/AdministrationAuthority.cs @@ -90,154 +90,163 @@ namespace Tgstation.Server.Host.Authority } /// - public async ValueTask> GetUpdateInformation(bool forceFresh, CancellationToken cancellationToken) - { - try - { - async Task CacheFactory() + public RequirementsGated> 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 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 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)rawCacheObject!; + + var result = await task.WaitAsync(cancellationToken); + return new AuthorityResponse(result); } - catch (NotFoundException e) + catch (RateLimitExceededException e) { - Logger.LogWarning(e, "Not found exception while retrieving upstream repository info!"); + return RateLimit(e); } - - return new AdministrationResponse + catch (ApiException e) { - LatestVersion = greatestVersion, - TrackedRepositoryUrl = repoUrl, - GeneratedAt = DateTimeOffset.UtcNow, - }; - } - - var ttl = TimeSpan.FromMinutes(30); - Task 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)rawCacheObject!; - - var result = await task.WaitAsync(cancellationToken); - return new AuthorityResponse(result); - } - catch (RateLimitExceededException e) - { - return RateLimit(e); - } - catch (ApiException e) - { - Logger.LogWarning(e, OctokitException); - return new AuthorityResponse( - new ErrorMessageResponse(ErrorCode.RemoteApiError) - { - AdditionalData = e.Message, - }, - HttpFailureResponse.FailedDependency); - } - } + Logger.LogWarning(e, OctokitException); + return new AuthorityResponse( + new ErrorMessageResponse(ErrorCode.RemoteApiError) + { + AdditionalData = e.Message, + }, + HttpFailureResponse.FailedDependency); + } + }); /// - public async ValueTask> TriggerServerVersionChange(Version targetVersion, bool uploadZip, CancellationToken cancellationToken) + public RequirementsGated> TriggerServerVersionChange(Version targetVersion, bool uploadZip, CancellationToken cancellationToken) { var attemptingUpload = uploadZip == true; - if (attemptingUpload) - { - if (!AuthenticationContext.PermissionSet.AdministrationRights!.Value.HasFlag(AdministrationRights.UploadVersion)) - return Forbid(); - } - else if (!AuthenticationContext.PermissionSet.AdministrationRights!.Value.HasFlag(AdministrationRights.ChangeVersion)) - return Forbid(); - - if (targetVersion.Major < 4) - return BadRequest(ErrorCode.CannotChangeServerSuite); - - if (!serverControl.WatchdogPresent) - return new AuthorityResponse( - 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(ex); - } - catch (ApiException e) - { - Logger.LogWarning(e, OctokitException); - return new AuthorityResponse( - new ErrorMessageResponse(ErrorCode.RemoteApiError) + return Flag(AdministrationRights.ChangeVersion); + }, + async () => + { + if (targetVersion.Major < 4) + return BadRequest(ErrorCode.CannotChangeServerSuite); + + if (!serverControl.WatchdogPresent) + return new AuthorityResponse( + 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(new ServerUpdateResponse(targetVersion, uploadTicket?.Ticket.FileTicket), HttpSuccessResponse.Accepted), - ServerUpdateResult.ReleaseMissing => Gone(), - ServerUpdateResult.UpdateInProgress => BadRequest(ErrorCode.ServerUpdateInProgress), - ServerUpdateResult.SwarmIntegrityCheckFailed => new AuthorityResponse( - new ErrorMessageResponse(ErrorCode.SwarmIntegrityCheckFailed), - HttpFailureResponse.FailedDependency), - _ => throw new InvalidOperationException($"Unexpected ServerUpdateResult: {updateResult}"), - }; + throw; + } + } + catch (RateLimitExceededException ex) + { + return RateLimit(ex); + } + catch (ApiException e) + { + Logger.LogWarning(e, OctokitException); + return new AuthorityResponse( + new ErrorMessageResponse(ErrorCode.RemoteApiError) + { + AdditionalData = e.Message, + }, + HttpFailureResponse.FailedDependency); + } + + return updateResult switch + { + ServerUpdateResult.Started => new AuthorityResponse(new ServerUpdateResponse(targetVersion, uploadTicket?.Ticket.FileTicket), HttpSuccessResponse.Accepted), + ServerUpdateResult.ReleaseMissing => Gone(), + ServerUpdateResult.UpdateInProgress => BadRequest(ErrorCode.ServerUpdateInProgress), + ServerUpdateResult.SwarmIntegrityCheckFailed => new AuthorityResponse( + new ErrorMessageResponse(ErrorCode.SwarmIntegrityCheckFailed), + HttpFailureResponse.FailedDependency), + _ => throw new InvalidOperationException($"Unexpected ServerUpdateResult: {updateResult}"), + }; + }); } /// - public async ValueTask 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 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(); + }); } } diff --git a/src/Tgstation.Server.Host/Authority/Core/AuthorityBase.cs b/src/Tgstation.Server.Host/Authority/Core/AuthorityBase.cs index 0ea01a89da..a3468b7f5b 100644 --- a/src/Tgstation.Server.Host/Authority/Core/AuthorityBase.cs +++ b/src/Tgstation.Server.Host/Authority/Core/AuthorityBase.cs @@ -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 /// abstract class AuthorityBase : IAuthority { - /// - /// Gets the for the . - /// - protected IAuthenticationContext AuthenticationContext { get; } - /// /// Gets the for the . /// @@ -94,18 +89,47 @@ namespace Tgstation.Server.Host.Authority.Core new ErrorMessageResponse(errorCode), HttpFailureResponse.Conflict); + /// + /// Helper to quickly construct a . + /// + /// The to evaluate. + /// The single bit flag of the . + /// A new . + protected static FlagRightsConditional Flag(TRights flag) + where TRights : Enum + => new(flag); + + /// + /// Helper to quickly construct an . + /// + /// The to evaluate. + /// The left hand side operand. + /// The right hand side operand. + /// A new . + protected static OrRightsConditional Or(RightsConditional lhs, RightsConditional rhs) + where TRights : Enum + => new(lhs, rhs); + + /// + /// Helper to quickly construct an . + /// + /// The to evaluate. + /// The left hand side operand. + /// The right hand side operand. + /// A new . + protected static AndRightsConditional And(RightsConditional lhs, RightsConditional rhs) + where TRights : Enum + => new(lhs, rhs); + /// /// Initializes a new instance of the class. /// - /// The value of . /// The value of . /// The value of . protected AuthorityBase( - IAuthenticationContext authenticationContext, IDatabaseContext databaseContext, ILogger logger) { - AuthenticationContext = authenticationContext ?? throw new ArgumentNullException(nameof(authenticationContext)); DatabaseContext = databaseContext ?? throw new ArgumentNullException(nameof(databaseContext)); Logger = logger ?? throw new ArgumentNullException(nameof(logger)); } diff --git a/src/Tgstation.Server.Host/Authority/Core/AuthorityInvokerBase{TAuthority}.cs b/src/Tgstation.Server.Host/Authority/Core/AuthorityInvokerBase{TAuthority}.cs index f04293aecc..93f600c12d 100644 --- a/src/Tgstation.Server.Host/Authority/Core/AuthorityInvokerBase{TAuthority}.cs +++ b/src/Tgstation.Server.Host/Authority/Core/AuthorityInvokerBase{TAuthority}.cs @@ -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 /// protected TAuthority Authority { get; } + /// + /// The for the . + /// + readonly IAuthorizationService authorizationService; + /// /// Initializes a new instance of the class. /// /// The value of . - public AuthorityInvokerBase(TAuthority authority) + /// The value of . + public AuthorityInvokerBase( + TAuthority authority, + IAuthorizationService authorizationService) { Authority = authority ?? throw new ArgumentNullException(nameof(authority)); + this.authorizationService = authorizationService ?? throw new ArgumentNullException(nameof(authorizationService)); } /// - IQueryable IAuthorityInvoker.InvokeQueryable(Func> authorityInvoker) + async ValueTask?> IAuthorityInvoker.InvokeQueryable(Func>> authorityInvoker) { ArgumentNullException.ThrowIfNull(authorityInvoker); - return authorityInvoker(Authority); + + var requirementsGate = authorityInvoker(Authority); + return await ExecuteIfRequirementsSatisfied(requirementsGate); } - /// - IQueryable IAuthorityInvoker.InvokeTransformableQueryable(Func> authorityInvoker) + /// + /// Unwrap a result, returning if the requirements weren't satisfied. + /// + /// The contained by the . + /// The result. + /// A resulting in the if the requirements were met, if the requirments weren't met. + protected async ValueTask ExecuteIfRequirementsSatisfied(RequirementsGated 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); } } } diff --git a/src/Tgstation.Server.Host/Authority/Core/GraphQLAuthorityInvoker{TAuthority}.cs b/src/Tgstation.Server.Host/Authority/Core/GraphQLAuthorityInvoker{TAuthority}.cs index a25a04e278..f9f8b26863 100644 --- a/src/Tgstation.Server.Host/Authority/Core/GraphQLAuthorityInvoker{TAuthority}.cs +++ b/src/Tgstation.Server.Host/Authority/Core/GraphQLAuthorityInvoker{TAuthority}.cs @@ -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 : AuthorityInvokerBase, IGraphQLAuthorityInvoker where TAuthority : IAuthority { + /// + /// Create a new to be thrown when a forbidden error occurs. + /// + /// A new . + static ErrorMessageException ForbiddenGraphQLError() + => new(new ErrorMessageResponse(), HttpFailureResponse.Forbidden.ToString()); + /// /// Throws a for errored s. /// - /// The potentially errored . + /// The being checked. + /// The potentially errored or if requirements evaluation failed. /// If an error should be raised for and failures. - static void ThrowGraphQLErrorIfNecessary(AuthorityResponse authorityResponse, bool errorOnMissing) + /// if an wasn't thrown. + static TAuthorityResponse ThrowGraphQLErrorIfNecessary(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 class. /// /// The . - public GraphQLAuthorityInvoker(TAuthority authority) - : base(authority) + /// the to use. + public GraphQLAuthorityInvoker(TAuthority authority, IAuthorizationService authorizationService) + : base(authority, authorizationService) { } /// - async ValueTask IGraphQLAuthorityInvoker.Invoke(Func> authorityInvoker) + async ValueTask IGraphQLAuthorityInvoker.Invoke(Func> authorityInvoker) { ArgumentNullException.ThrowIfNull(authorityInvoker); - var authorityResponse = await authorityInvoker(Authority); + var requirementsGate = authorityInvoker(Authority); + var authorityResponse = await ExecuteIfRequirementsSatisfied(requirementsGate); ThrowGraphQLErrorIfNecessary(authorityResponse, true); } /// - async ValueTask IGraphQLAuthorityInvoker.InvokeAllowMissing(Func>> authorityInvoker) + async ValueTask IGraphQLAuthorityInvoker.InvokeAllowMissing(Func>> 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; } /// - async ValueTask IGraphQLAuthorityInvoker.InvokeTransformableAllowMissing(Func>> authorityInvoker) + async ValueTask IGraphQLAuthorityInvoker.InvokeTransformableAllowMissing(Func>> 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 } /// - ValueTask IGraphQLAuthorityInvoker.Invoke(Func>> authorityInvoker) + async ValueTask> IGraphQLAuthorityInvoker.InvokeTransformableQueryable( + Func>> authorityInvoker, + Func, IQueryable>? 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); + } + + /// + ValueTask IGraphQLAuthorityInvoker.Invoke(Func>> authorityInvoker) => ((IGraphQLAuthorityInvoker)this).InvokeAllowMissing(authorityInvoker)!; /// - ValueTask IGraphQLAuthorityInvoker.InvokeTransformable(Func>> authorityInvoker) + ValueTask IGraphQLAuthorityInvoker.InvokeTransformable(Func>> authorityInvoker) => ((IGraphQLAuthorityInvoker)this).InvokeTransformableAllowMissing(authorityInvoker)!; } } diff --git a/src/Tgstation.Server.Host/Authority/Core/IAuthorityInvoker{TAuthority}.cs b/src/Tgstation.Server.Host/Authority/Core/IAuthorityInvoker{TAuthority}.cs index 9b49bd9dda..98270fcc96 100644 --- a/src/Tgstation.Server.Host/Authority/Core/IAuthorityInvoker{TAuthority}.cs +++ b/src/Tgstation.Server.Host/Authority/Core/IAuthorityInvoker{TAuthority}.cs @@ -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 method and get the result. /// /// The returned . - /// The returning a . - /// A returned. - IQueryable InvokeQueryable(Func> authorityInvoker); - - /// - /// Invoke a method and get the transformed result. - /// - /// The returned by the . - /// The returned . - /// The for converting s to s. - /// The returning a . - /// A returned. - IQueryable InvokeTransformableQueryable(Func> authorityInvoker) - where TResult : IApiTransformable - where TApiModel : notnull - where TTransformer : ITransformer, new(); + /// The authority invocation returning a . + /// A resulting in the returned on success or if the requirements weren't satisfied. + ValueTask?> InvokeQueryable(Func>> authorityInvoker); } } diff --git a/src/Tgstation.Server.Host/Authority/Core/RequirementsGated{TResult}.cs b/src/Tgstation.Server.Host/Authority/Core/RequirementsGated{TResult}.cs new file mode 100644 index 0000000000..7decf514d6 --- /dev/null +++ b/src/Tgstation.Server.Host/Authority/Core/RequirementsGated{TResult}.cs @@ -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 +{ + /// + /// Evaluates a set of s to be checked before executing a response. + /// + /// The of object the response generates. + public sealed class RequirementsGated + { + /// + /// The retrieval function. is included automatically. + /// + readonly Func>> getRequirements; + + /// + /// The response generation function. + /// + readonly Func> getResponse; + + /// + /// Convert a given into a . + /// + /// The to convert. + /// A new based on . +#pragma warning disable CA1000 // Do not declare static members on generic types + public static RequirementsGated FromResult(TResult result) +#pragma warning restore CA1000 // Do not declare static members on generic types + => new( + () => (IAuthorizationRequirement?)null, + () => ValueTask.FromResult(result)); + + /// + /// Initializes a new instance of the class. + /// + /// The value of . Resulting in a value is eqivalent to returning an empty of s. + /// The value of . + public RequirementsGated( + Func> getRequirement, + Func> getResponse) + { + ArgumentNullException.ThrowIfNull(getRequirement); + ArgumentNullException.ThrowIfNull(getResponse); + getRequirements = async () => + { + var requirement = await getRequirement(); + if (requirement == null) + return Enumerable.Empty(); + + return new List + { + requirement, + }; + }; + this.getResponse = _ => getResponse(); + } + + /// + /// Initializes a new instance of the class. + /// + /// The value of . + /// The value of . + public RequirementsGated( + Func> getRequirements, + Func> getResponse) + { + ArgumentNullException.ThrowIfNull(getRequirements); + ArgumentNullException.ThrowIfNull(getResponse); + this.getRequirements = () => ValueTask.FromResult(getRequirements()); + this.getResponse = _ => getResponse(); + } + + /// + /// Initializes a new instance of the class. + /// + /// The value of . Resulting in a value is eqivalent to returning an empty of s. + /// The value of . + public RequirementsGated( + Func getRequirement, + Func> getResponse) + { + ArgumentNullException.ThrowIfNull(getRequirement); + ArgumentNullException.ThrowIfNull(getResponse); + getRequirements = () => + { + var requirement = getRequirement(); + if (requirement == null) + return ValueTask.FromResult(Enumerable.Empty()); + + return ValueTask.FromResult>( + new List + { + requirement, + }); + }; + + this.getResponse = _ => getResponse(); + } + + /// + /// Initializes a new instance of the class. + /// + /// The value of . Resulting in a value is eqivalent to returning an empty of s. + /// The value of . + public RequirementsGated( + Func getRequirement, + Func> getResponse) + { + ArgumentNullException.ThrowIfNull(getRequirement); + getRequirements = () => + { + var requirement = getRequirement(); + if (requirement == null) + return ValueTask.FromResult(Enumerable.Empty()); + + return ValueTask.FromResult>( + new List + { + requirement, + }); + }; + + this.getResponse = getResponse ?? throw new ArgumentNullException(nameof(getResponse)); + } + + /// + /// Evaluates the s of the request. + /// + /// A resulting in the s for the request. + public async ValueTask> GetRequirements() + => (await getRequirements()).Concat([new UserSessionValidRequirement()]); + + /// + /// Executes the request. + /// + /// The authorization service to use. + /// A resulting in the request . + public ValueTask Execute(Security.IAuthorizationService authorizationService) + => getResponse(authorizationService); + } +} diff --git a/src/Tgstation.Server.Host/Authority/Core/RestAuthorityInvoker{TAuthority}.cs b/src/Tgstation.Server.Host/Authority/Core/RestAuthorityInvoker{TAuthority}.cs index 52532c2f0e..f0ac777801 100644 --- a/src/Tgstation.Server.Host/Authority/Core/RestAuthorityInvoker{TAuthority}.cs +++ b/src/Tgstation.Server.Host/Authority/Core/RestAuthorityInvoker{TAuthority}.cs @@ -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 /// An for the . /// The result returned in the . /// The REST API result model built from . - static IActionResult CreateSuccessfulActionResult(ApiController controller, Func resultTransformer, AuthorityResponse authorityResponse) + static IActionResult CreateSuccessfulActionResult( + ApiController controller, + Func resultTransformer, + AuthorityResponse authorityResponse) where TApiModel : notnull { if (authorityResponse.IsNoContent!.Value) @@ -44,9 +48,14 @@ namespace Tgstation.Server.Host.Authority.Core /// /// The to use. /// The . - /// An if the is not successful, otherwise. - static IActionResult? CreateErroredActionResult(ApiController controller, AuthorityResponse authorityResponse) + /// An if the is not successful, otherwise. If is returned, is not . + 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 class. /// /// The . - public RestAuthorityInvoker(TAuthority authority) - : base(authority) + /// The to use. + public RestAuthorityInvoker(TAuthority authority, IAuthorizationService authorizationService) + : base(authority, authorizationService) { } /// - async ValueTask IRestAuthorityInvoker.Invoke(ApiController controller, Func> authorityInvoker) + async ValueTask IRestAuthorityInvoker.Invoke(ApiController controller, Func> authorityInvoker) { ArgumentNullException.ThrowIfNull(controller); ArgumentNullException.ThrowIfNull(authorityInvoker); - var authorityResponse = await authorityInvoker(Authority); + var requirementsGate = authorityInvoker(Authority); + var authorityResponse = await ExecuteIfRequirementsSatisfied(requirementsGate); return CreateErroredActionResult(controller, authorityResponse) ?? controller.NoContent(); } /// - async ValueTask IRestAuthorityInvoker.Invoke(ApiController controller, Func>> authorityInvoker) + async ValueTask IRestAuthorityInvoker.Invoke(ApiController controller, Func>> authorityInvoker) { ArgumentNullException.ThrowIfNull(controller); ArgumentNullException.ThrowIfNull(authorityInvoker); - var authorityResponse = await authorityInvoker(Authority); + var 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!); } /// - async ValueTask IRestAuthorityInvoker.InvokeTransformable(ApiController controller, Func>> authorityInvoker) + async ValueTask IRestAuthorityInvoker.InvokeTransformable(ApiController controller, Func>> 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!); } } } diff --git a/src/Tgstation.Server.Host/Authority/IAdministrationAuthority.cs b/src/Tgstation.Server.Host/Authority/IAdministrationAuthority.cs index 7fb28925b5..3381b8aa2c 100644 --- a/src/Tgstation.Server.Host/Authority/IAdministrationAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/IAdministrationAuthority.cs @@ -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 /// /// Bypass the caching that the authority performs for this request, forcing it to contact GitHub. /// The for the operation. - /// A resulting in the . + /// A . [TgsAuthorize(AdministrationRights.ChangeVersion)] - ValueTask> GetUpdateInformation(bool forceFresh, CancellationToken cancellationToken); + RequirementsGated> GetUpdateInformation(bool forceFresh, CancellationToken cancellationToken); /// /// Triggers a restart of tgstation-server without terminating running game instances, setting its version to a given . @@ -29,15 +28,15 @@ namespace Tgstation.Server.Host.Authority /// The TGS will switch to upon reboot. /// If a will be returned and the call must provide an uploaded zip file containing the update data to the file transfer service. /// The for the operation. - /// A resulting in the . + /// A . [TgsAuthorize(AdministrationRights.ChangeVersion | AdministrationRights.UploadVersion)] - ValueTask> TriggerServerVersionChange(Version targetVersion, bool uploadZip, CancellationToken cancellationToken); + RequirementsGated> TriggerServerVersionChange(Version targetVersion, bool uploadZip, CancellationToken cancellationToken); /// /// Triggers a restart of tgstation-server without terminating running game instances. /// - /// A resulting in the . + /// A . [TgsAuthorize(AdministrationRights.RestartHost)] - ValueTask TriggerServerRestart(); + RequirementsGated TriggerServerRestart(); } } diff --git a/src/Tgstation.Server.Host/Authority/IGraphQLAuthorityInvoker{TAuthority}.cs b/src/Tgstation.Server.Host/Authority/IGraphQLAuthorityInvoker{TAuthority}.cs index edface219a..341b78a6cd 100644 --- a/src/Tgstation.Server.Host/Authority/IGraphQLAuthorityInvoker{TAuthority}.cs +++ b/src/Tgstation.Server.Host/Authority/IGraphQLAuthorityInvoker{TAuthority}.cs @@ -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 s from GraphQL endpoints. /// /// The invoked. + /// We take the approach that fields should be non-nullable if that is the case under ideal circumstances. Authorization issues should throw. public interface IGraphQLAuthorityInvoker : IAuthorityInvoker where TAuthority : IAuthority { /// /// Invoke a method with no success result. /// - /// The returning a resulting in the . + /// The resulting in the . /// A representing the running operation. - ValueTask Invoke(Func> authorityInvoker); + ValueTask Invoke(Func> authorityInvoker); /// /// Invoke a method and get the result. /// /// The . /// The resulting of the return value. - /// The returning a resulting in the . + /// The resulting in the . /// A resulting in the generated for the resulting . - ValueTask InvokeAllowMissing(Func>> authorityInvoker) + ValueTask InvokeAllowMissing(Func>> authorityInvoker) where TResult : TApiModel where TApiModel : notnull; @@ -37,9 +39,9 @@ namespace Tgstation.Server.Host.Authority /// The . /// The resulting of the return value. /// The for converting s to s. - /// The returning a resulting in the . + /// The resulting in the . /// A resulting in the generated for the resulting . - ValueTask InvokeTransformableAllowMissing(Func>> authorityInvoker) + ValueTask InvokeTransformableAllowMissing(Func>> authorityInvoker) where TResult : notnull, IApiTransformable where TApiModel : notnull where TTransformer : ITransformer, new(); @@ -49,9 +51,9 @@ namespace Tgstation.Server.Host.Authority /// /// The . /// The resulting of the return value. - /// The returning a resulting in the . + /// The resulting in the . /// A resulting in the generated for the resulting . - ValueTask Invoke(Func>> authorityInvoker) + ValueTask Invoke(Func>> authorityInvoker) where TResult : TApiModel where TApiModel : notnull; @@ -61,11 +63,27 @@ namespace Tgstation.Server.Host.Authority /// The . /// The resulting of the return value. /// The for converting s to s. - /// The returning a resulting in the . + /// The resulting in the . /// A resulting in the generated for the resulting . - ValueTask InvokeTransformable(Func>> authorityInvoker) + ValueTask InvokeTransformable(Func>> authorityInvoker) where TResult : notnull, IApiTransformable where TApiModel : notnull where TTransformer : ITransformer, new(); + + /// + /// Invoke a method and get the transformed result. + /// + /// The returned by the . + /// The returned . + /// The for converting s to s. + /// The returning a . + /// Optional transformer for the run once it has been acquired. + /// A resulting in the returned on success or if the requirements weren't satisfied. + ValueTask> InvokeTransformableQueryable( + Func>> authorityInvoker, + Func, IQueryable>? preTransformer = null) + where TResult : IApiTransformable + where TApiModel : notnull + where TTransformer : ITransformer, new(); } } diff --git a/src/Tgstation.Server.Host/Authority/ILoginAuthority.cs b/src/Tgstation.Server.Host/Authority/ILoginAuthority.cs index 0558bbfaa0..111d83271f 100644 --- a/src/Tgstation.Server.Host/Authority/ILoginAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/ILoginAuthority.cs @@ -16,13 +16,13 @@ namespace Tgstation.Server.Host.Authority /// /// The for the operation. /// A resulting in a . - ValueTask> AttemptLogin(CancellationToken cancellationToken); + RequirementsGated> AttemptLogin(CancellationToken cancellationToken); /// /// Attempt to login to an OAuth service with the current OAuth credentials. /// /// The for the operation. /// A resulting in an . - ValueTask> AttemptOAuthGatewayLogin(CancellationToken cancellationToken); + RequirementsGated> AttemptOAuthGatewayLogin(CancellationToken cancellationToken); } } diff --git a/src/Tgstation.Server.Host/Authority/IPermissionSetAuthority.cs b/src/Tgstation.Server.Host/Authority/IPermissionSetAuthority.cs index bb10fed383..8e4a370087 100644 --- a/src/Tgstation.Server.Host/Authority/IPermissionSetAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/IPermissionSetAuthority.cs @@ -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 /// The of . /// The for the operation. /// A resulting in a . - [TgsAuthorize(AdministrationRights.ReadUsers)] - ValueTask> GetId(long id, PermissionSetLookupType lookupType, CancellationToken cancellationToken); + RequirementsGated> GetId(long id, PermissionSetLookupType lookupType, CancellationToken cancellationToken); } } diff --git a/src/Tgstation.Server.Host/Authority/IRestAuthorityInvoker{TAuthority}.cs b/src/Tgstation.Server.Host/Authority/IRestAuthorityInvoker{TAuthority}.cs index 2cb05a2b9a..0e3b6f3b14 100644 --- a/src/Tgstation.Server.Host/Authority/IRestAuthorityInvoker{TAuthority}.cs +++ b/src/Tgstation.Server.Host/Authority/IRestAuthorityInvoker{TAuthority}.cs @@ -20,9 +20,9 @@ namespace Tgstation.Server.Host.Authority /// Invoke a method with no success result. /// /// The invoking the . - /// The returning a resulting in the . + /// The resulting in the . /// A resulting in the generated for the resulting . - ValueTask Invoke(ApiController controller, Func> authorityInvoker); + ValueTask Invoke(ApiController controller, Func> authorityInvoker); /// /// Invoke a method and get the result. @@ -30,9 +30,9 @@ namespace Tgstation.Server.Host.Authority /// The . /// The resulting of the . /// The invoking the . - /// The returning a resulting in the . + /// The resulting in the . /// A resulting in the generated for the resulting . - ValueTask Invoke(ApiController controller, Func>> authorityInvoker) + ValueTask Invoke(ApiController controller, Func>> authorityInvoker) where TResult : TApiModel where TApiModel : notnull; @@ -42,9 +42,9 @@ namespace Tgstation.Server.Host.Authority /// The . /// The returned REST . /// The invoking the . - /// The returning a resulting in the . + /// The resulting in the . /// A resulting in the generated for the resulting . - ValueTask InvokeTransformable(ApiController controller, Func>> authorityInvoker) + ValueTask InvokeTransformable(ApiController controller, Func>> authorityInvoker) where TResult : notnull, ILegacyApiTransformable where TApiModel : notnull; } diff --git a/src/Tgstation.Server.Host/Authority/IUserAuthority.cs b/src/Tgstation.Server.Host/Authority/IUserAuthority.cs index f60275aceb..d2b39e45dc 100644 --- a/src/Tgstation.Server.Host/Authority/IUserAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/IUserAuthority.cs @@ -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. /// /// The for the operation. - /// A resulting in a . + /// A . [TgsAuthorize] - ValueTask> Read(CancellationToken cancellationToken); + RequirementsGated> Read(CancellationToken cancellationToken); /// /// Gets the with a given . @@ -31,33 +30,33 @@ namespace Tgstation.Server.Host.Authority /// If related entities should be loaded. /// If the may be returned. /// The for the operation. - /// A resulting in a . + /// A . [TgsAuthorize(AdministrationRights.ReadUsers)] - ValueTask> GetId(long id, bool includeJoins, bool allowSystemUser, CancellationToken cancellationToken); + RequirementsGated> GetId(long id, bool includeJoins, bool allowSystemUser, CancellationToken cancellationToken); /// /// Gets the s for the with a given . /// /// The of the . /// The for the operation. - /// A resulting in an of . - ValueTask> OAuthConnections(long userId, CancellationToken cancellationToken); + /// A of . + RequirementsGated> OAuthConnections(long userId, CancellationToken cancellationToken); /// /// Gets the s for the with a given . /// /// The of the . /// The for the operation. - /// A resulting in an of . - ValueTask> OidcConnections(long userId, CancellationToken cancellationToken); + /// A of . + RequirementsGated> OidcConnections(long userId, CancellationToken cancellationToken); /// /// Gets all registered s. /// /// If related entities should be loaded. - /// A of s. + /// A of s. [TgsAuthorize(AdministrationRights.ReadUsers)] - IQueryable Queryable(bool includeJoins); + RequirementsGated> Queryable(bool includeJoins); /// /// Creates a . @@ -65,9 +64,9 @@ namespace Tgstation.Server.Host.Authority /// The . /// If a zero-length indicates and OAuth only user. /// The for the operation. - /// A resulting in am for the created . + /// A for the created . [TgsAuthorize(AdministrationRights.WriteUsers)] - ValueTask> Create( + RequirementsGated> Create( UserCreateRequest createRequest, bool? needZeroLengthPasswordWithOAuthConnections, CancellationToken cancellationToken); @@ -77,8 +76,8 @@ namespace Tgstation.Server.Host.Authority /// /// The . /// The for the operation. - /// A resulting in am for the created . + /// A for the created . [TgsAuthorize(AdministrationRights.WriteUsers | AdministrationRights.EditOwnPassword | AdministrationRights.EditOwnServiceConnections)] - ValueTask> Update(UserUpdateRequest updateRequest, CancellationToken cancellationToken); + RequirementsGated> Update(UserUpdateRequest updateRequest, CancellationToken cancellationToken); } } diff --git a/src/Tgstation.Server.Host/Authority/IUserGroupAuthority.cs b/src/Tgstation.Server.Host/Authority/IUserGroupAuthority.cs index 28113b95f5..c63c417ab1 100644 --- a/src/Tgstation.Server.Host/Authority/IUserGroupAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/IUserGroupAuthority.cs @@ -17,8 +17,9 @@ namespace Tgstation.Server.Host.Authority /// /// Gets the current . /// + /// The for the operation. /// A resulting in a . - ValueTask> Read(); + RequirementsGated> Read(CancellationToken cancellationToken); /// /// Gets the with a given . @@ -26,17 +27,17 @@ namespace Tgstation.Server.Host.Authority /// The of the . /// If related entities should be loaded. /// The for the operation. - /// A resulting in a . + /// A . [TgsAuthorize(AdministrationRights.ReadUsers)] - ValueTask> GetId(long id, bool includeJoins, CancellationToken cancellationToken); + RequirementsGated> GetId(long id, bool includeJoins, CancellationToken cancellationToken); /// /// Gets all registered s. /// /// If related entities should be loaded. - /// A of s. + /// A of s. [TgsAuthorize(AdministrationRights.ReadUsers)] - IQueryable Queryable(bool includeJoins); + RequirementsGated> Queryable(bool includeJoins); /// /// Create a . @@ -44,9 +45,9 @@ namespace Tgstation.Server.Host.Authority /// The created 's . /// The created 's . /// The for the operation. - /// A resulting in a . + /// A . [TgsAuthorize(AdministrationRights.WriteUsers)] - ValueTask> Create(string name, PermissionSet? permissionSet, CancellationToken cancellationToken); + RequirementsGated> Create(string name, PermissionSet? permissionSet, CancellationToken cancellationToken); /// /// Updates a . @@ -55,17 +56,17 @@ namespace Tgstation.Server.Host.Authority /// The optional new for the . /// The optional new for the . /// The for the operation. - /// A resulting in a . + /// A resulting in a . [TgsAuthorize(AdministrationRights.WriteUsers)] - ValueTask> Update(long id, string? newName, PermissionSet? newPermissionSet, CancellationToken cancellationToken); + RequirementsGated> Update(long id, string? newName, PermissionSet? newPermissionSet, CancellationToken cancellationToken); /// /// Deletes an empty . /// /// The of the to delete. /// The for the operation. - /// A representing the running operation. + /// A representing the running operation. [TgsAuthorize(AdministrationRights.WriteUsers)] - ValueTask DeleteEmpty(long id, CancellationToken cancellationToken); + RequirementsGated DeleteEmpty(long id, CancellationToken cancellationToken); } } diff --git a/src/Tgstation.Server.Host/Authority/LoginAuthority.cs b/src/Tgstation.Server.Host/Authority/LoginAuthority.cs index ab8af50f91..12dcd4879d 100644 --- a/src/Tgstation.Server.Host/Authority/LoginAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/LoginAuthority.cs @@ -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 /// /// Initializes a new instance of the class. /// - /// The to use. /// The to use. /// The to use. /// The value of . @@ -115,7 +115,6 @@ namespace Tgstation.Server.Host.Authority /// The value of . /// The containing the value of . public LoginAuthority( - IAuthenticationContext authenticationContext, IDatabaseContext databaseContext, ILogger logger, IApiHeadersProvider apiHeadersProvider, @@ -127,7 +126,6 @@ namespace Tgstation.Server.Host.Authority ISessionInvalidationTracker sessionInvalidationTracker, IOptions securityConfigurationOptions) : base( - authenticationContext, databaseContext, logger) { @@ -142,7 +140,44 @@ namespace Tgstation.Server.Host.Authority } /// - public async ValueTask> AttemptLogin(CancellationToken cancellationToken) + public RequirementsGated> AttemptLogin(CancellationToken cancellationToken) + => new( + () => (IAuthorizationRequirement?)null, + () => AttemptLoginImpl(cancellationToken)); + + /// + public RequirementsGated> AttemptOAuthGatewayLogin(CancellationToken cancellationToken) + => new( + () => (IAuthorizationRequirement?)null, + async () => + { + var headers = apiHeadersProvider.ApiHeaders; + if (headers == null) + return GenerateHeadersExceptionResponse(apiHeadersProvider.HeadersException!); + + var oAuthProvider = headers.OAuthProvider; + if (!oAuthProvider.HasValue) + return BadRequest(ErrorCode.BadHeaders); + + var (errorResponse, oAuthResult) = await TryOAuthenticate(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, + }); + }); + + /// + /// Login process. + /// + /// The for the operation. + /// A resulting in the for the . + private async ValueTask> AttemptLoginImpl(CancellationToken cancellationToken) { // password and oauth logins disabled if (securityConfiguration.OidcStrictMode) @@ -278,30 +313,6 @@ namespace Tgstation.Server.Host.Authority } } - /// - public async ValueTask> AttemptOAuthGatewayLogin(CancellationToken cancellationToken) - { - var headers = apiHeadersProvider.ApiHeaders; - if (headers == null) - return GenerateHeadersExceptionResponse(apiHeadersProvider.HeadersException!); - - var oAuthProvider = headers.OAuthProvider; - if (!oAuthProvider.HasValue) - return BadRequest(ErrorCode.BadHeaders); - - var (errorResponse, oAuthResult) = await TryOAuthenticate(headers, oAuthProvider.Value, false, cancellationToken); - if (errorResponse != null) - return errorResponse; - - Logger.LogDebug("Generated {provider} OAuth AccessCode", oAuthProvider.Value); - - return new AuthorityResponse( - new OAuthGatewayLoginResult - { - AccessCode = oAuthResult!.Value.AccessCode, - }); - } - /// /// Add a given to the . /// diff --git a/src/Tgstation.Server.Host/Authority/PermissionSetAuthority.cs b/src/Tgstation.Server.Host/Authority/PermissionSetAuthority.cs index 12e63a9ce6..431a1259f6 100644 --- a/src/Tgstation.Server.Host/Authority/PermissionSetAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/PermissionSetAuthority.cs @@ -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 /// readonly IPermissionSetsDataLoader permissionSetsDataLoader; + /// + /// The for the . + /// + readonly IClaimsPrincipalAccessor claimsPrincipalAccessor; + /// /// Implements . /// @@ -84,34 +90,59 @@ namespace Tgstation.Server.Host.Authority /// /// Initializes a new instance of the class. /// - /// The to use. /// The to use. /// The to use. /// The value of . + /// The value of . public PermissionSetAuthority( - IAuthenticationContext authenticationContext, IDatabaseContext databaseContext, ILogger 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)); } /// - public async ValueTask> GetId(long id, PermissionSetLookupType lookupType, CancellationToken cancellationToken) + public RequirementsGated> GetId(long id, PermissionSetLookupType lookupType, CancellationToken cancellationToken) { - if (id != AuthenticationContext.PermissionSet.Id && !((AdministrationRights)AuthenticationContext.GetRight(RightsType.Administration)).HasFlag(AdministrationRights.ReadUsers)) - return Forbid(); + 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(); + var groupIdQuery = DatabaseContext + .Users + .AsQueryable() + .Where(user => user.Id == userId) + .Select(user => user.GroupId); - return new AuthorityResponse(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(); + + return new AuthorityResponse(permissionSet); + }); } } } diff --git a/src/Tgstation.Server.Host/Authority/UserAuthority.cs b/src/Tgstation.Server.Host/Authority/UserAuthority.cs index e9577dbcfd..cbb3c06e38 100644 --- a/src/Tgstation.Server.Host/Authority/UserAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/UserAuthority.cs @@ -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 /// readonly ITopicEventSender topicEventSender; + /// + /// The for the . + /// + readonly IClaimsPrincipalAccessor claimsPrincipalAccessor; + /// /// The of for the . /// @@ -179,7 +187,6 @@ namespace Tgstation.Server.Host.Authority /// /// Initializes a new instance of the class. /// - /// The to use. /// The to use. /// The to use. /// The value of . @@ -190,10 +197,10 @@ namespace Tgstation.Server.Host.Authority /// The value of . /// The value of . /// The value of . + /// The value of . /// The value of . /// The value of . public UserAuthority( - IAuthenticationContext authenticationContext, IDatabaseContext databaseContext, ILogger logger, IUsersDataLoader usersDataLoader, @@ -204,10 +211,10 @@ namespace Tgstation.Server.Host.Authority ICryptographySuite cryptographySuite, ISessionInvalidationTracker sessionInvalidationTracker, ITopicEventSender topicEventSender, + IClaimsPrincipalAccessor claimsPrincipalAccessor, IOptionsSnapshot generalConfigurationOptions, IOptions 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 } /// - public ValueTask> Read(CancellationToken cancellationToken) - => ValueTask.FromResult(new AuthorityResponse(AuthenticationContext.User)); + public RequirementsGated> Read(CancellationToken cancellationToken) + => GetId( + claimsPrincipalAccessor.User.GetTgsUserId(), + false, + false, + cancellationToken); /// - public async ValueTask> GetId(long id, bool includeJoins, bool allowSystemUser, CancellationToken cancellationToken) - { - if (id != AuthenticationContext.User.Id && !((AdministrationRights)AuthenticationContext.GetRight(RightsType.Administration)).HasFlag(AdministrationRights.ReadUsers)) - return Forbid(); + public RequirementsGated> GetId(long id, bool includeJoins, bool allowSystemUser, CancellationToken cancellationToken) + => new( + () => + { + if (id != claimsPrincipalAccessor.User.GetTgsUserId()) + return Enumerable.Empty(); - User? user; - if (includeJoins) - { - var queryable = Queryable(true, true); + return new List + { + 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(); + if (user == default) + return NotFound(); - if (!allowSystemUser && user.CanonicalName == User.CanonicalizeName(User.TgsSystemUserName)) - return Forbid(); + if (!allowSystemUser && user.CanonicalName == User.CanonicalizeName(User.TgsSystemUserName)) + return Forbid(); - return new AuthorityResponse(user); - } + return new AuthorityResponse(user); + }); /// - public IQueryable Queryable(bool includeJoins) - => Queryable(includeJoins, false); + public RequirementsGated> Queryable(bool includeJoins) + => new( + () => Flag(AdministrationRights.ReadUsers), + () => ValueTask.FromResult(Queryable(includeJoins, false))); /// - public async ValueTask> OAuthConnections(long userId, CancellationToken cancellationToken) - => new AuthorityResponse( - await oAuthConnectionsDataLoader.LoadRequiredAsync(userId, cancellationToken)); + public RequirementsGated> OAuthConnections(long userId, CancellationToken cancellationToken) + => new( + () => claimsPrincipalAccessor.User.GetTgsUserId() != userId + ? Flag(AdministrationRights.ReadUsers) + : null, + async () => new AuthorityResponse( + await oAuthConnectionsDataLoader.LoadRequiredAsync(userId, cancellationToken))); /// - public async ValueTask> OidcConnections(long userId, CancellationToken cancellationToken) - => new AuthorityResponse( - await oidcConnectionsDataLoader.LoadRequiredAsync(userId, cancellationToken)); + public RequirementsGated> OidcConnections(long userId, CancellationToken cancellationToken) + => new( + () => claimsPrincipalAccessor.User.GetTgsUserId() != userId + ? Flag(AdministrationRights.ReadUsers) + : null, + async () => new AuthorityResponse( + await oidcConnectionsDataLoader.LoadRequiredAsync(userId, cancellationToken))); /// - public async ValueTask> Create( + public RequirementsGated> 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(ErrorCode.UserLimitReached); - - var dbUser = await CreateNewUserFromModel(createRequest, cancellationToken); - if (dbUser == null) - return Gone(); - - 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(ErrorCode.UserLimitReached); + + var dbUser = await CreateNewUserFromModel( + createRequest, + cancellationToken); + if (dbUser == null) return Gone(); - dbUser.Name = sysIdentity.Username; - dbUser.SystemIdentifier = sysIdentity.Uid; - } - catch (NotImplementedException ex) - { - Logger.LogTrace(ex, "System identities not implemented!"); - return new AuthorityResponse( - new ErrorMessageResponse(ErrorCode.RequiresPosixSystemIdentity), - HttpFailureResponse.NotImplemented); - } - else - { - 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(); + dbUser.Name = sysIdentity.Username; + dbUser.SystemIdentifier = sysIdentity.Uid; + } + catch (NotImplementedException ex) + { + Logger.LogTrace(ex, "System identities not implemented!"); + return new AuthorityResponse( + new ErrorMessageResponse(ErrorCode.RequiresPosixSystemIdentity), + HttpFailureResponse.NotImplemented); + } + else + { + 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(dbUser, HttpSuccessResponse.Created); - } + await SendUserUpdatedTopics(dbUser); + + return new AuthorityResponse(dbUser, HttpSuccessResponse.Created); + }); /// #pragma warning disable CA1502 #pragma warning disable CA1506 // TODO: Decomplexify - public async ValueTask> Update(UserUpdateRequest model, CancellationToken cancellationToken) + public RequirementsGated> 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(ErrorCode.ModelValidationFailure); - - if (model.Group != null && model.PermissionSet != null) - return BadRequest(ErrorCode.UserGroupAndPermissionSet); - - var callerAdministrationRights = (AdministrationRights)AuthenticationContext.GetRight(RightsType.Administration); - var canEditAllUsers = callerAdministrationRights.HasFlag(AdministrationRights.WriteUsers); - var passwordEdit = canEditAllUsers || callerAdministrationRights.HasFlag(AdministrationRights.EditOwnPassword); - var oAuthEdit = canEditAllUsers || callerAdministrationRights.HasFlag(AdministrationRights.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(); - - if (originalUser.CanonicalName == User.CanonicalizeName(User.TgsSystemUserName)) - return Forbid(); - - // Ensure they are only trying to edit things they have perms for (system identity change will trigger a bad request) - if ((!canEditAllUsers - && (model.Id != originalUser.Id - || model.Enabled.HasValue - || model.Group != null - || model.PermissionSet != null - || model.Name != null)) - || (!passwordEdit && model.Password != null) - || (!oAuthEdit && model.OAuthConnections != null)) - return Forbid(); - - var originalUserHasSid = originalUser.SystemIdentifier != null; - 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(ErrorCode.UserSidChange); - - if (model.Password != null) - { - if (originalUserHasSid) - return BadRequest(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(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(ErrorCode.BadUserEditDueToOidcStrictMode); - - if (originalUser.CanonicalName == User.CanonicalizeName(DefaultCredentials.AdminUserName)) - return BadRequest(ErrorCode.AdminUserCannotHaveServiceConnection); - - if (model.OAuthConnections.Count == 0 && originalUser.PasswordHash == null && originalUser.SystemIdentifier == null) - return BadRequest(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(ErrorCode.BadUserEditDueToOidcStrictMode); - - if (originalUser.CanonicalName == User.CanonicalizeName(DefaultCredentials.AdminUserName)) - return BadRequest(ErrorCode.AdminUserCannotHaveServiceConnection); - - if (model.OidcConnections.Count == 0 && originalUser.PasswordHash == null && originalUser.SystemIdentifier == null) - return BadRequest(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(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(); - - 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(ErrorCode.BadUserEditDueToOidcStrictMode); + RightsConditional? 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(ErrorCode.ModelValidationFailure); - originalUser.Group = null; - originalUser.GroupId = null; - } + if (model.Group != null && model.PermissionSet != null) + return BadRequest(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(); - if (model.Enabled.HasValue) - { - if (securityConfigurationOptions.Value.OidcStrictMode) - return BadRequest(ErrorCode.BadUserEditDueToOidcStrictMode); + if (originalUser.CanonicalName == User.CanonicalizeName(User.TgsSystemUserName)) + return Forbid(); - 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(ErrorCode.UserSidChange); - Logger.LogInformation("Updated user {userName} ({userId})", originalUser.Name, originalUser.Id); + if (model.Password != null) + { + if (originalUserHasSid) + return BadRequest(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(originalUser) - : new AuthorityResponse(); + if (model.Name != null && User.CanonicalizeName(model.Name) != originalUser.CanonicalName) + return BadRequest(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(ErrorCode.BadUserEditDueToOidcStrictMode); + + if (originalUser.CanonicalName == User.CanonicalizeName(DefaultCredentials.AdminUserName)) + return BadRequest(ErrorCode.AdminUserCannotHaveServiceConnection); + + if (model.OAuthConnections.Count == 0 && originalUser.PasswordHash == null && originalUser.SystemIdentifier == null) + return BadRequest(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(ErrorCode.BadUserEditDueToOidcStrictMode); + + if (originalUser.CanonicalName == User.CanonicalizeName(DefaultCredentials.AdminUserName)) + return BadRequest(ErrorCode.AdminUserCannotHaveServiceConnection); + + if (model.OidcConnections.Count == 0 && originalUser.PasswordHash == null && originalUser.SystemIdentifier == null) + return BadRequest(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(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(); + + 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(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(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(originalUser) + : new AuthorityResponse(); + }); } /// @@ -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, diff --git a/src/Tgstation.Server.Host/Authority/UserGroupAuthority.cs b/src/Tgstation.Server.Host/Authority/UserGroupAuthority.cs index 4b9fcdb401..a429b1bab4 100644 --- a/src/Tgstation.Server.Host/Authority/UserGroupAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/UserGroupAuthority.cs @@ -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 /// readonly IUserGroupsDataLoader userGroupsDataLoader; + /// + /// The for the . + /// + readonly IClaimsPrincipalAccessor claimsPrincipalAccessor; + /// /// The of the . /// @@ -59,58 +66,181 @@ namespace Tgstation.Server.Host.Authority /// /// Initializes a new instance of the class. /// - /// The to use. /// The to use. /// The to use. + /// The value of . /// The value of . /// The value of . public UserGroupAuthority( - IAuthenticationContext authenticationContext, IDatabaseContext databaseContext, ILogger logger, IUserGroupsDataLoader userGroupsDataLoader, + IClaimsPrincipalAccessor claimsPrincipalAccessor, IOptionsSnapshot 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)); } /// - public async ValueTask> GetId(long id, bool includeJoins, CancellationToken cancellationToken) + public RequirementsGated> 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(); + + return new AuthorityResponse(userGroup); + }); + + /// + public RequirementsGated> 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(); + + return new AuthorityResponse(group); + }); + + /// + public RequirementsGated> Queryable(bool includeJoins) + => new( + () => Flag(AdministrationRights.ReadUsers), + () => ValueTask.FromResult(QueryableImpl(includeJoins))); + + /// + public RequirementsGated> Create(string name, Models.PermissionSet? permissionSet, CancellationToken cancellationToken) { - if (id != AuthenticationContext.User.GroupId && !((AdministrationRights)AuthenticationContext.GetRight(RightsType.Administration)).HasFlag(AdministrationRights.ReadUsers)) - return Forbid(); + ArgumentNullException.ThrowIfNull(name); + return new( + () => Flag(AdministrationRights.WriteUsers), + async () => + { + var totalGroups = await DatabaseContext + .Groups + .AsQueryable() + .CountAsync(cancellationToken); + if (totalGroups >= generalConfigurationOptions.Value.UserGroupLimit) + return Conflict(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(); + var dbGroup = new UserGroup + { + Name = name, + PermissionSet = modelPermissionSet, + }; - return new AuthorityResponse(userGroup); + DatabaseContext.Groups.Add(dbGroup); + await DatabaseContext.Save(cancellationToken); + Logger.LogInformation("Created new user group {groupName} ({groupId})", dbGroup.Name, dbGroup.Id); + + return new AuthorityResponse( + dbGroup, + HttpSuccessResponse.Created); + }); } /// - public ValueTask> Read() - { - var group = AuthenticationContext.User!.Group; - if (group == null) - return ValueTask.FromResult(Gone()); + public RequirementsGated> 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(group)); - } + if (currentGroup == default) + return Gone(); + + if (newPermissionSet != null) + { + currentGroup.PermissionSet!.AdministrationRights = newPermissionSet.AdministrationRights ?? currentGroup.PermissionSet.AdministrationRights; + currentGroup.PermissionSet.InstanceManagerRights = newPermissionSet.InstanceManagerRights ?? currentGroup.PermissionSet.InstanceManagerRights; + } + + currentGroup.Name = newName ?? currentGroup.Name; + + await DatabaseContext.Save(cancellationToken); + + return new AuthorityResponse(currentGroup); + }); /// - public IQueryable Queryable(bool includeJoins) + public RequirementsGated 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); + }); + + /// + /// Get the s. + /// + /// If and should be included. + /// An of s. + IQueryable QueryableImpl(bool includeJoins) { var queryable = DatabaseContext .Groups @@ -123,92 +253,5 @@ namespace Tgstation.Server.Host.Authority return queryable; } - - /// - public async ValueTask> Create(string name, Models.PermissionSet? permissionSet, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(name); - - var totalGroups = await DatabaseContext - .Groups - .AsQueryable() - .CountAsync(cancellationToken); - if (totalGroups >= generalConfigurationOptions.Value.UserGroupLimit) - return Conflict(ErrorCode.UserGroupLimitReached); - - var modelPermissionSet = new Models.PermissionSet - { - AdministrationRights = permissionSet?.AdministrationRights ?? AdministrationRights.None, - InstanceManagerRights = permissionSet?.InstanceManagerRights ?? InstanceManagerRights.None, - }; - - var dbGroup = new UserGroup - { - Name = name, - PermissionSet = modelPermissionSet, - }; - - DatabaseContext.Groups.Add(dbGroup); - await DatabaseContext.Save(cancellationToken); - Logger.LogInformation("Created new user group {groupName} ({groupId})", dbGroup.Name, dbGroup.Id); - - return new AuthorityResponse( - dbGroup, - HttpSuccessResponse.Created); - } - - /// - public async ValueTask> Update(long id, string? newName, Models.PermissionSet? newPermissionSet, CancellationToken cancellationToken) - { - var currentGroup = await DatabaseContext - .Groups - .AsQueryable() - .Where(x => x.Id == id) - .Include(x => x.PermissionSet) - .FirstOrDefaultAsync(cancellationToken); - - if (currentGroup == default) - return Gone(); - - if (newPermissionSet != null) - { - currentGroup.PermissionSet!.AdministrationRights = newPermissionSet.AdministrationRights ?? currentGroup.PermissionSet.AdministrationRights; - currentGroup.PermissionSet.InstanceManagerRights = newPermissionSet.InstanceManagerRights ?? currentGroup.PermissionSet.InstanceManagerRights; - } - - currentGroup.Name = newName ?? currentGroup.Name; - - await DatabaseContext.Save(cancellationToken); - - return new AuthorityResponse(currentGroup); - } - - /// - public async ValueTask DeleteEmpty(long id, CancellationToken cancellationToken) - { - var numDeleted = await DatabaseContext - .Groups - .AsQueryable() - .Where(x => x.Id == id && x.Users!.Count == 0) - .ExecuteDeleteAsync(cancellationToken); - - if (numDeleted > 0) - return new(); - - // find out how we failed - var groupExists = await DatabaseContext - .Groups - .AsQueryable() - .Where(x => x.Id == id) - .AnyAsync(cancellationToken); - - return new( - groupExists - ? new ErrorMessageResponse(ErrorCode.UserGroupNotEmpty) - : new ErrorMessageResponse(), - groupExists - ? HttpFailureResponse.Conflict - : HttpFailureResponse.Gone); - } } } diff --git a/src/Tgstation.Server.Host/Controllers/ApiController.cs b/src/Tgstation.Server.Host/Controllers/ApiController.cs index 9b2b058010..771972a0f7 100644 --- a/src/Tgstation.Server.Host/Controllers/ApiController.cs +++ b/src/Tgstation.Server.Host/Controllers/ApiController.cs @@ -249,7 +249,7 @@ namespace Tgstation.Server.Host.Controllers /// The for the operation. /// A resulting in the for the operation. protected ValueTask Paginated( - Func>> queryGenerator, + Func?>> queryGenerator, Func? resultTransformer, int? pageQuery, int? pageSizeQuery, @@ -272,7 +272,7 @@ namespace Tgstation.Server.Host.Controllers /// The for the operation. /// A resulting in the for the operation. protected ValueTask Paginated( - Func>> queryGenerator, + Func?>> queryGenerator, Func? resultTransformer, int? pageQuery, int? pageSizeQuery, @@ -290,14 +290,14 @@ namespace Tgstation.Server.Host.Controllers /// /// The of model being generated. If different from , must implement for . /// The of model being returned. - /// A resulting in a resulting in the generated . + /// A resulting in a resulting in the generated or if an authorization requirment failed. /// A to transform the s after being queried. /// The requested page from the query. /// The requested page size from the query. /// The for the operation. /// A resulting in the for the operation. async ValueTask PaginatedImpl( - Func>> queryGenerator, + Func?>> queryGenerator, Func? 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; diff --git a/src/Tgstation.Server.Host/Controllers/ChatController.cs b/src/Tgstation.Server.Host/Controllers/ChatController.cs index fd6cf80e1d..2852768184 100644 --- a/src/Tgstation.Server.Host/Controllers/ChatController.cs +++ b/src/Tgstation.Server.Host/Controllers/ChatController.cs @@ -208,7 +208,7 @@ namespace Tgstation.Server.Host.Controllers { var connectionStrings = (AuthenticationContext.GetRight(RightsType.ChatBots) & (ulong)ChatBotRights.ReadConnectionString) != 0; return Paginated( - () => ValueTask.FromResult( + () => ValueTask.FromResult?>( new PaginatableResult( DatabaseContext .ChatBots diff --git a/src/Tgstation.Server.Host/Controllers/DreamMakerController.cs b/src/Tgstation.Server.Host/Controllers/DreamMakerController.cs index 7f8755fdd2..59e44772f8 100644 --- a/src/Tgstation.Server.Host/Controllers/DreamMakerController.cs +++ b/src/Tgstation.Server.Host/Controllers/DreamMakerController.cs @@ -126,7 +126,7 @@ namespace Tgstation.Server.Host.Controllers [ProducesResponseType(typeof(PaginatedResponse), 200)] public ValueTask List([FromQuery] int? page, [FromQuery] int? pageSize, CancellationToken cancellationToken) => Paginated( - () => ValueTask.FromResult( + () => ValueTask.FromResult?>( new PaginatableResult( BaseCompileJobsQuery() .OrderByDescending(x => x.Job.StoppedAt))), diff --git a/src/Tgstation.Server.Host/Controllers/EngineController.cs b/src/Tgstation.Server.Host/Controllers/EngineController.cs index 83a113e3b4..89a1ba8abe 100644 --- a/src/Tgstation.Server.Host/Controllers/EngineController.cs +++ b/src/Tgstation.Server.Host/Controllers/EngineController.cs @@ -110,7 +110,7 @@ namespace Tgstation.Server.Host.Controllers public ValueTask List([FromQuery] int? page, [FromQuery] int? pageSize, CancellationToken cancellationToken) => WithComponentInstance( instance => Paginated( - () => ValueTask.FromResult( + () => ValueTask.FromResult?>( new PaginatableResult( instance .EngineManager diff --git a/src/Tgstation.Server.Host/Controllers/InstanceController.cs b/src/Tgstation.Server.Host/Controllers/InstanceController.cs index 8f3f53dca2..633b0d2ecb 100644 --- a/src/Tgstation.Server.Host/Controllers/InstanceController.cs +++ b/src/Tgstation.Server.Host/Controllers/InstanceController.cs @@ -612,7 +612,7 @@ namespace Tgstation.Server.Host.Controllers var needsUpdate = false; var result = await Paginated( - () => ValueTask.FromResult( + () => ValueTask.FromResult?>( new PaginatableResult( GetBaseQuery() .OrderBy(x => x.Id))), diff --git a/src/Tgstation.Server.Host/Controllers/InstancePermissionSetController.cs b/src/Tgstation.Server.Host/Controllers/InstancePermissionSetController.cs index 5b4ac2a9c4..01f51bdc0c 100644 --- a/src/Tgstation.Server.Host/Controllers/InstancePermissionSetController.cs +++ b/src/Tgstation.Server.Host/Controllers/InstancePermissionSetController.cs @@ -197,7 +197,7 @@ namespace Tgstation.Server.Host.Controllers [ProducesResponseType(typeof(PaginatedResponse), 200)] public ValueTask List([FromQuery] int? page, [FromQuery] int? pageSize, CancellationToken cancellationToken) => Paginated( - () => ValueTask.FromResult( + () => ValueTask.FromResult?>( new PaginatableResult( DatabaseContext .Instances diff --git a/src/Tgstation.Server.Host/Controllers/JobController.cs b/src/Tgstation.Server.Host/Controllers/JobController.cs index 2b9f1b15f1..2cfb962891 100644 --- a/src/Tgstation.Server.Host/Controllers/JobController.cs +++ b/src/Tgstation.Server.Host/Controllers/JobController.cs @@ -71,7 +71,7 @@ namespace Tgstation.Server.Host.Controllers [ProducesResponseType(typeof(PaginatedResponse), 200)] public ValueTask Read([FromQuery] int? page, [FromQuery] int? pageSize, CancellationToken cancellationToken) => Paginated( - () => ValueTask.FromResult( + () => ValueTask.FromResult?>( new PaginatableResult( DatabaseContext .Jobs @@ -99,7 +99,7 @@ namespace Tgstation.Server.Host.Controllers [ProducesResponseType(typeof(PaginatedResponse), 200)] public ValueTask List([FromQuery] int? page, [FromQuery] int? pageSize, CancellationToken cancellationToken) => Paginated( - () => ValueTask.FromResult( + () => ValueTask.FromResult?>( new PaginatableResult( DatabaseContext .Jobs diff --git a/src/Tgstation.Server.Host/Controllers/UserController.cs b/src/Tgstation.Server.Host/Controllers/UserController.cs index f55218ef64..50c393e051 100644 --- a/src/Tgstation.Server.Host/Controllers/UserController.cs +++ b/src/Tgstation.Server.Host/Controllers/UserController.cs @@ -113,11 +113,15 @@ namespace Tgstation.Server.Host.Controllers [ProducesResponseType(typeof(PaginatedResponse), 200)] public ValueTask List([FromQuery] int? page, [FromQuery] int? pageSize, CancellationToken cancellationToken) => Paginated( - () => ValueTask.FromResult( - new PaginatableResult( - 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(queryable.OrderBy(x => x.Id)); + }, null, page, pageSize, diff --git a/src/Tgstation.Server.Host/Controllers/UserGroupController.cs b/src/Tgstation.Server.Host/Controllers/UserGroupController.cs index 3ff0f0bcac..9913dd7147 100644 --- a/src/Tgstation.Server.Host/Controllers/UserGroupController.cs +++ b/src/Tgstation.Server.Host/Controllers/UserGroupController.cs @@ -146,11 +146,15 @@ namespace Tgstation.Server.Host.Controllers [ProducesResponseType(typeof(PaginatedResponse), 200)] public ValueTask List([FromQuery] int? page, [FromQuery] int? pageSize, CancellationToken cancellationToken) => Paginated( - () => ValueTask.FromResult( - new PaginatableResult( - 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(queryable.OrderBy(x => x.Id)); + }, null, page, pageSize, diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs index 10f392b572..89aafa70a1 100644 --- a/src/Tgstation.Server.Host/Core/Application.cs +++ b/src/Tgstation.Server.Host/Core/Application.cs @@ -330,15 +330,6 @@ namespace Tgstation.Server.Host.Core services .AddScoped() .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; diff --git a/src/Tgstation.Server.Host/GraphQL/Mutations/AdministrationMutations.cs b/src/Tgstation.Server.Host/GraphQL/Mutations/AdministrationMutations.cs index 7390113a24..506ad173fd 100644 --- a/src/Tgstation.Server.Host/GraphQL/Mutations/AdministrationMutations.cs +++ b/src/Tgstation.Server.Host/GraphQL/Mutations/AdministrationMutations.cs @@ -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 /// /// The for the . /// A representing the running operation. - [TgsGraphQLAuthorize(nameof(IAdministrationAuthority.TriggerServerRestart))] [Error(typeof(ErrorMessageException))] public async ValueTask RestartServerNode( [Service] IGraphQLAuthorityInvoker administrationAuthority) @@ -44,7 +41,6 @@ namespace Tgstation.Server.Host.GraphQL.Mutations /// The for the . /// The for the operation. /// A representing the running operation. - [TgsGraphQLAuthorize(AdministrationRights.ChangeVersion)] [Error(typeof(ErrorMessageException))] public async ValueTask ChangeServerNodeVersionViaTrackedRepository( Version targetVersion, @@ -65,7 +61,6 @@ namespace Tgstation.Server.Host.GraphQL.Mutations /// The for the . /// The for the operation. /// A FileTicket that should be used to upload a zip containing the update data to the file transfer service. - [TgsGraphQLAuthorize(AdministrationRights.UploadVersion)] [Error(typeof(ErrorMessageException))] [GraphQLType] public async ValueTask ChangeServerNodeVersionViaUpload( diff --git a/src/Tgstation.Server.Host/GraphQL/Mutations/UserGroupMutations.cs b/src/Tgstation.Server.Host/GraphQL/Mutations/UserGroupMutations.cs index 7f27d8ea76..912780cacb 100644 --- a/src/Tgstation.Server.Host/GraphQL/Mutations/UserGroupMutations.cs +++ b/src/Tgstation.Server.Host/GraphQL/Mutations/UserGroupMutations.cs @@ -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 /// The for the . /// The for the operation. /// The created . - [TgsGraphQLAuthorize(nameof(IUserGroupAuthority.Create))] [Error(typeof(ErrorMessageException))] public ValueTask CreateUserGroup( string name, @@ -67,7 +65,6 @@ namespace Tgstation.Server.Host.GraphQL.Mutations /// The for the . /// The for the operation. /// The updated . - [TgsGraphQLAuthorize(nameof(IUserGroupAuthority.Update))] [Error(typeof(ErrorMessageException))] public ValueTask UpdateUserGroup( [ID(nameof(UserGroup))] long id, @@ -88,7 +85,6 @@ namespace Tgstation.Server.Host.GraphQL.Mutations /// The for the . /// The for the operation. /// The root. - [TgsGraphQLAuthorize(nameof(IUserGroupAuthority.DeleteEmpty))] [Error(typeof(ErrorMessageException))] public async ValueTask DeleteEmptyUserGroup( [ID(nameof(UserGroup))] long id, diff --git a/src/Tgstation.Server.Host/GraphQL/Mutations/UserMutations.cs b/src/Tgstation.Server.Host/GraphQL/Mutations/UserMutations.cs index 1653ef0165..2ad2d6f6f5 100644 --- a/src/Tgstation.Server.Host/GraphQL/Mutations/UserMutations.cs +++ b/src/Tgstation.Server.Host/GraphQL/Mutations/UserMutations.cs @@ -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 /// The for the . /// The for the operation. /// The created . - [TgsGraphQLAuthorize(nameof(IUserAuthority.Create))] [Error(typeof(ErrorMessageException))] public ValueTask CreateUserByPasswordAndPermissionSet( string name, @@ -99,7 +97,6 @@ namespace Tgstation.Server.Host.GraphQL.Mutations /// The for the . /// The for the operation. /// The created . - [TgsGraphQLAuthorize(nameof(IUserAuthority.Create))] [Error(typeof(ErrorMessageException))] public ValueTask CreateUserByPasswordAndGroup( string name, @@ -156,7 +153,6 @@ namespace Tgstation.Server.Host.GraphQL.Mutations /// The for the . /// The for the operation. /// The created . - [TgsGraphQLAuthorize(nameof(IUserAuthority.Create))] [Error(typeof(ErrorMessageException))] public ValueTask CreateUserByServiceConnectionAndPermissionSet( string name, @@ -215,7 +211,6 @@ namespace Tgstation.Server.Host.GraphQL.Mutations /// The for the . /// The for the operation. /// The created . - [TgsGraphQLAuthorize(nameof(IUserAuthority.Create))] [Error(typeof(ErrorMessageException))] public ValueTask CreateUserByServiceConnectionAndGroup( string name, @@ -271,7 +266,6 @@ namespace Tgstation.Server.Host.GraphQL.Mutations /// The for the . /// The for the operation. /// The created . - [TgsGraphQLAuthorize(nameof(IUserAuthority.Create))] [Error(typeof(ErrorMessageException))] public ValueTask CreateUserBySystemIDAndPermissionSet( string systemIdentifier, @@ -328,7 +322,6 @@ namespace Tgstation.Server.Host.GraphQL.Mutations /// The for the . /// The for the operation. /// The created . - [TgsGraphQLAuthorize(nameof(IUserAuthority.Create))] [Error(typeof(ErrorMessageException))] public ValueTask CreateUserBySystemIDAndGroup( string systemIdentifier, @@ -379,7 +372,6 @@ namespace Tgstation.Server.Host.GraphQL.Mutations /// The for the . /// The for the operation. /// The updated current . - [TgsGraphQLAuthorize(AdministrationRights.WriteUsers | AdministrationRights.EditOwnPassword)] [Error(typeof(ErrorMessageException))] public ValueTask SetCurrentUserPassword( string newPassword, @@ -390,7 +382,7 @@ namespace Tgstation.Server.Host.GraphQL.Mutations ArgumentNullException.ThrowIfNull(newPassword); ArgumentNullException.ThrowIfNull(userAuthority); return userAuthority.InvokeTransformable( - async authority => await authority.Update( + authority => authority.Update( new UserUpdateRequest { Id = authenticationContext.User.Id, @@ -408,7 +400,6 @@ namespace Tgstation.Server.Host.GraphQL.Mutations /// The for the . /// The for the operation. /// The updated current . - [TgsGraphQLAuthorize(AdministrationRights.WriteUsers | AdministrationRights.EditOwnServiceConnections)] [Error(typeof(ErrorMessageException))] public ValueTask SetCurrentServiceConnections( IEnumerable? newOAuthConnections, @@ -420,7 +411,7 @@ namespace Tgstation.Server.Host.GraphQL.Mutations ArgumentNullException.ThrowIfNull(newOAuthConnections); ArgumentNullException.ThrowIfNull(userAuthority); return userAuthority.InvokeTransformable( - async authority => await authority.Update( + authority => authority.Update( new UserUpdateRequest { Id = authenticationContext.User.Id, @@ -454,7 +445,6 @@ namespace Tgstation.Server.Host.GraphQL.Mutations /// The for the . /// The for the operation. /// The updated . - [TgsGraphQLAuthorize(AdministrationRights.WriteUsers)] [Error(typeof(ErrorMessageException))] public ValueTask UpdateUser( [ID(nameof(User))] long id, @@ -493,7 +483,6 @@ namespace Tgstation.Server.Host.GraphQL.Mutations /// The for the . /// The for the operation. /// The updated . - [TgsGraphQLAuthorize(AdministrationRights.WriteUsers)] [Error(typeof(ErrorMessageException))] public ValueTask UpdateUserSetOwnedPermissionSet( [ID(nameof(User))] long id, @@ -533,7 +522,6 @@ namespace Tgstation.Server.Host.GraphQL.Mutations /// The for the . /// The for the operation. /// The updated . - [TgsGraphQLAuthorize(AdministrationRights.WriteUsers)] [Error(typeof(ErrorMessageException))] public ValueTask UpdateUserSetGroup( [ID(nameof(User))] long id, @@ -586,7 +574,7 @@ namespace Tgstation.Server.Host.GraphQL.Mutations IGraphQLAuthorityInvoker userAuthority, CancellationToken cancellationToken) => userAuthority.InvokeTransformable( - async authority => await authority.Update( + authority => authority.Update( new UserUpdateRequest { Id = id, diff --git a/src/Tgstation.Server.Host/GraphQL/Subscription.cs b/src/Tgstation.Server.Host/GraphQL/Subscription.cs index 170dceb49a..93d8584719 100644 --- a/src/Tgstation.Server.Host/GraphQL/Subscription.cs +++ b/src/Tgstation.Server.Host/GraphQL/Subscription.cs @@ -63,7 +63,6 @@ namespace Tgstation.Server.Host.GraphQL /// The received from the publisher. /// The . [Subscribe(With = nameof(SessionInvalidatedStream))] - [TgsGraphQLAuthorize] public SessionInvalidationReason SessionInvalidated([EventMessage] SessionInvalidationReason sessionInvalidationReason) => sessionInvalidationReason; } diff --git a/src/Tgstation.Server.Host/GraphQL/Subscriptions/UserSubscriptions.cs b/src/Tgstation.Server.Host/GraphQL/Subscriptions/UserSubscriptions.cs index 35be564c52..2b23feba9e 100644 --- a/src/Tgstation.Server.Host/GraphQL/Subscriptions/UserSubscriptions.cs +++ b/src/Tgstation.Server.Host/GraphQL/Subscriptions/UserSubscriptions.cs @@ -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 /// The received from the publisher. /// The updated . [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 /// The received from the publisher. /// The updated . [Subscribe(With = nameof(CurrentUserUpdatedStream))] - [TgsGraphQLAuthorize] public User CurrentUserUpdated([EventMessage] User user) { ArgumentNullException.ThrowIfNull(user); diff --git a/src/Tgstation.Server.Host/GraphQL/Types/GatewayInformation.cs b/src/Tgstation.Server.Host/GraphQL/Types/GatewayInformation.cs index 9a1aee31cf..773ee48da8 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/GatewayInformation.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/GatewayInformation.cs @@ -30,8 +30,10 @@ namespace Tgstation.Server.Host.GraphQL.Types /// A specifying the minimumn valid password length for TGS users. [TgsGraphQLAuthorize(AdministrationRights.WriteUsers | AdministrationRights.EditOwnPassword)] public uint MinimumPasswordLength( + [Service] IAuthorizationService authorizationService, [Service] IOptionsSnapshot generalConfigurationOptions) { + ArgumentNullException.ThrowIfNull(authorizationService); ArgumentNullException.ThrowIfNull(generalConfigurationOptions); return generalConfigurationOptions.Value.MinimumPasswordLength; } diff --git a/src/Tgstation.Server.Host/GraphQL/Types/User.cs b/src/Tgstation.Server.Host/GraphQL/Types/User.cs index 7de3e7ca2a..7966144086 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/User.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/User.cs @@ -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 /// The for the . /// The for the operation. /// A resulting in the queried , if present. - [TgsGraphQLAuthorize] public static ValueTask GetUser( long id, [Service] IGraphQLAuthorityInvoker userAuthority, diff --git a/src/Tgstation.Server.Host/GraphQL/Types/UserGroup.cs b/src/Tgstation.Server.Host/GraphQL/Types/UserGroup.cs index 2511ab438a..20aef93411 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/UserGroup.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/UserGroup.cs @@ -63,14 +63,14 @@ namespace Tgstation.Server.Host.GraphQL.Types [UseFiltering] [UseSorting] [TgsGraphQLAuthorize(nameof(IUserAuthority.Queryable))] - public IQueryable QueryableUsersByGroup( + public async ValueTask> QueryableUsersByGroup( [Service] IGraphQLAuthorityInvoker userAuthority) { ArgumentNullException.ThrowIfNull(userAuthority); - var dtoQueryable = userAuthority.InvokeTransformableQueryable( + var dtoQueryable = await userAuthority.InvokeTransformableQueryable( authority => authority - .Queryable(false) - .Where(user => user.GroupId == Id)); + .Queryable(false), + queryable => queryable.Where(user => user.GroupId == Id)); return dtoQueryable; } } diff --git a/src/Tgstation.Server.Host/GraphQL/Types/UserGroups.cs b/src/Tgstation.Server.Host/GraphQL/Types/UserGroups.cs index eb9fc04a52..1df6b15f48 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/UserGroups.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/UserGroups.cs @@ -23,12 +23,14 @@ namespace Tgstation.Server.Host.GraphQL.Types /// Gets the current . /// /// The for the . + /// The for the operation. /// A resulting in the current 's . public ValueTask Current( - [Service] IGraphQLAuthorityInvoker userGroupAuthority) + [Service] IGraphQLAuthorityInvoker userGroupAuthority, + CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(userGroupAuthority); - return userGroupAuthority.InvokeTransformableAllowMissing(authority => authority.Read()); + return userGroupAuthority.InvokeTransformableAllowMissing(authority => authority.Read(cancellationToken)); } /// @@ -54,11 +56,12 @@ namespace Tgstation.Server.Host.GraphQL.Types [UseFiltering] [UseSorting] [TgsGraphQLAuthorize(nameof(IUserGroupAuthority.Queryable))] - public IQueryable QueryableGroups( + public async ValueTask> QueryableGroups( [Service] IGraphQLAuthorityInvoker userGroupAuthority) { ArgumentNullException.ThrowIfNull(userGroupAuthority); - var dtoQueryable = userGroupAuthority.InvokeTransformableQueryable(authority => authority.Queryable(false)); + var dtoQueryable = await userGroupAuthority.InvokeTransformableQueryable( + authority => authority.Queryable(false)); return dtoQueryable; } @@ -72,15 +75,15 @@ namespace Tgstation.Server.Host.GraphQL.Types [UseFiltering] [UseSorting] [TgsGraphQLAuthorize(nameof(IUserAuthority.Queryable))] - public IQueryable QueryableUsersByGroupId( + public async ValueTask> QueryableUsersByGroupId( [ID(nameof(UserGroup))]long groupId, [Service] IGraphQLAuthorityInvoker userAuthority) { ArgumentNullException.ThrowIfNull(userAuthority); - var dtoQueryable = userAuthority.InvokeTransformableQueryable( + var dtoQueryable = await userAuthority.InvokeTransformableQueryable( authority => authority - .Queryable(false) - .Where(user => user.GroupId == groupId)); + .Queryable(false), + queryable => queryable.Where(user => user.GroupId == groupId)); return dtoQueryable; } } diff --git a/src/Tgstation.Server.Host/GraphQL/Types/Users.cs b/src/Tgstation.Server.Host/GraphQL/Types/Users.cs index 8ec3667fe3..87874a2095 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/Users.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/Users.cs @@ -81,11 +81,12 @@ namespace Tgstation.Server.Host.GraphQL.Types [UseFiltering] [UseSorting] [TgsGraphQLAuthorize(nameof(IUserAuthority.Queryable))] - public IQueryable QueryableUsers( + public async ValueTask> QueryableUsers( [Service] IGraphQLAuthorityInvoker userAuthority) { ArgumentNullException.ThrowIfNull(userAuthority); - var dtoQueryable = userAuthority.InvokeTransformableQueryable(authority => authority.Queryable(false)); + var dtoQueryable = await userAuthority.InvokeTransformableQueryable( + authority => authority.Queryable(false)); return dtoQueryable; } } diff --git a/src/Tgstation.Server.Host/Security/AuthorizationService.cs b/src/Tgstation.Server.Host/Security/AuthorizationService.cs new file mode 100644 index 0000000000..1abd12ae06 --- /dev/null +++ b/src/Tgstation.Server.Host/Security/AuthorizationService.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +using Microsoft.AspNetCore.Http; + +namespace Tgstation.Server.Host.Security +{ + /// + sealed class AuthorizationService : IAuthorizationService + { + /// + /// The for the . + /// + readonly IClaimsPrincipalAccessor claimsPrincipalAccessor; + + /// + /// The for the . + /// + readonly Microsoft.AspNetCore.Authorization.IAuthorizationService aspNetCoreAuthorizationService; + + /// + /// Initializes a new instance of the class. + /// + /// The value of . + /// The value of . + 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)); + } + + /// + public async ValueTask AuthorizeAsync(IEnumerable requirements) + { + ArgumentNullException.ThrowIfNull(requirements); + var result = await aspNetCoreAuthorizationService.AuthorizeAsync( + claimsPrincipalAccessor.User, + null, + requirements); + + return result.Succeeded; + } + } +} diff --git a/src/Tgstation.Server.Host/Security/ClaimsPrincipalAccessor.cs b/src/Tgstation.Server.Host/Security/ClaimsPrincipalAccessor.cs new file mode 100644 index 0000000000..ae20f8b7ca --- /dev/null +++ b/src/Tgstation.Server.Host/Security/ClaimsPrincipalAccessor.cs @@ -0,0 +1,30 @@ +using System; +using System.Security.Claims; + +using Microsoft.AspNetCore.Http; + +namespace Tgstation.Server.Host.Security +{ + /// + sealed class ClaimsPrincipalAccessor : IClaimsPrincipalAccessor + { + /// + public ClaimsPrincipal User => httpContextAccessor.HttpContext?.User + ?? throw new InvalidOperationException("HTTP context was not present!"); + + /// + /// The for the . + /// + readonly IHttpContextAccessor httpContextAccessor; + + /// + /// Initializes a new instance of the class. + /// + /// The value of . + public ClaimsPrincipalAccessor( + IHttpContextAccessor httpContextAccessor) + { + this.httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor)); + } + } +} diff --git a/src/Tgstation.Server.Host/Security/IAuthorizationService.cs b/src/Tgstation.Server.Host/Security/IAuthorizationService.cs new file mode 100644 index 0000000000..032286572a --- /dev/null +++ b/src/Tgstation.Server.Host/Security/IAuthorizationService.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +using Microsoft.AspNetCore.Authorization; + +namespace Tgstation.Server.Host.Security +{ + /// + /// Interface for evaluating s. + /// + public interface IAuthorizationService + { + /// + /// Attempt to authorize the current context with a given . + /// + /// The to authorize. + /// A resulting in if authorization succeeded. otherwise. + ValueTask AuthorizeAsync(IEnumerable requirement); + } +} diff --git a/src/Tgstation.Server.Host/Security/IClaimsPrincipalAccessor.cs b/src/Tgstation.Server.Host/Security/IClaimsPrincipalAccessor.cs new file mode 100644 index 0000000000..9ac3da2494 --- /dev/null +++ b/src/Tgstation.Server.Host/Security/IClaimsPrincipalAccessor.cs @@ -0,0 +1,15 @@ +using System.Security.Claims; + +namespace Tgstation.Server.Host.Security +{ + /// + /// Interface for accessing the current request's . + /// + interface IClaimsPrincipalAccessor + { + /// + /// Get the current . + /// + ClaimsPrincipal User { get; } + } +} diff --git a/src/Tgstation.Server.Host/Security/RightsEvaluation/FlagRightsConditional{TRights}.cs b/src/Tgstation.Server.Host/Security/RightsEvaluation/FlagRightsConditional{TRights}.cs index b3068d83d6..5aa41b8bc7 100644 --- a/src/Tgstation.Server.Host/Security/RightsEvaluation/FlagRightsConditional{TRights}.cs +++ b/src/Tgstation.Server.Host/Security/RightsEvaluation/FlagRightsConditional{TRights}.cs @@ -10,7 +10,7 @@ namespace Tgstation.Server.Host.Security.RightsEvaluation where TRights : Enum { /// - /// The single bit flag of the. + /// The single bit flag of the . /// readonly TRights flag; diff --git a/src/Tgstation.Server.Host/Security/TgsGraphQLAuthorizeAttribute.cs b/src/Tgstation.Server.Host/Security/TgsGraphQLAuthorizeAttribute.cs deleted file mode 100644 index 2bd0bfac52..0000000000 --- a/src/Tgstation.Server.Host/Security/TgsGraphQLAuthorizeAttribute.cs +++ /dev/null @@ -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 -{ - /// - /// Helper for using the with the system. - /// -#pragma warning disable CA1019 - [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Property, AllowMultiple = true, Inherited = true)] - sealed class TgsGraphQLAuthorizeAttribute : AuthorizeAttribute - { - /// - /// Gets the associated with the if any. - /// - public RightsType? RightsType { get; } - - /// - /// Initializes a new instance of the class. - /// - public TgsGraphQLAuthorizeAttribute() - : this(Enumerable.Empty()) - { - } - - /// - /// Initializes a new instance of the class. - /// - /// The required. - public TgsGraphQLAuthorizeAttribute(AdministrationRights requiredRights) - : this(RightsHelper.RoleNames(requiredRights)) - { - RightsType = Api.Rights.RightsType.Administration; - } - - /// - /// Initializes a new instance of the class. - /// - /// The required. - public TgsGraphQLAuthorizeAttribute(InstanceManagerRights requiredRights) - : this(RightsHelper.RoleNames(requiredRights)) - { - RightsType = Api.Rights.RightsType.InstanceManager; - } - - /// - /// Initializes a new instance of the class. - /// - /// The required. - public TgsGraphQLAuthorizeAttribute(RepositoryRights requiredRights) - : this(RightsHelper.RoleNames(requiredRights)) - { - RightsType = Api.Rights.RightsType.Repository; - } - - /// - /// Initializes a new instance of the class. - /// - /// The required. - public TgsGraphQLAuthorizeAttribute(EngineRights requiredRights) - : this(RightsHelper.RoleNames(requiredRights)) - { - RightsType = Api.Rights.RightsType.Engine; - } - - /// - /// Initializes a new instance of the class. - /// - /// The required. - public TgsGraphQLAuthorizeAttribute(DreamMakerRights requiredRights) - : this(RightsHelper.RoleNames(requiredRights)) - { - RightsType = Api.Rights.RightsType.DreamMaker; - } - - /// - /// Initializes a new instance of the class. - /// - /// The required. - public TgsGraphQLAuthorizeAttribute(DreamDaemonRights requiredRights) - : this(RightsHelper.RoleNames(requiredRights)) - { - RightsType = Api.Rights.RightsType.DreamDaemon; - } - - /// - /// Initializes a new instance of the class. - /// - /// The required. - public TgsGraphQLAuthorizeAttribute(ChatBotRights requiredRights) - : this(RightsHelper.RoleNames(requiredRights)) - { - RightsType = Api.Rights.RightsType.ChatBots; - } - - /// - /// Initializes a new instance of the class. - /// - /// The required. - public TgsGraphQLAuthorizeAttribute(ConfigurationRights requiredRights) - : this(RightsHelper.RoleNames(requiredRights)) - { - RightsType = Api.Rights.RightsType.Configuration; - } - - /// - /// Initializes a new instance of the class. - /// - /// The required. - public TgsGraphQLAuthorizeAttribute(InstancePermissionSetRights requiredRights) - : this(RightsHelper.RoleNames(requiredRights)) - { - RightsType = Api.Rights.RightsType.InstancePermissionSet; - } - - /// - /// Initializes a new instance of the class. - /// - /// of role names. - private TgsGraphQLAuthorizeAttribute(IEnumerable roleNames) - { - var listRoles = roleNames.ToList(); - if (listRoles.Count != 0) - { - Roles = [.. listRoles]; - } - - Policy = TgsAuthorizeAttribute.PolicyName; - Apply = ApplyPolicy.Validation; - } - } -} diff --git a/src/Tgstation.Server.Host/Security/TgsGraphQLAuthorizeAttribute{TAuthority}.cs b/src/Tgstation.Server.Host/Security/TgsGraphQLAuthorizeAttribute{TAuthority}.cs deleted file mode 100644 index 495424073a..0000000000 --- a/src/Tgstation.Server.Host/Security/TgsGraphQLAuthorizeAttribute{TAuthority}.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System; -using System.Reflection; - -using HotChocolate.Authorization; - -using Tgstation.Server.Host.Authority.Core; - -namespace Tgstation.Server.Host.Security -{ - /// - /// Inherits the roles of s for GraphQL endpoints. - /// - /// The being wrapped. - [AttributeUsage(AttributeTargets.Method, AllowMultiple = true, Inherited = true)] - public sealed class TgsGraphQLAuthorizeAttribute : AuthorizeAttribute - where TAuthority : IAuthority - { - /// - /// The name of the method targeted. - /// - public string MethodName { get; } - - /// - /// Initializes a new instance of the class. - /// - /// The method name to inherit roles from. - public TgsGraphQLAuthorizeAttribute(string methodName) - { - ArgumentNullException.ThrowIfNull(methodName); - - var authorityType = typeof(TAuthority); - var authorityMethod = authorityType.GetMethod(methodName) - ?? throw new InvalidOperationException($"Could not find method {methodName} on {authorityType}!"); - var authorizeAttribute = authorityMethod.GetCustomAttribute() - ?? throw new InvalidOperationException($"Could not find method {authorityType}.{methodName}() has no {nameof(TgsAuthorizeAttribute)}!"); - MethodName = methodName; - Roles = authorizeAttribute.Roles?.Split(',', StringSplitOptions.RemoveEmptyEntries); - Apply = ApplyPolicy.Validation; - } - } -} diff --git a/src/Tgstation.Server.Host/Security/UserSessionValidRequirement.cs b/src/Tgstation.Server.Host/Security/UserSessionValidRequirement.cs new file mode 100644 index 0000000000..f1f6007231 --- /dev/null +++ b/src/Tgstation.Server.Host/Security/UserSessionValidRequirement.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Authorization; + +namespace Tgstation.Server.Host.Security +{ + /// + /// for testing if a user is enabled and their session is valid. + /// + sealed class UserSessionValidRequirement : IAuthorizationRequirement + { + } +}