diff --git a/.github/workflows/CI-CT-CD.yml b/.github/workflows/CI-CT-CD.yml
index d7714d0..ab4f0de 100644
--- a/.github/workflows/CI-CT-CD.yml
+++ b/.github/workflows/CI-CT-CD.yml
@@ -1,4 +1,4 @@
-name: CI - CT - CD
+name: Continuous Integration - Testing - Deploy
on:
push:
@@ -91,6 +91,7 @@ jobs:
if ($coverageFiles.Count -eq 0) {
"## Coverage gate`n`nNo Coverlet XML was found under the test-results GUID subdirectories." | Out-File $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8
+ Write-Error "Coverage data was not produced."
return
}
@@ -99,27 +100,21 @@ jobs:
$lineRate = [double]$coverage.coverage.'line-rate' * 100
$branchRate = [double]$coverage.coverage.'branch-rate' * 100
- @"
+ @"
## Coverage gate
| Metric | Result | Minimum | Status |
| --- | ---: | ---: | --- |
+ | Line coverage | $([math]::Round($lineRate, 2))% | 80% | $(if ($lineRate -ge 80) { '✅' } else { '❌' }) |
+ | Branch coverage | $([math]::Round($branchRate, 2))% | 80% | $(if ($branchRate -ge 80) { '✅' } else { '❌' }) |
"@ | Out-File $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8
+ "LINE_COVERAGE=$([math]::Round($lineRate, 2))" | Out-File artifacts/line-rate.txt -Encoding utf8
+ "BRANCH_COVERAGE=$([math]::Round($branchRate, 2))" | Out-File artifacts/branch-rate.txt -Encoding utf8
- if ($lineRate -ge 80) {
- "| Line coverage | $([math]::Round($lineRate, 2))% | 80% | ✅ |" | Out-File $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8
- } else {
- "| Line coverage | $([math]::Round($lineRate, 2))% | 80% | ❌ |" | Out-File $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8
+ if ($lineRate -lt 80 -or $branchRate -lt 80) {
+ Write-Error "Coverage gate failed. Line coverage: $([math]::Round($lineRate, 2))%; Branch coverage: $([math]::Round($branchRate, 2))%; minimum: 80%."
}
- if ($branchRate -ge 80) {
- "| Branch coverage | $([math]::Round($branchRate, 2))% | 80% | ✅ |" | Out-File $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8
- } else {
- "| Branch coverage | $([math]::Round($branchRate, 2))% | 80% | ❌ |" | Out-File $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8
- }
-
-
-
- name: Append test execution report
if: always()
shell: pwsh
diff --git a/src/server/RemoteTickets.Application/Common/System/SystemContracts.cs b/src/server/RemoteTickets.Application/Common/System/SystemContracts.cs
new file mode 100644
index 0000000..34e3552
--- /dev/null
+++ b/src/server/RemoteTickets.Application/Common/System/SystemContracts.cs
@@ -0,0 +1,29 @@
+namespace RemoteTickets.Application.Common.System;
+
+/// Represents the master database setup payload.
+/// The SQL Server connection string used by the tenant catalog.
+/// The value (in seconds) for use for operations timeout in SQL Server.
+public sealed record MasterDatabaseSetupRequest(
+ string ConnectionString,
+ int CommandTimeout);
+
+/// Represents the first-time setup administrator credentials.
+/// The administrator email address.
+/// The administrator password.
+public sealed record InitializeSetupRequest(string Email, string Password);
+
+///
+/// Contract for System Service
+///
+public interface ISystemService
+{
+ ///
+ /// Performs the setup of master database
+ ///
+ /// Connection string of master database
+ /// Timeout for operations on master database
+ /// Cancellation token for the operation
+ /// An with the status of setup operation
+ Task PerformMasterDatabaseSetup(string connectionString, int commandTimeout, CancellationToken cancellationToken);
+}
+
diff --git a/src/server/RemoteTickets.Application/Common/Tenancy/TenancyContracts.cs b/src/server/RemoteTickets.Application/Common/Tenancy/TenancyContracts.cs
new file mode 100644
index 0000000..7a6d287
--- /dev/null
+++ b/src/server/RemoteTickets.Application/Common/Tenancy/TenancyContracts.cs
@@ -0,0 +1,93 @@
+namespace RemoteTickets.Application.Common.Tenancy;
+
+/// Contains the application roles used by the tenant-aware authorization model.
+public static class TenantRoles
+{
+ /// Gets the role that can administer the complete installation and every tenant.
+ public const string SysAdmin = "sysadmin";
+ /// Gets the role that can administer a single tenant.
+ public const string TenantAdmin = "tenadmin";
+ /// Gets the role that can operate the cash register and other non-configuration business functions.
+ public const string TenantOperator = "tenop";
+ /// Gets the role that can perform attendance and non-critical business operations.
+ public const string TenantAttendant = "tenat";
+}
+
+/// Contains claim names used by the tenant-aware security model.
+public static class TenantClaimTypes
+{
+ /// Gets the claim containing the tenant identifier assigned to a user.
+ public const string TenantId = "tenant_id";
+}
+
+/// Contains authorization policy names used by tenant-aware endpoints.
+public static class TenantPolicies
+{
+ /// Gets the policy requiring system administration privileges.
+ public const string SysAdmin = "tenant.sysadmin";
+ /// Gets the policy requiring tenant administration privileges.
+ public const string TenantAdmin = "tenant.admin";
+ /// Gets the policy requiring operator privileges.
+ public const string TenantOperator = "tenant.operator";
+ /// Gets the policy requiring attendant privileges.
+ public const string TenantAttendant = "tenant.attendant";
+}
+
+/// Represents the setup state of a tenant database.
+/// Indicates whether tenant setup is still required.
+/// Indicates whether tenant setup has completed.
+public sealed record TenantSetupStatusResponse(bool IsSetupRequired, bool IsSetupComplete) : IResponse;
+
+/// Represents a tenant exposed to system administrators.
+/// The stable tenant identifier used in routes.
+/// The tenant display name.
+/// The database name assigned to the tenant.
+/// Indicates whether the tenant accepts normal operations.
+/// Indicates whether tenant setup has completed.
+public sealed record TenantResponse(string Id, string Name, string DatabaseName, bool IsActive, bool IsSetupComplete) : IResponse;
+
+/// Represents a request to provision a tenant and its first administrator.
+/// The tenant identifier.
+/// The tenant display name.
+/// The SQL Server database name.
+/// The connection string for the tenant database.
+/// The first tenant administrator email.
+/// The first tenant administrator password.
+public sealed record CreateTenantRequest(string Id, string Name, string DatabaseName, string ConnectionString, string AdminEmail, string AdminPassword)
+{
+ public CreateTenantCommand ToCommand()
+ => new ((string.IsNullOrEmpty(Name) || string.IsNullOrWhiteSpace(Name) ? throw new ArgumentNullException(nameof(Name)) : Name),
+ (string.IsNullOrEmpty(DatabaseName) || string.IsNullOrWhiteSpace(DatabaseName)? throw new ArgumentNullException(nameof(DatabaseName)) : DatabaseName),
+ (string.IsNullOrEmpty(ConnectionString) || string.IsNullOrWhiteSpace(ConnectionString)? throw new ArgumentNullException(nameof(ConnectionString)) : ConnectionString),
+ (string.IsNullOrEmpty(AdminEmail) || string.IsNullOrWhiteSpace(AdminEmail)? throw new ArgumentNullException(nameof(AdminEmail)) : AdminEmail),
+ (string.IsNullOrEmpty(AdminPassword) || string.IsNullOrWhiteSpace(AdminPassword)? throw new ArgumentNullException(nameof(AdminPassword)) : AdminPassword));
+}
+
+/// Provides tenant registration and setup operations.
+public interface ITenantManagementService
+{
+ /// Gets a tenant by its route identifier.
+ /// The tenant route identifier.
+ /// The token used to cancel the operation.
+ /// The tenant, or when it does not exist.
+ Task GetAsync(string tenantId, CancellationToken cancellationToken);
+
+ /// Creates a tenant database and its first tenant administrator.
+ /// The tenant provisioning request.
+ /// The token used to cancel the operation.
+ /// The provisioned tenant.
+ Task CreateAsync(CreateTenantRequest request, CancellationToken cancellationToken);
+
+ /// Gets the setup state stored in a tenant database.
+ /// The tenant route identifier.
+ /// The token used to cancel the operation.
+ /// The tenant setup state.
+ Task GetSetupStatusAsync(string tenantId, CancellationToken cancellationToken);
+
+ /// Completes tenant setup after all tenant initialization checks have succeeded.
+ /// The tenant route identifier.
+ /// The token used to cancel the operation.
+ /// The resulting setup state.
+ Task CompleteSetupAsync(string tenantId, CancellationToken cancellationToken);
+}
+
diff --git a/src/server/RemoteTickets.Application/Common/Util/RemoteTicketsConstants.cs b/src/server/RemoteTickets.Application/Common/Util/RemoteTicketsConstants.cs
new file mode 100644
index 0000000..f908e76
--- /dev/null
+++ b/src/server/RemoteTickets.Application/Common/Util/RemoteTicketsConstants.cs
@@ -0,0 +1,7 @@
+namespace RemoteTickets.Application.Common.Util;
+
+public static class RemoteTicketsConstants
+{
+ public const string SystemSetupId = "01a00d95-87a3-7d90-b45c-759bf28c5d50";
+ public const string TenantSetupId = "01a00e8b-ffe7-7186-aa29-46980b4cdbd7";
+}
diff --git a/src/server/RemoteTickets.Application/GlobalUsings.cs b/src/server/RemoteTickets.Application/GlobalUsings.cs
index a4a7c89..648656f 100644
--- a/src/server/RemoteTickets.Application/GlobalUsings.cs
+++ b/src/server/RemoteTickets.Application/GlobalUsings.cs
@@ -1,13 +1,19 @@
global using System;
global using System.Collections.Generic;
+global using System.Data.Common;
global using System.Linq;
global using System.Security.Claims;
global using System.Threading;
global using System.Threading.Tasks;
global using FluentValidation;
global using FluentValidation.Results;
+global using FluentValidation.Validators;
global using Mediator.Net.Context;
global using Mediator.Net.Contracts;
global using Mediator.Net.Pipeline;
global using RemoteTickets.Application.Common.Identity;
+global using RemoteTickets.Application.Common.System;
+global using RemoteTickets.Application.Common.Tenancy;
global using RemoteTickets.Application.Identity.Requests;
+global using RemoteTickets.Application.System.Requests;
+global using RemoteTickets.Application.Tenant.Requests;
\ No newline at end of file
diff --git a/src/server/RemoteTickets.Application/Identity/Handlers/IdentityHandlers.cs b/src/server/RemoteTickets.Application/Identity/Handlers/IdentityHandlers.cs
index e3d27f7..607c553 100644
--- a/src/server/RemoteTickets.Application/Identity/Handlers/IdentityHandlers.cs
+++ b/src/server/RemoteTickets.Application/Identity/Handlers/IdentityHandlers.cs
@@ -1,8 +1,8 @@
namespace RemoteTickets.Application.Identity.Handlers;
/// Handles the identity requests.
-/// The identity service that performs registration.
-public sealed class IdentityHandlers(IIdentityService identityService) :
+/// The identity service that performs registration and authentication.
+public sealed class IdentityHandlers(IIdentityService identityService) :
IRequestHandler,
IRequestHandler>,
IRequestHandler>,
@@ -18,104 +18,47 @@ public sealed class IdentityHandlers(IIdentityService identityService) :
IRequestHandler
{
/// Executes the registration request.
- /// The message context containing the registration request.
- /// The token used to cancel the operation.
- /// The registration result.
- public Task Handle(IReceiveContext context, CancellationToken cancellationToken) =>
- identityService.RegisterAsync(context.Message.Email, context.Message.Password, cancellationToken);
-
- /// Executes the login request and converts authentication failure to an application response.
- /// The message context containing the login request.
- /// The token used to cancel the operation.
- /// The authentication response containing tokens when successful.
+ public Task Handle(IReceiveContext context, CancellationToken cancellationToken) => identityService.RegisterAsync(context.Message.Email, context.Message.Password, cancellationToken);
+ /// Executes the login request.
public async Task> Handle(IReceiveContext context, CancellationToken cancellationToken)
{
- var result = await identityService.LoginAsync(context.Message.Email, context.Message.Password, context.Message.TwoFactorCode, context.Message.TwoFactorRecoveryCode, cancellationToken);
+ LoginCommand message = context.Message;
+ TokenResponse? result = message.TenantId is null ? await identityService.LoginAsync(message.Email, message.Password, message.TwoFactorCode, message.TwoFactorRecoveryCode, cancellationToken) : await identityService.LoginAsync(message.TenantId, message.Email, message.Password, message.TwoFactorCode, message.TwoFactorRecoveryCode, cancellationToken);
return result is null ? Response.Failure(["Invalid credentials."]) : Response.Success(result);
}
- /// Executes a refresh-token exchange.
- /// The message context containing the refresh token.
- /// The token used to cancel the operation.
- /// The refreshed token response.
+ /// Executes a tenant-bound refresh-token exchange.
public async Task> Handle(IReceiveContext context, CancellationToken cancellationToken)
{
- var result = await identityService.RefreshAsync(context.Message.RefreshToken, cancellationToken);
+ RefreshTokenCommand message = context.Message;
+ TokenResponse? result = message.TenantId is null ? await identityService.RefreshAsync(message.RefreshToken, cancellationToken) : await identityService.RefreshAsync(message.TenantId, message.RefreshToken, cancellationToken);
return result is null ? Response.Failure(["Invalid refresh token."]) : Response.Success(result);
}
/// Executes token revocation.
- /// The message context containing the access token.
- /// The token used to cancel the operation.
- /// A response indicating whether revocation succeeded.
- public async Task> Handle(IReceiveContext context, CancellationToken cancellationToken) =>
- Response.Success(await identityService.RevokeAsync(context.Message.AccessToken, cancellationToken));
-
+ public async Task> Handle(IReceiveContext context, CancellationToken cancellationToken) => Response.Success(await identityService.RevokeAsync(context.Message.AccessToken, cancellationToken));
/// Executes email confirmation.
- /// The message context containing the confirmation request.
- /// The token used to cancel the operation.
- /// A response indicating whether confirmation succeeded.
- public async Task> Handle(IReceiveContext context, CancellationToken cancellationToken) =>
- Response.Success(await identityService.ConfirmEmailAsync(context.Message.UserId, context.Message.Code, context.Message.ChangedEmail, cancellationToken));
-
+ public async Task> Handle(IReceiveContext context, CancellationToken cancellationToken) => Response.Success(await identityService.ConfirmEmailAsync(context.Message.UserId, context.Message.Code, context.Message.ChangedEmail, cancellationToken));
/// Executes a confirmation-email resend request.
- /// The message context containing the email address.
- /// The token used to cancel the operation.
- /// The resend result.
- public Task Handle(IReceiveContext context, CancellationToken cancellationToken) =>
- identityService.ResendConfirmationEmailAsync(context.Message.Email, cancellationToken);
-
+ public Task Handle(IReceiveContext context, CancellationToken cancellationToken) => identityService.ResendConfirmationEmailAsync(context.Message.Email, cancellationToken);
/// Executes a password-recovery request.
- /// The message context containing the email address.
- /// The token used to cancel the operation.
- /// The recovery result.
- public Task Handle(IReceiveContext context, CancellationToken cancellationToken) =>
- identityService.ForgotPasswordAsync(context.Message.Email, cancellationToken);
-
+ public Task Handle(IReceiveContext context, CancellationToken cancellationToken) => identityService.ForgotPasswordAsync(context.Message.Email, cancellationToken);
/// Executes a password reset.
- /// The message context containing the reset request.
- /// The token used to cancel the operation.
- /// The reset result.
- public Task Handle(IReceiveContext context, CancellationToken cancellationToken) =>
- identityService.ResetPasswordAsync(context.Message.Email, context.Message.ResetCode, context.Message.NewPassword, cancellationToken);
-
+ public Task Handle(IReceiveContext context, CancellationToken cancellationToken) => identityService.ResetPasswordAsync(context.Message.Email, context.Message.ResetCode, context.Message.NewPassword, cancellationToken);
/// Executes an identity information query.
- /// The message context containing the user identifier.
- /// The token used to cancel the operation.
- /// The identity information response.
public async Task> Handle(IReceiveContext context, CancellationToken cancellationToken)
{
- var result = await identityService.GetInfoAsync(context.Message.UserId, cancellationToken);
+ IdentityInfoResponse? result = await identityService.GetInfoAsync(context.Message.UserId, cancellationToken);
return result is null ? Response.Failure(["User not found."]) : Response.Success(result);
}
-
/// Executes an identity information update.
- /// The message context containing the update request.
- /// The token used to cancel the operation.
- /// The update result.
- public Task Handle(IReceiveContext context, CancellationToken cancellationToken) =>
- identityService.UpdateInfoAsync(context.Message.UserId, context.Message.NewEmail, context.Message.NewPassword, context.Message.OldPassword, cancellationToken);
-
+ public Task Handle(IReceiveContext context, CancellationToken cancellationToken) => identityService.UpdateInfoAsync(context.Message.UserId, context.Message.NewEmail, context.Message.NewPassword, context.Message.OldPassword, cancellationToken);
/// Executes two-factor configuration.
- /// The message context containing the configuration request.
- /// The token used to cancel the operation.
- /// The resulting two-factor configuration.
public async Task> Handle(IReceiveContext context, CancellationToken cancellationToken)
{
- var result = await identityService.ConfigureTwoFactorAsync(context.Message.UserId, context.Message.Enable, context.Message.TwoFactorCode, context.Message.ResetRecoveryCodes, context.Message.ResetSharedKey, context.Message.ForgetMachine, cancellationToken);
+ TwoFactorResponse? result = await identityService.ConfigureTwoFactorAsync(context.Message.UserId, context.Message.Enable, context.Message.TwoFactorCode, context.Message.ResetRecoveryCodes, context.Message.ResetSharedKey, context.Message.ForgetMachine, cancellationToken);
return result is null ? Response.Failure(["The two-factor configuration request is invalid."]) : Response.Success(result);
}
-
/// Reads the current setup status.
- /// The message context containing the query.
- /// The token used to cancel the operation.
- /// The current setup status.
- public Task Handle(IReceiveContext context, CancellationToken cancellationToken) =>
- identityService.GetSetupStatusAsync(cancellationToken);
-
-
+ public Task Handle(IReceiveContext context, CancellationToken cancellationToken) => identityService.GetSetupStatusAsync(cancellationToken);
/// Executes first-time setup initialization.
- /// The message context containing setup credentials.
- /// The token used to cancel the operation.
- /// The setup result.
- public Task Handle(IReceiveContext context, CancellationToken cancellationToken) =>
- identityService.InitializeSetupAsync(context.Message.Email, context.Message.Password, cancellationToken);
+ public Task Handle(IReceiveContext context, CancellationToken cancellationToken) => identityService.InitializeSetupAsync(context.Message.Email, context.Message.Password, cancellationToken);
}
diff --git a/src/server/RemoteTickets.Application/Identity/IIdentityService.cs b/src/server/RemoteTickets.Application/Identity/IIdentityService.cs
index be2c2e8..b263b25 100644
--- a/src/server/RemoteTickets.Application/Identity/IIdentityService.cs
+++ b/src/server/RemoteTickets.Application/Identity/IIdentityService.cs
@@ -4,96 +4,35 @@ namespace RemoteTickets.Application.Identity;
public interface IIdentityService
{
/// Registers a user and starts email confirmation.
- /// The user's email address.
- /// The user's password.
- /// The token used to cancel the operation.
- /// The registration result.
Task RegisterAsync(string email, string password, CancellationToken cancellationToken);
-
/// Authenticates a user and issues JWT tokens when all required factors are valid.
- /// The user's email address.
- /// The user's password.
- /// The authenticator code, when two-factor authentication is enabled.
- /// The recovery code, when a recovery code is used instead of an authenticator code.
- /// The token used to cancel the operation.
- /// The token pair, or when authentication fails.
Task LoginAsync(string email, string password, string? twoFactorCode, string? twoFactorRecoveryCode, CancellationToken cancellationToken);
-
+ /// Authenticates a user against a tenant route and rejects users assigned to another tenant.
+ Task LoginAsync(string tenantId, string email, string password, string? twoFactorCode, string? twoFactorRecoveryCode, CancellationToken cancellationToken);
+ /// Creates the first administrator account for a tenant in the central identity store.
+ Task CreateTenantAdminAsync(string tenantId, string email, string password, CancellationToken cancellationToken);
/// Exchanges a valid refresh token for a new token pair.
- /// The refresh token to exchange.
- /// The token used to cancel the operation.
- /// The new token pair, or when the refresh token is invalid.
Task RefreshAsync(string refreshToken, CancellationToken cancellationToken);
-
+ /// Exchanges a refresh token while enforcing its tenant route binding.
+ Task RefreshAsync(string tenantId, string refreshToken, CancellationToken cancellationToken);
/// Revokes an access token until its natural expiration.
- /// The access token to revoke.
- /// The token used to cancel the operation.
- /// when revocation succeeds; otherwise .
Task RevokeAsync(string accessToken, CancellationToken cancellationToken);
-
/// Confirms a user's email address or a changed email address.
- /// The user's identifier.
- /// The confirmation token.
- /// The replacement email address when confirming an email change.
- /// The token used to cancel the operation.
- /// when confirmation succeeds; otherwise .
Task ConfirmEmailAsync(string userId, string code, string? changedEmail, CancellationToken cancellationToken);
-
/// Resends email confirmation when required.
- /// The user's email address.
- /// The token used to cancel the operation.
- /// The resend operation result.
Task ResendConfirmationEmailAsync(string email, CancellationToken cancellationToken);
-
/// Starts password recovery for a password-bearing user.
- /// The user's email address.
- /// The token used to cancel the operation.
- /// A result that does not reveal whether an email is registered.
Task ForgotPasswordAsync(string email, CancellationToken cancellationToken);
-
/// Resets a user's password with a valid reset token.
- /// The user's email address.
- /// The password reset token.
- /// The replacement password.
- /// The token used to cancel the operation.
- /// The reset operation result.
Task ResetPasswordAsync(string email, string resetCode, string newPassword, CancellationToken cancellationToken);
-
/// Gets basic identity information for a user.
- /// The user's identifier.
- /// The token used to cancel the operation.
- /// The identity information, or when the user does not exist.
Task GetInfoAsync(string userId, CancellationToken cancellationToken);
-
/// Updates identity information after validating the current password.
- /// The user's identifier.
- /// The replacement email address, or to keep the current address.
- /// The replacement password, or to keep the current password.
- /// The current password.
- /// The token used to cancel the operation.
- /// The update operation result.
Task UpdateInfoAsync(string userId, string? newEmail, string? newPassword, string oldPassword, CancellationToken cancellationToken);
-
/// Configures authenticator-based two-factor authentication and recovery material.
- /// The user's identifier.
- /// Whether to enable or disable two-factor authentication.
- /// The authenticator code used to validate enabling two-factor authentication.
- /// Whether recovery codes should be regenerated.
- /// Whether the authenticator shared key should be regenerated.
- /// Whether the current machine should be forgotten.
- /// The token used to cancel the operation.
- /// The current configuration, or when the operation cannot be completed.
Task ConfigureTwoFactorAsync(string userId, bool? enable, string? twoFactorCode, bool resetRecoveryCodes, bool resetSharedKey, bool forgetMachine, CancellationToken cancellationToken);
-
/// Gets the current first-time setup status.
- /// The token used to cancel the operation.
- /// The setup status.
Task GetSetupStatusAsync(CancellationToken cancellationToken);
-
/// Creates the initial administrator account when setup has not yet completed.
- /// The administrator email address.
- /// The administrator password.
- /// The token used to cancel the operation.
- /// The setup operation result.
Task InitializeSetupAsync(string email, string password, CancellationToken cancellationToken);
}
diff --git a/src/server/RemoteTickets.Application/Identity/Requests/IdentityRequests.cs b/src/server/RemoteTickets.Application/Identity/Requests/IdentityRequests.cs
index 8a79fef..d3fccec 100644
--- a/src/server/RemoteTickets.Application/Identity/Requests/IdentityRequests.cs
+++ b/src/server/RemoteTickets.Application/Identity/Requests/IdentityRequests.cs
@@ -10,74 +10,47 @@ public sealed record RegisterCommand(string Email, string Password) : IRequest;
/// The user's password.
/// The authenticator code, when required.
/// The recovery code, when used instead of an authenticator code.
-public sealed record LoginCommand(
- string Email,
- string Password,
- string? TwoFactorCode = null,
- string? TwoFactorRecoveryCode = null) : IRequest;
+/// The tenant route identifier.
+public sealed record LoginCommand(string Email, string Password, string? TwoFactorCode = null, string? TwoFactorRecoveryCode = null, string? TenantId = null) : IRequest;
/// Requests exchange of a refresh token.
/// The refresh token to exchange.
-public sealed record RefreshTokenCommand(string RefreshToken) : IRequest;
+/// The tenant route identifier used to validate token scope.
+public sealed record RefreshTokenCommand(string RefreshToken, string? TenantId = null) : IRequest;
/// Requests revocation of an access token.
/// The access token to revoke.
public sealed record RevokeTokenCommand(string AccessToken) : IRequest;
-
/// Requests confirmation of a user's email address.
/// The user identifier.
/// The confirmation token.
/// The replacement email address when confirming an email change.
public sealed record ConfirmEmailCommand(string UserId, string Code, string? ChangedEmail = null) : IRequest;
-
/// Requests that an email confirmation link be sent again.
/// The user's email address.
public sealed record ResendConfirmationEmailCommand(string Email) : IRequest;
-
/// Starts password recovery for an email address.
/// The email address to recover.
public sealed record ForgotPasswordCommand(string Email) : IRequest;
-
/// Requests a password reset.
/// The user's email address.
/// The password reset token.
/// The replacement password.
public sealed record ResetPasswordCommand(string Email, string ResetCode, string NewPassword) : IRequest;
-
/// Requests basic identity information for a user.
/// The user identifier.
public sealed record GetIdentityInfoQuery(string UserId) : IRequest;
-
/// Requests an update to identity information.
/// The user identifier.
-/// The replacement email address, or to keep the current address.
-/// The replacement password, or to keep the current password.
-/// The current password used to authorize the change.
-public sealed record UpdateIdentityInfoCommand(
- string UserId,
- string? NewEmail,
- string? NewPassword,
- string OldPassword) : IRequest;
-
+/// The replacement email address.
+/// The replacement password.
+/// The current password.
+public sealed record UpdateIdentityInfoCommand(string UserId, string? NewEmail, string? NewPassword, string OldPassword) : IRequest;
/// Requests a change to authenticator-based two-factor authentication.
/// The user identifier.
/// Whether to enable or disable two-factor authentication.
-/// The authenticator code used when enabling two-factor authentication.
+/// The authenticator code.
/// Whether recovery codes should be regenerated.
-/// Whether the authenticator shared key should be regenerated.
-/// Whether the remembered machine state should be cleared.
-public sealed record ConfigureTwoFactorCommand(
- string UserId,
- bool? Enable,
- string? TwoFactorCode,
- bool ResetRecoveryCodes,
- bool ResetSharedKey,
- bool ForgetMachine) : IRequest;
-
-/// Requests the current first-time setup status.
-public sealed record GetSetupStatusQuery : IRequest;
-
-/// Requests creation of the initial administrator account.
-/// The administrator email address.
-/// The administrator password.
-public sealed record InitializeSetupCommand(string Email, string Password) : IRequest;
+/// Whether the shared key should be regenerated.
+/// Whether remembered-machine state should be cleared.
+public sealed record ConfigureTwoFactorCommand(string UserId, bool? Enable, string? TwoFactorCode, bool ResetRecoveryCodes, bool ResetSharedKey, bool ForgetMachine) : IRequest;
diff --git a/src/server/RemoteTickets.Application/System/Handlers/SystemHandlers.cs b/src/server/RemoteTickets.Application/System/Handlers/SystemHandlers.cs
new file mode 100644
index 0000000..d19000a
--- /dev/null
+++ b/src/server/RemoteTickets.Application/System/Handlers/SystemHandlers.cs
@@ -0,0 +1,7 @@
+namespace RemoteTickets.Application.System.Handlers;
+
+public class SystemHandlers(ISystemService service) : IRequestHandler
+{
+ public Task Handle(IReceiveContext context, CancellationToken cancellationToken)
+ => service.PerformMasterDatabaseSetup(context.Message.Request.ConnectionString, context.Message.Request.CommandTimeout, cancellationToken);
+}
diff --git a/src/server/RemoteTickets.Application/System/Requests/SystemRequests.cs b/src/server/RemoteTickets.Application/System/Requests/SystemRequests.cs
new file mode 100644
index 0000000..f9bc8d5
--- /dev/null
+++ b/src/server/RemoteTickets.Application/System/Requests/SystemRequests.cs
@@ -0,0 +1,17 @@
+namespace RemoteTickets.Application.System.Requests;
+
+/// Requests the current first-time setup status.
+public sealed record GetSetupStatusQuery : IRequest;
+/// Requests creation of the initial administrator account.
+/// The administrator email address.
+/// The administrator password.
+public sealed record InitializeSetupCommand(string Email, string Password) : IRequest;
+
+/// Represents the command for perform setup of master database.
+/// The request for this command.
+public sealed record MasterDatabaseSetupCommand(MasterDatabaseSetupRequest Request) : IRequest;
+
+/// Response for a command.
+/// The status of command execution.
+/// Optional message about the result..
+public sealed record MasterDatabaseSetupResponse(bool Status, string Message) : IResponse;
diff --git a/src/server/RemoteTickets.Application/System/Validators/ConnectionStringPropertyValidator.cs b/src/server/RemoteTickets.Application/System/Validators/ConnectionStringPropertyValidator.cs
new file mode 100644
index 0000000..13d70d8
--- /dev/null
+++ b/src/server/RemoteTickets.Application/System/Validators/ConnectionStringPropertyValidator.cs
@@ -0,0 +1,42 @@
+namespace RemoteTickets.Application.System.Validators;
+
+public class ConnectionStringPropertyValidator : PropertyValidator
+{
+ private readonly HashSet _allowedKeys;
+
+ public ConnectionStringPropertyValidator(IEnumerable allowedKeys)
+ {
+ _allowedKeys = new HashSet(allowedKeys, StringComparer.OrdinalIgnoreCase);
+ }
+
+ public override string Name => "ConnectionStringValidator";
+
+ public override bool IsValid(ValidationContext context, string connectionString)
+ {
+ if (string.IsNullOrWhiteSpace(connectionString)) { return true; }
+
+ try
+ {
+ // The DbConnectionStringBuilder parses any generic connection string
+ var builder = new DbConnectionStringBuilder { ConnectionString = connectionString };
+
+ foreach (string key in builder.Keys)
+ {
+ if (!_allowedKeys.Contains(key))
+ {
+ context.MessageFormatter.AppendArgument("InvalidKey", key);
+ return false;
+ }
+ }
+ return true;
+ }
+ catch (Exception)
+ {
+ return false;
+ }
+ }
+
+ protected override string GetDefaultMessageTemplate(string errorCode)
+ => "The connection string have an not supported property: '{InvalidKey}'";
+}
+
\ No newline at end of file
diff --git a/src/server/RemoteTickets.Application/System/Validators/ConnectionStringValidationExtensions.cs b/src/server/RemoteTickets.Application/System/Validators/ConnectionStringValidationExtensions.cs
new file mode 100644
index 0000000..3ab6ba3
--- /dev/null
+++ b/src/server/RemoteTickets.Application/System/Validators/ConnectionStringValidationExtensions.cs
@@ -0,0 +1,16 @@
+namespace RemoteTickets.Application.System.Validators;
+
+public static class ConnectionStringValidationExtensions
+{
+ ///
+ /// Extension method to be used into ab AbstractValidator to validate connection strings.
+ ///
+ /// Rule Builder from abstract validator
+ /// Allowed keys of connections string according to desired db engine.
+ /// an instance to be validated
+ ///
+ public static IRuleBuilderOptions IsValidConnectionString(
+ this IRuleBuilder ruleBuilder, IEnumerable allowedKeys)
+ => ruleBuilder.SetValidator(
+ new ConnectionStringPropertyValidator(allowedKeys));
+}
diff --git a/src/server/RemoteTickets.Application/System/Validators/MasterDatabaseSetupCommandValidator..cs b/src/server/RemoteTickets.Application/System/Validators/MasterDatabaseSetupCommandValidator..cs
new file mode 100644
index 0000000..04f89d1
--- /dev/null
+++ b/src/server/RemoteTickets.Application/System/Validators/MasterDatabaseSetupCommandValidator..cs
@@ -0,0 +1,34 @@
+namespace RemoteTickets.Application.System.Validators;
+
+public class MasterDatabaseSetupCommandValidator : AbstractValidator
+{
+ private static readonly string[] SqlServerKeys =
+ {
+ "Data Source", "Server", "Initial Catalog", "Database",
+ "Integrated Security", "User ID", "Password", "Encrypt",
+ "TrustServerCertificate", "Connection Timeout"
+ };
+ public MasterDatabaseSetupCommandValidator()
+ {
+ RuleFor(x => x)
+ .NotNull()
+ .NotEmpty()
+ .WithErrorCode("BAD_REQUEST")
+ .WithMessage("The request cannot be null or empty.");
+
+ RuleFor(x => x.Request)
+ .NotNull()
+ .NotEmpty()
+ .WithErrorCode("BAD_REQUEST")
+ .WithMessage("The request data cannot be null or empty.");
+
+ RuleFor(x => x.Request.ConnectionString)
+ .NotNull()
+ .NotEmpty()
+ .WithErrorCode("UNPROCESSABLE_ENTITY")
+ .WithMessage("The connection string cannot be null or empty")
+ .IsValidConnectionString(SqlServerKeys)
+ .WithErrorCode("UNPROCESSABLE_ENTITY")
+ .WithMessage("The connection string isn't valid");
+ }
+}
diff --git a/src/server/RemoteTickets.Application/Tenant/Handlers/TenantHandlers.cs b/src/server/RemoteTickets.Application/Tenant/Handlers/TenantHandlers.cs
new file mode 100644
index 0000000..10bfd14
--- /dev/null
+++ b/src/server/RemoteTickets.Application/Tenant/Handlers/TenantHandlers.cs
@@ -0,0 +1,33 @@
+namespace RemoteTickets.Application.Tenant.Handlers;
+
+///
+/// Class that implement all Tenant queries and commands handlers
+///
+/// An injected instance.
+public class TenantHandlers(ITenantManagementService service) :
+IRequestHandler,
+IRequestHandler,
+IRequestHandler,
+IRequestHandler
+{
+ public async Task Handle(
+ IReceiveContext context,
+ CancellationToken cancellationToken)
+ => await service.GetSetupStatusAsync(
+ context.Message.TenantId,
+ cancellationToken);
+ public async Task Handle(
+ IReceiveContext context,
+ CancellationToken cancellationToken)
+ => await service.CompleteSetupAsync(
+ context.Message.TenantId,
+ cancellationToken);
+ public async Task Handle(
+ IReceiveContext context,
+ CancellationToken cancellationToken)
+ => await service.CreateAsync(context.Message.ToRequest() , cancellationToken);
+ public async Task Handle(
+ IReceiveContext context,
+ CancellationToken cancellationToken)
+ => await service.GetAsync(context.Message.TenantId, cancellationToken);
+}
\ No newline at end of file
diff --git a/src/server/RemoteTickets.Application/Tenant/Requests/TenantRequests.cs b/src/server/RemoteTickets.Application/Tenant/Requests/TenantRequests.cs
new file mode 100644
index 0000000..9deb8d2
--- /dev/null
+++ b/src/server/RemoteTickets.Application/Tenant/Requests/TenantRequests.cs
@@ -0,0 +1,32 @@
+namespace RemoteTickets.Application.Tenant.Requests;
+
+/// Query to retrieve tenant setup status
+/// Id of tenant to retrieve setup status
+public sealed record GetTenantSetupStatusQuery(string TenantId) : IRequest;
+
+/// Command to complete tenant setup
+/// Id of tenant to complete setup
+public sealed record CompleteTenantSetupCommand(string TenantId) : IRequest;
+
+/// Represents a command to provision a tenant and its first administrator.
+/// The tenant display name.
+/// The SQL Server database name.
+/// The connection string for the tenant database.
+/// The first tenant administrator email.
+/// The first tenant administrator password.
+public sealed record CreateTenantCommand(string Name, string DatabaseName, string ConnectionString, string AdminEmail, string AdminPassword) : IRequest
+{
+ public CreateTenantRequest ToRequest()
+ => new (null!,(string.IsNullOrEmpty(Name) || string.IsNullOrWhiteSpace(Name) ? throw new ArgumentNullException(nameof(Name)) : Name),
+ (string.IsNullOrEmpty(DatabaseName) || string.IsNullOrWhiteSpace(DatabaseName)? throw new ArgumentNullException(nameof(DatabaseName)) : DatabaseName),
+ (string.IsNullOrEmpty(ConnectionString) || string.IsNullOrWhiteSpace(ConnectionString)? throw new ArgumentNullException(nameof(ConnectionString)) : ConnectionString),
+ (string.IsNullOrEmpty(AdminEmail) || string.IsNullOrWhiteSpace(AdminEmail)? throw new ArgumentNullException(nameof(AdminEmail)) : AdminEmail),
+ (string.IsNullOrEmpty(AdminPassword) || string.IsNullOrWhiteSpace(AdminPassword)? throw new ArgumentNullException(nameof(AdminPassword)) : AdminPassword));
+}
+
+/// Query to retrieve an tenant by id
+/// The id of tenant to retrieve.
+public sealed record GetTenantQuery(string TenantId) : IRequest;
+
+/// Query to retrieve all tenant
+public sealed record GetTenantsQuery() : IRequest;
\ No newline at end of file
diff --git a/src/server/RemoteTickets.Composition/GlobalUsings.cs b/src/server/RemoteTickets.Composition/GlobalUsings.cs
index 4b9d898..a49c65f 100644
--- a/src/server/RemoteTickets.Composition/GlobalUsings.cs
+++ b/src/server/RemoteTickets.Composition/GlobalUsings.cs
@@ -5,12 +5,17 @@
global using System.Threading.Tasks;
global using FluentValidation;
global using RemoteTickets.Application.Common.Identity;
+global using RemoteTickets.Application.Common.Tenancy;
+global using RemoteTickets.Application.Common.System;
global using RemoteTickets.Application.Identity;
global using RemoteTickets.Application.Identity.Handlers;
global using RemoteTickets.Application.Identity.Validators;
global using RemoteTickets.Application.Pipeline.Validation;
global using RemoteTickets.Infrastructure.Persistence;
+global using RemoteTickets.Infrastructure.Identity.Models;
global using RemoteTickets.Infrastructure.Identity.Services;
+global using RemoteTickets.Infrastructure.Setup;
+global using RemoteTickets.Infrastructure.Tenancy;
global using Mediator.Net.MicrosoftDependencyInjection;
global using Microsoft.AspNetCore.Authentication.JwtBearer;
global using Microsoft.AspNetCore.Builder;
@@ -21,3 +26,4 @@
global using Microsoft.Extensions.DependencyInjection;
global using Microsoft.Extensions.Hosting;
global using Microsoft.IdentityModel.Tokens;
+global using MudBlazor.Services;
\ No newline at end of file
diff --git a/src/server/RemoteTickets.Composition/InvisibleSPCompositionExtensions.cs b/src/server/RemoteTickets.Composition/InvisibleSPCompositionExtensions.cs
deleted file mode 100644
index 17b6292..0000000
--- a/src/server/RemoteTickets.Composition/InvisibleSPCompositionExtensions.cs
+++ /dev/null
@@ -1,140 +0,0 @@
-namespace RemoteTickets.Composition;
-
-/// Provides extension methods that compose RemoteTickets application services and HTTP middleware.
-public static class RemoteTicketsCompositionExtensions
-{
- extension (WebApplicationBuilder builder)
- {
- /// Registers all application, persistence, identity, validation, authentication, and authorization services.
- /// The same application builder for fluent composition.
- /// Thrown when required JWT configuration is missing or its secret key is too short.
- public WebApplicationBuilder AddRemoteTickets()
- {
- var services = builder.Services;
- var configuration = builder.Configuration;
-
- services.AddRazorComponents()
- .AddInteractiveServerComponents();
- services.AddControllers();
-
- services.Configure(configuration.GetSection(JwtOptions.SectionName));
-
- services.AddDbContext(options =>
- options.UseSqlServer(configuration.GetConnectionString("Identity")));
-
- services.AddIdentityCore(options =>
- {
- options.User.RequireUniqueEmail = true;
- options.SignIn.RequireConfirmedEmail = false;
- options.Password.RequiredLength = 8;
- options.Password.RequireDigit = true;
- options.Password.RequireUppercase = true;
- options.Password.RequireLowercase = true;
- options.Password.RequireNonAlphanumeric = true;
- options.Lockout.MaxFailedAccessAttempts = 5;
- options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15);
- }).AddRoles()
- .AddEntityFrameworkStores()
- .AddSignInManager()
- .AddDefaultTokenProviders();
-
- services.AddScoped();
- services.AddScoped();
- services.AddSingleton();
- services.AddScoped();
- services.AddScoped();
- services.AddValidatorsFromAssemblyContaining();
-
- services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
- .AddJwtBearer(options =>
- {
- var jwt = configuration.GetSection(JwtOptions.SectionName).Get()
- ?? throw new InvalidOperationException("JWT configuration is missing.");
-
- if (Encoding.UTF8.GetByteCount(jwt.SecretKey) < 32)
- {
- throw new InvalidOperationException("Authentication:Jwt:SecretKey must contain at least 256 bits.");
- }
-
- options.TokenValidationParameters = new TokenValidationParameters
- {
- ValidateIssuerSigningKey = true,
- IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwt.SecretKey)),
- ValidateIssuer = true,
- ValidIssuer = jwt.Issuer,
- ValidateAudience = true,
- ValidAudience = jwt.Audience,
- ValidateLifetime = true,
- ClockSkew = TimeSpan.FromSeconds(30)
- };
-
- options.Events = new JwtBearerEvents
- {
- OnTokenValidated = context =>
- {
- var token = context.SecurityToken as JwtSecurityToken;
- var tokenType = token?.Claims.FirstOrDefault(x => x.Type == JwtRegisteredClaimNames.Typ)?.Value;
- if (!string.Equals(tokenType, "access", StringComparison.Ordinal))
- {
- context.Fail("The supplied token is not an access token.");
- return Task.CompletedTask;
- }
-
- if (token is not null && context.HttpContext.RequestServices.GetRequiredService().IsRevoked(token.Id))
- {
- context.Fail("The supplied token has been revoked.");
- }
-
- return Task.CompletedTask;
- }
- };
- });
-
- services.AddAuthorizationBuilder()
- .AddPolicy(IdentityPolicies.Administrator, policy =>
- policy.RequireClaim(IdentityClaimTypes.Permission, AdministratorPermission));
- var mb = new Mediator.Net.MediatorBuilder();
- mb.RegisterHandlers(typeof(IdentityHandlers).Assembly)
- .ConfigureCommandReceivePipe(pipe => pipe.UseValidation())
- .ConfigureRequestPipe(pipe => pipe.UseValidation());
-
- services.RegisterMediator(mb);
-
- return builder;
- }
- }
-
- extension (WebApplication app)
- {
- /// Configures middleware, authentication, authorization, controllers, static assets, and Blazor endpoints.
- /// The Blazor root component used by the application.
- /// The same web application for fluent startup composition.
- public WebApplication UseRemoteTickets() where TApp : IComponent
- {
- if (!app.Environment.IsDevelopment())
- {
- app.UseExceptionHandler("/Error", createScopeForErrors: true);
- app.UseHsts();
- }
- else
- {
- app.UseDeveloperExceptionPage();
- }
-
- app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
- app.UseHttpsRedirection();
- app.UseAuthentication();
- app.UseAuthorization();
- app.UseAntiforgery();
-
- app.MapControllers();
- app.MapStaticAssets();
- app.MapRazorComponents()
- .AddInteractiveServerRenderMode();
-
- return app;
- }
- }
-
- private const string AdministratorPermission = "system.admin";
-}
diff --git a/src/server/RemoteTickets.Composition/RemoteTickets.Composition.csproj b/src/server/RemoteTickets.Composition/RemoteTickets.Composition.csproj
index 7cc596d..c26ee4a 100644
--- a/src/server/RemoteTickets.Composition/RemoteTickets.Composition.csproj
+++ b/src/server/RemoteTickets.Composition/RemoteTickets.Composition.csproj
@@ -1,5 +1,8 @@
+
+
+
diff --git a/src/server/RemoteTickets.Composition/WebApplicationBuilderExtensions.cs b/src/server/RemoteTickets.Composition/WebApplicationBuilderExtensions.cs
new file mode 100644
index 0000000..48c265e
--- /dev/null
+++ b/src/server/RemoteTickets.Composition/WebApplicationBuilderExtensions.cs
@@ -0,0 +1,119 @@
+namespace RemoteTickets.Composition;
+
+/// Provides extension methods that compose RemoteTickets application services and HTTP middleware.
+public static class WebApplicationBuilderExtensions
+{
+ extension (WebApplicationBuilder builder)
+ {
+ /// Registers all application, persistence, identity, validation, authentication, authorization, and tenant services.
+ /// The same application builder for fluent composition.
+ /// Thrown when required JWT configuration is missing or its secret key is too short.
+ public async Task RunRemoteTicketsAsync() where T : IComponent
+ {
+ IServiceCollection services = builder.Services;
+ ConfigurationManager configuration = builder.Configuration;
+
+ services.AddRazorComponents()
+ .AddInteractiveServerComponents();
+ services.AddMudServices();
+ services.AddControllers();
+ services.AddHttpClient();
+ services.AddHttpContextAccessor();
+ services.Configure(configuration.GetSection(JwtOptions.SectionName));
+ services.AddSingleton();
+ services.AddDbContext((sp, options) =>
+ {
+ ISetupConfigurationStore setup = sp.GetRequiredService();
+ string connectionString = setup.GetMasterConnectionString() ?? configuration.GetConnectionString("Identity") ?? configuration.GetConnectionString("Master") ?? "";
+ if (string.IsNullOrWhiteSpace(connectionString))
+ {
+ throw new InvalidOperationException("A master database connection string is required.");
+ }
+
+ options.UseSqlServer(connectionString);
+ });
+
+ services.AddIdentityCore(options =>
+ {
+ options.User.RequireUniqueEmail = true;
+ options.SignIn.RequireConfirmedEmail = false;
+ options.Password.RequiredLength = 8;
+ options.Password.RequireDigit = true;
+ options.Password.RequireUppercase = true;
+ options.Password.RequireLowercase = true;
+ options.Password.RequireNonAlphanumeric = true;
+ options.Lockout.MaxFailedAccessAttempts = 5;
+ options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15);
+ })
+ .AddRoles()
+ .AddEntityFrameworkStores()
+ .AddSignInManager()
+ .AddDefaultTokenProviders();
+
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddSingleton();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddValidatorsFromAssemblyContaining();
+
+ _ = services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(options =>
+ {
+ JwtOptions jwt = configuration.GetSection(JwtOptions.SectionName).Get() ?? throw new InvalidOperationException("JWT configuration is missing.");
+ if (Encoding.UTF8.GetByteCount(jwt.SecretKey) < 32)
+ {
+ throw new InvalidOperationException("Authentication:Jwt:SecretKey must contain at least 256 bits.");
+ }
+
+ options.TokenValidationParameters = new TokenValidationParameters
+ {
+ ValidateIssuerSigningKey = true,
+ IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwt.SecretKey)),
+ ValidateIssuer = true,
+ ValidIssuer = jwt.Issuer,
+ ValidateAudience = true,
+ ValidAudience = jwt.Audience,
+ ValidateLifetime = true,
+ ClockSkew = TimeSpan.FromSeconds(30)
+ };
+ options.Events = new JwtBearerEvents
+ {
+ OnTokenValidated = context =>
+ {
+ var token = context.SecurityToken as JwtSecurityToken;
+ string? tokenType = token?.Claims.FirstOrDefault(x => x.Type == JwtRegisteredClaimNames.Typ)?.Value;
+ if (!string.Equals(tokenType, JwtTokenTypes.Access, StringComparison.Ordinal))
+ {
+ context.Fail("The supplied token is not an access token.");
+ return Task.CompletedTask;
+ }
+ if (token is not null && context.HttpContext.RequestServices.GetRequiredService().IsRevoked(token.Id))
+ {
+ context.Fail("The supplied token has been revoked.");
+ }
+
+ return Task.CompletedTask;
+ }
+ };
+ });
+
+ services.AddAuthorizationBuilder()
+ .AddPolicy(TenantPolicies.SysAdmin, policy => policy.RequireRole(TenantRoles.SysAdmin))
+ .AddPolicy(TenantPolicies.TenantAdmin, policy => policy.RequireRole(TenantRoles.SysAdmin, TenantRoles.TenantAdmin))
+ .AddPolicy(TenantPolicies.TenantOperator, policy => policy.RequireRole(TenantRoles.SysAdmin, TenantRoles.TenantAdmin, TenantRoles.TenantOperator))
+ .AddPolicy(TenantPolicies.TenantAttendant, policy => policy.RequireRole(TenantRoles.SysAdmin, TenantRoles.TenantAdmin, TenantRoles.TenantOperator, TenantRoles.TenantAttendant))
+ .AddPolicy(IdentityPolicies.Administrator, policy => policy.RequireRole(TenantRoles.SysAdmin));
+
+ var mb = new Mediator.Net.MediatorBuilder();
+ mb.RegisterHandlers(typeof(IdentityHandlers).Assembly)
+ .ConfigureCommandReceivePipe(pipe => pipe.UseValidation())
+ .ConfigureRequestPipe(pipe => pipe.UseValidation());
+ services.RegisterMediator(mb);
+
+ await builder.Build().RunRemoteTickets();
+ }
+ }
+}
diff --git a/src/server/RemoteTickets.Composition/WebApplicationExtensions.cs b/src/server/RemoteTickets.Composition/WebApplicationExtensions.cs
new file mode 100644
index 0000000..0ab8e93
--- /dev/null
+++ b/src/server/RemoteTickets.Composition/WebApplicationExtensions.cs
@@ -0,0 +1,37 @@
+namespace RemoteTickets.Composition;
+
+/// Provides extension methods that compose RemoteTickets application services and HTTP middleware.
+public static class WebApplicationExtensions
+{
+ extension (WebApplication app)
+ {
+ /// Configures middleware, authentication, tenant isolation, authorization, controllers, static assets, and Blazor endpoints.
+ /// The Blazor root component used by the application.
+ /// The same web application for fluent startup composition.
+ public async Task RunRemoteTickets() where TApp : IComponent
+ {
+ if (!app.Environment.IsDevelopment())
+ {
+ app.UseExceptionHandler("/Error", createScopeForErrors: true);
+ app.UseHsts();
+ }
+ else
+ {
+ app.UseDeveloperExceptionPage();
+ }
+
+ app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
+ app.UseHttpsRedirection();
+ app.UseRouting();
+ app.UseAuthentication();
+ app.UseMiddleware();
+ app.UseAuthorization();
+ app.UseAntiforgery();
+ app.MapControllers();
+ app.MapStaticAssets();
+ app.MapRazorComponents()
+ .AddInteractiveServerRenderMode();
+ await app.RunAsync();
+ }
+ }
+}
diff --git a/src/server/RemoteTickets.Domain/Common/EntityAuditable.cs b/src/server/RemoteTickets.Domain/Common/EntityAuditable.cs
new file mode 100644
index 0000000..8ecd62b
--- /dev/null
+++ b/src/server/RemoteTickets.Domain/Common/EntityAuditable.cs
@@ -0,0 +1,28 @@
+namespace RemoteTickets.Domain.Common;
+
+/// Defines creation metadata that is persisted with an auditable entity.
+public interface IEntityAuditable
+{
+ /// Gets the UTC timestamp at which the entity was created.
+ DateTimeOffset CreatedAt { get; init; }
+
+ /// Gets the identifier of the actor that created the entity.
+ string CreatedBy { get; init; }
+}
+
+/// Defines the state transition recorded for an auditable entity.
+/// The auditable entity contract represented by the history record.
+public interface IEntityUpdateHistory where T : IEntityAuditable
+{
+ /// Gets the serialized state after the operation.
+ string CurrentEntityState { get; init; }
+
+ /// Gets the serialized state before the operation.
+ string PreviousEntityState { get; init; }
+
+ /// Gets the UTC timestamp at which the state transition was recorded.
+ DateTimeOffset UpdatedAt { get; init; }
+
+ /// Gets or sets the identifier of the actor that caused the state transition.
+ string UpdatedBy { get; set; }
+}
diff --git a/src/server/RemoteTickets.Domain/Common/EntityBase.cs b/src/server/RemoteTickets.Domain/Common/EntityBase.cs
new file mode 100644
index 0000000..4bf6c13
--- /dev/null
+++ b/src/server/RemoteTickets.Domain/Common/EntityBase.cs
@@ -0,0 +1,15 @@
+namespace RemoteTickets.Domain.Common;
+
+/// Interface for abstrace base entity
+public interface IEntityBase
+{
+ string Id { get; set; }
+}
+
+/// Abstract base entity for all entities tracked by DbContext.
+public abstract class EntityBase(string id = "") : IEntityBase
+{
+ public string Id { get; set; } = id == "" ? Guid.CreateVersion7().ToString() : id;
+}
+
+
\ No newline at end of file
diff --git a/src/server/RemoteTickets.Infrastructure/GlobalUsings.cs b/src/server/RemoteTickets.Infrastructure/GlobalUsings.cs
index 5d848bd..11d1d41 100644
--- a/src/server/RemoteTickets.Infrastructure/GlobalUsings.cs
+++ b/src/server/RemoteTickets.Infrastructure/GlobalUsings.cs
@@ -2,19 +2,35 @@
global using System.Collections.Concurrent;
global using System.Collections.Generic;
global using System.IdentityModel.Tokens.Jwt;
+global using System.IO;
global using System.Linq;
global using System.Linq.Expressions;
global using System.Security.Claims;
global using System.Text;
+global using System.Text.Json;
global using System.Threading;
global using System.Threading.Tasks;
global using RemoteTickets.Application.Common.Identity;
+global using RemoteTickets.Application.Common.Tenancy;
+global using RemoteTickets.Application.Common.System;
+global using RemoteTickets.Application.Common.Util;
+global using RemoteTickets.Application.System.Requests;
global using RemoteTickets.Application.Identity;
global using RemoteTickets.Domain.Common;
global using RemoteTickets.Infrastructure.Identity.Models;
+global using RemoteTickets.Infrastructure.Persistence;
+global using RemoteTickets.Infrastructure.Persistence.Auditing;
+global using RemoteTickets.Infrastructure.Setup;
+global using RemoteTickets.Infrastructure.Tenancy.Configurations;
+global using RemoteTickets.Infrastructure.Tenancy.Models;
+global using Microsoft.AspNetCore.Authorization;
+global using Microsoft.AspNetCore.Http;
global using Microsoft.AspNetCore.Identity;
global using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
+global using Microsoft.Data.SqlClient;
global using Microsoft.EntityFrameworkCore;
+global using Microsoft.EntityFrameworkCore.ChangeTracking;
+global using Microsoft.EntityFrameworkCore.Metadata.Builders;
global using Microsoft.Extensions.Logging;
global using Microsoft.Extensions.Options;
global using Microsoft.IdentityModel.Tokens;
diff --git a/src/server/RemoteTickets.Infrastructure/Identity/Models/Role.cs b/src/server/RemoteTickets.Infrastructure/Identity/Models/Role.cs
index 04b9c22..bc8b490 100644
--- a/src/server/RemoteTickets.Infrastructure/Identity/Models/Role.cs
+++ b/src/server/RemoteTickets.Infrastructure/Identity/Models/Role.cs
@@ -1,21 +1,21 @@
namespace RemoteTickets.Infrastructure.Identity.Models;
-///
-/// Represents a role in the RemoteTickets application.
-///
-public class Role : IdentityRole, ISoftDeletable
+
+/// Represents a role in the RemoteTickets application.
+public class Role : IdentityRole, ISoftDeletable, IEntityAuditable
{
/// Indicates whether the role is deleted.
public bool IsDeleted { get; set; }
/// Gets or sets the date and time when the role was deleted.
public DateTimeOffset? DeletedAt { get; set; }
- /// Role parameterless constructor.
- public Role() : base()
- {
- Id = Guid.CreateVersion7().ToString();
- }
- /// Role constructor.
- public Role(string name) : base(name)
- {
- Id = Guid.CreateVersion7().ToString();
- }
+ ///
+ public DateTimeOffset CreatedAt { get; init; }
+ ///
+ public string CreatedBy { get; init; } = string.Empty;
+
+ /// Initializes a role with a new identifier.
+ public Role() : base() => Id = Guid.CreateVersion7().ToString();
+
+ /// Initializes a role with the supplied name.
+ /// The role name.
+ public Role(string name) : base(name) => Id = Guid.CreateVersion7().ToString();
}
diff --git a/src/server/RemoteTickets.Infrastructure/Identity/Models/User.cs b/src/server/RemoteTickets.Infrastructure/Identity/Models/User.cs
index 6670520..68783fd 100644
--- a/src/server/RemoteTickets.Infrastructure/Identity/Models/User.cs
+++ b/src/server/RemoteTickets.Infrastructure/Identity/Models/User.cs
@@ -1,9 +1,7 @@
namespace RemoteTickets.Infrastructure.Identity.Models;
-///
-/// Represents a user in the RemoteTickets application.
-///
-public class User : IdentityUser, ISoftDeletable
+/// Represents a user in the RemoteTickets application.
+public class User : IdentityUser, ISoftDeletable, IEntityAuditable
{
/// User name displayed in the application.
public string? DisplayName { get; set; } = string.Empty;
@@ -11,21 +9,21 @@ public class User : IdentityUser, ISoftDeletable
public string? FirstName { get; set; } = string.Empty;
/// Surname of the user.
public string? SurName { get; set; } = string.Empty;
+ /// Gets or sets the tenant to which the user belongs. A null value identifies a system administrator.
+ public string? TenantId { get; set; }
+ ///
+ public DateTimeOffset CreatedAt { get; init; }
+ ///
+ public string CreatedBy { get; init; } = string.Empty;
/// Indicates whether the user is deleted.
- public bool IsDeleted { get; set; }
+ public bool IsDeleted { get; set; }
/// Gets or sets the date and time when the user was deleted.
- public DateTimeOffset? DeletedAt { get; set; } = null!;
+ public DateTimeOffset? DeletedAt { get; set; }
- ///
- /// Initializes a new instance of the class.
- ///
- public User() : base()
- {
- Id = Guid.CreateVersion7().ToString();
- }
- ///
- public User(string name) : base(name)
- {
- Id = Guid.CreateVersion7().ToString();
- }
+ /// Initializes a new instance of the class.
+ public User() : base() => Id = Guid.CreateVersion7().ToString();
+
+ /// Initializes a new user with the supplied user name.
+ /// The user name.
+ public User(string name) : base(name) => Id = Guid.CreateVersion7().ToString();
}
diff --git a/src/server/RemoteTickets.Infrastructure/Identity/Services/IdentityService.Refresh.cs b/src/server/RemoteTickets.Infrastructure/Identity/Services/IdentityService.Refresh.cs
new file mode 100644
index 0000000..fee94a7
--- /dev/null
+++ b/src/server/RemoteTickets.Infrastructure/Identity/Services/IdentityService.Refresh.cs
@@ -0,0 +1,57 @@
+namespace RemoteTickets.Infrastructure.Identity.Services;
+
+/// Provides tenant-bound refresh-token handling for .
+public sealed partial class IdentityService
+{
+ ///
+ public async Task RefreshAsync(string tenantId, string refreshToken, CancellationToken cancellationToken)
+ {
+ ClaimsPrincipal? principal = tokenService.ValidateToken(refreshToken);
+ if (principal is null || !string.Equals(principal.FindFirstValue("token_type"), JwtTokenTypes.Refresh, StringComparison.Ordinal))
+ {
+ return null;
+ }
+
+ string[] roles = principal.FindAll(ClaimTypes.Role).Select(x => x.Value).ToArray();
+ if (!roles.Contains(TenantRoles.SysAdmin, StringComparer.OrdinalIgnoreCase) && !string.Equals(principal.FindFirst(TenantClaimTypes.TenantId)?.Value, tenantId, StringComparison.OrdinalIgnoreCase))
+ {
+ return null;
+ }
+
+ string? userId = principal.FindFirstValue(JwtRegisteredClaimNames.Sub) ?? principal.FindFirstValue(ClaimTypes.NameIdentifier);
+ if (string.IsNullOrWhiteSpace(userId))
+ {
+ return null;
+ }
+
+ User? user = await userManager.FindByIdAsync(userId);
+ if (user is null)
+ {
+ return null;
+ }
+
+ IList claims = await userManager.GetClaimsAsync(user);
+ foreach (string? roleName in roles)
+ {
+ Role? role = await roleManager.FindByNameAsync(roleName);
+ if (role is not null)
+ {
+ claims = claims.Concat(await roleManager.GetClaimsAsync(role)).ToList();
+ }
+ }
+ if (user.TenantId is not null)
+ {
+ claims = claims.Append(new Claim(TenantClaimTypes.TenantId, user.TenantId)).ToList();
+ }
+
+ string? tokenId = tokenService.GetTokenId(refreshToken);
+ DateTimeOffset? expiration = tokenService.GetExpiration(refreshToken);
+ TokenResponse tokens = tokenService.CreateTokens(user.Id, user.Email ?? user.UserName ?? user.Id, roles, claims);
+ if (!string.IsNullOrWhiteSpace(tokenId) && expiration.HasValue)
+ {
+ revokedTokenStore.Revoke(tokenId, expiration.Value);
+ }
+
+ return tokens;
+ }
+}
diff --git a/src/server/RemoteTickets.Infrastructure/Identity/Services/IdentityService.Tenancy.cs b/src/server/RemoteTickets.Infrastructure/Identity/Services/IdentityService.Tenancy.cs
index cb518a1..c13dbb7 100644
--- a/src/server/RemoteTickets.Infrastructure/Identity/Services/IdentityService.Tenancy.cs
+++ b/src/server/RemoteTickets.Infrastructure/Identity/Services/IdentityService.Tenancy.cs
@@ -4,63 +4,106 @@ namespace RemoteTickets.Infrastructure.Identity.Services;
public sealed partial class IdentityService
{
///
- public async Task LoginAsync(string tenantId, string email, string password, string? twoFactorCode, string? twoFactorRecoveryCode, CancellationToken cancellationToken)
+ public async Task LoginAsync(
+ string tenantId,
+ string email,
+ string password,
+ string? twoFactorCode,
+ string? twoFactorRecoveryCode,
+ CancellationToken cancellationToken)
{
- var user = await userManager.FindByEmailAsync(email);
- if (user is null) return null;
- var roles = await userManager.GetRolesAsync(user);
- var sysAdmin = roles.Contains(TenantRoles.SysAdmin, StringComparer.OrdinalIgnoreCase);
- if (!sysAdmin && !string.Equals(user.TenantId, tenantId, StringComparison.OrdinalIgnoreCase)) return null;
- var passwordResult = await signInManager.CheckPasswordSignInAsync(user, password, true);
- if (passwordResult.IsLockedOut || passwordResult.IsNotAllowed || !passwordResult.Succeeded) return null;
+ User? user = await userManager.FindByEmailAsync(email);
+ if (user is null || (user.TenantId is not null && !string.Equals(user.TenantId, tenantId, StringComparison.OrdinalIgnoreCase) && !await userManager.IsInRoleAsync(user, TenantRoles.SysAdmin)))
+ {
+ return null;
+ }
+
+ if (user.TenantId is null && !await userManager.IsInRoleAsync(user, TenantRoles.SysAdmin))
+ {
+ return null;
+ }
+
+ if (user.TenantId is not null && !string.Equals(user.TenantId, tenantId, StringComparison.OrdinalIgnoreCase) && !await userManager.IsInRoleAsync(user, TenantRoles.SysAdmin))
+ {
+ return null;
+ }
+
+ SignInResult passwordResult = await signInManager.CheckPasswordSignInAsync(user, password, true);
+ if (passwordResult.IsLockedOut || passwordResult.IsNotAllowed || !passwordResult.Succeeded)
+ {
+ return null;
+ }
+
if (await userManager.GetTwoFactorEnabledAsync(user))
{
- var valid = !string.IsNullOrWhiteSpace(twoFactorCode)
+ bool valid = !string.IsNullOrWhiteSpace(twoFactorCode)
? await userManager.VerifyTwoFactorTokenAsync(user, userManager.Options.Tokens.AuthenticatorTokenProvider, twoFactorCode)
: !string.IsNullOrWhiteSpace(twoFactorRecoveryCode) && (await userManager.RedeemTwoFactorRecoveryCodeAsync(user, twoFactorRecoveryCode)).Succeeded;
- if (!valid) return null;
+ if (!valid)
+ {
+ return null;
+ }
+ }
+
+ IList roles = await userManager.GetRolesAsync(user);
+ IList claims = await userManager.GetClaimsAsync(user);
+ foreach (string roleName in roles)
+ {
+ Role? role = await roleManager.FindByNameAsync(roleName);
+ if (role is not null)
+ {
+ claims = claims.Concat(await roleManager.GetClaimsAsync(role)).ToList();
+ }
}
- var claims = await GetTokenClaimsAsync(user, roles);
+
+ if (!roles.Contains(TenantRoles.SysAdmin, StringComparer.OrdinalIgnoreCase))
+ {
+ claims = claims.Append(new Claim(TenantClaimTypes.TenantId, tenantId)).ToList();
+ }
+ else if (user.TenantId is not null)
+ {
+ claims = claims.Append(new Claim(TenantClaimTypes.TenantId, user.TenantId)).ToList();
+ }
+
return tokenService.CreateTokens(user.Id, user.Email ?? user.UserName ?? user.Id, roles, claims);
}
///
public async Task CreateTenantAdminAsync(string tenantId, string email, string password, CancellationToken cancellationToken)
{
- if (string.IsNullOrWhiteSpace(tenantId)) return IdentityResultResponse.Failure(["A tenant identifier is required."]);
- if (await userManager.FindByEmailAsync(email) is not null) return IdentityResultResponse.Failure(["The email address is already registered."]);
- var role = await roleManager.FindByNameAsync(TenantRoles.TenantAdmin);
- if (role is null)
+ if (string.IsNullOrWhiteSpace(tenantId))
{
- var roleResult = await roleManager.CreateAsync(new Role(TenantRoles.TenantAdmin));
- if (!roleResult.Succeeded) return IdentityResultResponse.Failure(roleResult.Errors.Select(x => x.Description));
+ return IdentityResultResponse.Failure(["A tenant identifier is required."]);
}
- var user = new User(email) { Email = email, EmailConfirmed = true, TenantId = tenantId };
- var userResult = await userManager.CreateAsync(user, password);
- if (!userResult.Succeeded) return IdentityResultResponse.Failure(userResult.Errors.Select(x => x.Description));
- var membership = await userManager.AddToRoleAsync(user, TenantRoles.TenantAdmin);
- return membership.Succeeded ? IdentityResultResponse.Success() : IdentityResultResponse.Failure(membership.Errors.Select(x => x.Description));
- }
- ///
- public async Task RefreshAsync(string tenantId, string refreshToken, CancellationToken cancellationToken)
- {
- var principal = tokenService.ValidateToken(refreshToken);
- if (principal is null || !string.Equals(principal.FindFirstValue("token_type"), JwtTokenTypes.Refresh, StringComparison.Ordinal)) return null;
- var roles = principal.FindAll(ClaimTypes.Role).Select(x => x.Value).ToArray();
- if (!roles.Contains(TenantRoles.SysAdmin, StringComparer.OrdinalIgnoreCase) && !string.Equals(principal.FindFirst(TenantClaimTypes.TenantId)?.Value, tenantId, StringComparison.OrdinalIgnoreCase)) return null;
- return await RefreshAsync(refreshToken, cancellationToken);
- }
+ if (await userManager.FindByEmailAsync(email) is not null)
+ {
+ return IdentityResultResponse.Failure(["The email address is already registered."]);
+ }
- private async Task> GetTokenClaimsAsync(User user, IReadOnlyCollection roles)
- {
- var claims = (await userManager.GetClaimsAsync(user)).ToList();
- foreach (var roleName in roles)
+ Role? role = await roleManager.FindByNameAsync(TenantRoles.TenantAdmin);
+ if (role is null)
{
- var role = await roleManager.FindByNameAsync(roleName);
- if (role is not null) claims.AddRange(await roleManager.GetClaimsAsync(role));
+ IdentityResult roleResult = await roleManager.CreateAsync(new Role(TenantRoles.TenantAdmin));
+ if (!roleResult.Succeeded)
+ {
+ return IdentityResultResponse.Failure(roleResult.Errors.Select(x => x.Description));
+ }
}
- if (user.TenantId is not null) claims.Add(new Claim(TenantClaimTypes.TenantId, user.TenantId));
- return claims;
+
+ var user = new User(email)
+ {
+ Email = email,
+ EmailConfirmed = true,
+ TenantId = tenantId
+ };
+ IdentityResult userResult = await userManager.CreateAsync(user, password);
+ if (!userResult.Succeeded)
+ {
+ return IdentityResultResponse.Failure(userResult.Errors.Select(x => x.Description));
+ }
+
+ IdentityResult membership = await userManager.AddToRoleAsync(user, TenantRoles.TenantAdmin);
+ return membership.Succeeded ? IdentityResultResponse.Success() : IdentityResultResponse.Failure(membership.Errors.Select(x => x.Description));
}
}
diff --git a/src/server/RemoteTickets.Infrastructure/Identity/Services/IdentityService.cs b/src/server/RemoteTickets.Infrastructure/Identity/Services/IdentityService.cs
index 464f47d..d1f27c2 100644
--- a/src/server/RemoteTickets.Infrastructure/Identity/Services/IdentityService.cs
+++ b/src/server/RemoteTickets.Infrastructure/Identity/Services/IdentityService.cs
@@ -10,74 +10,44 @@ namespace RemoteTickets.Infrastructure.Identity.Services;
/// The store used to track revoked tokens.
/// The service used to send identity-related email messages.
/// The logger used to record operational identity events.
-public sealed class IdentityService(
+public sealed partial class IdentityService(
UserManager userManager,
RoleManager roleManager,
SignInManager signInManager,
IJwtTokenService tokenService,
IRevokedTokenStore revokedTokenStore,
IIdentityEmailSender emailSender,
+ ISetupConfigurationStore configurationStore,
ILogger logger) : IIdentityService
{
private const string AdministratorRole = "Administrator";
private const string AdministratorPermission = "system.admin";
- ///
- /// Registers a new user and sends an email confirmation link.
- ///
- /// The email address of the user.
- /// The password to assign to the user.
- /// The token used to cancel the operation.
- /// The result of the registration operation.
+ /// Registers a new user and sends an email confirmation link.
public async Task RegisterAsync(string email, string password, CancellationToken cancellationToken)
{
- var user = new User(email)
- {
- Email = email,
- DisplayName = "",
- FirstName = "",
- SurName = "",
- EmailConfirmed = false
- };
-
- var result = await userManager.CreateAsync(user, password);
+ var user = new User(email) { Email = email, DisplayName = "", FirstName = "", SurName = "", EmailConfirmed = false };
+ IdentityResult result = await userManager.CreateAsync(user, password);
if (!result.Succeeded)
{
return Failure(result);
}
- var code = await userManager.GenerateEmailConfirmationTokenAsync(user);
- await emailSender.SendConfirmationAsync(
- email,
- $"/confirmEmail?userId={Uri.EscapeDataString(user.Id)}&code={Uri.EscapeDataString(code)}",
- cancellationToken);
-
+ string code = await userManager.GenerateEmailConfirmationTokenAsync(user);
+ await emailSender.SendConfirmationAsync(email, $"/confirmEmail?userId={Uri.EscapeDataString(user.Id)}&code={Uri.EscapeDataString(code)}", cancellationToken);
return IdentityResultResponse.Success();
}
- ///
- /// Authenticates a user and issues JWT access and refresh tokens when all required authentication factors are valid.
- ///
- /// The user's email address.
- /// The user's password.
- /// The authenticator application code, when two-factor authentication is enabled.
- /// The recovery code, when two-factor authentication is enabled and a recovery code is used.
- /// The token used to cancel the operation.
- /// The issued tokens, or when authentication fails.
- public async Task LoginAsync(
- string email,
- string password,
- string? twoFactorCode,
- string? twoFactorRecoveryCode,
- CancellationToken cancellationToken)
+ /// Authenticates a user and issues JWT access and refresh tokens when all required authentication factors are valid.
+ public async Task LoginAsync(string email, string password, string? twoFactorCode, string? twoFactorRecoveryCode, CancellationToken cancellationToken)
{
- var user = await userManager.FindByEmailAsync(email);
+ User? user = await userManager.FindByEmailAsync(email);
if (user is null)
{
return null;
}
- var passwordResult = await signInManager.CheckPasswordSignInAsync(user, password, true);
+ SignInResult passwordResult = await signInManager.CheckPasswordSignInAsync(user, password, true);
if (passwordResult.IsLockedOut || passwordResult.IsNotAllowed || !passwordResult.Succeeded)
{
return null;
@@ -85,58 +55,41 @@ await emailSender.SendConfirmationAsync(
if (await userManager.GetTwoFactorEnabledAsync(user))
{
- var valid = !string.IsNullOrWhiteSpace(twoFactorCode)
- ? await userManager.VerifyTwoFactorTokenAsync(
- user,
- userManager.Options.Tokens.AuthenticatorTokenProvider,
- twoFactorCode)
- : !string.IsNullOrWhiteSpace(twoFactorRecoveryCode) &&
- (await userManager.RedeemTwoFactorRecoveryCodeAsync(user, twoFactorRecoveryCode)).Succeeded;
-
+ bool valid = !string.IsNullOrWhiteSpace(twoFactorCode)
+ ? await userManager.VerifyTwoFactorTokenAsync(user, userManager.Options.Tokens.AuthenticatorTokenProvider, twoFactorCode)
+ : !string.IsNullOrWhiteSpace(twoFactorRecoveryCode) && (await userManager.RedeemTwoFactorRecoveryCodeAsync(user, twoFactorRecoveryCode)).Succeeded;
if (!valid)
{
return null;
}
}
-
return await CreateUserTokensAsync(user, cancellationToken);
}
- ///
- /// Exchanges a valid refresh token for a new access and refresh token pair and revokes the previous refresh token.
- ///
- /// The refresh token to exchange.
- /// The token used to cancel the operation.
- /// The new token pair, or when the refresh token is invalid.
+ /// Exchanges a valid refresh token for a new access and refresh token pair and revokes the previous refresh token.
public async Task RefreshAsync(string refreshToken, CancellationToken cancellationToken)
{
- var principal = tokenService.ValidateToken(refreshToken);
+ ClaimsPrincipal? principal = tokenService.ValidateToken(refreshToken);
if (principal is null || !string.Equals(principal.FindFirstValue("token_type"), JwtTokenTypes.Refresh, StringComparison.Ordinal))
{
return null;
}
- var userId = principal.FindFirstValue(JwtRegisteredClaimNames.Sub);
- if (string.IsNullOrWhiteSpace(userId))
- {
- userId = principal.FindFirstValue(ClaimTypes.NameIdentifier);
- }
-
+ string? userId = principal.FindFirstValue(JwtRegisteredClaimNames.Sub) ?? principal.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrWhiteSpace(userId))
{
return null;
}
- var user = await userManager.FindByIdAsync(userId);
+ User? user = await userManager.FindByIdAsync(userId);
if (user is null)
{
return null;
}
- var tokenId = tokenService.GetTokenId(refreshToken);
- var expiration = tokenService.GetExpiration(refreshToken);
- var tokens = await CreateUserTokensAsync(user, cancellationToken);
-
+ string? tokenId = tokenService.GetTokenId(refreshToken);
+ DateTimeOffset? expiration = tokenService.GetExpiration(refreshToken);
+ TokenResponse tokens = await CreateUserTokensAsync(user, cancellationToken);
if (!string.IsNullOrWhiteSpace(tokenId) && expiration.HasValue)
{
revokedTokenStore.Revoke(tokenId, expiration.Value);
@@ -145,18 +98,12 @@ await emailSender.SendConfirmationAsync(
return tokens;
}
- ///
- /// Revokes a valid access token until its natural expiration.
- ///
- /// The access token to revoke.
- /// The token used to cancel the operation.
- /// when the token was revoked; otherwise, .
+ /// Revokes a valid access token until its natural expiration.
public Task RevokeAsync(string accessToken, CancellationToken cancellationToken)
{
- var principal = tokenService.ValidateToken(accessToken);
- var tokenId = tokenService.GetTokenId(accessToken);
- var expiration = tokenService.GetExpiration(accessToken);
-
+ ClaimsPrincipal? principal = tokenService.ValidateToken(accessToken);
+ string? tokenId = tokenService.GetTokenId(accessToken);
+ DateTimeOffset? expiration = tokenService.GetExpiration(accessToken);
if (principal is null || string.IsNullOrWhiteSpace(tokenId) || !expiration.HasValue)
{
return Task.FromResult(false);
@@ -166,121 +113,71 @@ public Task RevokeAsync(string accessToken, CancellationToken cancellation
return Task.FromResult(true);
}
- ///
- /// Confirms a user's email address or confirms a changed email address using a supplied Identity token.
- ///
- /// The identifier of the user.
- /// The confirmation token.
- /// The new email address when confirming an email change; otherwise .
- /// The token used to cancel the operation.
- /// when confirmation succeeds; otherwise, .
+ /// Confirms a user's email address or confirms a changed email address using a supplied Identity token.
public async Task ConfirmEmailAsync(string userId, string code, string? changedEmail, CancellationToken cancellationToken)
{
- var user = await userManager.FindByIdAsync(userId);
+ User? user = await userManager.FindByIdAsync(userId);
if (user is null)
{
return false;
}
- var result = !string.IsNullOrWhiteSpace(changedEmail)
- ? await userManager.ChangeEmailAsync(user, changedEmail, code)
- : await userManager.ConfirmEmailAsync(user, code);
-
+ IdentityResult result = !string.IsNullOrWhiteSpace(changedEmail) ? await userManager.ChangeEmailAsync(user, changedEmail, code) : await userManager.ConfirmEmailAsync(user, code);
return result.Succeeded;
}
- ///
- /// Resends an email confirmation link when the specified user exists and remains unconfirmed.
- ///
- /// The email address of the user.
- /// The token used to cancel the operation.
- /// The result of the resend operation.
+ /// Resends an email confirmation link when the specified user exists and remains unconfirmed.
public async Task ResendConfirmationEmailAsync(string email, CancellationToken cancellationToken)
{
- var user = await userManager.FindByEmailAsync(email);
+ User? user = await userManager.FindByEmailAsync(email);
if (user is null || await userManager.IsEmailConfirmedAsync(user))
{
return IdentityResultResponse.Success();
}
- var code = await userManager.GenerateEmailConfirmationTokenAsync(user);
- await emailSender.SendConfirmationAsync(
- email,
- $"/confirmEmail?userId={Uri.EscapeDataString(user.Id)}&code={Uri.EscapeDataString(code)}",
- cancellationToken);
-
+ string code = await userManager.GenerateEmailConfirmationTokenAsync(user);
+ await emailSender.SendConfirmationAsync(email, $"/confirmEmail?userId={Uri.EscapeDataString(user.Id)}&code={Uri.EscapeDataString(code)}", cancellationToken);
return IdentityResultResponse.Success();
}
- ///
- /// Starts a password recovery operation for a user with a password.
- ///
- /// The email address of the user.
- /// The token used to cancel the operation.
- /// A successful result whether or not a matching password-bearing user exists.
+ /// Starts a password recovery operation for a user with a password.
public async Task ForgotPasswordAsync(string email, CancellationToken cancellationToken)
{
- var user = await userManager.FindByEmailAsync(email);
+ User? user = await userManager.FindByEmailAsync(email);
if (user is null || !await userManager.HasPasswordAsync(user))
{
return IdentityResultResponse.Success();
}
- var code = await userManager.GeneratePasswordResetTokenAsync(user);
- await emailSender.SendPasswordResetAsync(
- email,
- $"/resetPassword?email={Uri.EscapeDataString(email)}&code={Uri.EscapeDataString(code)}",
- cancellationToken);
-
+ string code = await userManager.GeneratePasswordResetTokenAsync(user);
+ await emailSender.SendPasswordResetAsync(email, $"/resetPassword?email={Uri.EscapeDataString(email)}&code={Uri.EscapeDataString(code)}", cancellationToken);
return IdentityResultResponse.Success();
}
- ///
- /// Resets a user's password using a valid password-reset token.
- ///
- /// The email address of the user.
- /// The password-reset token.
- /// The new password.
- /// The token used to cancel the operation.
- /// The result of the password reset operation.
+ /// Resets a user's password using a valid password-reset token.
public async Task ResetPasswordAsync(string email, string resetCode, string newPassword, CancellationToken cancellationToken)
{
- var user = await userManager.FindByEmailAsync(email);
+ User? user = await userManager.FindByEmailAsync(email);
if (user is null)
{
return IdentityResultResponse.Failure(["Invalid password reset request."]);
}
- var result = await userManager.ResetPasswordAsync(user, resetCode, newPassword);
+ IdentityResult result = await userManager.ResetPasswordAsync(user, resetCode, newPassword);
return result.Succeeded ? IdentityResultResponse.Success() : Failure(result);
}
- ///
- /// Gets basic identity information for a user.
- ///
- /// The identifier of the user.
- /// The token used to cancel the operation.
- /// The user's identity information, or when the user does not exist.
+ /// Gets basic identity information for a user.
public async Task GetInfoAsync(string userId, CancellationToken cancellationToken)
{
- var user = await userManager.FindByIdAsync(userId);
- return user is null
- ? null
- : new IdentityInfoResponse(user.Email ?? string.Empty, await userManager.IsEmailConfirmedAsync(user));
+ User? user = await userManager.FindByIdAsync(userId);
+ return user is null ? null : new IdentityInfoResponse(user.Email ?? string.Empty, await userManager.IsEmailConfirmedAsync(user));
}
- ///
- /// Updates a user's email address and password after validating the current password.
- ///
- /// The identifier of the user.
- /// The new email address, or to leave it unchanged.
- /// The new password, or to leave it unchanged.
- /// The current password.
- /// The token used to cancel the operation.
- /// The result of the update operation.
+ /// Updates a user's email address and password after validating the current password.
public async Task UpdateInfoAsync(string userId, string? newEmail, string? newPassword, string oldPassword, CancellationToken cancellationToken)
{
- var user = await userManager.FindByIdAsync(userId);
+ User? user = await userManager.FindByIdAsync(userId);
if (user is null || !await userManager.CheckPasswordAsync(user, oldPassword))
{
return IdentityResultResponse.Failure(["The current credentials are invalid."]);
@@ -288,44 +185,29 @@ public async Task UpdateInfoAsync(string userId, string?
if (!string.IsNullOrWhiteSpace(newEmail) && !string.Equals(user.Email, newEmail, StringComparison.OrdinalIgnoreCase))
{
- var emailCode = await userManager.GenerateChangeEmailTokenAsync(user, newEmail);
- var emailResult = await userManager.ChangeEmailAsync(user, newEmail, emailCode);
+ IdentityResult emailResult = await userManager.ChangeEmailAsync(user, newEmail, await userManager.GenerateChangeEmailTokenAsync(user, newEmail));
if (!emailResult.Succeeded)
{
return Failure(emailResult);
}
}
-
if (!string.IsNullOrWhiteSpace(newPassword))
{
- var passwordResult = await userManager.ChangePasswordAsync(user, oldPassword, newPassword);
+ IdentityResult passwordResult = await userManager.ChangePasswordAsync(user, oldPassword, newPassword);
if (!passwordResult.Succeeded)
{
return Failure(passwordResult);
}
}
-
return IdentityResultResponse.Success();
}
- ///
- /// Configures authenticator-based two-factor authentication and optionally rotates its recovery material.
- ///
- /// The identifier of the user.
- /// Whether to enable or disable two-factor authentication.
- /// The authenticator code used to validate enabling two-factor authentication.
- /// Whether new recovery codes should be generated.
- /// Whether the authenticator shared key should be regenerated.
- /// Whether the current machine should be forgotten; reserved for parity with the Identity API contract.
- /// The token used to cancel the operation.
- /// The current two-factor configuration, or when the user does not exist or an enable operation fails.
+ /// Configures authenticator-based two-factor authentication and optionally rotates its recovery material.
public async Task ConfigureTwoFactorAsync(string userId, bool? enable, string? twoFactorCode, bool resetRecoveryCodes, bool resetSharedKey, bool forgetMachine, CancellationToken cancellationToken)
{
- var user = await userManager.FindByIdAsync(userId);
- if (user is null)
- {
- return null;
- }
+ User user = await userManager.FindByIdAsync(userId) ?? null!;
+
+ if (user == null) { return null; }
if (enable == true)
{
@@ -333,7 +215,6 @@ public async Task UpdateInfoAsync(string userId, string?
{
return null;
}
-
await userManager.SetTwoFactorEnabledAsync(user, true);
}
else if (enable == false || resetSharedKey)
@@ -352,35 +233,19 @@ public async Task UpdateInfoAsync(string userId, string?
recoveryCodes = (await userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10))?.ToArray();
}
- var key = await userManager.GetAuthenticatorKeyAsync(user);
- if (string.IsNullOrWhiteSpace(key))
- {
- await userManager.ResetAuthenticatorKeyAsync(user);
- key = await userManager.GetAuthenticatorKeyAsync(user);
- }
-
- var recoveryCodesLeft = await userManager.CountRecoveryCodesAsync(user);
- return new TwoFactorResponse(key, recoveryCodesLeft, recoveryCodes, await userManager.GetTwoFactorEnabledAsync(user), false);
+ string? key = await userManager.GetAuthenticatorKeyAsync(user);
+ if (string.IsNullOrWhiteSpace(key)) { await userManager.ResetAuthenticatorKeyAsync(user); key = await userManager.GetAuthenticatorKeyAsync(user); }
+ return new TwoFactorResponse(key, await userManager.CountRecoveryCodesAsync(user), recoveryCodes, await userManager.GetTwoFactorEnabledAsync(user), false);
}
- ///
- /// Gets whether initial system setup is required or has already been completed.
- ///
- /// The token used to cancel the operation.
- /// The current setup status.
+ /// Gets whether initial system setup is required or has already been completed.
public async Task GetSetupStatusAsync(CancellationToken cancellationToken)
{
- var hasUsers = await userManager.Users.AsNoTracking().AnyAsync(cancellationToken);
+ bool hasUsers = await userManager.Users.AsNoTracking().AnyAsync(cancellationToken);
return new SetupStatusResponse(!hasUsers, hasUsers);
}
- ///
- /// Creates the initial administrator account when system setup has not yet been completed.
- ///
- /// The administrator email address.
- /// The administrator password.
- /// The token used to cancel the operation.
- /// The result of the setup operation.
+ /// Creates the initial system administrator account when setup has not yet been completed.
public async Task InitializeSetupAsync(string email, string password, CancellationToken cancellationToken)
{
if (await userManager.Users.AsNoTracking().AnyAsync(cancellationToken))
@@ -388,93 +253,107 @@ public async Task InitializeSetupAsync(string email, str
return IdentityResultResponse.Failure(["The system setup has already been completed."]);
}
- var role = await roleManager.FindByNameAsync(AdministratorRole);
+ await EnsureRoleAsync(TenantRoles.SysAdmin, cancellationToken);
+ await EnsureRoleAsync(TenantRoles.TenantAdmin, cancellationToken);
+ await EnsureRoleAsync(TenantRoles.TenantOperator, cancellationToken);
+ await EnsureRoleAsync(TenantRoles.TenantAttendant, cancellationToken);
+ Role? role = await roleManager.FindByNameAsync(AdministratorRole);
if (role is null)
{
role = new Role(AdministratorRole);
- var roleResult = await roleManager.CreateAsync(role);
+ IdentityResult roleResult = await roleManager.CreateAsync(role);
if (!roleResult.Succeeded)
{
return Failure(roleResult);
}
- var claimResult = await roleManager.AddClaimAsync(role, new Claim(IdentityClaimTypes.Permission, AdministratorPermission));
+ IdentityResult claimResult = await roleManager.AddClaimAsync(role, new Claim(IdentityClaimTypes.Permission, AdministratorPermission));
if (!claimResult.Succeeded)
{
return Failure(claimResult);
}
}
-
- var user = new User(email)
- {
- Email = email,
- EmailConfirmed = true
- };
-
- var userResult = await userManager.CreateAsync(user, password);
+ var user = new User(email) { Email = email, EmailConfirmed = true };
+ IdentityResult userResult = await userManager.CreateAsync(user, password);
if (!userResult.Succeeded)
{
return Failure(userResult);
}
- var membershipResult = await userManager.AddToRoleAsync(user, AdministratorRole);
+ IdentityResult membershipResult = await userManager.AddToRoleAsync(user, TenantRoles.SysAdmin);
+ if (!membershipResult.Succeeded)
+ {
+ return Failure(membershipResult);
+ }
+
+ membershipResult = await userManager.AddToRoleAsync(user, AdministratorRole);
if (!membershipResult.Succeeded)
{
return Failure(membershipResult);
}
+ string? connectionString = configurationStore.GetMasterConnectionString();
+ if (!string.IsNullOrWhiteSpace(connectionString))
+ {
+ DbContextOptions options = new DbContextOptionsBuilder().UseSqlServer(connectionString).Options;
+ await using var dbContext = new RemoteTicketsDbContext(options);
+ SystemSetupState? state = await dbContext.SystemSetup.SingleOrDefaultAsync(x => x.Id == RemoteTicketsConstants.SystemSetupId, cancellationToken);
+ if (state is null)
+ {
+ state = new SystemSetupState { Id = RemoteTicketsConstants.SystemSetupId };
+ dbContext.SystemSetup.Add(state);
+ }
+ state.IsComplete = true;
+ state.CompletedAt = DateTimeOffset.UtcNow;
+ await dbContext.SaveChangesAsync(cancellationToken);
+ }
logger.LogInformation("Initial system setup completed for user {UserId}.", user.Id);
return IdentityResultResponse.Success();
}
private async Task CreateUserTokensAsync(User user, CancellationToken cancellationToken)
{
- var roles = await userManager.GetRolesAsync(user);
- var claims = await userManager.GetClaimsAsync(user);
-
- foreach (var roleName in roles)
+ IList roles = await userManager.GetRolesAsync(user);
+ IList claims = await userManager.GetClaimsAsync(user);
+ foreach (string roleName in roles)
{
- var role = await roleManager.FindByNameAsync(roleName);
- if (role is null)
+ Role? role = await roleManager.FindByNameAsync(roleName);
+ if (role is not null)
{
- continue;
+ claims = claims.Concat(await roleManager.GetClaimsAsync(role)).ToList();
}
+ }
+ return tokenService.CreateTokens(user.Id, user.Email ?? user.UserName ?? user.Id, roles, claims);
+ }
- claims = claims.Concat(await roleManager.GetClaimsAsync(role)).ToList();
+ private async Task EnsureRoleAsync(string roleName, CancellationToken cancellationToken)
+ {
+ if (await roleManager.RoleExistsAsync(roleName))
+ {
+ return;
}
- return tokenService.CreateTokens(user.Id, user.Email ?? user.UserName ?? user.Id, roles, claims);
+ IdentityResult result = await roleManager.CreateAsync(new Role(roleName));
+ if (!result.Succeeded)
+ {
+ throw new InvalidOperationException(string.Join(" ", result.Errors.Select(x => x.Description)));
+ }
}
private static IdentityResultResponse Failure(IdentityResult result) => IdentityResultResponse.Failure(result.Errors.Select(error => error.Description));
}
-///
-/// Logs identity email messages instead of delivering them through an external email provider.
-///
-/// The logger used to record email messages.
+/// Logs identity email messages instead of delivering them through an external email provider.
public sealed class LoggingIdentityEmailSender(ILogger logger) : IIdentityEmailSender
{
- ///
- /// Logs an email confirmation message.
- ///
- /// The recipient email address.
- /// The confirmation link.
- /// The token used to cancel the operation.
- /// A completed task.
+ /// Logs an email confirmation message.
public Task SendConfirmationAsync(string email, string confirmationLink, CancellationToken cancellationToken)
{
logger.LogInformation("Identity confirmation link for {Email}: {Link}", email, confirmationLink);
return Task.CompletedTask;
}
- ///
- /// Logs a password reset message.
- ///
- /// The recipient email address.
- /// The password reset link.
- /// The token used to cancel the operation.
- /// A completed task.
+ /// Logs a password reset message.
public Task SendPasswordResetAsync(string email, string resetLink, CancellationToken cancellationToken)
{
logger.LogInformation("Identity password reset link for {Email}: {Link}", email, resetLink);
diff --git a/src/server/RemoteTickets.Infrastructure/Identity/Services/JwtTokenService.cs b/src/server/RemoteTickets.Infrastructure/Identity/Services/JwtTokenService.cs
index ed649df..de64d58 100644
--- a/src/server/RemoteTickets.Infrastructure/Identity/Services/JwtTokenService.cs
+++ b/src/server/RemoteTickets.Infrastructure/Identity/Services/JwtTokenService.cs
@@ -10,15 +10,11 @@ public sealed class JwtTokenService(IOptions options, IRevokedTokenS
///
public TokenResponse CreateTokens(string userId, string email, IEnumerable roles, IEnumerable claims)
{
- var now = DateTimeOffset.UtcNow;
- var access = CreateToken(userId, email, roles, claims, JwtTokenTypes.Access, now, _options.AccessTokenLifetime);
- var refresh = CreateToken(userId, email, roles, Array.Empty(), JwtTokenTypes.Refresh, now, _options.RefreshTokenLifetime);
-
- return new TokenResponse(
- "Bearer",
- new JwtSecurityTokenHandler().WriteToken(access),
- (int)_options.AccessTokenLifetime.TotalSeconds,
- new JwtSecurityTokenHandler().WriteToken(refresh));
+ DateTimeOffset now = DateTimeOffset.UtcNow;
+ Claim[] claimSet = claims.ToArray();
+ JwtSecurityToken access = CreateToken(userId, email, roles, claimSet, JwtTokenTypes.Access, now, _options.AccessTokenLifetime);
+ JwtSecurityToken refresh = CreateToken(userId, email, roles, claimSet, JwtTokenTypes.Refresh, now, _options.RefreshTokenLifetime);
+ return new TokenResponse("Bearer", new JwtSecurityTokenHandler().WriteToken(access), (int)_options.AccessTokenLifetime.TotalSeconds, new JwtSecurityTokenHandler().WriteToken(refresh));
}
///
@@ -40,39 +36,25 @@ public TokenResponse CreateTokens(string userId, string email, IEnumerable
public string? GetTokenId(string token)
{
- try
- {
- return new JwtSecurityTokenHandler().ReadJwtToken(token).Id;
- }
- catch (ArgumentException)
- {
- return null;
- }
+ try { return new JwtSecurityTokenHandler().ReadJwtToken(token).Id; }
+ catch (ArgumentException) { return null; }
}
///
@@ -80,23 +62,13 @@ public TokenResponse CreateTokens(string userId, string email, IEnumerable roles,
- IEnumerable claims,
- string tokenType,
- DateTimeOffset issuedAt,
- TimeSpan lifetime)
+ private JwtSecurityToken CreateToken(string userId, string email, IEnumerable roles, IEnumerable claims, string tokenType, DateTimeOffset issuedAt, TimeSpan lifetime)
{
var tokenClaims = new List
{
@@ -105,24 +77,14 @@ private JwtSecurityToken CreateToken(
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString("N")),
new("token_type", tokenType)
};
-
tokenClaims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role)));
tokenClaims.AddRange(claims);
-
- var credentials = new SigningCredentials(CreateSecurityKey(), SecurityAlgorithms.HmacSha256);
-
- return new JwtSecurityToken(
- issuer: _options.Issuer,
- audience: _options.Audience,
- claims: tokenClaims,
- notBefore: issuedAt.UtcDateTime,
- expires: issuedAt.Add(lifetime).UtcDateTime,
- signingCredentials: credentials);
+ return new JwtSecurityToken(issuer: _options.Issuer, audience: _options.Audience, claims: tokenClaims, notBefore: issuedAt.UtcDateTime, expires: issuedAt.Add(lifetime).UtcDateTime, signingCredentials: new SigningCredentials(CreateSecurityKey(), SecurityAlgorithms.HmacSha256));
}
private SymmetricSecurityKey CreateSecurityKey()
{
- var bytes = Encoding.UTF8.GetBytes(_options.SecretKey);
+ byte[] bytes = Encoding.UTF8.GetBytes(_options.SecretKey);
if (bytes.Length < 32)
{
throw new InvalidOperationException("Authentication:Jwt:SecretKey must contain at least 256 bits.");
@@ -136,11 +98,10 @@ private SymmetricSecurityKey CreateSecurityKey()
public sealed class RevokedTokenStore : IRevokedTokenStore
{
private readonly ConcurrentDictionary _tokens = new();
-
///
public bool IsRevoked(string tokenId)
{
- if (!_tokens.TryGetValue(tokenId, out var expiresAt))
+ if (!_tokens.TryGetValue(tokenId, out DateTimeOffset expiresAt))
{
return false;
}
@@ -153,7 +114,6 @@ public bool IsRevoked(string tokenId)
_tokens.TryRemove(tokenId, out _);
return false;
}
-
///
public void Revoke(string tokenId, DateTimeOffset expiresAt)
{
diff --git a/src/server/RemoteTickets.Infrastructure/Persistence/Auditing/EntityAuditRecord.cs b/src/server/RemoteTickets.Infrastructure/Persistence/Auditing/EntityAuditRecord.cs
new file mode 100644
index 0000000..9e32646
--- /dev/null
+++ b/src/server/RemoteTickets.Infrastructure/Persistence/Auditing/EntityAuditRecord.cs
@@ -0,0 +1,29 @@
+namespace RemoteTickets.Infrastructure.Persistence.Auditing;
+
+/// Represents a persisted state transition for an auditable entity.
+public sealed class EntityAuditRecord : IEntityUpdateHistory
+{
+ /// Gets or sets the audit record identifier.
+ public string Id { get; set; } = Guid.CreateVersion7().ToString();
+
+ /// Gets or sets the CLR entity type name represented by the record.
+ public string EntityType { get; set; } = string.Empty;
+
+ /// Gets or sets the identifier of the audited entity.
+ public string EntityId { get; set; } = string.Empty;
+
+ /// Gets or sets the operation that caused the audit entry.
+ public string Operation { get; set; } = string.Empty;
+
+ ///
+ public string CurrentEntityState { get; init; } = string.Empty;
+
+ ///
+ public string PreviousEntityState { get; init; } = string.Empty;
+
+ ///
+ public DateTimeOffset UpdatedAt { get; init; } = DateTimeOffset.UtcNow;
+
+ ///
+ public string UpdatedBy { get; set; } = string.Empty;
+}
diff --git a/src/server/RemoteTickets.Infrastructure/Persistence/Configurations/Auditing/EntityAuditRecordConfiguration.cs b/src/server/RemoteTickets.Infrastructure/Persistence/Configurations/Auditing/EntityAuditRecordConfiguration.cs
new file mode 100644
index 0000000..370a682
--- /dev/null
+++ b/src/server/RemoteTickets.Infrastructure/Persistence/Configurations/Auditing/EntityAuditRecordConfiguration.cs
@@ -0,0 +1,21 @@
+namespace RemoteTickets.Infrastructure.Persistence.Configurations.Auditing;
+
+/// Configures the SQL representation of persisted entity audit history.
+public sealed class EntityAuditRecordConfiguration : IEntityTypeConfiguration
+{
+ ///
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("EntityHistory", "audit");
+ builder.HasKey(x => x.Id);
+ builder.Property(x => x.Id).HasMaxLength(36).IsRequired();
+ builder.Property(x => x.EntityType).HasMaxLength(256).IsRequired();
+ builder.Property(x => x.EntityId).HasMaxLength(128).IsRequired();
+ builder.Property(x => x.Operation).HasMaxLength(32).IsRequired();
+ builder.Property(x => x.UpdatedBy).HasMaxLength(256).IsRequired();
+ builder.Property(x => x.UpdatedAt).IsRequired();
+ builder.Property(x => x.PreviousEntityState).IsRequired();
+ builder.Property(x => x.CurrentEntityState).IsRequired();
+ builder.HasIndex(x => new { x.EntityType, x.EntityId, x.UpdatedAt });
+ }
+}
diff --git a/src/server/RemoteTickets.Infrastructure/Persistence/Configurations/Identity/IdentityConfigurations.cs b/src/server/RemoteTickets.Infrastructure/Persistence/Configurations/Identity/IdentityConfigurations.cs
new file mode 100644
index 0000000..542e31f
--- /dev/null
+++ b/src/server/RemoteTickets.Infrastructure/Persistence/Configurations/Identity/IdentityConfigurations.cs
@@ -0,0 +1,25 @@
+namespace RemoteTickets.Infrastructure.Persistence.Configurations.Identity;
+
+internal sealed class IdentityConfigurations :
+IEntityTypeConfiguration,
+IEntityTypeConfiguration,
+IEntityTypeConfiguration>
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("Users");
+ builder.HasKey(u => u.Id);
+ builder.HasIndex(u => new { u.Id, u.TenantId })
+ .IsUnique()
+ .HasDatabaseName("IX_UQ_UID_TID");
+ }
+
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("Roles");
+ builder.HasKey(r => r.Id);
+ }
+
+ public void Configure(EntityTypeBuilder> builder)
+ => builder.ToTable("UserRoles");
+}
diff --git a/src/server/RemoteTickets.Infrastructure/Persistence/Configurations/Identity/IdentityrConfigurations.cs b/src/server/RemoteTickets.Infrastructure/Persistence/Configurations/Identity/IdentityrConfigurations.cs
deleted file mode 100644
index a478ef1..0000000
--- a/src/server/RemoteTickets.Infrastructure/Persistence/Configurations/Identity/IdentityrConfigurations.cs
+++ /dev/null
@@ -1,16 +0,0 @@
-using Microsoft.EntityFrameworkCore.Metadata.Builders;
-
-namespace RemoteTickets.Infrastructure.Persistence.Configurations.Identity;
-
-internal sealed class IdentityrConfigurations : IEntityTypeConfiguration, IEntityTypeConfiguration
-{
- public void Configure(EntityTypeBuilder builder)
- {
- builder.ToTable("Users");
- }
-
- public void Configure(EntityTypeBuilder builder)
- {
- builder.ToTable("Roles");
- }
-}
diff --git a/src/server/RemoteTickets.Infrastructure/Persistence/Configurations/Tenancy/TenancyConfigurations.cs b/src/server/RemoteTickets.Infrastructure/Persistence/Configurations/Tenancy/TenancyConfigurations.cs
new file mode 100644
index 0000000..fe59faf
--- /dev/null
+++ b/src/server/RemoteTickets.Infrastructure/Persistence/Configurations/Tenancy/TenancyConfigurations.cs
@@ -0,0 +1,23 @@
+namespace RemoteTickets.Infrastructure.Persistence.Configurations.Tenancy;
+
+internal sealed class TenancyConfigurations :
+IEntityTypeConfiguration,
+IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("Tenants");
+ builder.HasKey(x => x.Id);
+ builder.Property(x => x.Id).HasMaxLength(128);
+ builder.Property(x => x.Name).HasMaxLength(256).IsRequired();
+ builder.Property(x => x.DatabaseName).HasMaxLength(128).IsRequired();
+ builder.Property(x => x.ConnectionString).IsRequired();
+ builder.HasIndex(x => x.DatabaseName).IsUnique();
+ }
+
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("SystemSetupState");
+ builder.HasKey(x => x.Id);
+ }
+}
diff --git a/src/server/RemoteTickets.Infrastructure/Persistence/InvisibleSPDbContext.cs b/src/server/RemoteTickets.Infrastructure/Persistence/InvisibleSPDbContext.cs
deleted file mode 100644
index ab37dd2..0000000
--- a/src/server/RemoteTickets.Infrastructure/Persistence/InvisibleSPDbContext.cs
+++ /dev/null
@@ -1,75 +0,0 @@
-using Microsoft.EntityFrameworkCore.ChangeTracking;
-
-namespace RemoteTickets.Infrastructure.Persistence;
-
-///
-/// Represents the database context for the RemoteTickets application.
-///
-public sealed class RemoteTicketsDbContext(
- DbContextOptions options) : IdentityDbContext(options)
-{
- ///
- protected override void OnModelCreating(ModelBuilder builder)
- {
- base.OnModelCreating(builder);
-
- foreach (var entityType in builder.Model.GetEntityTypes())
- {
- if (!typeof(ISoftDeletable).IsAssignableFrom(entityType.ClrType))
- {
- continue;
- }
-
- var parameter = Expression.Parameter(entityType.ClrType, "entity");
- var property = Expression.Property(parameter, nameof(ISoftDeletable.IsDeleted));
- var filter = Expression.Lambda(Expression.Not(property), parameter);
- builder.Entity(entityType.ClrType).HasQueryFilter(filter);
- }
- builder.ApplyConfigurationsFromAssembly(typeof(RemoteTicketsDbContext).Assembly);
- }
-
- ///
- public override int SaveChanges(bool acceptAllChangesOnSuccess)
- {
- ApplySoftDelete();
- return base.SaveChanges(acceptAllChangesOnSuccess);
- }
-
- ///
- public override int SaveChanges()
- {
- ApplySoftDelete();
- return base.SaveChanges();
- }
-
- ///
- public override Task SaveChangesAsync(
- bool acceptAllChangesOnSuccess,
- CancellationToken cancellationToken = default)
- {
- ApplySoftDelete();
- return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
- }
-
- ///
- public override Task SaveChangesAsync(CancellationToken cancellationToken = default)
- {
- ApplySoftDelete();
- return base.SaveChangesAsync(cancellationToken);
- }
-
- private void ApplySoftDelete()
- {
- foreach (EntityEntry entry in ChangeTracker.Entries())
- {
- if (entry.State != EntityState.Deleted)
- {
- continue;
- }
-
- entry.State = EntityState.Modified;
- entry.Entity.IsDeleted = true;
- entry.Entity.DeletedAt = DateTimeOffset.UtcNow;
- }
- }
-}
diff --git a/src/server/RemoteTickets.Infrastructure/Persistence/RemoteTicketsDbContext.cs b/src/server/RemoteTickets.Infrastructure/Persistence/RemoteTicketsDbContext.cs
new file mode 100644
index 0000000..3dff1ec
--- /dev/null
+++ b/src/server/RemoteTickets.Infrastructure/Persistence/RemoteTicketsDbContext.cs
@@ -0,0 +1,159 @@
+using Microsoft.EntityFrameworkCore.Metadata;
+
+namespace RemoteTickets.Infrastructure.Persistence;
+
+/// Represents the central database context for RemoteTickets identity, setup, tenant catalog, and audit data.
+public sealed class RemoteTicketsDbContext(DbContextOptions options, IHttpContextAccessor? httpContextAccessor = null) : IdentityDbContext(options)
+{
+ private readonly IHttpContextAccessor? _httpContextAccessor = httpContextAccessor;
+
+ /// Gets the registered tenants.
+ public DbSet Tenants => Set();
+
+ /// Gets the singleton system setup state.
+ public DbSet SystemSetup => Set();
+
+ /// Gets the persisted entity state transitions stored in the audit schema.
+ public DbSet EntityAuditHistory => Set();
+
+ ///
+ protected override void OnModelCreating(ModelBuilder builder)
+ {
+ base.OnModelCreating(builder);
+ foreach (IMutableEntityType entityType in builder.Model.GetEntityTypes())
+ {
+ if (!typeof(ISoftDeletable).IsAssignableFrom(entityType.ClrType))
+ {
+ continue;
+ }
+
+ ParameterExpression parameter = Expression.Parameter(entityType.ClrType, "entity");
+ MemberExpression property = Expression.Property(parameter, nameof(ISoftDeletable.IsDeleted));
+ LambdaExpression filter = Expression.Lambda(Expression.Not(property), parameter);
+ builder.Entity(entityType.ClrType).HasQueryFilter(filter);
+ }
+
+ builder.ApplyConfigurationsFromAssembly(typeof(RemoteTicketsDbContext).Assembly);
+ }
+
+ ///
+ public override int SaveChanges(bool acceptAllChangesOnSuccess)
+ {
+ PreparePersistenceChanges();
+ return base.SaveChanges(acceptAllChangesOnSuccess);
+ }
+
+ ///
+ public override int SaveChanges()
+ => SaveChanges(true);
+
+ ///
+ public override Task SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default)
+ {
+ PreparePersistenceChanges();
+ return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
+ }
+
+ ///
+ public override Task SaveChangesAsync(CancellationToken cancellationToken = default)
+ => SaveChangesAsync(true, cancellationToken);
+
+ private void PreparePersistenceChanges()
+ {
+ ApplySoftDelete();
+ ApplyCreationMetadata();
+ AddAuditEntries();
+ }
+
+ private void ApplyCreationMetadata()
+ {
+ string actor = GetActor();
+ DateTimeOffset now = DateTimeOffset.UtcNow;
+
+ foreach (EntityEntry entry in ChangeTracker.Entries().Where(x => x.State == EntityState.Added))
+ {
+ entry.Property(nameof(IEntityAuditable.CreatedAt)).CurrentValue = now;
+ entry.Property(nameof(IEntityAuditable.CreatedBy)).CurrentValue = actor;
+ }
+ }
+
+ private void AddAuditEntries()
+ {
+ string actor = GetActor();
+ DateTimeOffset now = DateTimeOffset.UtcNow;
+ List auditEntries = new();
+
+ foreach (EntityEntry entry in ChangeTracker.Entries().ToArray())
+ {
+ if (entry.State is not (EntityState.Added or EntityState.Modified or EntityState.Deleted))
+ {
+ continue;
+ }
+
+ PropertyEntry? idProperty = entry.Properties.FirstOrDefault(x => string.Equals(x.Metadata.Name, "Id", StringComparison.OrdinalIgnoreCase));
+ if (idProperty is null || idProperty.CurrentValue is null)
+ {
+ continue;
+ }
+
+ string operation = entry.State switch
+ {
+ EntityState.Added => "Created",
+ EntityState.Modified when IsSoftDeleteTransition(entry) => "Deleted",
+ EntityState.Modified => "Updated",
+ EntityState.Deleted => "Deleted",
+ _ => throw new InvalidOperationException("Unsupported audit state.")
+ };
+
+ object previousState = entry.State == EntityState.Added ? new { } : entry.OriginalValues.ToObject();
+ object currentState = entry.State == EntityState.Deleted ? entry.OriginalValues.ToObject() : entry.CurrentValues.ToObject();
+ auditEntries.Add(new EntityAuditRecord
+ {
+ EntityType = entry.Metadata.ClrType.FullName ?? entry.Metadata.ClrType.Name,
+ EntityId = idProperty.CurrentValue.ToString() ?? string.Empty,
+ Operation = operation,
+ PreviousEntityState = JsonSerializer.Serialize(previousState),
+ CurrentEntityState = JsonSerializer.Serialize(currentState),
+ UpdatedAt = now,
+ UpdatedBy = actor
+ });
+ }
+
+ if (auditEntries.Count != 0)
+ {
+ EntityAuditHistory.AddRange(auditEntries);
+ }
+ }
+
+ private static bool IsSoftDeleteTransition(EntityEntry entry)
+ {
+ if (!entry.Metadata.ClrType.GetInterfaces().Contains(typeof(ISoftDeletable)))
+ {
+ return false;
+ }
+
+ PropertyEntry? deletedProperty = entry.Properties.FirstOrDefault(x => string.Equals(x.Metadata.Name, nameof(ISoftDeletable.IsDeleted), StringComparison.OrdinalIgnoreCase));
+ return deletedProperty?.CurrentValue is true && deletedProperty.OriginalValue is false;
+ }
+
+ private string GetActor()
+ => _httpContextAccessor?.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier)
+ ?? _httpContextAccessor?.HttpContext?.User.FindFirstValue(JwtRegisteredClaimNames.Sub)
+ ?? _httpContextAccessor?.HttpContext?.User.FindFirstValue(ClaimTypes.Email)
+ ?? "system";
+
+ private void ApplySoftDelete()
+ {
+ foreach (EntityEntry entry in ChangeTracker.Entries())
+ {
+ if (entry.State != EntityState.Deleted)
+ {
+ continue;
+ }
+
+ entry.State = EntityState.Modified;
+ entry.Entity.IsDeleted = true;
+ entry.Entity.DeletedAt = DateTimeOffset.UtcNow;
+ }
+ }
+}
diff --git a/src/server/RemoteTickets.Infrastructure/Setup/SetupConfigurationStore.cs b/src/server/RemoteTickets.Infrastructure/Setup/SetupConfigurationStore.cs
new file mode 100644
index 0000000..87e1ece
--- /dev/null
+++ b/src/server/RemoteTickets.Infrastructure/Setup/SetupConfigurationStore.cs
@@ -0,0 +1,55 @@
+namespace RemoteTickets.Infrastructure.Setup;
+
+/// Persists the installation master-database connection string used by the tenant catalog.
+public interface ISetupConfigurationStore
+{
+ /// Gets the configured master-database connection string.
+ /// The configured connection string, or when setup has not stored one.
+ string? GetMasterConnectionString();
+
+ /// Validates and persists the master-database connection string.
+ /// The SQL Server connection string.
+ /// The cancellation token.
+ /// A completed task when persistence succeeds.
+ Task SetMasterConnectionStringAsync(string connectionString, CancellationToken cancellationToken);
+}
+
+/// Stores setup configuration in an application-local file until a dedicated secrets store is configured.
+public sealed class SetupConfigurationStore : ISetupConfigurationStore
+{
+ private readonly string _path = Path.Combine(AppContext.BaseDirectory, "data", "setup.json");
+
+ ///
+ public string? GetMasterConnectionString()
+ {
+ if (!File.Exists(_path))
+ {
+ return null;
+ }
+
+ try
+ {
+ State? state = JsonSerializer.Deserialize(File.ReadAllText(_path));
+ return state?.MasterConnectionString;
+ }
+ catch (JsonException)
+ {
+ return null;
+ }
+ }
+
+ ///
+ public async Task SetMasterConnectionStringAsync(string connectionString, CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(connectionString))
+ {
+ throw new ArgumentException("A master database connection string is required.", nameof(connectionString));
+ }
+
+ Directory.CreateDirectory(Path.GetDirectoryName(_path)!);
+ string json = JsonSerializer.Serialize(new State(connectionString));
+ await File.WriteAllTextAsync(_path, json, cancellationToken);
+ }
+
+ private sealed record State(string MasterConnectionString);
+}
diff --git a/src/server/RemoteTickets.Infrastructure/Setup/SystemService.cs b/src/server/RemoteTickets.Infrastructure/Setup/SystemService.cs
new file mode 100644
index 0000000..16b50b2
--- /dev/null
+++ b/src/server/RemoteTickets.Infrastructure/Setup/SystemService.cs
@@ -0,0 +1,20 @@
+namespace RemoteTickets.Infrastructure.Setup;
+
+/// System service implementation.
+public sealed class SystemService(ISetupConfigurationStore configurationStore) : ISystemService
+{
+ ///
+ public async Task PerformMasterDatabaseSetup(string connectionString, int commandTimeout, CancellationToken cancellationToken)
+ {
+ await configurationStore.SetMasterConnectionStringAsync(connectionString, cancellationToken);
+ DbContextOptions options = new DbContextOptionsBuilder()
+ .UseSqlServer(connectionString, options =>
+ {
+ options.CommandTimeout(commandTimeout);
+ })
+ .Options;
+ await using RemoteTicketsDbContext dbContext = new(options);
+ bool status = await dbContext.Database.EnsureCreatedAsync(cancellationToken);
+ return new(status, status? "Master Database Successfully set up!" : "Master Database not set up!");
+ }
+}
diff --git a/src/server/RemoteTickets.Infrastructure/Setup/SystemSetupState.cs b/src/server/RemoteTickets.Infrastructure/Setup/SystemSetupState.cs
new file mode 100644
index 0000000..34a51be
--- /dev/null
+++ b/src/server/RemoteTickets.Infrastructure/Setup/SystemSetupState.cs
@@ -0,0 +1,16 @@
+namespace RemoteTickets.Infrastructure.Setup;
+
+/// Stores the singleton installation setup state in the master database.
+public sealed class SystemSetupState : IEntityAuditable
+{
+ /// Gets or sets the singleton setup-state identifier.
+ public string Id { get; set; } = RemoteTicketsConstants.SystemSetupId;
+ /// Gets or sets whether the installation is ready for normal use.
+ public bool IsComplete { get; set; }
+ /// Gets or sets the completion timestamp.
+ public DateTimeOffset? CompletedAt { get; set; }
+ ///
+ public DateTimeOffset CreatedAt { get; init; }
+ ///
+ public string CreatedBy { get; init; } = string.Empty;
+}
diff --git a/src/server/RemoteTickets.Infrastructure/Tenancy/Configurations/TenancyConfigurations.cs b/src/server/RemoteTickets.Infrastructure/Tenancy/Configurations/TenancyConfigurations.cs
new file mode 100644
index 0000000..ee62419
--- /dev/null
+++ b/src/server/RemoteTickets.Infrastructure/Tenancy/Configurations/TenancyConfigurations.cs
@@ -0,0 +1,13 @@
+namespace RemoteTickets.Infrastructure.Tenancy.Configurations;
+
+internal sealed class TenantSetupStateConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("TenantSetupState");
+ builder.HasKey(x => x.Id);
+ builder.Property(x => x.IsComplete).IsRequired();
+ }
+
+
+}
diff --git a/src/server/RemoteTickets.Infrastructure/Tenancy/Models/Tenant.cs b/src/server/RemoteTickets.Infrastructure/Tenancy/Models/Tenant.cs
new file mode 100644
index 0000000..2b18976
--- /dev/null
+++ b/src/server/RemoteTickets.Infrastructure/Tenancy/Models/Tenant.cs
@@ -0,0 +1,26 @@
+namespace RemoteTickets.Infrastructure.Tenancy.Models;
+
+/// Represents a tenant registered in the installation catalog.
+public sealed class Tenant : IEntityBase, ISoftDeletable, IEntityAuditable
+{
+ /// Gets or sets the stable tenant identifier used by routes and tokens.
+ public string Id { get; set; } = string.Empty;
+ /// Gets or sets the display name of the tenant.
+ public string Name { get; set; } = string.Empty;
+ /// Gets or sets the SQL Server database name assigned to the tenant.
+ public string DatabaseName { get; set; } = string.Empty;
+ /// Gets or sets the connection string used to access the tenant database.
+ public string ConnectionString { get; set; } = string.Empty;
+ /// Gets or sets whether the tenant accepts normal requests.
+ public bool IsActive { get; set; } = true;
+ /// Gets or sets whether tenant setup has completed.
+ public bool IsSetupComplete { get; set; }
+ ///
+ public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
+ ///
+ public string CreatedBy { get; init; } = string.Empty;
+ /// Flag to indicate if the entity is deleted.
+ public bool IsDeleted { get; set; }
+ /// DateTimeOffset (UTC) of when the entity is deleted.
+ public DateTimeOffset? DeletedAt { get; set; }
+}
diff --git a/src/server/RemoteTickets.Infrastructure/Tenancy/Models/TenantSetupState.cs b/src/server/RemoteTickets.Infrastructure/Tenancy/Models/TenantSetupState.cs
new file mode 100644
index 0000000..3714808
--- /dev/null
+++ b/src/server/RemoteTickets.Infrastructure/Tenancy/Models/TenantSetupState.cs
@@ -0,0 +1,12 @@
+namespace RemoteTickets.Infrastructure.Tenancy.Models;
+
+/// Stores the initialization state of a tenant database.
+public sealed class TenantSetupState : IEntityBase
+{
+ /// Gets or sets the singleton setup-state identifier.
+ public string Id { get; set; } = RemoteTicketsConstants.TenantSetupId;
+ /// Gets or sets whether the tenant database setup has completed.
+ public bool IsComplete { get; set; }
+ /// Gets or sets the last setup validation timestamp.
+ public DateTimeOffset? CompletedAt { get; set; }
+}
diff --git a/src/server/RemoteTickets.Infrastructure/Tenancy/TenantAccessMiddleware.cs b/src/server/RemoteTickets.Infrastructure/Tenancy/TenantAccessMiddleware.cs
index 6204f4e..345c373 100644
--- a/src/server/RemoteTickets.Infrastructure/Tenancy/TenantAccessMiddleware.cs
+++ b/src/server/RemoteTickets.Infrastructure/Tenancy/TenantAccessMiddleware.cs
@@ -1,40 +1,57 @@
-using Microsoft.AspNetCore.Authorization;
-using Microsoft.AspNetCore.Http;
-using RemoteTickets.Application.Common.Tenancy;
+using Microsoft.AspNetCore.Mvc;
namespace RemoteTickets.Infrastructure.Tenancy;
/// Enforces tenant route isolation and mandatory setup state for HTTP requests.
-public sealed class TenantAccessMiddleware(RequestDelegate next, ITenantManagementService tenants, IIdentityService identityService)
+public sealed class TenantAccessMiddleware(RequestDelegate next, ITenantManagementService tenants, RemoteTicketsDbContext masterDb)
{
/// Processes the current request and enforces tenant isolation when a tenant route is present.
/// The current HTTP context.
/// The asynchronous middleware operation.
public async Task InvokeAsync(HttpContext context)
{
- var endpoint = context.GetEndpoint();
- var anonymous = endpoint?.Metadata.GetMetadata() is not null;
- var systemStatus = await identityService.GetSetupStatusAsync(context.RequestAborted);
- if (systemStatus.IsSetupRequired)
+ if (IsInfrastructurePath(context.Request.Path)) { await next(context); return; }
+ Endpoint? endpoint = context.GetEndpoint();
+ bool anonymous = endpoint?.Metadata.GetMetadata() is not null;
+ SystemSetupState? setupState = await masterDb.SystemSetup.AsNoTracking().SingleOrDefaultAsync(x => x.Id == RemoteTicketsConstants.SystemSetupId, context.RequestAborted);
+ bool systemSetupRequired = setupState is null || !setupState.IsComplete;
+ if (systemSetupRequired)
{
if (IsSystemSetupEndpoint(context)) { await next(context); return; }
await RejectOrRedirectAsync(context, "/setup", StatusCodes.Status503ServiceUnavailable);
return;
}
- if (!context.Request.RouteValues.TryGetValue("tenantId", out var value) || string.IsNullOrWhiteSpace(value?.ToString()))
+ if (IsSystemSetupEndpoint(context) && HttpMethods.IsPost(context.Request.Method))
{
- if (anonymous) await next(context); else await RejectOrRedirectAsync(context, "/setup", StatusCodes.Status503ServiceUnavailable);
+ context.Response.StatusCode = StatusCodes.Status409Conflict;
+ await context.Response.WriteAsJsonAsync(new { error = "System setup is already complete." }, context.RequestAborted);
return;
}
- var tenantId = value.ToString()!;
+ if (!context.Request.RouteValues.TryGetValue("tenantId", out object? value) || string.IsNullOrWhiteSpace(value?.ToString()))
+ {
+ if (anonymous)
+ {
+ await next(context);
+ }
+ else
+ {
+ await RejectOrRedirectAsync(context, "/setup", StatusCodes.Status503ServiceUnavailable);
+ }
+
+ return;
+ }
+ string tenantId = value.ToString()!;
if (string.Equals(tenantId, "system", StringComparison.OrdinalIgnoreCase))
{
if (context.User.Identity?.IsAuthenticated != true || !context.User.IsInRole(TenantRoles.SysAdmin)) { context.Response.StatusCode = StatusCodes.Status403Forbidden; return; }
await next(context);
return;
}
- var tenant = await tenants.GetAsync(tenantId, context.RequestAborted);
- if (tenant is null || !tenant.IsActive) { await RejectOrRedirectAsync(context, "/not-found", StatusCodes.Status404NotFound); return; }
+ TenantResponse? tenant = await tenants.GetAsync(tenantId, context.RequestAborted);
+ if (tenant is null || !tenant.IsActive)
+ {
+ await RejectOrRedirectAsync(context, "/not-found", StatusCodes.Status404NotFound); return;
+ }
if (anonymous)
{
if (!tenant.IsSetupComplete && !IsTenantSetupEndpoint(context) && !IsTenantLoginEndpoint(context)) { await RejectOrRedirectAsync(context, $"/{tenantId}/setup", StatusCodes.Status409Conflict); return; }
@@ -44,27 +61,67 @@ public async Task InvokeAsync(HttpContext context)
if (context.User.Identity?.IsAuthenticated != true) { context.Response.StatusCode = StatusCodes.Status401Unauthorized; return; }
if (!context.User.IsInRole(TenantRoles.SysAdmin))
{
- var assignedTenant = context.User.FindFirst(TenantClaimTypes.TenantId)?.Value;
+ string? assignedTenant = context.User.FindFirst(TenantClaimTypes.TenantId)?.Value;
if (!string.Equals(assignedTenant, tenantId, StringComparison.OrdinalIgnoreCase)) { await RejectOrRedirectAsync(context, $"/{assignedTenant ?? tenantId}", StatusCodes.Status403Forbidden); return; }
}
if (!tenant.IsSetupComplete && !IsTenantSetupEndpoint(context)) { await RejectOrRedirectAsync(context, $"/{tenantId}/setup", StatusCodes.Status409Conflict); return; }
await next(context);
}
- private static bool IsSystemSetupEndpoint(HttpContext context) => context.Request.Path.StartsWithSegments("/api/setup") || string.Equals(context.Request.Path.Value, "/setup", StringComparison.OrdinalIgnoreCase);
- private static bool IsTenantLoginEndpoint(HttpContext context) => string.Equals(context.Request.Path.Value, $"/api/v1/{context.Request.RouteValues["tenantId"]}/login", StringComparison.OrdinalIgnoreCase);
+ private static bool IsInfrastructurePath(PathString path)
+ => path.StartsWithSegments("/_blazor") ||
+ path.StartsWithSegments("/_framework") ||
+ path.StartsWithSegments("/_content") ||
+ path.StartsWithSegments("/favicon") ||
+ path.StartsWithSegments("/css") ||
+ path.StartsWithSegments("/js") ||
+ path.StartsWithSegments("/lib");
+
+ private static bool IsSystemSetupEndpoint(HttpContext context)
+ => context.Request.Path.StartsWithSegments("/api/v1/setup") ||
+ string.Equals(context.Request.Path.Value, "/setup",
+ StringComparison.OrdinalIgnoreCase);
+
+ private static bool IsTenantLoginEndpoint(HttpContext context)
+ => string.Equals(context.Request.Path.Value, $"/api/v1/{
+ context.Request.RouteValues["tenantId"]}/login",
+ StringComparison.OrdinalIgnoreCase);
+
private static bool IsTenantSetupEndpoint(HttpContext context)
{
- var tenantId = context.Request.RouteValues.TryGetValue("tenantId", out var value) ? value?.ToString() : null;
- if (string.IsNullOrWhiteSpace(tenantId)) return false;
- var path = context.Request.Path.Value;
- return string.Equals(path, $"/{tenantId}/setup", StringComparison.OrdinalIgnoreCase) || string.Equals(path, $"/api/v1/{tenantId}/setup", StringComparison.OrdinalIgnoreCase) || string.Equals(path, $"/api/v1/{tenantId}/setup/complete", StringComparison.OrdinalIgnoreCase);
+ string? tenantId = context
+ .Request
+ .RouteValues
+ .TryGetValue("tenantId", out object? value) ?
+ value?.ToString()
+ : null;
+ if (string.IsNullOrWhiteSpace(tenantId ) ||
+ string.IsNullOrEmpty(tenantId) ||
+ Guid.TryParse(tenantId, out Guid _)) { return false; }
+ string? path = context.Request.Path.Value;
+ return string.Equals(path,
+ $"/{tenantId}/setup",
+ StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(path,
+ $"/api/v1/{tenantId}/setup",
+ StringComparison.OrdinalIgnoreCase) || string.Equals(path,
+ $"/api/v1/{tenantId}/setup/complete",
+ StringComparison.OrdinalIgnoreCase);
}
private static async Task RejectOrRedirectAsync(HttpContext context, string location, int statusCode)
{
- if (HttpMethods.IsGet(context.Request.Method) && context.Request.Headers.Accept.Any(x => x.Contains("text/html", StringComparison.OrdinalIgnoreCase))) { context.Response.Redirect(location); return; }
+ if (HttpMethods.IsGet(context.Request.Method) &&
+ context.Request.Headers.Accept.Any(
+ x => x != null && x.Contains("text/html", StringComparison.OrdinalIgnoreCase)))
+ { context.Response.Redirect(location); return; }
context.Response.StatusCode = statusCode;
context.Response.ContentType = "application/problem+json";
- await context.Response.WriteAsJsonAsync(new { title = "Setup required", detail = "Complete the required setup before using this endpoint.", setup = location }, context.RequestAborted);
+ await context.Response.WriteAsJsonAsync(
+ new ProblemDetails
+ {
+ Title = "Setup required",
+ Detail = $"Complete the required setup at {location} before using this endpoint."
+ },
+ context.RequestAborted);
}
}
diff --git a/src/server/RemoteTickets.Infrastructure/Tenancy/TenantManagementService.cs b/src/server/RemoteTickets.Infrastructure/Tenancy/TenantManagementService.cs
new file mode 100644
index 0000000..b6a7b21
--- /dev/null
+++ b/src/server/RemoteTickets.Infrastructure/Tenancy/TenantManagementService.cs
@@ -0,0 +1,136 @@
+namespace RemoteTickets.Infrastructure.Tenancy;
+
+/// Implements tenant catalog, database provisioning, and tenant setup operations.
+public sealed class TenantManagementService(
+ RemoteTicketsDbContext catalog,
+ IIdentityService identityService,
+ ILogger logger) : ITenantManagementService
+{
+ ///
+ public async Task GetAsync(string tenantId, CancellationToken cancellationToken)
+ {
+ Tenant? tenant = await catalog.Tenants.AsNoTracking().SingleOrDefaultAsync(x => x.Id == tenantId, cancellationToken);
+ return tenant is null ? null : Map(tenant);
+ }
+
+ ///