diff --git a/build/Version.props b/build/Version.props
index 1436ece2e9..bc013847ab 100644
--- a/build/Version.props
+++ b/build/Version.props
@@ -3,13 +3,13 @@
- 6.14.1
- 5.5.0
- 10.12.1
- 0.5.0
+ 6.15.0
+ 5.6.0
+ 10.13.0
+ 0.6.0
7.0.0
- 17.0.1
- 20.0.0
+ 18.0.0
+ 21.0.0
7.3.1
5.10.0
1.6.0
diff --git a/src/Tgstation.Server.Api/ApiHeaders.cs b/src/Tgstation.Server.Api/ApiHeaders.cs
index 00b7733715..051d3022c1 100644
--- a/src/Tgstation.Server.Api/ApiHeaders.cs
+++ b/src/Tgstation.Server.Api/ApiHeaders.cs
@@ -53,6 +53,11 @@ namespace Tgstation.Server.Api
///
public const string OAuthAuthenticationScheme = "OAuth";
+ ///
+ /// The OIDC authentication header scheme.
+ ///
+ public const string OpenIDConnectAuthenticationSchemePrefix = "OpenIdConnect.";
+
///
/// Added to in netstandard2.1. Can't use because of lack of .NET Framework support.
///
@@ -103,6 +108,11 @@ namespace Tgstation.Server.Api
///
public TokenResponse? Token { get; }
+ ///
+ /// The parsed if any.
+ ///
+ public AuthenticationHeaderValue? AuthenticationHeader { get; }
+
///
/// The client's username.
///
@@ -252,6 +262,7 @@ namespace Tgstation.Server.Api
{
splits.RemoveAt(0);
var parameter = String.Concat(splits);
+ AuthenticationHeader = new AuthenticationHeaderValue(scheme, parameter);
if (String.IsNullOrEmpty(parameter))
AddError(HeaderErrorTypes.AuthorizationInvalid, "Missing authentication parameter!");
else
@@ -320,7 +331,8 @@ namespace Tgstation.Server.Api
Password = String.Concat(basicAuthSplits.Skip(1));
break;
default:
- AddError(HeaderErrorTypes.AuthorizationInvalid, "Invalid authentication scheme!");
+ if (!scheme.StartsWith(OpenIDConnectAuthenticationSchemePrefix))
+ AddError(HeaderErrorTypes.AuthorizationInvalid, "Invalid authentication scheme!");
break;
}
}
diff --git a/src/Tgstation.Server.Api/Models/ErrorCode.cs b/src/Tgstation.Server.Api/Models/ErrorCode.cs
index 37a61e55fb..3a88a276e0 100644
--- a/src/Tgstation.Server.Api/Models/ErrorCode.cs
+++ b/src/Tgstation.Server.Api/Models/ErrorCode.cs
@@ -584,7 +584,7 @@ namespace Tgstation.Server.Api.Models
/// Attempted to set for the admin user.
///
[Description("The admin user cannot use OAuth connections!")]
- AdminUserCannotOAuth,
+ AdminUserCannotHaveServiceConnection,
///
/// Attempted to login with a disabled OAuth provider.
diff --git a/src/Tgstation.Server.Api/Models/Internal/UserApiBase.cs b/src/Tgstation.Server.Api/Models/Internal/UserApiBase.cs
index 5151a1e1bd..cb2158b461 100644
--- a/src/Tgstation.Server.Api/Models/Internal/UserApiBase.cs
+++ b/src/Tgstation.Server.Api/Models/Internal/UserApiBase.cs
@@ -10,6 +10,11 @@ namespace Tgstation.Server.Api.Models.Internal
///
public ICollection? OAuthConnections { get; set; }
+ ///
+ /// List of s associated with the user.
+ ///
+ public ICollection? OidcConnections { get; set; }
+
///
/// The directly associated with the user.
///
diff --git a/src/Tgstation.Server.Api/Models/OAuthProvider.cs b/src/Tgstation.Server.Api/Models/OAuthProvider.cs
index bc766e0bcc..323f29cf1b 100644
--- a/src/Tgstation.Server.Api/Models/OAuthProvider.cs
+++ b/src/Tgstation.Server.Api/Models/OAuthProvider.cs
@@ -6,7 +6,7 @@ using Newtonsoft.Json.Converters;
namespace Tgstation.Server.Api.Models
{
///
- /// List of OAuth providers supported by TGS.
+ /// List of OAuth2.0 providers supported by TGS that do not support OIDC.
///
[JsonConverter(typeof(StringEnumConverter))]
public enum OAuthProvider
@@ -24,12 +24,13 @@ namespace Tgstation.Server.Api.Models
///
/// Pre-hostening https://tgstation13.org. No longer supported.
///
- [Obsolete("tgstation13.org no longer has a custom OAuth solution", true)]
+ [Obsolete("tgstation13.org no longer has a custom OAuth solution. This option will be removed in a future TGS version.", true)]
TGForums,
///
/// https://www.keycloak.org.
///
+ [Obsolete("This should now be implemented as an OIDC provider. This option will be removed in a future TGS version.")]
Keycloak,
///
diff --git a/src/Tgstation.Server.Api/Models/OidcConnection.cs b/src/Tgstation.Server.Api/Models/OidcConnection.cs
new file mode 100644
index 0000000000..57a0c16c93
--- /dev/null
+++ b/src/Tgstation.Server.Api/Models/OidcConnection.cs
@@ -0,0 +1,28 @@
+using System.ComponentModel.DataAnnotations;
+
+using Newtonsoft.Json;
+
+namespace Tgstation.Server.Api.Models
+{
+ ///
+ /// Represents a valid OIDC connection.
+ ///
+ public class OidcConnection
+ {
+ ///
+ /// The of the .
+ /// ]
+ [Required]
+ [RequestOptions(FieldPresence.Required)]
+ [StringLength(Limits.MaximumIndexableStringLength, MinimumLength = 1)]
+ public string? SchemeKey { get; set; }
+
+ ///
+ /// The ID of the user in the OIDC proivder ("sub" claim).
+ ///
+ [Required]
+ [RequestOptions(FieldPresence.Required)]
+ [StringLength(Limits.MaximumIndexableStringLength, MinimumLength = 1)]
+ public string? ExternalUserId { get; set; }
+ }
+}
diff --git a/src/Tgstation.Server.Api/Models/OidcProviderInfo.cs b/src/Tgstation.Server.Api/Models/OidcProviderInfo.cs
new file mode 100644
index 0000000000..2bbf378327
--- /dev/null
+++ b/src/Tgstation.Server.Api/Models/OidcProviderInfo.cs
@@ -0,0 +1,30 @@
+using System;
+
+namespace Tgstation.Server.Api.Models
+{
+ ///
+ /// Represents a configured OIDC provider.
+ ///
+ public sealed class OidcProviderInfo
+ {
+ ///
+ /// The scheme key used to reference the . Unique amongst providers.
+ ///
+ public string? SchemeKey { get; set; }
+
+ ///
+ /// The provider's name as it should be displayed to the user.
+ ///
+ public string? FriendlyName { get; set; }
+
+ ///
+ /// Colour that should be used to theme this OIDC provider.
+ ///
+ public string? ThemeColour { get; set; }
+
+ ///
+ /// Image URL that should be used to theme this OIDC provider.
+ ///
+ public Uri? ThemeIconUrl { get; set; }
+ }
+}
diff --git a/src/Tgstation.Server.Api/Models/Response/ServerInformationResponse.cs b/src/Tgstation.Server.Api/Models/Response/ServerInformationResponse.cs
index 82f27e7e7c..7fb4b689b9 100644
--- a/src/Tgstation.Server.Api/Models/Response/ServerInformationResponse.cs
+++ b/src/Tgstation.Server.Api/Models/Response/ServerInformationResponse.cs
@@ -36,6 +36,11 @@ namespace Tgstation.Server.Api.Models.Response
///
public Dictionary? OAuthProviderInfos { get; set; }
+ ///
+ /// List of configured s.
+ ///
+ public List? OidcProviderInfos { get; set; }
+
///
/// If there is a server update in progress.
///
diff --git a/src/Tgstation.Server.Api/Rights/AdministrationRights.cs b/src/Tgstation.Server.Api/Rights/AdministrationRights.cs
index ed530eee44..a0360aaf1a 100644
--- a/src/Tgstation.Server.Api/Rights/AdministrationRights.cs
+++ b/src/Tgstation.Server.Api/Rights/AdministrationRights.cs
@@ -44,9 +44,9 @@ namespace Tgstation.Server.Api.Rights
DownloadLogs = 1 << 5,
///
- /// User can modify their own .
+ /// User can modify their own and .
///
- EditOwnOAuthConnections = 1 << 6,
+ EditOwnServiceConnections = 1 << 6,
///
/// User can upgrade/downgrade TGS using file uploads through the API.
diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateUserFromOAuthConnection.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateUserFromOAuthConnection.graphql
index 9d374b5ff0..dc69b9ef29 100644
--- a/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateUserFromOAuthConnection.graphql
+++ b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateUserFromOAuthConnection.graphql
@@ -1,5 +1,5 @@
mutation CreateUserFromOAuthConnection($name: String!, $oAuthConnections: [OAuthConnectionInput!]!) {
- createUserByOAuthAndPermissionSet(input: { name: $name, oAuthConnections: $oAuthConnections }) {
+ createUserByServiceConnectionAndPermissionSet(input: { name: $name, oAuthConnections: $oAuthConnections }) {
errors {
... on ErrorMessageError {
additionalData
diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateUserGroup.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateUserGroup.graphql
index 2d8489ecb6..440994e631 100644
--- a/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateUserGroup.graphql
+++ b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateUserGroup.graphql
@@ -7,7 +7,7 @@ mutation CreateUserGroup($name: String!) {
administrationRights {
canChangeVersion
canDownloadLogs
- canEditOwnOAuthConnections
+ canEditOwnServiceConnections
canEditOwnPassword
canReadUsers
canRestartHost
diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateUserGroupWithInstanceListPerm.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateUserGroupWithInstanceListPerm.graphql
index 6a99c7ede0..c4f88b6770 100644
--- a/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateUserGroupWithInstanceListPerm.graphql
+++ b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/CreateUserGroupWithInstanceListPerm.graphql
@@ -7,7 +7,7 @@ mutation CreateUserGroupWithInstanceListPerm($name: String!) {
administrationRights {
canChangeVersion
canDownloadLogs
- canEditOwnOAuthConnections
+ canEditOwnServiceConnections
canEditOwnPassword
canReadUsers
canRestartHost
diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/SetFullPermsOnUserGroup.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/SetFullPermsOnUserGroup.graphql
index 1766cb4149..8c77199901 100644
--- a/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/SetFullPermsOnUserGroup.graphql
+++ b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/SetFullPermsOnUserGroup.graphql
@@ -6,7 +6,7 @@ mutation SetFullPermsOnUserGroup($id: ID!) {
administrationRights: {
canChangeVersion: true
canDownloadLogs: true
- canEditOwnOAuthConnections: true
+ canEditOwnServiceConnections: true
canEditOwnPassword: true
canReadUsers: true
canWriteUsers: true
@@ -36,7 +36,7 @@ mutation SetFullPermsOnUserGroup($id: ID!) {
administrationRights {
canChangeVersion
canDownloadLogs
- canEditOwnOAuthConnections
+ canEditOwnServiceConnections
canEditOwnPassword
canReadUsers
canRestartHost
diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/SetUserGroup.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/SetUserGroup.graphql
index d68865e550..4299d5b537 100644
--- a/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/SetUserGroup.graphql
+++ b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/SetUserGroup.graphql
@@ -10,27 +10,27 @@ mutation SetUserGroup($id: ID!, $newGroupId: ID!) {
user {
ownedPermissionSet {
instanceManagerRights {
- canCreate
- canDelete
- canGrantPermissions
- canList
- canRead
- canRelocate
- canRename
- canSetAutoUpdate
- canSetChatBotLimit
- canSetConfiguration
- canSetOnline
+ canCreate
+ canDelete
+ canGrantPermissions
+ canList
+ canRead
+ canRelocate
+ canRename
+ canSetAutoUpdate
+ canSetChatBotLimit
+ canSetConfiguration
+ canSetOnline
}
administrationRights {
- canChangeVersion
- canDownloadLogs
- canEditOwnOAuthConnections
- canEditOwnPassword
- canReadUsers
- canRestartHost
- canUploadVersion
- canWriteUsers
+ canChangeVersion
+ canDownloadLogs
+ canEditOwnServiceConnections
+ canEditOwnPassword
+ canReadUsers
+ canRestartHost
+ canUploadVersion
+ canWriteUsers
}
}
group {
diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/SetUserPermissionSet.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/SetUserPermissionSet.graphql
index a1dbcb24e6..fa2fa558fe 100644
--- a/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/SetUserPermissionSet.graphql
+++ b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/SetUserPermissionSet.graphql
@@ -12,7 +12,7 @@ mutation SetUserPermissionSet($id: ID!, $permissionSet: PermissionSetInput!) {
administrationRights {
canChangeVersion
canDownloadLogs
- canEditOwnOAuthConnections
+ canEditOwnServiceConnections
canEditOwnPassword
canReadUsers
canRestartHost
@@ -37,7 +37,7 @@ mutation SetUserPermissionSet($id: ID!, $permissionSet: PermissionSetInput!) {
administrationRights {
canChangeVersion
canDownloadLogs
- canEditOwnOAuthConnections
+ canEditOwnServiceConnections
canEditOwnPassword
canReadUsers
canRestartHost
diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Queries/GetSomeGroupInfo.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Queries/GetSomeGroupInfo.graphql
index f36dee754a..5d37e33b07 100644
--- a/src/Tgstation.Server.Client.GraphQL/GQL/Queries/GetSomeGroupInfo.graphql
+++ b/src/Tgstation.Server.Client.GraphQL/GQL/Queries/GetSomeGroupInfo.graphql
@@ -20,7 +20,7 @@ query GetSomeGroupInfo($id: ID!) {
administrationRights {
canChangeVersion
canDownloadLogs
- canEditOwnOAuthConnections
+ canEditOwnServiceConnections
canEditOwnPassword
canReadUsers
canRestartHost
diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Queries/ReadCurrentUser.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Queries/ReadCurrentUser.graphql
index dea2849d19..79dfa3e5ef 100644
--- a/src/Tgstation.Server.Client.GraphQL/GQL/Queries/ReadCurrentUser.graphql
+++ b/src/Tgstation.Server.Client.GraphQL/GQL/Queries/ReadCurrentUser.graphql
@@ -20,7 +20,7 @@ query ReadCurrentUser {
administrationRights {
canChangeVersion
canDownloadLogs
- canEditOwnOAuthConnections
+ canEditOwnServiceConnections
canEditOwnPassword
canReadUsers
canRestartHost
diff --git a/src/Tgstation.Server.Host/Authority/IUserAuthority.cs b/src/Tgstation.Server.Host/Authority/IUserAuthority.cs
index 9e2a37da59..f60275aceb 100644
--- a/src/Tgstation.Server.Host/Authority/IUserAuthority.cs
+++ b/src/Tgstation.Server.Host/Authority/IUserAuthority.cs
@@ -43,6 +43,14 @@ namespace Tgstation.Server.Host.Authority
/// A resulting in an of .
ValueTask> OAuthConnections(long userId, CancellationToken cancellationToken);
+ ///
+ /// Gets the s for the with a given .
+ ///
+ /// The of the .
+ /// The for the operation.
+ /// A resulting in an of .
+ ValueTask> OidcConnections(long userId, CancellationToken cancellationToken);
+
///
/// Gets all registered s.
///
@@ -70,7 +78,7 @@ namespace Tgstation.Server.Host.Authority
/// The .
/// The for the operation.
/// A resulting in am for the created .
- [TgsAuthorize(AdministrationRights.WriteUsers | AdministrationRights.EditOwnPassword | AdministrationRights.EditOwnOAuthConnections)]
+ [TgsAuthorize(AdministrationRights.WriteUsers | AdministrationRights.EditOwnPassword | AdministrationRights.EditOwnServiceConnections)]
ValueTask> Update(UserUpdateRequest updateRequest, CancellationToken cancellationToken);
}
}
diff --git a/src/Tgstation.Server.Host/Authority/UserAuthority.cs b/src/Tgstation.Server.Host/Authority/UserAuthority.cs
index 686eb10f47..7d881ad712 100644
--- a/src/Tgstation.Server.Host/Authority/UserAuthority.cs
+++ b/src/Tgstation.Server.Host/Authority/UserAuthority.cs
@@ -41,6 +41,11 @@ namespace Tgstation.Server.Host.Authority
///
readonly IOAuthConnectionsDataLoader oAuthConnectionsDataLoader;
+ ///
+ /// The for the .
+ ///
+ readonly IOidcConnectionsDataLoader oidcConnectionsDataLoader;
+
///
/// The for the .
///
@@ -95,7 +100,7 @@ namespace Tgstation.Server.Host.Authority
}
///
- /// Implements the .
+ /// Implements the .
///
/// The of s to load the OAuthConnections for.
/// The to load from.
@@ -117,10 +122,37 @@ namespace Tgstation.Server.Host.Authority
.ToListAsync(cancellationToken);
return list.ToLookup(
- oauthConnection => oauthConnection.UserId,
+ oAuthConnection => oAuthConnection.UserId,
x => new GraphQL.Types.OAuth.OAuthConnection(x.ExternalUserId!, x.Provider));
}
+ ///
+ /// Implements the .
+ ///
+ /// The of s to load the OidcConnections for.
+ /// The to load from.
+ /// The for the operation.
+ /// A resulting in a of the requested s.
+ [DataLoader]
+ public static async ValueTask> GetOidcConnections(
+ IReadOnlyList userIds,
+ IDatabaseContext databaseContext,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(userIds);
+ ArgumentNullException.ThrowIfNull(databaseContext);
+
+ var list = await databaseContext
+ .OidcConnections
+ .AsQueryable()
+ .Where(x => userIds.Contains(x.User!.Id!.Value))
+ .ToListAsync(cancellationToken);
+
+ return list.ToLookup(
+ oidcConnection => oidcConnection.UserId,
+ x => new GraphQL.Types.OAuth.OidcConnection(x.ExternalUserId!, x.SchemeKey!));
+ }
+
///
/// Check if a given has a valid specified.
///
@@ -147,6 +179,7 @@ namespace Tgstation.Server.Host.Authority
/// The to use.
/// The value of .
/// The value of .
+ /// The value of .
/// The value of .
/// The value of .
/// The value of .
@@ -159,6 +192,7 @@ namespace Tgstation.Server.Host.Authority
ILogger logger,
IUsersDataLoader usersDataLoader,
IOAuthConnectionsDataLoader oAuthConnectionsDataLoader,
+ IOidcConnectionsDataLoader oidcConnectionsDataLoader,
ISystemIdentityFactory systemIdentityFactory,
IPermissionsUpdateNotifyee permissionsUpdateNotifyee,
ICryptographySuite cryptographySuite,
@@ -172,6 +206,7 @@ namespace Tgstation.Server.Host.Authority
{
this.usersDataLoader = usersDataLoader ?? throw new ArgumentNullException(nameof(usersDataLoader));
this.oAuthConnectionsDataLoader = oAuthConnectionsDataLoader ?? throw new ArgumentNullException(nameof(oAuthConnectionsDataLoader));
+ this.oidcConnectionsDataLoader = oidcConnectionsDataLoader ?? throw new ArgumentNullException(nameof(oidcConnectionsDataLoader));
this.systemIdentityFactory = systemIdentityFactory ?? throw new ArgumentNullException(nameof(systemIdentityFactory));
this.permissionsUpdateNotifyee = permissionsUpdateNotifyee ?? throw new ArgumentNullException(nameof(permissionsUpdateNotifyee));
this.cryptographySuite = cryptographySuite ?? throw new ArgumentNullException(nameof(cryptographySuite));
@@ -289,6 +324,11 @@ namespace Tgstation.Server.Host.Authority
=> new AuthorityResponse(
await oAuthConnectionsDataLoader.LoadRequiredAsync(userId, cancellationToken));
+ ///
+ public async ValueTask> OidcConnections(long userId, CancellationToken cancellationToken)
+ => new AuthorityResponse(
+ await oidcConnectionsDataLoader.LoadRequiredAsync(userId, cancellationToken));
+
///
public async ValueTask> Create(
UserCreateRequest createRequest,
@@ -372,7 +412,7 @@ namespace Tgstation.Server.Host.Authority
var callerAdministrationRights = (AdministrationRights)AuthenticationContext.GetRight(RightsType.Administration);
var canEditAllUsers = callerAdministrationRights.HasFlag(AdministrationRights.WriteUsers);
var passwordEdit = canEditAllUsers || callerAdministrationRights.HasFlag(AdministrationRights.EditOwnPassword);
- var oAuthEdit = canEditAllUsers || callerAdministrationRights.HasFlag(AdministrationRights.EditOwnOAuthConnections);
+ var oAuthEdit = canEditAllUsers || callerAdministrationRights.HasFlag(AdministrationRights.EditOwnServiceConnections);
var originalUser = !canEditAllUsers
? AuthenticationContext.User
@@ -382,6 +422,7 @@ namespace Tgstation.Server.Host.Authority
.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)
@@ -438,7 +479,7 @@ namespace Tgstation.Server.Host.Authority
|| !model.OAuthConnections.All(x => originalUser.OAuthConnections.Any(y => y.Provider == x.Provider && y.ExternalUserId == x.ExternalUserId))))
{
if (originalUser.CanonicalName == User.CanonicalizeName(DefaultCredentials.AdminUserName))
- return BadRequest(ErrorCode.AdminUserCannotOAuth);
+ return BadRequest(ErrorCode.AdminUserCannotHaveServiceConnection);
if (model.OAuthConnections.Count == 0 && originalUser.PasswordHash == null && originalUser.SystemIdentifier == null)
return BadRequest(ErrorCode.CannotRemoveLastAuthenticationOption);
@@ -452,6 +493,25 @@ namespace Tgstation.Server.Host.Authority
});
}
+ 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 (originalUser.CanonicalName == User.CanonicalizeName(DefaultCredentials.AdminUserName))
+ return BadRequest(ErrorCode.AdminUserCannotHaveServiceConnection);
+
+ if (model.OidcConnections.Count == 0 && originalUser.PasswordHash == null && originalUser.SystemIdentifier == null)
+ return BadRequest(ErrorCode.CannotRemoveLastAuthenticationOption);
+
+ 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)
{
originalUser.Group = await DatabaseContext
@@ -554,6 +614,7 @@ namespace Tgstation.Server.Host.Authority
queryable = queryable
.Include(x => x.CreatedBy)
.Include(x => x.OAuthConnections)
+ .Include(x => x.OidcConnections)
.Include(x => x.Group!)
.ThenInclude(x => x.PermissionSet)
.Include(x => x.PermissionSet);
@@ -603,6 +664,15 @@ namespace Tgstation.Server.Host.Authority
})
.ToList()
?? new List(),
+ OidcConnections = model
+ .OidcConnections
+ ?.Select(x => new Models.OidcConnection
+ {
+ SchemeKey = x.SchemeKey,
+ ExternalUserId = x.ExternalUserId,
+ })
+ .ToList()
+ ?? new List(),
};
}
diff --git a/src/Tgstation.Server.Host/Configuration/OidcConfiguration.cs b/src/Tgstation.Server.Host/Configuration/OidcConfiguration.cs
new file mode 100644
index 0000000000..4cdcb72ebb
--- /dev/null
+++ b/src/Tgstation.Server.Host/Configuration/OidcConfiguration.cs
@@ -0,0 +1,45 @@
+using System;
+
+namespace Tgstation.Server.Host.Configuration
+{
+ ///
+ /// Configuration for an OpenID Connect provider.
+ ///
+ public sealed class OidcConfiguration
+ {
+ ///
+ /// The containing the .well-known endpoint for the provider.
+ ///
+ public Uri? Authority { get; set; }
+
+ ///
+ /// The OIDC client ID.
+ ///
+ public string? ClientId { get; set; }
+
+ ///
+ /// The OIDC client secret.
+ ///
+ public string? ClientSecret { get; set; }
+
+ ///
+ /// The provider's name as it should be displayed to the user.
+ ///
+ public string? FriendlyName { get; set; }
+
+ ///
+ /// The path to return to once OIDC authentication is complete. On success the "code" and "state" query strings will be set, containing a TGS token and "oidc.(scheme key)" strings respectively. On failure the "error" query string will be set with a user readable error message.
+ ///
+ public string? ReturnPath { get; set; } = "/app";
+
+ ///
+ /// Colour that should be used to theme this OIDC provider.
+ ///
+ public string? ThemeColour { get; set; }
+
+ ///
+ /// Image URL that should be used to theme this OIDC provider.
+ ///
+ public Uri? ThemeIconUrl { get; set; }
+ }
+}
diff --git a/src/Tgstation.Server.Host/Configuration/SecurityConfiguration.cs b/src/Tgstation.Server.Host/Configuration/SecurityConfiguration.cs
index 35585612c0..8f542b3f83 100644
--- a/src/Tgstation.Server.Host/Configuration/SecurityConfiguration.cs
+++ b/src/Tgstation.Server.Host/Configuration/SecurityConfiguration.cs
@@ -1,4 +1,5 @@
-using System.Collections.Generic;
+using System;
+using System.Collections.Generic;
using System.Linq;
using Swashbuckle.AspNetCore.SwaggerGen;
@@ -87,5 +88,23 @@ namespace Tgstation.Server.Host.Configuration
/// Backing field for .
///
IDictionary? oAuth;
+
+ ///
+ /// OIDC provider settings keyed by scheme name.
+ ///
+ public IDictionary? OpenIDConnect { get; set; }
+
+ ///
+ /// Get the s from the .
+ ///
+ /// An of s.
+ public IEnumerable OidcProviderInfos()
+ => OpenIDConnect?.Select(oidcConfig => new OidcProviderInfo
+ {
+ SchemeKey = oidcConfig.Key,
+ FriendlyName = oidcConfig.Value.FriendlyName ?? oidcConfig.Key,
+ ThemeColour = oidcConfig.Value.ThemeColour,
+ ThemeIconUrl = oidcConfig.Value.ThemeIconUrl,
+ }) ?? Enumerable.Empty();
}
}
diff --git a/src/Tgstation.Server.Host/Controllers/ApiRootController.cs b/src/Tgstation.Server.Host/Controllers/ApiRootController.cs
index 49f3e89c2b..bc58e7cc93 100644
--- a/src/Tgstation.Server.Host/Controllers/ApiRootController.cs
+++ b/src/Tgstation.Server.Host/Controllers/ApiRootController.cs
@@ -70,6 +70,11 @@ namespace Tgstation.Server.Host.Controllers
///
readonly GeneralConfiguration generalConfiguration;
+ ///
+ /// The for the .
+ ///
+ readonly SecurityConfiguration securityConfiguration;
+
///
/// Initializes a new instance of the class.
///
@@ -81,6 +86,7 @@ namespace Tgstation.Server.Host.Controllers
/// The value of .
/// The value of .
/// The containing the value of .
+ /// The containing the value of .
/// The for the .
/// The for the .
/// The value of .
@@ -93,6 +99,7 @@ namespace Tgstation.Server.Host.Controllers
ISwarmService swarmService,
IServerControl serverControl,
IOptions generalConfigurationOptions,
+ IOptionsSnapshot securityConfigurationOptions,
ILogger logger,
IApiHeadersProvider apiHeadersProvider,
IRestAuthorityInvoker loginAuthority)
@@ -109,6 +116,7 @@ namespace Tgstation.Server.Host.Controllers
this.swarmService = swarmService ?? throw new ArgumentNullException(nameof(swarmService));
this.serverControl = serverControl ?? throw new ArgumentNullException(nameof(serverControl));
generalConfiguration = generalConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(generalConfigurationOptions));
+ securityConfiguration = securityConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(securityConfigurationOptions));
this.loginAuthority = loginAuthority ?? throw new ArgumentNullException(nameof(loginAuthority));
}
@@ -162,6 +170,7 @@ namespace Tgstation.Server.Host.Controllers
?.Select(swarmServerInfo => new SwarmServerResponse(swarmServerInfo))
.ToList(),
OAuthProviderInfos = oAuthProviders.ProviderInfos(),
+ OidcProviderInfos = securityConfiguration.OidcProviderInfos().ToList(),
UpdateInProgress = serverControl.UpdateInProgress,
});
}
diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs
index 08f39565a6..ee013656f6 100644
--- a/src/Tgstation.Server.Host/Core/Application.cs
+++ b/src/Tgstation.Server.Host/Core/Application.cs
@@ -3,6 +3,7 @@ using System.Collections.Frozen;
using System.Collections.Generic;
using System.Globalization;
using System.Threading.Tasks;
+using System.Web;
using Cyberboss.AspNetCore.AsyncInitializer;
@@ -13,7 +14,9 @@ using HotChocolate.Subscriptions;
using HotChocolate.Types;
using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Cors.Infrastructure;
using Microsoft.AspNetCore.Hosting;
@@ -27,6 +30,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
+using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Newtonsoft.Json;
@@ -121,6 +125,14 @@ namespace Tgstation.Server.Host.Core
services.AddSingleton();
}
+ ///
+ /// Get the OIDC authentication scheme name for a given .
+ ///
+ /// The scheme key for the .
+ /// The authentication scheme name.
+ static string GetOidcScheme(string schemeKey)
+ => ApiHeaders.OpenIDConnectAuthenticationSchemePrefix + schemeKey;
+
///
/// Initializes a new instance of the class.
///
@@ -239,7 +251,7 @@ namespace Tgstation.Server.Host.Core
postSetupServices.FileLoggingConfiguration);
// configure authentication pipeline
- ConfigureAuthenticationPipeline(services);
+ ConfigureAuthenticationPipeline(services, postSetupServices.SecurityConfiguration);
// add mvc, configure the json serializer settings
var jsonVersionConverterList = new List
@@ -316,9 +328,21 @@ namespace Tgstation.Server.Host.Core
.AddScoped()
.AddGraphQLServer()
.AddAuthorization(
- options => options.AddPolicy(
- TgsAuthorizeAttribute.PolicyName,
- builder => builder.RequireRole(TgsAuthorizeAttribute.UserEnabledRole)))
+ options =>
+ {
+ options.AddPolicy(
+ TgsAuthorizeAttribute.PolicyName,
+ builder => builder
+ .RequireAuthenticatedUser()
+ .RequireRole(TgsAuthorizeAttribute.UserEnabledRole));
+ options.AddPolicy(
+ "testingasdf",
+ builder =>
+ {
+ builder.RequireAuthenticatedUser();
+ builder.AuthenticationSchemes.Add(CookieAuthenticationDefaults.AuthenticationScheme);
+ });
+ })
.ModifyOptions(options =>
{
options.EnsureAllNodesCanBeResolved = true;
@@ -548,6 +572,7 @@ namespace Tgstation.Server.Host.Core
/// The containing the to use.
/// The containing the to use.
/// The containing the to use.
+ /// The containing the to use.
/// The containing the to use.
/// The containing the to use.
/// The for the .
@@ -560,6 +585,7 @@ namespace Tgstation.Server.Host.Core
IOptions controlPanelConfigurationOptions,
IOptions generalConfigurationOptions,
IOptions databaseConfigurationOptions,
+ IOptions securityConfigurationOptions,
IOptions swarmConfigurationOptions,
IOptions internalConfigurationOptions,
ILogger logger)
@@ -728,6 +754,20 @@ namespace Tgstation.Server.Host.Core
logger.LogTrace("Prometheus disabled");
endpoints.MapHealthChecks("/health");
+
+ var oidcConfig = securityConfigurationOptions.Value.OpenIDConnect;
+ if (oidcConfig == null)
+ return;
+
+ foreach (var kvp in oidcConfig)
+ endpoints.MapGet(
+ $"/oidc/{kvp.Key}/signin",
+ context => context.ChallengeAsync(
+ GetOidcScheme(kvp.Key),
+ new AuthenticationProperties
+ {
+ RedirectUri = $"/oidc/{kvp.Key}/landing",
+ }));
});
// 404 anything that gets this far
@@ -748,7 +788,8 @@ namespace Tgstation.Server.Host.Core
/// Configure the for the authentication pipeline.
///
/// The to configure.
- void ConfigureAuthenticationPipeline(IServiceCollection services)
+ /// The .
+ void ConfigureAuthenticationPipeline(IServiceCollection services, SecurityConfiguration securityConfiguration)
{
services.AddHttpContextAccessor();
services.AddScoped();
@@ -768,8 +809,11 @@ namespace Tgstation.Server.Host.Core
.CurrentAuthenticationContext);
services.AddScoped();
- services
- .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
+ var authBuilder = services
+ .AddAuthentication(options =>
+ {
+ options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
+ })
.AddJwtBearer(jwtBearerOptions =>
{
// this line isn't actually run until the first request is made
@@ -798,13 +842,85 @@ namespace Tgstation.Server.Host.Core
.HttpContext
.RequestServices
.GetRequiredService()
- .ValidateToken(
+ .ValidateTgsToken(
context,
context
.HttpContext
.RequestAborted),
};
});
+
+ var oidcConfig = securityConfiguration.OpenIDConnect;
+ if (oidcConfig == null || oidcConfig.Count == 0)
+ return;
+
+ authBuilder.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme);
+
+ foreach (var kvp in oidcConfig)
+ {
+ var configName = kvp.Key;
+ authBuilder
+ .AddOpenIdConnect(
+ GetOidcScheme(configName),
+ options =>
+ {
+ var config = kvp.Value;
+
+ options.Authority = config.Authority?.ToString();
+ options.ClientId = config.ClientId;
+ options.ClientSecret = config.ClientSecret;
+
+ options.Scope.Add(OpenIdConnectScope.OpenId);
+ options.Scope.Add(OpenIdConnectScope.OfflineAccess);
+
+#if DEBUG
+ options.RequireHttpsMetadata = false;
+#endif
+
+ options.SaveTokens = true;
+ options.ResponseType = OpenIdConnectResponseType.Code;
+ options.MapInboundClaims = false;
+
+ options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
+
+ var basePath = $"/oidc/{configName}/";
+ options.CallbackPath = new PathString(basePath + "signin-callback");
+ options.SignedOutCallbackPath = new PathString(basePath + "signout-callback");
+ options.RemoteSignOutPath = new PathString(basePath + "signout");
+
+ options.Events = new OpenIdConnectEvents
+ {
+ OnTokenValidated = context => context
+ .HttpContext
+ .RequestServices
+ .GetRequiredService()
+ .ValidateOidcToken(
+ context,
+ context
+ .HttpContext
+ .RequestAborted),
+ OnRemoteFailure = context =>
+ {
+ context.HandleResponse();
+ context.HttpContext.Response.Redirect($"{config.ReturnPath}?error={HttpUtility.UrlEncode(context.Failure?.Message ?? $"{options.Events.OnRemoteFailure} was called without an {nameof(Exception)}!")}&state=oidc.{HttpUtility.UrlEncode(configName)}");
+ return Task.CompletedTask;
+ },
+ OnTicketReceived = context =>
+ {
+ var services = context
+ .HttpContext
+ .RequestServices;
+ var tokenFactory = services
+ .GetRequiredService();
+ var authenticationContext = services
+ .GetRequiredService();
+ context.HandleResponse();
+ context.HttpContext.Response.Redirect($"{config.ReturnPath}?code={HttpUtility.UrlEncode(tokenFactory.CreateToken(authenticationContext.User, true))}&state=oidc.{HttpUtility.UrlEncode(configName)}");
+ return Task.CompletedTask;
+ },
+ };
+ });
+ }
}
}
}
diff --git a/src/Tgstation.Server.Host/Database/DatabaseContext.cs b/src/Tgstation.Server.Host/Database/DatabaseContext.cs
index 6b8013f8c6..7fe10cd56c 100644
--- a/src/Tgstation.Server.Host/Database/DatabaseContext.cs
+++ b/src/Tgstation.Server.Host/Database/DatabaseContext.cs
@@ -98,6 +98,11 @@ namespace Tgstation.Server.Host.Database
///
public DbSet OAuthConnections { get; set; }
+ ///
+ /// The s in the .
+ ///
+ public DbSet OidcConnections { get; set; }
+
///
/// The s in the .
///
@@ -153,6 +158,9 @@ namespace Tgstation.Server.Host.Database
///
IDatabaseCollection IDatabaseContext.OAuthConnections => oAuthConnections;
+ ///
+ IDatabaseCollection IDatabaseContext.OidcConnections => oidcConnections;
+
///
IDatabaseCollection IDatabaseContext.Groups => groups;
@@ -239,6 +247,11 @@ namespace Tgstation.Server.Host.Database
///
readonly IDatabaseCollection oAuthConnections;
+ ///
+ /// Backing field for .
+ ///
+ readonly IDatabaseCollection oidcConnections;
+
///
/// Backing field for .
///
@@ -288,6 +301,7 @@ namespace Tgstation.Server.Host.Database
jobsCollection = new DatabaseCollection(Jobs!);
reattachInformationsCollection = new DatabaseCollection(ReattachInformations!);
oAuthConnections = new DatabaseCollection(OAuthConnections!);
+ oidcConnections = new DatabaseCollection(OidcConnections!);
groups = new DatabaseCollection(Groups!);
permissionSets = new DatabaseCollection(PermissionSets!);
}
@@ -387,8 +401,10 @@ namespace Tgstation.Server.Host.Database
userModel.HasIndex(x => x.SystemIdentifier).IsUnique();
userModel.HasMany(x => x.TestMerges).WithOne(x => x.MergedBy).OnDelete(DeleteBehavior.Restrict);
userModel.HasMany(x => x.OAuthConnections).WithOne(x => x.User).OnDelete(DeleteBehavior.Cascade);
+ userModel.HasMany(x => x.OidcConnections).WithOne(x => x.User).OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity().HasIndex(x => new { x.Provider, x.ExternalUserId }).IsUnique();
+ modelBuilder.Entity().HasIndex(x => new { x.SchemeKey, x.ExternalUserId }).IsUnique();
var groupsModel = modelBuilder.Entity();
groupsModel.HasIndex(x => x.Name).IsUnique();
diff --git a/src/Tgstation.Server.Host/Database/IDatabaseContext.cs b/src/Tgstation.Server.Host/Database/IDatabaseContext.cs
index 2ca3f748f6..b947de2dbd 100644
--- a/src/Tgstation.Server.Host/Database/IDatabaseContext.cs
+++ b/src/Tgstation.Server.Host/Database/IDatabaseContext.cs
@@ -90,6 +90,11 @@ namespace Tgstation.Server.Host.Database
///
IDatabaseCollection OAuthConnections { get; }
+ ///
+ /// The for s.
+ ///
+ IDatabaseCollection OidcConnections { get; }
+
///
/// The for s.
///
diff --git a/src/Tgstation.Server.Host/Database/Migrations/20250302232550_MSAddOidcConnections.Designer.cs b/src/Tgstation.Server.Host/Database/Migrations/20250302232550_MSAddOidcConnections.Designer.cs
new file mode 100644
index 0000000000..ad2d731905
--- /dev/null
+++ b/src/Tgstation.Server.Host/Database/Migrations/20250302232550_MSAddOidcConnections.Designer.cs
@@ -0,0 +1,1146 @@
+//
+using System;
+
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+namespace Tgstation.Server.Host.Database.Migrations
+{
+ [DbContext(typeof(SqlServerDatabaseContext))]
+ [Migration("20250302232550_MSAddOidcConnections")]
+ partial class MSAddOidcConnections
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "8.0.13")
+ .HasAnnotation("Relational:MaxIdentifierLength", 128);
+
+ SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
+
+ modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("ChannelLimit")
+ .HasColumnType("int");
+
+ b.Property("ConnectionString")
+ .IsRequired()
+ .HasMaxLength(10000)
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Enabled")
+ .HasColumnType("bit");
+
+ b.Property("InstanceId")
+ .HasColumnType("bigint");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("Provider")
+ .HasColumnType("int");
+
+ b.Property("ReconnectionInterval")
+ .HasColumnType("bigint");
+
+ b.HasKey("Id");
+
+ b.HasIndex("InstanceId", "Name")
+ .IsUnique();
+
+ b.ToTable("ChatBots");
+ });
+
+ modelBuilder.Entity("Tgstation.Server.Host.Models.ChatChannel", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("ChatSettingsId")
+ .HasColumnType("bigint");
+
+ b.Property("DiscordChannelId")
+ .HasColumnType("decimal(20,0)");
+
+ b.Property("IrcChannel")
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("IsAdminChannel")
+ .IsRequired()
+ .HasColumnType("bit");
+
+ b.Property("IsSystemChannel")
+ .IsRequired()
+ .HasColumnType("bit");
+
+ b.Property("IsUpdatesChannel")
+ .IsRequired()
+ .HasColumnType("bit");
+
+ b.Property("IsWatchdogChannel")
+ .IsRequired()
+ .HasColumnType("bit");
+
+ b.Property("Tag")
+ .HasMaxLength(10000)
+ .HasColumnType("nvarchar(max)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ChatSettingsId", "DiscordChannelId")
+ .IsUnique()
+ .HasFilter("[DiscordChannelId] IS NOT NULL");
+
+ b.HasIndex("ChatSettingsId", "IrcChannel")
+ .IsUnique()
+ .HasFilter("[IrcChannel] IS NOT NULL");
+
+ b.ToTable("ChatChannels");
+ });
+
+ modelBuilder.Entity("Tgstation.Server.Host.Models.CompileJob", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("DMApiMajorVersion")
+ .HasColumnType("int");
+
+ b.Property("DMApiMinorVersion")
+ .HasColumnType("int");
+
+ b.Property("DMApiPatchVersion")
+ .HasColumnType("int");
+
+ b.Property("DirectoryName")
+ .IsRequired()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("DmeName")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("EngineVersion")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("GitHubDeploymentId")
+ .HasColumnType("bigint");
+
+ b.Property("GitHubRepoId")
+ .HasColumnType("bigint");
+
+ b.Property("JobId")
+ .HasColumnType("bigint");
+
+ b.Property("MinimumSecurityLevel")
+ .HasColumnType("int");
+
+ b.Property("Output")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("RepositoryOrigin")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("RevisionInformationId")
+ .HasColumnType("bigint");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DirectoryName");
+
+ b.HasIndex("JobId")
+ .IsUnique();
+
+ b.HasIndex("RevisionInformationId");
+
+ b.ToTable("CompileJobs");
+ });
+
+ modelBuilder.Entity("Tgstation.Server.Host.Models.DreamDaemonSettings", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("AdditionalParameters")
+ .IsRequired()
+ .HasMaxLength(10000)
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("AllowWebClient")
+ .IsRequired()
+ .HasColumnType("bit");
+
+ b.Property("AutoStart")
+ .IsRequired()
+ .HasColumnType("bit");
+
+ b.Property("DumpOnHealthCheckRestart")
+ .IsRequired()
+ .HasColumnType("bit");
+
+ b.Property("HealthCheckSeconds")
+ .HasColumnType("bigint");
+
+ b.Property("InstanceId")
+ .HasColumnType("bigint");
+
+ b.Property("LogOutput")
+ .IsRequired()
+ .HasColumnType("bit");
+
+ b.Property("MapThreads")
+ .HasColumnType("bigint");
+
+ b.Property("Minidumps")
+ .IsRequired()
+ .HasColumnType("bit");
+
+ b.Property("OpenDreamTopicPort")
+ .HasColumnType("int");
+
+ b.Property("Port")
+ .HasColumnType("int");
+
+ b.Property("SecurityLevel")
+ .HasColumnType("int");
+
+ b.Property("StartProfiler")
+ .IsRequired()
+ .HasColumnType("bit");
+
+ b.Property("StartupTimeout")
+ .HasColumnType("bigint");
+
+ b.Property("TopicRequestTimeout")
+ .HasColumnType("bigint");
+
+ b.Property("Visibility")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("InstanceId")
+ .IsUnique();
+
+ b.ToTable("DreamDaemonSettings");
+ });
+
+ modelBuilder.Entity("Tgstation.Server.Host.Models.DreamMakerSettings", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("ApiValidationPort")
+ .HasColumnType("int");
+
+ b.Property("ApiValidationSecurityLevel")
+ .HasColumnType("int");
+
+ b.Property("CompilerAdditionalArguments")
+ .HasMaxLength(10000)
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("DMApiValidationMode")
+ .HasColumnType("int");
+
+ b.Property("InstanceId")
+ .HasColumnType("bigint");
+
+ b.Property("ProjectName")
+ .HasMaxLength(10000)
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Timeout")
+ .IsRequired()
+ .HasColumnType("time");
+
+ b.HasKey("Id");
+
+ b.HasIndex("InstanceId")
+ .IsUnique();
+
+ b.ToTable("DreamMakerSettings");
+ });
+
+ modelBuilder.Entity("Tgstation.Server.Host.Models.Instance", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("AutoStartCron")
+ .IsRequired()
+ .HasMaxLength(1000)
+ .HasColumnType("nvarchar(1000)");
+
+ b.Property("AutoStopCron")
+ .IsRequired()
+ .HasMaxLength(1000)
+ .HasColumnType("nvarchar(1000)");
+
+ b.Property("AutoUpdateCron")
+ .IsRequired()
+ .HasMaxLength(1000)
+ .HasColumnType("nvarchar(1000)");
+
+ b.Property("AutoUpdateInterval")
+ .HasColumnType("bigint");
+
+ b.Property("ChatBotLimit")
+ .HasColumnType("int");
+
+ b.Property("ConfigurationType")
+ .HasColumnType("int");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("Online")
+ .IsRequired()
+ .HasColumnType("bit");
+
+ b.Property("Path")
+ .IsRequired()
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("SwarmIdentifer")
+ .HasColumnType("nvarchar(450)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Path", "SwarmIdentifer")
+ .IsUnique()
+ .HasFilter("[SwarmIdentifer] IS NOT NULL");
+
+ b.ToTable("Instances");
+ });
+
+ modelBuilder.Entity("Tgstation.Server.Host.Models.InstancePermissionSet", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("ChatBotRights")
+ .HasColumnType("decimal(20,0)");
+
+ b.Property("ConfigurationRights")
+ .HasColumnType("decimal(20,0)");
+
+ b.Property("DreamDaemonRights")
+ .HasColumnType("decimal(20,0)");
+
+ b.Property("DreamMakerRights")
+ .HasColumnType("decimal(20,0)");
+
+ b.Property("EngineRights")
+ .HasColumnType("decimal(20,0)");
+
+ b.Property("InstanceId")
+ .HasColumnType("bigint");
+
+ b.Property("InstancePermissionSetRights")
+ .HasColumnType("decimal(20,0)");
+
+ b.Property("PermissionSetId")
+ .HasColumnType("bigint");
+
+ b.Property("RepositoryRights")
+ .HasColumnType("decimal(20,0)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("InstanceId");
+
+ b.HasIndex("PermissionSetId", "InstanceId")
+ .IsUnique();
+
+ b.ToTable("InstancePermissionSets");
+ });
+
+ modelBuilder.Entity("Tgstation.Server.Host.Models.Job", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("CancelRight")
+ .HasColumnType("decimal(20,0)");
+
+ b.Property("CancelRightsType")
+ .HasColumnType("decimal(20,0)");
+
+ b.Property("Cancelled")
+ .IsRequired()
+ .HasColumnType("bit");
+
+ b.Property("CancelledById")
+ .HasColumnType("bigint");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("ErrorCode")
+ .HasColumnType("bigint");
+
+ b.Property("ExceptionDetails")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("InstanceId")
+ .HasColumnType("bigint");
+
+ b.Property("JobCode")
+ .HasColumnType("tinyint");
+
+ b.Property("StartedAt")
+ .IsRequired()
+ .HasColumnType("datetimeoffset");
+
+ b.Property("StartedById")
+ .HasColumnType("bigint");
+
+ b.Property("StoppedAt")
+ .HasColumnType("datetimeoffset");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CancelledById");
+
+ b.HasIndex("InstanceId");
+
+ b.HasIndex("StartedById");
+
+ b.ToTable("Jobs");
+ });
+
+ modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("ExternalUserId")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("Provider")
+ .HasColumnType("int");
+
+ b.Property("UserId")
+ .HasColumnType("bigint");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.HasIndex("Provider", "ExternalUserId")
+ .IsUnique();
+
+ b.ToTable("OAuthConnections");
+ });
+
+ modelBuilder.Entity("Tgstation.Server.Host.Models.OidcConnection", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("ExternalUserId")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("SchemeKey")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("UserId")
+ .HasColumnType("bigint");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.HasIndex("SchemeKey", "ExternalUserId")
+ .IsUnique();
+
+ b.ToTable("OidcConnections");
+ });
+
+ modelBuilder.Entity("Tgstation.Server.Host.Models.PermissionSet", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("AdministrationRights")
+ .HasColumnType("decimal(20,0)");
+
+ b.Property("GroupId")
+ .HasColumnType("bigint");
+
+ b.Property("InstanceManagerRights")
+ .HasColumnType("decimal(20,0)");
+
+ b.Property("UserId")
+ .HasColumnType("bigint");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GroupId")
+ .IsUnique()
+ .HasFilter("[GroupId] IS NOT NULL");
+
+ b.HasIndex("UserId")
+ .IsUnique()
+ .HasFilter("[UserId] IS NOT NULL");
+
+ b.ToTable("PermissionSets");
+ });
+
+ modelBuilder.Entity("Tgstation.Server.Host.Models.ReattachInformation", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("AccessIdentifier")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("CompileJobId")
+ .HasColumnType("bigint");
+
+ b.Property("InitialCompileJobId")
+ .HasColumnType("bigint");
+
+ b.Property("LaunchSecurityLevel")
+ .HasColumnType("int");
+
+ b.Property("LaunchVisibility")
+ .HasColumnType("int");
+
+ b.Property("Port")
+ .HasColumnType("int");
+
+ b.Property("ProcessId")
+ .HasColumnType("int");
+
+ b.Property("RebootState")
+ .HasColumnType("int");
+
+ b.Property("TopicPort")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CompileJobId");
+
+ b.HasIndex("InitialCompileJobId");
+
+ b.ToTable("ReattachInformations");
+ });
+
+ modelBuilder.Entity("Tgstation.Server.Host.Models.RepositorySettings", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("AccessToken")
+ .HasMaxLength(10000)
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("AccessUser")
+ .HasMaxLength(10000)
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("AutoUpdatesKeepTestMerges")
+ .IsRequired()
+ .HasColumnType("bit");
+
+ b.Property("AutoUpdatesSynchronize")
+ .IsRequired()
+ .HasColumnType("bit");
+
+ b.Property("CommitterEmail")
+ .IsRequired()
+ .HasMaxLength(10000)
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("CommitterName")
+ .IsRequired()
+ .HasMaxLength(10000)
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("CreateGitHubDeployments")
+ .IsRequired()
+ .HasColumnType("bit");
+
+ b.Property("InstanceId")
+ .HasColumnType("bigint");
+
+ b.Property("PostTestMergeComment")
+ .IsRequired()
+ .HasColumnType("bit");
+
+ b.Property("PushTestMergeCommits")
+ .IsRequired()
+ .HasColumnType("bit");
+
+ b.Property("ShowTestMergeCommitters")
+ .IsRequired()
+ .HasColumnType("bit");
+
+ b.Property("UpdateSubmodules")
+ .IsRequired()
+ .HasColumnType("bit");
+
+ b.HasKey("Id");
+
+ b.HasIndex("InstanceId")
+ .IsUnique();
+
+ b.ToTable("RepositorySettings");
+ });
+
+ modelBuilder.Entity("Tgstation.Server.Host.Models.RevInfoTestMerge", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("RevisionInformationId")
+ .HasColumnType("bigint");
+
+ b.Property("TestMergeId")
+ .HasColumnType("bigint");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RevisionInformationId");
+
+ b.HasIndex("TestMergeId");
+
+ b.ToTable("RevInfoTestMerges");
+ });
+
+ modelBuilder.Entity("Tgstation.Server.Host.Models.RevisionInformation", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("CommitSha")
+ .IsRequired()
+ .HasMaxLength(40)
+ .HasColumnType("nvarchar(40)");
+
+ b.Property("InstanceId")
+ .HasColumnType("bigint");
+
+ b.Property("OriginCommitSha")
+ .IsRequired()
+ .HasMaxLength(40)
+ .HasColumnType("nvarchar(40)");
+
+ b.Property("Timestamp")
+ .HasColumnType("datetimeoffset");
+
+ b.HasKey("Id");
+
+ b.HasIndex("InstanceId", "CommitSha")
+ .IsUnique();
+
+ b.ToTable("RevisionInformations");
+ });
+
+ modelBuilder.Entity("Tgstation.Server.Host.Models.TestMerge", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("Author")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("BodyAtMerge")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Comment")
+ .HasMaxLength(10000)
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("MergedAt")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("MergedById")
+ .HasColumnType("bigint");
+
+ b.Property("Number")
+ .HasColumnType("int");
+
+ b.Property("PrimaryRevisionInformationId")
+ .IsRequired()
+ .HasColumnType("bigint");
+
+ b.Property("TargetCommitSha")
+ .IsRequired()
+ .HasMaxLength(40)
+ .HasColumnType("nvarchar(40)");
+
+ b.Property("TitleAtMerge")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Url")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("MergedById");
+
+ b.HasIndex("PrimaryRevisionInformationId")
+ .IsUnique();
+
+ b.ToTable("TestMerges");
+ });
+
+ modelBuilder.Entity("Tgstation.Server.Host.Models.User", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("CanonicalName")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("CreatedAt")
+ .IsRequired()
+ .HasColumnType("datetimeoffset");
+
+ b.Property("CreatedById")
+ .HasColumnType("bigint");
+
+ b.Property("Enabled")
+ .IsRequired()
+ .HasColumnType("bit");
+
+ b.Property("GroupId")
+ .HasColumnType("bigint");
+
+ b.Property("LastPasswordUpdate")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("PasswordHash")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("SystemIdentifier")
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CanonicalName")
+ .IsUnique();
+
+ b.HasIndex("CreatedById");
+
+ b.HasIndex("GroupId");
+
+ b.HasIndex("SystemIdentifier")
+ .IsUnique()
+ .HasFilter("[SystemIdentifier] IS NOT NULL");
+
+ b.ToTable("Users");
+ });
+
+ modelBuilder.Entity("Tgstation.Server.Host.Models.UserGroup", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Name")
+ .IsUnique();
+
+ b.ToTable("Groups");
+ });
+
+ modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b =>
+ {
+ b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance")
+ .WithMany("ChatSettings")
+ .HasForeignKey("InstanceId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Instance");
+ });
+
+ modelBuilder.Entity("Tgstation.Server.Host.Models.ChatChannel", b =>
+ {
+ b.HasOne("Tgstation.Server.Host.Models.ChatBot", "ChatSettings")
+ .WithMany("Channels")
+ .HasForeignKey("ChatSettingsId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("ChatSettings");
+ });
+
+ modelBuilder.Entity("Tgstation.Server.Host.Models.CompileJob", b =>
+ {
+ b.HasOne("Tgstation.Server.Host.Models.Job", "Job")
+ .WithOne()
+ .HasForeignKey("Tgstation.Server.Host.Models.CompileJob", "JobId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "RevisionInformation")
+ .WithMany("CompileJobs")
+ .HasForeignKey("RevisionInformationId")
+ .OnDelete(DeleteBehavior.ClientNoAction)
+ .IsRequired();
+
+ b.Navigation("Job");
+
+ b.Navigation("RevisionInformation");
+ });
+
+ modelBuilder.Entity("Tgstation.Server.Host.Models.DreamDaemonSettings", b =>
+ {
+ b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance")
+ .WithOne("DreamDaemonSettings")
+ .HasForeignKey("Tgstation.Server.Host.Models.DreamDaemonSettings", "InstanceId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Instance");
+ });
+
+ modelBuilder.Entity("Tgstation.Server.Host.Models.DreamMakerSettings", b =>
+ {
+ b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance")
+ .WithOne("DreamMakerSettings")
+ .HasForeignKey("Tgstation.Server.Host.Models.DreamMakerSettings", "InstanceId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Instance");
+ });
+
+ modelBuilder.Entity("Tgstation.Server.Host.Models.InstancePermissionSet", b =>
+ {
+ b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance")
+ .WithMany("InstancePermissionSets")
+ .HasForeignKey("InstanceId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Tgstation.Server.Host.Models.PermissionSet", "PermissionSet")
+ .WithMany("InstancePermissionSets")
+ .HasForeignKey("PermissionSetId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Instance");
+
+ b.Navigation("PermissionSet");
+ });
+
+ modelBuilder.Entity("Tgstation.Server.Host.Models.Job", b =>
+ {
+ b.HasOne("Tgstation.Server.Host.Models.User", "CancelledBy")
+ .WithMany()
+ .HasForeignKey("CancelledById");
+
+ b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance")
+ .WithMany("Jobs")
+ .HasForeignKey("InstanceId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Tgstation.Server.Host.Models.User", "StartedBy")
+ .WithMany()
+ .HasForeignKey("StartedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("CancelledBy");
+
+ b.Navigation("Instance");
+
+ b.Navigation("StartedBy");
+ });
+
+ modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b =>
+ {
+ b.HasOne("Tgstation.Server.Host.Models.User", "User")
+ .WithMany("OAuthConnections")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("Tgstation.Server.Host.Models.OidcConnection", b =>
+ {
+ b.HasOne("Tgstation.Server.Host.Models.User", "User")
+ .WithMany("OidcConnections")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("Tgstation.Server.Host.Models.PermissionSet", b =>
+ {
+ b.HasOne("Tgstation.Server.Host.Models.UserGroup", "Group")
+ .WithOne("PermissionSet")
+ .HasForeignKey("Tgstation.Server.Host.Models.PermissionSet", "GroupId")
+ .OnDelete(DeleteBehavior.Cascade);
+
+ b.HasOne("Tgstation.Server.Host.Models.User", "User")
+ .WithOne("PermissionSet")
+ .HasForeignKey("Tgstation.Server.Host.Models.PermissionSet", "UserId")
+ .OnDelete(DeleteBehavior.Cascade);
+
+ b.Navigation("Group");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("Tgstation.Server.Host.Models.ReattachInformation", b =>
+ {
+ b.HasOne("Tgstation.Server.Host.Models.CompileJob", "CompileJob")
+ .WithMany()
+ .HasForeignKey("CompileJobId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Tgstation.Server.Host.Models.CompileJob", "InitialCompileJob")
+ .WithMany()
+ .HasForeignKey("InitialCompileJobId");
+
+ b.Navigation("CompileJob");
+
+ b.Navigation("InitialCompileJob");
+ });
+
+ modelBuilder.Entity("Tgstation.Server.Host.Models.RepositorySettings", b =>
+ {
+ b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance")
+ .WithOne("RepositorySettings")
+ .HasForeignKey("Tgstation.Server.Host.Models.RepositorySettings", "InstanceId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Instance");
+ });
+
+ modelBuilder.Entity("Tgstation.Server.Host.Models.RevInfoTestMerge", b =>
+ {
+ b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "RevisionInformation")
+ .WithMany("ActiveTestMerges")
+ .HasForeignKey("RevisionInformationId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Tgstation.Server.Host.Models.TestMerge", "TestMerge")
+ .WithMany("RevisonInformations")
+ .HasForeignKey("TestMergeId")
+ .OnDelete(DeleteBehavior.ClientNoAction)
+ .IsRequired();
+
+ b.Navigation("RevisionInformation");
+
+ b.Navigation("TestMerge");
+ });
+
+ modelBuilder.Entity("Tgstation.Server.Host.Models.RevisionInformation", b =>
+ {
+ b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance")
+ .WithMany("RevisionInformations")
+ .HasForeignKey("InstanceId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Instance");
+ });
+
+ modelBuilder.Entity("Tgstation.Server.Host.Models.TestMerge", b =>
+ {
+ b.HasOne("Tgstation.Server.Host.Models.User", "MergedBy")
+ .WithMany("TestMerges")
+ .HasForeignKey("MergedById")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "PrimaryRevisionInformation")
+ .WithOne("PrimaryTestMerge")
+ .HasForeignKey("Tgstation.Server.Host.Models.TestMerge", "PrimaryRevisionInformationId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("MergedBy");
+
+ b.Navigation("PrimaryRevisionInformation");
+ });
+
+ modelBuilder.Entity("Tgstation.Server.Host.Models.User", b =>
+ {
+ b.HasOne("Tgstation.Server.Host.Models.User", "CreatedBy")
+ .WithMany("CreatedUsers")
+ .HasForeignKey("CreatedById");
+
+ b.HasOne("Tgstation.Server.Host.Models.UserGroup", "Group")
+ .WithMany("Users")
+ .HasForeignKey("GroupId");
+
+ b.Navigation("CreatedBy");
+
+ b.Navigation("Group");
+ });
+
+ modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b =>
+ {
+ b.Navigation("Channels");
+ });
+
+ modelBuilder.Entity("Tgstation.Server.Host.Models.Instance", b =>
+ {
+ b.Navigation("ChatSettings");
+
+ b.Navigation("DreamDaemonSettings");
+
+ b.Navigation("DreamMakerSettings");
+
+ b.Navigation("InstancePermissionSets");
+
+ b.Navigation("Jobs");
+
+ b.Navigation("RepositorySettings");
+
+ b.Navigation("RevisionInformations");
+ });
+
+ modelBuilder.Entity("Tgstation.Server.Host.Models.PermissionSet", b =>
+ {
+ b.Navigation("InstancePermissionSets");
+ });
+
+ modelBuilder.Entity("Tgstation.Server.Host.Models.RevisionInformation", b =>
+ {
+ b.Navigation("ActiveTestMerges");
+
+ b.Navigation("CompileJobs");
+
+ b.Navigation("PrimaryTestMerge");
+ });
+
+ modelBuilder.Entity("Tgstation.Server.Host.Models.TestMerge", b =>
+ {
+ b.Navigation("RevisonInformations");
+ });
+
+ modelBuilder.Entity("Tgstation.Server.Host.Models.User", b =>
+ {
+ b.Navigation("CreatedUsers");
+
+ b.Navigation("OAuthConnections");
+
+ b.Navigation("OidcConnections");
+
+ b.Navigation("PermissionSet");
+
+ b.Navigation("TestMerges");
+ });
+
+ modelBuilder.Entity("Tgstation.Server.Host.Models.UserGroup", b =>
+ {
+ b.Navigation("PermissionSet")
+ .IsRequired();
+
+ b.Navigation("Users");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/Tgstation.Server.Host/Database/Migrations/20250302232550_MSAddOidcConnections.cs b/src/Tgstation.Server.Host/Database/Migrations/20250302232550_MSAddOidcConnections.cs
new file mode 100644
index 0000000000..d176cbea59
--- /dev/null
+++ b/src/Tgstation.Server.Host/Database/Migrations/20250302232550_MSAddOidcConnections.cs
@@ -0,0 +1,57 @@
+using System;
+
+using Microsoft.EntityFrameworkCore.Migrations;
+
+namespace Tgstation.Server.Host.Database.Migrations
+{
+ ///
+ public partial class MSAddOidcConnections : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ ArgumentNullException.ThrowIfNull(migrationBuilder);
+
+ migrationBuilder.CreateTable(
+ name: "OidcConnections",
+ columns: table => new
+ {
+ Id = table.Column(type: "bigint", nullable: false)
+ .Annotation("SqlServer:Identity", "1, 1"),
+ UserId = table.Column(type: "bigint", nullable: false),
+ SchemeKey = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false),
+ ExternalUserId = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false),
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_OidcConnections", x => x.Id);
+ table.ForeignKey(
+ name: "FK_OidcConnections_Users_UserId",
+ column: x => x.UserId,
+ principalTable: "Users",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_OidcConnections_SchemeKey_ExternalUserId",
+ table: "OidcConnections",
+ columns: new[] { "SchemeKey", "ExternalUserId" },
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_OidcConnections_UserId",
+ table: "OidcConnections",
+ column: "UserId");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ ArgumentNullException.ThrowIfNull(migrationBuilder);
+
+ migrationBuilder.DropTable(
+ name: "OidcConnections");
+ }
+ }
+}
diff --git a/src/Tgstation.Server.Host/Database/Migrations/20250302232601_MYAddOidcConnections.Designer.cs b/src/Tgstation.Server.Host/Database/Migrations/20250302232601_MYAddOidcConnections.Designer.cs
new file mode 100644
index 0000000000..ce403613c5
--- /dev/null
+++ b/src/Tgstation.Server.Host/Database/Migrations/20250302232601_MYAddOidcConnections.Designer.cs
@@ -0,0 +1,1217 @@
+//
+using System;
+
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+namespace Tgstation.Server.Host.Database.Migrations
+{
+ [DbContext(typeof(MySqlDatabaseContext))]
+ [Migration("20250302232601_MYAddOidcConnections")]
+ partial class MYAddOidcConnections
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "8.0.13")
+ .HasAnnotation("Relational:MaxIdentifierLength", 64);
+
+ MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
+
+ modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("ChannelLimit")
+ .IsRequired()
+ .HasColumnType("smallint unsigned");
+
+ b.Property("ConnectionString")
+ .IsRequired()
+ .HasMaxLength(10000)
+ .HasColumnType("longtext");
+
+ MySqlPropertyBuilderExtensions.HasCharSet(b.Property("ConnectionString"), "utf8mb4");
+
+ b.Property("Enabled")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("InstanceId")
+ .HasColumnType("bigint");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("varchar(100)");
+
+ b.Property("Provider")
+ .HasColumnType("int");
+
+ b.Property("ReconnectionInterval")
+ .IsRequired()
+ .HasColumnType("int unsigned");
+
+ b.HasKey("Id");
+
+ b.HasIndex("InstanceId", "Name")
+ .IsUnique();
+
+ b.ToTable("ChatBots");
+ });
+
+ modelBuilder.Entity("Tgstation.Server.Host.Models.ChatChannel", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("ChatSettingsId")
+ .HasColumnType("bigint");
+
+ b.Property("DiscordChannelId")
+ .HasColumnType("bigint unsigned");
+
+ b.Property