diff --git a/SecurityService.BusinessLogic/SecurityServiceOptions.cs b/SecurityService.BusinessLogic/SecurityServiceOptions.cs index 88733fe2..2bff99c1 100644 --- a/SecurityService.BusinessLogic/SecurityServiceOptions.cs +++ b/SecurityService.BusinessLogic/SecurityServiceOptions.cs @@ -17,6 +17,7 @@ public ServiceOptions() this.TokenOptions = new TokenOptions(); this.SignInOptions = new SignInOptions(); this.UserOptions = new UserOptions(); + this.ManagementBootstrap = new ManagementBootstrapOptions(); } #region Properties @@ -45,9 +46,28 @@ public ServiceOptions() public UserOptions UserOptions { get; set; } + public ManagementBootstrapOptions ManagementBootstrap { get; set; } + #endregion } +public sealed class ManagementBootstrapOptions +{ + public bool Enabled { get; set; } + + public string ClientId { get; set; } = string.Empty; + + public string ClientSecret { get; set; } = string.Empty; + + public string ClientName { get; set; } = "Management Bootstrap Client"; + + public string? AdminUserName { get; set; } + + public string? AdminEmail { get; set; } + + public string? AdminPassword { get; set; } +} + public class KestrelOptions { public bool AutoDiscoverFromContentRoot { get; set; } @@ -172,4 +192,4 @@ public string CreateClientAssertion(string clientId, string issuer, Int32 expire return new JwtSecurityTokenHandler().WriteToken(token); } -} \ No newline at end of file +} diff --git a/SecurityService.Client/ISecurityServiceClient.cs b/SecurityService.Client/ISecurityServiceClient.cs index a14fb9d2..ea291b44 100644 --- a/SecurityService.Client/ISecurityServiceClient.cs +++ b/SecurityService.Client/ISecurityServiceClient.cs @@ -15,6 +15,8 @@ public interface ISecurityServiceClient { #region Methods + void SetAccessToken(String accessToken); + Task CreateApiResource(CreateApiResourceRequest createApiResourceRequest, CancellationToken cancellationToken); Task CreateApiScope(CreateApiScopeRequest createApiScopeRequest, @@ -78,4 +80,4 @@ Task>> GetUsers(String userName, #endregion } -} \ No newline at end of file +} diff --git a/SecurityService.Client/SecurityServiceClient.cs b/SecurityService.Client/SecurityServiceClient.cs index 949eee52..1615bb2d 100644 --- a/SecurityService.Client/SecurityServiceClient.cs +++ b/SecurityService.Client/SecurityServiceClient.cs @@ -6,6 +6,7 @@ namespace SecurityService.Client using System; using System.Collections.Generic; using System.Net.Http; + using System.Net.Http.Headers; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -32,6 +33,8 @@ public class SecurityServiceClient : ClientProxyBase, ISecurityServiceClient /// private readonly Func BaseAddressResolver; + private readonly HttpClient HttpClient; + #endregion #region Constructors @@ -48,10 +51,16 @@ public SecurityServiceClient(Func baseAddressResolver, { this.BaseAddressResolver = baseAddressResolver; this.BaseAddress = baseAddressResolver("SecurityService"); + this.HttpClient = httpClient; } #endregion + public void SetAccessToken(String accessToken) + { + this.HttpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + } + #region Methods public async Task CreateApiResource(CreateApiResourceRequest createApiResourceRequest, @@ -561,4 +570,4 @@ private async Task> GetToken(String tokenRequest, #endregion } -} \ No newline at end of file +} diff --git a/SecurityService.IntegrationTests/Common/DockerHelper.cs b/SecurityService.IntegrationTests/Common/DockerHelper.cs index 0fe6b94d..01f43148 100644 --- a/SecurityService.IntegrationTests/Common/DockerHelper.cs +++ b/SecurityService.IntegrationTests/Common/DockerHelper.cs @@ -13,6 +13,7 @@ namespace SecurityService.IntergrationTests.Common using System.Net; using System.Net.Http; using System.Security.Cryptography.X509Certificates; + using System.Threading; using System.Threading.Tasks; public class DockerHelper : Shared.IntegrationTesting.TestContainers.DockerHelper @@ -42,6 +43,14 @@ public override async Task StartContainersForScenarioRun(String scenarioName, Do HttpClient httpClient = this.CreatePinnedHttpClient(); this.SecurityServiceClient = new SecurityServiceClient(securityServiceBaseAddressResolver, httpClient, Serialise, Deserialise); + SimpleResults.Result bootstrapToken = await this.SecurityServiceClient.GetToken("management-bootstrap", "management-bootstrap-secret", CancellationToken.None); + if (bootstrapToken.IsFailed || String.IsNullOrWhiteSpace(bootstrapToken.Data?.AccessToken)) + { + throw new InvalidOperationException("Unable to obtain the integration-test management bootstrap token."); + } + + this.SecurityServiceClient.SetAccessToken(bootstrapToken.Data.AccessToken); + ServicePointManager.SecurityProtocol = SecurityProtocolType.SystemDefault; } catch @@ -72,6 +81,9 @@ public override ContainerBuilder SetupSecurityServiceContainer() environmentVariables.Add("ServiceOptions:PasswordOptions:RequireUpperCase", "false"); environmentVariables.Add("ServiceOptions:UserOptions:RequireUniqueEmail", "false"); environmentVariables.Add("ServiceOptions:SignInOptions:RequireConfirmedEmail", "false"); + environmentVariables.Add("ServiceOptions:ManagementBootstrap:Enabled", "true"); + environmentVariables.Add("ServiceOptions:ManagementBootstrap:ClientId", "management-bootstrap"); + environmentVariables.Add("ServiceOptions:ManagementBootstrap:ClientSecret", "management-bootstrap-secret"); environmentVariables.Add("ConnectionStrings:PersistedGrantDbContext", this.SetConnectionString($"PersistedGrantStore-{this.TestId}", this.UseSecureSqlServerDatabase)); environmentVariables.Add("ConnectionStrings:ConfigurationDbContext", this.SetConnectionString($"Configuration-{this.TestId}", this.UseSecureSqlServerDatabase)); environmentVariables.Add("ConnectionStrings:AuthenticationDbContext", this.SetConnectionString($"Authentication-{this.TestId}", this.UseSecureSqlServerDatabase)); diff --git a/SecurityService.OpenIdConnect.IntegrationTests/Common/DockerHelper.cs b/SecurityService.OpenIdConnect.IntegrationTests/Common/DockerHelper.cs index a9557036..d308550c 100644 --- a/SecurityService.OpenIdConnect.IntegrationTests/Common/DockerHelper.cs +++ b/SecurityService.OpenIdConnect.IntegrationTests/Common/DockerHelper.cs @@ -113,6 +113,14 @@ public override async Task StartContainersForScenarioRun(String scenarioName, Do this.httpClient = this.CreatePinnedHttpClient(); this.SecurityServiceClient = new SecurityServiceClient(securityServiceBaseAddressResolver, this.httpClient, Serialise, Deserialise); + SimpleResults.Result bootstrapToken = await this.SecurityServiceClient.GetToken("management-bootstrap", "management-bootstrap-secret", CancellationToken.None); + if (bootstrapToken.IsFailed || String.IsNullOrWhiteSpace(bootstrapToken.Data?.AccessToken)) + { + throw new InvalidOperationException("Unable to obtain the integration-test management bootstrap token."); + } + + this.SecurityServiceClient.SetAccessToken(bootstrapToken.Data.AccessToken); + DockerHelper.AddEntryToHostsFile("127.0.0.1", SecurityServiceContainerName); DockerHelper.AddEntryToHostsFile("localhost", SecurityServiceContainerName); } @@ -264,6 +272,9 @@ public override ContainerBuilder SetupSecurityServiceContainer() environmentVariables.Add($"ServiceOptions:PasswordOptions:RequireUpperCase","false"); environmentVariables.Add($"ServiceOptions:UserOptions:RequireUniqueEmail","false"); environmentVariables.Add($"ServiceOptions:SignInOptions:RequireConfirmedEmail","false"); + environmentVariables.Add("ServiceOptions:ManagementBootstrap:Enabled", "true"); + environmentVariables.Add("ServiceOptions:ManagementBootstrap:ClientId", "management-bootstrap"); + environmentVariables.Add("ServiceOptions:ManagementBootstrap:ClientSecret", "management-bootstrap-secret"); environmentVariables.Add("ConnectionStrings:PersistedGrantDbContext",this.SetConnectionString($"PersistedGrantStore-{this.TestId}", this.UseSecureSqlServerDatabase)); environmentVariables.Add("ConnectionStrings:ConfigurationDbContext", this.SetConnectionString( $"Configuration-{this.TestId}", this.UseSecureSqlServerDatabase)); diff --git a/SecurityService.UnitTests/Authorization/ManagementAuthorizationPolicyTests.cs b/SecurityService.UnitTests/Authorization/ManagementAuthorizationPolicyTests.cs new file mode 100644 index 00000000..5f9dd7a7 --- /dev/null +++ b/SecurityService.UnitTests/Authorization/ManagementAuthorizationPolicyTests.cs @@ -0,0 +1,24 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authorization.Infrastructure; +using OpenIddict.Validation.AspNetCore; +using SecurityService.Authorization; +using Shouldly; + +namespace SecurityService.UnitTests.Authorization; + +public sealed class ManagementAuthorizationPolicyTests +{ + [Fact] + public void AddManagementApiPolicy_RequiresAnAuthenticatedUser() + { + var options = new AuthorizationOptions(); + + ManagementAuthorizationPolicies.AddManagementApiPolicy(options); + + var policy = options.GetPolicy(ManagementAuthorizationPolicies.ManagementApi); + + policy.ShouldNotBeNull(); + policy!.Requirements.ShouldContain(requirement => requirement is DenyAnonymousAuthorizationRequirement); + policy.AuthenticationSchemes.ShouldContain(OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme); + } +} diff --git a/SecurityService.UnitTests/Endpoints/ManagementEndpointsTests.cs b/SecurityService.UnitTests/Endpoints/ManagementEndpointsTests.cs new file mode 100644 index 00000000..dd4d1b2b --- /dev/null +++ b/SecurityService.UnitTests/Endpoints/ManagementEndpointsTests.cs @@ -0,0 +1,36 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.AspNetCore.Routing; +using MediatR; +using SecurityService.BusinessLogic.Requests; +using SecurityService.Authorization; +using SecurityService.Endpoints; +using Shouldly; + +namespace SecurityService.UnitTests.Endpoints; + +public sealed class ManagementEndpointsTests +{ + [Fact] + public async Task MapManagementEndpoints_AppliesManagementPolicyToEveryRoute() + { + var builder = WebApplication.CreateBuilder(); + builder.Services.AddMediatR(configuration => + configuration.RegisterServicesFromAssembly(typeof(SecurityServiceCommands).Assembly)); + using var app = builder.Build(); + + app.MapManagementEndpoints(); + await app.StartAsync(); + + var endpoints = app.Services.GetRequiredService() + .Endpoints + .OfType() + .ToArray(); + + endpoints.ShouldNotBeEmpty(); + endpoints.All(endpoint => endpoint.Metadata.GetMetadata()?.Policy == ManagementAuthorizationPolicies.ManagementApi) + .ShouldBeTrue(); + } +} diff --git a/SecurityService.UnitTests/HostedServices/ManagementBootstrapperTests.cs b/SecurityService.UnitTests/HostedServices/ManagementBootstrapperTests.cs new file mode 100644 index 00000000..4cfda635 --- /dev/null +++ b/SecurityService.UnitTests/HostedServices/ManagementBootstrapperTests.cs @@ -0,0 +1,116 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Microsoft.EntityFrameworkCore; +using OpenIddict.Abstractions; +using SecurityService.BusinessLogic; +using SecurityService.Database.DbContexts; +using SecurityService.HostedServices; +using SecurityService.UnitTests.Infrastructure; +using Shouldly; + +namespace SecurityService.UnitTests.HostedServices; + +public sealed class ManagementBootstrapperTests +{ + [Fact] + public async Task InitializeAsync_WhenEnabled_CreatesBootstrapClientAndIsIdempotent() + { + using var provider = TestServiceProviderFactory.Create( + Guid.NewGuid().ToString(), + configureOptions: options => + { + options.ManagementBootstrap.Enabled = true; + options.ManagementBootstrap.ClientId = "bootstrap-client"; + options.ManagementBootstrap.ClientSecret = "bootstrap-secret"; + }); + + using var scope = provider.CreateScope(); + var bootstrapper = new ManagementBootstrapper( + scope.ServiceProvider.GetRequiredService(), + scope.ServiceProvider.GetRequiredService(), + scope.ServiceProvider.GetRequiredService>(), + scope.ServiceProvider.GetRequiredService>(), + scope.ServiceProvider.GetRequiredService>()); + + await bootstrapper.InitializeAsync(CancellationToken.None); + await bootstrapper.InitializeAsync(CancellationToken.None); + + var dbContext = scope.ServiceProvider.GetRequiredService(); + var applicationManager = scope.ServiceProvider.GetRequiredService(); + + (await applicationManager.FindByClientIdAsync("bootstrap-client")).ShouldNotBeNull(); + (await dbContext.ClientDefinitions.CountAsync(client => client.ClientId == "bootstrap-client")).ShouldBe(1); + } + + [Fact] + public async Task InitializeAsync_WhenBootstrapApplicationSecretConflicts_FailsClearly() + { + using var provider = TestServiceProviderFactory.Create( + Guid.NewGuid().ToString(), + configureOptions: options => + { + options.ManagementBootstrap.Enabled = true; + options.ManagementBootstrap.ClientId = "bootstrap-client"; + options.ManagementBootstrap.ClientSecret = "configured-secret"; + }); + + using var scope = provider.CreateScope(); + var applicationManager = scope.ServiceProvider.GetRequiredService(); + await applicationManager.CreateAsync(new OpenIddictApplicationDescriptor + { + ClientId = "bootstrap-client", + ClientSecret = "existing-secret", + ClientType = OpenIddictConstants.ClientTypes.Confidential, + ConsentType = OpenIddictConstants.ConsentTypes.Implicit + }); + + var bootstrapper = CreateBootstrapper(scope); + + var exception = await Should.ThrowAsync(() => bootstrapper.InitializeAsync(CancellationToken.None)); + + exception.Message.ShouldContain("bootstrap application"); + exception.Message.ShouldContain("secret"); + } + + [Fact] + public async Task InitializeAsync_WhenBootstrapAdministratorEmailConflicts_FailsClearly() + { + using var provider = TestServiceProviderFactory.Create( + Guid.NewGuid().ToString(), + configureOptions: options => + { + options.ManagementBootstrap.Enabled = true; + options.ManagementBootstrap.ClientId = "bootstrap-client"; + options.ManagementBootstrap.ClientSecret = "bootstrap-secret"; + options.ManagementBootstrap.AdminUserName = "administrator"; + options.ManagementBootstrap.AdminEmail = "configured@example.com"; + options.ManagementBootstrap.AdminPassword = "Password1!"; + }); + + using var scope = provider.CreateScope(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + (await userManager.CreateAsync(new SecurityService.Database.Entities.ApplicationUser + { + UserName = "administrator", + Email = "existing@example.com" + }, "Password1!")) + .Succeeded.ShouldBeTrue(); + + var bootstrapper = CreateBootstrapper(scope); + + var exception = await Should.ThrowAsync(() => bootstrapper.InitializeAsync(CancellationToken.None)); + + exception.Message.ShouldContain("bootstrap administrator"); + exception.Message.ShouldContain("email"); + } + + private static ManagementBootstrapper CreateBootstrapper(IServiceScope scope) + { + return new ManagementBootstrapper( + scope.ServiceProvider.GetRequiredService(), + scope.ServiceProvider.GetRequiredService(), + scope.ServiceProvider.GetRequiredService>(), + scope.ServiceProvider.GetRequiredService>(), + scope.ServiceProvider.GetRequiredService>()); + } +} diff --git a/SecurityService.UnitTests/Infrastructure/TestServiceProviderFactory.cs b/SecurityService.UnitTests/Infrastructure/TestServiceProviderFactory.cs index 4403df27..2f78b176 100644 --- a/SecurityService.UnitTests/Infrastructure/TestServiceProviderFactory.cs +++ b/SecurityService.UnitTests/Infrastructure/TestServiceProviderFactory.cs @@ -12,7 +12,7 @@ namespace SecurityService.UnitTests.Infrastructure; public static class TestServiceProviderFactory { - public static ServiceProvider Create(string databaseName, UserManager? userManager = null, SignInManager? signInManager = null) + public static ServiceProvider Create(string databaseName, UserManager? userManager = null, SignInManager? signInManager = null, Action? configureOptions = null) { var rsa = RSA.Create(2048); var key = new RsaSecurityKey(rsa); @@ -26,6 +26,7 @@ public static ServiceProvider Create(string databaseName, UserManager(); services.AddLogging(); diff --git a/SecurityService/Authorization/ManagementAuthorizationPolicies.cs b/SecurityService/Authorization/ManagementAuthorizationPolicies.cs new file mode 100644 index 00000000..ad6477e6 --- /dev/null +++ b/SecurityService/Authorization/ManagementAuthorizationPolicies.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Authorization; +using OpenIddict.Validation.AspNetCore; + +namespace SecurityService.Authorization; + +public static class ManagementAuthorizationPolicies +{ + public static string ManagementApi { get; } = "ManagementApi"; + + public static void AddManagementApiPolicy(AuthorizationOptions options) + { + options.AddPolicy(ManagementApi, policy => + policy.AddAuthenticationSchemes(OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme) + .RequireAuthenticatedUser()); + } +} diff --git a/SecurityService/Endpoints/ManagementEndpoints.cs b/SecurityService/Endpoints/ManagementEndpoints.cs index 70797322..3a011538 100644 --- a/SecurityService/Endpoints/ManagementEndpoints.cs +++ b/SecurityService/Endpoints/ManagementEndpoints.cs @@ -1,31 +1,36 @@ namespace SecurityService.Endpoints; +using SecurityService.Authorization; + public static class ManagementEndpoints { public static void MapManagementEndpoints(this IEndpointRouteBuilder endpoints) { - endpoints.MapPost("/api/clients", Handlers.ClientHandler.CreateClient).WithName("CreateClient"); - endpoints.MapGet("/api/clients/{clientId}", Handlers.ClientHandler.GetClient).WithName("GetClient"); - endpoints.MapGet("/api/clients", Handlers.ClientHandler.GetClients).WithName("GetClients"); + RouteGroupBuilder management = endpoints.MapGroup("/api") + .RequireAuthorization(ManagementAuthorizationPolicies.ManagementApi); + + management.MapPost("/clients", Handlers.ClientHandler.CreateClient).WithName("CreateClient"); + management.MapGet("/clients/{clientId}", Handlers.ClientHandler.GetClient).WithName("GetClient"); + management.MapGet("/clients", Handlers.ClientHandler.GetClients).WithName("GetClients"); - endpoints.MapPost("/api/apiscopes", Handlers.ApiScopeHandler.CreateApiScope).WithName("CreateApiScope"); - endpoints.MapGet("/api/apiscopes/{name}", Handlers.ApiScopeHandler.GetApiScope).WithName("GetApiScope"); - endpoints.MapGet("/api/apiscopes", Handlers.ApiScopeHandler.GetApiScopes).WithName("GetApiScopes"); + management.MapPost("/apiscopes", Handlers.ApiScopeHandler.CreateApiScope).WithName("CreateApiScope"); + management.MapGet("/apiscopes/{name}", Handlers.ApiScopeHandler.GetApiScope).WithName("GetApiScope"); + management.MapGet("/apiscopes", Handlers.ApiScopeHandler.GetApiScopes).WithName("GetApiScopes"); - endpoints.MapPost("/api/apiresources", Handlers.ApiResourceHandler.CreateApiResource).WithName("CreateApiResource"); - endpoints.MapGet("/api/apiresources/{name}", Handlers.ApiResourceHandler.GetApiResource).WithName("GetApiResource"); - endpoints.MapGet("/api/apiresources", Handlers.ApiResourceHandler.GetApiResources).WithName("GetApiResources"); + management.MapPost("/apiresources", Handlers.ApiResourceHandler.CreateApiResource).WithName("CreateApiResource"); + management.MapGet("/apiresources/{name}", Handlers.ApiResourceHandler.GetApiResource).WithName("GetApiResource"); + management.MapGet("/apiresources", Handlers.ApiResourceHandler.GetApiResources).WithName("GetApiResources"); - endpoints.MapPost("/api/identityresources", Handlers.IdentityResourceHandler.CreateIdentityResource).WithName("CreateIdentityResource"); - endpoints.MapGet("/api/identityresources/{name}", Handlers.IdentityResourceHandler.GetIdentityResource).WithName("GetIdentityResource"); - endpoints.MapGet("/api/identityresources", Handlers.IdentityResourceHandler.GetIdentityResources).WithName("GetIdentityResources"); + management.MapPost("/identityresources", Handlers.IdentityResourceHandler.CreateIdentityResource).WithName("CreateIdentityResource"); + management.MapGet("/identityresources/{name}", Handlers.IdentityResourceHandler.GetIdentityResource).WithName("GetIdentityResource"); + management.MapGet("/identityresources", Handlers.IdentityResourceHandler.GetIdentityResources).WithName("GetIdentityResources"); - endpoints.MapPost("/api/roles", Handlers.RoleHandler.CreateRole).WithName("CreateRole"); - endpoints.MapGet("/api/roles/{roleId}", Handlers.RoleHandler.GetRole).WithName("GetRole"); - endpoints.MapGet("/api/roles", Handlers.RoleHandler.GetRoles).WithName("GetRoles"); + management.MapPost("/roles", Handlers.RoleHandler.CreateRole).WithName("CreateRole"); + management.MapGet("/roles/{roleId}", Handlers.RoleHandler.GetRole).WithName("GetRole"); + management.MapGet("/roles", Handlers.RoleHandler.GetRoles).WithName("GetRoles"); - endpoints.MapPost("/api/users", Handlers.UserHandler.CreateUser).WithName("CreateUser"); - endpoints.MapGet("/api/users/{userId}", Handlers.UserHandler.GetUser).WithName("GetUser"); - endpoints.MapGet("/api/users", Handlers.UserHandler.GetUsers).WithName("GetUsers"); + management.MapPost("/users", Handlers.UserHandler.CreateUser).WithName("CreateUser"); + management.MapGet("/users/{userId}", Handlers.UserHandler.GetUser).WithName("GetUser"); + management.MapGet("/users", Handlers.UserHandler.GetUsers).WithName("GetUsers"); } } diff --git a/SecurityService/HostedServices/DatabaseInitializer.cs b/SecurityService/HostedServices/DatabaseInitializer.cs index b7e3ac36..606189bf 100644 --- a/SecurityService/HostedServices/DatabaseInitializer.cs +++ b/SecurityService/HostedServices/DatabaseInitializer.cs @@ -50,6 +50,9 @@ public async Task StartAsync(CancellationToken cancellationToken) } } + ManagementBootstrapper bootstrapper = scope.ServiceProvider.GetRequiredService(); + await bootstrapper.InitializeAsync(cancellationToken); + Logger.LogWarning("Database initialization complete."); } catch (Exception ex) { diff --git a/SecurityService/HostedServices/ManagementBootstrapper.cs b/SecurityService/HostedServices/ManagementBootstrapper.cs new file mode 100644 index 00000000..c14ac90f --- /dev/null +++ b/SecurityService/HostedServices/ManagementBootstrapper.cs @@ -0,0 +1,163 @@ +using System.Security.Cryptography; +using System.Text; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using OpenIddict.Abstractions; +using SecurityService.BusinessLogic; +using SecurityService.Database; +using SecurityService.Database.DbContexts; +using SecurityService.Database.Entities; +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace SecurityService.HostedServices; + +public sealed class ManagementBootstrapper +{ + private const string AdministratorRole = "Administrator"; + + private readonly SecurityServiceDbContext _dbContext; + private readonly IOpenIddictApplicationManager _applicationManager; + private readonly UserManager _userManager; + private readonly RoleManager _roleManager; + private readonly ManagementBootstrapOptions _options; + + public ManagementBootstrapper( + SecurityServiceDbContext dbContext, + IOpenIddictApplicationManager applicationManager, + UserManager userManager, + RoleManager roleManager, + IOptions options) + { + _dbContext = dbContext; + _applicationManager = applicationManager; + _userManager = userManager; + _roleManager = roleManager; + _options = options.Value.ManagementBootstrap; + } + + public async Task InitializeAsync(CancellationToken cancellationToken) + { + if (_options.Enabled == false) + { + return; + } + + ValidateOptions(); + await EnsureBootstrapClientAsync(cancellationToken); + await EnsureAdministratorAsync(); + } + + private void ValidateOptions() + { + if (string.IsNullOrWhiteSpace(_options.ClientId)) + { + throw new InvalidOperationException("Management bootstrap is enabled but ServiceOptions:ManagementBootstrap:ClientId is empty."); + } + + if (string.IsNullOrWhiteSpace(_options.ClientSecret)) + { + throw new InvalidOperationException("Management bootstrap is enabled but ServiceOptions:ManagementBootstrap:ClientSecret is empty."); + } + + bool anyAdminValue = string.IsNullOrWhiteSpace(_options.AdminUserName) == false || + string.IsNullOrWhiteSpace(_options.AdminEmail) == false || + string.IsNullOrWhiteSpace(_options.AdminPassword) == false; + bool completeAdminValue = string.IsNullOrWhiteSpace(_options.AdminUserName) == false && + string.IsNullOrWhiteSpace(_options.AdminEmail) == false && + string.IsNullOrWhiteSpace(_options.AdminPassword) == false; + if (anyAdminValue && completeAdminValue == false) + { + throw new InvalidOperationException("Management bootstrap administrator configuration requires AdminUserName, AdminEmail, and AdminPassword."); + } + } + + private async Task EnsureBootstrapClientAsync(CancellationToken cancellationToken) + { + object? application = await _applicationManager.FindByClientIdAsync(_options.ClientId, cancellationToken); + if (application is null) + { + var descriptor = new OpenIddictApplicationDescriptor + { + ClientId = _options.ClientId, + ClientSecret = _options.ClientSecret, + ClientType = ClientTypes.Confidential, + ConsentType = ConsentTypes.Implicit, + DisplayName = _options.ClientName + }; + + await _applicationManager.CreateAsync(descriptor, cancellationToken); + } + else if (await _applicationManager.ValidateClientSecretAsync(application, _options.ClientSecret, cancellationToken) == false) + { + throw new InvalidOperationException("The existing management bootstrap application has a different client secret."); + } + + ClientDefinition? definition = await _dbContext.ClientDefinitions + .SingleOrDefaultAsync(client => client.ClientId == _options.ClientId, cancellationToken); + if (definition is null) + { + await _dbContext.ClientDefinitions.AddAsync(new ClientDefinition + { + Id = Guid.NewGuid(), + ClientId = _options.ClientId, + ClientName = _options.ClientName, + SecretHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(_options.ClientSecret))), + AllowedGrantTypesJson = JsonListSerializer.Serialize([GrantTypes.ClientCredentials]), + AllowedScopesJson = "[]", + RedirectUrisJson = "[]", + PostLogoutRedirectUrisJson = "[]", + ClientType = ClientTypes.Confidential + }, cancellationToken); + await _dbContext.SaveChangesAsync(cancellationToken); + } + } + + private async Task EnsureAdministratorAsync() + { + if (string.IsNullOrWhiteSpace(_options.AdminUserName)) + { + return; + } + + if (await _roleManager.RoleExistsAsync(AdministratorRole) == false) + { + IdentityResult roleResult = await _roleManager.CreateAsync(new IdentityRole(AdministratorRole)); + if (roleResult.Succeeded == false) + { + throw new InvalidOperationException($"Unable to create bootstrap administrator role: {roleResult}"); + } + } + + ApplicationUser? user = await _userManager.FindByNameAsync(_options.AdminUserName); + if (user is null) + { + user = new ApplicationUser + { + UserName = _options.AdminUserName, + Email = _options.AdminEmail, + EmailConfirmed = true, + RegistrationDateTime = DateTime.UtcNow + }; + + IdentityResult userResult = await _userManager.CreateAsync(user, _options.AdminPassword!); + if (userResult.Succeeded == false) + { + throw new InvalidOperationException($"Unable to create bootstrap administrator user: {userResult}"); + } + } + else if (string.Equals(user.Email, _options.AdminEmail, StringComparison.OrdinalIgnoreCase) == false) + { + throw new InvalidOperationException("The existing bootstrap administrator has a different email address."); + } + + if (await _userManager.IsInRoleAsync(user, AdministratorRole) == false) + { + IdentityResult roleAssignmentResult = await _userManager.AddToRoleAsync(user, AdministratorRole); + if (roleAssignmentResult.Succeeded == false) + { + throw new InvalidOperationException($"Unable to assign bootstrap administrator role: {roleAssignmentResult}"); + } + } + } +} diff --git a/SecurityService/Program.cs b/SecurityService/Program.cs index 843d1725..00a38593 100644 --- a/SecurityService/Program.cs +++ b/SecurityService/Program.cs @@ -21,6 +21,7 @@ using SecurityService.Endpoints; using SecurityService.HealthChecks; using SecurityService.HostedServices; +using SecurityService.Authorization; using Sentry.Extensibility; using Shared.EntityFramework; using Shared.Extensions; @@ -180,6 +181,28 @@ { cookieOptions.LoginPath = "/Account/Login"; cookieOptions.LogoutPath = "/Account/Logout"; + cookieOptions.Events.OnRedirectToLogin = context => + { + if (context.Request.Path.StartsWithSegments("/api")) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + return Task.CompletedTask; + } + + context.Response.Redirect(context.RedirectUri); + return Task.CompletedTask; + }; + cookieOptions.Events.OnRedirectToAccessDenied = context => + { + if (context.Request.Path.StartsWithSegments("/api")) + { + context.Response.StatusCode = StatusCodes.Status403Forbidden; + return Task.CompletedTask; + } + + context.Response.Redirect(context.RedirectUri); + return Task.CompletedTask; + }; }); builder.Services.AddHttpContextAccessor(); @@ -213,7 +236,7 @@ builder.Services.AddSingleton(config); -builder.Services.AddAuthorization(); +builder.Services.AddAuthorization(ManagementAuthorizationPolicies.AddManagementApiPolicy); builder.Services.AddMediatR(configuration => { configuration.RegisterServicesFromAssembly(typeof(SecurityServiceCommands).Assembly); @@ -222,6 +245,7 @@ builder.Services.AddRazorPages(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); +builder.Services.AddScoped(); builder.Services.AddHostedService(); if (builder.Environment.IsEnvironment("IntegrationTest")) { diff --git a/docs/management-authorization.md b/docs/management-authorization.md new file mode 100644 index 00000000..83e48cee --- /dev/null +++ b/docs/management-authorization.md @@ -0,0 +1,36 @@ +# Management API authentication + +All management endpoints under `/api` now require an authenticated access token. This iteration intentionally does not distinguish permissions between users, clients, resources, or roles. Fine-grained authorization can be added later without changing the bootstrap mechanism. + +Unauthenticated API requests return `401`. Browser requests to Razor Pages continue to use the normal login redirect flow. + +## First installation + +The service can create a client-credentials client during database initialization. Enable it only through deployment configuration or a secret store: + +```text +ServiceOptions:ManagementBootstrap:Enabled=true +ServiceOptions:ManagementBootstrap:ClientId=management-bootstrap +ServiceOptions:ManagementBootstrap:ClientSecret= +``` + +On startup, the initializer: + +1. Applies database migrations. +2. Creates the configured OpenIddict application if it does not exist. +3. Creates the matching `ClientDefinition` if it does not exist. +4. Leaves existing records unchanged on later startups. + +Use the bootstrap client with `client_credentials` at `/connect/token`, then send the returned access token as: + +```http +Authorization: Bearer +``` + +After normal clients/users have been provisioned, disable or rotate the bootstrap credential. Do not place the secret in source-controlled `appsettings.json` or logs. + +An optional initial Identity administrator can also be configured with the complete `AdminUserName`, `AdminEmail`, and `AdminPassword` option set. The password must be supplied through secret storage. + +## Existing installations + +Existing deployments must provision a valid management client before enabling the protected endpoints. The bootstrap settings can be enabled temporarily for the upgrade, or the client can be created through an out-of-band provisioning process. diff --git a/docs/superpowers/plans/2026-10-03-management-endpoint-authorization.md b/docs/superpowers/plans/2026-10-03-management-endpoint-authorization.md new file mode 100644 index 00000000..70a1679d --- /dev/null +++ b/docs/superpowers/plans/2026-10-03-management-endpoint-authorization.md @@ -0,0 +1,147 @@ +# Management Endpoint Authorization Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Require authentication for management endpoints while preserving a safe, repeatable first-install bootstrap path when the database contains no clients or users. + +**Architecture:** Use one `ManagementApi` policy requiring an authenticated caller, and attach it to the management endpoint group in `ManagementEndpoints`. Defer fine-grained permission scopes/claims until a later change. Add an idempotent bootstrap service invoked by `DatabaseInitializer` that creates a configured management client and optional initial administrator user/role from deployment secrets; normal management requests remain protected from the first request onward. + +**Tech Stack:** .NET 10, ASP.NET Core Minimal APIs, OpenIddict 7.7, ASP.NET Core Identity, Entity Framework Core, xUnit/NUnit/Reqnroll integration-test conventions already present in the repository. + +**Spec:** GitHub issue [TransactionProcessing/SecurityService#1713](https://github.com/TransactionProcessing/SecurityService/issues/1713) + +## Global Constraints + +- Unauthenticated management requests must return `401`. +- Fine-grained permission separation is explicitly deferred; any valid authenticated caller may use the management endpoints in this iteration. +- First installation must not require an already-existing client or user to create the first administrative credential. +- Bootstrap must be idempotent and must not grant administrative access to an arbitrary unauthenticated request. +- Bootstrap secrets must be supplied through deployment configuration/secret storage, not committed to source-controlled `appsettings.json`. +- Existing OIDC endpoints, Razor Pages, health endpoints, and developer endpoints must not accidentally inherit management policies. + +## Review Focus + +- Empty database with bootstrap disabled or incompletely configured: startup must fail clearly or remain operable without exposing an anonymous admin path; cover in bootstrap tests. +- Existing database with the bootstrap client already present: startup must not duplicate clients, users, roles, or OpenIddict records; cover in idempotency tests. +- Cookie-authenticated browser requests to API endpoints: API authorization must challenge with `401`, not silently redirect to `/Account/Login`; cover in endpoint integration tests. +- Valid tokens from different authenticated client types: all management endpoints must be available in this iteration; cover with representative requests. +- Test and deployment clients that currently create management data before obtaining a token: update fixtures to provision/use the bootstrap client first; cover in integration setup tests. + +--- + +### Task 1: Define the authenticated management policy + +**Files:** +- Create: `SecurityService/Authorization/ManagementAuthorizationPolicies.cs` +- Modify: `SecurityService/Program.cs:216` +- Test: `SecurityService.UnitTests/Authorization/ManagementAuthorizationPolicyTests.cs` + +**Interfaces:** +- Produces one policy named `ManagementApi`. + +- [ ] **Step 1: Write policy tests** asserting `ManagementApi` allows an authenticated principal and rejects an anonymous principal. +- [ ] **Step 2: Run the focused unit tests** and verify they fail because the policy definitions do not exist. +- [ ] **Step 3: Register `ManagementApi` through `AddAuthorization` using only `RequireAuthenticatedUser()`; do not add permission claims, permission scopes, or role requirements. +- [ ] **Step 4: Configure API challenge behavior** so an unauthenticated request to a management endpoint produces `401` rather than a cookie login redirect. Preserve normal browser login behavior for Razor Pages. +- [ ] **Step 5: Run the focused unit tests** and verify they pass. +- [ ] **Step 6: Commit** with message `feat: require authentication for management API`. + +### Task 2: Apply policies to management endpoint groups + +**Files:** +- Modify: `SecurityService/Endpoints/ManagementEndpoints.cs:5` +- Test: `SecurityService.UnitTests/Endpoints/ManagementEndpointsTests.cs` or the repository’s existing endpoint-test location + +**Interfaces:** +- `MapManagementEndpoints` must expose one policy-bearing management route group covering all existing `/api/clients`, `/api/apiscopes`, `/api/apiresources`, `/api/identityresources`, `/api/roles`, and `/api/users` routes. + +- [ ] **Step 1: Add endpoint metadata tests** verifying every management route has the `ManagementApi` policy and that no management route remains unprotected. +- [ ] **Step 2: Run the focused endpoint tests** and verify they fail for the current unannotated routes. +- [ ] **Step 3: Refactor endpoint mapping into one policy-bearing `MapGroup`** while preserving route templates, handler methods, names, and response behavior. +- [ ] **Step 4: Run the focused endpoint tests** and verify they pass. +- [ ] **Step 5: Commit** with message `feat: protect management endpoints with policies`. + +### Task 3: Add bootstrap-client configuration and first-install provisioning + +**Files:** +- Modify: `SecurityService.BusinessLogic/SecurityServiceOptions.cs` +- Modify: `SecurityService/HostedServices/DatabaseInitializer.cs:11` +- Modify: `SecurityService/appsettings.json` only for non-secret defaults/documentation-safe structure; do not add a usable secret +- Modify: `SecurityService/appsettings.development.json` only if local development needs an explicitly opt-in bootstrap configuration +- Create: `SecurityService/HostedServices/ManagementBootstrapper.cs` +- Create: `SecurityService.UnitTests/HostedServices/ManagementBootstrapperTests.cs` + +**Interfaces:** +- Add an options section such as `ServiceOptions:ManagementBootstrap` with: + - `Enabled` (`bool`, default `false`) + - `ClientId` (`string`) + - `ClientSecret` (`string`) + - `ClientName` (`string`) + - `AdminUserName` (`string?`) + - `AdminEmail` (`string?`) + - `AdminPassword` (`string?`) +- Add `ManagementBootstrapper.InitializeAsync(CancellationToken)` returning a result/diagnostic object or throwing a descriptive startup exception on invalid enabled configuration. +- Do not add management permission scopes in this iteration; the bootstrap client only needs to be a valid authenticated client. + +- [ ] **Step 1: Write bootstrap tests** for disabled bootstrap, missing required configuration, first-run client creation, rerun idempotency, and existing partial records. +- [ ] **Step 2: Run the focused bootstrap tests** and verify they fail because the bootstrap service/options do not exist. +- [ ] **Step 3: Add configuration binding and validation** with bootstrap disabled by default; when enabled, require client ID and secret, and require a complete admin-user tuple if admin-user creation is requested. +- [ ] **Step 4: Implement idempotent bootstrap-client creation** as an OpenIddict application with `client_credentials` support. Keep the secret out of logs and do not overwrite an existing client secret silently. +- [ ] **Step 5: Implement optional initial-admin creation** only if the existing application requires a seeded human user; do not attach management permission claims in this iteration. Fail safely if a conflicting record exists with incompatible configuration. +- [ ] **Step 6: Invoke the bootstrapper from `DatabaseInitializer` after migrations**, ensuring startup ordering is migrations → bootstrap records. +- [ ] **Step 7: Run the focused bootstrap tests** and verify they pass, including a second initialization against the same database. +- [ ] **Step 8: Commit** with message `feat: add first-install management bootstrap`. + +### Task 4: Verify authenticated token flow + +**Files:** +- Modify: the OpenIddict server/token configuration in `SecurityService/Program.cs` +- Modify: the relevant token/request handler or claims configuration discovered during implementation +- Test: `SecurityService.UnitTests/Oidc/OidcEndpointTests.cs` or a new focused token authorization test + +**Interfaces:** +- A token issued to the configured bootstrap client authenticates successfully at every management endpoint. +- Any valid access token accepted by the existing OpenIddict validation configuration satisfies `ManagementApi`. + +- [ ] **Step 1: Write token tests** for an allowed bootstrap client and an invalid/absent token. +- [ ] **Step 2: Run the focused token tests** and verify they fail until the policy and bootstrap client are wired. +- [ ] **Step 3: Verify the existing OpenIddict client-credentials flow** issues a token for the bootstrapped client and that the validation principal is authenticated. +- [ ] **Step 4: Run the focused token tests** and verify they pass. +- [ ] **Step 5: Commit** with message `test: verify authenticated management token flow`. + +### Task 5: Add end-to-end authorization coverage + +**Files:** +- Create or modify: the existing `SecurityService.IntegrationTests` authorization-test feature/steps +- Modify: `SecurityService.IntegrationTesting.Helpers/SecurityServiceSteps.cs` and test HTTP-client setup as needed +- Create or modify: integration-test application factory/bootstrap configuration + +**Interfaces:** +- Tests must be able to obtain a valid access token and send it as `Authorization: Bearer `. +- Tests must be able to issue requests without credentials. + +- [ ] **Step 1: Add scenarios** covering `401` for unauthenticated requests and `2xx` for allowed access on representative management endpoints. +- [ ] **Step 2: Add a clean-database scenario** that starts with no clients/users, runs bootstrap, obtains a client-credentials token, and successfully performs an initial protected management operation. +- [ ] **Step 3: Update existing integration setup** so it no longer assumes management creation endpoints are anonymous; provision the test management client through the bootstrap configuration before creating fixtures. +- [ ] **Step 4: Run the relevant integration-test projects** and verify all authorization and existing management scenarios pass. +- [ ] **Step 5: Commit** with message `test: cover authenticated management access and bootstrap`. + +### Task 6: Document deployment and operational rollout + +**Files:** +- Create or modify: `docs/management-authorization.md` +- Modify: deployment/sample configuration documentation used by the repository + +- [ ] **Step 1: Document first installation**: enable bootstrap, supply the client secret through secret storage, start the service, obtain an access token, create normal clients/users, then disable or rotate bootstrap credentials. +- [ ] **Step 2: Document upgrade behavior** for existing installations: deploy the policy changes, provision a management client out-of-band or enable bootstrap temporarily, then verify scoped access before disabling bootstrap. +- [ ] **Step 3: Document that this iteration provides authentication-only protection, with fine-grained permission authorization deferred; document expected `401` responses and successful authenticated access. +- [ ] **Step 4: Run the complete solution test suite** and record the exact command/result in the implementation PR. +- [ ] **Step 5: Commit** with message `docs: document management authorization bootstrap`. + +## Final Verification + +- [ ] Run focused unit tests for authorization policies, endpoint metadata, bootstrap, and tokens. +- [ ] Run management integration tests, including the clean-database bootstrap scenario. +- [ ] Run the complete solution build and test commands used by CI. +- [ ] Inspect the final diff for secrets, unintended policy inheritance, changed OIDC/Razor behavior, and duplicate bootstrap records. +- [ ] Record that the issue’s fine-grained permission-separation criterion is intentionally deferred to a follow-up issue.