From 0cda4a03680d9702a21fe75227ffe0c2c5770688 Mon Sep 17 00:00:00 2001 From: MilePrivate Date: Mon, 24 Aug 2026 11:55:17 +0200 Subject: [PATCH] api security with Entra --- .../CustomWebApplicationFactory.cs | 83 ++- .../Infrastructure/IntegrationTestBase.cs | 2 +- .../TestAuthenticationHandler.cs | 88 +++ .../TestOrderReadModelReader.cs.orig | 201 +++++ .../Integration/ApiIntegrationTests.cs | 13 +- .../Integration/ApiIntegrationTests.cs.orig | 687 ++++++++++++++++++ .../Integration/AuthorizationTests.cs | 37 + .../BackgroundJobs/OutboxBackgroundService.cs | 5 +- .../Controllers/CustomersController.cs | 7 +- .../Controllers/HealthController.cs | 4 +- .../Controllers/OrdersController.cs | 7 + .../Controllers/ProductsController.cs | 8 +- .../SecurityServiceCollectionExtensions.cs | 63 ++ .../AuthorizationOperationTransformer.cs | 34 + .../OpenApi/EntraOAuthDocumentTransformer.cs | 56 ++ .../OrderProcessing.Api.csproj | 4 +- OrderProcessing.Api/Program.cs | 30 +- OrderProcessing.Api/Security/ApiRoles.cs | 6 + OrderProcessing.Api/Security/ApiScopes.cs | 8 + .../Security/AuthorizationPolicies.cs | 8 + .../ScopeOrRoleAuthorizationHandler.cs | 48 ++ .../Security/ScopeOrRoleRequirement.cs | 17 + .../appsettings.Development.json | 3 + OrderProcessing.Api/appsettings.json | 3 + 24 files changed, 1399 insertions(+), 23 deletions(-) create mode 100644 OrderProcessing.Api.Tests/Infrastructure/TestAuthenticationHandler.cs create mode 100644 OrderProcessing.Api.Tests/Infrastructure/TestOrderReadModelReader.cs.orig create mode 100644 OrderProcessing.Api.Tests/Integration/ApiIntegrationTests.cs.orig create mode 100644 OrderProcessing.Api.Tests/Integration/AuthorizationTests.cs create mode 100644 OrderProcessing.Api/Extensions/SecurityServiceCollectionExtensions.cs create mode 100644 OrderProcessing.Api/OpenApi/AuthorizationOperationTransformer.cs create mode 100644 OrderProcessing.Api/OpenApi/EntraOAuthDocumentTransformer.cs create mode 100644 OrderProcessing.Api/Security/ApiRoles.cs create mode 100644 OrderProcessing.Api/Security/ApiScopes.cs create mode 100644 OrderProcessing.Api/Security/AuthorizationPolicies.cs create mode 100644 OrderProcessing.Api/Security/ScopeOrRoleAuthorizationHandler.cs create mode 100644 OrderProcessing.Api/Security/ScopeOrRoleRequirement.cs diff --git a/OrderProcessing.Api.Tests/Infrastructure/CustomWebApplicationFactory.cs b/OrderProcessing.Api.Tests/Infrastructure/CustomWebApplicationFactory.cs index a2a8d4e..eb6fd87 100644 --- a/OrderProcessing.Api.Tests/Infrastructure/CustomWebApplicationFactory.cs +++ b/OrderProcessing.Api.Tests/Infrastructure/CustomWebApplicationFactory.cs @@ -1,14 +1,17 @@ -using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; using Microsoft.VisualStudio.TestPlatform.TestHost; using OrderProcessing.Api.Data; using OrderProcessing.Api.Features.Orders.Queries.ReadModel; +using OrderProcessing.Api.Security; using System.Data.Common; namespace OrderProcessing.Api.Tests.Infrastructure; @@ -27,6 +30,23 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.UseEnvironment("Testing"); + builder.ConfigureAppConfiguration( + (_, configuration) => + { + configuration.AddInMemoryCollection( + new Dictionary + { + ["AzureAd:Instance"] = + "https://login.microsoftonline.com/", + + ["AzureAd:TenantId"] = + "00000000-0000-0000-0000-000000000001", + + ["AzureAd:ClientId"] = + "00000000-0000-0000-0000-000000000002" + }); + }); + builder.ConfigureServices(services => { // Remove the SQL Server DbContext registration @@ -53,7 +73,18 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) services.RemoveAll(); services.AddSingleton(); - }); + + services + .AddAuthentication(options => + { + options.DefaultAuthenticateScheme = TestAuthenticationHandler.SchemeName; + + options.DefaultChallengeScheme = TestAuthenticationHandler.SchemeName; + }) + .AddScheme( + TestAuthenticationHandler.SchemeName, _ => { }); + }); + } protected override IHost CreateHost( @@ -82,4 +113,52 @@ protected override void Dispose(bool disposing) _connection.Dispose(); } } + + public HttpClient CreateAuthenticatedClient() + { + return CreateClientWithScopes(ApiScopes.Read, ApiScopes.Write); + } + + public HttpClient CreateClientWithScopes( + params string[] scopes) + { + var client = CreateClient(); + + AddTestUser(client); + + if (scopes.Length > 0) + { + client.DefaultRequestHeaders.Add(TestAuthenticationHandler.ScopesHeaderName, string.Join(' ', scopes)); + } + + return client; + } + + public HttpClient CreateClientWithRoles(params string[] roles) + { + var client = CreateClient(); + + AddTestUser(client); + + if (roles.Length > 0) + { + client.DefaultRequestHeaders.Add(TestAuthenticationHandler.RolesHeaderName, string.Join(' ', roles)); + } + + return client; + } + + public HttpClient CreateAuthenticatedClientWithoutPermissions() + { + var client = CreateClient(); + + AddTestUser(client); + + return client; + } + + private static void AddTestUser(HttpClient client) + { + client.DefaultRequestHeaders.Add(TestAuthenticationHandler.UserHeaderName, "integration-test-user"); + } } \ No newline at end of file diff --git a/OrderProcessing.Api.Tests/Infrastructure/IntegrationTestBase.cs b/OrderProcessing.Api.Tests/Infrastructure/IntegrationTestBase.cs index e192c70..a5086e1 100644 --- a/OrderProcessing.Api.Tests/Infrastructure/IntegrationTestBase.cs +++ b/OrderProcessing.Api.Tests/Infrastructure/IntegrationTestBase.cs @@ -12,7 +12,7 @@ public abstract class IntegrationTestBase : IDisposable protected IntegrationTestBase() { Factory = new CustomWebApplicationFactory(); - Client = Factory.CreateClient(); + Client = Factory.CreateAuthenticatedClient(); } public void Dispose() diff --git a/OrderProcessing.Api.Tests/Infrastructure/TestAuthenticationHandler.cs b/OrderProcessing.Api.Tests/Infrastructure/TestAuthenticationHandler.cs new file mode 100644 index 0000000..74e93cc --- /dev/null +++ b/OrderProcessing.Api.Tests/Infrastructure/TestAuthenticationHandler.cs @@ -0,0 +1,88 @@ +using System.Security.Claims; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace OrderProcessing.Api.Tests.Infrastructure; + +public sealed class TestAuthenticationHandler : AuthenticationHandler +{ + public const string SchemeName = "TestAuthentication"; + + public const string UserHeaderName = "X-Test-User"; + + public const string ScopesHeaderName = "X-Test-Scopes"; + + public const string RolesHeaderName = "X-Test-Roles"; + + public TestAuthenticationHandler( IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder) + : base(options, logger, encoder) + { + } + + protected override Task HandleAuthenticateAsync() + { + if (!Request.Headers.TryGetValue( + UserHeaderName, + out var userHeader) || + string.IsNullOrWhiteSpace(userHeader.ToString())) + { + return Task.FromResult( + AuthenticateResult.NoResult()); + } + + var claims = new List + { + new( + ClaimTypes.NameIdentifier, + userHeader.ToString()), + + new( + ClaimTypes.Name, + "Integration Test User") + }; + + if (Request.Headers.TryGetValue( + ScopesHeaderName, + out var scopesHeader) && + !string.IsNullOrWhiteSpace(scopesHeader.ToString())) + { + claims.Add( + new Claim( + "scp", + scopesHeader.ToString())); + } + + if (Request.Headers.TryGetValue( + RolesHeaderName, + out var rolesHeader)) + { + var roles = rolesHeader + .ToString() + .Split( + ' ', + StringSplitOptions.RemoveEmptyEntries | + StringSplitOptions.TrimEntries); + + claims.AddRange( + roles.Select(role => + new Claim("roles", role))); + } + + var identity = new ClaimsIdentity( + claims, + SchemeName, + ClaimTypes.Name, + "roles"); + + var principal = new ClaimsPrincipal(identity); + + var ticket = new AuthenticationTicket( + principal, + SchemeName); + + return Task.FromResult( + AuthenticateResult.Success(ticket)); + } +} \ No newline at end of file diff --git a/OrderProcessing.Api.Tests/Infrastructure/TestOrderReadModelReader.cs.orig b/OrderProcessing.Api.Tests/Infrastructure/TestOrderReadModelReader.cs.orig new file mode 100644 index 0000000..66c3a24 --- /dev/null +++ b/OrderProcessing.Api.Tests/Infrastructure/TestOrderReadModelReader.cs.orig @@ -0,0 +1,201 @@ +using OrderProcessing.Api.DTOs.Orders; +using OrderProcessing.Api.Features.Orders.Queries.ReadModel; +using OrderProcessing.ReadModels.Orders; + +namespace OrderProcessing.Api.Tests.Infrastructure; + +public sealed class TestOrderReadModelReader : IOrderReadModelReader +{ + public const int ExistingOrderId = 88001; + + public Task GetByIdAsync(int orderId, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var order = CreateOrders().SingleOrDefault(order => order.OrderId == orderId); +<<<<<<< Updated upstream +======= + +>>>>>>> Stashed changes + return Task.FromResult(order); + } + + public Task GetPageAsync(OrderQueryParameters parameters, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + IEnumerable query = CreateOrders(); + + query = ApplyFilters(query, parameters); + + var totalCount = query.Count(); + + query = ApplySorting(query, parameters); + + var orders = query + .Skip((parameters.Page - 1) * parameters.PageSize) + .Take(parameters.PageSize) + .ToList(); + + return Task.FromResult( + new OrderReadModelPage(orders, totalCount)); + } + + private static IEnumerable ApplyFilters(IEnumerable query, OrderQueryParameters parameters) + { + if (parameters.CustomerId.HasValue) + { + query = query.Where(order => order.CustomerId == parameters.CustomerId.Value); + } + + if (parameters.Status.HasValue) + { + var status = parameters.Status.Value.ToString(); + + query = query.Where(order => + string.Equals( + order.Status, + status, + StringComparison.OrdinalIgnoreCase)); + } + + if (parameters.CreatedFromUtc.HasValue) + { + query = query.Where(order => order.CreatedAtUtc >= parameters.CreatedFromUtc.Value); + } + + if (parameters.CreatedToUtc.HasValue) + { + query = query.Where(order => order.CreatedAtUtc <= parameters.CreatedToUtc.Value); + } + + return query; + } + + private static IEnumerable ApplySorting(IEnumerable query, OrderQueryParameters parameters) + { + return (parameters.SortBy, parameters.SortDirection) switch + { + (OrderSortBy.Id, SortDirection.Ascending) => + query.OrderBy(order => order.OrderId), + + (OrderSortBy.Id, SortDirection.Descending) => + query.OrderByDescending(order => order.OrderId), + + (OrderSortBy.TotalAmount, SortDirection.Ascending) => + query + .OrderBy(order => order.TotalAmount) + .ThenBy(order => order.OrderId), + + (OrderSortBy.TotalAmount, SortDirection.Descending) => + query + .OrderByDescending(order => order.TotalAmount) + .ThenByDescending(order => order.OrderId), + + (OrderSortBy.CreatedAtUtc, SortDirection.Ascending) => + query + .OrderBy(order => order.CreatedAtUtc) + .ThenBy(order => order.OrderId), + + _ => + query + .OrderByDescending(order => order.CreatedAtUtc) + .ThenByDescending(order => order.OrderId) + }; + } + + private static IReadOnlyList CreateOrders() + { + return + [ + CreateOrder( + orderId: ExistingOrderId, + customerId: TestDataSeeder.CustomerId, + customerName: "Integration Test Customer", + status: "Pending", + quantity: 1, + createdAtUtc: new DateTime( + 2026, + 8, + 1, + 10, + 0, + 0, + DateTimeKind.Utc)), + + CreateOrder( + orderId: 88002, + customerId: TestDataSeeder.CustomerId, + customerName: "Integration Test Customer", + status: "Completed", + quantity: 2, + createdAtUtc: new DateTime( + 2026, + 8, + 2, + 10, + 0, + 0, + DateTimeKind.Utc), + completedAtUtc: new DateTime( + 2026, + 8, + 2, + 11, + 0, + 0, + DateTimeKind.Utc)), + + CreateOrder( + orderId: 88003, + customerId: TestDataSeeder.SecondCustomerId, + customerName: "Second Integration Test Customer", + status: "Pending", + quantity: 3, + createdAtUtc: new DateTime( + 2026, + 8, + 3, + 10, + 0, + 0, + DateTimeKind.Utc)) + ]; + } + + private static OrderReadModel CreateOrder( + int orderId, + int customerId, + string customerName, + string status, + int quantity, + DateTime createdAtUtc, + DateTime? completedAtUtc = null) + { + const decimal unitPrice = 24.99m; + + return new OrderReadModel + { + OrderId = orderId, + CustomerId = customerId, + CustomerName = customerName, + Status = status, + TotalAmount = unitPrice * quantity, + CreatedAtUtc = createdAtUtc, + CompletedAtUtc = completedAtUtc, + LastUpdatedAtUtc = + completedAtUtc ?? createdAtUtc, + Items = + [ + new OrderItemReadModel + { + ProductId = TestDataSeeder.ProductId, + ProductName = "Integration Test Product", + Quantity = quantity, + UnitPrice = unitPrice, + LineTotal = unitPrice * quantity + } + ] + }; + } +} \ No newline at end of file diff --git a/OrderProcessing.Api.Tests/Integration/ApiIntegrationTests.cs b/OrderProcessing.Api.Tests/Integration/ApiIntegrationTests.cs index ece1c34..e4b3e7e 100644 --- a/OrderProcessing.Api.Tests/Integration/ApiIntegrationTests.cs +++ b/OrderProcessing.Api.Tests/Integration/ApiIntegrationTests.cs @@ -69,12 +69,9 @@ public async Task CancelOrder_RestoresProductStock() [Fact] public async Task GetOrders_ReturnsPagedResponse() { - // Act - var response = await Client.GetAsync( - "/api/orders?page=1&pageSize=2"); + var response = await Client.GetAsync("/api/orders?page=1&pageSize=2"); - var result = await response.Content - .ReadFromJsonAsync>(); + var result = await response.Content.ReadFromJsonAsync>(); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -93,11 +90,9 @@ public async Task GetOrders_ReturnsPagedResponse() public async Task GetOrders_WhenCustomerFilterProvided_ReturnsOnlyMatchingOrders() { // Act - var response = await Client.GetAsync( - $"/api/orders?customerId={TestDataSeeder.SecondCustomerId}"); + var response = await Client.GetAsync($"/api/orders?customerId={TestDataSeeder.SecondCustomerId}"); - var result = await response.Content - .ReadFromJsonAsync>(); + var result = await response.Content.ReadFromJsonAsync>(); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); diff --git a/OrderProcessing.Api.Tests/Integration/ApiIntegrationTests.cs.orig b/OrderProcessing.Api.Tests/Integration/ApiIntegrationTests.cs.orig new file mode 100644 index 0000000..437adf3 --- /dev/null +++ b/OrderProcessing.Api.Tests/Integration/ApiIntegrationTests.cs.orig @@ -0,0 +1,687 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using OrderProcessing.Api.Data; +using OrderProcessing.Api.DTOs.Common; +using OrderProcessing.Api.DTOs.Orders; +using OrderProcessing.Api.Entities; +using OrderProcessing.Api.Services.Auditing; +using OrderProcessing.Api.Tests.Infrastructure; +using OrderProcessing.Contracts.Orders; +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; + +namespace OrderProcessing.Api.Tests.Integration; + +public sealed class ApiIntegrationTests : IntegrationTestBase +{ + [Fact] + public async Task TestDatabase_StartsWithoutOrders() + { + using var scope = Factory.Services.CreateScope(); + + var dbContext = scope.ServiceProvider + .GetRequiredService(); + + var orderCount = await dbContext.Orders.CountAsync(); + var auditLogCount = await dbContext.AuditLogs.CountAsync(); + + Assert.Equal(0, orderCount); + Assert.Equal(0, auditLogCount); + } + + [Fact] + public async Task CancelOrder_RestoresProductStock() + { + // Arrange + var createdOrder = await CreateTestOrderAsync(quantity: 2); + + var stockAfterCreation = await GetProductStockAsync( + TestDataSeeder.ProductId); + + Assert.Equal( + TestDataSeeder.InitialProductStock - 2, + stockAfterCreation); + + // Act + var response = await Client.PatchAsync( + $"/api/orders/{createdOrder.Id}/cancel", + content: null); + + var cancelledOrder = await response.Content + .ReadFromJsonAsync(); + + var stockAfterCancellation = await GetProductStockAsync( + TestDataSeeder.ProductId); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + Assert.NotNull(cancelledOrder); + Assert.Equal(OrderStatus.Cancelled, cancelledOrder.Status); + + Assert.Equal( + TestDataSeeder.InitialProductStock, + stockAfterCancellation); + } + + + [Fact] + public async Task GetOrders_ReturnsPagedResponse() + { +<<<<<<< Updated upstream + // Act + var response = await Client.GetAsync( + "/api/orders?page=1&pageSize=2"); +======= + // Arrange + var response = await Client.GetAsync("/api/orders?page=1&pageSize=2"); +>>>>>>> Stashed changes + + var result = await response.Content + .ReadFromJsonAsync>(); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + Assert.NotNull(result); + Assert.Equal(2, result.Items.Count); + Assert.Equal(1, result.Page); + Assert.Equal(2, result.PageSize); + Assert.Equal(3, result.TotalCount); + Assert.Equal(2, result.TotalPages); + Assert.False(result.HasPreviousPage); + Assert.True(result.HasNextPage); + } + + [Fact] + public async Task GetOrders_WhenCustomerFilterProvided_ReturnsOnlyMatchingOrders() + { + // Act + var response = await Client.GetAsync($"/api/orders?customerId={TestDataSeeder.SecondCustomerId}"); + + var result = await response.Content.ReadFromJsonAsync>(); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + Assert.NotNull(result); + Assert.Single(result.Items); + + Assert.All( + result.Items, + order => Assert.Equal( + TestDataSeeder.SecondCustomerId, + order.CustomerId)); + } + + [Fact] + public async Task GetOrders_WithInvalidDateRange_ReturnsBadRequest() + { + // Act + var response = await Client.GetAsync( + "/api/orders" + + "?createdFromUtc=2026-07-20T00:00:00Z" + + "&createdToUtc=2026-07-10T00:00:00Z"); + + var body = await response.Content + .ReadFromJsonAsync(); + + // Assert + Assert.Equal( + HttpStatusCode.BadRequest, + response.StatusCode); + + Assert.Equal( + "invalid_date_range", + body.GetProperty("errorCode").GetString()); + } + + [Fact] + public async Task CompleteOrder_UpdatesStatusAndCreatesAuditLog() + { + // Arrange + var createdOrder = await CreateTestOrderAsync(); + + // Act + var response = await Client.PatchAsync( + $"/api/orders/{createdOrder.Id}/complete", + content: null); + + var completedOrder = await response.Content + .ReadFromJsonAsync(); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + Assert.NotNull(completedOrder); + Assert.Equal(OrderStatus.Completed, completedOrder.Status); + Assert.NotNull(completedOrder.CompletedAtUtc); + + using var scope = Factory.Services.CreateScope(); + + var dbContext = scope.ServiceProvider + .GetRequiredService(); + + var auditLogExists = await dbContext.AuditLogs + .AsNoTracking() + .AnyAsync(audit => + audit.EntityName == nameof(Order) && + audit.EntityId == createdOrder.Id.ToString() && + audit.Action == AuditActions.Completed); + + Assert.True(auditLogExists); + } + + [Fact] + public async Task TestDatabase_ContainsDedicatedFixtureData() + { + using var scope = Factory.Services.CreateScope(); + + var dbContext = scope.ServiceProvider + .GetRequiredService(); + + var customerExists = await dbContext.Customers + .AsNoTracking() + .AnyAsync( + customer => + customer.Id == TestDataSeeder.CustomerId); + + var productExists = await dbContext.Products + .AsNoTracking() + .AnyAsync( + product => + product.Id == TestDataSeeder.ProductId); + + Assert.True(customerExists); + Assert.True(productExists); + } + + [Fact] + public async Task HealthEndpoint_ReturnsOk() + { + // Act + var response = await Client.GetAsync("/api/health"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task GetMissingOrder_ReturnsConsistentNotFoundProblem() + { + // Act + var response = await Client.GetAsync( + "/api/orders/999999"); + + var body = await response.Content + .ReadFromJsonAsync(); + + // Assert + Assert.Equal( + HttpStatusCode.NotFound, + response.StatusCode); + + Assert.Equal( + "Not Found", + body.GetProperty("title").GetString()); + + Assert.Equal( + 404, + body.GetProperty("status").GetInt32()); + + Assert.Equal( + "resource_not_found", + body.GetProperty("errorCode").GetString()); + + Assert.True(body.TryGetProperty("traceId", out _)); + Assert.True(body.TryGetProperty("timestampUtc", out _)); + } + + [Fact] + public async Task CreateOrder_WithInvalidRequest_ReturnsValidationProblem() + { + // Arrange + var request = new CreateOrderRequest + { + CustomerId = 0, + Items = [] + }; + + // Act + var response = await Client.PostAsJsonAsync( + "/api/orders", + request); + + var body = await response.Content + .ReadFromJsonAsync(); + + // Assert + Assert.Equal( + HttpStatusCode.BadRequest, + response.StatusCode); + + Assert.Equal( + "validation_failed", + body.GetProperty("errorCode").GetString()); + + Assert.True(body.TryGetProperty("errors", out var errors)); + Assert.True(errors.TryGetProperty("CustomerId", out _)); + Assert.True(errors.TryGetProperty("Items", out _)); + } + + [Fact] + public async Task CreateOrder_WithValidRequest_CreatesOrderAndReducesStock() + { + var stockBefore = await GetProductStockAsync( + TestDataSeeder.ProductId); + + Assert.Equal( + TestDataSeeder.InitialProductStock, + stockBefore); + + var request = new CreateOrderRequest + { + CustomerId = TestDataSeeder.CustomerId, + Items = + [ + new CreateOrderItemRequest + { + ProductId = TestDataSeeder.ProductId, + Quantity = 1 + } + ] + }; + + // Act + var response = await Client.PostAsJsonAsync( + "/api/orders", + request); + + var createdOrder = await response.Content + .ReadFromJsonAsync(); + + var stockAfter = await GetProductStockAsync(TestDataSeeder.ProductId); + + // Assert + Assert.Equal( + HttpStatusCode.Created, + response.StatusCode); + + Assert.NotNull(createdOrder); + Assert.True(createdOrder.Id > 0); + Assert.Equal(TestDataSeeder.CustomerId, createdOrder.CustomerId); + Assert.Equal(24.99m, createdOrder.TotalAmount); + + Assert.Equal( + TestDataSeeder.InitialProductStock - 1, + stockAfter); + + Assert.NotNull(response.Headers.Location); + Assert.Contains( + $"/api/orders/{createdOrder.Id}", + response.Headers.Location.ToString().ToLowerInvariant()); + } + + private async Task GetProductStockAsync( + int productId) + { + using var scope = Factory.Services.CreateScope(); + + var dbContext = scope.ServiceProvider + .GetRequiredService(); + + return await dbContext.Products + .AsNoTracking() + .Where(product => product.Id == productId) + .Select(product => product.StockQuantity) + .SingleAsync(); + } + + + [Fact] + public async Task CancelOrder_CreatesCancellationAuditLog() + { + // Arrange + var createdOrder = await CreateTestOrderAsync(quantity: 2); + + // Act + var response = await Client.PatchAsync( + $"/api/orders/{createdOrder.Id}/cancel", + content: null); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + using var scope = Factory.Services.CreateScope(); + + var dbContext = scope.ServiceProvider + .GetRequiredService(); + + var auditLog = await dbContext.AuditLogs + .AsNoTracking() + .SingleOrDefaultAsync(audit => + audit.EntityName == nameof(Order) && + audit.EntityId == createdOrder.Id.ToString() && + audit.Action == AuditActions.Cancelled); + + Assert.NotNull(auditLog); + Assert.NotNull(auditLog.OldValues); + Assert.NotNull(auditLog.NewValues); + } + + + [Fact] + public async Task CancelOrder_WhenOrderIsCompleted_ReturnsInvalidStatusError() + { + // Arrange + var createdOrder = await CreateTestOrderAsync(); + + var completeResponse = await Client.PatchAsync( + $"/api/orders/{createdOrder.Id}/complete", + content: null); + + completeResponse.EnsureSuccessStatusCode(); + + var stockBeforeCancellationAttempt = + await GetProductStockAsync(TestDataSeeder.ProductId); + + // Act + var response = await Client.PatchAsync( + $"/api/orders/{createdOrder.Id}/cancel", + content: null); + + var body = await response.Content + .ReadFromJsonAsync(); + + var stockAfterCancellationAttempt = + await GetProductStockAsync(TestDataSeeder.ProductId); + + // Assert + Assert.Equal( + HttpStatusCode.BadRequest, + response.StatusCode); + + Assert.Equal( + "invalid_order_status", + body.GetProperty("errorCode").GetString()); + + Assert.Equal( + stockBeforeCancellationAttempt, + stockAfterCancellationAttempt); + } + + + [Fact] + public async Task CancelOrder_WhenOrderDoesNotExist_ReturnsNotFound() + { + // Act + var response = await Client.PatchAsync( + "/api/orders/999999/cancel", + content: null); + + var body = await response.Content + .ReadFromJsonAsync(); + + // Assert + Assert.Equal( + HttpStatusCode.NotFound, + response.StatusCode); + + Assert.Equal( + "resource_not_found", + body.GetProperty("errorCode").GetString()); + } + + [Fact] + public async Task CreateOrder_PersistsOrderCreatedEventInOutbox() + { + // Arrange and Act + var createdOrder = await CreateTestOrderAsync(quantity: 2); + + // Assert + await using var scope = Factory.Services.CreateAsyncScope(); + + var dbContext = scope.ServiceProvider + .GetRequiredService(); + + var messages = await dbContext.OutboxMessages + .AsNoTracking() + .Where(message => + message.Type == typeof(OrderCreatedIntegrationEvent).FullName) + .ToListAsync(); + + var storedMessage = Assert.Single(messages); + + Assert.Null(storedMessage.ProcessedAtUtc); + Assert.Equal(0, storedMessage.RetryCount); + Assert.Null(storedMessage.LastAttemptAtUtc); + Assert.Null(storedMessage.LastError); + + var orderExists = await dbContext.Orders + .AsNoTracking() + .AnyAsync(order => + order.Id == createdOrder.Id); + + Assert.True(orderExists); + + var integrationEvent = + JsonSerializer.Deserialize( + storedMessage.Payload, + new JsonSerializerOptions( + JsonSerializerDefaults.Web)); + + Assert.NotNull(integrationEvent); + + Assert.Equal( + storedMessage.Id, + integrationEvent.MessageId); + + Assert.Equal( + createdOrder.Id, + integrationEvent.OrderId); + + Assert.Equal( + TestDataSeeder.CustomerId, + integrationEvent.CustomerId); + + Assert.Equal( + createdOrder.TotalAmount, + integrationEvent.TotalAmount); + + var item = Assert.Single(integrationEvent.Items); + + Assert.Equal( + TestDataSeeder.ProductId, + item.ProductId); + + Assert.Equal(2, item.Quantity); + } + + + [Fact] + public async Task CreateOrder_WhenCustomerDoesNotExist_DoesNotCreateOutboxMessage() + { + // Arrange + var request = new CreateOrderRequest + { + CustomerId = 999999, + Items = + [ + new CreateOrderItemRequest + { + ProductId = TestDataSeeder.ProductId, + Quantity = 1 + } + ] + }; + + // Act + var response = await Client.PostAsJsonAsync( + "/api/orders", + request); + + // Assert + Assert.Equal( + HttpStatusCode.NotFound, + response.StatusCode); + + await using var scope = + Factory.Services.CreateAsyncScope(); + + var dbContext = scope.ServiceProvider + .GetRequiredService(); + + var outboxMessageCount = + await dbContext.OutboxMessages.CountAsync(); + + Assert.Equal(0, outboxMessageCount); + + var orderCount = + await dbContext.Orders.CountAsync(); + + Assert.Equal(0, orderCount); + } + + + [Fact] + public async Task CompleteOrder_PersistsOrderCompletedEventInOutbox() + { + // Arrange + var createdOrder = await CreateTestOrderAsync(); + + // Act + var response = await Client.PatchAsync( + $"/api/orders/{createdOrder.Id}/complete", + content: null); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + await using var scope = + Factory.Services.CreateAsyncScope(); + + var dbContext = scope.ServiceProvider + .GetRequiredService(); + + var storedMessage = await dbContext.OutboxMessages + .AsNoTracking() + .SingleAsync(message => + message.Type == + typeof(OrderCompletedIntegrationEvent).FullName); + + Assert.Null(storedMessage.ProcessedAtUtc); + Assert.Equal(0, storedMessage.RetryCount); + + var integrationEvent = + JsonSerializer.Deserialize( + storedMessage.Payload, + new JsonSerializerOptions( + JsonSerializerDefaults.Web)); + + Assert.NotNull(integrationEvent); + + Assert.Equal( + storedMessage.Id, + integrationEvent.MessageId); + + Assert.Equal( + createdOrder.Id, + integrationEvent.OrderId); + + Assert.Equal( + TestDataSeeder.CustomerId, + integrationEvent.CustomerId); + + Assert.Equal( + createdOrder.TotalAmount, + integrationEvent.TotalAmount); + + Assert.NotEqual( + default, + integrationEvent.CompletedAtUtc); + } + + [Fact] + public async Task CancelOrder_PersistsOrderCancelledEventInOutbox() + { + // Arrange + var createdOrder = + await CreateTestOrderAsync(quantity: 2); + + // Act + var response = await Client.PatchAsync( + $"/api/orders/{createdOrder.Id}/cancel", + content: null); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + await using var scope = + Factory.Services.CreateAsyncScope(); + + var dbContext = scope.ServiceProvider + .GetRequiredService(); + + var storedMessage = await dbContext.OutboxMessages + .AsNoTracking() + .SingleAsync(message => + message.Type == + typeof(OrderCancelledIntegrationEvent).FullName); + + Assert.Null(storedMessage.ProcessedAtUtc); + Assert.Equal(0, storedMessage.RetryCount); + + var integrationEvent = + JsonSerializer.Deserialize( + storedMessage.Payload, + new JsonSerializerOptions( + JsonSerializerDefaults.Web)); + + Assert.NotNull(integrationEvent); + + Assert.Equal( + storedMessage.Id, + integrationEvent.MessageId); + + Assert.Equal( + createdOrder.Id, + integrationEvent.OrderId); + + Assert.Equal( + TestDataSeeder.CustomerId, + integrationEvent.CustomerId); + + Assert.Equal( + createdOrder.TotalAmount, + integrationEvent.TotalAmount); + + Assert.NotEqual( + default, + integrationEvent.CancelledAtUtc); + } + + [Fact] + public async Task GetOrderById_WhenReadModelExists_ReturnsOrder() + { + var response = await Client.GetAsync( + $"/api/orders/" + + $"{TestOrderReadModelReader.ExistingOrderId}"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var order = await response.Content.ReadFromJsonAsync(); + + Assert.NotNull(order); + + Assert.Equal(TestOrderReadModelReader.ExistingOrderId, order.Id); + } + + [Fact] + public async Task GetOrderById_WhenReadModelDoesNotExist_ReturnsNotFound() + { + var response = await Client.GetAsync("/api/orders/999999"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } +} \ No newline at end of file diff --git a/OrderProcessing.Api.Tests/Integration/AuthorizationTests.cs b/OrderProcessing.Api.Tests/Integration/AuthorizationTests.cs new file mode 100644 index 0000000..710380f --- /dev/null +++ b/OrderProcessing.Api.Tests/Integration/AuthorizationTests.cs @@ -0,0 +1,37 @@ +using OrderProcessing.Api.Security; +using OrderProcessing.Api.Tests.Infrastructure; +using SharpCompress.Factories; +using System.Net; + +public sealed class AuthorizationTests : IntegrationTestBase +{ + [Fact] + public async Task GetOrders_WithoutAuthentication_ReturnsUnauthorized() + { + using var client = Factory.CreateClient(); + + var response = await client.GetAsync("/api/orders"); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task GetOrders_WithReadPermission_ReturnsOk() + { + using var client = Factory.CreateClientWithScopes(ApiScopes.Read); + + var response = await client.GetAsync("/api/orders"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task Health_WithoutAuthentication_ReturnsOk() + { + using var client = Factory.CreateClient(); + + var response = await client.GetAsync("/api/health"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } +} \ No newline at end of file diff --git a/OrderProcessing.Api/BackgroundJobs/OutboxBackgroundService.cs b/OrderProcessing.Api/BackgroundJobs/OutboxBackgroundService.cs index b6fca05..4586566 100644 --- a/OrderProcessing.Api/BackgroundJobs/OutboxBackgroundService.cs +++ b/OrderProcessing.Api/BackgroundJobs/OutboxBackgroundService.cs @@ -9,10 +9,7 @@ public sealed class OutboxBackgroundService : BackgroundService private readonly OutboxOptions _options; private readonly ILogger _logger; - public OutboxBackgroundService( - IServiceScopeFactory scopeFactory, - IOptions options, - ILogger logger) + public OutboxBackgroundService(IServiceScopeFactory scopeFactory, IOptions options, ILogger logger) { _scopeFactory = scopeFactory; _options = options.Value; diff --git a/OrderProcessing.Api/Controllers/CustomersController.cs b/OrderProcessing.Api/Controllers/CustomersController.cs index cb4a94d..72007f0 100644 --- a/OrderProcessing.Api/Controllers/CustomersController.cs +++ b/OrderProcessing.Api/Controllers/CustomersController.cs @@ -1,5 +1,7 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; using OrderProcessing.Api.DTOs.Customers; +using OrderProcessing.Api.Security; using OrderProcessing.Api.Services.Customers; namespace OrderProcessing.Api.Controllers; @@ -16,6 +18,7 @@ public CustomersController(ICustomerService customerService) } [HttpPost] + [Authorize(Policy = AuthorizationPolicies.WriteAccess)] public async Task> Create( CreateCustomerRequest request, CancellationToken cancellationToken) @@ -29,6 +32,7 @@ public async Task> Create( } [HttpGet] + [Authorize(Policy = AuthorizationPolicies.ReadAccess)] public async Task>> GetAll( CancellationToken cancellationToken) { @@ -38,6 +42,7 @@ public async Task>> GetAll( } [HttpGet("{id:int}")] + [Authorize(Policy = AuthorizationPolicies.ReadAccess)] public async Task> GetById( int id, CancellationToken cancellationToken) diff --git a/OrderProcessing.Api/Controllers/HealthController.cs b/OrderProcessing.Api/Controllers/HealthController.cs index 2b265a2..ec56127 100644 --- a/OrderProcessing.Api/Controllers/HealthController.cs +++ b/OrderProcessing.Api/Controllers/HealthController.cs @@ -1,8 +1,10 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; namespace OrderProcessing.Api.Controllers; [ApiController] +[AllowAnonymous] [Route("api/[controller]")] public class HealthController : ControllerBase { diff --git a/OrderProcessing.Api/Controllers/OrdersController.cs b/OrderProcessing.Api/Controllers/OrdersController.cs index cfb7406..151bb0a 100644 --- a/OrderProcessing.Api/Controllers/OrdersController.cs +++ b/OrderProcessing.Api/Controllers/OrdersController.cs @@ -1,4 +1,5 @@ using MediatR; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using OrderProcessing.Api.DTOs.Common; using OrderProcessing.Api.DTOs.Orders; @@ -7,6 +8,7 @@ using OrderProcessing.Api.Features.Orders.Commands.CreateOrder; using OrderProcessing.Api.Features.Orders.Queries.GetOrderById; using OrderProcessing.Api.Features.Orders.Queries.GetOrders; +using OrderProcessing.Api.Security; namespace OrderProcessing.Api.Controllers; @@ -22,6 +24,7 @@ public OrdersController(ISender sender) } [HttpPost] + [Authorize(Policy = AuthorizationPolicies.WriteAccess)] public async Task> Create( CreateOrderRequest request, CancellationToken cancellationToken) @@ -35,6 +38,7 @@ public async Task> Create( } [HttpGet] + [Authorize(Policy = AuthorizationPolicies.ReadAccess)] public async Task>> GetAll( [FromQuery] OrderQueryParameters parameters, CancellationToken cancellationToken) @@ -45,6 +49,7 @@ public async Task>> GetAll( } [HttpGet("{id:int}")] + [Authorize(Policy = AuthorizationPolicies.ReadAccess)] public async Task> GetById( int id, CancellationToken cancellationToken) @@ -55,6 +60,7 @@ public async Task> GetById( } [HttpPatch("{id:int}/complete")] + [Authorize(Policy = AuthorizationPolicies.WriteAccess)] public async Task> Complete( int id, CancellationToken cancellationToken) @@ -65,6 +71,7 @@ public async Task> Complete( } [HttpPatch("{id:int}/cancel")] + [Authorize(Policy = AuthorizationPolicies.WriteAccess)] public async Task> Cancel( int id, CancellationToken cancellationToken) diff --git a/OrderProcessing.Api/Controllers/ProductsController.cs b/OrderProcessing.Api/Controllers/ProductsController.cs index 17cb0db..1d481de 100644 --- a/OrderProcessing.Api/Controllers/ProductsController.cs +++ b/OrderProcessing.Api/Controllers/ProductsController.cs @@ -1,5 +1,7 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; using OrderProcessing.Api.DTOs.Products; +using OrderProcessing.Api.Security; using OrderProcessing.Api.Services.Products; namespace OrderProcessing.Api.Controllers; @@ -16,6 +18,7 @@ public ProductsController(IProductService productService) } [HttpPost] + [Authorize(Policy = AuthorizationPolicies.WriteAccess)] public async Task> Create( CreateProductRequest request, CancellationToken cancellationToken) @@ -30,6 +33,7 @@ public async Task> Create( } [HttpGet] + [Authorize(Policy = AuthorizationPolicies.ReadAccess)] public async Task>> GetAll( CancellationToken cancellationToken) { @@ -39,6 +43,7 @@ public async Task>> GetAll( } [HttpGet("{id:int}")] + [Authorize(Policy = AuthorizationPolicies.ReadAccess)] public async Task> GetById( int id, CancellationToken cancellationToken) @@ -49,6 +54,7 @@ public async Task> GetById( } [HttpPut("{id:int}")] + [Authorize(Policy = AuthorizationPolicies.WriteAccess)] public async Task> Update( int id, UpdateProductRequest request, diff --git a/OrderProcessing.Api/Extensions/SecurityServiceCollectionExtensions.cs b/OrderProcessing.Api/Extensions/SecurityServiceCollectionExtensions.cs new file mode 100644 index 0000000..33320d8 --- /dev/null +++ b/OrderProcessing.Api/Extensions/SecurityServiceCollectionExtensions.cs @@ -0,0 +1,63 @@ +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Identity.Web; +using OrderProcessing.Api.Security; + +namespace OrderProcessing.Api.Extensions; + +public static class SecurityServiceCollectionExtensions +{ + public static IServiceCollection AddApiSecurity(this IServiceCollection services, IConfiguration configuration) + { + services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddMicrosoftIdentityWebApi(configuration.GetSection("AzureAd")); + + services.AddSingleton(); + + services.AddAuthorization(options => + { + options.FallbackPolicy = + new AuthorizationPolicyBuilder() + .RequireAuthenticatedUser() + .Build(); + + options.AddPolicy(AuthorizationPolicies.ReadAccess, + policy => + { + policy.RequireAuthenticatedUser(); + + policy.AddRequirements( + new ScopeOrRoleRequirement( + acceptedScopes: + [ + ApiScopes.Read, + ApiScopes.Write + ], + acceptedRoles: + [ + ApiRoles.Admin + ])); + }); + + options.AddPolicy(AuthorizationPolicies.WriteAccess, + policy => + { + policy.RequireAuthenticatedUser(); + + policy.AddRequirements( + new ScopeOrRoleRequirement( + acceptedScopes: + [ + ApiScopes.Write + ], + acceptedRoles: + [ + ApiRoles.Admin + ])); + }); + }); + + return services; + } +} \ No newline at end of file diff --git a/OrderProcessing.Api/OpenApi/AuthorizationOperationTransformer.cs b/OrderProcessing.Api/OpenApi/AuthorizationOperationTransformer.cs new file mode 100644 index 0000000..37bf9cb --- /dev/null +++ b/OrderProcessing.Api/OpenApi/AuthorizationOperationTransformer.cs @@ -0,0 +1,34 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi; + +namespace OrderProcessing.Api.OpenApi; + +// This makes Swagger display locks only on endpoints carrying authorization metadata. The runtime policies remain the real security enforcement. Operation transformers are the recommended approach when security metadata depends on individual endpoints. +public sealed class AuthorizationOperationTransformer : IOpenApiOperationTransformer +{ + public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var metadata = context.Description.ActionDescriptor.EndpointMetadata; + + var allowsAnonymous = metadata.OfType().Any(); + + var requiresAuthorization = metadata.OfType().Any(); + + if (allowsAnonymous || !requiresAuthorization) + { + return Task.CompletedTask; + } + + operation.Security ??= []; + + operation.Security.Add(new OpenApiSecurityRequirement + { + [new OpenApiSecuritySchemeReference("oauth2", context.Document)] = [] + }); + + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/OrderProcessing.Api/OpenApi/EntraOAuthDocumentTransformer.cs b/OrderProcessing.Api/OpenApi/EntraOAuthDocumentTransformer.cs new file mode 100644 index 0000000..f0926eb --- /dev/null +++ b/OrderProcessing.Api/OpenApi/EntraOAuthDocumentTransformer.cs @@ -0,0 +1,56 @@ +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi; +using OrderProcessing.Api.Security; + +namespace OrderProcessing.Api.OpenApi; + +public sealed class EntraOAuthDocumentTransformer(IConfiguration configuration) : IOpenApiDocumentTransformer +{ + public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var instance = GetRequiredConfigurationValue("AzureAd:Instance").TrimEnd('/'); + + var tenantId = GetRequiredConfigurationValue("AzureAd:TenantId"); + + var apiClientId = GetRequiredConfigurationValue("AzureAd:ClientId"); + + var readScope = $"api://{apiClientId}/{ApiScopes.Read}"; + + var writeScope = $"api://{apiClientId}/{ApiScopes.Write}"; + + document.Components ??= new OpenApiComponents(); + + document.Components.SecuritySchemes = new Dictionary + { + ["oauth2"] = new OpenApiSecurityScheme + { + Type = SecuritySchemeType.OAuth2, + Flows = new OpenApiOAuthFlows + { + AuthorizationCode = new OpenApiOAuthFlow + { + AuthorizationUrl = new Uri($"{instance}/{tenantId}/oauth2/v2.0/authorize"), + + TokenUrl = new Uri($"{instance}/{tenantId}/oauth2/v2.0/token"), + + Scopes = new Dictionary + { + [readScope] = "Read order-processing resources.", + + [writeScope] = "Create or modify order-processing resources." + } + } + } + } + }; + + return Task.CompletedTask; + } + + private string GetRequiredConfigurationValue(string key) + { + return configuration[key] ?? throw new InvalidOperationException($"Configuration value '{key}' is required."); + } +} \ No newline at end of file diff --git a/OrderProcessing.Api/OrderProcessing.Api.csproj b/OrderProcessing.Api/OrderProcessing.Api.csproj index 773b6c7..468a35f 100644 --- a/OrderProcessing.Api/OrderProcessing.Api.csproj +++ b/OrderProcessing.Api/OrderProcessing.Api.csproj @@ -1,9 +1,10 @@ - + net10.0 enable enable + 0fe70da2-d2af-40ad-a4ae-4a1750f78e04 @@ -19,6 +20,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/OrderProcessing.Api/Program.cs b/OrderProcessing.Api/Program.cs index 09c712e..c26ecac 100644 --- a/OrderProcessing.Api/Program.cs +++ b/OrderProcessing.Api/Program.cs @@ -7,6 +7,8 @@ using OrderProcessing.Api.Data.Seeding; using OrderProcessing.Api.Extensions; using OrderProcessing.Api.Features.Orders.Queries.ReadModel; +using OrderProcessing.Api.OpenApi; +using OrderProcessing.Api.Security; using OrderProcessing.Api.Services.Auditing; using OrderProcessing.Api.Services.Customers; using OrderProcessing.Api.Services.Messaging; @@ -33,9 +35,15 @@ public static async Task Main(string[] args) // Add services to the container. builder.Services.AddControllers(); + builder.Services.AddApiSecurity(builder.Configuration); builder.Services.AddCustomValidationResponse(); // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi - builder.Services.AddOpenApi(); + builder.Services.AddOpenApi(options => + { + options.AddDocumentTransformer(); + + options.AddOperationTransformer(); + }); builder.Services.AddDbContext(options => options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"))); @@ -178,18 +186,34 @@ public static async Task Main(string[] args) // Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) { - app.MapOpenApi(); + app.MapOpenApi().AllowAnonymous(); + + var apiClientId =app.Configuration["AzureAd:ClientId"] ?? throw new InvalidOperationException("AzureAd:ClientId is required."); + + var swaggerClientId = app.Configuration["SwaggerOAuth:ClientId"] ?? throw new InvalidOperationException("SwaggerOAuth:ClientId is required."); + + var readScope = $"api://{apiClientId}/{ApiScopes.Read}"; + + var writeScope = $"api://{apiClientId}/{ApiScopes.Write}"; app.UseSwaggerUI(options => { options.SwaggerEndpoint("/openapi/v1.json", "Order Processing API v1"); + + options.OAuthClientId(swaggerClientId); + options.OAuthAppName("Order Processing API - Swagger"); + + options.OAuthScopes(readScope, writeScope); + + options.OAuthUsePkce(); }); } app.UseHttpsRedirection(); - app.UseAuthorization(); + app.UseAuthentication(); + app.UseAuthorization(); app.MapControllers(); diff --git a/OrderProcessing.Api/Security/ApiRoles.cs b/OrderProcessing.Api/Security/ApiRoles.cs new file mode 100644 index 0000000..9b4a2e9 --- /dev/null +++ b/OrderProcessing.Api/Security/ApiRoles.cs @@ -0,0 +1,6 @@ +namespace OrderProcessing.Api.Security; + +public static class ApiRoles +{ + public const string Admin = "OrderProcessing.Admin"; +} \ No newline at end of file diff --git a/OrderProcessing.Api/Security/ApiScopes.cs b/OrderProcessing.Api/Security/ApiScopes.cs new file mode 100644 index 0000000..4e59b03 --- /dev/null +++ b/OrderProcessing.Api/Security/ApiScopes.cs @@ -0,0 +1,8 @@ +namespace OrderProcessing.Api.Security; + +public static class ApiScopes +{ + public const string Read = "OrderProcessing.Read"; + + public const string Write = "OrderProcessing.Write"; +} \ No newline at end of file diff --git a/OrderProcessing.Api/Security/AuthorizationPolicies.cs b/OrderProcessing.Api/Security/AuthorizationPolicies.cs new file mode 100644 index 0000000..428658e --- /dev/null +++ b/OrderProcessing.Api/Security/AuthorizationPolicies.cs @@ -0,0 +1,8 @@ +namespace OrderProcessing.Api.Security; + +public static class AuthorizationPolicies +{ + public const string ReadAccess = "ReadAccess"; + + public const string WriteAccess = "WriteAccess"; +} \ No newline at end of file diff --git a/OrderProcessing.Api/Security/ScopeOrRoleAuthorizationHandler.cs b/OrderProcessing.Api/Security/ScopeOrRoleAuthorizationHandler.cs new file mode 100644 index 0000000..92590ec --- /dev/null +++ b/OrderProcessing.Api/Security/ScopeOrRoleAuthorizationHandler.cs @@ -0,0 +1,48 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; + +namespace OrderProcessing.Api.Security; + +public sealed class ScopeOrRoleAuthorizationHandler : AuthorizationHandler +{ + private const string ScopeClaimType = "scp"; + + private const string MappedScopeClaimType = "http://schemas.microsoft.com/identity/claims/scope"; + + private const string RoleClaimType = "roles"; + + protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, ScopeOrRoleRequirement requirement) + { + if (context.User.Identity?.IsAuthenticated != true) + { + return Task.CompletedTask; + } + + var scopes = context.User.Claims.Where(claim => + claim.Type == ScopeClaimType || + claim.Type == MappedScopeClaimType) + .SelectMany(claim => + claim.Value.Split( + ' ', + StringSplitOptions.RemoveEmptyEntries | + StringSplitOptions.TrimEntries)); + + var roles = context.User.Claims.Where(claim => + claim.Type == RoleClaimType || + claim.Type == ClaimTypes.Role) + .Select(claim => claim.Value); + + var hasAcceptedScope = scopes.Any( + requirement.AcceptedScopes.Contains); + + var hasAcceptedRole = roles.Any( + requirement.AcceptedRoles.Contains); + + if (hasAcceptedScope || hasAcceptedRole) + { + context.Succeed(requirement); + } + + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/OrderProcessing.Api/Security/ScopeOrRoleRequirement.cs b/OrderProcessing.Api/Security/ScopeOrRoleRequirement.cs new file mode 100644 index 0000000..c6e7dd1 --- /dev/null +++ b/OrderProcessing.Api/Security/ScopeOrRoleRequirement.cs @@ -0,0 +1,17 @@ +using Microsoft.AspNetCore.Authorization; + +namespace OrderProcessing.Api.Security; + +public sealed class ScopeOrRoleRequirement : IAuthorizationRequirement +{ + public ScopeOrRoleRequirement(IEnumerable acceptedScopes, IEnumerable acceptedRoles) + { + AcceptedScopes = acceptedScopes.ToHashSet(StringComparer.Ordinal); + + AcceptedRoles = acceptedRoles.ToHashSet(StringComparer.Ordinal); + } + + public IReadOnlySet AcceptedScopes { get; } + + public IReadOnlySet AcceptedRoles { get; } +} \ No newline at end of file diff --git a/OrderProcessing.Api/appsettings.Development.json b/OrderProcessing.Api/appsettings.Development.json index be76cea..39e94bf 100644 --- a/OrderProcessing.Api/appsettings.Development.json +++ b/OrderProcessing.Api/appsettings.Development.json @@ -7,5 +7,8 @@ }, "RabbitMq": { "Enabled": true + }, + "SwaggerOAuth": { + "ClientId": "" } } diff --git a/OrderProcessing.Api/appsettings.json b/OrderProcessing.Api/appsettings.json index 3b8f58d..6579631 100644 --- a/OrderProcessing.Api/appsettings.json +++ b/OrderProcessing.Api/appsettings.json @@ -65,5 +65,8 @@ "ConnectionString": "mongodb://localhost:27017", "DatabaseName": "OrderProcessingReadDb", "OrdersCollectionName": "orders" + }, + "AzureAd": { + "Instance": "https://login.microsoftonline.com/" } }