Merge fixes

This commit is contained in:
Jordan Dominion
2025-08-04 16:23:41 -04:00
parent 75fd5cff9c
commit dabe87da09
185 changed files with 3440 additions and 2155 deletions
+1
View File
@@ -169,6 +169,7 @@ The following dependencies are required.
- aspnetcore-runtime-10.0 (See Prerequisites under the `Ubuntu/Debian Package` section)
- libc6-i386
- libstdc++6:i386
- libcurl4:i386
- gcc-multilib (Only on 64-bit systems)
- gdb (for using gcore to create core dumps)
+7 -7
View File
@@ -7,11 +7,11 @@ ARG TGS_TELEMETRY_KEY_FILE=
# replace shell with bash so we can source files
RUN curl --silent -o- https://raw.githubusercontent.com/creationix/nvm/v0.39.1/install.sh | sh
ENV NODE_VERSION 20.5.1
ENV NODE_VERSION=20.5.1
ENV NVM_DIR /root/.nvm
ENV NODE_PATH $NVM_DIR/v$NODE_VERSION/lib/node_modules
ENV PATH $NVM_DIR/versions/node/v$NODE_VERSION/bin:$PATH
ENV NVM_DIR=/root/.nvm
ENV NODE_PATH=$NVM_DIR/v$NODE_VERSION/lib/node_modules
ENV PATH=$NVM_DIR/versions/node/v$NODE_VERSION/bin:$PATH
RUN . $NVM_DIR/nvm.sh \
&& nvm install $NODE_VERSION \
@@ -70,9 +70,9 @@ RUN dpkg --add-architecture i386 \
EXPOSE 5000
ENV General__ValidInstancePaths__0 /tgs_instances
ENV FileLogging__Directory /tgs_logs
ENV Internal__UsingDocker true
ENV General__ValidInstancePaths__0=/tgs_instances
ENV FileLogging__Directory=/tgs_logs
ENV Internal__UsingDocker=true
WORKDIR /app
+1 -1
View File
@@ -5,7 +5,7 @@
<PropertyGroup>
<TgsCoreVersion>7.0.0</TgsCoreVersion>
<TgsConfigVersion>6.0.0</TgsConfigVersion>
<TgsRestVersion>10.13.0</TgsRestVersion>
<TgsRestVersion>10.13.1</TgsRestVersion>
<TgsGraphQLVersion>0.6.0</TgsGraphQLVersion>
<TgsCommonLibraryVersion>7.0.0</TgsCommonLibraryVersion>
<TgsApiLibraryVersion>19.0.0</TgsApiLibraryVersion>
+1 -1
View File
@@ -493,7 +493,7 @@ namespace Tgstation.Server.Api.Models
/// <summary>
/// Attempted to restart a stopped watchdog.
/// </summary>
[Description("Cannot restart the watchdog as it is not running!")]
[Description("Cannot perform watchdog operation as it is not running!")]
WatchdogNotRunning,
/// <summary>
@@ -33,6 +33,12 @@ namespace Tgstation.Server.Api.Rights
/// <returns>The <see cref="Enum"/> <see cref="Type"/> of the given <paramref name="rightsType"/>.</returns>
public static Type RightToType(RightsType rightsType) => TypeMap[rightsType];
/// <summary>
/// Iterate the <see cref="Type"/> of each right.
/// </summary>
/// <returns>An <see cref="IEnumerable{T}"/> of each <see cref="Type"/> of right.</returns>
public static IEnumerable<Type> AllRightTypes() => TypeMap.Values;
/// <summary>
/// Map a given <typeparamref name="TRight"/> to its respective <see cref="RightsType"/>.
/// </summary>
@@ -9,9 +9,11 @@ mutation CreateSystemUserWithPermissionSet($systemIdentifier: String!) {
message
}
}
user {
updatedUser {
id
name
user {
name
}
}
}
}
@@ -7,7 +7,7 @@ mutation CreateUserFromOAuthConnection($name: String!, $oAuthConnections: [OAuth
message
}
}
user {
updatedUser {
id
}
}
@@ -7,7 +7,7 @@ mutation CreateUserWithPassword($name: String!, $password: String!) {
message
}
}
user {
updatedUser {
id
}
}
@@ -1,10 +1,12 @@
mutation CreateUserWithPasswordSelectOAuthConnections($name: String!, $password: String!) {
createUserByPasswordAndPermissionSet(input: { name: $name, password: $password }) {
user {
updatedUser {
id
oAuthConnections {
externalUserId
provider
user {
oAuthConnections {
externalUserId
provider
}
}
}
errors {
@@ -7,35 +7,37 @@ mutation SetUserGroup($id: ID!, $newGroupId: ID!) {
message
}
}
user {
ownedPermissionSet {
instanceManagerRights {
canCreate
canDelete
canGrantPermissions
canList
canRead
canRelocate
canRename
canSetAutoUpdate
canSetChatBotLimit
canSetConfiguration
canSetOnline
updatedUser {
user {
ownedPermissionSet {
instanceManagerRights {
canCreate
canDelete
canGrantPermissions
canList
canRead
canRelocate
canRename
canSetAutoUpdate
canSetChatBotLimit
canSetConfiguration
canSetOnline
}
administrationRights {
canChangeVersion
canDownloadLogs
canEditOwnServiceConnections
canEditOwnPassword
canReadUsers
canRestartHost
canUploadVersion
canWriteUsers
}
}
administrationRights {
canChangeVersion
canDownloadLogs
canEditOwnServiceConnections
canEditOwnPassword
canReadUsers
canRestartHost
canUploadVersion
canWriteUsers
group {
id
}
}
group {
id
}
}
}
}
@@ -2,17 +2,19 @@ mutation SetUserOAuthConnections($id: ID!, $newOAuthConnections: [OAuthConnectio
updateUser(
input: { id: $id, newOAuthConnections: $newOAuthConnections }
) {
user {
canonicalName
createdAt
enabled
id
name
systemIdentifier
oAuthConnections {
externalUserId
provider
}
updatedUser {
user {
canonicalName
createdAt
enabled
id
name
systemIdentifier
oAuthConnections {
externalUserId
provider
}
}
}
errors {
... on ErrorMessageError {
@@ -7,60 +7,62 @@ mutation SetUserPermissionSet($id: ID!, $permissionSet: PermissionSetInput!) {
message
}
}
user {
effectivePermissionSet {
administrationRights {
canChangeVersion
canDownloadLogs
canEditOwnServiceConnections
canEditOwnPassword
canReadUsers
canRestartHost
canUploadVersion
canWriteUsers
updatedUser {
user {
effectivePermissionSet {
administrationRights {
canChangeVersion
canDownloadLogs
canEditOwnServiceConnections
canEditOwnPassword
canReadUsers
canRestartHost
canUploadVersion
canWriteUsers
}
instanceManagerRights {
canCreate
canDelete
canGrantPermissions
canList
canRead
canRelocate
canRename
canSetAutoUpdate
canSetChatBotLimit
canSetConfiguration
canSetOnline
}
}
ownedPermissionSet {
administrationRights {
canChangeVersion
canDownloadLogs
canEditOwnServiceConnections
canEditOwnPassword
canReadUsers
canRestartHost
canUploadVersion
canWriteUsers
}
instanceManagerRights {
canCreate
canDelete
canGrantPermissions
canList
canRead
canRelocate
canRename
canSetAutoUpdate
canSetChatBotLimit
canSetConfiguration
canSetOnline
}
}
group {
id
}
}
instanceManagerRights {
canCreate
canDelete
canGrantPermissions
canList
canRead
canRelocate
canRename
canSetAutoUpdate
canSetChatBotLimit
canSetConfiguration
canSetOnline
}
}
ownedPermissionSet {
administrationRights {
canChangeVersion
canDownloadLogs
canEditOwnServiceConnections
canEditOwnPassword
canReadUsers
canRestartHost
canUploadVersion
canWriteUsers
}
instanceManagerRights {
canCreate
canDelete
canGrantPermissions
canList
canRead
canRelocate
canRename
canSetAutoUpdate
canSetChatBotLimit
canSetConfiguration
canSetOnline
}
}
group {
id
}
}
}
}
@@ -1,10 +1,12 @@
mutation UpdateUserOAuthConnections($id: ID!, $newOAuthConnections: [OAuthConnectionInput!]) {
updateUser(input: { id: $id, newOAuthConnections: $newOAuthConnections }) {
user {
updatedUser {
id
oAuthConnections {
externalUserId
provider
user {
oAuthConnections {
externalUserId
provider
}
}
}
errors {
+3 -3
View File
@@ -69,9 +69,9 @@ namespace Tgstation.Server.Client
};
/// <summary>
/// The <see cref="IHttpClient"/> for the <see cref="ApiClient"/>.
/// The <see cref="HttpClient"/> for the <see cref="ApiClient"/>.
/// </summary>
readonly IHttpClient httpClient;
readonly HttpClient httpClient;
/// <summary>
/// The <see cref="IRequestLogger"/>s used by the <see cref="ApiClient"/>.
@@ -166,7 +166,7 @@ namespace Tgstation.Server.Client
/// <param name="tokenRefreshHeaders">The value of <see cref="tokenRefreshHeaders"/>.</param>
/// <param name="authless">The value of <see cref="authless"/>.</param>
public ApiClient(
IHttpClient httpClient,
HttpClient httpClient,
Uri url,
ApiHeaders apiHeaders,
ApiHeaders? tokenRefreshHeaders,
@@ -1,7 +1,7 @@
using System;
using System.Net.Http;
using Tgstation.Server.Api;
using Tgstation.Server.Common.Http;
namespace Tgstation.Server.Client
{
@@ -19,5 +19,19 @@ namespace Tgstation.Server.Client
apiHeaders,
tokenRefreshHeaders,
authless);
/// <inheritdoc />
public IApiClient CreateApiClient(
Uri url,
ApiHeaders apiHeaders,
ApiHeaders? tokenRefreshHeaders,
HttpMessageHandler handler,
bool disposeHandler,
bool authless) => new ApiClient(
new HttpClient(handler, disposeHandler),
url,
apiHeaders,
tokenRefreshHeaders,
authless);
}
}
@@ -1,4 +1,5 @@
using System;
using System.Net.Http;
using Tgstation.Server.Api;
@@ -22,5 +23,23 @@ namespace Tgstation.Server.Client
ApiHeaders apiHeaders,
ApiHeaders? tokenRefreshHeaders,
bool authless);
/// <summary>
/// Create an <see cref="IApiClient"/>.
/// </summary>
/// <param name="url">The base <see cref="Uri"/>.</param>
/// <param name="apiHeaders">The <see cref="ApiHeaders"/> for the <see cref="IApiClient"/>.</param>
/// <param name="tokenRefreshHeaders">The <see cref="ApiHeaders"/> to use to generate a new <see cref="Api.Models.Response.TokenResponse"/>.</param>
/// <param name="handler">The <see cref="HttpMessageHandler"/> to use with the internal <see cref="HttpClient"/>.</param>
/// <param name="disposeHandler">If <paramref name="handler"/> should be disposed with the created <see cref="IApiClient"/>.</param>
/// <param name="authless">If there should be no authentication performed.</param>
/// <returns>A new <see cref="IApiClient"/>.</returns>
public IApiClient CreateApiClient(
Uri url,
ApiHeaders apiHeaders,
ApiHeaders? tokenRefreshHeaders,
HttpMessageHandler handler,
bool disposeHandler,
bool authless);
}
}
@@ -36,7 +36,7 @@ namespace Tgstation.Server.Common.Http
response.Content = null;
try
{
// don't cry about the missing CancellationToken overload: https://github.com/dotnet/runtime/issues/916
// don't cry about the missing CancellationToken overload: https://github.com/dotnet/corefx/issues/32615#issuecomment-562083237
var responseStream = await content.ReadAsStreamAsync().ConfigureAwait(false);
return new CachedResponseStream(content, responseStream);
}
@@ -1,51 +0,0 @@
using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;
namespace Tgstation.Server.Common.Http
{
/// <inheritdoc />
public sealed class HttpClient : IHttpClient
{
/// <inheritdoc />
public TimeSpan Timeout
{
get => httpClient.Timeout;
set => httpClient.Timeout = value;
}
/// <inheritdoc />
public HttpRequestHeaders DefaultRequestHeaders => httpClient.DefaultRequestHeaders;
/// <summary>
/// The real <see cref="System.Net.Http.HttpClient"/>.
/// </summary>
readonly System.Net.Http.HttpClient httpClient;
/// <summary>
/// Initializes a new instance of the <see cref="HttpClient"/> class.
/// </summary>
/// <param name="implementation">The <see cref="System.Net.Http.HttpClient"/> to wrap.</param>
public HttpClient(System.Net.Http.HttpClient implementation)
{
httpClient = implementation ?? throw new ArgumentNullException(nameof(implementation));
}
/// <summary>
/// Initializes a new instance of the <see cref="HttpClient"/> class.
/// </summary>
public HttpClient()
: this(new System.Net.Http.HttpClient())
{
}
/// <inheritdoc />
public void Dispose() => httpClient.Dispose();
/// <inheritdoc />
public Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, HttpCompletionOption completionOption, CancellationToken cancellationToken)
=> httpClient.SendAsync(request, completionOption, cancellationToken);
}
}
@@ -1,41 +0,0 @@
using System;
using System.Net.Http.Headers;
namespace Tgstation.Server.Common.Http
{
/// <summary>
/// <see cref="IAbstractHttpClientFactory"/> that creates <see cref="HttpClient"/>s.
/// </summary>
public sealed class HttpClientFactory : IAbstractHttpClientFactory
{
/// <inheritdoc />
public IHttpClient CreateClient()
{
var client = new HttpClient();
try
{
client.DefaultRequestHeaders.UserAgent.Add(userAgent);
return client;
}
catch
{
client.Dispose();
throw;
}
}
/// <summary>
/// The <see cref="ProductInfoHeaderValue"/> used as created client's User-Agent header on request.
/// </summary>
readonly ProductInfoHeaderValue userAgent;
/// <summary>
/// Initializes a new instance of the <see cref="HttpClientFactory"/> class.
/// </summary>
/// <param name="userAgent">The value of <see cref="userAgent"/>.</param>
public HttpClientFactory(ProductInfoHeaderValue userAgent)
{
this.userAgent = userAgent ?? throw new ArgumentNullException(nameof(userAgent));
}
}
}
@@ -1,14 +0,0 @@
namespace Tgstation.Server.Common.Http
{
/// <summary>
/// Creates <see cref="IHttpClient"/>s.
/// </summary>
public interface IAbstractHttpClientFactory
{
/// <summary>
/// Create a <see cref="IHttpClient"/>.
/// </summary>
/// <returns>A new <see cref="IHttpClient"/>.</returns>
IHttpClient CreateClient();
}
}
@@ -1,33 +0,0 @@
using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;
namespace Tgstation.Server.Common.Http
{
/// <summary>
/// For sending HTTP requests.
/// </summary>
public interface IHttpClient : IDisposable
{
/// <summary>
/// The request timeout.
/// </summary>
TimeSpan Timeout { get; set; }
/// <summary>
/// The <see cref="HttpRequestHeaders"/> used on every request.
/// </summary>
HttpRequestHeaders DefaultRequestHeaders { get; }
/// <summary>
/// Send an HTTP request.
/// </summary>
/// <param name="request">The <see cref="HttpRequestMessage"/>.</param>
/// <param name="completionOption">The <see cref="HttpCompletionOption"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="HttpResponseMessage"/> of the request.</returns>
Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, HttpCompletionOption completionOption, CancellationToken cancellationToken);
}
}
@@ -1,9 +1,12 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using System.Web;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Octokit;
@@ -11,9 +14,11 @@ using Tgstation.Server.Api.Models;
using Tgstation.Server.Api.Models.Response;
using Tgstation.Server.Api.Rights;
using Tgstation.Server.Host.Authority.Core;
using Tgstation.Server.Host.Configuration;
using Tgstation.Server.Host.Core;
using Tgstation.Server.Host.Database;
using Tgstation.Server.Host.Security;
using Tgstation.Server.Host.IO;
using Tgstation.Server.Host.System;
using Tgstation.Server.Host.Transfer;
using Tgstation.Server.Host.Utils.GitHub;
@@ -57,10 +62,29 @@ namespace Tgstation.Server.Host.Authority
/// </summary>
readonly IMemoryCache cacheService;
/// <summary>
/// The <see cref="IAssemblyInformationProvider"/> for the <see cref="AdministrationAuthority"/>.
/// </summary>
readonly IAssemblyInformationProvider assemblyInformationProvider;
/// <summary>
/// The <see cref="IPlatformIdentifier"/> for the <see cref="AdministrationAuthority"/>.
/// </summary>
readonly IPlatformIdentifier platformIdentifier;
/// <summary>
/// The <see cref="IIOManager"/> for the <see cref="AdministrationAuthority"/>.
/// </summary>
readonly IIOManager ioManager;
/// <summary>
/// The <see cref="FileLoggingConfiguration"/> for the <see cref="AdministrationAuthority"/>.
/// </summary>
readonly IOptionsSnapshot<FileLoggingConfiguration> fileLoggingConfigurationOptions;
/// <summary>
/// Initializes a new instance of the <see cref="AdministrationAuthority"/> class.
/// </summary>
/// <param name="authenticationContext">The <see cref="IAuthenticationContext"/> to use.</param>
/// <param name="databaseContext">The <see cref="IDatabaseContext"/> to use.</param>
/// <param name="logger">The <see cref="ILogger"/> to use.</param>
/// <param name="gitHubServiceFactory">The value of <see cref="gitHubServiceFactory"/>.</param>
@@ -68,17 +92,23 @@ namespace Tgstation.Server.Host.Authority
/// <param name="serverUpdateInitiator">The value of <see cref="serverUpdateInitiator"/>.</param>
/// <param name="fileTransferService">The value of <see cref="fileTransferService"/>.</param>
/// <param name="cacheService">The value of <see cref="cacheService"/>.</param>
/// <param name="assemblyInformationProvider">The value of <see cref="assemblyInformationProvider"/>.</param>
/// <param name="platformIdentifier">The value of <see cref="platformIdentifier"/>.</param>
/// <param name="ioManager">The value of <see cref="ioManager"/>.</param>
/// <param name="fileLoggingConfigurationOptions">The value of <see cref="fileLoggingConfigurationOptions"/>.</param>
public AdministrationAuthority(
IAuthenticationContext authenticationContext,
IDatabaseContext databaseContext,
ILogger<UserAuthority> logger,
IGitHubServiceFactory gitHubServiceFactory,
IServerControl serverControl,
IServerUpdateInitiator serverUpdateInitiator,
IFileTransferTicketProvider fileTransferService,
IMemoryCache cacheService)
IMemoryCache cacheService,
IAssemblyInformationProvider assemblyInformationProvider,
IPlatformIdentifier platformIdentifier,
IIOManager ioManager,
IOptionsSnapshot<FileLoggingConfiguration> fileLoggingConfigurationOptions)
: base(
authenticationContext,
databaseContext,
logger)
{
@@ -87,157 +117,212 @@ namespace Tgstation.Server.Host.Authority
this.serverUpdateInitiator = serverUpdateInitiator ?? throw new ArgumentNullException(nameof(serverUpdateInitiator));
this.fileTransferService = fileTransferService ?? throw new ArgumentNullException(nameof(fileTransferService));
this.cacheService = cacheService ?? throw new ArgumentNullException(nameof(cacheService));
this.assemblyInformationProvider = assemblyInformationProvider ?? throw new ArgumentNullException(nameof(assemblyInformationProvider));
this.platformIdentifier = platformIdentifier ?? throw new ArgumentNullException(nameof(platformIdentifier));
this.ioManager = ioManager ?? throw new ArgumentNullException(nameof(ioManager));
this.fileLoggingConfigurationOptions = fileLoggingConfigurationOptions ?? throw new ArgumentNullException(nameof(fileLoggingConfigurationOptions));
}
/// <inheritdoc />
public async ValueTask<AuthorityResponse<AdministrationResponse>> GetUpdateInformation(bool forceFresh, CancellationToken cancellationToken)
{
try
{
async Task<AdministrationResponse> CacheFactory()
public RequirementsGated<AuthorityResponse<AdministrationResponse>> GetUpdateInformation(bool forceFresh, CancellationToken cancellationToken)
=> new(
() => Flag(AdministrationRights.ChangeVersion),
async () =>
{
Version? greatestVersion = null;
Uri? repoUrl = null;
var scopeCancellationToken = CancellationToken.None; // DCT: None available
try
{
var gitHubService = await gitHubServiceFactory.CreateService(scopeCancellationToken);
var repositoryUrlTask = gitHubService.GetUpdatesRepositoryUrl(scopeCancellationToken);
var releases = await gitHubService.GetTgsReleases(scopeCancellationToken);
foreach (var kvp in releases)
async Task<AdministrationResponse> CacheFactory()
{
var version = kvp.Key;
var release = kvp.Value;
if (version.Major > 3 // Forward/backward compatible but not before TGS4
&& (greatestVersion == null || version > greatestVersion))
greatestVersion = version;
Version? greatestVersion = null;
Uri? repoUrl = null;
var scopeCancellationToken = CancellationToken.None; // DCT: None available
try
{
var gitHubService = await gitHubServiceFactory.CreateService(scopeCancellationToken);
var repositoryUrlTask = gitHubService.GetUpdatesRepositoryUrl(scopeCancellationToken);
var releases = await gitHubService.GetTgsReleases(scopeCancellationToken);
foreach (var kvp in releases)
{
var version = kvp.Key;
var release = kvp.Value;
if (version.Major > 3 // Forward/backward compatible but not before TGS4
&& (greatestVersion == null || version > greatestVersion))
greatestVersion = version;
}
repoUrl = await repositoryUrlTask;
}
catch (NotFoundException e)
{
Logger.LogWarning(e, "Not found exception while retrieving upstream repository info!");
}
return new AdministrationResponse
{
LatestVersion = greatestVersion,
TrackedRepositoryUrl = repoUrl,
GeneratedAt = DateTimeOffset.UtcNow,
};
}
repoUrl = await repositoryUrlTask;
var ttl = TimeSpan.FromMinutes(30);
Task<AdministrationResponse> task;
if (forceFresh || !cacheService.TryGetValue(ReadCacheKey, out var rawCacheObject))
{
using var entry = cacheService.CreateEntry(ReadCacheKey);
entry.AbsoluteExpirationRelativeToNow = ttl;
entry.Value = task = CacheFactory();
}
else
task = (Task<AdministrationResponse>)rawCacheObject!;
var result = await task.WaitAsync(cancellationToken);
return new AuthorityResponse<AdministrationResponse>(result);
}
catch (NotFoundException e)
catch (RateLimitExceededException e)
{
Logger.LogWarning(e, "Not found exception while retrieving upstream repository info!");
return RateLimit<AdministrationResponse>(e);
}
catch (ApiException e)
{
Logger.LogWarning(e, OctokitException);
return new AuthorityResponse<AdministrationResponse>(
new ErrorMessageResponse(ErrorCode.RemoteApiError)
{
AdditionalData = e.Message,
},
HttpFailureResponse.FailedDependency);
}
});
/// <inheritdoc />
public RequirementsGated<AuthorityResponse<ServerUpdateResponse>> TriggerServerVersionChange(Version targetVersion, bool uploadZip, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(targetVersion);
return new(
() =>
{
if (uploadZip)
return Flag(AdministrationRights.UploadVersion);
return Flag(AdministrationRights.ChangeVersion);
},
async () =>
{
if (targetVersion.Major < 4)
return BadRequest<ServerUpdateResponse>(ErrorCode.CannotChangeServerSuite);
if (!serverControl.WatchdogPresent)
return new AuthorityResponse<ServerUpdateResponse>(
new ErrorMessageResponse(ErrorCode.MissingHostWatchdog),
HttpFailureResponse.UnprocessableEntity);
IFileUploadTicket? uploadTicket = uploadZip
? fileTransferService.CreateUpload(FileUploadStreamKind.None)
: null;
ServerUpdateResult updateResult;
try
{
try
{
updateResult = await serverUpdateInitiator.InitiateUpdate(uploadTicket, targetVersion, cancellationToken);
}
catch
{
if (uploadZip)
await uploadTicket!.DisposeAsync();
throw;
}
}
catch (RateLimitExceededException ex)
{
return RateLimit<ServerUpdateResponse>(ex);
}
catch (ApiException e)
{
Logger.LogWarning(e, OctokitException);
return new AuthorityResponse<ServerUpdateResponse>(
new ErrorMessageResponse(ErrorCode.RemoteApiError)
{
AdditionalData = e.Message,
},
HttpFailureResponse.FailedDependency);
}
return new AdministrationResponse
return updateResult switch
{
LatestVersion = greatestVersion,
TrackedRepositoryUrl = repoUrl,
GeneratedAt = DateTimeOffset.UtcNow,
ServerUpdateResult.Started => new AuthorityResponse<ServerUpdateResponse>(new ServerUpdateResponse(targetVersion, uploadTicket?.Ticket.FileTicket), HttpSuccessResponse.Accepted),
ServerUpdateResult.ReleaseMissing => Gone<ServerUpdateResponse>(),
ServerUpdateResult.UpdateInProgress => BadRequest<ServerUpdateResponse>(ErrorCode.ServerUpdateInProgress),
ServerUpdateResult.SwarmIntegrityCheckFailed => new AuthorityResponse<ServerUpdateResponse>(
new ErrorMessageResponse(ErrorCode.SwarmIntegrityCheckFailed),
HttpFailureResponse.FailedDependency),
_ => throw new InvalidOperationException($"Unexpected ServerUpdateResult: {updateResult}"),
};
}
var ttl = TimeSpan.FromMinutes(30);
Task<AdministrationResponse> task;
if (forceFresh || !cacheService.TryGetValue(ReadCacheKey, out var rawCacheObject))
{
using var entry = cacheService.CreateEntry(ReadCacheKey);
entry.AbsoluteExpirationRelativeToNow = ttl;
entry.Value = task = CacheFactory();
}
else
task = (Task<AdministrationResponse>)rawCacheObject!;
var result = await task.WaitAsync(cancellationToken);
return new AuthorityResponse<AdministrationResponse>(result);
}
catch (RateLimitExceededException e)
{
return RateLimit<AdministrationResponse>(e);
}
catch (ApiException e)
{
Logger.LogWarning(e, OctokitException);
return new AuthorityResponse<AdministrationResponse>(
new ErrorMessageResponse(ErrorCode.RemoteApiError)
{
AdditionalData = e.Message,
},
HttpFailureResponse.FailedDependency);
}
});
}
/// <inheritdoc />
public async ValueTask<AuthorityResponse<ServerUpdateResponse>> TriggerServerVersionChange(Version targetVersion, bool uploadZip, CancellationToken cancellationToken)
{
var attemptingUpload = uploadZip == true;
if (attemptingUpload)
{
if (!AuthenticationContext.PermissionSet.AdministrationRights!.Value.HasFlag(AdministrationRights.UploadVersion))
return Forbid<ServerUpdateResponse>();
}
else if (!AuthenticationContext.PermissionSet.AdministrationRights!.Value.HasFlag(AdministrationRights.ChangeVersion))
return Forbid<ServerUpdateResponse>();
if (targetVersion.Major < 4)
return BadRequest<ServerUpdateResponse>(ErrorCode.CannotChangeServerSuite);
if (!serverControl.WatchdogPresent)
return new AuthorityResponse<ServerUpdateResponse>(
new ErrorMessageResponse(ErrorCode.MissingHostWatchdog),
HttpFailureResponse.UnprocessableEntity);
IFileUploadTicket? uploadTicket = attemptingUpload
? fileTransferService.CreateUpload(FileUploadStreamKind.None)
: null;
ServerUpdateResult updateResult;
try
{
try
public RequirementsGated<AuthorityResponse> TriggerServerRestart()
=> new(
() => Flag(AdministrationRights.RestartHost),
async () =>
{
updateResult = await serverUpdateInitiator.InitiateUpdate(uploadTicket, targetVersion, cancellationToken);
}
catch
{
if (attemptingUpload)
await uploadTicket!.DisposeAsync();
throw;
}
}
catch (RateLimitExceededException ex)
{
return RateLimit<ServerUpdateResponse>(ex);
}
catch (ApiException e)
{
Logger.LogWarning(e, OctokitException);
return new AuthorityResponse<ServerUpdateResponse>(
new ErrorMessageResponse(ErrorCode.RemoteApiError)
if (!serverControl.WatchdogPresent)
{
AdditionalData = e.Message,
},
HttpFailureResponse.FailedDependency);
}
Logger.LogDebug("Restart request failed due to lack of host watchdog!");
return new AuthorityResponse(
new ErrorMessageResponse(ErrorCode.MissingHostWatchdog),
HttpFailureResponse.UnprocessableEntity);
}
return updateResult switch
{
ServerUpdateResult.Started => new AuthorityResponse<ServerUpdateResponse>(new ServerUpdateResponse(targetVersion, uploadTicket?.Ticket.FileTicket), HttpSuccessResponse.Accepted),
ServerUpdateResult.ReleaseMissing => Gone<ServerUpdateResponse>(),
ServerUpdateResult.UpdateInProgress => BadRequest<ServerUpdateResponse>(ErrorCode.ServerUpdateInProgress),
ServerUpdateResult.SwarmIntegrityCheckFailed => new AuthorityResponse<ServerUpdateResponse>(
new ErrorMessageResponse(ErrorCode.SwarmIntegrityCheckFailed),
HttpFailureResponse.FailedDependency),
_ => throw new InvalidOperationException($"Unexpected ServerUpdateResult: {updateResult}"),
};
}
await serverControl.Restart();
return new AuthorityResponse();
});
/// <inheritdoc />
public async ValueTask<AuthorityResponse> TriggerServerRestart()
public RequirementsGated<AuthorityResponse<LogFileResponse>> GetLog(string path, CancellationToken cancellationToken)
{
if (!serverControl.WatchdogPresent)
{
Logger.LogDebug("Restart request failed due to lack of host watchdog!");
return new AuthorityResponse(
new ErrorMessageResponse(ErrorCode.MissingHostWatchdog),
HttpFailureResponse.UnprocessableEntity);
}
ArgumentNullException.ThrowIfNull(path);
return new(
() => Flag(AdministrationRights.DownloadLogs),
async () =>
{
path = HttpUtility.UrlDecode(path);
await serverControl.Restart();
return new AuthorityResponse();
// guard against directory navigation
var sanitizedPath = ioManager.GetFileName(path);
if (path != sanitizedPath)
return Forbid<LogFileResponse>();
var fullPath = ioManager.ConcatPath(
fileLoggingConfigurationOptions.Value.GetFullLogDirectory(ioManager, assemblyInformationProvider, platformIdentifier),
path);
try
{
var fileTransferTicket = fileTransferService.CreateDownload(
new FileDownloadProvider(
() => null,
null,
fullPath,
true));
return new AuthorityResponse<LogFileResponse>(new LogFileResponse
{
Name = path,
LastModified = await ioManager.GetLastModified(fullPath, cancellationToken),
FileTicket = fileTransferTicket.FileTicket,
});
}
catch (IOException ex)
{
return Conflict<LogFileResponse>(ErrorCode.IOError, ex.ToString());
}
});
}
}
}
@@ -8,7 +8,7 @@ using Octokit;
using Tgstation.Server.Api.Models;
using Tgstation.Server.Api.Models.Response;
using Tgstation.Server.Host.Database;
using Tgstation.Server.Host.Security;
using Tgstation.Server.Host.Security.RightsEvaluation;
namespace Tgstation.Server.Host.Authority.Core
{
@@ -17,11 +17,6 @@ namespace Tgstation.Server.Host.Authority.Core
/// </summary>
abstract class AuthorityBase : IAuthority
{
/// <summary>
/// Gets the <see cref="IAuthenticationContext"/> for the <see cref="AuthorityBase"/>.
/// </summary>
protected IAuthenticationContext AuthenticationContext { get; }
/// <summary>
/// Gets the <see cref="IDatabaseContext"/> for the <see cref="AuthorityBase"/>.
/// </summary>
@@ -88,24 +83,57 @@ namespace Tgstation.Server.Host.Authority.Core
/// </summary>
/// <typeparam name="TResult">The <see cref="Type"/> of the <see cref="AuthorityResponse{TResult}.Result"/>.</typeparam>
/// <param name="errorCode">The <see cref="ErrorCode"/>.</param>
/// <param name="additionalData"><see cref="ErrorMessageResponse.AdditionalData"/> for the error message.</param>
/// <returns>A new, errored <see cref="AuthorityResponse{TResult}"/>.</returns>
protected static AuthorityResponse<TResult> Conflict<TResult>(ErrorCode errorCode)
protected static AuthorityResponse<TResult> Conflict<TResult>(ErrorCode errorCode, string? additionalData = null)
=> new(
new ErrorMessageResponse(errorCode),
new ErrorMessageResponse(errorCode)
{
AdditionalData = additionalData,
},
HttpFailureResponse.Conflict);
/// <summary>
/// Helper to quickly construct a <see cref="FlagRightsConditional{TRights}"/>.
/// </summary>
/// <typeparam name="TRights">The <typeparamref name="TRights"/> to evaluate.</typeparam>
/// <param name="flag">The single bit flag of the <typeparamref name="TRights"/>.</param>
/// <returns>A new <see cref="FlagRightsConditional{TRights}"/>.</returns>
protected static FlagRightsConditional<TRights> Flag<TRights>(TRights flag)
where TRights : Enum
=> new(flag);
/// <summary>
/// Helper to quickly construct an <see cref="OrRightsConditional{TRights}"/>.
/// </summary>
/// <typeparam name="TRights">The <typeparamref name="TRights"/> to evaluate.</typeparam>
/// <param name="lhs">The left hand side operand.</param>
/// <param name="rhs">The right hand side operand.</param>
/// <returns>A new <see cref="OrRightsConditional{TRights}"/>.</returns>
protected static OrRightsConditional<TRights> Or<TRights>(RightsConditional<TRights> lhs, RightsConditional<TRights> rhs)
where TRights : Enum
=> new(lhs, rhs);
/// <summary>
/// Helper to quickly construct an <see cref="AndRightsConditional{TRights}"/>.
/// </summary>
/// <typeparam name="TRights">The <typeparamref name="TRights"/> to evaluate.</typeparam>
/// <param name="lhs">The left hand side operand.</param>
/// <param name="rhs">The right hand side operand.</param>
/// <returns>A new <see cref="AndRightsConditional{TRights}"/>.</returns>
protected static AndRightsConditional<TRights> And<TRights>(RightsConditional<TRights> lhs, RightsConditional<TRights> rhs)
where TRights : Enum
=> new(lhs, rhs);
/// <summary>
/// Initializes a new instance of the <see cref="AuthorityBase"/> class.
/// </summary>
/// <param name="authenticationContext">The value of <see cref="AuthenticationContext"/>.</param>
/// <param name="databaseContext">The value of <see cref="DatabaseContext"/>.</param>
/// <param name="logger">The value of <see cref="Logger"/>.</param>
protected AuthorityBase(
IAuthenticationContext authenticationContext,
IDatabaseContext databaseContext,
ILogger<AuthorityBase> logger)
{
AuthenticationContext = authenticationContext ?? throw new ArgumentNullException(nameof(authenticationContext));
DatabaseContext = databaseContext ?? throw new ArgumentNullException(nameof(databaseContext));
Logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
@@ -1,7 +1,8 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Tgstation.Server.Api.Models;
using Microsoft.AspNetCore.Authorization;
namespace Tgstation.Server.Host.Authority.Core
{
@@ -14,35 +15,60 @@ namespace Tgstation.Server.Host.Authority.Core
/// </summary>
protected TAuthority Authority { get; }
/// <summary>
/// The authorization service for the <see cref="AuthorityInvokerBase{TAuthority}"/>.
/// </summary>
readonly Security.IAuthorizationService authorizationService;
/// <summary>
/// Initializes a new instance of the <see cref="AuthorityInvokerBase{TAuthority}"/> class.
/// </summary>
/// <param name="authority">The value of <see cref="Authority"/>.</param>
public AuthorityInvokerBase(TAuthority authority)
/// <param name="authorizationService">The value of <see cref="authorizationService"/>.</param>
public AuthorityInvokerBase(
TAuthority authority,
Security.IAuthorizationService authorizationService)
{
Authority = authority ?? throw new ArgumentNullException(nameof(authority));
this.authorizationService = authorizationService ?? throw new ArgumentNullException(nameof(authorizationService));
}
/// <inheritdoc />
IQueryable<TResult> IAuthorityInvoker<TAuthority>.InvokeQueryable<TResult>(Func<TAuthority, IQueryable<TResult>> authorityInvoker)
async ValueTask<IQueryable<TResult>?> IAuthorityInvoker<TAuthority>.InvokeQueryable<TResult>(Func<TAuthority, RequirementsGated<IQueryable<TResult>>> authorityInvoker)
{
ArgumentNullException.ThrowIfNull(authorityInvoker);
return authorityInvoker(Authority);
var requirementsGate = authorityInvoker(Authority);
return await ExecuteIfRequirementsSatisfied(requirementsGate);
}
/// <inheritdoc />
IQueryable<TApiModel> IAuthorityInvoker<TAuthority>.InvokeTransformableQueryable<TResult, TApiModel, TTransformer>(Func<TAuthority, IQueryable<TResult>> authorityInvoker)
/// <summary>
/// Unwrap a <see cref="RequirementsGated{TResult}"/> result, returning <see langword="null"/> if the requirements weren't satisfied.
/// </summary>
/// <typeparam name="TResult">The <see cref="Type"/> contained by the <paramref name="requirementsGate"/>.</typeparam>
/// <param name="requirementsGate">The <see cref="RequirementsGated{TResult}"/> result.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <typeparamref name="TResult"/> if the requirements were met, <see langword="null"/> if the requirments weren't met.</returns>
protected async ValueTask<TResult?> ExecuteIfRequirementsSatisfied<TResult>(RequirementsGated<TResult> requirementsGate)
where TResult : class
{
ArgumentNullException.ThrowIfNull(authorityInvoker);
var requirements = await requirementsGate.GetRequirements();
var authorizationResult = await authorizationService.AuthorizeAsync(requirements);
var queryable = authorityInvoker(Authority);
if (!authorizationResult.Succeeded)
{
OnRequirementsFailure(authorizationResult.Failure);
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
return await requirementsGate.Execute(authorizationService);
}
var expression = new TTransformer().Expression;
return queryable
.Select(expression);
/// <summary>
/// Called to handle generic behavior when requirements evaluation fails.
/// </summary>
/// <param name="authFailure">The <see cref="AuthorizationFailure"/>.</param>
protected virtual void OnRequirementsFailure(AuthorizationFailure authFailure)
{
}
}
}
@@ -1,6 +1,12 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using HotChocolate;
using Microsoft.AspNetCore.Authorization;
using Tgstation.Server.Api.Models;
using Tgstation.Server.Host.GraphQL;
namespace Tgstation.Server.Host.Authority.Core
@@ -12,14 +18,17 @@ namespace Tgstation.Server.Host.Authority.Core
/// <summary>
/// Throws a <see cref="ErrorMessageException"/> for errored <paramref name="authorityResponse"/>s.
/// </summary>
/// <param name="authorityResponse">The potentially errored <paramref name="authorityResponse"/>.</param>
/// <typeparam name="TAuthorityResponse">The <see cref="AuthorityResponse"/> <see cref="Type"/> being checked.</typeparam>
/// <param name="authorityResponse">The potentially errored <paramref name="authorityResponse"/> or <see langword="null"/> if requirements evaluation failed.</param>
/// <param name="errorOnMissing">If an error should be raised for <see cref="HttpFailureResponse.NotFound"/> and <see cref="HttpFailureResponse.Gone"/> failures.</param>
static void ThrowGraphQLErrorIfNecessary(AuthorityResponse authorityResponse, bool errorOnMissing)
/// <returns><paramref name="authorityResponse"/> if an <see cref="ErrorMessageException"/> wasn't thrown.</returns>
static TAuthorityResponse ThrowGraphQLErrorIfNecessary<TAuthorityResponse>(TAuthorityResponse authorityResponse, bool errorOnMissing)
where TAuthorityResponse : AuthorityResponse
{
if (authorityResponse.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,38 +38,43 @@ namespace Tgstation.Server.Host.Authority.Core
/// Initializes a new instance of the <see cref="GraphQLAuthorityInvoker{TAuthority}"/> class.
/// </summary>
/// <param name="authority">The <typeparamref name="TAuthority"/>.</param>
public GraphQLAuthorityInvoker(TAuthority authority)
: base(authority)
/// <param name="authorizationService">the authorization service to use.</param>
public GraphQLAuthorityInvoker(TAuthority authority, Security.IAuthorizationService authorizationService)
: base(authority, authorizationService)
{
}
/// <inheritdoc />
async ValueTask IGraphQLAuthorityInvoker<TAuthority>.Invoke(Func<TAuthority, ValueTask<AuthorityResponse>> authorityInvoker)
async ValueTask IGraphQLAuthorityInvoker<TAuthority>.Invoke(Func<TAuthority, RequirementsGated<AuthorityResponse>> authorityInvoker)
{
ArgumentNullException.ThrowIfNull(authorityInvoker);
var authorityResponse = await authorityInvoker(Authority);
var requirementsGate = authorityInvoker(Authority);
var authorityResponse = await ExecuteIfRequirementsSatisfied(requirementsGate);
ThrowGraphQLErrorIfNecessary(authorityResponse, true);
}
/// <inheritdoc />
async ValueTask<TApiModel?> IGraphQLAuthorityInvoker<TAuthority>.InvokeAllowMissing<TResult, TApiModel>(Func<TAuthority, ValueTask<AuthorityResponse<TResult>>> authorityInvoker)
async ValueTask<TApiModel?> IGraphQLAuthorityInvoker<TAuthority>.InvokeAllowMissing<TResult, TApiModel>(Func<TAuthority, RequirementsGated<AuthorityResponse<TResult>>> authorityInvoker)
where TApiModel : default
{
ArgumentNullException.ThrowIfNull(authorityInvoker);
var authorityResponse = await authorityInvoker(Authority);
var requirementsGate = authorityInvoker(Authority);
var authorityResponse = await ExecuteIfRequirementsSatisfied(requirementsGate);
ThrowGraphQLErrorIfNecessary(authorityResponse, false);
return authorityResponse.Result;
}
/// <inheritdoc />
async ValueTask<TApiModel?> IGraphQLAuthorityInvoker<TAuthority>.InvokeTransformableAllowMissing<TResult, TApiModel, TTransformer>(Func<TAuthority, ValueTask<AuthorityResponse<TResult>>> authorityInvoker)
async ValueTask<TApiModel?> IGraphQLAuthorityInvoker<TAuthority>.InvokeTransformableAllowMissing<TResult, TApiModel, TTransformer>(Func<TAuthority, RequirementsGated<AuthorityResponse<TResult>>> authorityInvoker)
where TApiModel : default
{
ArgumentNullException.ThrowIfNull(authorityInvoker);
var authorityResponse = await authorityInvoker(Authority);
var requirementsGate = authorityInvoker(Authority);
var authorityResponse = await ExecuteIfRequirementsSatisfied(requirementsGate);
ThrowGraphQLErrorIfNecessary(authorityResponse, false);
var result = authorityResponse.Result;
if (result == null)
@@ -70,11 +84,49 @@ namespace Tgstation.Server.Host.Authority.Core
}
/// <inheritdoc />
ValueTask<TApiModel> IGraphQLAuthorityInvoker<TAuthority>.Invoke<TResult, TApiModel>(Func<TAuthority, ValueTask<AuthorityResponse<TResult>>> authorityInvoker)
=> ((IGraphQLAuthorityInvoker<TAuthority>)this).InvokeAllowMissing<TResult, TApiModel>(authorityInvoker)!;
async ValueTask<IQueryable<TApiModel>> IGraphQLAuthorityInvoker<TAuthority>.InvokeTransformableQueryable<TResult, TApiModel, TTransformer>(
Func<TAuthority, RequirementsGated<IQueryable<TResult>>> authorityInvoker,
Func<IQueryable<TResult>, IQueryable<TResult>>? preTransformer)
{
ArgumentNullException.ThrowIfNull(authorityInvoker);
var requirementsGate = authorityInvoker(Authority);
var queryable = await ExecuteIfRequirementsSatisfied(requirementsGate);
if (preTransformer != null)
queryable = preTransformer(queryable);
if (typeof(EntityId).IsAssignableFrom(typeof(TResult)))
queryable = queryable.OrderBy(item => ((EntityId)(object)item).Id!.Value); // order by ID to fix an EFCore warning
var expression = new TTransformer().Expression;
return queryable
.Select(expression);
}
/// <inheritdoc />
ValueTask<TApiModel> IGraphQLAuthorityInvoker<TAuthority>.InvokeTransformable<TResult, TApiModel, TTransformer>(Func<TAuthority, ValueTask<AuthorityResponse<TResult>>> authorityInvoker)
=> ((IGraphQLAuthorityInvoker<TAuthority>)this).InvokeTransformableAllowMissing<TResult, TApiModel, TTransformer>(authorityInvoker)!;
async ValueTask<TApiModel> IGraphQLAuthorityInvoker<TAuthority>.Invoke<TResult, TApiModel>(Func<TAuthority, RequirementsGated<AuthorityResponse<TResult>>> authorityInvoker)
=> await ((IGraphQLAuthorityInvoker<TAuthority>)this).InvokeAllowMissing<TResult, TApiModel>(authorityInvoker)
?? throw new InvalidOperationException("Authority invocation should have returned a non-nullable result!");
/// <inheritdoc />
async ValueTask<TApiModel> IGraphQLAuthorityInvoker<TAuthority>.InvokeTransformable<TResult, TApiModel, TTransformer>(Func<TAuthority, RequirementsGated<AuthorityResponse<TResult>>> authorityInvoker)
=> await ((IGraphQLAuthorityInvoker<TAuthority>)this).InvokeTransformableAllowMissing<TResult, TApiModel, TTransformer>(authorityInvoker)
?? throw new InvalidOperationException("Authority invocation should have returned a non-nullable result!");
/// <inheritdoc />
protected override void OnRequirementsFailure(AuthorizationFailure authFailure)
=> throw authFailure.ForbiddenGraphQLException();
/// <summary>
/// Unwrap a <see cref="RequirementsGated{TResult}"/> result, throwing a <see cref="GraphQLException"/> if they weren't met.
/// </summary>
/// <typeparam name="TResult">The <see cref="Type"/> contained by the <paramref name="requirementsGate"/>.</typeparam>
/// <param name="requirementsGate">The <see cref="RequirementsGated{TResult}"/> result.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <typeparamref name="TResult"/> if the requirements were met.</returns>
/// <exception cref="GraphQLException">Throw when requirements were not met.</exception>
new async ValueTask<TResult> ExecuteIfRequirementsSatisfied<TResult>(RequirementsGated<TResult> requirementsGate)
where TResult : class
=> (await base.ExecuteIfRequirementsSatisfied(requirementsGate))!; // base class throws if requirements evaluation fails
}
}
@@ -1,7 +1,6 @@
using System;
using System.Linq;
using Tgstation.Server.Host.Models;
using System.Threading.Tasks;
namespace Tgstation.Server.Host.Authority.Core
{
@@ -16,21 +15,8 @@ namespace Tgstation.Server.Host.Authority.Core
/// Invoke a <typeparamref name="TAuthority"/> method and get the result.
/// </summary>
/// <typeparam name="TResult">The returned <see cref="Type"/>.</typeparam>
/// <param name="authorityInvoker">The <typeparamref name="TAuthority"/> <see cref="Func{T, TResult}"/> returning a <see cref="IQueryable{T}"/> <typeparamref name="TResult"/>.</param>
/// <returns>A <see cref="IQueryable{T}"/> <typeparamref name="TResult"/> returned.</returns>
IQueryable<TResult> InvokeQueryable<TResult>(Func<TAuthority, IQueryable<TResult>> authorityInvoker);
/// <summary>
/// Invoke a <typeparamref name="TAuthority"/> method and get the transformed result.
/// </summary>
/// <typeparam name="TResult">The <see cref="Type"/> returned by the <typeparamref name="TAuthority"/>.</typeparam>
/// <typeparam name="TApiModel">The returned <see cref="Type"/>.</typeparam>
/// <typeparam name="TTransformer">The <see cref="ITransformer{TInput, TOutput}"/> for converting <typeparamref name="TResult"/>s to <typeparamref name="TApiModel"/>s.</typeparam>
/// <param name="authorityInvoker">The <typeparamref name="TAuthority"/> <see cref="Func{T, TResult}"/> returning a <see cref="IQueryable{T}"/> <typeparamref name="TResult"/>.</param>
/// <returns>A <see cref="IQueryable{T}"/> <typeparamref name="TResult"/> returned.</returns>
IQueryable<TApiModel> InvokeTransformableQueryable<TResult, TApiModel, TTransformer>(Func<TAuthority, IQueryable<TResult>> authorityInvoker)
where TResult : IApiTransformable<TResult, TApiModel, TTransformer>
where TApiModel : notnull
where TTransformer : ITransformer<TResult, TApiModel>, new();
/// <param name="authorityInvoker">The authority invocation returning a <see cref="IQueryable{T}"/> <typeparamref name="TResult"/>.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="IQueryable{T}"/> <typeparamref name="TResult"/> returned on success or <see langword="null"/> if the requirements weren't satisfied.</returns>
ValueTask<IQueryable<TResult>?> InvokeQueryable<TResult>(Func<TAuthority, RequirementsGated<IQueryable<TResult>>> authorityInvoker);
}
}
@@ -0,0 +1,163 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Tgstation.Server.Host.Security;
namespace Tgstation.Server.Host.Authority.Core
{
/// <summary>
/// Evaluates a set of <see cref="IAuthorizationRequirement"/>s to be checked before executing a response.
/// </summary>
/// <typeparam name="TResult">The <see cref="Type"/> of object the response generates.</typeparam>
public sealed class RequirementsGated<TResult>
{
/// <summary>
/// The <see cref="IAuthorizationRequirement"/> retrieval function. <see cref="UserSessionValidRequirement"/> is included automatically.
/// </summary>
readonly Func<ValueTask<IEnumerable<IAuthorizationRequirement>>> getRequirements;
/// <summary>
/// The response generation function.
/// </summary>
readonly Func<Security.IAuthorizationService, ValueTask<TResult>> getResponse;
/// <summary>
/// If the <see cref="UserSessionValidRequirement"/> should not be added.
/// </summary>
readonly bool doNotAddUserSessionValidRequirement;
/// <summary>
/// Convert a given <paramref name="result"/> into a <see cref="RequirementsGated{TResult}"/>.
/// </summary>
/// <param name="result">The <typeparamref name="TResult"/> to convert.</param>
/// <returns>A new <see cref="RequirementsGated{TResult}"/> based on <paramref name="result"/>.</returns>
#pragma warning disable CA1000 // Do not declare static members on generic types
public static RequirementsGated<TResult> FromResult(TResult result)
#pragma warning restore CA1000 // Do not declare static members on generic types
=> new(
() => (IAuthorizationRequirement?)null,
() => ValueTask.FromResult(result));
/// <summary>
/// Initializes a new instance of the <see cref="RequirementsGated{TResult}"/> class.
/// </summary>
/// <param name="getRequirement">The value of <see cref="getRequirements"/>. Resulting in a <see langword="null"/> value is eqivalent to returning an empty <see cref="IEnumerable{T}"/> of <see cref="IAuthorizationRequirement"/>s.</param>
/// <param name="getResponse">The value of <see cref="getResponse"/>.</param>
public RequirementsGated(
Func<ValueTask<IAuthorizationRequirement?>> getRequirement,
Func<ValueTask<TResult>> getResponse)
{
ArgumentNullException.ThrowIfNull(getRequirement);
ArgumentNullException.ThrowIfNull(getResponse);
getRequirements = async () =>
{
var requirement = await getRequirement();
if (requirement == null)
return Enumerable.Empty<IAuthorizationRequirement>();
return new List<IAuthorizationRequirement>
{
requirement,
};
};
this.getResponse = _ => getResponse();
}
/// <summary>
/// Initializes a new instance of the <see cref="RequirementsGated{TResult}"/> class.
/// </summary>
/// <param name="getRequirements">The value of <see cref="getRequirements"/>.</param>
/// <param name="getResponse">The value of <see cref="getResponse"/>.</param>
public RequirementsGated(
Func<IEnumerable<IAuthorizationRequirement>> getRequirements,
Func<ValueTask<TResult>> getResponse)
{
ArgumentNullException.ThrowIfNull(getRequirements);
ArgumentNullException.ThrowIfNull(getResponse);
this.getRequirements = () => ValueTask.FromResult(getRequirements());
this.getResponse = _ => getResponse();
}
/// <summary>
/// Initializes a new instance of the <see cref="RequirementsGated{TResult}"/> class.
/// </summary>
/// <param name="getRequirement">The value of <see cref="getRequirements"/>. Resulting in a <see langword="null"/> value is eqivalent to returning an empty <see cref="IEnumerable{T}"/> of <see cref="IAuthorizationRequirement"/>s.</param>
/// <param name="getResponse">The value of <see cref="getResponse"/>.</param>
/// <param name="doNotAddUserSessionValidRequirement">The value of <see cref="doNotAddUserSessionValidRequirement"/>.</param>
public RequirementsGated(
Func<IAuthorizationRequirement?> getRequirement,
Func<ValueTask<TResult>> getResponse,
bool doNotAddUserSessionValidRequirement = false)
{
ArgumentNullException.ThrowIfNull(getRequirement);
ArgumentNullException.ThrowIfNull(getResponse);
getRequirements = () =>
{
var requirement = getRequirement();
if (requirement == null)
return ValueTask.FromResult(Enumerable.Empty<IAuthorizationRequirement>());
return ValueTask.FromResult<IEnumerable<IAuthorizationRequirement>>(
new List<IAuthorizationRequirement>
{
requirement,
});
};
this.getResponse = _ => getResponse();
this.doNotAddUserSessionValidRequirement = doNotAddUserSessionValidRequirement;
}
/// <summary>
/// Initializes a new instance of the <see cref="RequirementsGated{TResult}"/> class.
/// </summary>
/// <param name="getRequirement">The value of <see cref="getRequirements"/>. Resulting in a <see langword="null"/> value is eqivalent to returning an empty <see cref="IEnumerable{T}"/> of <see cref="IAuthorizationRequirement"/>s.</param>
/// <param name="getResponse">The value of <see cref="getResponse"/>.</param>
public RequirementsGated(
Func<IAuthorizationRequirement?> getRequirement,
Func<Security.IAuthorizationService, ValueTask<TResult>> getResponse)
{
ArgumentNullException.ThrowIfNull(getRequirement);
getRequirements = () =>
{
var requirement = getRequirement();
if (requirement == null)
return ValueTask.FromResult(Enumerable.Empty<IAuthorizationRequirement>());
return ValueTask.FromResult<IEnumerable<IAuthorizationRequirement>>(
new List<IAuthorizationRequirement>
{
requirement,
});
};
this.getResponse = getResponse ?? throw new ArgumentNullException(nameof(getResponse));
}
/// <summary>
/// Evaluates the <see cref="IAuthorizationRequirement"/>s of the request.
/// </summary>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="IAuthorizationRequirement"/>s for the request.</returns>
public async ValueTask<IEnumerable<IAuthorizationRequirement>> GetRequirements()
{
var requirements = await getRequirements();
if (!doNotAddUserSessionValidRequirement)
requirements = UserSessionValidRequirement.InstanceAsEnumerable.Concat(requirements);
return requirements;
}
/// <summary>
/// Executes the request.
/// </summary>
/// <param name="authorizationService">The authorization service to use.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the request <typeparamref name="TResult"/>.</returns>
public ValueTask<TResult> Execute(Security.IAuthorizationService authorizationService)
=> getResponse(authorizationService);
}
}
@@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Mvc;
using Tgstation.Server.Host.Controllers;
using Tgstation.Server.Host.Extensions;
using Tgstation.Server.Host.Security;
namespace Tgstation.Server.Host.Authority.Core
{
@@ -22,7 +23,10 @@ namespace Tgstation.Server.Host.Authority.Core
/// <returns>An <see cref="IActionResult"/> for the <paramref name="authorityResponse"/>.</returns>
/// <typeparam name="TResult">The result <see cref="Type"/> returned in the <paramref name="authorityResponse"/>.</typeparam>
/// <typeparam name="TApiModel">The REST API result model built from <paramref name="authorityResponse"/>.</typeparam>
static IActionResult CreateSuccessfulActionResult<TResult, TApiModel>(ApiController controller, Func<TResult, TApiModel> resultTransformer, AuthorityResponse<TResult> authorityResponse)
static IActionResult CreateSuccessfulActionResult<TResult, TApiModel>(
ApiController controller,
Func<TResult, TApiModel> resultTransformer,
AuthorityResponse<TResult> authorityResponse)
where TApiModel : notnull
{
if (authorityResponse.IsNoContent!.Value)
@@ -44,9 +48,14 @@ namespace Tgstation.Server.Host.Authority.Core
/// </summary>
/// <param name="controller">The <see cref="ApiController"/> to use.</param>
/// <param name="authorityResponse">The <see cref="AuthorityResponse"/>.</param>
/// <returns>An <see cref="IActionResult"/> if the <paramref name="authorityResponse"/> is not successful, <see langword="null"/> otherwise.</returns>
static IActionResult? CreateErroredActionResult(ApiController controller, AuthorityResponse authorityResponse)
/// <returns>An <see cref="IActionResult"/> if the <paramref name="authorityResponse"/> is not successful, <see langword="null"/> otherwise. If <see langword="null"/> is returned, <paramref name="authorityResponse"/> is not <see langword="null"/>.</returns>
static IActionResult? CreateErroredActionResult(
ApiController controller,
AuthorityResponse? authorityResponse)
{
if (authorityResponse == null)
return controller.Forbid();
if (authorityResponse.Success)
return null;
@@ -74,47 +83,51 @@ namespace Tgstation.Server.Host.Authority.Core
/// Initializes a new instance of the <see cref="RestAuthorityInvoker{TAuthority}"/> class.
/// </summary>
/// <param name="authority">The <typeparamref name="TAuthority"/>.</param>
public RestAuthorityInvoker(TAuthority authority)
: base(authority)
/// <param name="authorizationService">The <see cref="IAuthorizationService"/> to use.</param>
public RestAuthorityInvoker(TAuthority authority, IAuthorizationService authorizationService)
: base(authority, authorizationService)
{
}
/// <inheritdoc />
async ValueTask<IActionResult> IRestAuthorityInvoker<TAuthority>.Invoke(ApiController controller, Func<TAuthority, ValueTask<AuthorityResponse>> authorityInvoker)
async ValueTask<IActionResult> IRestAuthorityInvoker<TAuthority>.Invoke(ApiController controller, Func<TAuthority, RequirementsGated<AuthorityResponse>> authorityInvoker)
{
ArgumentNullException.ThrowIfNull(controller);
ArgumentNullException.ThrowIfNull(authorityInvoker);
var authorityResponse = await authorityInvoker(Authority);
var requirementsGate = authorityInvoker(Authority);
var authorityResponse = await ExecuteIfRequirementsSatisfied(requirementsGate);
return CreateErroredActionResult(controller, authorityResponse) ?? controller.NoContent();
}
/// <inheritdoc />
async ValueTask<IActionResult> IRestAuthorityInvoker<TAuthority>.Invoke<TResult, TApiModel>(ApiController controller, Func<TAuthority, ValueTask<AuthorityResponse<TResult>>> authorityInvoker)
async ValueTask<IActionResult> IRestAuthorityInvoker<TAuthority>.Invoke<TResult, TApiModel>(ApiController controller, Func<TAuthority, RequirementsGated<AuthorityResponse<TResult>>> authorityInvoker)
{
ArgumentNullException.ThrowIfNull(controller);
ArgumentNullException.ThrowIfNull(authorityInvoker);
var authorityResponse = await authorityInvoker(Authority);
var requirementsGate = authorityInvoker(Authority);
var authorityResponse = await ExecuteIfRequirementsSatisfied(requirementsGate);
var erroredResult = CreateErroredActionResult(controller, authorityResponse);
if (erroredResult != null)
return erroredResult;
return CreateSuccessfulActionResult(controller, result => result, authorityResponse);
return CreateSuccessfulActionResult(controller, result => result, authorityResponse!);
}
/// <inheritdoc />
async ValueTask<IActionResult> IRestAuthorityInvoker<TAuthority>.InvokeTransformable<TResult, TApiModel>(ApiController controller, Func<TAuthority, ValueTask<AuthorityResponse<TResult>>> authorityInvoker)
async ValueTask<IActionResult> IRestAuthorityInvoker<TAuthority>.InvokeTransformable<TResult, TApiModel>(ApiController controller, Func<TAuthority, RequirementsGated<AuthorityResponse<TResult>>> authorityInvoker)
{
ArgumentNullException.ThrowIfNull(controller);
ArgumentNullException.ThrowIfNull(authorityInvoker);
var authorityResponse = await authorityInvoker(Authority);
var requirementsGate = authorityInvoker(Authority);
var authorityResponse = await ExecuteIfRequirementsSatisfied(requirementsGate);
var erroredResult = CreateErroredActionResult(controller, authorityResponse);
if (erroredResult != null)
return erroredResult;
return CreateSuccessfulActionResult(controller, result => result.ToApi(), authorityResponse);
return CreateSuccessfulActionResult(controller, result => result.ToApi(), authorityResponse!);
}
}
}
@@ -1,11 +1,8 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Tgstation.Server.Api.Models.Response;
using Tgstation.Server.Api.Rights;
using Tgstation.Server.Host.Authority.Core;
using Tgstation.Server.Host.Security;
namespace Tgstation.Server.Host.Authority
{
@@ -19,9 +16,8 @@ namespace Tgstation.Server.Host.Authority
/// </summary>
/// <param name="forceFresh">Bypass the caching that the authority performs for this request, forcing it to contact GitHub.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="AdministrationResponse"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
[TgsAuthorize(AdministrationRights.ChangeVersion)]
ValueTask<AuthorityResponse<AdministrationResponse>> GetUpdateInformation(bool forceFresh, CancellationToken cancellationToken);
/// <returns>A <see cref="RequirementsGated{TResult}"/> <see cref="AdministrationResponse"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
RequirementsGated<AuthorityResponse<AdministrationResponse>> GetUpdateInformation(bool forceFresh, CancellationToken cancellationToken);
/// <summary>
/// Triggers a restart of tgstation-server without terminating running game instances, setting its version to a given <paramref name="targetVersion"/>.
@@ -29,15 +25,21 @@ namespace Tgstation.Server.Host.Authority
/// <param name="targetVersion">The <see cref="Version"/> TGS will switch to upon reboot.</param>
/// <param name="uploadZip">If <see langword="true"/> a <see cref="FileTicketResponse.FileTicket"/> will be returned and the call must provide an uploaded zip file containing the update data to the file transfer service.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="ServerUpdateResponse"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
[TgsAuthorize(AdministrationRights.ChangeVersion | AdministrationRights.UploadVersion)]
ValueTask<AuthorityResponse<ServerUpdateResponse>> TriggerServerVersionChange(Version targetVersion, bool uploadZip, CancellationToken cancellationToken);
/// <returns>A <see cref="RequirementsGated{TResult}"/> <see cref="ServerUpdateResponse"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
RequirementsGated<AuthorityResponse<ServerUpdateResponse>> TriggerServerVersionChange(Version targetVersion, bool uploadZip, CancellationToken cancellationToken);
/// <summary>
/// Triggers a restart of tgstation-server without terminating running game instances.
/// </summary>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="AuthorityResponse"/>.</returns>
[TgsAuthorize(AdministrationRights.RestartHost)]
ValueTask<AuthorityResponse> TriggerServerRestart();
/// <returns>A <see cref="RequirementsGated{TResult}"/> <see cref="AuthorityResponse"/>.</returns>
RequirementsGated<AuthorityResponse> TriggerServerRestart();
/// <summary>
/// Get a ticket for downloading a log file at a given <paramref name="path"/>.
/// </summary>
/// <param name="path">The relative path to the log file in the directory.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="RequirementsGated{TResult}"/> <see cref="LogFileResponse"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
RequirementsGated<AuthorityResponse<LogFileResponse>> GetLog(string path, CancellationToken cancellationToken);
}
}
@@ -1,4 +1,5 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Tgstation.Server.Host.Authority.Core;
@@ -10,24 +11,25 @@ namespace Tgstation.Server.Host.Authority
/// Invokes <typeparamref name="TAuthority"/>s from GraphQL endpoints.
/// </summary>
/// <typeparam name="TAuthority">The <see cref="IAuthority"/> invoked.</typeparam>
/// <remarks>We take the approach that fields should be non-nullable if that is the case under ideal circumstances. Authorization issues should throw.</remarks>
public interface IGraphQLAuthorityInvoker<TAuthority> : IAuthorityInvoker<TAuthority>
where TAuthority : IAuthority
{
/// <summary>
/// Invoke a <typeparamref name="TAuthority"/> method with no success result.
/// </summary>
/// <param name="authorityInvoker">The <typeparamref name="TAuthority"/> <see cref="Func{T, TResult}"/> returning a <see cref="ValueTask{TResult}"/> resulting in the <see cref="AuthorityResponse"/>.</param>
/// <param name="authorityInvoker">The <typeparamref name="TAuthority"/> <see cref="Func{T, TResult}"/> resulting in the <see cref="RequirementsGated{TResult}"/> <see cref="AuthorityResponse"/>.</param>
/// <returns>A <see cref="ValueTask"/> representing the running operation.</returns>
ValueTask Invoke(Func<TAuthority, ValueTask<AuthorityResponse>> authorityInvoker);
ValueTask Invoke(Func<TAuthority, RequirementsGated<AuthorityResponse>> authorityInvoker);
/// <summary>
/// Invoke a <typeparamref name="TAuthority"/> method and get the result.
/// </summary>
/// <typeparam name="TResult">The <see cref="AuthorityResponse{TResult}.Result"/> <see cref="Type"/>.</typeparam>
/// <typeparam name="TApiModel">The resulting <see cref="Type"/> of the return value.</typeparam>
/// <param name="authorityInvoker">The <typeparamref name="TAuthority"/> <see cref="Func{T, TResult}"/> returning a <see cref="ValueTask{TResult}"/> resulting in the <see cref="AuthorityResponse{TResult}"/>.</param>
/// <param name="authorityInvoker">The <typeparamref name="TAuthority"/> <see cref="Func{T, TResult}"/> resulting in the <see cref="RequirementsGated{TResult}"/> <see cref="AuthorityResponse{TResult}"/>.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <typeparamref name="TApiModel"/> generated for the resulting <see cref="AuthorityResponse{TResult}"/>.</returns>
ValueTask<TApiModel?> InvokeAllowMissing<TResult, TApiModel>(Func<TAuthority, ValueTask<AuthorityResponse<TResult>>> authorityInvoker)
ValueTask<TApiModel?> InvokeAllowMissing<TResult, TApiModel>(Func<TAuthority, RequirementsGated<AuthorityResponse<TResult>>> authorityInvoker)
where TResult : TApiModel
where TApiModel : notnull;
@@ -37,9 +39,9 @@ namespace Tgstation.Server.Host.Authority
/// <typeparam name="TResult">The <see cref="AuthorityResponse{TResult}.Result"/> <see cref="Type"/>.</typeparam>
/// <typeparam name="TApiModel">The resulting <see cref="Type"/> of the return value.</typeparam>
/// <typeparam name="TTransformer">The <see cref="ITransformer{TInput, TOutput}"/> for converting <typeparamref name="TResult"/>s to <typeparamref name="TApiModel"/>s.</typeparam>
/// <param name="authorityInvoker">The <typeparamref name="TAuthority"/> <see cref="Func{T, TResult}"/> returning a <see cref="ValueTask{TResult}"/> resulting in the <see cref="AuthorityResponse{TResult}"/>.</param>
/// <param name="authorityInvoker">The <typeparamref name="TAuthority"/> <see cref="Func{T, TResult}"/> resulting in the <see cref="RequirementsGated{TResult}"/> <see cref="AuthorityResponse{TResult}"/>.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <typeparamref name="TApiModel"/> generated for the resulting <see cref="AuthorityResponse{TResult}"/>.</returns>
ValueTask<TApiModel?> InvokeTransformableAllowMissing<TResult, TApiModel, TTransformer>(Func<TAuthority, ValueTask<AuthorityResponse<TResult>>> authorityInvoker)
ValueTask<TApiModel?> InvokeTransformableAllowMissing<TResult, TApiModel, TTransformer>(Func<TAuthority, RequirementsGated<AuthorityResponse<TResult>>> authorityInvoker)
where TResult : notnull, IApiTransformable<TResult, TApiModel, TTransformer>
where TApiModel : notnull
where TTransformer : ITransformer<TResult, TApiModel>, new();
@@ -49,9 +51,9 @@ namespace Tgstation.Server.Host.Authority
/// </summary>
/// <typeparam name="TResult">The <see cref="AuthorityResponse{TResult}.Result"/> <see cref="Type"/>.</typeparam>
/// <typeparam name="TApiModel">The resulting <see cref="Type"/> of the return value.</typeparam>
/// <param name="authorityInvoker">The <typeparamref name="TAuthority"/> <see cref="Func{T, TResult}"/> returning a <see cref="ValueTask{TResult}"/> resulting in the <see cref="AuthorityResponse{TResult}"/>.</param>
/// <param name="authorityInvoker">The <typeparamref name="TAuthority"/> <see cref="Func{T, TResult}"/> resulting in the <see cref="RequirementsGated{TResult}"/> <see cref="AuthorityResponse{TResult}"/>.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <typeparamref name="TApiModel"/> generated for the resulting <see cref="AuthorityResponse{TResult}"/>.</returns>
ValueTask<TApiModel> Invoke<TResult, TApiModel>(Func<TAuthority, ValueTask<AuthorityResponse<TResult>>> authorityInvoker)
ValueTask<TApiModel> Invoke<TResult, TApiModel>(Func<TAuthority, RequirementsGated<AuthorityResponse<TResult>>> authorityInvoker)
where TResult : TApiModel
where TApiModel : notnull;
@@ -61,11 +63,27 @@ namespace Tgstation.Server.Host.Authority
/// <typeparam name="TResult">The <see cref="AuthorityResponse{TResult}.Result"/> <see cref="Type"/>.</typeparam>
/// <typeparam name="TApiModel">The resulting <see cref="Type"/> of the return value.</typeparam>
/// <typeparam name="TTransformer">The <see cref="ITransformer{TInput, TOutput}"/> for converting <typeparamref name="TResult"/>s to <typeparamref name="TApiModel"/>s.</typeparam>
/// <param name="authorityInvoker">The <typeparamref name="TAuthority"/> <see cref="Func{T, TResult}"/> returning a <see cref="ValueTask{TResult}"/> resulting in the <see cref="AuthorityResponse{TResult}"/>.</param>
/// <param name="authorityInvoker">The <typeparamref name="TAuthority"/> <see cref="Func{T, TResult}"/> resulting in the <see cref="RequirementsGated{TResult}"/> <see cref="AuthorityResponse{TResult}"/>.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <typeparamref name="TApiModel"/> generated for the resulting <see cref="AuthorityResponse{TResult}"/>.</returns>
ValueTask<TApiModel> InvokeTransformable<TResult, TApiModel, TTransformer>(Func<TAuthority, ValueTask<AuthorityResponse<TResult>>> authorityInvoker)
ValueTask<TApiModel> InvokeTransformable<TResult, TApiModel, TTransformer>(Func<TAuthority, RequirementsGated<AuthorityResponse<TResult>>> authorityInvoker)
where TResult : notnull, IApiTransformable<TResult, TApiModel, TTransformer>
where TApiModel : notnull
where TTransformer : ITransformer<TResult, TApiModel>, new();
/// <summary>
/// Invoke a <typeparamref name="TAuthority"/> method and get the transformed result.
/// </summary>
/// <typeparam name="TResult">The <see cref="Type"/> returned by the <typeparamref name="TAuthority"/>.</typeparam>
/// <typeparam name="TApiModel">The returned <see cref="Type"/>.</typeparam>
/// <typeparam name="TTransformer">The <see cref="ITransformer{TInput, TOutput}"/> for converting <typeparamref name="TResult"/>s to <typeparamref name="TApiModel"/>s.</typeparam>
/// <param name="authorityInvoker">The <typeparamref name="TAuthority"/> <see cref="Func{T, TResult}"/> returning a <see cref="IQueryable{T}"/> <typeparamref name="TResult"/>.</param>
/// <param name="preTransformer">Optional transformer for the <see cref="IQueryable{T}"/> run once it has been acquired.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="IQueryable{T}"/> <typeparamref name="TResult"/> returned on success or <see langword="null"/> if the requirements weren't satisfied.</returns>
ValueTask<IQueryable<TApiModel>> InvokeTransformableQueryable<TResult, TApiModel, TTransformer>(
Func<TAuthority, RequirementsGated<IQueryable<TResult>>> authorityInvoker,
Func<IQueryable<TResult>, IQueryable<TResult>>? preTransformer = null)
where TResult : IApiTransformable<TResult, TApiModel, TTransformer>
where TApiModel : notnull
where TTransformer : ITransformer<TResult, TApiModel>, new();
}
}
@@ -16,13 +16,13 @@ namespace Tgstation.Server.Host.Authority
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a <see cref="LoginResult"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
ValueTask<AuthorityResponse<LoginResult>> AttemptLogin(CancellationToken cancellationToken);
RequirementsGated<AuthorityResponse<LoginResult>> AttemptLogin(CancellationToken cancellationToken);
/// <summary>
/// Attempt to login to an OAuth service with the current OAuth credentials.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in an <see cref="OAuthGatewayLoginResult"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
ValueTask<AuthorityResponse<OAuthGatewayLoginResult>> AttemptOAuthGatewayLogin(CancellationToken cancellationToken);
RequirementsGated<AuthorityResponse<OAuthGatewayLoginResult>> AttemptOAuthGatewayLogin(CancellationToken cancellationToken);
}
}
@@ -1,10 +1,8 @@
using System.Threading;
using System.Threading.Tasks;
using Tgstation.Server.Api.Rights;
using Tgstation.Server.Host.Authority.Core;
using Tgstation.Server.Host.Models;
using Tgstation.Server.Host.Security;
namespace Tgstation.Server.Host.Authority
{
@@ -20,7 +18,6 @@ namespace Tgstation.Server.Host.Authority
/// <param name="lookupType">The <see cref="PermissionSetLookupType"/> of <paramref name="id"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a <see cref="PermissionSet"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
[TgsAuthorize(AdministrationRights.ReadUsers)]
ValueTask<AuthorityResponse<PermissionSet>> GetId(long id, PermissionSetLookupType lookupType, CancellationToken cancellationToken);
RequirementsGated<AuthorityResponse<PermissionSet>> GetId(long id, PermissionSetLookupType lookupType, CancellationToken cancellationToken);
}
}
@@ -20,9 +20,9 @@ namespace Tgstation.Server.Host.Authority
/// Invoke a <typeparamref name="TAuthority"/> method with no success result.
/// </summary>
/// <param name="controller">The <see cref="ApiController"/> invoking the <typeparamref name="TAuthority"/>.</param>
/// <param name="authorityInvoker">The <typeparamref name="TAuthority"/> <see cref="Func{T, TResult}"/> returning a <see cref="ValueTask{TResult}"/> resulting in the <see cref="AuthorityResponse"/>.</param>
/// <param name="authorityInvoker">The <typeparamref name="TAuthority"/> <see cref="Func{T, TResult}"/> resulting in the <see cref="RequirementsGated{TResult}"/> <see cref="AuthorityResponse"/>.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="IActionResult"/> generated for the resulting <see cref="AuthorityResponse"/>.</returns>
ValueTask<IActionResult> Invoke(ApiController controller, Func<TAuthority, ValueTask<AuthorityResponse>> authorityInvoker);
ValueTask<IActionResult> Invoke(ApiController controller, Func<TAuthority, RequirementsGated<AuthorityResponse>> authorityInvoker);
/// <summary>
/// Invoke a <typeparamref name="TAuthority"/> method and get the result.
@@ -30,9 +30,9 @@ namespace Tgstation.Server.Host.Authority
/// <typeparam name="TResult">The <see cref="AuthorityResponse{TResult}.Result"/> <see cref="Type"/>.</typeparam>
/// <typeparam name="TApiModel">The resulting <see cref="Type"/> of the <see cref="IActionResult"/>.</typeparam>
/// <param name="controller">The <see cref="ApiController"/> invoking the <typeparamref name="TAuthority"/>.</param>
/// <param name="authorityInvoker">The <typeparamref name="TAuthority"/> <see cref="Func{T, TResult}"/> returning a <see cref="ValueTask{TResult}"/> resulting in the <see cref="AuthorityResponse{TResult}"/>.</param>
/// <param name="authorityInvoker">The <typeparamref name="TAuthority"/> <see cref="Func{T, TResult}"/> resulting in the <see cref="RequirementsGated{TResult}"/> <see cref="AuthorityResponse{TResult}"/>.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="IActionResult"/> generated for the resulting <see cref="AuthorityResponse{TResult}"/>.</returns>
ValueTask<IActionResult> Invoke<TResult, TApiModel>(ApiController controller, Func<TAuthority, ValueTask<AuthorityResponse<TResult>>> authorityInvoker)
ValueTask<IActionResult> Invoke<TResult, TApiModel>(ApiController controller, Func<TAuthority, RequirementsGated<AuthorityResponse<TResult>>> authorityInvoker)
where TResult : TApiModel
where TApiModel : notnull;
@@ -42,9 +42,9 @@ namespace Tgstation.Server.Host.Authority
/// <typeparam name="TResult">The <see cref="AuthorityResponse{TResult}.Result"/> <see cref="Type"/>.</typeparam>
/// <typeparam name="TApiModel">The returned REST <see cref="Type"/>.</typeparam>
/// <param name="controller">The <see cref="ApiController"/> invoking the <typeparamref name="TAuthority"/>.</param>
/// <param name="authorityInvoker">The <typeparamref name="TAuthority"/> <see cref="Func{T, TResult}"/> returning a <see cref="ValueTask{TResult}"/> resulting in the <see cref="AuthorityResponse{TResult}"/>.</param>
/// <param name="authorityInvoker">The <typeparamref name="TAuthority"/> <see cref="Func{T, TResult}"/> resulting in the <see cref="RequirementsGated{TResult}"/> <see cref="AuthorityResponse{TResult}"/>.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="IActionResult"/> generated for the resulting <see cref="AuthorityResponse{TResult}"/>.</returns>
ValueTask<IActionResult> InvokeTransformable<TResult, TApiModel>(ApiController controller, Func<TAuthority, ValueTask<AuthorityResponse<TResult>>> authorityInvoker)
ValueTask<IActionResult> InvokeTransformable<TResult, TApiModel>(ApiController controller, Func<TAuthority, RequirementsGated<AuthorityResponse<TResult>>> authorityInvoker)
where TResult : notnull, ILegacyApiTransformable<TApiModel>
where TApiModel : notnull;
}
@@ -1,13 +1,10 @@
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Tgstation.Server.Api.Models;
using Tgstation.Server.Api.Models.Request;
using Tgstation.Server.Api.Rights;
using Tgstation.Server.Host.Authority.Core;
using Tgstation.Server.Host.Models;
using Tgstation.Server.Host.Security;
namespace Tgstation.Server.Host.Authority
{
@@ -20,9 +17,8 @@ namespace Tgstation.Server.Host.Authority
/// Gets the currently authenticated user.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a <see cref="User"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
[TgsAuthorize]
ValueTask<AuthorityResponse<User>> Read(CancellationToken cancellationToken);
/// <returns>A <see cref="RequirementsGated{TResult}"/> <see cref="User"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
RequirementsGated<AuthorityResponse<User>> Read(CancellationToken cancellationToken);
/// <summary>
/// Gets the <see cref="User"/> with a given <paramref name="id"/>.
@@ -31,33 +27,31 @@ namespace Tgstation.Server.Host.Authority
/// <param name="includeJoins">If related entities should be loaded.</param>
/// <param name="allowSystemUser">If the <see cref="User.TgsSystemUserName"/> may be returned.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a <see cref="User"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
[TgsAuthorize(AdministrationRights.ReadUsers)]
ValueTask<AuthorityResponse<User>> GetId(long id, bool includeJoins, bool allowSystemUser, CancellationToken cancellationToken);
/// <returns>A <see cref="RequirementsGated{TResult}"/> <see cref="User"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
RequirementsGated<AuthorityResponse<User>> GetId(long id, bool includeJoins, bool allowSystemUser, CancellationToken cancellationToken);
/// <summary>
/// Gets the <see cref="GraphQL.Types.OAuth.OAuthConnection"/>s for the <see cref="User"/> with a given <paramref name="userId"/>.
/// </summary>
/// <param name="userId">The <see cref="EntityId.Id"/> of the <see cref="User"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in an <see cref="global::System.Array"/> of <see cref="GraphQL.Types.OAuth.OAuthConnection"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
ValueTask<AuthorityResponse<GraphQL.Types.OAuth.OAuthConnection[]>> OAuthConnections(long userId, CancellationToken cancellationToken);
/// <returns>A <see cref="RequirementsGated{TResult}"/> <see cref="global::System.Array"/> of <see cref="GraphQL.Types.OAuth.OAuthConnection"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
RequirementsGated<AuthorityResponse<GraphQL.Types.OAuth.OAuthConnection[]>> OAuthConnections(long userId, CancellationToken cancellationToken);
/// <summary>
/// Gets the <see cref="GraphQL.Types.OAuth.OidcConnection"/>s for the <see cref="User"/> with a given <paramref name="userId"/>.
/// </summary>
/// <param name="userId">The <see cref="EntityId.Id"/> of the <see cref="User"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in an <see cref="global::System.Array"/> of <see cref="GraphQL.Types.OAuth.OidcConnection"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
ValueTask<AuthorityResponse<GraphQL.Types.OAuth.OidcConnection[]>> OidcConnections(long userId, CancellationToken cancellationToken);
/// <returns>A <see cref="RequirementsGated{TResult}"/> <see cref="global::System.Array"/> of <see cref="GraphQL.Types.OAuth.OidcConnection"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
RequirementsGated<AuthorityResponse<GraphQL.Types.OAuth.OidcConnection[]>> OidcConnections(long userId, CancellationToken cancellationToken);
/// <summary>
/// Gets all registered <see cref="User"/>s.
/// </summary>
/// <param name="includeJoins">If related entities should be loaded.</param>
/// <returns>A <see cref="IQueryable{T}"/> of <see cref="User"/>s.</returns>
[TgsAuthorize(AdministrationRights.ReadUsers)]
IQueryable<User> Queryable(bool includeJoins);
/// <returns>A <see cref="RequirementsGated{TResult}"/> <see cref="IQueryable{T}"/> of <see cref="User"/>s.</returns>
RequirementsGated<IQueryable<User>> Queryable(bool includeJoins);
/// <summary>
/// Creates a <see cref="User"/>.
@@ -65,9 +59,8 @@ namespace Tgstation.Server.Host.Authority
/// <param name="createRequest">The <see cref="UserCreateRequest"/>.</param>
/// <param name="needZeroLengthPasswordWithOAuthConnections">If a zero-length <see cref="UserUpdateRequest.Password"/> indicates and OAuth only user.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in am <see cref="AuthorityResponse{TResult}"/> for the created <see cref="User"/>.</returns>
[TgsAuthorize(AdministrationRights.WriteUsers)]
ValueTask<AuthorityResponse<User>> Create(
/// <returns>A <see cref="RequirementsGated{TResult}"/> <see cref="AuthorityResponse{TResult}"/> for the created <see cref="UpdatedUser"/>.</returns>
RequirementsGated<AuthorityResponse<UpdatedUser>> Create(
UserCreateRequest createRequest,
bool? needZeroLengthPasswordWithOAuthConnections,
CancellationToken cancellationToken);
@@ -77,8 +70,7 @@ namespace Tgstation.Server.Host.Authority
/// </summary>
/// <param name="updateRequest">The <see cref="UserUpdateRequest"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in am <see cref="AuthorityResponse{TResult}"/> for the created <see cref="User"/>.</returns>
[TgsAuthorize(AdministrationRights.WriteUsers | AdministrationRights.EditOwnPassword | AdministrationRights.EditOwnServiceConnections)]
ValueTask<AuthorityResponse<User>> Update(UserUpdateRequest updateRequest, CancellationToken cancellationToken);
/// <returns>A <see cref="RequirementsGated{TResult}"/> <see cref="AuthorityResponse{TResult}"/> for the created <see cref="UpdatedUser"/>.</returns>
RequirementsGated<AuthorityResponse<UpdatedUser>> Update(UserUpdateRequest updateRequest, CancellationToken cancellationToken);
}
}
@@ -17,8 +17,9 @@ namespace Tgstation.Server.Host.Authority
/// <summary>
/// Gets the current <see cref="UserGroup"/>.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a <see cref="UserGroup"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
ValueTask<AuthorityResponse<UserGroup>> Read();
RequirementsGated<AuthorityResponse<UserGroup>> Read(CancellationToken cancellationToken);
/// <summary>
/// Gets the <see cref="UserGroup"/> with a given <paramref name="id"/>.
@@ -26,17 +27,17 @@ namespace Tgstation.Server.Host.Authority
/// <param name="id">The <see cref="Api.Models.EntityId.Id"/> of the <see cref="UserGroup"/>.</param>
/// <param name="includeJoins">If related entities should be loaded.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a <see cref="User"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
/// <returns>A <see cref="RequirementsGated{TResult}"/> <see cref="User"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
[TgsAuthorize(AdministrationRights.ReadUsers)]
ValueTask<AuthorityResponse<UserGroup>> GetId(long id, bool includeJoins, CancellationToken cancellationToken);
RequirementsGated<AuthorityResponse<UserGroup>> GetId(long id, bool includeJoins, CancellationToken cancellationToken);
/// <summary>
/// Gets all registered <see cref="UserGroup"/>s.
/// </summary>
/// <param name="includeJoins">If related entities should be loaded.</param>
/// <returns>A <see cref="IQueryable{T}"/> of <see cref="UserGroup"/>s.</returns>
/// <returns>A <see cref="RequirementsGated{TResult}"/> <see cref="IQueryable{T}"/> of <see cref="UserGroup"/>s.</returns>
[TgsAuthorize(AdministrationRights.ReadUsers)]
IQueryable<UserGroup> Queryable(bool includeJoins);
RequirementsGated<IQueryable<UserGroup>> Queryable(bool includeJoins);
/// <summary>
/// Create a <see cref="UserGroup"/>.
@@ -44,9 +45,9 @@ namespace Tgstation.Server.Host.Authority
/// <param name="name">The created <see cref="UserGroup"/>'s <see cref="Api.Models.NamedEntity.Name"/>.</param>
/// <param name="permissionSet">The created <see cref="UserGroup"/>'s <see cref="UserGroup.PermissionSet"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a <see cref="UserGroup"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
/// <returns>A <see cref="RequirementsGated{TResult}"/> <see cref="UserGroup"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
[TgsAuthorize(AdministrationRights.WriteUsers)]
ValueTask<AuthorityResponse<UserGroup>> Create(string name, PermissionSet? permissionSet, CancellationToken cancellationToken);
RequirementsGated<AuthorityResponse<UserGroup>> Create(string name, PermissionSet? permissionSet, CancellationToken cancellationToken);
/// <summary>
/// Updates a <see cref="UserGroup"/>.
@@ -55,17 +56,17 @@ namespace Tgstation.Server.Host.Authority
/// <param name="newName">The optional new <see cref="Api.Models.NamedEntity.Name"/> for the <see cref="UserGroup"/>.</param>
/// <param name="newPermissionSet">The optional new <see cref="UserGroup.PermissionSet"/> for the <see cref="UserGroup"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a <see cref="UserGroup"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a <see cref="RequirementsGated{TResult}"/> <see cref="UserGroup"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
[TgsAuthorize(AdministrationRights.WriteUsers)]
ValueTask<AuthorityResponse<UserGroup>> Update(long id, string? newName, PermissionSet? newPermissionSet, CancellationToken cancellationToken);
RequirementsGated<AuthorityResponse<UserGroup>> Update(long id, string? newName, PermissionSet? newPermissionSet, CancellationToken cancellationToken);
/// <summary>
/// Deletes an empty <see cref="UserGroup"/>.
/// </summary>
/// <param name="id">The <see cref="Api.Models.EntityId.Id"/> of the <see cref="UserGroup"/> to delete.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask"/> representing the running operation.</returns>
/// <returns>A <see cref="RequirementsGated{TResult}"/> <see cref="AuthorityResponse"/> representing the running operation.</returns>
[TgsAuthorize(AdministrationRights.WriteUsers)]
ValueTask<AuthorityResponse> DeleteEmpty(long id, CancellationToken cancellationToken);
RequirementsGated<AuthorityResponse> DeleteEmpty(long id, CancellationToken cancellationToken);
}
}
@@ -3,6 +3,7 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
@@ -103,7 +104,6 @@ namespace Tgstation.Server.Host.Authority
/// <summary>
/// Initializes a new instance of the <see cref="LoginAuthority"/> class.
/// </summary>
/// <param name="authenticationContext">The <see cref="IAuthenticationContext"/> to use.</param>
/// <param name="databaseContext">The <see cref="IDatabaseContext"/> to use.</param>
/// <param name="logger">The <see cref="ILogger"/> to use.</param>
/// <param name="apiHeadersProvider">The value of <see cref="apiHeadersProvider"/>.</param>
@@ -115,7 +115,6 @@ namespace Tgstation.Server.Host.Authority
/// <param name="sessionInvalidationTracker">The value of <see cref="sessionInvalidationTracker"/>.</param>
/// <param name="securityConfigurationOptions">The <see cref="IOptions{TOptions}"/> containing the value of <see cref="securityConfiguration"/>.</param>
public LoginAuthority(
IAuthenticationContext authenticationContext,
IDatabaseContext databaseContext,
ILogger<LoginAuthority> logger,
IApiHeadersProvider apiHeadersProvider,
@@ -127,7 +126,6 @@ namespace Tgstation.Server.Host.Authority
ISessionInvalidationTracker sessionInvalidationTracker,
IOptions<SecurityConfiguration> securityConfigurationOptions)
: base(
authenticationContext,
databaseContext,
logger)
{
@@ -142,7 +140,45 @@ namespace Tgstation.Server.Host.Authority
}
/// <inheritdoc />
public async ValueTask<AuthorityResponse<LoginResult>> AttemptLogin(CancellationToken cancellationToken)
public RequirementsGated<AuthorityResponse<LoginResult>> AttemptLogin(CancellationToken cancellationToken)
=> new(
() => null,
() => AttemptLoginImpl(cancellationToken),
true);
/// <inheritdoc />
public RequirementsGated<AuthorityResponse<OAuthGatewayLoginResult>> AttemptOAuthGatewayLogin(CancellationToken cancellationToken)
=> new(
() => (IAuthorizationRequirement?)null,
async () =>
{
var headers = apiHeadersProvider.ApiHeaders;
if (headers == null)
return GenerateHeadersExceptionResponse<OAuthGatewayLoginResult>(apiHeadersProvider.HeadersException!);
var oAuthProvider = headers.OAuthProvider;
if (!oAuthProvider.HasValue)
return BadRequest<OAuthGatewayLoginResult>(ErrorCode.BadHeaders);
var (errorResponse, oAuthResult) = await TryOAuthenticate<OAuthGatewayLoginResult>(headers, oAuthProvider.Value, false, cancellationToken);
if (errorResponse != null)
return errorResponse;
Logger.LogDebug("Generated {provider} OAuth AccessCode", oAuthProvider.Value);
return new(
new OAuthGatewayLoginResult
{
AccessCode = oAuthResult!.Value.AccessCode,
});
});
/// <summary>
/// Login process.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="AuthorityResponse{TResult}"/> for the <see cref="LoginResult"/>.</returns>
private async ValueTask<AuthorityResponse<LoginResult>> AttemptLoginImpl(CancellationToken cancellationToken)
{
// password and oauth logins disabled
if (securityConfiguration.OidcStrictMode)
@@ -278,30 +314,6 @@ namespace Tgstation.Server.Host.Authority
}
}
/// <inheritdoc />
public async ValueTask<AuthorityResponse<OAuthGatewayLoginResult>> AttemptOAuthGatewayLogin(CancellationToken cancellationToken)
{
var headers = apiHeadersProvider.ApiHeaders;
if (headers == null)
return GenerateHeadersExceptionResponse<OAuthGatewayLoginResult>(apiHeadersProvider.HeadersException!);
var oAuthProvider = headers.OAuthProvider;
if (!oAuthProvider.HasValue)
return BadRequest<OAuthGatewayLoginResult>(ErrorCode.BadHeaders);
var (errorResponse, oAuthResult) = await TryOAuthenticate<OAuthGatewayLoginResult>(headers, oAuthProvider.Value, false, cancellationToken);
if (errorResponse != null)
return errorResponse;
Logger.LogDebug("Generated {provider} OAuth AccessCode", oAuthProvider.Value);
return new AuthorityResponse<OAuthGatewayLoginResult>(
new OAuthGatewayLoginResult
{
AccessCode = oAuthResult!.Value.AccessCode,
});
}
/// <summary>
/// Add a given <paramref name="systemIdentity"/> to the <see cref="identityCache"/>.
/// </summary>
@@ -12,6 +12,7 @@ using Microsoft.Extensions.Logging;
using Tgstation.Server.Api.Rights;
using Tgstation.Server.Host.Authority.Core;
using Tgstation.Server.Host.Database;
using Tgstation.Server.Host.Extensions;
using Tgstation.Server.Host.Models;
using Tgstation.Server.Host.Security;
@@ -25,6 +26,11 @@ namespace Tgstation.Server.Host.Authority
/// </summary>
readonly IPermissionSetsDataLoader permissionSetsDataLoader;
/// <summary>
/// The <see cref="IClaimsPrincipalAccessor"/> for the <see cref="PermissionSetAuthority"/>.
/// </summary>
readonly IClaimsPrincipalAccessor claimsPrincipalAccessor;
/// <summary>
/// Implements <see cref="permissionSetsDataLoader"/>.
/// </summary>
@@ -85,34 +91,59 @@ namespace Tgstation.Server.Host.Authority
/// <summary>
/// Initializes a new instance of the <see cref="PermissionSetAuthority"/> class.
/// </summary>
/// <param name="authenticationContext">The <see cref="IAuthenticationContext"/> to use.</param>
/// <param name="databaseContext">The <see cref="IDatabaseContext"/> to use.</param>
/// <param name="logger">The <see cref="ILogger"/> to use.</param>
/// <param name="permissionSetsDataLoader">The value of <see cref="permissionSetsDataLoader"/>.</param>
/// <param name="claimsPrincipalAccessor">The value of <see cref="claimsPrincipalAccessor"/>.</param>
public PermissionSetAuthority(
IAuthenticationContext authenticationContext,
IDatabaseContext databaseContext,
ILogger<AuthorityBase> logger,
IPermissionSetsDataLoader permissionSetsDataLoader)
IPermissionSetsDataLoader permissionSetsDataLoader,
IClaimsPrincipalAccessor claimsPrincipalAccessor)
: base(
authenticationContext,
databaseContext,
logger)
{
this.permissionSetsDataLoader = permissionSetsDataLoader ?? throw new ArgumentNullException(nameof(permissionSetsDataLoader));
this.claimsPrincipalAccessor = claimsPrincipalAccessor ?? throw new ArgumentNullException(nameof(claimsPrincipalAccessor));
}
/// <inheritdoc />
public async ValueTask<AuthorityResponse<PermissionSet>> GetId(long id, PermissionSetLookupType lookupType, CancellationToken cancellationToken)
public RequirementsGated<AuthorityResponse<PermissionSet>> GetId(long id, PermissionSetLookupType lookupType, CancellationToken cancellationToken)
{
if (id != AuthenticationContext.PermissionSet.Id && !((AdministrationRights)AuthenticationContext.GetRight(RightsType.Administration)).HasFlag(AdministrationRights.ReadUsers))
return Forbid<PermissionSet>();
var permissionSetTask = permissionSetsDataLoader.LoadAsync((Id: id, LookupType: lookupType), cancellationToken);
return new(
async () =>
{
var userId = claimsPrincipalAccessor.User.GetTgsUserId();
var permissionSet = await permissionSetsDataLoader.LoadAsync((Id: id, LookupType: lookupType), cancellationToken);
if (permissionSet == null)
return NotFound<PermissionSet>();
var groupIdQuery = DatabaseContext
.Users
.AsQueryable()
.Where(user => user.Id == userId)
.Select(user => user.GroupId);
return new AuthorityResponse<PermissionSet>(permissionSet);
var permissionSetId = await DatabaseContext
.PermissionSets
.Where(permissionSet => permissionSet.UserId == userId
|| groupIdQuery.Contains(permissionSet.GroupId))
.Select(permissionSet => permissionSet.Id!.Value)
.FirstAsync(cancellationToken);
if (permissionSetId == id)
return null;
return Flag(AdministrationRights.ReadUsers);
},
async () =>
{
var permissionSet = await permissionSetTask;
if (permissionSet == null)
return NotFound<PermissionSet>();
return new AuthorityResponse<PermissionSet>(permissionSet);
});
}
}
}
@@ -9,6 +9,7 @@ using GreenDonut;
using HotChocolate.Subscriptions;
using Microsoft.AspNetCore.Authorization;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
@@ -22,9 +23,11 @@ using Tgstation.Server.Common.Extensions;
using Tgstation.Server.Host.Authority.Core;
using Tgstation.Server.Host.Configuration;
using Tgstation.Server.Host.Database;
using Tgstation.Server.Host.Extensions;
using Tgstation.Server.Host.Models;
using Tgstation.Server.Host.Models.Transformers;
using Tgstation.Server.Host.Security;
using Tgstation.Server.Host.Security.RightsEvaluation;
namespace Tgstation.Server.Host.Authority
{
@@ -71,6 +74,11 @@ namespace Tgstation.Server.Host.Authority
/// </summary>
readonly ITopicEventSender topicEventSender;
/// <summary>
/// The <see cref="IClaimsPrincipalAccessor"/> for the <see cref="UserAuthority"/>.
/// </summary>
readonly IClaimsPrincipalAccessor claimsPrincipalAccessor;
/// <summary>
/// The <see cref="IOptionsSnapshot{TOptions}"/> of <see cref="GeneralConfiguration"/> for the <see cref="UserAuthority"/>.
/// </summary>
@@ -164,22 +172,21 @@ namespace Tgstation.Server.Host.Authority
/// <param name="model">The <see cref="UserUpdateRequest"/> to check.</param>
/// <param name="newUser">If this is a new <see cref="User"/>.</param>
/// <returns><see langword="null"/> if <paramref name="model"/> is valid, an <see cref="AuthorityResponse{TResult}"/> errored otherwise.</returns>
static AuthorityResponse<User>? CheckValidName(UserUpdateRequest model, bool newUser)
static AuthorityResponse<UpdatedUser>? CheckValidName(UserUpdateRequest model, bool newUser)
{
var userInvalidWithNullName = newUser && model.Name == null && model.SystemIdentifier == null;
if (userInvalidWithNullName || (model.Name != null && String.IsNullOrWhiteSpace(model.Name)))
return BadRequest<User>(ErrorCode.UserMissingName);
return BadRequest<UpdatedUser>(ErrorCode.UserMissingName);
model.Name = model.Name?.Trim();
if (model.Name != null && model.Name.Contains(':', StringComparison.InvariantCulture))
return BadRequest<User>(ErrorCode.UserColonInName);
return BadRequest<UpdatedUser>(ErrorCode.UserColonInName);
return null;
}
/// <summary>
/// Initializes a new instance of the <see cref="UserAuthority"/> class.
/// </summary>
/// <param name="authenticationContext">The <see cref="IAuthenticationContext"/> to use.</param>
/// <param name="databaseContext">The <see cref="IDatabaseContext"/> to use.</param>
/// <param name="logger">The <see cref="ILogger"/> to use.</param>
/// <param name="usersDataLoader">The value of <see cref="usersDataLoader"/>.</param>
@@ -190,10 +197,10 @@ namespace Tgstation.Server.Host.Authority
/// <param name="cryptographySuite">The value of <see cref="cryptographySuite"/>.</param>
/// <param name="sessionInvalidationTracker">The value of <see cref="sessionInvalidationTracker"/>.</param>
/// <param name="topicEventSender">The value of <see cref="topicEventSender"/>.</param>
/// <param name="claimsPrincipalAccessor">The value of <see cref="claimsPrincipalAccessor"/>.</param>
/// <param name="generalConfigurationOptions">The value of <see cref="generalConfigurationOptions"/>.</param>
/// <param name="securityConfigurationOptions">The value of <see cref="securityConfigurationOptions"/>.</param>
public UserAuthority(
IAuthenticationContext authenticationContext,
IDatabaseContext databaseContext,
ILogger<UserAuthority> logger,
IUsersDataLoader usersDataLoader,
@@ -204,10 +211,10 @@ namespace Tgstation.Server.Host.Authority
ICryptographySuite cryptographySuite,
ISessionInvalidationTracker sessionInvalidationTracker,
ITopicEventSender topicEventSender,
IClaimsPrincipalAccessor claimsPrincipalAccessor,
IOptionsSnapshot<GeneralConfiguration> generalConfigurationOptions,
IOptions<SecurityConfiguration> securityConfigurationOptions)
: base(
authenticationContext,
databaseContext,
logger)
{
@@ -219,6 +226,7 @@ namespace Tgstation.Server.Host.Authority
this.cryptographySuite = cryptographySuite ?? throw new ArgumentNullException(nameof(cryptographySuite));
this.sessionInvalidationTracker = sessionInvalidationTracker ?? throw new ArgumentNullException(nameof(sessionInvalidationTracker));
this.topicEventSender = topicEventSender ?? throw new ArgumentNullException(nameof(topicEventSender));
this.claimsPrincipalAccessor = claimsPrincipalAccessor ?? throw new ArgumentNullException(nameof(claimsPrincipalAccessor));
this.generalConfigurationOptions = generalConfigurationOptions ?? throw new ArgumentNullException(nameof(generalConfigurationOptions));
this.securityConfigurationOptions = securityConfigurationOptions ?? throw new ArgumentNullException(nameof(securityConfigurationOptions));
}
@@ -233,11 +241,11 @@ namespace Tgstation.Server.Host.Authority
static bool BadCreateRequestChecks(
UserCreateRequest createRequest,
bool? needZeroLengthPasswordWithOAuthConnections,
[NotNullWhen(true)] out AuthorityResponse<User>? failResponse)
[NotNullWhen(true)] out AuthorityResponse<UpdatedUser>? failResponse)
{
if (createRequest.OAuthConnections?.Any(x => x == null) == true)
{
failResponse = BadRequest<User>(ErrorCode.ModelValidationFailure);
failResponse = BadRequest<UpdatedUser>(ErrorCode.ModelValidationFailure);
return true;
}
@@ -247,7 +255,7 @@ namespace Tgstation.Server.Host.Authority
if ((hasNonNullPassword && hasNonNullSystemIdentifier)
|| (!hasNonNullPassword && !hasNonNullSystemIdentifier && !hasOAuthConnections))
{
failResponse = BadRequest<User>(ErrorCode.UserMismatchPasswordSid);
failResponse = BadRequest<UpdatedUser>(ErrorCode.UserMismatchPasswordSid);
return true;
}
@@ -261,20 +269,20 @@ namespace Tgstation.Server.Host.Authority
if (createRequest.OAuthConnections.Count == 0)
{
failResponse = BadRequest<User>(ErrorCode.ModelValidationFailure);
failResponse = BadRequest<UpdatedUser>(ErrorCode.ModelValidationFailure);
return true;
}
}
else if (hasZeroLengthPassword)
{
failResponse = BadRequest<User>(ErrorCode.ModelValidationFailure);
failResponse = BadRequest<UpdatedUser>(ErrorCode.ModelValidationFailure);
return true;
}
}
if (createRequest.Group != null && createRequest.PermissionSet != null)
{
failResponse = BadRequest<User>(ErrorCode.UserGroupAndPermissionSet);
failResponse = BadRequest<UpdatedUser>(ErrorCode.UserGroupAndPermissionSet);
return true;
}
@@ -284,7 +292,7 @@ namespace Tgstation.Server.Host.Authority
if (!(createRequest.Name == null ^ createRequest.SystemIdentifier == null))
{
failResponse = BadRequest<User>(ErrorCode.UserMismatchNameSid);
failResponse = BadRequest<UpdatedUser>(ErrorCode.UserMismatchNameSid);
return true;
}
@@ -293,15 +301,350 @@ namespace Tgstation.Server.Host.Authority
}
/// <inheritdoc />
public ValueTask<AuthorityResponse<User>> Read(CancellationToken cancellationToken)
=> ValueTask.FromResult(new AuthorityResponse<User>(AuthenticationContext.User));
public RequirementsGated<AuthorityResponse<User>> Read(CancellationToken cancellationToken)
=> new(
() => Enumerable.Empty<IAuthorizationRequirement>(),
() => GetIdImpl(claimsPrincipalAccessor.User.RequireTgsUserId(), true, false, cancellationToken));
/// <inheritdoc />
public async ValueTask<AuthorityResponse<User>> GetId(long id, bool includeJoins, bool allowSystemUser, CancellationToken cancellationToken)
{
if (id != AuthenticationContext.User.Id && !((AdministrationRights)AuthenticationContext.GetRight(RightsType.Administration)).HasFlag(AdministrationRights.ReadUsers))
return Forbid<User>();
public RequirementsGated<AuthorityResponse<User>> GetId(long id, bool includeJoins, bool allowSystemUser, CancellationToken cancellationToken)
=> new(
() =>
{
if (id != claimsPrincipalAccessor.User.GetTgsUserId())
return Enumerable.Empty<IAuthorizationRequirement>();
return new List<IAuthorizationRequirement>
{
Flag(AdministrationRights.ReadUsers),
};
},
() => GetIdImpl(id, includeJoins, allowSystemUser, cancellationToken));
/// <inheritdoc />
public RequirementsGated<IQueryable<User>> Queryable(bool includeJoins)
=> new(
() => Flag(AdministrationRights.ReadUsers),
() => ValueTask.FromResult(Queryable(includeJoins, false)));
/// <inheritdoc />
public RequirementsGated<AuthorityResponse<GraphQL.Types.OAuth.OAuthConnection[]>> OAuthConnections(long userId, CancellationToken cancellationToken)
=> new(
() => claimsPrincipalAccessor.User.GetTgsUserId() != userId
? Flag(AdministrationRights.ReadUsers)
: null,
async () => new AuthorityResponse<GraphQL.Types.OAuth.OAuthConnection[]>(
await oAuthConnectionsDataLoader.LoadRequiredAsync(userId, cancellationToken)));
/// <inheritdoc />
public RequirementsGated<AuthorityResponse<GraphQL.Types.OAuth.OidcConnection[]>> OidcConnections(long userId, CancellationToken cancellationToken)
=> new(
() => claimsPrincipalAccessor.User.GetTgsUserId() != userId
? Flag(AdministrationRights.ReadUsers)
: null,
async () => new AuthorityResponse<GraphQL.Types.OAuth.OidcConnection[]>(
await oidcConnectionsDataLoader.LoadRequiredAsync(userId, cancellationToken)));
/// <inheritdoc />
#pragma warning disable CA1506 // TODO: Decomplexify
public RequirementsGated<AuthorityResponse<UpdatedUser>> Create(
UserCreateRequest createRequest,
bool? needZeroLengthPasswordWithOAuthConnections,
CancellationToken cancellationToken)
#pragma warning restore CA1506
=> new(
() => Flag(AdministrationRights.WriteUsers),
async authorizationService =>
{
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<UpdatedUser>(ErrorCode.UserLimitReached);
var dbUser = await CreateNewUserFromModel(
createRequest,
cancellationToken);
if (dbUser == null)
return Gone<UpdatedUser>();
if (createRequest.SystemIdentifier != null)
try
{
using var sysIdentity = await systemIdentityFactory.CreateSystemIdentity(dbUser, cancellationToken);
if (sysIdentity == null)
return Gone<UpdatedUser>();
dbUser.Name = sysIdentity.Username;
dbUser.SystemIdentifier = sysIdentity.Uid;
}
catch (NotImplementedException ex)
{
Logger.LogTrace(ex, "System identities not implemented!");
return new AuthorityResponse<UpdatedUser>(
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;
}
}
dbUser.CanonicalName = User.CanonicalizeName(dbUser.Name!);
DatabaseContext.Users.Add(dbUser);
await DatabaseContext.Save(cancellationToken);
Logger.LogInformation("Created new user {name} ({id})", dbUser.Name, dbUser.Id);
var responseTask = UpdatedUserResponse(authorizationService, dbUser, HttpSuccessResponse.Created);
await SendUserUpdatedTopics(dbUser);
return await responseTask;
});
/// <inheritdoc />
#pragma warning disable CA1502
#pragma warning disable CA1506 // TODO: Decomplexify
public RequirementsGated<AuthorityResponse<UpdatedUser>> Update(UserUpdateRequest model, CancellationToken cancellationToken)
#pragma warning restore CA1502
#pragma warning restore CA1506
=> new(
() =>
{
RightsConditional<AdministrationRights>? conditional = 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 =>
{
ArgumentNullException.ThrowIfNull(model);
if (!model.Id.HasValue || model.OAuthConnections?.Any(x => x == null) == true)
return BadRequest<UpdatedUser>(ErrorCode.ModelValidationFailure);
if (model.Group != null && model.PermissionSet != null)
return BadRequest<UpdatedUser>(ErrorCode.UserGroupAndPermissionSet);
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);
var originalUser = await userQuery;
if (originalUser == default)
return NotFound<UpdatedUser>();
if (originalUser.CanonicalName == User.CanonicalizeName(User.TgsSystemUserName))
return Forbid<UpdatedUser>();
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<UpdatedUser>(ErrorCode.UserSidChange);
if (model.Password != null)
{
if (originalUserHasSid)
return BadRequest<UpdatedUser>(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<UpdatedUser>(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<UpdatedUser>(ErrorCode.BadUserEditDueToOidcStrictMode);
if (originalUser.CanonicalName == User.CanonicalizeName(DefaultCredentials.AdminUserName))
return BadRequest<UpdatedUser>(ErrorCode.AdminUserCannotHaveServiceConnection);
if (model.OAuthConnections.Count == 0 && originalUser.PasswordHash == null && originalUser.SystemIdentifier == null)
return BadRequest<UpdatedUser>(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<UpdatedUser>(ErrorCode.BadUserEditDueToOidcStrictMode);
if (originalUser.CanonicalName == User.CanonicalizeName(DefaultCredentials.AdminUserName))
return BadRequest<UpdatedUser>(ErrorCode.AdminUserCannotHaveServiceConnection);
if (model.OidcConnections.Count == 0 && originalUser.PasswordHash == null && originalUser.SystemIdentifier == null)
return BadRequest<UpdatedUser>(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<UpdatedUser>(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<UpdatedUser>();
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<UpdatedUser>(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<UpdatedUser>(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);
var responseTask = UpdatedUserResponse(authorizationService, originalUser, HttpSuccessResponse.Ok);
ValueTask sessionInvalidationTask;
if (invalidateSessions)
sessionInvalidationTask = permissionsUpdateNotifyee.UserDisabled(originalUser, cancellationToken);
else
sessionInvalidationTask = ValueTask.CompletedTask;
await ValueTaskExtensions.WhenAll(SendUserUpdatedTopics(originalUser), sessionInvalidationTask);
return await responseTask;
});
/// <summary>
/// Implementation of retrieving a <see cref="User"/> by ID.
/// </summary>
/// <param name="id">The <see cref="EntityId.Id"/> of the user to retrieve.</param>
/// <param name="includeJoins">If related entities should be loaded.</param>
/// <param name="allowSystemUser">If the <see cref="User.TgsSystemUserName"/> may be returned.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="User"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
async ValueTask<AuthorityResponse<User>> GetIdImpl(long id, bool includeJoins, bool allowSystemUser, CancellationToken cancellationToken)
{
User? user;
if (includeJoins)
{
@@ -323,286 +666,29 @@ namespace Tgstation.Server.Host.Authority
return new AuthorityResponse<User>(user);
}
/// <inheritdoc />
public IQueryable<User> Queryable(bool includeJoins)
=> Queryable(includeJoins, false);
/// <inheritdoc />
public async ValueTask<AuthorityResponse<GraphQL.Types.OAuth.OAuthConnection[]>> OAuthConnections(long userId, CancellationToken cancellationToken)
=> new AuthorityResponse<GraphQL.Types.OAuth.OAuthConnection[]>(
await oAuthConnectionsDataLoader.LoadRequiredAsync(userId, cancellationToken));
/// <inheritdoc />
public async ValueTask<AuthorityResponse<GraphQL.Types.OAuth.OidcConnection[]>> OidcConnections(long userId, CancellationToken cancellationToken)
=> new AuthorityResponse<GraphQL.Types.OAuth.OidcConnection[]>(
await oidcConnectionsDataLoader.LoadRequiredAsync(userId, cancellationToken));
/// <inheritdoc />
public async ValueTask<AuthorityResponse<User>> Create(
UserCreateRequest createRequest,
bool? needZeroLengthPasswordWithOAuthConnections,
CancellationToken cancellationToken)
/// <summary>
/// Create the <see cref="AuthorityResponse{TResult}"/> for an <see cref="UpdatedUser"/>.
/// </summary>
/// <param name="authorizationService">The authorization service to use.</param>
/// <param name="user">The <see cref="User"/> for the result.</param>
/// <param name="successResponse">The <see cref="HttpSuccessResponse"/> to use.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="UpdatedUser"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
async ValueTask<AuthorityResponse<UpdatedUser>> UpdatedUserResponse(
Security.IAuthorizationService authorizationService,
User user,
HttpSuccessResponse successResponse)
{
ArgumentNullException.ThrowIfNull(createRequest);
if (BadCreateRequestChecks(createRequest, needZeroLengthPasswordWithOAuthConnections, out var failResponse))
return failResponse;
var totalUsers = await DatabaseContext
.Users
.AsQueryable()
.CountAsync(cancellationToken);
if (totalUsers >= generalConfigurationOptions.Value.UserLimit)
return Conflict<User>(ErrorCode.UserLimitReached);
var dbUser = await CreateNewUserFromModel(createRequest, cancellationToken);
if (dbUser == null)
return Gone<User>();
if (createRequest.SystemIdentifier != null)
try
{
using var sysIdentity = await systemIdentityFactory.CreateSystemIdentity(dbUser, cancellationToken);
if (sysIdentity == null)
return Gone<User>();
dbUser.Name = sysIdentity.Username;
dbUser.SystemIdentifier = sysIdentity.Uid;
}
catch (NotImplementedException ex)
{
Logger.LogTrace(ex, "System identities not implemented!");
return new AuthorityResponse<User>(
new ErrorMessageResponse(ErrorCode.RequiresPosixSystemIdentity),
HttpFailureResponse.NotImplemented);
}
else
{
var hasZeroLengthPassword = createRequest.Password?.Length == 0;
var hasOAuthConnections = (createRequest.OAuthConnections?.Count > 0) == true;
// 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;
}
}
dbUser.CanonicalName = User.CanonicalizeName(dbUser.Name!);
DatabaseContext.Users.Add(dbUser);
await DatabaseContext.Save(cancellationToken);
Logger.LogInformation("Created new user {name} ({id})", dbUser.Name, dbUser.Id);
await SendUserUpdatedTopics(dbUser);
return new AuthorityResponse<User>(dbUser, HttpSuccessResponse.Created);
}
/// <inheritdoc />
#pragma warning disable CA1502
#pragma warning disable CA1506 // TODO: Decomplexify
public async ValueTask<AuthorityResponse<User>> Update(UserUpdateRequest model, CancellationToken cancellationToken)
#pragma warning restore CA1502
#pragma warning restore CA1506
{
ArgumentNullException.ThrowIfNull(model);
if (!model.Id.HasValue || model.OAuthConnections?.Any(x => x == null) == true)
return BadRequest<User>(ErrorCode.ModelValidationFailure);
if (model.Group != null && model.PermissionSet != null)
return BadRequest<User>(ErrorCode.UserGroupAndPermissionSet);
var callerAdministrationRights = (AdministrationRights)AuthenticationContext.GetRight(RightsType.Administration);
var canEditAllUsers = callerAdministrationRights.HasFlag(AdministrationRights.WriteUsers);
var passwordEdit = canEditAllUsers || callerAdministrationRights.HasFlag(AdministrationRights.EditOwnPassword);
var oAuthEdit = canEditAllUsers || callerAdministrationRights.HasFlag(AdministrationRights.EditOwnServiceConnections);
var originalUser = !canEditAllUsers
? AuthenticationContext.User
: await DatabaseContext
.Users
.AsQueryable()
.Where(x => x.Id == model.Id)
.Include(x => x.CreatedBy)
.Include(x => x.OAuthConnections)
.Include(x => x.OidcConnections)
.Include(x => x.Group!)
.ThenInclude(x => x.PermissionSet)
.Include(x => x.PermissionSet)
.FirstOrDefaultAsync(cancellationToken);
if (originalUser == default)
return NotFound<User>();
if (originalUser.CanonicalName == User.CanonicalizeName(User.TgsSystemUserName))
return Forbid<User>();
// Ensure they are only trying to edit things they have perms for (system identity change will trigger a bad request)
if ((!canEditAllUsers
&& (model.Id != originalUser.Id
|| model.Enabled.HasValue
|| model.Group != null
|| model.PermissionSet != null
|| model.Name != null))
|| (!passwordEdit && model.Password != null)
|| (!oAuthEdit && model.OAuthConnections != null))
return Forbid<User>();
var originalUserHasSid = originalUser.SystemIdentifier != null;
var invalidateSessions = false;
if (originalUserHasSid && originalUser.PasswordHash != null)
{
// cleanup from https://github.com/tgstation/tgstation-server/issues/1528
Logger.LogDebug("System user ID {userId}'s PasswordHash is polluted, updating database.", originalUser.Id);
originalUser.PasswordHash = null;
invalidateSessions = true;
}
if (model.SystemIdentifier != null && model.SystemIdentifier != originalUser.SystemIdentifier)
return BadRequest<User>(ErrorCode.UserSidChange);
if (model.Password != null)
{
if (originalUserHasSid)
return BadRequest<User>(ErrorCode.UserMismatchPasswordSid);
var result = TrySetPassword(originalUser, model.Password, false);
if (result != null)
return result;
invalidateSessions = true;
}
if (model.Name != null && User.CanonicalizeName(model.Name) != originalUser.CanonicalName)
return BadRequest<User>(ErrorCode.UserNameChange);
if (model.OAuthConnections != null
&& (model.OAuthConnections.Count != originalUser.OAuthConnections!.Count
|| !model.OAuthConnections.All(x => originalUser.OAuthConnections.Any(y => y.Provider == x.Provider && y.ExternalUserId == x.ExternalUserId))))
{
if (securityConfigurationOptions.Value.OidcStrictMode)
return BadRequest<User>(ErrorCode.BadUserEditDueToOidcStrictMode);
if (originalUser.CanonicalName == User.CanonicalizeName(DefaultCredentials.AdminUserName))
return BadRequest<User>(ErrorCode.AdminUserCannotHaveServiceConnection);
if (model.OAuthConnections.Count == 0 && originalUser.PasswordHash == null && originalUser.SystemIdentifier == null)
return BadRequest<User>(ErrorCode.CannotRemoveLastAuthenticationOption);
DatabaseContext.OAuthConnections.RemoveRange(originalUser.OAuthConnections);
originalUser.OAuthConnections.Clear();
foreach (var updatedConnection in model.OAuthConnections)
originalUser.OAuthConnections.Add(new Models.OAuthConnection
{
Provider = updatedConnection.Provider,
ExternalUserId = updatedConnection.ExternalUserId,
});
}
if (model.OidcConnections != null
&& (model.OidcConnections.Count != originalUser.OidcConnections!.Count
|| !model.OidcConnections.All(x => originalUser.OidcConnections.Any(y => y.SchemeKey == x.SchemeKey && y.ExternalUserId == x.ExternalUserId))))
{
if (securityConfigurationOptions.Value.OidcStrictMode)
return BadRequest<User>(ErrorCode.BadUserEditDueToOidcStrictMode);
if (originalUser.CanonicalName == User.CanonicalizeName(DefaultCredentials.AdminUserName))
return BadRequest<User>(ErrorCode.AdminUserCannotHaveServiceConnection);
if (model.OidcConnections.Count == 0 && originalUser.PasswordHash == null && originalUser.SystemIdentifier == null)
return BadRequest<User>(ErrorCode.CannotRemoveLastAuthenticationOption);
DatabaseContext.OidcConnections.RemoveRange(originalUser.OidcConnections);
originalUser.OidcConnections.Clear();
foreach (var updatedConnection in model.OidcConnections)
originalUser.OidcConnections.Add(new Models.OidcConnection
{
SchemeKey = updatedConnection.SchemeKey,
ExternalUserId = updatedConnection.ExternalUserId,
});
}
if (model.Group != null)
{
if (securityConfigurationOptions.Value.OidcStrictMode)
return BadRequest<User>(ErrorCode.BadUserEditDueToOidcStrictMode);
originalUser.Group = await DatabaseContext
.Groups
.AsQueryable()
.Where(x => x.Id == model.Group.Id)
.Include(x => x.PermissionSet)
.FirstOrDefaultAsync(cancellationToken);
if (originalUser.Group == default)
return Gone<User>();
DatabaseContext.Groups.Attach(originalUser.Group);
if (originalUser.PermissionSet != null)
{
Logger.LogInformation("Deleting permission set {permissionSetId}...", originalUser.PermissionSet.Id);
DatabaseContext.PermissionSets.Remove(originalUser.PermissionSet);
originalUser.PermissionSet = null;
}
}
else if (model.PermissionSet != null)
{
if (securityConfigurationOptions.Value.OidcStrictMode)
return BadRequest<User>(ErrorCode.BadUserEditDueToOidcStrictMode);
if (originalUser.PermissionSet == null)
{
Logger.LogTrace("Creating new permission set...");
originalUser.PermissionSet = new Models.PermissionSet();
}
originalUser.PermissionSet.AdministrationRights = model.PermissionSet.AdministrationRights ?? AdministrationRights.None;
originalUser.PermissionSet.InstanceManagerRights = model.PermissionSet.InstanceManagerRights ?? InstanceManagerRights.None;
originalUser.Group = null;
originalUser.GroupId = null;
}
var fail = CheckValidName(model, false);
if (fail != null)
return fail;
originalUser.Name = model.Name ?? originalUser.Name;
if (model.Enabled.HasValue)
{
if (securityConfigurationOptions.Value.OidcStrictMode)
return BadRequest<User>(ErrorCode.BadUserEditDueToOidcStrictMode);
invalidateSessions = originalUser.Require(x => x.Enabled) && !model.Enabled.Value;
originalUser.Enabled = model.Enabled.Value;
}
if (invalidateSessions)
sessionInvalidationTracker.UserModifiedInvalidateSessions(originalUser);
await DatabaseContext.Save(cancellationToken);
Logger.LogInformation("Updated user {userName} ({userId})", originalUser.Name, originalUser.Id);
if (invalidateSessions)
await permissionsUpdateNotifyee.UserDisabled(originalUser, cancellationToken);
await SendUserUpdatedTopics(originalUser);
// return id only if not a self update and cannot read users
var canReadBack = AuthenticationContext.User.Id == originalUser.Id
|| callerAdministrationRights.HasFlag(AdministrationRights.ReadUsers);
return canReadBack
? new AuthorityResponse<User>(originalUser)
: new AuthorityResponse<User>();
var userId = user.Require(u => u.Id);
var canReadBack = claimsPrincipalAccessor.User.GetTgsUserId() == userId
|| (await authorizationService.AuthorizeAsync(
[Flag(AdministrationRights.ReadUsers)])).Succeeded;
return new AuthorityResponse<UpdatedUser>(
canReadBack
? new UpdatedUser(user)
: new UpdatedUser(userId),
successResponse);
}
/// <summary>
@@ -672,10 +758,23 @@ namespace Tgstation.Server.Host.Authority
InstanceManagerRights = model.PermissionSet?.InstanceManagerRights ?? InstanceManagerRights.None,
};
/*
var currentUser = new User
{
Id = claimsPrincipalAccessor.User.GetTgsUserId(),
};
*/
// Temporary workaround while we work to remove authentication context
var currentUser = DatabaseContext.Users.Local.First(
user => 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,
@@ -709,11 +808,11 @@ namespace Tgstation.Server.Host.Authority
/// <param name="newPassword">The new password.</param>
/// <param name="newUser">If this is for a new <see cref="UserResponse"/>.</param>
/// <returns><see langword="null"/> on success, an errored <see cref="AuthorityResponse{TResult}"/> if <paramref name="newPassword"/> is too short.</returns>
AuthorityResponse<User>? TrySetPassword(User dbUser, string newPassword, bool newUser)
AuthorityResponse<UpdatedUser>? TrySetPassword(User dbUser, string newPassword, bool newUser)
{
newPassword ??= String.Empty;
if (newPassword.Length < generalConfigurationOptions.Value.MinimumPasswordLength)
return new AuthorityResponse<User>(
return new AuthorityResponse<UpdatedUser>(
new ErrorMessageResponse(ErrorCode.UserPasswordLength)
{
AdditionalData = $"Required password length: {generalConfigurationOptions.Value.MinimumPasswordLength}",
@@ -6,6 +6,7 @@ using System.Threading.Tasks;
using GreenDonut;
using Microsoft.AspNetCore.Authorization;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
@@ -16,6 +17,7 @@ using Tgstation.Server.Api.Rights;
using Tgstation.Server.Host.Authority.Core;
using Tgstation.Server.Host.Configuration;
using Tgstation.Server.Host.Database;
using Tgstation.Server.Host.Extensions;
using Tgstation.Server.Host.Models;
using Tgstation.Server.Host.Security;
@@ -29,6 +31,11 @@ namespace Tgstation.Server.Host.Authority
/// </summary>
readonly IUserGroupsDataLoader userGroupsDataLoader;
/// <summary>
/// The <see cref="IClaimsPrincipalAccessor"/> for the <see cref="UserGroupAuthority"/>.
/// </summary>
readonly IClaimsPrincipalAccessor claimsPrincipalAccessor;
/// <summary>
/// The <see cref="IOptionsSnapshot{TOptions}"/> of the <see cref="GeneralConfiguration"/>.
/// </summary>
@@ -60,58 +67,181 @@ namespace Tgstation.Server.Host.Authority
/// <summary>
/// Initializes a new instance of the <see cref="UserGroupAuthority"/> class.
/// </summary>
/// <param name="authenticationContext">The <see cref="IAuthenticationContext"/> to use.</param>
/// <param name="databaseContext">The <see cref="IDatabaseContext"/> to use.</param>
/// <param name="logger">The <see cref="ILogger"/> to use.</param>
/// <param name="claimsPrincipalAccessor">The value of <see cref="claimsPrincipalAccessor"/>.</param>
/// <param name="userGroupsDataLoader">The value of <see cref="userGroupsDataLoader"/>.</param>
/// <param name="generalConfigurationOptions">The value of <see cref="generalConfigurationOptions"/>.</param>
public UserGroupAuthority(
IAuthenticationContext authenticationContext,
IDatabaseContext databaseContext,
ILogger<UserGroupAuthority> logger,
IUserGroupsDataLoader userGroupsDataLoader,
IClaimsPrincipalAccessor claimsPrincipalAccessor,
IOptionsSnapshot<GeneralConfiguration> generalConfigurationOptions)
: base(
authenticationContext,
databaseContext,
logger)
{
this.userGroupsDataLoader = userGroupsDataLoader ?? throw new ArgumentNullException(nameof(userGroupsDataLoader));
this.claimsPrincipalAccessor = claimsPrincipalAccessor ?? throw new ArgumentNullException(nameof(claimsPrincipalAccessor));
this.generalConfigurationOptions = generalConfigurationOptions ?? throw new ArgumentNullException(nameof(generalConfigurationOptions));
}
/// <inheritdoc />
public async ValueTask<AuthorityResponse<UserGroup>> GetId(long id, bool includeJoins, CancellationToken cancellationToken)
public RequirementsGated<AuthorityResponse<UserGroup>> GetId(long id, bool includeJoins, CancellationToken cancellationToken)
=> new(
() =>
{
if (id != claimsPrincipalAccessor.User.GetTgsUserId())
return Flag(AdministrationRights.ReadUsers);
return null;
},
async () =>
{
UserGroup? userGroup;
if (includeJoins)
userGroup = await QueryableImpl(true)
.Where(x => x.Id == id)
.FirstOrDefaultAsync(cancellationToken);
else
userGroup = await userGroupsDataLoader.LoadAsync(id, cancellationToken);
if (userGroup == null)
return Gone<UserGroup>();
return new AuthorityResponse<UserGroup>(userGroup);
});
/// <inheritdoc />
public RequirementsGated<AuthorityResponse<UserGroup>> Read(CancellationToken cancellationToken)
=> new(
() => (IAuthorizationRequirement?)null,
async () =>
{
var userId = claimsPrincipalAccessor.User.GetTgsUserId();
var group = await DatabaseContext
.Users
.AsQueryable()
.Where(user => user.Id == userId)
.Select(user => user.Group)
.FirstOrDefaultAsync(cancellationToken);
if (group == null)
return Gone<UserGroup>();
return new AuthorityResponse<UserGroup>(group);
});
/// <inheritdoc />
public RequirementsGated<IQueryable<UserGroup>> Queryable(bool includeJoins)
=> new(
() => Flag(AdministrationRights.ReadUsers),
() => ValueTask.FromResult(QueryableImpl(includeJoins)));
/// <inheritdoc />
public RequirementsGated<AuthorityResponse<UserGroup>> Create(string name, Models.PermissionSet? permissionSet, CancellationToken cancellationToken)
{
if (id != AuthenticationContext.User.GroupId && !((AdministrationRights)AuthenticationContext.GetRight(RightsType.Administration)).HasFlag(AdministrationRights.ReadUsers))
return Forbid<UserGroup>();
ArgumentNullException.ThrowIfNull(name);
return new(
() => Flag(AdministrationRights.WriteUsers),
async () =>
{
var totalGroups = await DatabaseContext
.Groups
.AsQueryable()
.CountAsync(cancellationToken);
if (totalGroups >= generalConfigurationOptions.Value.UserGroupLimit)
return Conflict<UserGroup>(ErrorCode.UserGroupLimitReached);
UserGroup? userGroup;
if (includeJoins)
userGroup = await Queryable(true)
.Where(x => x.Id == id)
.FirstOrDefaultAsync(cancellationToken);
else
userGroup = await userGroupsDataLoader.LoadAsync(id, cancellationToken);
var modelPermissionSet = new Models.PermissionSet
{
AdministrationRights = permissionSet?.AdministrationRights ?? AdministrationRights.None,
InstanceManagerRights = permissionSet?.InstanceManagerRights ?? InstanceManagerRights.None,
};
if (userGroup == null)
return Gone<UserGroup>();
var dbGroup = new UserGroup
{
Name = name,
PermissionSet = modelPermissionSet,
};
return new AuthorityResponse<UserGroup>(userGroup);
DatabaseContext.Groups.Add(dbGroup);
await DatabaseContext.Save(cancellationToken);
Logger.LogInformation("Created new user group {groupName} ({groupId})", dbGroup.Name, dbGroup.Id);
return new AuthorityResponse<UserGroup>(
dbGroup,
HttpSuccessResponse.Created);
});
}
/// <inheritdoc />
public ValueTask<AuthorityResponse<UserGroup>> Read()
{
var group = AuthenticationContext.User!.Group;
if (group == null)
return ValueTask.FromResult(Gone<UserGroup>());
public RequirementsGated<AuthorityResponse<UserGroup>> Update(long id, string? newName, Models.PermissionSet? newPermissionSet, CancellationToken cancellationToken)
=> new(
() => Flag(AdministrationRights.WriteUsers),
async () =>
{
var currentGroup = await DatabaseContext
.Groups
.AsQueryable()
.Where(x => x.Id == id)
.Include(x => x.PermissionSet)
.FirstOrDefaultAsync(cancellationToken);
return ValueTask.FromResult(new AuthorityResponse<UserGroup>(group));
}
if (currentGroup == default)
return Gone<UserGroup>();
if (newPermissionSet != null)
{
currentGroup.PermissionSet!.AdministrationRights = newPermissionSet.AdministrationRights ?? currentGroup.PermissionSet.AdministrationRights;
currentGroup.PermissionSet.InstanceManagerRights = newPermissionSet.InstanceManagerRights ?? currentGroup.PermissionSet.InstanceManagerRights;
}
currentGroup.Name = newName ?? currentGroup.Name;
await DatabaseContext.Save(cancellationToken);
return new AuthorityResponse<UserGroup>(currentGroup);
});
/// <inheritdoc />
public IQueryable<UserGroup> Queryable(bool includeJoins)
public RequirementsGated<AuthorityResponse> DeleteEmpty(long id, CancellationToken cancellationToken)
=> new(
() => Flag(AdministrationRights.WriteUsers),
async () =>
{
var numDeleted = await DatabaseContext
.Groups
.AsQueryable()
.Where(x => x.Id == id && x.Users!.Count == 0)
.ExecuteDeleteAsync(cancellationToken);
if (numDeleted > 0)
return new();
// find out how we failed
var groupExists = await DatabaseContext
.Groups
.AsQueryable()
.Where(x => x.Id == id)
.AnyAsync(cancellationToken);
return new(
groupExists
? new ErrorMessageResponse(ErrorCode.UserGroupNotEmpty)
: new ErrorMessageResponse(),
groupExists
? HttpFailureResponse.Conflict
: HttpFailureResponse.Gone);
});
/// <summary>
/// Get the <see cref="IQueryable{T}"/> <see cref="UserGroup"/>s.
/// </summary>
/// <param name="includeJoins">If <see cref="UserGroup.Users"/> and <see cref="UserGroup.PermissionSet"/> should be included.</param>
/// <returns>An <see cref="IQueryable{T}"/> of <see cref="UserGroup"/>s.</returns>
IQueryable<UserGroup> QueryableImpl(bool includeJoins)
{
var queryable = DatabaseContext
.Groups
@@ -124,92 +254,5 @@ namespace Tgstation.Server.Host.Authority
return queryable;
}
/// <inheritdoc />
public async ValueTask<AuthorityResponse<UserGroup>> Create(string name, Models.PermissionSet? permissionSet, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(name);
var totalGroups = await DatabaseContext
.Groups
.AsQueryable()
.CountAsync(cancellationToken);
if (totalGroups >= generalConfigurationOptions.Value.UserGroupLimit)
return Conflict<UserGroup>(ErrorCode.UserGroupLimitReached);
var modelPermissionSet = new Models.PermissionSet
{
AdministrationRights = permissionSet?.AdministrationRights ?? AdministrationRights.None,
InstanceManagerRights = permissionSet?.InstanceManagerRights ?? InstanceManagerRights.None,
};
var dbGroup = new UserGroup
{
Name = name,
PermissionSet = modelPermissionSet,
};
DatabaseContext.Groups.Add(dbGroup);
await DatabaseContext.Save(cancellationToken);
Logger.LogInformation("Created new user group {groupName} ({groupId})", dbGroup.Name, dbGroup.Id);
return new AuthorityResponse<UserGroup>(
dbGroup,
HttpSuccessResponse.Created);
}
/// <inheritdoc />
public async ValueTask<AuthorityResponse<UserGroup>> Update(long id, string? newName, Models.PermissionSet? newPermissionSet, CancellationToken cancellationToken)
{
var currentGroup = await DatabaseContext
.Groups
.AsQueryable()
.Where(x => x.Id == id)
.Include(x => x.PermissionSet)
.FirstOrDefaultAsync(cancellationToken);
if (currentGroup == default)
return Gone<UserGroup>();
if (newPermissionSet != null)
{
currentGroup.PermissionSet!.AdministrationRights = newPermissionSet.AdministrationRights ?? currentGroup.PermissionSet.AdministrationRights;
currentGroup.PermissionSet.InstanceManagerRights = newPermissionSet.InstanceManagerRights ?? currentGroup.PermissionSet.InstanceManagerRights;
}
currentGroup.Name = newName ?? currentGroup.Name;
await DatabaseContext.Save(cancellationToken);
return new AuthorityResponse<UserGroup>(currentGroup);
}
/// <inheritdoc />
public async ValueTask<AuthorityResponse> DeleteEmpty(long id, CancellationToken cancellationToken)
{
var numDeleted = await DatabaseContext
.Groups
.AsQueryable()
.Where(x => x.Id == id && x.Users!.Count == 0)
.ExecuteDeleteAsync(cancellationToken);
if (numDeleted > 0)
return new();
// find out how we failed
var groupExists = await DatabaseContext
.Groups
.AsQueryable()
.Where(x => x.Id == id)
.AnyAsync(cancellationToken);
return new(
groupExists
? new ErrorMessageResponse(ErrorCode.UserGroupNotEmpty)
: new ErrorMessageResponse(),
groupExists
? HttpFailureResponse.Conflict
: HttpFailureResponse.Gone);
}
}
}
@@ -63,7 +63,7 @@ namespace Tgstation.Server.Host.Components.Deployment
/// </summary>
/// <param name="dmbLock">The <see cref="DmbLock"/> to get a description of.</param>
/// <returns>A verbose description of <paramref name="dmbLock"/>.</returns>
static string GetFullLockDescriptor(DmbLock dmbLock) => $"{dmbLock.LockID} {dmbLock.Descriptor} (Created at {dmbLock.LockTime}){(dmbLock.KeptAlive ? " (RELEASED)" : String.Empty)}";
static string GetFullLockDescriptor(DmbLock dmbLock) => $"{dmbLock.LockID} {dmbLock.EngineVersion} {dmbLock.Descriptor} (Created at {dmbLock.LockTime}){(dmbLock.KeptAlive ? " (RELEASED)" : String.Empty)}";
/// <summary>
/// Initializes a new instance of the <see cref="DeploymentLockManager"/> class.
@@ -81,7 +81,7 @@ namespace Tgstation.Server.Host.Components.Deployment
readonly CancellationTokenSource cleanupCts;
/// <summary>
/// The <see cref="CancellationTokenSource"/> for <see cref="LogLockStates"/>.
/// The <see cref="CancellationTokenSource"/> for <see cref="LogLockStatesLoop"/>.
/// </summary>
readonly CancellationTokenSource lockLogCts;
@@ -224,7 +224,7 @@ namespace Tgstation.Server.Host.Components.Deployment
}
// we dont do CleanUnusedCompileJobs here because the watchdog may have plans for them yet
cleanupTask = Task.WhenAll(cleanupTask, LogLockStates());
cleanupTask = Task.WhenAll(cleanupTask, LogLockStatesLoop());
}
/// <inheritdoc />
@@ -331,6 +331,18 @@ namespace Tgstation.Server.Host.Components.Deployment
return provider.CompileJob;
}
/// <inheritdoc />
public void LogLockStates()
{
var builder = new StringBuilder();
lock (jobLockManagers)
foreach (var lockManager in jobLockManagers.Values)
lockManager.LogLockStats(builder);
logger.LogTrace("Periodic deployment log states report:{newLine}{report}", Environment.NewLine, builder);
}
/// <summary>
/// Gets a <see cref="IDmbProvider"/> and potentially the <see cref="DeploymentLockManager"/> for a given <see cref="CompileJob"/>.
/// </summary>
@@ -517,7 +529,7 @@ namespace Tgstation.Server.Host.Components.Deployment
/// Lock all <see cref="DeploymentLockManager"/>s states.
/// </summary>
/// <returns>A <see cref="Task"/> representing the running operation.</returns>
async Task LogLockStates()
async Task LogLockStatesLoop()
{
logger.LogTrace("Entering lock logging loop");
CancellationToken cancellationToken = lockLogCts.Token;
@@ -525,14 +537,7 @@ namespace Tgstation.Server.Host.Components.Deployment
while (!cancellationToken.IsCancellationRequested)
try
{
var builder = new StringBuilder();
lock (jobLockManagers)
foreach (var lockManager in jobLockManagers.Values)
lockManager.LogLockStats(builder);
logger.LogTrace("Periodic deployment log states report:{newLine}{report}", Environment.NewLine, builder);
LogLockStates();
await asyncDelayer.Delay(TimeSpan.FromMinutes(10), cancellationToken);
}
catch (OperationCanceledException ex)
@@ -49,5 +49,10 @@ namespace Tgstation.Server.Host.Components.Deployment
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask"/> representing the running operation.</returns>
ValueTask CleanUnusedCompileJobs(CancellationToken cancellationToken);
/// <summary>
/// Log the states of all active <see cref="IDmbProvider"/>s.
/// </summary>
void LogLockStates();
}
}
@@ -113,19 +113,24 @@ namespace Tgstation.Server.Host.Components.Engine
/// <inheritdoc />
public override string FormatServerArguments(
IDmbProvider dmbProvider,
IReadOnlyDictionary<string, string> parameters,
IReadOnlyDictionary<string, string>? parameters,
DreamDaemonLaunchParameters launchParameters,
string accessIdentifier,
string? logFilePath)
{
ArgumentNullException.ThrowIfNull(dmbProvider);
ArgumentNullException.ThrowIfNull(parameters);
ArgumentNullException.ThrowIfNull(launchParameters);
ArgumentNullException.ThrowIfNull(accessIdentifier);
var parametersString = EncodeParameters(parameters, launchParameters);
var encodedParameters = EncodeParameters(parameters, launchParameters);
var parametersString = !String.IsNullOrEmpty(encodedParameters)
? $" -params \"{encodedParameters}\""
: String.Empty;
// important to run on all ports to allow port changing
var arguments = String.Format(
CultureInfo.InvariantCulture,
"\"{0}\" -port {1} -ports 1-65535 {2}-close -verbose -{3} -{4}{5}{6}{7} -params \"{8}\"",
"\"{0}\" -port {1} -ports 1-65535 {2}-close -verbose -{3} -{4}{5}{6}{7}{8}",
dmbProvider.DmbName,
launchParameters.Port!.Value,
launchParameters.AllowWebClient!.Value
@@ -141,34 +141,38 @@ namespace Tgstation.Server.Host.Components.Engine
}
/// <inheritdoc />
public override ValueTask<IEngineInstallation> CreateInstallation(EngineVersion version, string path, Task installationTask, CancellationToken cancellationToken)
public sealed override ValueTask<IEngineInstallation> GetInstallation(EngineVersion version, string path, Task installationTask, CancellationToken cancellationToken)
{
CheckVersionValidity(version);
var installationIOManager = IOManager.CreateResolverForSubdirectory(path);
var supportsMapThreads = version.Version >= MapThreadsVersion;
var dreamDaemonName = GetDreamDaemonName(
version.Version!,
out var supportsCli);
var dreamDaemonPath = installationIOManager.ResolvePath(
installationIOManager.ConcatPath(
ByondBinPath,
dreamDaemonName));
var dreamMakerPath = installationIOManager.ResolvePath(
installationIOManager.ConcatPath(
ByondBinPath,
DreamMakerName));
return ValueTask.FromResult<IEngineInstallation>(
new ByondInstallation(
installationIOManager,
installationTask,
version,
installationIOManager.ResolvePath(
installationIOManager.ConcatPath(
ByondBinPath,
GetDreamDaemonName(
version.Version!,
out var supportsCli))),
installationIOManager.ResolvePath(
installationIOManager.ConcatPath(
ByondBinPath,
DreamMakerName)),
dreamDaemonPath,
dreamMakerPath,
supportsCli,
supportsMapThreads));
}
/// <inheritdoc />
public override async Task CleanCache(CancellationToken cancellationToken)
public sealed override async Task CleanCache(CancellationToken cancellationToken)
{
try
{
@@ -214,7 +218,7 @@ namespace Tgstation.Server.Host.Components.Engine
}
/// <inheritdoc />
public override async ValueTask<IEngineInstallationData> DownloadVersion(EngineVersion version, JobProgressReporter progressReporter, CancellationToken cancellationToken)
public sealed override async ValueTask<IEngineInstallationData> DownloadVersion(EngineVersion version, JobProgressReporter progressReporter, CancellationToken cancellationToken)
{
CheckVersionValidity(version);
@@ -33,15 +33,15 @@ namespace Tgstation.Server.Host.Components.Engine
=> Task.WhenAll(delegatedInstallers.Values.Select(installer => installer.CleanCache(cancellationToken)));
/// <inheritdoc />
public ValueTask<IEngineInstallation> CreateInstallation(EngineVersion version, string path, Task installationTask, CancellationToken cancellationToken)
=> DelegateCall(version, installer => installer.CreateInstallation(version, path, installationTask, cancellationToken));
public ValueTask<IEngineInstallation> GetInstallation(EngineVersion version, string path, Task installationTask, CancellationToken cancellationToken)
=> DelegateCall(version, installer => installer.GetInstallation(version, path, installationTask, cancellationToken));
/// <inheritdoc />
public ValueTask<IEngineInstallationData> DownloadVersion(EngineVersion version, JobProgressReporter jobProgressReporter, CancellationToken cancellationToken)
=> DelegateCall(version, installer => installer.DownloadVersion(version, jobProgressReporter, cancellationToken));
/// <inheritdoc />
public ValueTask Install(EngineVersion version, string path, bool deploymentPipelineProcesses, CancellationToken cancellationToken)
public ValueTask<IEngineInstallation> Install(EngineVersion version, string path, bool deploymentPipelineProcesses, CancellationToken cancellationToken)
=> DelegateCall(version, installer => installer.Install(version, path, deploymentPipelineProcesses, cancellationToken));
/// <inheritdoc />
@@ -45,13 +45,15 @@ namespace Tgstation.Server.Host.Components.Engine
/// <inheritdoc />
public string FormatServerArguments(
IDmbProvider dmbProvider,
IReadOnlyDictionary<string, string> parameters,
IReadOnlyDictionary<string, string>? parameters,
DreamDaemonLaunchParameters launchParameters,
string accessIdentifier,
string? logFilePath)
=> Instance.FormatServerArguments(
dmbProvider,
parameters,
launchParameters,
accessIdentifier,
logFilePath);
/// <inheritdoc />
@@ -57,13 +57,15 @@ namespace Tgstation.Server.Host.Components.Engine
/// <param name="launchParameters">The active <see cref="DreamDaemonLaunchParameters"/>.</param>
/// <returns>The formatted parameters <see cref="string"/>.</returns>
protected static string EncodeParameters(
IReadOnlyDictionary<string, string> parameters,
IReadOnlyDictionary<string, string>? parameters,
DreamDaemonLaunchParameters launchParameters)
{
var parametersString = String.Join('&', parameters.Select(kvp => $"{HttpUtility.UrlEncode(kvp.Key)}={HttpUtility.UrlEncode(kvp.Value)}"));
var parametersString = parameters != null
? $"{String.Join('&', parameters.Select(kvp => $"{HttpUtility.UrlEncode(kvp.Key)}={HttpUtility.UrlEncode(kvp.Value)}"))}&"
: String.Empty;
if (!String.IsNullOrEmpty(launchParameters.AdditionalParameters))
parametersString = $"{parametersString}&{launchParameters.AdditionalParameters}";
parametersString += launchParameters.AdditionalParameters;
return parametersString;
}
@@ -83,8 +85,9 @@ namespace Tgstation.Server.Host.Components.Engine
/// <inheritdoc />
public abstract string FormatServerArguments(
IDmbProvider dmbProvider,
IReadOnlyDictionary<string, string> parameters,
IReadOnlyDictionary<string, string>? parameters,
DreamDaemonLaunchParameters launchParameters,
string accessIdentifier,
string? logFilePath);
/// <inheritdoc />
@@ -40,13 +40,21 @@ namespace Tgstation.Server.Host.Components.Engine
}
/// <inheritdoc />
public abstract ValueTask<IEngineInstallation> CreateInstallation(EngineVersion version, string path, Task installationTask, CancellationToken cancellationToken);
public abstract ValueTask<IEngineInstallation> GetInstallation(EngineVersion version, string path, Task installationTask, CancellationToken cancellationToken);
/// <inheritdoc />
public abstract Task CleanCache(CancellationToken cancellationToken);
/// <inheritdoc />
public abstract ValueTask Install(EngineVersion version, string path, bool deploymentPipelineProcesses, CancellationToken cancellationToken);
public async ValueTask<IEngineInstallation> Install(EngineVersion version, string path, bool deploymentPipelineProcesses, CancellationToken cancellationToken)
{
CheckVersionValidity(version);
ArgumentNullException.ThrowIfNull(path);
await InstallImpl(version, path, deploymentPipelineProcesses, cancellationToken);
return await GetInstallation(version, path, Task.CompletedTask, cancellationToken);
}
/// <inheritdoc />
public abstract ValueTask UpgradeInstallation(EngineVersion version, string path, CancellationToken cancellationToken);
@@ -67,5 +75,15 @@ namespace Tgstation.Server.Host.Components.Engine
if (version.Engine!.Value != TargetEngineType)
throw new InvalidOperationException($"Non-{TargetEngineType} engine specified: {version.Engine.Value}");
}
/// <summary>
/// Does actions necessary to get an extracted installation working.
/// </summary>
/// <param name="version">The <see cref="EngineVersion"/> being installed.</param>
/// <param name="path">The path to the installation.</param>
/// <param name="deploymentPipelineProcesses">If the operation should consider processes it launches to be part of the deployment pipeline.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask"/> representing the running operation.</returns>
protected abstract ValueTask InstallImpl(EngineVersion version, string path, bool deploymentPipelineProcesses, CancellationToken cancellationToken);
}
}
@@ -11,6 +11,7 @@ using Microsoft.Extensions.Logging;
using Tgstation.Server.Api.Models;
using Tgstation.Server.Common.Extensions;
using Tgstation.Server.Host.Components.Deployment;
using Tgstation.Server.Host.Components.Events;
using Tgstation.Server.Host.IO;
using Tgstation.Server.Host.Jobs;
@@ -59,6 +60,11 @@ namespace Tgstation.Server.Host.Components.Engine
/// </summary>
readonly IEventConsumer eventConsumer;
/// <summary>
/// The <see cref="IDmbFactory"/> for the <see cref="EngineManager"/>.
/// </summary>
readonly IDmbFactory dmbFactory;
/// <summary>
/// The <see cref="ILogger"/> for the <see cref="EngineManager"/>.
/// </summary>
@@ -100,12 +106,19 @@ namespace Tgstation.Server.Host.Components.Engine
/// <param name="ioManager">The value of <see cref="ioManager"/>.</param>
/// <param name="engineInstaller">The value of <see cref="engineInstaller"/>.</param>
/// <param name="eventConsumer">The value of <see cref="eventConsumer"/>.</param>
/// <param name="dmbFactory">The value of <see cref="dmbFactory"/>.</param>
/// <param name="logger">The value of <see cref="logger"/>.</param>
public EngineManager(IIOManager ioManager, IEngineInstaller engineInstaller, IEventConsumer eventConsumer, ILogger<EngineManager> logger)
public EngineManager(
IIOManager ioManager,
IEngineInstaller engineInstaller,
IEventConsumer eventConsumer,
IDmbFactory dmbFactory,
ILogger<EngineManager> logger)
{
this.ioManager = ioManager ?? throw new ArgumentNullException(nameof(ioManager));
this.engineInstaller = engineInstaller ?? throw new ArgumentNullException(nameof(engineInstaller));
this.eventConsumer = eventConsumer ?? throw new ArgumentNullException(nameof(eventConsumer));
this.dmbFactory = dmbFactory ?? throw new ArgumentNullException(nameof(dmbFactory));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
installedVersions = new Dictionary<EngineVersion, ReferenceCountingContainer<IEngineInstallation, EngineExecutableLock>>();
@@ -228,6 +241,9 @@ namespace Tgstation.Server.Host.Components.Engine
activeVersionUpdate = activeVersionChanged.Task;
logger.LogTrace("Waiting for container.OnZeroReferences or switch of active version...");
if (!containerTask.IsCompleted)
dmbFactory.LogLockStates();
await Task.WhenAny(
containerTask,
activeVersionUpdate)
@@ -337,7 +353,7 @@ namespace Tgstation.Server.Host.Components.Engine
try
{
var installation = await engineInstaller.CreateInstallation(version, path, Task.CompletedTask, cancellationToken);
var installation = await engineInstaller.GetInstallation(version, path, Task.CompletedTask, cancellationToken);
AddInstallationContainer(installation);
logger.LogDebug("Added detected BYOND version {versionKey}...", version);
}
@@ -428,7 +444,7 @@ namespace Tgstation.Server.Host.Components.Engine
}
}
var potentialInstallation = await engineInstaller.CreateInstallation(
var potentialInstallation = await engineInstaller.GetInstallation(
version,
ioManager.ResolvePath(version.ToString()),
ourTcs.Task,
@@ -598,7 +614,21 @@ namespace Tgstation.Server.Host.Components.Engine
remainingReporter.StageName = "Running installation actions";
await engineInstaller.Install(version, installFullPath, deploymentPipelineProcesses, cancellationToken);
var installation = await engineInstaller.Install(version, installFullPath, deploymentPipelineProcesses, cancellationToken);
// some minor validation
var serverInstallTask = ioManager.FileExists(installation.ServerExePath, cancellationToken);
if (!await ioManager.FileExists(installation.CompilerExePath, cancellationToken))
{
logger.LogError("Compiler executable does not exist after engine installation!");
throw new JobException(ErrorCode.EngineDownloadFail);
}
if (!await serverInstallTask)
{
logger.LogError("Server executable does not exist after engine installation!");
throw new JobException(ErrorCode.EngineDownloadFail);
}
remainingReporter.ReportProgress(0.9);
remainingReporter.StageName = "Writing version file";
@@ -60,14 +60,16 @@ namespace Tgstation.Server.Host.Components.Engine
/// Return the command line arguments for launching with given <paramref name="launchParameters"/>.
/// </summary>
/// <param name="dmbProvider">The <see cref="IDmbProvider"/>.</param>
/// <param name="parameters">The map of parameter <see cref="string"/>s as a <see cref="IReadOnlyDictionary{TKey, TValue}"/>. MUST include <see cref="Interop.DMApiConstants.ParamAccessIdentifier"/>. Should NOT include the <see cref="DreamDaemonLaunchParameters.AdditionalParameters"/> of <paramref name="launchParameters"/>.</param>
/// <param name="parameters">The optional map of parameter <see cref="string"/>s as a <see cref="IReadOnlyDictionary{TKey, TValue}"/>. MUST include <see cref="Interop.DMApiConstants.ParamAccessIdentifier"/>. Should NOT include the <see cref="DreamDaemonLaunchParameters.AdditionalParameters"/> of <paramref name="launchParameters"/>.</param>
/// <param name="launchParameters">The <see cref="DreamDaemonLaunchParameters"/>.</param>
/// <param name="accessIdentifier">The secure <see cref="string"/> used to authenticate communication with the game server.</param>
/// <param name="logFilePath">The full path to the log file, if any.</param>
/// <returns>The formatted arguments <see cref="string"/>.</returns>
string FormatServerArguments(
IDmbProvider dmbProvider,
IReadOnlyDictionary<string, string> parameters,
IReadOnlyDictionary<string, string>? parameters,
DreamDaemonLaunchParameters launchParameters,
string accessIdentifier,
string? logFilePath);
/// <summary>
@@ -83,7 +85,7 @@ namespace Tgstation.Server.Host.Components.Engine
/// </summary>
/// <param name="logger">The <see cref="ILogger"/> to write to.</param>
/// <param name="process">The <see cref="IProcess"/> to be terminated.</param>
/// <param name="accessIdentifier">The <see cref="Interop.DMApiParameters.AccessIdentifier"/> of the session.</param>
/// <param name="accessIdentifier">The secure <see cref="string"/> used to authenticate communication with the game server.</param>
/// <param name="port">The port the server is running on.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask"/> representing the running operation.</returns>
@@ -14,7 +14,7 @@ namespace Tgstation.Server.Host.Components.Engine
/// </summary>
/// <param name="path">The full path to extract to.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task"/> representing the running operation.</returns>
Task ExtractToPath(string path, CancellationToken cancellationToken);
/// <returns>A <see cref="ValueTask"/> representing the running operation.</returns>
ValueTask ExtractToPath(string path, CancellationToken cancellationToken);
}
}
@@ -18,8 +18,8 @@ namespace Tgstation.Server.Host.Components.Engine
/// <param name="path">The path to the installation.</param>
/// <param name="installationTask">The <see cref="Task"/> representing the installation process for the installation.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="IEngineInstallation"/>.</returns>
ValueTask<IEngineInstallation> CreateInstallation(EngineVersion version, string path, Task installationTask, CancellationToken cancellationToken);
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a new <see cref="IEngineInstallation"/> for the given <paramref name="version"/>.</returns>
ValueTask<IEngineInstallation> GetInstallation(EngineVersion version, string path, Task installationTask, CancellationToken cancellationToken);
/// <summary>
/// Download a given engine <paramref name="version"/>.
@@ -37,8 +37,8 @@ namespace Tgstation.Server.Host.Components.Engine
/// <param name="path">The path to the installation.</param>
/// <param name="deploymentPipelineProcesses">If the operation should consider processes it launches to be part of the deployment pipeline.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask"/> representing the running operation.</returns>
ValueTask Install(EngineVersion version, string path, bool deploymentPipelineProcesses, CancellationToken cancellationToken);
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the new <see cref="IEngineInstallation"/>.</returns>
ValueTask<IEngineInstallation> Install(EngineVersion version, string path, bool deploymentPipelineProcesses, CancellationToken cancellationToken);
/// <summary>
/// Does actions necessary to get upgrade a version installed by a previous version of TGS.
@@ -12,9 +12,7 @@ using Microsoft.Extensions.Logging;
using Tgstation.Server.Api.Models;
using Tgstation.Server.Api.Models.Internal;
using Tgstation.Server.Common.Http;
using Tgstation.Server.Host.Components.Deployment;
using Tgstation.Server.Host.Components.Interop;
using Tgstation.Server.Host.IO;
using Tgstation.Server.Host.System;
using Tgstation.Server.Host.Utils;
@@ -56,9 +54,9 @@ namespace Tgstation.Server.Host.Components.Engine
readonly IAsyncDelayer asyncDelayer;
/// <summary>
/// The <see cref="IAbstractHttpClientFactory"/> for the <see cref="OpenDreamInstallation"/>.
/// The <see cref="IHttpClientFactory"/> for the <see cref="OpenDreamInstallation"/>.
/// </summary>
readonly IAbstractHttpClientFactory httpClientFactory;
readonly IHttpClientFactory httpClientFactory;
/// <summary>
/// Path to the Robust.Server.dll.
@@ -84,7 +82,7 @@ namespace Tgstation.Server.Host.Components.Engine
public OpenDreamInstallation(
IIOManager installationIOManager,
IAsyncDelayer asyncDelayer,
IAbstractHttpClientFactory httpClientFactory,
IHttpClientFactory httpClientFactory,
string dotnetPath,
string serverDllPath,
string compilerDllPath,
@@ -110,20 +108,21 @@ namespace Tgstation.Server.Host.Components.Engine
/// <inheritdoc />
public override string FormatServerArguments(
IDmbProvider dmbProvider,
IReadOnlyDictionary<string, string> parameters,
IReadOnlyDictionary<string, string>? parameters,
DreamDaemonLaunchParameters launchParameters,
string accessIdentifier,
string? logFilePath)
{
ArgumentNullException.ThrowIfNull(dmbProvider);
ArgumentNullException.ThrowIfNull(parameters);
ArgumentNullException.ThrowIfNull(launchParameters);
ArgumentNullException.ThrowIfNull(accessIdentifier);
if (!parameters.TryGetValue(DMApiConstants.ParamAccessIdentifier, out var accessIdentifier))
throw new ArgumentException($"parameters must have \"{DMApiConstants.ParamAccessIdentifier}\" set!", nameof(parameters));
var encodedParameters = EncodeParameters(parameters, launchParameters);
var parametersString = !String.IsNullOrEmpty(encodedParameters)
? $" --cvar opendream.world_params=\"{encodedParameters}\""
: String.Empty;
var parametersString = EncodeParameters(parameters, launchParameters);
var arguments = $"{serverDllPath} --cvar {(logFilePath != null ? $"log.path=\"{InstallationIOManager.GetDirectoryName(logFilePath)}\" --cvar log.format=\"{InstallationIOManager.GetFileName(logFilePath)}\"" : "log.enabled=false")} --cvar watchdog.token={accessIdentifier} --cvar log.runtimelog=false --cvar net.port={launchParameters.Port!.Value} --cvar opendream.topic_port={launchParameters.OpenDreamTopicPort!.Value} --cvar opendream.world_params=\"{parametersString}\" --cvar opendream.json_path=\"./{dmbProvider.DmbName}\"";
var arguments = $"{serverDllPath} --cvar {(logFilePath != null ? $"log.path=\"{InstallationIOManager.GetDirectoryName(logFilePath)}\" --cvar log.format=\"{InstallationIOManager.GetFileName(logFilePath)}\"" : "log.enabled=false")} --cvar watchdog.token={accessIdentifier} --cvar log.runtimelog=false --cvar net.port={launchParameters.Port!.Value} --cvar opendream.topic_port={launchParameters.OpenDreamTopicPort!.Value}{parametersString} --cvar opendream.json_path=\"./{dmbProvider.DmbName}\"";
return arguments;
}
@@ -1,5 +1,6 @@
using System;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
@@ -8,7 +9,6 @@ using Microsoft.Extensions.Options;
using Tgstation.Server.Api.Models;
using Tgstation.Server.Common.Extensions;
using Tgstation.Server.Common.Http;
using Tgstation.Server.Host.Components.Repository;
using Tgstation.Server.Host.Configuration;
using Tgstation.Server.Host.IO;
@@ -77,9 +77,9 @@ namespace Tgstation.Server.Host.Components.Engine
readonly IAsyncDelayer asyncDelayer;
/// <summary>
/// The <see cref="IAbstractHttpClientFactory"/> for the <see cref="OpenDreamInstaller"/>.
/// The <see cref="IHttpClientFactory"/> for the <see cref="OpenDreamInstaller"/>.
/// </summary>
readonly IAbstractHttpClientFactory httpClientFactory;
readonly IHttpClientFactory httpClientFactory;
/// <summary>
/// Initializes a new instance of the <see cref="OpenDreamInstaller"/> class.
@@ -100,7 +100,7 @@ namespace Tgstation.Server.Host.Components.Engine
IProcessExecutor processExecutor,
IRepositoryManager repositoryManager,
IAsyncDelayer asyncDelayer,
IAbstractHttpClientFactory httpClientFactory,
IHttpClientFactory httpClientFactory,
IOptions<GeneralConfiguration> generalConfigurationOptions,
IOptions<SessionConfiguration> sessionConfigurationOptions)
: base(ioManager, logger)
@@ -115,10 +115,10 @@ namespace Tgstation.Server.Host.Components.Engine
}
/// <inheritdoc />
public override Task CleanCache(CancellationToken cancellationToken) => Task.CompletedTask;
public sealed override Task CleanCache(CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
public override async ValueTask<IEngineInstallation> CreateInstallation(EngineVersion version, string path, Task installationTask, CancellationToken cancellationToken)
public sealed override async ValueTask<IEngineInstallation> GetInstallation(EngineVersion version, string path, Task installationTask, CancellationToken cancellationToken)
{
CheckVersionValidity(version);
GetExecutablePaths(path, out var serverExePath, out var compilerExePath);
@@ -137,7 +137,7 @@ namespace Tgstation.Server.Host.Components.Engine
}
/// <inheritdoc />
public override async ValueTask<IEngineInstallationData> DownloadVersion(EngineVersion version, JobProgressReporter jobProgressReporter, CancellationToken cancellationToken)
public sealed override async ValueTask<IEngineInstallationData> DownloadVersion(EngineVersion version, JobProgressReporter jobProgressReporter, CancellationToken cancellationToken)
{
CheckVersionValidity(version);
ArgumentNullException.ThrowIfNull(jobProgressReporter);
@@ -216,10 +216,26 @@ namespace Tgstation.Server.Host.Components.Engine
}
/// <inheritdoc />
public override async ValueTask Install(EngineVersion version, string installPath, bool deploymentPipelineProcesses, CancellationToken cancellationToken)
public override ValueTask UpgradeInstallation(EngineVersion version, string path, CancellationToken cancellationToken)
{
CheckVersionValidity(version);
ArgumentNullException.ThrowIfNull(installPath);
ArgumentNullException.ThrowIfNull(path);
return ValueTask.CompletedTask;
}
/// <inheritdoc />
public override ValueTask TrustDmbPath(EngineVersion engineVersion, string fullDmbPath, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(engineVersion);
ArgumentNullException.ThrowIfNull(fullDmbPath);
Logger.LogTrace("TrustDmbPath is a no-op: {path}", fullDmbPath);
return ValueTask.CompletedTask;
}
/// <inheritdoc />
protected override async ValueTask InstallImpl(EngineVersion version, string installPath, bool deploymentPipelineProcesses, CancellationToken cancellationToken)
{
var sourcePath = IOManager.ConcatPath(installPath, InstallationSourceSubDirectory);
if (!await IOManager.DirectoryExists(sourcePath, cancellationToken))
@@ -335,24 +351,6 @@ namespace Tgstation.Server.Host.Components.Engine
await IOManager.DeleteDirectory(sourcePath, cancellationToken);
}
/// <inheritdoc />
public override ValueTask UpgradeInstallation(EngineVersion version, string path, CancellationToken cancellationToken)
{
CheckVersionValidity(version);
ArgumentNullException.ThrowIfNull(path);
return ValueTask.CompletedTask;
}
/// <inheritdoc />
public override ValueTask TrustDmbPath(EngineVersion engineVersion, string fullDmbPath, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(engineVersion);
ArgumentNullException.ThrowIfNull(fullDmbPath);
Logger.LogTrace("TrustDmbPath is a no-op: {path}", fullDmbPath);
return ValueTask.CompletedTask;
}
/// <summary>
/// Perform an operation on a very long path.
/// </summary>
@@ -75,11 +75,27 @@ namespace Tgstation.Server.Host.Components.Engine
}
/// <inheritdoc />
public override ValueTask Install(EngineVersion version, string path, bool deploymentPipelineProcesses, CancellationToken cancellationToken)
public override ValueTask UpgradeInstallation(EngineVersion version, string path, CancellationToken cancellationToken)
{
CheckVersionValidity(version);
ArgumentNullException.ThrowIfNull(path);
return ValueTask.CompletedTask;
}
/// <inheritdoc />
public override ValueTask TrustDmbPath(EngineVersion version, string fullDmbPath, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(version);
ArgumentNullException.ThrowIfNull(fullDmbPath);
Logger.LogTrace("No need to trust .dmb path \"{path}\" on POSIX", fullDmbPath);
return ValueTask.CompletedTask;
}
/// <inheritdoc />
protected override ValueTask InstallImpl(EngineVersion version, string path, bool deploymentPipelineProcesses, CancellationToken cancellationToken)
{
// write the scripts for running the ting
// need to add $ORIGIN to LD_LIBRARY_PATH
const string StandardScript = "#!/bin/sh\nexport LD_LIBRARY_PATH=\"\\$ORIGIN:$LD_LIBRARY_PATH\"\nBASEDIR=$(dirname \"$0\")\nexec \"$BASEDIR/{0}\" \"$@\"\n";
@@ -114,25 +130,6 @@ namespace Tgstation.Server.Host.Components.Engine
return task;
}
/// <inheritdoc />
public override ValueTask UpgradeInstallation(EngineVersion version, string path, CancellationToken cancellationToken)
{
CheckVersionValidity(version);
ArgumentNullException.ThrowIfNull(path);
return ValueTask.CompletedTask;
}
/// <inheritdoc />
public override ValueTask TrustDmbPath(EngineVersion version, string fullDmbPath, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(version);
ArgumentNullException.ThrowIfNull(fullDmbPath);
Logger.LogTrace("No need to trust .dmb path \"{path}\" on POSIX", fullDmbPath);
return ValueTask.CompletedTask;
}
/// <inheritdoc />
protected override string GetDreamDaemonName(Version byondVersion, out bool supportsCli)
{
@@ -48,12 +48,11 @@ namespace Tgstation.Server.Host.Components.Engine
}
/// <inheritdoc />
public Task ExtractToPath(string path, CancellationToken cancellationToken)
public ValueTask ExtractToPath(string path, CancellationToken cancellationToken)
=> repository.CopyTo(
ioManager.ConcatPath(
path,
targetSubDirectory),
cancellationToken)
.AsTask();
cancellationToken);
}
}
@@ -130,29 +130,6 @@ namespace Tgstation.Server.Host.Components.Engine
/// <inheritdoc />
public void Dispose() => semaphore.Dispose();
/// <inheritdoc />
public override ValueTask Install(EngineVersion version, string path, bool deploymentPipelineProcesses, CancellationToken cancellationToken)
{
CheckVersionValidity(version);
ArgumentNullException.ThrowIfNull(path);
var noPromptTrustedTask = SetNoPromptTrusted(path, cancellationToken);
var installDirectXTask = InstallDirectX(path, cancellationToken);
var tasks = new List<ValueTask>(3)
{
noPromptTrustedTask,
installDirectXTask,
};
if (!GeneralConfigurationOptions.CurrentValue.SkipAddingByondFirewallException)
{
var firewallTask = AddDreamDaemonToFirewall(version, path, deploymentPipelineProcesses, cancellationToken);
tasks.Add(firewallTask);
}
return ValueTaskExtensions.WhenAll(tasks);
}
/// <inheritdoc />
public override async ValueTask UpgradeInstallation(EngineVersion version, string path, CancellationToken cancellationToken)
{
@@ -215,6 +192,26 @@ namespace Tgstation.Server.Host.Components.Engine
}
}
/// <inheritdoc />
protected override ValueTask InstallImpl(EngineVersion version, string path, bool deploymentPipelineProcesses, CancellationToken cancellationToken)
{
var noPromptTrustedTask = SetNoPromptTrusted(path, cancellationToken);
var installDirectXTask = InstallDirectX(path, cancellationToken);
var tasks = new List<ValueTask>(3)
{
noPromptTrustedTask,
installDirectXTask,
};
if (!GeneralConfigurationOptions.CurrentValue.SkipAddingByondFirewallException)
{
var firewallTask = AddDreamDaemonToFirewall(version, path, deploymentPipelineProcesses, cancellationToken);
tasks.Add(firewallTask);
}
return ValueTaskExtensions.WhenAll(tasks);
}
/// <inheritdoc />
protected override string GetDreamDaemonName(Version byondVersion, out bool supportsCli)
{
@@ -1,4 +1,5 @@
using System;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
@@ -7,7 +8,6 @@ using Microsoft.Extensions.Options;
using Tgstation.Server.Api.Models;
using Tgstation.Server.Common.Extensions;
using Tgstation.Server.Common.Http;
using Tgstation.Server.Host.Components.Repository;
using Tgstation.Server.Host.Configuration;
using Tgstation.Server.Host.IO;
@@ -36,7 +36,7 @@ namespace Tgstation.Server.Host.Components.Engine
/// <param name="processExecutor">The <see cref="IProcessExecutor"/> for the <see cref="OpenDreamInstaller"/>.</param>
/// <param name="repositoryManager">The <see cref="IRepositoryManager"/> for the <see cref="OpenDreamInstaller"/>.</param>
/// <param name="asyncDelayer">The <see cref="IAsyncDelayer"/> for the <see cref="OpenDreamInstaller"/>.</param>
/// <param name="httpClientFactory">The <see cref="IAbstractHttpClientFactory"/> for the <see cref="OpenDreamInstaller"/>.</param>
/// <param name="httpClientFactory">The <see cref="IHttpClientFactory"/> for the <see cref="OpenDreamInstaller"/>.</param>
/// <param name="generalConfigurationOptions">The <see cref="IOptions{TOptions}"/> of <see cref="GeneralConfiguration"/> for the <see cref="OpenDreamInstaller"/>.</param>
/// <param name="sessionConfigurationOptions">The <see cref="IOptions{TOptions}"/> of <see cref="SessionConfiguration"/> for the <see cref="OpenDreamInstaller"/>.</param>
/// <param name="linkFactory">The value of <see cref="linkFactory"/>.</param>
@@ -47,7 +47,7 @@ namespace Tgstation.Server.Host.Components.Engine
IProcessExecutor processExecutor,
IRepositoryManager repositoryManager,
IAsyncDelayer asyncDelayer,
IAbstractHttpClientFactory httpClientFactory,
IHttpClientFactory httpClientFactory,
IOptions<GeneralConfiguration> generalConfigurationOptions,
IOptions<SessionConfiguration> sessionConfigurationOptions,
IFilesystemLinkFactory linkFactory)
@@ -66,9 +66,9 @@ namespace Tgstation.Server.Host.Components.Engine
}
/// <inheritdoc />
public override ValueTask Install(EngineVersion version, string installPath, bool deploymentPipelineProcesses, CancellationToken cancellationToken)
protected override ValueTask InstallImpl(EngineVersion version, string installPath, bool deploymentPipelineProcesses, CancellationToken cancellationToken)
{
var installTask = base.Install(
var installTask = base.InstallImpl(
version,
installPath,
deploymentPipelineProcesses,
@@ -37,7 +37,7 @@ namespace Tgstation.Server.Host.Components.Engine
public ValueTask DisposeAsync() => zipStream.DisposeAsync();
/// <inheritdoc />
public Task ExtractToPath(string path, CancellationToken cancellationToken)
public ValueTask ExtractToPath(string path, CancellationToken cancellationToken)
=> ioManager.ZipToDirectory(path, zipStream, cancellationToken);
}
}
@@ -290,12 +290,6 @@ namespace Tgstation.Server.Host.Components
var repoManager = repositoryManagerFactory.CreateRepositoryManager(repoIoManager, eventConsumer);
try
{
var engineManager = new EngineManager(
byondIOManager,
engineInstaller,
eventConsumer,
loggerFactory.CreateLogger<EngineManager>());
var dmbFactory = new DmbFactory(
databaseContextFactory,
gameIoManager,
@@ -306,100 +300,114 @@ namespace Tgstation.Server.Host.Components
metadata);
try
{
var commandFactory = new CommandFactory(assemblyInformationProvider, engineManager, repoManager, databaseContextFactory, dmbFactory, metadata);
var chatManager = chatFactory.CreateChatManager(commandFactory, metadata.ChatSettings);
var engineManager = new EngineManager(
byondIOManager,
engineInstaller,
eventConsumer,
dmbFactory,
loggerFactory.CreateLogger<EngineManager>());
try
{
var reattachInfoHandler = new SessionPersistor(
databaseContextFactory,
dmbFactory,
processExecutor,
loggerFactory.CreateLogger<SessionPersistor>(),
metadata);
var commandFactory = new CommandFactory(assemblyInformationProvider, engineManager, repoManager, databaseContextFactory, dmbFactory, metadata);
var sessionControllerFactory = new SessionControllerFactory(
processExecutor,
engineManager,
topicClientFactory,
cryptographySuite,
assemblyInformationProvider,
gameIoManager,
diagnosticsIOManager,
chatManager,
networkPromptReaper,
platformIdentifier,
bridgeRegistrar,
eventConsumer,
asyncDelayer,
dotnetDumpService,
metricFactory,
loggerFactory,
loggerFactory.CreateLogger<SessionControllerFactory>(),
sessionConfiguration,
metadata);
var watchdog = watchdogFactory.CreateWatchdog(
chatManager,
dmbFactory,
reattachInfoHandler,
sessionControllerFactory,
gameIoManager,
diagnosticsIOManager,
configuration, // watchdog doesn't need itself as an event consumer
remoteDeploymentManagerFactory,
metricFactory,
metadata,
metadata.DreamDaemonSettings!);
var chatManager = chatFactory.CreateChatManager(commandFactory, metadata.ChatSettings);
try
{
eventConsumer.SetWatchdog(watchdog);
commandFactory.SetWatchdog(watchdog);
Instance? instance = null;
var dreamMaker = new DreamMaker(
engineManager,
gameIoManager,
configuration,
sessionControllerFactory,
eventConsumer,
chatManager,
processExecutor,
var reattachInfoHandler = new SessionPersistor(
databaseContextFactory,
dmbFactory,
repoManager,
remoteDeploymentManagerFactory,
processExecutor,
loggerFactory.CreateLogger<SessionPersistor>(),
metadata);
var sessionControllerFactory = new SessionControllerFactory(
processExecutor,
engineManager,
topicClientFactory,
cryptographySuite,
assemblyInformationProvider,
gameIoManager,
diagnosticsIOManager,
chatManager,
networkPromptReaper,
platformIdentifier,
bridgeRegistrar,
eventConsumer,
asyncDelayer,
dotnetDumpService,
metricFactory,
loggerFactory.CreateLogger<DreamMaker>(),
loggerFactory,
loggerFactory.CreateLogger<SessionControllerFactory>(),
sessionConfiguration,
metadata);
instance = new Instance(
metadata,
repoManager,
engineManager,
dreamMaker,
watchdog,
var watchdog = watchdogFactory.CreateWatchdog(
chatManager,
configuration,
dmbFactory,
jobManager,
eventConsumer,
reattachInfoHandler,
sessionControllerFactory,
gameIoManager,
diagnosticsIOManager,
configuration, // watchdog doesn't need itself as an event consumer
remoteDeploymentManagerFactory,
asyncDelayer,
loggerFactory.CreateLogger<Instance>());
metricFactory,
metadata,
metadata.DreamDaemonSettings!);
try
{
eventConsumer.SetWatchdog(watchdog);
commandFactory.SetWatchdog(watchdog);
return instance;
Instance? instance = null;
var dreamMaker = new DreamMaker(
engineManager,
gameIoManager,
configuration,
sessionControllerFactory,
eventConsumer,
chatManager,
processExecutor,
dmbFactory,
repoManager,
remoteDeploymentManagerFactory,
asyncDelayer,
metricFactory,
loggerFactory.CreateLogger<DreamMaker>(),
sessionConfiguration,
metadata);
instance = new Instance(
metadata,
repoManager,
engineManager,
dreamMaker,
watchdog,
chatManager,
configuration,
dmbFactory,
jobManager,
eventConsumer,
remoteDeploymentManagerFactory,
asyncDelayer,
loggerFactory.CreateLogger<Instance>());
return instance;
}
catch
{
await watchdog.DisposeAsync();
throw;
}
}
catch
{
await watchdog.DisposeAsync();
await chatManager.DisposeAsync();
throw;
}
}
catch
{
await chatManager.DisposeAsync();
engineManager.Dispose();
throw;
}
}
@@ -518,17 +518,23 @@ namespace Tgstation.Server.Host.Components.Session
bool apiValidate,
CancellationToken cancellationToken)
{
// important to run on all ports to allow port changing
var environment = await engineLock.LoadEnv(logger, false, cancellationToken);
var arguments = engineLock.FormatServerArguments(
dmbProvider,
new Dictionary<string, string>
var serverMayHaveDMApi = apiValidate || dmbProvider.CompileJob.DMApiVersion != null;
var serverArguments = serverMayHaveDMApi
? new Dictionary<string, string>
{
{ DMApiConstants.ParamApiVersion, DMApiConstants.InteropVersion.Semver().ToString() },
{ DMApiConstants.ParamServerPort, sessionConfiguration.BridgePort.ToString(CultureInfo.InvariantCulture) },
{ DMApiConstants.ParamAccessIdentifier, accessIdentifier },
},
}
: null;
var environment = await engineLock.LoadEnv(logger, false, cancellationToken);
var arguments = engineLock.FormatServerArguments(
dmbProvider,
serverArguments,
launchParameters,
accessIdentifier,
!engineLock.HasStandardOutput || engineLock.PreferFileLogging
? logFilePath
: null);
@@ -604,11 +610,11 @@ namespace Tgstation.Server.Host.Components.Session
if (cliSupported)
ddOutput = (await process.GetCombinedOutput(cancellationToken))!;
if (ddOutput == null)
if (String.IsNullOrWhiteSpace(ddOutput) && outputFilePath != null)
try
{
var dreamDaemonLogBytes = await gameIOManager.ReadAllBytes(
outputFilePath!,
outputFilePath,
cancellationToken);
ddOutput = Encoding.UTF8.GetString(dreamDaemonLogBytes.Span);
@@ -619,7 +625,7 @@ namespace Tgstation.Server.Host.Components.Session
try
{
logger.LogTrace("Deleting temporary log file {path}...", outputFilePath);
await gameIOManager.DeleteFile(outputFilePath!, cancellationToken);
await gameIOManager.DeleteFile(outputFilePath, cancellationToken);
}
catch (Exception ex)
{
@@ -3,8 +3,8 @@ using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Web;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
@@ -29,6 +29,7 @@ namespace Tgstation.Server.Host.Controllers
/// <summary>
/// <see cref="ApiController"/> for TGS administration purposes.
/// </summary>
[Authorize]
[Route(Routes.Administration)]
public sealed class AdministrationController : ApiController
{
@@ -111,7 +112,6 @@ namespace Tgstation.Server.Host.Controllers
/// <response code="424">The GitHub API rate limit was hit. See response header Retry-After.</response>
/// <response code="429">A GitHub API error occurred. See error message for details.</response>
[HttpGet]
[TgsRestAuthorize<IAdministrationAuthority>(nameof(IAdministrationAuthority.GetUpdateInformation))]
[ProducesResponseType(typeof(AdministrationResponse), 200)]
[ProducesResponseType(typeof(ErrorMessageResponse), 424)]
[ProducesResponseType(typeof(ErrorMessageResponse), 429)]
@@ -132,7 +132,6 @@ namespace Tgstation.Server.Host.Controllers
/// <response code="424">A GitHub rate limit was encountered or the swarm integrity check failed.</response>
/// <response code="429">A GitHub API error occurred.</response>
[HttpPost]
[TgsRestAuthorize<IAdministrationAuthority>(nameof(IAdministrationAuthority.TriggerServerVersionChange))]
[ProducesResponseType(typeof(ServerUpdateResponse), 202)]
[ProducesResponseType(typeof(ErrorMessageResponse), 410)]
[ProducesResponseType(typeof(ErrorMessageResponse), 422)]
@@ -160,7 +159,6 @@ namespace Tgstation.Server.Host.Controllers
/// <response code="204">Restart begun successfully.</response>
/// <response code="422">Restart operations are unavailable due to the launch configuration of TGS.</response>
[HttpDelete]
[TgsRestAuthorize<IAdministrationAuthority>(nameof(IAdministrationAuthority.TriggerServerRestart))]
[ProducesResponseType(204)]
[ProducesResponseType(typeof(ErrorMessageResponse), 422)]
public ValueTask<IActionResult> Delete()
@@ -236,43 +234,9 @@ namespace Tgstation.Server.Host.Controllers
[TgsAuthorize(AdministrationRights.DownloadLogs)]
[ProducesResponseType(typeof(LogFileResponse), 200)]
[ProducesResponseType(typeof(ErrorMessageResponse), 409)]
public async ValueTask<IActionResult> GetLog(string path, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(path);
path = HttpUtility.UrlDecode(path);
// guard against directory navigation
var sanitizedPath = ioManager.GetFileName(path);
if (path != sanitizedPath)
return Forbid();
var fullPath = ioManager.ConcatPath(
fileLoggingConfiguration.GetFullLogDirectory(ioManager, assemblyInformationProvider, platformIdentifier),
path);
try
{
var fileTransferTicket = fileTransferService.CreateDownload(
new FileDownloadProvider(
() => null,
null,
fullPath,
true));
return Ok(new LogFileResponse
{
Name = path,
LastModified = await ioManager.GetLastModified(fullPath, cancellationToken),
FileTicket = fileTransferTicket.FileTicket,
});
}
catch (IOException ex)
{
return Conflict(new ErrorMessageResponse(ErrorCode.IOError)
{
AdditionalData = ex.ToString(),
});
}
}
public ValueTask<IActionResult> GetLog(string path, CancellationToken cancellationToken)
=> administrationAuthority.Invoke<LogFileResponse, LogFileResponse>(
this,
authority => authority.GetLog(path, cancellationToken));
}
}
@@ -249,7 +249,7 @@ namespace Tgstation.Server.Host.Controllers
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="IActionResult"/> for the operation.</returns>
protected ValueTask<IActionResult> Paginated<TModel>(
Func<ValueTask<PaginatableResult<TModel>>> queryGenerator,
Func<ValueTask<PaginatableResult<TModel>?>> queryGenerator,
Func<TModel, ValueTask>? resultTransformer,
int? pageQuery,
int? pageSizeQuery,
@@ -272,7 +272,7 @@ namespace Tgstation.Server.Host.Controllers
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="IActionResult"/> for the operation.</returns>
protected ValueTask<IActionResult> Paginated<TModel, TApiModel>(
Func<ValueTask<PaginatableResult<TModel>>> queryGenerator,
Func<ValueTask<PaginatableResult<TModel>?>> queryGenerator,
Func<TApiModel, ValueTask>? resultTransformer,
int? pageQuery,
int? pageSizeQuery,
@@ -290,14 +290,14 @@ namespace Tgstation.Server.Host.Controllers
/// </summary>
/// <typeparam name="TModel">The <see cref="Type"/> of model being generated. If different from <typeparamref name="TResultModel"/>, must implement <see cref="ILegacyApiTransformable{TApiModel}"/> for <typeparamref name="TResultModel"/>.</typeparam>
/// <typeparam name="TResultModel">The <see cref="Type"/> of model being returned.</typeparam>
/// <param name="queryGenerator">A <see cref="Func{TResult}"/> resulting in a <see cref="ValueTask{TResult}"/> resulting in the generated <see cref="PaginatableResult{TModel}"/>.</param>
/// <param name="queryGenerator">A <see cref="Func{TResult}"/> resulting in a <see cref="ValueTask{TResult}"/> resulting in the generated <see cref="PaginatableResult{TModel}"/> or <see langword="null"/> if an authorization requirment failed.</param>
/// <param name="resultTransformer">A <see cref="Func{T, TResult}"/> to transform the <typeparamref name="TResultModel"/>s after being queried.</param>
/// <param name="pageQuery">The requested page from the query.</param>
/// <param name="pageSizeQuery">The requested page size from the query.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="IActionResult"/> for the operation.</returns>
async ValueTask<IActionResult> PaginatedImpl<TModel, TResultModel>(
Func<ValueTask<PaginatableResult<TModel>>> queryGenerator,
Func<ValueTask<PaginatableResult<TModel>?>> queryGenerator,
Func<TResultModel, ValueTask>? resultTransformer,
int? pageQuery,
int? pageSizeQuery,
@@ -318,6 +318,9 @@ namespace Tgstation.Server.Host.Controllers
var page = pageQuery ?? 1;
var paginationResult = await queryGenerator();
if (paginationResult == null)
return Forbid();
if (!paginationResult.Valid)
return paginationResult.EarlyOut;
@@ -3,7 +3,6 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
@@ -128,7 +127,6 @@ namespace Tgstation.Server.Host.Controllers
/// </returns>
/// <response code="200"><see cref="ServerInformationResponse"/> retrieved successfully.</response>
[HttpGet]
[AllowAnonymous]
[ProducesResponseType(typeof(ServerInformationResponse), 200)]
public IActionResult ServerInfo()
{
@@ -161,7 +161,7 @@ namespace Tgstation.Server.Host.Controllers
var response = await bridgeDispatcher.ProcessBridgeRequest(request, cancellationToken);
if (response == null)
TypedResults.Forbid();
TypedResults.Unauthorized();
var responseJson = JsonConvert.SerializeObject(response, DMApiConstants.SerializerSettings);
@@ -208,7 +208,7 @@ namespace Tgstation.Server.Host.Controllers
{
var connectionStrings = (AuthenticationContext.GetRight(RightsType.ChatBots) & (ulong)ChatBotRights.ReadConnectionString) != 0;
return Paginated<ChatBot, ChatBotResponse>(
() => ValueTask.FromResult(
() => ValueTask.FromResult<PaginatableResult<ChatBot>?>(
new PaginatableResult<ChatBot>(
DatabaseContext
.ChatBots
@@ -126,7 +126,7 @@ namespace Tgstation.Server.Host.Controllers
[ProducesResponseType(typeof(PaginatedResponse<CompileJobResponse>), 200)]
public ValueTask<IActionResult> List([FromQuery] int? page, [FromQuery] int? pageSize, CancellationToken cancellationToken)
=> Paginated<CompileJob, CompileJobResponse>(
() => ValueTask.FromResult(
() => ValueTask.FromResult<PaginatableResult<CompileJob>?>(
new PaginatableResult<CompileJob>(
BaseCompileJobsQuery()
.OrderByDescending(x => x.Job.StoppedAt))),
@@ -110,7 +110,7 @@ namespace Tgstation.Server.Host.Controllers
public ValueTask<IActionResult> List([FromQuery] int? page, [FromQuery] int? pageSize, CancellationToken cancellationToken)
=> WithComponentInstance(
instance => Paginated(
() => ValueTask.FromResult(
() => ValueTask.FromResult<PaginatableResult<EngineResponse>?>(
new PaginatableResult<EngineResponse>(
instance
.EngineManager
@@ -612,7 +612,7 @@ namespace Tgstation.Server.Host.Controllers
var needsUpdate = false;
var result = await Paginated<Models.Instance, InstanceResponse>(
() => ValueTask.FromResult(
() => ValueTask.FromResult<PaginatableResult<Models.Instance>?>(
new PaginatableResult<Models.Instance>(
GetBaseQuery()
.OrderBy(x => x.Id))),
@@ -197,7 +197,7 @@ namespace Tgstation.Server.Host.Controllers
[ProducesResponseType(typeof(PaginatedResponse<InstancePermissionSetResponse>), 200)]
public ValueTask<IActionResult> List([FromQuery] int? page, [FromQuery] int? pageSize, CancellationToken cancellationToken)
=> Paginated<InstancePermissionSet, InstancePermissionSetResponse>(
() => ValueTask.FromResult(
() => ValueTask.FromResult<PaginatableResult<InstancePermissionSet>?>(
new PaginatableResult<InstancePermissionSet>(
DatabaseContext
.Instances
@@ -71,7 +71,7 @@ namespace Tgstation.Server.Host.Controllers
[ProducesResponseType(typeof(PaginatedResponse<JobResponse>), 200)]
public ValueTask<IActionResult> Read([FromQuery] int? page, [FromQuery] int? pageSize, CancellationToken cancellationToken)
=> Paginated<Job, JobResponse>(
() => ValueTask.FromResult(
() => ValueTask.FromResult<PaginatableResult<Job>?>(
new PaginatableResult<Job>(
DatabaseContext
.Jobs
@@ -99,7 +99,7 @@ namespace Tgstation.Server.Host.Controllers
[ProducesResponseType(typeof(PaginatedResponse<JobResponse>), 200)]
public ValueTask<IActionResult> List([FromQuery] int? page, [FromQuery] int? pageSize, CancellationToken cancellationToken)
=> Paginated<Job, JobResponse>(
() => ValueTask.FromResult(
() => ValueTask.FromResult<PaginatableResult<Job>?>(
new PaginatableResult<Job>(
DatabaseContext
.Jobs
@@ -3,6 +3,7 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
@@ -24,6 +25,7 @@ namespace Tgstation.Server.Host.Controllers
/// <see cref="ApiController"/> for managing <see cref="User"/>s.
/// </summary>
[Route(Routes.User)]
[Authorize]
public sealed class UserController : ApiController
{
/// <summary>
@@ -64,10 +66,9 @@ namespace Tgstation.Server.Host.Controllers
/// <response code="201"><see cref="User"/> created successfully.</response>
/// <response code="410">The requested system identifier could not be found.</response>
[HttpPut]
[TgsRestAuthorize<IUserAuthority>(nameof(IUserAuthority.Create))]
[ProducesResponseType(typeof(UserResponse), 201)]
public ValueTask<IActionResult> Create([FromBody] UserCreateRequest model, CancellationToken cancellationToken)
=> userAuthority.InvokeTransformable<User, UserResponse>(this, authority => authority.Create(model, null, cancellationToken));
=> userAuthority.InvokeTransformable<UpdatedUser, UserResponse>(this, authority => authority.Create(model, null, cancellationToken));
/// <summary>
/// Update a <see cref="User"/>.
@@ -80,13 +81,12 @@ namespace Tgstation.Server.Host.Controllers
/// <response code="404">Requested <see cref="EntityId.Id"/> does not exist.</response>
/// <response code="410">Requested <see cref="Api.Models.Internal.UserApiBase.Group"/> does not exist.</response>
[HttpPost]
[TgsRestAuthorize<IUserAuthority>(nameof(IUserAuthority.Update))]
[ProducesResponseType(typeof(UserResponse), 200)]
[ProducesResponseType(204)]
[ProducesResponseType(typeof(ErrorMessageResponse), 404)]
[ProducesResponseType(typeof(ErrorMessageResponse), 410)]
public ValueTask<IActionResult> Update([FromBody] UserUpdateRequest model, CancellationToken cancellationToken)
=> userAuthority.InvokeTransformable<User, UserResponse>(this, authority => authority.Update(model, cancellationToken));
=> userAuthority.InvokeTransformable<UpdatedUser, UserResponse>(this, authority => authority.Update(model, cancellationToken));
/// <summary>
/// Get information about the current <see cref="User"/>.
@@ -95,7 +95,7 @@ namespace Tgstation.Server.Host.Controllers
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="IActionResult"/> of the operation.</returns>
/// <response code="200">The <see cref="User"/> was retrieved successfully.</response>
[HttpGet]
[TgsRestAuthorize<IUserAuthority>(nameof(IUserAuthority.Read))]
[Authorize]
[ProducesResponseType(typeof(UserResponse), 200)]
public ValueTask<IActionResult> Read(CancellationToken cancellationToken)
=> userAuthority.InvokeTransformable<User, UserResponse>(this, authority => authority.Read(cancellationToken));
@@ -109,15 +109,18 @@ namespace Tgstation.Server.Host.Controllers
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="IActionResult"/> of the operation.</returns>
/// <response code="200">Retrieved <see cref="User"/>s successfully.</response>
[HttpGet(Routes.List)]
[TgsRestAuthorize<IUserAuthority>(nameof(IUserAuthority.Queryable))]
[ProducesResponseType(typeof(PaginatedResponse<UserResponse>), 200)]
public ValueTask<IActionResult> List([FromQuery] int? page, [FromQuery] int? pageSize, CancellationToken cancellationToken)
=> Paginated<User, UserResponse>(
() => ValueTask.FromResult(
new PaginatableResult<User>(
userAuthority.InvokeQueryable(
authority => authority.Queryable(true))
.OrderBy(x => x.Id))),
async () =>
{
var queryable = await userAuthority.InvokeQueryable(
authority => authority.Queryable(true));
if (queryable == null)
return null;
return new PaginatableResult<User>(queryable.OrderBy(x => x.Id));
},
null,
page,
pageSize,
@@ -132,7 +135,6 @@ namespace Tgstation.Server.Host.Controllers
/// <response code="200">The <see cref="User"/> was retrieved successfully.</response>
/// <response code="404">The <see cref="User"/> does not exist.</response>
[HttpGet("{id}")]
[TgsAuthorize]
[ProducesResponseType(typeof(UserResponse), 200)]
[ProducesResponseType(typeof(ErrorMessageResponse), 404)]
public async ValueTask<IActionResult> GetId(long id, CancellationToken cancellationToken)
@@ -3,6 +3,7 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
@@ -10,7 +11,6 @@ using Tgstation.Server.Api;
using Tgstation.Server.Api.Models;
using Tgstation.Server.Api.Models.Request;
using Tgstation.Server.Api.Models.Response;
using Tgstation.Server.Api.Rights;
using Tgstation.Server.Host.Authority;
using Tgstation.Server.Host.Controllers.Results;
using Tgstation.Server.Host.Database;
@@ -24,6 +24,7 @@ namespace Tgstation.Server.Host.Controllers
/// <see cref="ApiController"/> for managing <see cref="UserGroupResponse"/>s.
/// </summary>
[Route(Routes.UserGroup)]
[Authorize]
public class UserGroupController : ApiController
{
/// <summary>
@@ -77,7 +78,6 @@ namespace Tgstation.Server.Host.Controllers
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="IActionResult"/> of the operation.</returns>
/// <response code="201"><see cref="UserGroup"/> created successfully.</response>
[HttpPut]
[TgsAuthorize(AdministrationRights.WriteUsers)]
[ProducesResponseType(typeof(UserGroupResponse), 201)]
public async ValueTask<IActionResult> Create([FromBody] UserGroupCreateRequest model, CancellationToken cancellationToken)
{
@@ -103,7 +103,6 @@ namespace Tgstation.Server.Host.Controllers
/// <response code="200"><see cref="UserGroup"/> updated successfully.</response>
/// <response code="410">The requested <see cref="UserGroup"/> does not currently exist.</response>
[HttpPost]
[TgsAuthorize(AdministrationRights.WriteUsers)]
[ProducesResponseType(typeof(UserGroupResponse), 200)]
public ValueTask<IActionResult> Update([FromBody] UserGroupUpdateRequest model, CancellationToken cancellationToken)
{
@@ -127,7 +126,6 @@ namespace Tgstation.Server.Host.Controllers
/// <response code="200">Retrieve <see cref="UserGroup"/> successfully.</response>
/// <response code="410">The requested <see cref="UserGroup"/> does not currently exist.</response>
[HttpGet("{id}")]
[TgsRestAuthorize<IUserGroupAuthority>(nameof(IUserGroupAuthority.GetId))]
[ProducesResponseType(typeof(UserGroupResponse), 200)]
[ProducesResponseType(typeof(ErrorMessageResponse), 410)]
public ValueTask<IActionResult> GetId(long id, CancellationToken cancellationToken)
@@ -142,15 +140,18 @@ namespace Tgstation.Server.Host.Controllers
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="IActionResult"/> of the request.</returns>
/// <response code="200">Retrieved <see cref="UserGroup"/>s successfully.</response>
[HttpGet(Routes.List)]
[TgsRestAuthorize<IUserGroupAuthority>(nameof(IUserGroupAuthority.Queryable))]
[ProducesResponseType(typeof(PaginatedResponse<UserGroupResponse>), 200)]
public ValueTask<IActionResult> List([FromQuery] int? page, [FromQuery] int? pageSize, CancellationToken cancellationToken)
=> Paginated<UserGroup, UserGroupResponse>(
() => ValueTask.FromResult(
new PaginatableResult<UserGroup>(
userGroupAuthority
.InvokeQueryable(authority => authority.Queryable(true))
.OrderBy(x => x.Id))),
async () =>
{
var queryable = await userGroupAuthority
.InvokeQueryable(authority => authority.Queryable(true));
if (queryable == null)
return null;
return new PaginatableResult<UserGroup>(queryable.OrderBy(x => x.Id));
},
null,
page,
pageSize,
@@ -166,7 +167,6 @@ namespace Tgstation.Server.Host.Controllers
/// <response code="409">The <see cref="UserGroup"/> is not empty.</response>
/// <response code="410">The <see cref="UserGroup"/> didn't exist.</response>
[HttpDelete("{id}")]
[TgsAuthorize(AdministrationRights.WriteUsers)]
[ProducesResponseType(204)]
[ProducesResponseType(typeof(ErrorMessageResponse), 409)]
[ProducesResponseType(typeof(ErrorMessageResponse), 410)]
+29 -19
View File
@@ -20,6 +20,7 @@ using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Cors.Infrastructure;
using Microsoft.AspNetCore.Hosting;
@@ -49,7 +50,6 @@ using Serilog.Sinks.Elasticsearch;
using Tgstation.Server.Api;
using Tgstation.Server.Api.Hubs;
using Tgstation.Server.Api.Models;
using Tgstation.Server.Common.Http;
using Tgstation.Server.Host.Authority;
using Tgstation.Server.Host.Authority.Core;
using Tgstation.Server.Host.Components;
@@ -309,8 +309,12 @@ namespace Tgstation.Server.Host.Core
services.AddCors();
// Enable managed HTTP clients
services.AddHttpClient();
services.AddSingleton<IAbstractHttpClientFactory, AbstractHttpClientFactory>();
services
.AddHttpClient()
.ConfigureHttpClientDefaults(
builder => builder.ConfigureHttpClient(
client => client.DefaultRequestHeaders.UserAgent.Add(
assemblyInformationProvider.ProductInfoHeaderValue)));
services.AddSingleton<IMetricFactory>(_ => Metrics.DefaultFactory);
services.AddSingleton<ICollectorRegistry>(_ => Metrics.DefaultRegistry);
@@ -325,21 +329,6 @@ namespace Tgstation.Server.Host.Core
services
.AddScoped<GraphQL.Subscriptions.ITopicEventReceiver, ShutdownAwareTopicEventReceiver>()
.AddGraphQLServer()
.AddAuthorization(
options =>
{
options.AddPolicy(
TgsAuthorizeAttribute.PolicyName,
builder => builder
.RequireAuthenticatedUser()
.RequireRole(TgsAuthorizeAttribute.UserEnabledRole));
options.AddPolicy(
SwarmConstants.AuthenticationSchemeAndPolicy,
builder => builder
.RequireAuthenticatedUser()
.AddAuthenticationSchemes(SwarmConstants.AuthenticationSchemeAndPolicy));
})
.ModifyOptions(options =>
{
options.EnsureAllNodesCanBeResolved = true;
@@ -373,10 +362,10 @@ namespace Tgstation.Server.Host.Core
.AddFiltering()
.AddSorting()
.AddHostTypes()
.AddAuthorization()
.AddErrorFilter<ErrorMessageFilter>()
.AddType<StandaloneNode>()
.AddType<LocalGateway>()
.AddType<RemoteGateway>()
.AddType<GraphQL.Types.UserName>()
.AddType<UnsignedIntType>()
.BindRuntimeType<Version, SemverType>()
@@ -843,6 +832,10 @@ namespace Tgstation.Server.Host.Core
services.AddScoped<AuthenticationContextFactory>();
services.AddScoped<ITokenValidator>(provider => provider.GetRequiredService<AuthenticationContextFactory>());
services.AddScoped<IClaimsPrincipalAccessor, ClaimsPrincipalAccessor>();
services.AddScoped<Security.IAuthorizationService, AuthorizationService>();
services.AddScoped<IAuthorizationHandler, AuthorizationHandler>();
// what if you
// wanted to just do this:
// return provider.GetRequiredService<AuthenticationContextFactory>().CurrentAuthenticationContext
@@ -899,6 +892,23 @@ namespace Tgstation.Server.Host.Core
authBuilder.AddScheme<AuthenticationSchemeOptions, SwarmAuthenticationHandler>(SwarmConstants.AuthenticationSchemeAndPolicy, "Swarm Authentication", null);
services.AddAuthorization(options =>
{
options.AddPolicy(
TgsAuthorizeAttribute.PolicyName,
builder => builder
.RequireAuthenticatedUser()
.RequireRole(TgsAuthorizeAttribute.UserEnabledRole));
options.AddPolicy(
SwarmConstants.AuthenticationSchemeAndPolicy,
builder => builder
.RequireAuthenticatedUser()
.AddAuthenticationSchemes(SwarmConstants.AuthenticationSchemeAndPolicy));
options.DefaultPolicy = options.GetPolicy(TgsAuthorizeAttribute.PolicyName)!;
});
var oidcConfig = securityConfiguration.OpenIDConnect;
if (oidcConfig == null || oidcConfig.Count == 0)
return;
@@ -0,0 +1,76 @@
using System;
using System.Globalization;
using System.Security.Claims;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
namespace Tgstation.Server.Host.Extensions
{
/// <summary>
/// Extension methods for the <see cref="ClaimsPrincipal"/> class.
/// </summary>
static class ClaimsPrincipalExtensions
{
/// <summary>
/// Parse the <see cref="Models.User"/> <see cref="Api.Models.EntityId.Id"/> out of a given authenticated <paramref name="principal"/>.
/// </summary>
/// <param name="principal">The <see cref="ClaimsPrincipal"/> to use to parse the user ID.</param>
/// <returns>The user ID in the <paramref name="principal"/> if it was present.</returns>
public static long? GetTgsUserId(this ClaimsPrincipal principal)
{
ArgumentNullException.ThrowIfNull(principal);
var userIdClaim = principal.FindFirst(JwtRegisteredClaimNames.Sub);
if (userIdClaim == default)
return null;
long userId;
try
{
userId = Int64.Parse(userIdClaim.Value, CultureInfo.InvariantCulture);
}
catch (Exception e)
{
throw new InvalidOperationException("Failed to parse user ID!", e);
}
return userId;
}
/// <summary>
/// Parse the <see cref="Models.User"/> <see cref="Api.Models.EntityId.Id"/> out of a given authenticated <paramref name="principal"/>.
/// </summary>
/// <param name="principal">The <see cref="ClaimsPrincipal"/> to use to parse the user ID.</param>
/// <returns>The user ID in the <paramref name="principal"/>.</returns>
public static long RequireTgsUserId(this ClaimsPrincipal principal)
=> principal.GetTgsUserId() ?? throw new InvalidOperationException($"Missing '{JwtRegisteredClaimNames.Sub}' claim!");
/// <summary>
/// Parse a <see cref="DateTimeOffset"/> out of a <see cref="Claim"/> in a given <paramref name="principal"/>.
/// </summary>
/// <param name="principal">The <see cref="ClaimsPrincipal"/> containing claims.</param>
/// <param name="claimName">The <see cref="Claim"/> name to parse from.</param>
/// <returns>The parsed <see cref="DateTimeOffset"/>.</returns>
public static DateTimeOffset ParseTime(this ClaimsPrincipal principal, string claimName)
{
ArgumentNullException.ThrowIfNull(principal);
ArgumentNullException.ThrowIfNull(claimName);
var claim = principal.FindFirst(claimName);
if (claim == null)
throw new InvalidOperationException($"Missing '{claimName}' claim!");
try
{
return new DateTimeOffset(
EpochTime.DateTime(
Int64.Parse(claim.Value, CultureInfo.InvariantCulture)));
}
catch (Exception ex)
{
throw new InvalidOperationException($"Failed to parse claim {claimName}: '{claim.Value}'!", ex);
}
}
}
}
@@ -0,0 +1,67 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using HotChocolate;
using Microsoft.AspNetCore.Authorization;
using Tgstation.Server.Host.Security;
namespace Tgstation.Server.Host.GraphQL
{
/// <summary>
/// Helper for authorization functionality related to GraphQL.
/// </summary>
static class AuthorizationHelper
{
/// <summary>
/// Create a new <see cref="GraphQLException"/> to be thrown when a forbidden error occurs.
/// </summary>
/// <param name="authorizationFailure">The <see cref="AuthorizationFailure"/>.</param>
/// <returns>A new <see cref="GraphQLException"/>.</returns>
public static GraphQLException ForbiddenGraphQLException(this AuthorizationFailure authorizationFailure)
{
ArgumentNullException.ThrowIfNull(authorizationFailure);
var messageBuilder = new StringBuilder("The current user is not authorized to access this resource.");
foreach (var failureReason in authorizationFailure.FailureReasons)
{
messageBuilder.AppendLine();
messageBuilder.Append("\t- ");
messageBuilder.Append(failureReason.Message);
}
return new(ErrorBuilder.New()
.SetMessage(messageBuilder.ToString()) // Copied from graphql-platform: AuthorizeMiddleware.cs
.SetCode(ErrorCodes.Authentication.NotAuthorized)
.Build());
}
/// <summary>
/// Evaluate a given set of <paramref name="authorizationRequirements"/>, throwing the approriate <see cref="GraphQLException"/> on failure.
/// </summary>
/// <param name="authorizationService">The authorization service to use.</param>
/// <param name="authorizationRequirements">The <see cref="IEnumerable{T}"/> of <see cref="IAuthorizationRequirement"/>s to evaluate..</param>
/// <param name="excludeUserSessionValidRequirement">If the <see cref="UserSessionValidRequirement"/> should be excluded.</param>
/// <returns>A <see cref="ValueTask"/> representing the running operation.</returns>
public static async ValueTask CheckGraphQLAuthorized(
this Security.IAuthorizationService authorizationService,
IEnumerable<IAuthorizationRequirement>? authorizationRequirements,
bool excludeUserSessionValidRequirement = false)
{
ArgumentNullException.ThrowIfNull(authorizationService);
ArgumentNullException.ThrowIfNull(authorizationRequirements);
if (!excludeUserSessionValidRequirement)
authorizationRequirements = UserSessionValidRequirement.InstanceAsEnumerable.Concat(authorizationRequirements);
var result = await authorizationService.AuthorizeAsync(authorizationRequirements);
if (!result.Succeeded)
throw result.Failure.ForbiddenGraphQLException();
}
}
}
@@ -1,5 +1,7 @@
using System.Linq;
using HotChocolate.Authorization;
using Tgstation.Server.Host.GraphQL.Types;
namespace Tgstation.Server.Host.GraphQL.Interfaces
@@ -18,7 +20,8 @@ namespace Tgstation.Server.Host.GraphQL.Interfaces
/// <summary>
/// Queries all <see cref="Instance"/>s in the <see cref="IGateway"/>.
/// </summary>
/// <returns>Queryable <see cref="Instance"/>s.</returns>
/// <returns>Queryable <see cref="Instance"/>s in the <see cref="IGateway"/>.</returns>
[Authorize]
IQueryable<Instance> Instances();
}
}
@@ -3,6 +3,7 @@ using System.Threading;
using System.Threading.Tasks;
using HotChocolate;
using HotChocolate.Authorization;
using HotChocolate.Types;
using Tgstation.Server.Host.Authority;
@@ -14,6 +15,7 @@ namespace Tgstation.Server.Host.GraphQL
/// Root type for GraphQL mutations.
/// </summary>
/// <remarks>Intentionally left mostly empty, use type extensions to properly scope operations to domains.</remarks>
[Authorize]
[GraphQLDescription(GraphQLDescription)]
public sealed class Mutation
{
@@ -28,6 +30,7 @@ namespace Tgstation.Server.Host.GraphQL
/// <param name="loginAuthority">The <see cref="IGraphQLAuthorityInvoker{TAuthority}"/> for the <see cref="ILoginAuthority"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="LoginResult"/>.</returns>
[AllowAnonymous]
[Error(typeof(ErrorMessageException))]
public ValueTask<LoginResult> Login(
[Service] IGraphQLAuthorityInvoker<ILoginAuthority> loginAuthority,
@@ -45,6 +48,7 @@ namespace Tgstation.Server.Host.GraphQL
/// <param name="loginAuthority">The <see cref="IGraphQLAuthorityInvoker{TAuthority}"/> for the <see cref="ILoginAuthority"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>An <see cref="OAuthGatewayLoginResult"/>.</returns>
[AllowAnonymous]
[Error(typeof(ErrorMessageException))]
public ValueTask<OAuthGatewayLoginResult> OAuthGateway(
[Service] IGraphQLAuthorityInvoker<ILoginAuthority> loginAuthority,
@@ -6,10 +6,8 @@ using HotChocolate;
using HotChocolate.Types;
using Tgstation.Server.Api.Models.Response;
using Tgstation.Server.Api.Rights;
using Tgstation.Server.Host.Authority;
using Tgstation.Server.Host.GraphQL.Scalars;
using Tgstation.Server.Host.Security;
namespace Tgstation.Server.Host.GraphQL.Mutations
{
@@ -25,7 +23,6 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
/// </summary>
/// <param name="administrationAuthority">The <see cref="IGraphQLAuthorityInvoker{TAuthority}"/> for the <see cref="IAdministrationAuthority"/>.</param>
/// <returns>A <see cref="ValueTask"/> representing the running operation.</returns>
[TgsGraphQLAuthorize<IAdministrationAuthority>(nameof(IAdministrationAuthority.TriggerServerRestart))]
[Error(typeof(ErrorMessageException))]
public async ValueTask<Query> RestartServerNode(
[Service] IGraphQLAuthorityInvoker<IAdministrationAuthority> administrationAuthority)
@@ -44,7 +41,6 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
/// <param name="administrationAuthority">The <see cref="IGraphQLAuthorityInvoker{TAuthority}"/> for the <see cref="IAdministrationAuthority"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask"/> representing the running operation.</returns>
[TgsGraphQLAuthorize(AdministrationRights.ChangeVersion)]
[Error(typeof(ErrorMessageException))]
public async ValueTask<Query> ChangeServerNodeVersionViaTrackedRepository(
Version targetVersion,
@@ -65,9 +61,8 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
/// <param name="administrationAuthority">The <see cref="IGraphQLAuthorityInvoker{TAuthority}"/> for the <see cref="IAdministrationAuthority"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A FileTicket that should be used to upload a zip containing the update data to the file transfer service.</returns>
[TgsGraphQLAuthorize(AdministrationRights.UploadVersion)]
[Error(typeof(ErrorMessageException))]
[GraphQLType<FileUploadTicketType>]
[Error(typeof(ErrorMessageException))]
public async ValueTask<string> ChangeServerNodeVersionViaUpload(
Version targetVersion,
[Service] IGraphQLAuthorityInvoker<IAdministrationAuthority> administrationAuthority,
@@ -10,7 +10,6 @@ using Tgstation.Server.Host.Authority;
using Tgstation.Server.Host.GraphQL.Mutations.Payloads;
using Tgstation.Server.Host.GraphQL.Types;
using Tgstation.Server.Host.Models.Transformers;
using Tgstation.Server.Host.Security;
namespace Tgstation.Server.Host.GraphQL.Mutations
{
@@ -43,7 +42,6 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
/// <param name="userGroupAuthority">The <see cref="IGraphQLAuthorityInvoker{TAuthority}"/> for the <see cref="IUserGroupAuthority"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>The created <see cref="UserGroup"/>.</returns>
[TgsGraphQLAuthorize<IUserGroupAuthority>(nameof(IUserGroupAuthority.Create))]
[Error(typeof(ErrorMessageException))]
public ValueTask<UserGroup> CreateUserGroup(
string name,
@@ -67,7 +65,6 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
/// <param name="userGroupAuthority">The <see cref="IGraphQLAuthorityInvoker{TAuthority}"/> for the <see cref="IUserGroupAuthority"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>The updated <see cref="UserGroup"/>.</returns>
[TgsGraphQLAuthorize<IUserGroupAuthority>(nameof(IUserGroupAuthority.Update))]
[Error(typeof(ErrorMessageException))]
public ValueTask<UserGroup> UpdateUserGroup(
[ID(nameof(UserGroup))] long id,
@@ -88,7 +85,6 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
/// <param name="userGroupAuthority">The <see cref="IGraphQLAuthorityInvoker{TAuthority}"/> for the <see cref="IUserGroupAuthority"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>The <see cref="Query"/> root.</returns>
[TgsGraphQLAuthorize<IUserGroupAuthority>(nameof(IUserGroupAuthority.DeleteEmpty))]
[Error(typeof(ErrorMessageException))]
public async ValueTask<Query> DeleteEmptyUserGroup(
[ID(nameof(UserGroup))] long id,
@@ -9,7 +9,6 @@ using HotChocolate.Types;
using HotChocolate.Types.Relay;
using Tgstation.Server.Api.Models.Request;
using Tgstation.Server.Api.Rights;
using Tgstation.Server.Host.Authority;
using Tgstation.Server.Host.GraphQL.Mutations.Payloads;
using Tgstation.Server.Host.GraphQL.Types;
@@ -38,9 +37,8 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
/// <param name="userAuthority">The <see cref="IGraphQLAuthorityInvoker{TAuthority}"/> for the <see cref="IUserAuthority"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>The created <see cref="User"/>.</returns>
[TgsGraphQLAuthorize<IUserAuthority>(nameof(IUserAuthority.Create))]
[Error(typeof(ErrorMessageException))]
public ValueTask<User> CreateUserByPasswordAndPermissionSet(
public ValueTask<UpdatedUser> CreateUserByPasswordAndPermissionSet(
string name,
string password,
bool? enabled,
@@ -54,7 +52,7 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
ArgumentNullException.ThrowIfNull(password);
ArgumentNullException.ThrowIfNull(userAuthority);
return userAuthority.InvokeTransformable<Models.User, User, UserGraphQLTransformer>(
return userAuthority.InvokeTransformable<Models.UpdatedUser, UpdatedUser, UpdatedUserGraphQLTransformer>(
authority => authority.Create(
new UserCreateRequest
{
@@ -99,9 +97,8 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
/// <param name="userAuthority">The <see cref="IGraphQLAuthorityInvoker{TAuthority}"/> for the <see cref="IUserAuthority"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>The created <see cref="User"/>.</returns>
[TgsGraphQLAuthorize<IUserAuthority>(nameof(IUserAuthority.Create))]
[Error(typeof(ErrorMessageException))]
public ValueTask<User> CreateUserByPasswordAndGroup(
public ValueTask<UpdatedUser> CreateUserByPasswordAndGroup(
string name,
string password,
bool? enabled,
@@ -115,7 +112,7 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
ArgumentNullException.ThrowIfNull(password);
ArgumentNullException.ThrowIfNull(userAuthority);
return userAuthority.InvokeTransformable<Models.User, User, UserGraphQLTransformer>(
return userAuthority.InvokeTransformable<Models.UpdatedUser, UpdatedUser, UpdatedUserGraphQLTransformer>(
authority => authority.Create(
new UserCreateRequest
{
@@ -156,9 +153,8 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
/// <param name="userAuthority">The <see cref="IGraphQLAuthorityInvoker{TAuthority}"/> for the <see cref="IUserAuthority"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>The created <see cref="User"/>.</returns>
[TgsGraphQLAuthorize<IUserAuthority>(nameof(IUserAuthority.Create))]
[Error(typeof(ErrorMessageException))]
public ValueTask<User> CreateUserByServiceConnectionAndPermissionSet(
public ValueTask<UpdatedUser> CreateUserByServiceConnectionAndPermissionSet(
string name,
IEnumerable<OAuthConnection>? oAuthConnections,
IEnumerable<OidcConnection>? oidcConnections,
@@ -171,7 +167,7 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
ArgumentNullException.ThrowIfNull(oAuthConnections);
ArgumentNullException.ThrowIfNull(userAuthority);
return userAuthority.InvokeTransformable<Models.User, User, UserGraphQLTransformer>(
return userAuthority.InvokeTransformable<Models.UpdatedUser, UpdatedUser, UpdatedUserGraphQLTransformer>(
authority => authority.Create(
new UserCreateRequest
{
@@ -215,9 +211,8 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
/// <param name="userAuthority">The <see cref="IGraphQLAuthorityInvoker{TAuthority}"/> for the <see cref="IUserAuthority"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>The created <see cref="User"/>.</returns>
[TgsGraphQLAuthorize<IUserAuthority>(nameof(IUserAuthority.Create))]
[Error(typeof(ErrorMessageException))]
public ValueTask<User> CreateUserByServiceConnectionAndGroup(
public ValueTask<UpdatedUser> CreateUserByServiceConnectionAndGroup(
string name,
IEnumerable<OAuthConnection> oAuthConnections,
IEnumerable<OidcConnection> oidcConnections,
@@ -230,7 +225,7 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
ArgumentNullException.ThrowIfNull(oAuthConnections);
ArgumentNullException.ThrowIfNull(userAuthority);
return userAuthority.InvokeTransformable<Models.User, User, UserGraphQLTransformer>(
return userAuthority.InvokeTransformable<Models.UpdatedUser, UpdatedUser, UpdatedUserGraphQLTransformer>(
authority => authority.Create(
new UserCreateRequest
{
@@ -271,9 +266,8 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
/// <param name="userAuthority">The <see cref="IGraphQLAuthorityInvoker{TAuthority}"/> for the <see cref="IUserAuthority"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>The created <see cref="User"/>.</returns>
[TgsGraphQLAuthorize<IUserAuthority>(nameof(IUserAuthority.Create))]
[Error(typeof(ErrorMessageException))]
public ValueTask<User> CreateUserBySystemIDAndPermissionSet(
public ValueTask<UpdatedUser> CreateUserBySystemIDAndPermissionSet(
string systemIdentifier,
bool? enabled,
IEnumerable<OAuthConnection>? oAuthConnections,
@@ -285,7 +279,7 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
ArgumentNullException.ThrowIfNull(systemIdentifier);
ArgumentNullException.ThrowIfNull(userAuthority);
return userAuthority.InvokeTransformable<Models.User, User, UserGraphQLTransformer>(
return userAuthority.InvokeTransformable<Models.UpdatedUser, UpdatedUser, UpdatedUserGraphQLTransformer>(
authority => authority.Create(
new UserCreateRequest
{
@@ -328,9 +322,8 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
/// <param name="userAuthority">The <see cref="IGraphQLAuthorityInvoker{TAuthority}"/> for the <see cref="IUserAuthority"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>The created <see cref="User"/>.</returns>
[TgsGraphQLAuthorize<IUserAuthority>(nameof(IUserAuthority.Create))]
[Error(typeof(ErrorMessageException))]
public ValueTask<User> CreateUserBySystemIDAndGroup(
public ValueTask<UpdatedUser> CreateUserBySystemIDAndGroup(
string systemIdentifier,
bool? enabled,
[ID(nameof(UserGroup))] long groupId,
@@ -342,7 +335,7 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
ArgumentNullException.ThrowIfNull(systemIdentifier);
ArgumentNullException.ThrowIfNull(userAuthority);
return userAuthority.InvokeTransformable<Models.User, User, UserGraphQLTransformer>(
return userAuthority.InvokeTransformable<Models.UpdatedUser, UpdatedUser, UpdatedUserGraphQLTransformer>(
authority => authority.Create(
new UserCreateRequest
{
@@ -379,9 +372,8 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
/// <param name="userAuthority">The <see cref="IGraphQLAuthorityInvoker{TAuthority}"/> for the <see cref="IUserAuthority"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>The updated current <see cref="User"/>.</returns>
[TgsGraphQLAuthorize(AdministrationRights.WriteUsers | AdministrationRights.EditOwnPassword)]
[Error(typeof(ErrorMessageException))]
public ValueTask<User> SetCurrentUserPassword(
public ValueTask<UpdatedUser> SetCurrentUserPassword(
string newPassword,
[Service] IAuthenticationContext authenticationContext,
[Service] IGraphQLAuthorityInvoker<IUserAuthority> userAuthority,
@@ -389,8 +381,8 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
{
ArgumentNullException.ThrowIfNull(newPassword);
ArgumentNullException.ThrowIfNull(userAuthority);
return userAuthority.InvokeTransformable<Models.User, User, UserGraphQLTransformer>(
async authority => await authority.Update(
return userAuthority.InvokeTransformable<Models.UpdatedUser, UpdatedUser, UpdatedUserGraphQLTransformer>(
authority => authority.Update(
new UserUpdateRequest
{
Id = authenticationContext.User.Id,
@@ -408,9 +400,8 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
/// <param name="userAuthority">The <see cref="IGraphQLAuthorityInvoker{TAuthority}"/> for the <see cref="IUserAuthority"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>The updated current <see cref="User"/>.</returns>
[TgsGraphQLAuthorize(AdministrationRights.WriteUsers | AdministrationRights.EditOwnServiceConnections)]
[Error(typeof(ErrorMessageException))]
public ValueTask<User> SetCurrentServiceConnections(
public ValueTask<UpdatedUser> SetCurrentServiceConnections(
IEnumerable<OAuthConnection>? newOAuthConnections,
IEnumerable<OidcConnection>? newOidcConnections,
[Service] IAuthenticationContext authenticationContext,
@@ -419,8 +410,8 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
{
ArgumentNullException.ThrowIfNull(newOAuthConnections);
ArgumentNullException.ThrowIfNull(userAuthority);
return userAuthority.InvokeTransformable<Models.User, User, UserGraphQLTransformer>(
async authority => await authority.Update(
return userAuthority.InvokeTransformable<Models.UpdatedUser, UpdatedUser, UpdatedUserGraphQLTransformer>(
authority => authority.Update(
new UserUpdateRequest
{
Id = authenticationContext.User.Id,
@@ -454,9 +445,8 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
/// <param name="userAuthority">The <see cref="IGraphQLAuthorityInvoker{TAuthority}"/> for the <see cref="IUserAuthority"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>The updated <see cref="User"/>.</returns>
[TgsGraphQLAuthorize(AdministrationRights.WriteUsers)]
[Error(typeof(ErrorMessageException))]
public ValueTask<User> UpdateUser(
public ValueTask<UpdatedUser> UpdateUser(
[ID(nameof(User))] long id,
string? casingOnlyNameChange,
string? newPassword,
@@ -493,9 +483,8 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
/// <param name="userAuthority">The <see cref="IGraphQLAuthorityInvoker{TAuthority}"/> for the <see cref="IUserAuthority"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>The updated <see cref="User"/>.</returns>
[TgsGraphQLAuthorize(AdministrationRights.WriteUsers)]
[Error(typeof(ErrorMessageException))]
public ValueTask<User> UpdateUserSetOwnedPermissionSet(
public ValueTask<UpdatedUser> UpdateUserSetOwnedPermissionSet(
[ID(nameof(User))] long id,
string? casingOnlyNameChange,
string? newPassword,
@@ -533,9 +522,8 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
/// <param name="userAuthority">The <see cref="IGraphQLAuthorityInvoker{TAuthority}"/> for the <see cref="IUserAuthority"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>The updated <see cref="User"/>.</returns>
[TgsGraphQLAuthorize(AdministrationRights.WriteUsers)]
[Error(typeof(ErrorMessageException))]
public ValueTask<User> UpdateUserSetGroup(
public ValueTask<UpdatedUser> UpdateUserSetGroup(
[ID(nameof(User))] long id,
string? casingOnlyNameChange,
string? newPassword,
@@ -574,7 +562,7 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
/// <param name="userAuthority">The <see cref="IGraphQLAuthorityInvoker{TAuthority}"/> for the <see cref="IUserAuthority"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>The updated <see cref="User"/>.</returns>
ValueTask<User> UpdateUserCore(
ValueTask<UpdatedUser> UpdateUserCore(
[ID(nameof(User))] long id,
string? casingOnlyNameChange,
string? newPassword,
@@ -585,8 +573,8 @@ namespace Tgstation.Server.Host.GraphQL.Mutations
IEnumerable<OidcConnection>? newOidcConnections,
IGraphQLAuthorityInvoker<IUserAuthority> userAuthority,
CancellationToken cancellationToken)
=> userAuthority.InvokeTransformable<Models.User, User, UserGraphQLTransformer>(
async authority => await authority.Update(
=> userAuthority.InvokeTransformable<Models.UpdatedUser, UpdatedUser, UpdatedUserGraphQLTransformer>(
authority => authority.Update(
new UserUpdateRequest
{
Id = id,
@@ -3,6 +3,7 @@ using System.Threading;
using System.Threading.Tasks;
using HotChocolate;
using HotChocolate.Authorization;
using HotChocolate.Execution;
using HotChocolate.Subscriptions;
using HotChocolate.Types;
@@ -16,6 +17,7 @@ namespace Tgstation.Server.Host.GraphQL
/// Root type for GraphQL subscriptions.
/// </summary>
/// <remarks>Intentionally left mostly empty, use type extensions to properly scope operations to domains.</remarks>
[Authorize(ApplyPolicy.Validation)] // See https://github.com/ChilliCream/graphql-platform/issues/6259
[GraphQLDescription(GraphQLDescription)]
public sealed class Subscription
{
@@ -63,7 +65,6 @@ namespace Tgstation.Server.Host.GraphQL
/// <param name="sessionInvalidationReason">The <see cref="SessionInvalidationReason"/> received from the publisher.</param>
/// <returns>The <see cref="SessionInvalidationReason"/>.</returns>
[Subscribe(With = nameof(SessionInvalidatedStream))]
[TgsGraphQLAuthorize]
public SessionInvalidationReason SessionInvalidated([EventMessage] SessionInvalidationReason sessionInvalidationReason)
=> sessionInvalidationReason;
}
@@ -8,7 +8,6 @@ using HotChocolate.Execution;
using HotChocolate.Types;
using HotChocolate.Types.Relay;
using Tgstation.Server.Api.Rights;
using Tgstation.Server.Host.GraphQL.Types;
using Tgstation.Server.Host.Security;
@@ -68,7 +67,6 @@ namespace Tgstation.Server.Host.GraphQL.Subscriptions
/// <param name="user">The <see cref="Models.User"/> received from the publisher.</param>
/// <returns>The updated <see cref="User"/>.</returns>
[Subscribe(With = nameof(UserUpdatedStream))]
[TgsGraphQLAuthorize(AdministrationRights.ReadUsers)]
public User UserUpdated([EventMessage] User user)
{
ArgumentNullException.ThrowIfNull(user);
@@ -98,7 +96,6 @@ namespace Tgstation.Server.Host.GraphQL.Subscriptions
/// <param name="user">The <see cref="Models.User"/> received from the publisher.</param>
/// <returns>The updated <see cref="User"/>.</returns>
[Subscribe(With = nameof(CurrentUserUpdatedStream))]
[TgsGraphQLAuthorize]
public User CurrentUserUpdated([EventMessage] User user)
{
ArgumentNullException.ThrowIfNull(user);
@@ -1,7 +1,9 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using HotChocolate;
using HotChocolate.Authorization;
using Microsoft.Extensions.Options;
@@ -14,6 +16,7 @@ using Tgstation.Server.Host.GraphQL.Types.OAuth;
using Tgstation.Server.Host.Properties;
using Tgstation.Server.Host.Security;
using Tgstation.Server.Host.Security.OAuth;
using Tgstation.Server.Host.Security.RightsEvaluation;
using Tgstation.Server.Host.System;
namespace Tgstation.Server.Host.GraphQL.Types
@@ -23,70 +26,117 @@ namespace Tgstation.Server.Host.GraphQL.Types
/// </summary>
public sealed class GatewayInformation
{
/// <summary>
/// Access the GraphQL API <see cref="global::System.Version"/> without auth.
/// </summary>
static Version GraphQLApiVersionNoAuth { get; } = global::System.Version.Parse(MasterVersionsAttribute.Instance.RawGraphQLVersion);
/// <summary>
/// Gets the major GraphQL API <see cref="global::System.Version"/> number of the <see cref="SwarmNode"/>.
/// </summary>
public int MajorGraphQLApiVersion => GraphQLApiVersionNoAuth.Major;
/// <summary>
/// Gets the minimum valid password length for TGS users.
/// </summary>
/// <param name="authorizationService">The <see cref="IAuthorizationService"/> to use.</param>
/// <param name="generalConfigurationOptions">The <see cref="IOptionsSnapshot{TOptions}"/> containing the <see cref="GeneralConfiguration"/>.</param>
/// <returns>A <see cref="uint"/> specifying the minimumn valid password length for TGS users.</returns>
[TgsGraphQLAuthorize(AdministrationRights.WriteUsers | AdministrationRights.EditOwnPassword)]
public uint MinimumPasswordLength(
[Authorize]
public async ValueTask<uint> MinimumPasswordLength(
[Service] IAuthorizationService authorizationService,
[Service] IOptionsSnapshot<GeneralConfiguration> generalConfigurationOptions)
{
ArgumentNullException.ThrowIfNull(authorizationService);
ArgumentNullException.ThrowIfNull(generalConfigurationOptions);
await authorizationService.CheckGraphQLAuthorized(
[new OrRightsConditional<AdministrationRights>(
new FlagRightsConditional<AdministrationRights>(AdministrationRights.WriteUsers),
new FlagRightsConditional<AdministrationRights>(AdministrationRights.EditOwnPassword))]);
return generalConfigurationOptions.Value.MinimumPasswordLength;
}
/// <summary>
/// Gets the maximum allowed attached instances for the <see cref="SwarmNode"/>.
/// </summary>
/// <param name="authorizationService">The <see cref="IAuthorizationService"/> to use.</param>
/// <param name="generalConfigurationOptions">The <see cref="IOptionsSnapshot{TOptions}"/> containing the <see cref="GeneralConfiguration"/>.</param>
/// <returns>A <see cref="uint"/> specifying the maximum allowed attached instances for the <see cref="SwarmNode"/>.</returns>
[TgsGraphQLAuthorize(InstanceManagerRights.Create)]
public uint InstanceLimit(
[Authorize]
public async ValueTask<uint> InstanceLimit(
[Service] IAuthorizationService authorizationService,
[Service] IOptionsSnapshot<GeneralConfiguration> generalConfigurationOptions)
{
ArgumentNullException.ThrowIfNull(authorizationService);
ArgumentNullException.ThrowIfNull(generalConfigurationOptions);
await authorizationService.CheckGraphQLAuthorized(
[new FlagRightsConditional<InstanceManagerRights>(InstanceManagerRights.Create)]);
return generalConfigurationOptions.Value.InstanceLimit;
}
/// <summary>
/// Gets the maximum allowed registered <see cref="User"/>s for the <see cref="ServerSwarm"/>.
/// </summary>
/// <param name="authorizationService">The <see cref="IAuthorizationService"/> to use.</param>
/// <param name="generalConfigurationOptions">The <see cref="IOptionsSnapshot{TOptions}"/> containing the <see cref="GeneralConfiguration"/>.</param>
/// <returns>A <see cref="uint"/> specifying the maximum allowed registered users for the <see cref="ServerSwarm"/>.</returns>
/// <remarks>This limit only applies to user creation attempts made via the current <see cref="SwarmNode"/>.</remarks>
[TgsGraphQLAuthorize(AdministrationRights.WriteUsers)]
public uint UserLimit(
[Authorize]
public async ValueTask<uint> UserLimit(
[Service] IAuthorizationService authorizationService,
[Service] IOptionsSnapshot<GeneralConfiguration> generalConfigurationOptions)
{
ArgumentNullException.ThrowIfNull(authorizationService);
ArgumentNullException.ThrowIfNull(generalConfigurationOptions);
await authorizationService.CheckGraphQLAuthorized(
[new FlagRightsConditional<AdministrationRights>(AdministrationRights.WriteUsers)]);
return generalConfigurationOptions.Value.UserLimit;
}
/// <summary>
/// Gets the maximum allowed registered <see cref="UserGroup"/>s for the <see cref="ServerSwarm"/>.
/// </summary>
/// <param name="authorizationService">The <see cref="IAuthorizationService"/> to use.</param>
/// <param name="generalConfigurationOptions">The <see cref="IOptionsSnapshot{TOptions}"/> containing the <see cref="GeneralConfiguration"/>.</param>
/// <returns>A <see cref="uint"/> specifying the maximum allowed registered <see cref="UserGroup"/>s for the <see cref="ServerSwarm"/>.</returns>
/// <remarks>This limit only applies to <see cref="UserGroup"/> creation attempts made via the current <see cref="SwarmNode"/>.</remarks>
[TgsGraphQLAuthorize(AdministrationRights.WriteUsers)]
public uint UserGroupLimit(
[Authorize]
public async ValueTask<uint> UserGroupLimit(
[Service] IAuthorizationService authorizationService,
[Service] IOptionsSnapshot<GeneralConfiguration> generalConfigurationOptions)
{
ArgumentNullException.ThrowIfNull(authorizationService);
ArgumentNullException.ThrowIfNull(generalConfigurationOptions);
await authorizationService.CheckGraphQLAuthorized(
[new FlagRightsConditional<AdministrationRights>(AdministrationRights.WriteUsers)]);
return generalConfigurationOptions.Value.UserGroupLimit;
}
/// <summary>
/// Gets the locations <see cref="Instance"/>s may be created or attached from if there are restrictions.
/// </summary>
/// <param name="authorizationService">The <see cref="IAuthorizationService"/> to use.</param>
/// <param name="generalConfigurationOptions">The <see cref="IOptionsSnapshot{TOptions}"/> containing the <see cref="GeneralConfiguration"/>.</param>
/// <returns>The locations <see cref="Instance"/>s may be created or attached from if there are restrictions, <see langword="null"/> otherwise.</returns>
[TgsGraphQLAuthorize(InstanceManagerRights.Create | InstanceManagerRights.Relocate)]
public IReadOnlyCollection<string>? ValidInstancePaths(
[Authorize]
public async ValueTask<IReadOnlyCollection<string>?> ValidInstancePaths(
[Service] IAuthorizationService authorizationService,
[Service] IOptionsSnapshot<GeneralConfiguration> generalConfigurationOptions)
{
ArgumentNullException.ThrowIfNull(authorizationService);
ArgumentNullException.ThrowIfNull(generalConfigurationOptions);
await authorizationService.CheckGraphQLAuthorized(
[new OrRightsConditional<InstanceManagerRights>(
new FlagRightsConditional<InstanceManagerRights>(InstanceManagerRights.Create),
new FlagRightsConditional<InstanceManagerRights>(InstanceManagerRights.Relocate))]);
return generalConfigurationOptions.Value.ValidInstancePaths;
}
@@ -95,26 +145,29 @@ namespace Tgstation.Server.Host.GraphQL.Types
/// </summary>
/// <param name="platformIdentifier">The <see cref="IPlatformIdentifier"/> to use.</param>
/// <returns><see langword="true"/> if the <see cref="SwarmNode"/> runs on a Windows operating system, <see langword="false"/> otherwise.</returns>
[TgsGraphQLAuthorize]
[Authorize]
public bool WindowsHost(
[Service] IPlatformIdentifier platformIdentifier)
{
ArgumentNullException.ThrowIfNull(platformIdentifier);
return platformIdentifier.IsWindows;
}
/// <summary>
/// Gets the swarm protocol <see cref="Version"/>.
/// </summary>
[TgsGraphQLAuthorize]
public Version SwarmProtocolVersion => global::System.Version.Parse(MasterVersionsAttribute.Instance.RawSwarmProtocolVersion);
/// <returns>The swarm protocol <see cref="global::System.Version"/>.</returns>
[Authorize]
public Version SwarmProtocolVersion()
=> global::System.Version.Parse(MasterVersionsAttribute.Instance.RawSwarmProtocolVersion);
/// <summary>
/// Gets the <see cref="global::System.Version"/> of tgstation-server the <see cref="SwarmNode"/> is running.
/// </summary>
/// <param name="assemblyInformationProvider">The <see cref="IAssemblyInformationProvider"/> to use.</param>
/// <returns>The <see cref="global::System.Version"/> of tgstation-server the <see cref="SwarmNode"/> is running.</returns>
[TgsGraphQLAuthorize]
[Authorize]
public Version Version(
[Service] IAssemblyInformationProvider assemblyInformationProvider)
{
@@ -122,28 +175,28 @@ namespace Tgstation.Server.Host.GraphQL.Types
return assemblyInformationProvider.Version;
}
/// <summary>
/// Gets the major GraphQL API <see cref="global::System.Version"/> number of the <see cref="SwarmNode"/>.
/// </summary>
public int MajorGraphQLApiVersion => GraphQLApiVersion.Major;
/// <summary>
/// Gets the GraphQL API <see cref="global::System.Version"/> of the <see cref="SwarmNode"/>.
/// </summary>
[TgsGraphQLAuthorize]
public Version GraphQLApiVersion => global::System.Version.Parse(MasterVersionsAttribute.Instance.RawGraphQLVersion);
/// <returns>The GraphQL API <see cref="global::System.Version"/> of the <see cref="SwarmNode"/>.</returns>
[Authorize]
public Version GraphQLApiVersion()
=> GraphQLApiVersionNoAuth;
/// <summary>
/// Gets the REST API <see cref="global::System.Version"/> of the <see cref="SwarmNode"/>.
/// </summary>
[TgsGraphQLAuthorize]
public Version ApiVersion => ApiHeaders.Version;
/// <returns>The REST API <see cref="global::System.Version"/> of the <see cref="SwarmNode"/>.</returns>
[Authorize]
public Version ApiVersion() => ApiHeaders.Version;
/// <summary>
/// Gets the DMAPI interop <see cref="global::System.Version"/> the <see cref="SwarmNode"/> uses.
/// </summary>
[TgsGraphQLAuthorize]
public Version DMApiVersion => DMApiConstants.InteropVersion;
/// <returns>Yhe DMAPI interop <see cref="global::System.Version"/> the <see cref="SwarmNode"/> uses.</returns>
[Authorize]
public Version DMApiVersion()
=> DMApiConstants.InteropVersion;
/// <summary>
/// Gets the information needed to perform open authentication with the <see cref="SwarmNode"/>.
@@ -1,20 +0,0 @@
using System;
using System.Linq;
using Tgstation.Server.Host.GraphQL.Interfaces;
namespace Tgstation.Server.Host.GraphQL.Types
{
/// <summary>
/// <see cref="IGateway"/> for accessing remote <see cref="SwarmNode"/>s.
/// </summary>
/// <remarks>This is currently unimplemented.</remarks>
public sealed class RemoteGateway : IGateway
{
/// <inheritdoc />
public GatewayInformation Information() => throw new NotImplementedException();
/// <inheritdoc />
public IQueryable<Instance> Instances() => throw new NotImplementedException();
}
}
@@ -3,13 +3,14 @@ using System.Collections.Generic;
using System.Linq;
using HotChocolate;
using HotChocolate.Authorization;
using Microsoft.Extensions.Options;
using Tgstation.Server.Api.Models;
using Tgstation.Server.Host.Configuration;
using Tgstation.Server.Host.GraphQL.Interfaces;
using Tgstation.Server.Host.Properties;
using Tgstation.Server.Host.Security;
using Tgstation.Server.Host.Swarm;
namespace Tgstation.Server.Host.GraphQL.Types
@@ -19,17 +20,27 @@ namespace Tgstation.Server.Host.GraphQL.Types
/// </summary>
public sealed class ServerSwarm
{
/// <summary>
/// Access all instances in the <see cref="ServerSwarm"/>.
/// </summary>
/// <returns>Queryable <see cref="Instance"/> in the <see cref="ServerSwarm"/>.</returns>
[Authorize]
public IQueryable<Instance> Instances()
=> throw new ErrorMessageException(ErrorCode.RemoteGatewaysNotImplemented);
/// <summary>
/// Gets the swarm protocol major version in use.
/// </summary>
[TgsGraphQLAuthorize]
public int ProtocolMajorVersion => Version.Parse(MasterVersionsAttribute.Instance.RawSwarmProtocolVersion).Major;
/// <returns>The swarm protocol major version in use.</returns>
[Authorize]
public int ProtocolMajorVersion()
=> Version.Parse(MasterVersionsAttribute.Instance.RawSwarmProtocolVersion).Major;
/// <summary>
/// Gets the swarm's <see cref="Types.Users"/>.
/// </summary>
/// <returns>A new <see cref="Types.Users"/>.</returns>
[TgsGraphQLAuthorize]
[Authorize]
public Users Users() => new();
/// <summary>
@@ -37,7 +48,7 @@ namespace Tgstation.Server.Host.GraphQL.Types
/// </summary>
/// <param name="swarmConfigurationOptions">The <see cref="IOptionsSnapshot{TOptions}"/> containing the current <see cref="SwarmConfiguration"/>.</param>
/// <param name="swarmService">The <see cref="ISwarmService"/> to use.</param>
/// <returns>A new <see cref="SwarmNode"/> for the local node if it is part of a swarm, <see langword="null"/> otherwise.</returns>
/// <returns>The <see cref="SwarmNode"/> for the local node if it is part of a swarm, a <see cref="StandaloneNode"/> otherwise.</returns>
public IServerNode CurrentNode(
[Service] IOptionsSnapshot<SwarmConfiguration> swarmConfigurationOptions,
[Service] ISwarmService swarmService)
@@ -45,11 +56,12 @@ namespace Tgstation.Server.Host.GraphQL.Types
ArgumentNullException.ThrowIfNull(swarmConfigurationOptions);
ArgumentNullException.ThrowIfNull(swarmService);
var ourIdentifier = swarmConfigurationOptions.Value.Identifier;
if (ourIdentifier == null)
if (swarmConfigurationOptions.Value.PrivateKey == null)
return new StandaloneNode();
return (IServerNode?)SwarmNode.GetSwarmNode(ourIdentifier, swarmService) ?? new StandaloneNode();
return ((IServerNode?)SwarmNode.GetSwarmNode(
swarmConfigurationOptions.Value.Identifier!,
swarmService)) ?? new StandaloneNode();
}
/// <summary>
@@ -57,7 +69,7 @@ namespace Tgstation.Server.Host.GraphQL.Types
/// </summary>
/// <param name="swarmService">The <see cref="ISwarmService"/> to use.</param>
/// <returns>A <see cref="List{T}"/> of <see cref="SwarmNode"/>s if the local node is part of a swarm, <see langword="null"/> otherwise.</returns>
[TgsGraphQLAuthorize]
[Authorize]
public List<SwarmNode>? Nodes(
[Service] ISwarmService swarmService)
{
@@ -69,7 +81,7 @@ namespace Tgstation.Server.Host.GraphQL.Types
/// Gets the <see cref="Types.UpdateInformation"/> for the swarm.
/// </summary>
/// <returns>A new <see cref="Types.UpdateInformation"/>.</returns>
[TgsGraphQLAuthorize]
[Authorize]
public UpdateInformation UpdateInformation() => new();
}
}
@@ -2,6 +2,7 @@
using System.Linq;
using HotChocolate;
using HotChocolate.Authorization;
using HotChocolate.Types.Relay;
using Microsoft.Extensions.Options;
@@ -10,7 +11,6 @@ using Tgstation.Server.Api.Models;
using Tgstation.Server.Api.Models.Internal;
using Tgstation.Server.Host.Configuration;
using Tgstation.Server.Host.GraphQL.Interfaces;
using Tgstation.Server.Host.Security;
using Tgstation.Server.Host.Swarm;
namespace Tgstation.Server.Host.GraphQL.Types
@@ -53,13 +53,14 @@ namespace Tgstation.Server.Host.GraphQL.Types
/// <param name="identifier">The <see cref="Identifier"/>.</param>
/// <param name="swarmService">The <see cref="ISwarmService"/> to load from.</param>
/// <returns>A new <see cref="SwarmNode"/> with the matching <paramref name="identifier"/> if found, <see langword="null"/> otherwise.</returns>
[TgsGraphQLAuthorize]
[Authorize]
public static SwarmNode? GetSwarmNode(
string identifier,
[Service] ISwarmService swarmService)
{
ArgumentNullException.ThrowIfNull(identifier);
ArgumentNullException.ThrowIfNull(swarmService);
var info = swarmService
.GetSwarmServers()
?.FirstOrDefault(x => x.Identifier == identifier);
@@ -0,0 +1,45 @@
using System;
using HotChocolate.Types.Relay;
using Tgstation.Server.Host.Models;
using Tgstation.Server.Host.Models.Transformers;
namespace Tgstation.Server.Host.GraphQL.Types
{
/// <summary>
/// Represents a <see cref="User"/> that has been updated.
/// </summary>
public sealed class UpdatedUser
{
/// <summary>
/// The <see cref="User"/>'s <see cref="Entity.Id"/>.
/// </summary>
[ID(nameof(Types.User))]
public long Id { get; }
/// <summary>
/// The <see cref="Types.User"/>, if was authorized to be read.
/// </summary>
public User? User { get; }
/// <summary>
/// Initializes a new instance of the <see cref="UpdatedUser"/> class.
/// </summary>
/// <param name="user">The value of <see cref="User"/> containing the <see cref="Id"/>.</param>
public UpdatedUser(Models.User user)
: this((user ?? throw new ArgumentNullException(nameof(user))).Require(u => u.Id))
{
User = ((IApiTransformable<Models.User, User, UserGraphQLTransformer>)user).ToApi();
}
/// <summary>
/// Initializes a new instance of the <see cref="UpdatedUser"/> class.
/// </summary>
/// <param name="id">The value of <see cref="Id"/>.</param>
public UpdatedUser(long id)
{
Id = id;
}
}
}
@@ -3,13 +3,13 @@ using System.Threading;
using System.Threading.Tasks;
using HotChocolate;
using HotChocolate.Authorization;
using HotChocolate.Types.Relay;
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
{
@@ -17,6 +17,7 @@ namespace Tgstation.Server.Host.GraphQL.Types
/// A user registered in the server.
/// </summary>
[Node]
[Authorize]
public sealed class User : NamedEntity, IUserName
{
/// <summary>
@@ -58,7 +59,6 @@ namespace Tgstation.Server.Host.GraphQL.Types
/// <param name="userAuthority">The <see cref="IGraphQLAuthorityInvoker{TAuthority}"/> for the <see cref="IUserAuthority"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask"/> resulting in the queried <see cref="User"/>, if present.</returns>
[TgsGraphQLAuthorize]
public static ValueTask<User?> GetUser(
long id,
[Service] IGraphQLAuthorityInvoker<IUserAuthority> userAuthority,
@@ -4,13 +4,13 @@ using System.Threading;
using System.Threading.Tasks;
using HotChocolate;
using HotChocolate.Authorization;
using HotChocolate.Data;
using HotChocolate.Types;
using HotChocolate.Types.Relay;
using Tgstation.Server.Host.Authority;
using Tgstation.Server.Host.Models.Transformers;
using Tgstation.Server.Host.Security;
namespace Tgstation.Server.Host.GraphQL.Types
{
@@ -18,6 +18,7 @@ namespace Tgstation.Server.Host.GraphQL.Types
/// Represents a group of <see cref="User"/>s.
/// </summary>
[Node]
[Authorize]
public sealed class UserGroup : NamedEntity
{
/// <summary>
@@ -27,7 +28,6 @@ namespace Tgstation.Server.Host.GraphQL.Types
/// <param name="userGroupAuthority">The <see cref="IGraphQLAuthorityInvoker{TAuthority}"/> for the <see cref="IUserGroupAuthority"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask"/> resulting in the queried <see cref="User"/>, if present.</returns>
[TgsGraphQLAuthorize]
public static ValueTask<UserGroup?> GetUserGroup(
long id,
[Service] IGraphQLAuthorityInvoker<IUserGroupAuthority> userGroupAuthority,
@@ -62,15 +62,14 @@ namespace Tgstation.Server.Host.GraphQL.Types
[UsePaging]
[UseFiltering]
[UseSorting]
[TgsGraphQLAuthorize<IUserGroupAuthority>(nameof(IUserAuthority.Queryable))]
public IQueryable<User> QueryableUsersByGroup(
public async ValueTask<IQueryable<User>> QueryableUsersByGroup(
[Service] IGraphQLAuthorityInvoker<IUserAuthority> userAuthority)
{
ArgumentNullException.ThrowIfNull(userAuthority);
var dtoQueryable = userAuthority.InvokeTransformableQueryable<Models.User, User, UserGraphQLTransformer>(
var dtoQueryable = await userAuthority.InvokeTransformableQueryable<Models.User, User, UserGraphQLTransformer>(
authority => authority
.Queryable(false)
.Where(user => user.GroupId == Id));
.Queryable(false),
queryable => queryable.Where(user => user.GroupId == Id));
return dtoQueryable;
}
}
@@ -4,31 +4,34 @@ using System.Threading;
using System.Threading.Tasks;
using HotChocolate;
using HotChocolate.Authorization;
using HotChocolate.Data;
using HotChocolate.Types;
using HotChocolate.Types.Relay;
using Tgstation.Server.Host.Authority;
using Tgstation.Server.Host.Models.Transformers;
using Tgstation.Server.Host.Security;
namespace Tgstation.Server.Host.GraphQL.Types
{
/// <summary>
/// Wrapper for accessing <see cref="UserGroup"/>s.
/// </summary>
[Authorize]
public sealed class UserGroups
{
/// <summary>
/// Gets the current <see cref="User"/>.
/// </summary>
/// <param name="userGroupAuthority">The <see cref="IGraphQLAuthorityInvoker{TAuthority}"/> for the <see cref="IUserGroupAuthority"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the current <see cref="User"/>'s <see cref="UserGroup"/>.</returns>
public ValueTask<UserGroup?> Current(
[Service] IGraphQLAuthorityInvoker<IUserGroupAuthority> userGroupAuthority)
[Service] IGraphQLAuthorityInvoker<IUserGroupAuthority> userGroupAuthority,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(userGroupAuthority);
return userGroupAuthority.InvokeTransformableAllowMissing<Models.UserGroup, UserGroup, UserGroupGraphQLTransformer>(authority => authority.Read());
return userGroupAuthority.InvokeTransformableAllowMissing<Models.UserGroup, UserGroup, UserGroupGraphQLTransformer>(authority => authority.Read(cancellationToken));
}
/// <summary>
@@ -38,7 +41,6 @@ namespace Tgstation.Server.Host.GraphQL.Types
/// <param name="userGroupAuthority">The <see cref="IGraphQLAuthorityInvoker{TAuthority}"/> for the <see cref="IUserGroupAuthority"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>The <see cref="UserGroup"/> represented by <paramref name="id"/>, if any.</returns>
[TgsGraphQLAuthorize<IUserAuthority>(nameof(IUserGroupAuthority.GetId))]
public ValueTask<UserGroup?> ById(
[ID(nameof(UserGroup))] long id,
[Service] IGraphQLAuthorityInvoker<IUserGroupAuthority> userGroupAuthority,
@@ -53,12 +55,12 @@ namespace Tgstation.Server.Host.GraphQL.Types
[UsePaging]
[UseFiltering]
[UseSorting]
[TgsGraphQLAuthorize<IUserGroupAuthority>(nameof(IUserGroupAuthority.Queryable))]
public IQueryable<UserGroup> QueryableGroups(
public async ValueTask<IQueryable<UserGroup>> QueryableGroups(
[Service] IGraphQLAuthorityInvoker<IUserGroupAuthority> userGroupAuthority)
{
ArgumentNullException.ThrowIfNull(userGroupAuthority);
var dtoQueryable = userGroupAuthority.InvokeTransformableQueryable<Models.UserGroup, UserGroup, UserGroupGraphQLTransformer>(authority => authority.Queryable(false));
var dtoQueryable = await userGroupAuthority.InvokeTransformableQueryable<Models.UserGroup, UserGroup, UserGroupGraphQLTransformer>(
authority => authority.Queryable(false));
return dtoQueryable;
}
@@ -71,16 +73,15 @@ namespace Tgstation.Server.Host.GraphQL.Types
[UsePaging]
[UseFiltering]
[UseSorting]
[TgsGraphQLAuthorize<IUserGroupAuthority>(nameof(IUserAuthority.Queryable))]
public IQueryable<User> QueryableUsersByGroupId(
public async ValueTask<IQueryable<User>> QueryableUsersByGroupId(
[ID(nameof(UserGroup))]long groupId,
[Service] IGraphQLAuthorityInvoker<IUserAuthority> userAuthority)
{
ArgumentNullException.ThrowIfNull(userAuthority);
var dtoQueryable = userAuthority.InvokeTransformableQueryable<Models.User, User, UserGraphQLTransformer>(
var dtoQueryable = await userAuthority.InvokeTransformableQueryable<Models.User, User, UserGraphQLTransformer>(
authority => authority
.Queryable(false)
.Where(user => user.GroupId == groupId));
.Queryable(false),
queryable => queryable.Where(user => user.GroupId == groupId));
return dtoQueryable;
}
}
@@ -4,12 +4,12 @@ using System.Threading;
using System.Threading.Tasks;
using HotChocolate;
using HotChocolate.Authorization;
using HotChocolate.Types.Relay;
using Tgstation.Server.Host.Authority;
using Tgstation.Server.Host.GraphQL.Interfaces;
using Tgstation.Server.Host.Models.Transformers;
using Tgstation.Server.Host.Security;
namespace Tgstation.Server.Host.GraphQL.Types
{
@@ -17,6 +17,7 @@ namespace Tgstation.Server.Host.GraphQL.Types
/// A <see cref="User"/> with limited fields.
/// </summary>
[Node]
[Authorize]
public sealed class UserName : NamedEntity, IUserName
{
/// <summary>
@@ -26,7 +27,6 @@ namespace Tgstation.Server.Host.GraphQL.Types
/// <param name="userAuthority">The <see cref="IGraphQLAuthorityInvoker{TAuthority}"/> for the <see cref="IUserAuthority"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask"/> resulting in the queried <see cref="UserName"/>, if present.</returns>
[TgsGraphQLAuthorize]
public static ValueTask<UserName?> GetUserName(
long id,
[Service] IGraphQLAuthorityInvoker<IUserAuthority> userAuthority,
@@ -13,7 +13,6 @@ using Microsoft.Extensions.Options;
using Tgstation.Server.Host.Authority;
using Tgstation.Server.Host.Configuration;
using Tgstation.Server.Host.Models.Transformers;
using Tgstation.Server.Host.Security;
#pragma warning disable CA1724 // conflict with GitLabApiClient.Models.Users. They can fuck off
@@ -48,7 +47,6 @@ namespace Tgstation.Server.Host.GraphQL.Types
/// <param name="userAuthority">The <see cref="IGraphQLAuthorityInvoker{TAuthority}"/> for the <see cref="IUserAuthority"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the current <see cref="User"/>.</returns>
[TgsGraphQLAuthorize<IUserAuthority>(nameof(IUserAuthority.Read))]
public ValueTask<User> Current(
[Service] IGraphQLAuthorityInvoker<IUserAuthority> userAuthority,
CancellationToken cancellationToken)
@@ -65,7 +63,6 @@ namespace Tgstation.Server.Host.GraphQL.Types
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>The <see cref="User"/> represented by <paramref name="id"/>, if any.</returns>
[Error(typeof(ErrorMessageException))]
[TgsGraphQLAuthorize<IUserAuthority>(nameof(IUserAuthority.GetId))]
public ValueTask<User?> ById(
[ID(nameof(User))] long id,
[Service] IGraphQLAuthorityInvoker<IUserAuthority> userAuthority,
@@ -80,12 +77,12 @@ namespace Tgstation.Server.Host.GraphQL.Types
[UsePaging]
[UseFiltering]
[UseSorting]
[TgsGraphQLAuthorize<IUserAuthority>(nameof(IUserAuthority.Queryable))]
public IQueryable<User> QueryableUsers(
public async ValueTask<IQueryable<User>> QueryableUsers(
[Service] IGraphQLAuthorityInvoker<IUserAuthority> userAuthority)
{
ArgumentNullException.ThrowIfNull(userAuthority);
var dtoQueryable = userAuthority.InvokeTransformableQueryable<Models.User, User, UserGraphQLTransformer>(authority => authority.Queryable(false));
var dtoQueryable = await userAuthority.InvokeTransformableQueryable<Models.User, User, UserGraphQLTransformer>(
authority => authority.Queryable(false));
return dtoQueryable;
}
}
@@ -7,6 +7,7 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Tgstation.Server.Host.Jobs;
using Tgstation.Server.Host.Utils;
namespace Tgstation.Server.Host.IO
@@ -258,8 +259,18 @@ namespace Tgstation.Server.Host.IO
=> ResolvePath(CurrentDirectory);
/// <inheritdoc />
public virtual string ResolvePath(string path)
=> fileSystem.Path.GetFullPath(path ?? throw new ArgumentNullException(nameof(path)));
public string ResolvePath(string path)
{
if (fileSystem.Path.IsPathRooted(path ?? throw new ArgumentNullException(nameof(path))))
{
// Important to evaluate the path anyway to normalize front slashes to backslashes on Windows
// Some tools (looking at you netsh.exe) bitch if you pass them forward slashes as directory separators
// Can't rely on ResolvePathCore to do this either because its contract stipulates a relative path
return fileSystem.Path.GetFullPath(path);
}
return ResolvePathCore(path);
}
/// <inheritdoc />
public async ValueTask WriteAllBytes(string path, ReadOnlyMemory<byte> contents, CancellationToken cancellationToken)
@@ -335,26 +346,46 @@ namespace Tgstation.Server.Host.IO
TaskScheduler.Current);
/// <inheritdoc />
public Task ZipToDirectory(string path, Stream zipFile, CancellationToken cancellationToken) => Task.Factory.StartNew(
() =>
{
path = ResolvePath(path);
ArgumentNullException.ThrowIfNull(zipFile);
public async ValueTask ZipToDirectory(string path, Stream zipFile, CancellationToken cancellationToken)
{
path = ResolvePath(path);
ArgumentNullException.ThrowIfNull(zipFile);
#if NET11_0_OR_GREATER
#error Check if zip file seeking has been addressed. See https://github.com/tgstation/tgstation-server/issues/1531
#endif
// ZipArchive does a synchronous copy on unseekable streams we want to avoid
if (!zipFile.CanSeek)
throw new ArgumentException("Stream does not support seeking!", nameof(zipFile));
// ZipArchive does a synchronous copy on unseekable streams we want to avoid
if (!zipFile.CanSeek)
throw new ArgumentException("Stream does not support seeking!", nameof(zipFile));
using var archive = new ZipArchive(zipFile, ZipArchiveMode.Read, true);
archive.ExtractToDirectory(path);
},
cancellationToken,
BlockingTaskCreationOptions,
TaskScheduler.Current);
using var archive = new ZipArchive(zipFile, ZipArchiveMode.Read, true);
// start async context
await Task.Yield();
foreach (var entry in archive.Entries)
{
var entryPath = fileSystem.Path.Combine(path, entry.FullName);
if (string.IsNullOrEmpty(entry.Name))
{
fileSystem.Directory.CreateDirectory(entryPath);
continue;
}
var directoryPath = fileSystem.Path.GetDirectoryName(entryPath);
if (directoryPath == null)
{
throw new JobException("Zip archive concatenation resulted in a null directory path!");
}
fileSystem.Directory.CreateDirectory(directoryPath);
using var entryStream = entry.Open();
using var outputStream = fileSystem.File.Create(entryPath);
await entryStream.CopyToAsync(outputStream, cancellationToken);
}
}
/// <inheritdoc />
public bool PathContainsParentAccess(string path) => path
@@ -422,7 +453,7 @@ namespace Tgstation.Server.Host.IO
{
ArgumentNullException.ThrowIfNull(subdirectoryPath);
if (!Path.IsPathRooted(subdirectoryPath))
if (!fileSystem.Path.IsPathRooted(subdirectoryPath))
subdirectoryPath = ConcatPath(
ResolvePath(),
subdirectoryPath);
@@ -432,6 +463,14 @@ namespace Tgstation.Server.Host.IO
subdirectoryPath);
}
/// <summary>
/// Resolve a given, non-rooted, <paramref name="path"/>.
/// </summary>
/// <param name="path">The non-rooted path to resolve.</param>
/// <returns>The fully resolved path.</returns>
protected virtual string ResolvePathCore(string path)
=> fileSystem.Path.GetFullPath(path ?? throw new ArgumentNullException(nameof(path)));
/// <summary>
/// Copies a directory from <paramref name="src"/> to <paramref name="dest"/>.
/// </summary>
@@ -5,7 +5,6 @@ using System.Net.Http.Headers;
using Microsoft.Extensions.Logging;
using Tgstation.Server.Api;
using Tgstation.Server.Common.Http;
namespace Tgstation.Server.Host.IO
{
@@ -13,9 +12,9 @@ namespace Tgstation.Server.Host.IO
public sealed class FileDownloader : IFileDownloader
{
/// <summary>
/// The <see cref="IAbstractHttpClientFactory"/> for the <see cref="FileDownloader"/>.
/// The <see cref="IHttpClientFactory"/> for the <see cref="FileDownloader"/>.
/// </summary>
readonly IAbstractHttpClientFactory httpClientFactory;
readonly IHttpClientFactory httpClientFactory;
/// <summary>
/// The <see cref="ILogger"/> for the <see cref="FileDownloader"/>.
@@ -27,7 +26,7 @@ namespace Tgstation.Server.Host.IO
/// </summary>
/// <param name="httpClientFactory">The value of <see cref="httpClientFactory"/>.</param>
/// <param name="logger">The value of <see cref="logger"/>.</param>
public FileDownloader(IAbstractHttpClientFactory httpClientFactory, ILogger<FileDownloader> logger)
public FileDownloader(IHttpClientFactory httpClientFactory, ILogger<FileDownloader> logger)
{
this.httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
+2 -2
View File
@@ -238,8 +238,8 @@ namespace Tgstation.Server.Host.IO
/// <param name="path">The path to unzip to.</param>
/// <param name="zipFile">The <see cref="Stream"/> of the <see cref="global::System.IO.Compression.ZipArchive"/>. Must have <see cref="Stream.CanSeek"/> set to <see langword="true"/>. Will be read completely and left open. <see cref="Stream.Position"/> will be indeterminate.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task"/> representing the running operation.</returns>
Task ZipToDirectory(string path, Stream zipFile, CancellationToken cancellationToken);
/// <returns>A <see cref="ValueTask"/> representing the running operation.</returns>
ValueTask ZipToDirectory(string path, Stream zipFile, CancellationToken cancellationToken);
/// <summary>
/// Get the <see cref="DateTimeOffset"/> of when a given <paramref name="path"/> was last modified.
@@ -14,9 +14,9 @@ namespace Tgstation.Server.Host.IO
sealed class RequestFileStreamProvider : IFileStreamProvider
{
/// <summary>
/// The <see cref="IHttpClient"/> for the <see cref="RequestFileStreamProvider"/>.
/// The <see cref="HttpClient"/> for the <see cref="RequestFileStreamProvider"/>.
/// </summary>
readonly IHttpClient httpClient;
readonly HttpClient httpClient;
/// <summary>
/// The <see cref="IFileDownloader"/> for the <see cref="RequestFileStreamProvider"/>.
@@ -43,7 +43,7 @@ namespace Tgstation.Server.Host.IO
/// </summary>
/// <param name="httpClient">The value of <see cref="httpClient"/>.</param>
/// <param name="requestMessage">The value of <see cref="requestMessage"/>.</param>
public RequestFileStreamProvider(IHttpClient httpClient, HttpRequestMessage requestMessage)
public RequestFileStreamProvider(HttpClient httpClient, HttpRequestMessage requestMessage)
{
this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
this.requestMessage = requestMessage ?? throw new ArgumentNullException(nameof(requestMessage));
@@ -27,11 +27,8 @@ namespace Tgstation.Server.Host.IO
}
/// <inheritdoc />
public override string ResolvePath(string path)
{
if (!IsPathRooted(path))
return base.ResolvePath(ConcatPath(subdirectory, path));
return path;
}
protected override string ResolvePathCore(string path)
=> base.ResolvePathCore(
ConcatPath(subdirectory, path));
}
}
@@ -9,7 +9,7 @@ namespace Tgstation.Server.Host.Models.Transformers
/// <summary>
/// <see langword="static"/> cache for <see cref="CompiledExpression"/>.
/// </summary>
static Func<TInput, TOutput>? compiledExpression;
static Func<TInput, TOutput>? compiledExpression; // This is safe https://stackoverflow.com/a/9647661/3976486
/// <inheritdoc />
public Expression<Func<TInput, TOutput>> Expression { get; }
@@ -0,0 +1,18 @@
namespace Tgstation.Server.Host.Models.Transformers
{
/// <summary>
/// <see cref="ITransformer{TInput, TOutput}"/> for <see cref="UpdatedUser"/>s.
/// </summary>
sealed class UpdatedUserGraphQLTransformer : TransformerBase<UpdatedUser, GraphQL.Types.UpdatedUser>
{
/// <summary>
/// Initializes a new instance of the <see cref="UpdatedUserGraphQLTransformer"/> class.
/// </summary>
public UpdatedUserGraphQLTransformer()
: base(model => model.User != null
? new GraphQL.Types.UpdatedUser(model.User)
: new GraphQL.Types.UpdatedUser(model.Id))
{
}
}
}

Some files were not shown because too many files have changed in this diff Show More