From 218cc2e0f1f5afd4aa0523b162b8a898b7af15da Mon Sep 17 00:00:00 2001 From: PowerTurtle Date: Sat, 14 Feb 2026 14:31:26 +0100 Subject: [PATCH 1/3] feat: rework --- .config/opencode/opencode.json | 2 +- .cursor/rules/architecture/README.md | 59 - .../architecture/data-access-patterns.mdc | 57 - .../event-driven-architecture.mdc | 235 - .../inter-service-communication.mdc | 275 -- .../rules/architecture/project-structure.mdc | 218 - .cursor/rules/benchmarking/README.md | 20 - .cursor/rules/benchmarking/benchmarking.mdc | 235 - .cursor/rules/containers/README.md | 31 - .../rules/containers/container-tooling.mdc | 89 - .../rules/containers/dockerfile-patterns.mdc | 144 - .cursor/rules/csharp/README.md | 35 - .cursor/rules/csharp/coding-style.mdc | 746 ---- .cursor/rules/csharp/testing.mdc | 668 --- .cursor/rules/dotnet-sdk/README.md | 31 - .../dotnet-sdk/dependency-management.mdc | 191 - .../rules/dotnet-sdk/solution-management.mdc | 172 - .cursor/rules/dotnet-tools/README.md | 17 - .../dotnet-tools/consuming-dotnettool.mdc | 70 - .../dotnet-tools/publishing-dotnettool.mdc | 44 - .github/copilot-instructions.md | 21 + .vscode/settings.json | 8 + AGENTS.md | 14 + Directory.Packages.props | 17 +- IMPLEMENTATION_COMPLETE.md | 498 --- README.md | 9 +- Teck.Cloud.slnx | 2 + deployment/README.md | 22 + deployment/argocd/apps/catalog-api.yaml | 20 + deployment/argocd/apps/customer-api.yaml | 20 + deployment/argocd/apps/web-bff.yaml | 20 + deployment/argocd/project.yaml | 18 + deployment/argocd/root-application.yaml | 20 + .../manifests/catalog-api/deployment.yaml | 59 + .../manifests/catalog-api/kustomization.yaml | 6 + deployment/manifests/catalog-api/service.yaml | 14 + .../manifests/customer-api/deployment.yaml | 52 + .../manifests/customer-api/kustomization.yaml | 6 + .../manifests/customer-api/service.yaml | 14 + deployment/manifests/web-bff/deployment.yaml | 56 + .../manifests/web-bff/kustomization.yaml | 6 + deployment/manifests/web-bff/service.yaml | 14 + docs/MIGRATION_SYSTEM.md | 382 -- docs/ai/rules/README.md | 26 + .../architecture/data-access-patterns.md | 19 + .../architecture/event-driven-architecture.md | 23 + .../inter-service-communication.md | 19 + .../rules/architecture/project-structure.md | 27 + docs/ai/rules/auth/auth-setup.md | 28 + docs/ai/rules/benchmarking/benchmarking.md | 18 + docs/ai/rules/containers/container-tooling.md | 12 + .../rules/containers/dockerfile-patterns.md | 18 + docs/ai/rules/csharp/coding-style.md | 26 + docs/ai/rules/csharp/testing.md | 23 + docs/ai/rules/deployment/argocd.md | 24 + .../rules/dotnet-sdk/dependency-management.md | 21 + .../rules/dotnet-sdk/solution-management.md | 18 + .../dotnet-tools/consuming-dotnettool.md | 12 + .../dotnet-tools/publishing-dotnettool.md | 13 + .../rules/source-control/github-workflow.md | 37 + global.json | 3 + keycloak/teck-customer-authz.json | 2 - scripts/count_trx_tests.ps1 | 24 - scripts/parse_coverage.ps1 | 59 - scripts/verify-signatures.sh | 351 -- src/aspire/Teck.Cloud.AppHost/Program.cs | 57 +- .../Properties/launchSettings.json | 6 +- .../Teck.Cloud.AppHost.csproj | 3 +- src/auth/Dockerfile | 26 +- src/auth/README.md | 10 +- src/auth/realm-export.json | 3885 +++++++++++++++++ .../MultiTenant/TenantDbConnectionResolver.cs | 65 + .../Web.BFF/Controllers/AuthController.cs | 30 - .../Endpoints/SwitchOrganizationEndpoint.cs | 38 - .../InternalIdentityValidationMiddleware.cs | 129 + .../Middleware/TokenExchangeMiddleware.cs | 97 +- src/gateways/Web.BFF/Program.cs | 27 + .../Services/TenantRoutingMetadataService.cs | 88 + .../Web.BFF/Services/TokenExchangeService.cs | 73 +- src/gateways/Web.BFF/Web.BFF.csproj | 2 + .../Web.BFF/appsettings.Development.json | 2 +- .../EdgeRequestSanitizationMiddleware.cs | 46 + src/gateways/Web.Edge/Program.cs | 96 + .../Security/InternalIdentityTokenService.cs | 74 + src/gateways/Web.Edge/Web.Edge.csproj | 20 + .../Web.Edge/appsettings.Development.json | 79 + src/gateways/Web.Edge/appsettings.json | 85 + .../Catalog.Api/appsettings.Development.json | 6 +- .../catalog/Catalog.Api/appsettings.json | 8 +- .../InfrastructureServiceExtensions.cs | 17 +- .../CheckServiceReadinessEndpoint.cs | 50 - .../CreateTenant/CreateTenantRequest.cs | 2 +- .../GetTenantDatabaseInfoEndpoint.cs | 47 - .../Customer.Api/appsettings.Development.json | 6 +- .../customer/Customer.Api/appsettings.json | 10 +- .../CreateTenantCommandHandler.cs | 17 +- .../CheckServiceReadinessQuery.cs | 11 - .../CheckServiceReadinessQueryHandler.cs | 52 - .../GetTenantDatabaseInfoQuery.cs | 12 - .../GetTenantDatabaseInfoQueryHandler.cs | 48 - .../InfrastructureServiceExtensions.cs | 11 +- .../CheckServiceReadinessQueryHandlerTests.cs | 167 - .../Tenants/GetTenantByIdQueryHandlerTests.cs | 6 +- .../GetTenantDatabaseInfoQueryHandlerTests.cs | 166 - .../CreateTenantCommandValidatorTests.cs | 2 +- .../TenantDbConnectionResolverTests.cs | 126 + .../TokenExchangeMiddlewareEdgeTests.cs | 34 +- ...ExchangeMiddlewareTenantResolutionTests.cs | 255 ++ .../TokenExchangeMiddlewareTests.cs | 12 +- .../TokenExchangeServiceEdgeTests.cs | 50 +- .../TokenExchangeServiceTests.cs | 134 +- .../EdgeRequestSanitizationMiddlewareTests.cs | 74 + .../InternalIdentityTokenServiceTests.cs | 59 + .../Web.Edge.UnitTests.csproj | 28 + 114 files changed, 6341 insertions(+), 5447 deletions(-) delete mode 100644 .cursor/rules/architecture/README.md delete mode 100644 .cursor/rules/architecture/data-access-patterns.mdc delete mode 100644 .cursor/rules/architecture/event-driven-architecture.mdc delete mode 100644 .cursor/rules/architecture/inter-service-communication.mdc delete mode 100644 .cursor/rules/architecture/project-structure.mdc delete mode 100644 .cursor/rules/benchmarking/README.md delete mode 100644 .cursor/rules/benchmarking/benchmarking.mdc delete mode 100644 .cursor/rules/containers/README.md delete mode 100644 .cursor/rules/containers/container-tooling.mdc delete mode 100644 .cursor/rules/containers/dockerfile-patterns.mdc delete mode 100644 .cursor/rules/csharp/README.md delete mode 100644 .cursor/rules/csharp/coding-style.mdc delete mode 100644 .cursor/rules/csharp/testing.mdc delete mode 100644 .cursor/rules/dotnet-sdk/README.md delete mode 100644 .cursor/rules/dotnet-sdk/dependency-management.mdc delete mode 100644 .cursor/rules/dotnet-sdk/solution-management.mdc delete mode 100644 .cursor/rules/dotnet-tools/README.md delete mode 100644 .cursor/rules/dotnet-tools/consuming-dotnettool.mdc delete mode 100644 .cursor/rules/dotnet-tools/publishing-dotnettool.mdc create mode 100644 .github/copilot-instructions.md create mode 100644 .vscode/settings.json create mode 100644 AGENTS.md delete mode 100644 IMPLEMENTATION_COMPLETE.md create mode 100644 deployment/README.md create mode 100644 deployment/argocd/apps/catalog-api.yaml create mode 100644 deployment/argocd/apps/customer-api.yaml create mode 100644 deployment/argocd/apps/web-bff.yaml create mode 100644 deployment/argocd/project.yaml create mode 100644 deployment/argocd/root-application.yaml create mode 100644 deployment/manifests/catalog-api/deployment.yaml create mode 100644 deployment/manifests/catalog-api/kustomization.yaml create mode 100644 deployment/manifests/catalog-api/service.yaml create mode 100644 deployment/manifests/customer-api/deployment.yaml create mode 100644 deployment/manifests/customer-api/kustomization.yaml create mode 100644 deployment/manifests/customer-api/service.yaml create mode 100644 deployment/manifests/web-bff/deployment.yaml create mode 100644 deployment/manifests/web-bff/kustomization.yaml create mode 100644 deployment/manifests/web-bff/service.yaml delete mode 100644 docs/MIGRATION_SYSTEM.md create mode 100644 docs/ai/rules/README.md create mode 100644 docs/ai/rules/architecture/data-access-patterns.md create mode 100644 docs/ai/rules/architecture/event-driven-architecture.md create mode 100644 docs/ai/rules/architecture/inter-service-communication.md create mode 100644 docs/ai/rules/architecture/project-structure.md create mode 100644 docs/ai/rules/auth/auth-setup.md create mode 100644 docs/ai/rules/benchmarking/benchmarking.md create mode 100644 docs/ai/rules/containers/container-tooling.md create mode 100644 docs/ai/rules/containers/dockerfile-patterns.md create mode 100644 docs/ai/rules/csharp/coding-style.md create mode 100644 docs/ai/rules/csharp/testing.md create mode 100644 docs/ai/rules/deployment/argocd.md create mode 100644 docs/ai/rules/dotnet-sdk/dependency-management.md create mode 100644 docs/ai/rules/dotnet-sdk/solution-management.md create mode 100644 docs/ai/rules/dotnet-tools/consuming-dotnettool.md create mode 100644 docs/ai/rules/dotnet-tools/publishing-dotnettool.md create mode 100644 docs/ai/rules/source-control/github-workflow.md delete mode 100644 keycloak/teck-customer-authz.json delete mode 100644 scripts/count_trx_tests.ps1 delete mode 100644 scripts/parse_coverage.ps1 delete mode 100644 scripts/verify-signatures.sh create mode 100644 src/auth/realm-export.json delete mode 100644 src/gateways/Web.BFF/Controllers/AuthController.cs delete mode 100644 src/gateways/Web.BFF/Endpoints/SwitchOrganizationEndpoint.cs create mode 100644 src/gateways/Web.BFF/Middleware/InternalTrust/InternalIdentityValidationMiddleware.cs create mode 100644 src/gateways/Web.BFF/Services/TenantRoutingMetadataService.cs create mode 100644 src/gateways/Web.Edge/Middleware/EdgeRequestSanitizationMiddleware.cs create mode 100644 src/gateways/Web.Edge/Program.cs create mode 100644 src/gateways/Web.Edge/Security/InternalIdentityTokenService.cs create mode 100644 src/gateways/Web.Edge/Web.Edge.csproj create mode 100644 src/gateways/Web.Edge/appsettings.Development.json create mode 100644 src/gateways/Web.Edge/appsettings.json delete mode 100644 src/services/customer/Customer.Api/Endpoints/V1/Tenants/CheckServiceReadiness/CheckServiceReadinessEndpoint.cs delete mode 100644 src/services/customer/Customer.Api/Endpoints/V1/Tenants/GetTenantDatabaseInfo/GetTenantDatabaseInfoEndpoint.cs delete mode 100644 src/services/customer/Customer.Application/Tenants/Queries/CheckServiceReadiness/CheckServiceReadinessQuery.cs delete mode 100644 src/services/customer/Customer.Application/Tenants/Queries/CheckServiceReadiness/CheckServiceReadinessQueryHandler.cs delete mode 100644 src/services/customer/Customer.Application/Tenants/Queries/GetTenantDatabaseInfo/GetTenantDatabaseInfoQuery.cs delete mode 100644 src/services/customer/Customer.Application/Tenants/Queries/GetTenantDatabaseInfo/GetTenantDatabaseInfoQueryHandler.cs delete mode 100644 tests/unit/Customer.UnitTests/Application/Queries/Tenants/CheckServiceReadinessQueryHandlerTests.cs delete mode 100644 tests/unit/Customer.UnitTests/Application/Queries/Tenants/GetTenantDatabaseInfoQueryHandlerTests.cs create mode 100644 tests/unit/SharedKernel.Persistence.UnitTests/Database/MultiTenant/TenantDbConnectionResolverTests.cs create mode 100644 tests/unit/Web.BFF.UnitTests/TokenExchangeMiddlewareTenantResolutionTests.cs create mode 100644 tests/unit/Web.Edge.UnitTests/EdgeRequestSanitizationMiddlewareTests.cs create mode 100644 tests/unit/Web.Edge.UnitTests/InternalIdentityTokenServiceTests.cs create mode 100644 tests/unit/Web.Edge.UnitTests/Web.Edge.UnitTests.csproj diff --git a/.config/opencode/opencode.json b/.config/opencode/opencode.json index e56bbd1b..3aca529e 100644 --- a/.config/opencode/opencode.json +++ b/.config/opencode/opencode.json @@ -1,4 +1,4 @@ { "$schema": "https://opencode.ai/config.json", - "instructions": ["CONTRIBUTING.md", "docs/guidelines.md", ".cursor/rules/**/*.md"] + "instructions": [".github/copilot-instructions.md", "AGENTS.md", "docs/ai/rules/**/*.md"] } \ No newline at end of file diff --git a/.cursor/rules/architecture/README.md b/.cursor/rules/architecture/README.md deleted file mode 100644 index dbc9a645..00000000 --- a/.cursor/rules/architecture/README.md +++ /dev/null @@ -1,59 +0,0 @@ -# ๐Ÿ—๏ธ Architecture Guidelines - -This directory contains rules for maintaining consistent architecture patterns across the Teck.Cloud solution. - -## ๐Ÿ“‹ [Project Structure](project-structure.mdc) - -Use this when you're: -- Setting up a new service -- Organizing code within a service -- Creating new building blocks -- Setting up gateways - -These rules define: -- Service layer organization (Domain, Application, Infrastructure, Api) -- Building blocks structure -- Gateway (BFF) patterns -- Naming conventions - -## ๐Ÿ”„ [Event-Driven Architecture](event-driven-architecture.mdc) - -Use this when you're: -- Implementing domain events -- Publishing integration events -- Handling events -- Setting up event handlers - -These rules ensure: -- Proper separation between domain and integration events -- Correct event publishing patterns -- Event handler organization -- Async communication between services - -## ๐ŸŒ [Inter-Service Communication](inter-service-communication.mdc) - -Use this when you're: -- Implementing gRPC services -- Setting up HTTP clients -- Configuring gateways -- Making service-to-service calls - -These rules define: -- When to use gRPC vs HTTP -- Gateway (BFF) patterns -- Service discovery -- Resilience patterns - -## ๐Ÿ—„๏ธ [Data Access Patterns](data-access-patterns.mdc) - -Use this when you're: -- Implementing repositories -- Setting up database contexts -- Working with read/write models -- Implementing CQRS - -These rules ensure: -- Proper repository patterns -- CQRS separation -- Multi-tenancy support -- Database strategy patterns diff --git a/.cursor/rules/architecture/data-access-patterns.mdc b/.cursor/rules/architecture/data-access-patterns.mdc deleted file mode 100644 index 49d4a3f9..00000000 --- a/.cursor/rules/architecture/data-access-patterns.mdc +++ /dev/null @@ -1,57 +0,0 @@ ---- -description: This file provides guidelines for data access patterns including CQRS, repository pattern, read/write separation, and multi-tenancy. -globs: **/Repositories/**/*.cs, **/DbContext*.cs, **/ReadModels/**/*.cs ---- -# Cursor Rules File: Data Access Patterns - -Role Definition: - - Data Access Architect - - CQRS Specialist - - Multi-Tenancy Expert - -General: - Description: > - The Teck.Cloud solution follows CQRS (Command Query Responsibility Segregation) - principles with separate read and write models, repository pattern for data access, - and multi-tenancy support. - -CQRS Pattern: - Command Side (Write): - - Use domain entities from Domain layer - - Write repositories in Application layer (interfaces) - - Implementations in Infrastructure layer - - Use Unit of Work pattern - - Query Side (Read): - - Use read models (DTOs optimized for reading) - - Read repositories in Application layer (interfaces) - - Implementations in Infrastructure layer - - No domain entities in read path - -Repository Pattern: - Interface Location: - - Application layer: {Aggregate}/Repositories/ - - Naming: I{Entity}ReadRepository, I{Entity}WriteRepository - - Implementation Location: - - Infrastructure layer: Persistence/{Aggregate}/Repositories/ - - Naming: {Entity}ReadRepository, {Entity}WriteRepository - -Database Contexts: - Write Context: - - Inherit from BaseDbContext - - Contains DbSet for domain entities - - Used for commands and domain operations - - Read Context: - - Inherit from BaseDbContext - - Contains DbSet or direct queries - - Used for queries only - - Optimized for read performance - -Multi-Tenancy: - - Use Finbuckle.MultiTenant - - Support multiple tenant resolution strategies - - Repositories automatically filter by tenant - -# End of Cursor Rules File diff --git a/.cursor/rules/architecture/event-driven-architecture.mdc b/.cursor/rules/architecture/event-driven-architecture.mdc deleted file mode 100644 index 47e1f96f..00000000 --- a/.cursor/rules/architecture/event-driven-architecture.mdc +++ /dev/null @@ -1,235 +0,0 @@ ---- -description: This file provides guidelines for implementing event-driven architecture patterns using Domain Events for internal communication and Integration Events for cross-service communication. -globs: **/*Event*.cs, **/EventHandlers/**/*.cs ---- -# Cursor Rules File: Event-Driven Architecture - -Role Definition: - - Event-Driven Architecture Expert - - Domain-Driven Design Specialist - - Message Broker Specialist - -General: - Description: > - The Teck.Cloud solution uses a two-tier event system: Domain Events for internal - service communication and Integration Events for asynchronous cross-service communication. - Domain Events are raised by aggregates and handled within the same transaction boundary, - while Integration Events are published to message brokers for eventual consistency - across service boundaries. - Requirements: - - Domain Events for internal service communication only - - Integration Events for cross-service async communication - - Proper event handler organization - - Event sourcing patterns where appropriate - -Domain Events: - Purpose: - - Internal communication within a single service - - Synchronous handling within the same transaction - - Triggering side effects within the service boundary - - Maintaining consistency within an aggregate - - Definition: - - Must inherit from DomainEvent base class - - Located in Domain layer: Entities/{Aggregate}Aggregate/Events/ - - Naming: {Entity}{Action}DomainEvent (e.g., BrandCreatedDomainEvent) - - Example: - ```csharp - namespace Catalog.Domain.Entities.BrandAggregate.Events; - - public sealed class BrandCreatedDomainEvent(Guid brandId, string brandName) : DomainEvent - { - public Guid BrandId { get; } = brandId; - public string BrandName { get; } = brandName; - } - ``` - - Raising Domain Events: - - Raise from aggregate root entities only - - Use AddDomainEvent() method on BaseEntity - - Raise after state change, before persistence - - Example: - ```csharp - public static ErrorOr Create(string name, string? description, string? website) - { - // ... validation ... - - var brand = new Brand { Name = name, Description = description }; - - // Raise domain event - brand.AddDomainEvent(new BrandCreatedDomainEvent(brand.Id, brand.Name)); - - return brand; - } - ``` - - Handling Domain Events: - - Handlers in Application layer: EventHandlers/DomainEvents/ - - Use Wolverine for domain event processing - - Handlers can trigger integration events - - Handlers execute within the same transaction - - Example: - ```csharp - namespace Catalog.Application.EventHandlers.DomainEvents; - - public static class BrandCreatedDomainEventProcessor - { - public static IStorageAction Handle( - CreateBrandCommand command, - [Entity] Brand item, - [Entity] BrandCreatedDomainEvent domainEvent, - ILogger logger) - { - logger.LogInformation("Brand {BrandId} created", domainEvent.BrandId); - // Can publish integration event here if needed - return Storage.Update(item); - } - } - ``` - - Configuration: - - Wolverine automatically publishes domain events from EF Core entities - - Configure in Infrastructure layer: - ```csharp - opts.PublishDomainEventsFromEntityFrameworkCore( - entity => entity.DomainEvents); - ``` - -Integration Events: - Purpose: - - Asynchronous communication between services - - Eventual consistency across service boundaries - - Decoupling services - - Event sourcing and audit trails - - Definition: - - Must inherit from IntegrationEvent base class - - Located in SharedKernel.Events project (shared contracts) - - Naming: {Entity}{Action}IntegrationEvent (e.g., BrandCreatedIntegrationEvent) - - Must be serializable (for message broker) - - Example: - ```csharp - namespace SharedKernel.Events; - - public class BrandCreatedIntegrationEvent : IntegrationEvent - { - public Guid BrandId { get; set; } - - public BrandCreatedIntegrationEvent() { } - - public BrandCreatedIntegrationEvent(Guid brandId) - { - BrandId = brandId; - } - } - ``` - - Publishing Integration Events: - - Publish from Application layer event handlers - - Use Wolverine message publishing - - Publish after domain event is successfully persisted - - Example: - ```csharp - public static async Task Handle( - BrandCreatedDomainEvent domainEvent, - IMessageBus bus, - ILogger logger) - { - var integrationEvent = new BrandCreatedIntegrationEvent(domainEvent.BrandId); - await bus.PublishAsync(integrationEvent); - logger.LogInformation("Published integration event for brand {BrandId}", domainEvent.BrandId); - } - ``` - - Handling Integration Events: - - Handlers in Application layer: EventHandlers/IntegrationEvents/ - - Use Wolverine message handlers - - Handlers should be idempotent - - Handle failures gracefully (retry, dead letter queue) - - Example: - ```csharp - namespace Catalog.Application.EventHandlers.IntegrationEvents; - - public static class BrandCreatedIntegrationEventHandler - { - public static async Task Handle( - BrandCreatedIntegrationEvent @event, - ILogger logger, - CancellationToken cancellationToken) - { - logger.LogInformation("Received brand created event {BrandId}", @event.BrandId); - // Update read models, send notifications, etc. - } - } - ``` - - Message Broker Configuration: - - Use RabbitMQ for integration events - - Configure in Infrastructure layer: - ```csharp - builder.UseWolverine(opts => - { - var rabbit = opts.UseRabbitMq(new Uri(rabbitmqConnectionString)); - rabbit.AutoProvision(); - rabbit.EnableWolverineControlQueues(); - rabbit.UseConventionalRouting(); - }); - ``` - -Event Handler Organization: - - Domain Event Handlers: - - Location: Application/EventHandlers/DomainEvents/ - - Naming: {Entity}{Action}DomainEventProcessor - - Purpose: Handle internal side effects, trigger integration events - - - Integration Event Handlers: - - Location: Application/EventHandlers/IntegrationEvents/ - - Naming: {Entity}{Action}IntegrationEventHandler - - Purpose: Handle cross-service events, update read models - -Event Naming Conventions: - - Domain Events: {Entity}{Action}DomainEvent - - Examples: BrandCreatedDomainEvent, ProductUpdatedDomainEvent - - Integration Events: {Entity}{Action}IntegrationEvent - - Examples: BrandCreatedIntegrationEvent, OrderPlacedIntegrationEvent - -Best Practices: - - Domain Events: - - Keep domain events focused on single aggregate changes - - Include only essential data in domain events - - Handle domain events synchronously within transaction - - Use domain events to maintain aggregate consistency - - - Integration Events: - - Include all data needed by consuming services - - Make integration events backward compatible - - Use versioning for breaking changes - - Always include correlation IDs for tracing - - Design for idempotency in handlers - - - General: - - Never raise domain events from outside the aggregate - - Always validate event data - - Log all event publications and handlers - - Use event metadata for correlation and tracing - - Consider event ordering requirements - -Error Handling: - - Domain Events: - - Failures should roll back the transaction - - Log errors but don't swallow exceptions - - - Integration Events: - - Implement retry policies - - Use dead letter queues for failed messages - - Implement circuit breakers for external dependencies - - Log all failures with context - -Testing: - - Unit test domain event raising - - Integration test event handlers - - Test event serialization/deserialization - - Test idempotency of integration event handlers - - Use TestContainers for message broker testing - -# End of Cursor Rules File \ No newline at end of file diff --git a/.cursor/rules/architecture/inter-service-communication.mdc b/.cursor/rules/architecture/inter-service-communication.mdc deleted file mode 100644 index 42051de9..00000000 --- a/.cursor/rules/architecture/inter-service-communication.mdc +++ /dev/null @@ -1,275 +0,0 @@ ---- -description: This file provides guidelines for inter-service communication patterns: gRPC for direct synchronous calls and HTTP for gateway-to-service communication. -globs: **/*Grpc*.cs, **/*gRPC*.cs, **/gateways/**/*.cs ---- -# Cursor Rules File: Inter-Service Communication - -Role Definition: - - Microservices Communication Expert - - API Gateway Specialist - - gRPC Protocol Expert - -General: - Description: > - The Teck.Cloud solution uses different communication patterns based on the use case: - - gRPC for direct synchronous service-to-service communication - - HTTP for gateway (BFF) to downstream service communication - - Integration Events (message broker) for async cross-service communication - - Service discovery for dynamic service location - Requirements: - - Use appropriate communication pattern for each scenario - - Implement proper error handling and resilience - - Use service discovery for service location - - Implement proper authentication/authorization - -gRPC for Direct Service-to-Service Communication: - When to Use: - - Direct synchronous calls between services - - High-performance requirements - - Strong typing needed - - Internal service communication only - - NOT for gateway-to-service communication - - Project Structure: - ``` - {ServiceName}.Api/ - โ”œโ”€โ”€ Grpc/ - โ”‚ โ”œโ”€โ”€ Services/ - โ”‚ โ”‚ โ””โ”€โ”€ {ServiceName}Service.cs - โ”‚ โ””โ”€โ”€ {ServiceName}.proto - โ””โ”€โ”€ Program.cs - ``` - - Implementation: - - Define .proto files for service contracts - - Generate C# code from .proto files - - Implement gRPC services in Api layer - - Register gRPC services in Program.cs - - Example: - ```csharp - // Catalog.Api/Grpc/Services/CatalogService.cs - namespace Catalog.Api.Grpc.Services; - - public class CatalogService : Catalog.CatalogBase - { - private readonly IMediator _mediator; - - public CatalogService(IMediator mediator) - { - _mediator = mediator; - } - - public override async Task GetBrand( - GetBrandRequest request, - ServerCallContext context) - { - var query = new GetBrandByIdQuery(request.BrandId); - var result = await _mediator.Send(query); - - return new GetBrandResponse - { - Id = result.Value.Id.ToString(), - Name = result.Value.Name - }; - } - } - ``` - - Configuration: - - Register gRPC services: - ```csharp - builder.Services.AddGrpc(); - ``` - - Map gRPC endpoints: - ```csharp - app.MapGrpcService(); - ``` - - Enable gRPC reflection (development only): - ```csharp - builder.Services.AddGrpcReflection(); - ``` - - Client Usage: - - Create gRPC client in consuming service: - ```csharp - builder.Services.AddGrpcClient(options => - { - options.Address = new Uri("https://catalog-api"); - }); - ``` - - Use client in application layer: - ```csharp - public class GetBrandFromCatalogHandler - { - private readonly Catalog.CatalogClient _catalogClient; - - public async Task Handle(Guid brandId) - { - var request = new GetBrandRequest { BrandId = brandId.ToString() }; - var response = await _catalogClient.GetBrandAsync(request); - return MapToDto(response); - } - } - ``` - - Best Practices: - - Use gRPC for internal service-to-service calls only - - Implement proper error handling (Status codes) - - Use deadlines for request timeouts - - Implement retry policies with exponential backoff - - Use service discovery for service addresses - - Implement health checks for gRPC services - - Use authentication (mTLS or token-based) - -HTTP for Gateway-to-Service Communication: - When to Use: - - Gateway (BFF) calling downstream services - - Public API endpoints - - RESTful interfaces - - When HTTP is required by client - - Gateway Structure: - ``` - gateways/ - โ””โ”€โ”€ {GatewayName}/ - โ”œโ”€โ”€ Endpoints/ - โ”‚ โ””โ”€โ”€ {Aggregate}/ - โ”‚ โ””โ”€โ”€ {Action}Endpoint.cs - โ”œโ”€โ”€ Clients/ - โ”‚ โ””โ”€โ”€ {ServiceName}Client.cs - โ””โ”€โ”€ Program.cs - ``` - - Implementation: - - Use HttpClient with service discovery - - Implement typed HTTP clients - - Use resilience policies (Polly) - - Example: - ```csharp - // Gateway/Clients/CatalogApiClient.cs - namespace Gateway.Clients; - - public class CatalogApiClient - { - private readonly HttpClient _httpClient; - - public CatalogApiClient(HttpClient httpClient) - { - _httpClient = httpClient; - } - - public async Task GetBrandAsync(Guid brandId, CancellationToken ct) - { - var response = await _httpClient.GetAsync( - $"/api/v1/brands/{brandId}", ct); - response.EnsureSuccessStatusCode(); - return await response.Content.ReadFromJsonAsync(ct); - } - } - ``` - - Configuration: - - Register typed HTTP clients: - ```csharp - builder.Services.AddHttpClient(client => - { - client.BaseAddress = new Uri("https://catalog-api"); - }) - .AddStandardResilienceHandler() - .AddServiceDiscovery(); - ``` - - Gateway Endpoints: - - Use FastEndpoints for gateway endpoints - - Aggregate data from multiple services - - Handle client-specific concerns - - Example: - ```csharp - namespace Gateway.Endpoints.Brands; - - public class GetBrandEndpoint : Endpoint - { - private readonly CatalogApiClient _catalogClient; - - public override async Task HandleAsync( - GetBrandRequest req, - CancellationToken ct) - { - var brand = await _catalogClient.GetBrandAsync(req.BrandId, ct); - await SendOkAsync(brand, ct); - } - } - ``` - -Service Discovery: - - Use .NET Aspire service discovery - - Configure in ServiceDefaults: - ```csharp - builder.Services.AddServiceDiscovery(); - builder.Services.ConfigureHttpClientDefaults(http => - { - http.AddServiceDiscovery(); - }); - ``` - - Services register with discovery service - - Clients resolve service addresses dynamically - -Authentication/Authorization: - - gRPC Services: - - Use mTLS for service-to-service authentication - - Or use JWT tokens in metadata - - Implement authorization policies - - - HTTP Services: - - Use Keycloak for authentication - - Forward authorization headers from gateway - - Implement proper token validation - -Error Handling: - - gRPC: - - Use proper gRPC status codes - - Implement proper error details - - Handle deadline exceeded - - Implement circuit breakers - - - HTTP: - - Use proper HTTP status codes - - Return ProblemDetails for errors - - Implement retry policies - - Handle timeouts gracefully - -Resilience Patterns: - - Implement retry policies: - ```csharp - .AddStandardResilienceHandler() - ``` - - Use circuit breakers for failing services - - Implement timeout policies - - Use bulkhead isolation - - Implement fallback strategies - -Monitoring: - - Log all inter-service calls - - Track latency and success rates - - Use distributed tracing (OpenTelemetry) - - Monitor circuit breaker states - - Alert on service failures - -Testing: - - Unit test HTTP clients with TestServer - - Integration test gRPC services - - Use TestContainers for service discovery testing - - Test resilience policies - - Test error scenarios - -Communication Pattern Decision Tree: - 1. Is this a gateway calling a service? - - Yes โ†’ Use HTTP - 2. Is this a direct service-to-service call? - - Yes โ†’ Use gRPC - 3. Is this async communication? - - Yes โ†’ Use Integration Events (message broker) - 4. Is this within the same service? - - Yes โ†’ Use Domain Events - -# End of Cursor Rules File \ No newline at end of file diff --git a/.cursor/rules/architecture/project-structure.mdc b/.cursor/rules/architecture/project-structure.mdc deleted file mode 100644 index fe4e7bff..00000000 --- a/.cursor/rules/architecture/project-structure.mdc +++ /dev/null @@ -1,218 +0,0 @@ ---- -description: This file provides guidelines for organizing projects and code within the Teck.Cloud solution following Clean Architecture principles. -globs: *.csproj, **/Domain/**/*.cs, **/Application/**/*.cs, **/Infrastructure/**/*.cs, **/Api/**/*.cs ---- -# Cursor Rules File: Project Structure and Organization - -Role Definition: - - Solution Architect - - Clean Architecture Expert - - Domain-Driven Design Specialist - -General: - Description: > - The Teck.Cloud solution follows Clean Architecture principles with clear separation - of concerns across layers. Services are organized into Domain, Application, Infrastructure, - and Api layers, with shared building blocks providing cross-cutting concerns. - Requirements: - - Follow Clean Architecture layer boundaries - - Maintain clear dependency direction (outer layers depend on inner layers) - - Use consistent naming conventions - - Organize code by feature/aggregate - -Solution Structure: - - Root organization: - ``` - Teck.Cloud/ - โ”œโ”€โ”€ src/ - โ”‚ โ”œโ”€โ”€ buildingblocks/ # Shared kernel components - โ”‚ โ”‚ โ”œโ”€โ”€ SharedKernel.Core/ # Domain abstractions, events, CQRS interfaces - โ”‚ โ”‚ โ”œโ”€โ”€ SharedKernel.Infrastructure/ # Cross-cutting infrastructure - โ”‚ โ”‚ โ”œโ”€โ”€ SharedKernel.Persistence/ # Data access abstractions - โ”‚ โ”‚ โ””โ”€โ”€ SharedKernel.Events/ # Integration event contracts - โ”‚ โ”œโ”€โ”€ services/ # Business services - โ”‚ โ”‚ โ””โ”€โ”€ {ServiceName}/ - โ”‚ โ”‚ โ”œโ”€โ”€ {ServiceName}.Domain/ - โ”‚ โ”‚ โ”œโ”€โ”€ {ServiceName}.Application/ - โ”‚ โ”‚ โ”œโ”€โ”€ {ServiceName}.Infrastructure/ - โ”‚ โ”‚ โ””โ”€โ”€ {ServiceName}.Api/ - โ”‚ โ”œโ”€โ”€ gateways/ # BFF (Backend for Frontend) gateways - โ”‚ โ””โ”€โ”€ aspire/ # .NET Aspire orchestration - โ”‚ โ”œโ”€โ”€ Teck.Cloud.AppHost/ - โ”‚ โ””โ”€โ”€ Teck.Cloud.ServiceDefaults/ - โ””โ”€โ”€ tests/ - โ”œโ”€โ”€ unit/ - โ”œโ”€โ”€ integration/ - โ””โ”€โ”€ architecture/ - ``` - -Service Layer Structure: - - Domain Layer ({ServiceName}.Domain): - - Purpose: Core business logic, domain entities, value objects, domain events - - Dependencies: Only SharedKernel.Core - - Organization: - ``` - Domain/ - โ”œโ”€โ”€ Entities/ - โ”‚ โ””โ”€โ”€ {AggregateName}Aggregate/ - โ”‚ โ”œโ”€โ”€ {AggregateName}.cs - โ”‚ โ”œโ”€โ”€ ValueObjects/ - โ”‚ โ”œโ”€โ”€ Events/ - โ”‚ โ””โ”€โ”€ Errors/ - โ””โ”€โ”€ Exceptions/ - ``` - - Rules: - - Entities must inherit from BaseEntity - - Aggregates must implement IAggregateRoot - - Domain events must inherit from DomainEvent - - Value objects must inherit from ValueObject - - No infrastructure dependencies - - No application logic - - - Application Layer ({ServiceName}.Application): - - Purpose: Use cases, commands, queries, DTOs, application services - - Dependencies: Domain, SharedKernel.Core - - Organization: - ``` - Application/ - โ”œโ”€โ”€ {AggregateName}/ - โ”‚ โ”œโ”€โ”€ Features/ - โ”‚ โ”‚ โ””โ”€โ”€ {FeatureName}/ - โ”‚ โ”‚ โ”œโ”€โ”€ {Command/Query}.cs - โ”‚ โ”‚ โ”œโ”€โ”€ {Command/Query}Handler.cs - โ”‚ โ”‚ โ””โ”€โ”€ {Validator}.cs - โ”‚ โ”œโ”€โ”€ Repositories/ - โ”‚ โ”œโ”€โ”€ ReadModels/ - โ”‚ โ”œโ”€โ”€ Mappings/ - โ”‚ โ””โ”€โ”€ Responses/ - โ”œโ”€โ”€ EventHandlers/ - โ”‚ โ”œโ”€โ”€ DomainEvents/ - โ”‚ โ””โ”€โ”€ IntegrationEvents/ - โ””โ”€โ”€ Contracts/ - ``` - - Rules: - - Commands/Queries must implement ICommand/IQuery - - Handlers must implement ICommandHandler/IQueryHandler - - Use MediatR for CQRS - - Read models separate from domain entities - - No direct database access - - - Infrastructure Layer ({ServiceName}.Infrastructure): - - Purpose: Data access, external services, infrastructure implementations - - Dependencies: Application, Domain, SharedKernel.* - - Organization: - ``` - Infrastructure/ - โ”œโ”€โ”€ Persistence/ - โ”‚ โ”œโ”€โ”€ {AggregateName}/ - โ”‚ โ”‚ โ”œโ”€โ”€ Configurations/ - โ”‚ โ”‚ โ””โ”€โ”€ Repositories/ - โ”‚ โ””โ”€โ”€ {ServiceName}DbContext.cs - โ”œโ”€โ”€ Caching/ - โ”œโ”€โ”€ DependencyInjection/ - โ””โ”€โ”€ Options/ - ``` - - Rules: - - Implement repository interfaces from Application layer - - EF Core configurations in Configurations folder - - DbContext must inherit from BaseDbContext - - All infrastructure concerns isolated here - - - Api Layer ({ServiceName}.Api): - - Purpose: HTTP endpoints, gRPC services, request/response models, API configuration - - Dependencies: Application, Infrastructure - - Organization: - ``` - Api/ - โ”œโ”€โ”€ Endpoints/ # HTTP endpoints (for gateways) - โ”‚ โ””โ”€โ”€ {AggregateName}/ - โ”‚ โ”œโ”€โ”€ {EndpointName}Endpoint.cs - โ”‚ โ””โ”€โ”€ {EndpointName}Request.cs - โ”œโ”€โ”€ Grpc/ # gRPC services (for direct service-to-service) - โ”‚ โ”œโ”€โ”€ Services/ - โ”‚ โ”‚ โ””โ”€โ”€ {ServiceName}Service.cs - โ”‚ โ””โ”€โ”€ {ServiceName}.proto - โ”œโ”€โ”€ Extensions/ - โ””โ”€โ”€ Program.cs - ``` - - Rules: - - Use FastEndpoints for HTTP endpoints (called by gateways) - - Use gRPC services for direct service-to-service communication - - Endpoints must be in Endpoints folder - - gRPC services must be in Grpc/Services folder - - Request/Response models in same folder as endpoint - - No business logic in API layer - - HTTP endpoints are for gateway consumption - - gRPC services are for direct service-to-service calls - -Building Blocks: - - SharedKernel.Core: - - Domain abstractions (BaseEntity, IAggregateRoot, ValueObject) - - CQRS interfaces (ICommand, IQuery, ICommandHandler, IQueryHandler) - - Event interfaces (IDomainEvent, IIntegrationEvent) - - Common exceptions - - No infrastructure dependencies - - - SharedKernel.Infrastructure: - - Cross-cutting infrastructure (auth, caching, logging, multi-tenancy) - - Middleware - - Extension methods for service configuration - - Framework-specific implementations - - - SharedKernel.Persistence: - - Repository abstractions - - Database context base classes - - Multi-tenancy database support - - EF Core configurations - - - SharedKernel.Events: - - Integration event contracts (shared between services) - - Must be in separate project for service independence - - Only event definitions, no handlers - -Gateways (BFF Pattern): - - Purpose: Backend for Frontend - aggregates data from multiple services for specific clients - - Organization: - ``` - gateways/ - โ””โ”€โ”€ {GatewayName}/ - โ”œโ”€โ”€ Endpoints/ - โ”‚ โ””โ”€โ”€ {Aggregate}/ - โ”‚ โ””โ”€โ”€ {Action}Endpoint.cs - โ”œโ”€โ”€ Clients/ - โ”‚ โ””โ”€โ”€ {ServiceName}Client.cs - โ”œโ”€โ”€ Extensions/ - โ””โ”€โ”€ Program.cs - ``` - - Rules: - - Use HTTP to call downstream APIs (NOT gRPC) - - Aggregate data from multiple services - - Handle client-specific concerns (formatting, filtering) - - No direct database access - - Use service discovery for downstream services - - Use typed HTTP clients with resilience policies - - Forward authentication headers to downstream services - - Implement proper error handling and aggregation - -Naming Conventions: - - Projects: {ServiceName}.{Layer} (e.g., Catalog.Domain, Catalog.Application) - - Aggregates: {AggregateName}Aggregate folder (e.g., BrandAggregate) - - Entities: {EntityName}.cs (e.g., Brand.cs) - - Commands: {Action}{Entity}Command (e.g., CreateBrandCommand) - - Queries: {Action}{Entity}Query (e.g., GetBrandByIdQuery) - - Handlers: {Command/Query}Handler (e.g., CreateBrandCommandHandler) - - Domain Events: {Entity}{Action}DomainEvent (e.g., BrandCreatedDomainEvent) - - Integration Events: {Entity}{Action}IntegrationEvent (e.g., BrandCreatedIntegrationEvent) - - Endpoints: {Action}{Entity}Endpoint (e.g., CreateBrandEndpoint) - - Repositories: I{Entity}ReadRepository, I{Entity}WriteRepository - -Dependency Rules: - - Domain: No dependencies (except SharedKernel.Core) - - Application: Can depend on Domain and SharedKernel.Core - - Infrastructure: Can depend on Application, Domain, and all SharedKernel.* - - Api: Can depend on Application, Infrastructure, Domain, and SharedKernel.* - - Gateways: Can depend on SharedKernel.* and HTTP clients - - SharedKernel.Core: No dependencies on other projects - - SharedKernel.Events: Only depends on SharedKernel.Core - -# End of Cursor Rules File \ No newline at end of file diff --git a/.cursor/rules/benchmarking/README.md b/.cursor/rules/benchmarking/README.md deleted file mode 100644 index 4761039b..00000000 --- a/.cursor/rules/benchmarking/README.md +++ /dev/null @@ -1,20 +0,0 @@ -# โšก .NET Benchmarking Guidelines - -This directory contains rules for writing effective benchmarks and performance tests for .NET applications. - -## ๐Ÿ“Š [Benchmarking](benchmarking.mdc) - -Use these rules when: -- ๐Ÿ”ฌ Writing micro-benchmarks with BenchmarkDotNet -- ๐Ÿ“ˆ Measuring performance regressions -- ๐Ÿงฎ Analyzing memory allocations -- ๐Ÿ’ช Testing hardware-specific optimizations -- ๐Ÿ”„ Setting up performance testing in CI/CD - -These rules ensure: -- โœ… Accurate and reproducible benchmarks -- ๐Ÿ”’ Proper benchmark isolation -- ๐Ÿ“Š Memory and GC analysis -- โšก Hardware intrinsics optimization -- ๐Ÿ“ˆ Performance regression tracking -- ๐Ÿš€ CI/CD integration for benchmarks \ No newline at end of file diff --git a/.cursor/rules/benchmarking/benchmarking.mdc b/.cursor/rules/benchmarking/benchmarking.mdc deleted file mode 100644 index b49ecd8f..00000000 --- a/.cursor/rules/benchmarking/benchmarking.mdc +++ /dev/null @@ -1,235 +0,0 @@ -# Cursor Rules File: .NET Benchmarking Best Practices -# This file provides guidelines for writing effective benchmarks using BenchmarkDotNet -# and other performance testing tools. - -Role Definition: - - Performance Engineer - - .NET Runtime Specialist - - Optimization Expert - -General: - Description: > - Performance testing and benchmarking should be systematic, reproducible, - and provide meaningful insights. Use BenchmarkDotNet as the primary tool - for micro-benchmarking and performance regression testing. - Requirements: - - Use BenchmarkDotNet for micro-benchmarks - - Ensure consistent test environments - - Follow scientific method - - Track performance metrics over time - - Consider memory and allocation patterns - -Project Setup: - - Configure benchmark projects: - ```xml - - - Exe - net8.0 - Release - true - false - - - - - - - - ``` - -Benchmark Structure: - - Basic benchmark setup: - ```csharp - [MemoryDiagnoser] - [RankColumn, MinColumn, MaxColumn, MeanColumn, MedianColumn] - public class StringOperationsBenchmarks - { - private const string TestString = "Hello, World!"; - private readonly StringBuilder _builder = new(); - - [Params(10, 100, 1000)] - public int Iterations { get; set; } - - [GlobalSetup] - public void Setup() - { - // Setup code that runs once before all benchmarks - } - - [Benchmark(Baseline = true)] - public string StringConcat() - { - var result = string.Empty; - for (int i = 0; i < Iterations; i++) - result += TestString; - return result; - } - - [Benchmark] - public string StringBuilder() - { - _builder.Clear(); - for (int i = 0; i < Iterations; i++) - _builder.Append(TestString); - return _builder.ToString(); - } - } - ``` - -Memory Analysis: - - Track allocations and GC: - ```csharp - [MemoryDiagnoser] - [GcServer(true)] - public class MemoryBenchmarks - { - [Benchmark] - public IEnumerable AllocatingMethod() - { - return Enumerable.Range(0, 1000) - .Select(i => i.ToString()); - } - - [Benchmark] - public IEnumerable NonAllocatingMethod() - { - return Enumerable.Range(0, 1000) - .Select(i => i.ToString()) - .ToArray(); - } - } - ``` - -Hardware Intrinsics: - - Measure SIMD performance: - ```csharp - [SimpleJob(RuntimeMoniker.Net80)] - [RyuJitX64Job] - public class VectorBenchmarks - { - private float[] _data; - - [GlobalSetup] - public void Setup() - { - _data = new float[1024]; - // Initialize data - } - - [Benchmark(Baseline = true)] - public float ScalarSum() - { - float sum = 0; - for (int i = 0; i < _data.Length; i++) - sum += _data[i]; - return sum; - } - - [Benchmark] - public float VectorSum() - { - return Vector.Sum(_data); - } - } - ``` - -Async Performance: - - Benchmark async operations: - ```csharp - public class AsyncBenchmarks - { - private HttpClient _client; - - [GlobalSetup] - public void Setup() - { - _client = new HttpClient(); - } - - [Benchmark] - public async Task SingleRequest() - { - return await _client.GetStringAsync("http://example.com"); - } - - [Benchmark] - public async Task ParallelRequests() - { - var tasks = Enumerable.Range(0, 10) - .Select(_ => _client.GetStringAsync("http://example.com")) - .ToArray(); - - return await Task.WhenAll(tasks); - } - } - ``` - -CI/CD Integration: - - Configure benchmark runs: - ```yaml - - name: Run Benchmarks - run: | - dotnet run -c Release --filter '*' - - - name: Store Results - uses: actions/upload-artifact@v3 - with: - name: benchmark-results - path: BenchmarkDotNet.Artifacts/** - ``` - - Track performance over time: - ```csharp - [Config(typeof(RegressionConfig))] - public class RegressionBenchmarks - { - private class RegressionConfig : ManualConfig - { - public RegressionConfig() - { - AddExporter(MarkdownExporter.GitHub); - AddDiagnoser(MemoryDiagnoser.Default); - AddColumn(StatisticColumn.Median); - AddColumn(RankColumn.Arabic); - } - } - } - ``` - -Best Practices: - - Avoid common pitfalls: - ```csharp - // Good: Proper benchmark isolation - [IterationSetup] - public void IterationSetup() - { - _data = new byte[1024]; // Fresh data for each iteration - } - - // Avoid: Shared state between iterations - private byte[] _sharedData = new byte[1024]; // Can lead to false results - ``` - - Use appropriate job configurations: - ```csharp - [SimpleJob(RuntimeMoniker.Net80, baseline: true)] - [SimpleJob(RuntimeMoniker.Net70)] - [SimpleJob(RuntimeMoniker.Net60)] - public class CrossVersionBenchmarks - { - [Benchmark] - public void BenchmarkMethod() { } - } - ``` - - Document environment requirements: - ```csharp - /* - ## Required Environment - - Windows 10+ or Linux with perf_event_paranoid <= 2 - - CPU: Modern x64 processor with AVX2 support - - RAM: 16GB minimum - - Minimal background processes - */ - ``` - -# End of Cursor Rules File \ No newline at end of file diff --git a/.cursor/rules/containers/README.md b/.cursor/rules/containers/README.md deleted file mode 100644 index f1f4c0d3..00000000 --- a/.cursor/rules/containers/README.md +++ /dev/null @@ -1,31 +0,0 @@ -# ๐Ÿณ Container & Infrastructure Rules - -This directory contains rules for container tooling and infrastructure preferences for the Teck.Cloud project. - -## ๐Ÿ“ฆ [Container Tooling](container-tooling.mdc) - -Use this when you're: -- Building container images -- Running containers locally -- Debugging containerized applications -- Writing Docker/Podman commands - -These rules ensure: -- Consistent use of Podman as the container runtime -- Proper container build commands -- Container orchestration preferences -- Multi-platform container support - -## ๐Ÿ—๏ธ [Dockerfile Best Practices](dockerfile-patterns.mdc) - -Use this when you're: -- Creating new Dockerfiles -- Optimizing existing Dockerfiles -- Setting up multi-stage builds -- Configuring build arguments - -These rules define: -- Multi-stage build patterns -- Build optimization techniques -- Wolverine codegen integration -- Runtime configuration diff --git a/.cursor/rules/containers/container-tooling.mdc b/.cursor/rules/containers/container-tooling.mdc deleted file mode 100644 index e4c4f119..00000000 --- a/.cursor/rules/containers/container-tooling.mdc +++ /dev/null @@ -1,89 +0,0 @@ -# Container Tooling Preferences - -## Container Runtime - -**Always use Podman instead of Docker for all container operations.** - -### Core Principles - -1. **Use Podman CLI**: All container commands should use `podman` instead of `docker` -2. **Podman Compose**: Use `podman-compose` for multi-container orchestration (if applicable) -3. **Drop-in Replacement**: Podman is a drop-in replacement for Docker with compatible CLI syntax - -### Command Examples - -#### Building Images -```bash -# โœ… Correct -podman build -f src/services/catalog/Catalog.Api/Dockerfile -t catalog-api:latest . - -# โŒ Incorrect -docker build -f src/services/catalog/Catalog.Api/Dockerfile -t catalog-api:latest . -``` - -#### Running Containers -```bash -# โœ… Correct -podman run -d -p 8080:8080 catalog-api:latest - -# โŒ Incorrect -docker run -d -p 8080:8080 catalog-api:latest -``` - -#### Managing Images -```bash -# โœ… Correct -podman images -podman rmi catalog-api:latest -podman pull mcr.microsoft.com/dotnet/sdk:10.0 - -# โŒ Incorrect -docker images -docker rmi catalog-api:latest -docker pull mcr.microsoft.com/dotnet/sdk:10.0 -``` - -#### Container Management -```bash -# โœ… Correct -podman ps -podman ps -a -podman stop -podman rm - -# โŒ Incorrect -docker ps -docker ps -a -docker stop -docker rm -``` - -### Platform-Specific Notes - -- **Windows**: Podman Desktop provides GUI management -- **WSL2**: Podman can run rootless containers -- **Multi-platform builds**: Use `podman buildx` for cross-platform images - -### GitHub Actions / CI/CD - -In CI/CD pipelines (GitHub Actions), Docker is used because: -- GitHub-hosted runners have Docker pre-installed -- Many GitHub Actions are designed for Docker -- Enterprise runners may not have Podman - -**Rule**: Use Docker in CI/CD YAML files, but use Podman for local development commands and documentation. - -### When Providing Examples - -When suggesting container commands to the user: -1. Always use `podman` for local development examples -2. Use `docker` only in GitHub Actions workflow files -3. If showing both, clearly label which is for local vs CI/CD -4. Default to Podman unless context clearly indicates CI/CD - -### Documentation - -When updating or creating documentation: -- Use Podman in README instructions -- Include note about Docker compatibility if relevant -- Reference Podman Desktop for Windows users diff --git a/.cursor/rules/containers/dockerfile-patterns.mdc b/.cursor/rules/containers/dockerfile-patterns.mdc deleted file mode 100644 index ef828d42..00000000 --- a/.cursor/rules/containers/dockerfile-patterns.mdc +++ /dev/null @@ -1,144 +0,0 @@ -# Dockerfile Patterns for Teck.Cloud - -## Project-Specific Dockerfile Standards - -### Multi-Stage Build Pattern - -All Dockerfiles in this project follow a consistent multi-stage pattern: - -1. **Build Stage**: Compile and build the application -2. **Publish Stage**: Create optimized output -3. **Final Stage**: Minimal runtime image - -### Wolverine Code Generation Integration - -Services using Wolverine require code generation during the Docker build process. - -#### Required Pattern - -```dockerfile -# Build with output directory specified -RUN if [ "$TARGETPLATFORM" = "linux/arm64" ]; then export RID=linux-musl-arm64; else export RID=linux-musl-x64; fi && \ - dotnet build "YourProject.csproj" -c $BUILD_CONFIGURATION -o /app/build -r $RID /p:PublishReadyToRun=true - -# Generate Wolverine codegen with connection strings -ENV ASPNETCORE_ENVIRONMENT=Development \ - ConnectionStrings__postgres-write="${POSTGRES_CONNECTION}" \ - ConnectionStrings__rabbitmq="${RABBITMQ_CONNECTION}" -RUN dotnet /app/build/YourProject.dll -- codegen write -``` - -#### โš ๏ธ Critical Rules - -1. **DO** use `-o /app/build` to specify explicit output directory in the build step - - This ensures the binary location is known and consistent - - The codegen step can then reference the exact path - -2. **DO** run the binary directly using `dotnet /app/build/YourProject.dll` - - Do NOT use `dotnet run` as it has issues with build output paths and configurations - - Direct execution is more reliable and explicit - -3. **DO** provide connection strings for codegen via build args and environment variables - - Pass `POSTGRES_CONNECTION` and `RABBITMQ_CONNECTION` as build args - - Set via environment variables before codegen step - -### Build Arguments - -Standard build arguments used across all Dockerfiles: - -```dockerfile -ARG DOTNET_VERSION=10.0 -ARG BUILD_CONFIGURATION=Release -ARG TARGETPLATFORM -ARG POSTGRES_CONNECTION="Host=localhost;Database=catalog;Username=postgres;Password=postgres" -ARG RABBITMQ_CONNECTION="amqp://guest:guest@localhost:5672/" -``` - -### Runtime Identifier (RID) Mapping - -Map Docker platform to .NET Runtime Identifier: - -```dockerfile -RUN if [ "$TARGETPLATFORM" = "linux/arm64" ]; then \ - export RID=linux-musl-arm64; \ - else \ - export RID=linux-musl-x64; \ - fi -``` - -### Base Images - -- **Build**: `mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION}-alpine` -- **Runtime (API)**: `mcr.microsoft.com/dotnet/aspnet:${DOTNET_VERSION}-alpine-composite` -- **Runtime (Console)**: `mcr.microsoft.com/dotnet/runtime:${DOTNET_VERSION}-alpine-composite` - -### Optimization Flags - -#### Build Stage -```dockerfile -/p:PublishReadyToRun=false \ -/p:DebugType=none \ -/p:DebugSymbols=false \ -/p:RunAnalyzersDuringBuild=false \ -/p:EnforceCodeStyleInBuild=false \ -/p:TreatWarningsAsErrors=false -``` - -#### Publish Stage -```dockerfile -/p:UseAppHost=true \ -/p:PublishReadyToRun=true \ -/p:PublishTrimmed=true \ -/p:PublishSingleFile=false \ -/p:TrimMode=partial \ -/p:ServerGarbageCollection=true \ -/p:ConcurrentGarbageCollection=true -``` - -### Security Best Practices - -1. **Non-root user**: - ```dockerfile - RUN addgroup -g 1000 appgroup && \ - adduser -u 1000 -G appgroup -s /bin/sh -D appuser - USER appuser - ``` - -2. **Minimal permissions**: - ```dockerfile - RUN chmod -R 550 /app && chmod 770 /app - ``` - -3. **Security updates**: - ```dockerfile - RUN apk upgrade --no-cache - ``` - -### Layer Caching Strategy - -1. **Copy project files first** for restore layer caching -2. **Copy source code after restore** -3. **Use BuildKit cache mounts** for NuGet packages: - ```dockerfile - RUN --mount=type=cache,target=/root/.nuget/packages - ``` - -### Working Directory Structure - -``` -/src -โ”œโ”€โ”€ Directory.Packages.props -โ”œโ”€โ”€ Directory.Build.props -โ”œโ”€โ”€ src/ -โ”‚ โ”œโ”€โ”€ services/ -โ”‚ โ”œโ”€โ”€ buildingblocks/ -โ”‚ โ””โ”€โ”€ aspire/ -``` - -### Projects Using Wolverine Codegen - -Current projects requiring codegen: -- `Catalog.Api` -- `Catalog.Migrator` - -When adding new services with Wolverine, follow the established pattern in these Dockerfiles. diff --git a/.cursor/rules/csharp/README.md b/.cursor/rules/csharp/README.md deleted file mode 100644 index 82c45143..00000000 --- a/.cursor/rules/csharp/README.md +++ /dev/null @@ -1,35 +0,0 @@ -# ๐Ÿ’ป C# Coding Guidelines - -This directory contains rules for writing clean, maintainable C# code with a focus on modern practices, functional patterns, and effective testing. - -## โœจ [Coding Style](coding-style.mdc) - -Use these rules when: -- ๐Ÿ“ Writing new C# code -- ๐Ÿ”„ Refactoring existing code -- ๐Ÿ‘€ Reviewing pull requests -- ๐Ÿ—๏ธ Setting up new C# projects - -These rules promote: -- ๐Ÿงฎ Functional programming patterns where appropriate -- ๐ŸŽฏ Simple and focused abstractions -- ๐Ÿ”’ Immutable data structures -- ๐Ÿ“– Clear and maintainable code -- โœ… Testable design -- ๐Ÿš€ Modern C# features and idioms - -## ๐Ÿงช [Testing Guidelines](testing.mdc) - -Use these rules when: -- โœ… Writing unit tests -- ๐Ÿ”„ Setting up integration tests -- ๐Ÿ› ๏ธ Configuring test projects -- ๐Ÿš€ Setting up CI/CD pipelines for testing - -These rules ensure: -- ๐Ÿ“‹ Consistent test structure with xUnit -- ๐Ÿ”’ Proper test isolation and data management -- ๐ŸŽฏ Effective use of test fixtures and theories -- ๐Ÿ“Š Comprehensive code coverage -- โšก Reliable CI/CD test execution -- ๐Ÿ”ฌ Performance and integration testing best practices \ No newline at end of file diff --git a/.cursor/rules/csharp/coding-style.mdc b/.cursor/rules/csharp/coding-style.mdc deleted file mode 100644 index f187513c..00000000 --- a/.cursor/rules/csharp/coding-style.mdc +++ /dev/null @@ -1,746 +0,0 @@ ---- -description: This file provides guidelines for writing clean, maintainable, and idiomatic C# code with a focus on functional patterns and proper abstraction. -globs: *.cs ---- -Role Definition: - - C# Language Expert - - Software Architect - - Code Quality Specialist - -General: - Description: > - C# code should be written to maximize readability, maintainability, and correctness - while minimizing complexity and coupling. Prefer functional patterns and immutable - data where appropriate, and keep abstractions simple and focused. - Requirements: - - Write clear, self-documenting code - - Keep abstractions simple and focused - - Minimize dependencies and coupling - - Use modern C# features appropriately - -Type Definitions: - - Prefer records for data types: - ```csharp - // Good: Immutable data type with value semantics - public sealed record CustomerDto(string Name, Email Email); - - // Avoid: Class with mutable properties - public class Customer - { - public string Name { get; set; } - public string Email { get; set; } - } - ``` - - Make classes sealed by default: - ```csharp - // Good: Sealed by default - public sealed class OrderProcessor - { - // Implementation - } - - // Only unsealed when inheritance is specifically designed for - public abstract class Repository - { - // Base implementation - } - ``` - - Use value objects to avoid primitive obsession: - ```csharp - // Good: Strong typing with value objects - public sealed record OrderId(Guid Value) - { - public static OrderId New() => new(Guid.NewGuid()); - public static OrderId From(string value) => new(Guid.Parse(value)); - } - - // Avoid: Primitive types for identifiers - public class Order - { - public Guid Id { get; set; } // Primitive obsession - } - ``` - -Functional Patterns: - - Use pattern matching effectively: - ```csharp - // Good: Clear pattern matching - public decimal CalculateDiscount(Customer customer) => - customer switch - { - { Tier: CustomerTier.Premium } => 0.2m, - { OrderCount: > 10 } => 0.1m, - _ => 0m - }; - - // Avoid: Nested if statements - public decimal CalculateDiscount(Customer customer) - { - if (customer.Tier == CustomerTier.Premium) - return 0.2m; - if (customer.OrderCount > 10) - return 0.1m; - return 0m; - } - ``` - - Prefer pure methods: - ```csharp - // Good: Pure function - public static decimal CalculateTotalPrice( - IEnumerable lines, - decimal taxRate) => - lines.Sum(line => line.Price * line.Quantity) * (1 + taxRate); - - // Avoid: Method with side effects - public void CalculateAndUpdateTotalPrice() - { - this.Total = this.Lines.Sum(l => l.Price * l.Quantity); - this.UpdateDatabase(); - } - ``` - -Code Organization: - - Separate state from behavior: - ```csharp - // Good: Behavior separate from state - public sealed record Order(OrderId Id, List Lines); - - public static class OrderOperations - { - public static decimal CalculateTotal(Order order) => - order.Lines.Sum(line => line.Price * line.Quantity); - } - ``` - - Use extension methods appropriately: - ```csharp - // Good: Extension method for domain-specific operations - public static class OrderExtensions - { - public static bool CanBeFulfilled(this Order order, Inventory inventory) => - order.Lines.All(line => inventory.HasStock(line.ProductId, line.Quantity)); - } - ``` - -Dependency Management: - - Minimize constructor injection: - ```csharp - // Good: Minimal dependencies - public sealed class OrderProcessor - { - private readonly IOrderRepository _repository; - - public OrderProcessor(IOrderRepository repository) - { - _repository = repository; - } - } - - // Avoid: Too many dependencies - public class OrderProcessor - { - public OrderProcessor( - IOrderRepository repository, - ILogger logger, - IEmailService emailService, - IMetrics metrics, - IValidator validator) - { - // Too many dependencies indicates possible design issues - } - } - ``` - - Prefer composition with interfaces: - ```csharp - // Good: Composition with interfaces - public sealed class EnhancedLogger : ILogger - { - private readonly ILogger _baseLogger; - private readonly IMetrics _metrics; - - public EnhancedLogger(ILogger baseLogger, IMetrics metrics) - { - _baseLogger = baseLogger; - _metrics = metrics; - } - } - ``` - -Code Clarity: - - Prefer range indexers over LINQ: - ```csharp - // Good: Using range indexers with clear comments - var lastItem = items[^1]; // ^1 means "1 from the end" - var firstThree = items[..3]; // ..3 means "take first 3 items" - var slice = items[2..5]; // take items from index 2 to 4 (5 exclusive) - - // Avoid: Using LINQ when range indexers are clearer - var lastItem = items.LastOrDefault(); - var firstThree = items.Take(3).ToList(); - var slice = items.Skip(2).Take(3).ToList(); - ``` - - Use meaningful names: - ```csharp - // Good: Clear intent - public async Task> ProcessOrderAsync( - OrderRequest request, - CancellationToken cancellationToken) - - // Avoid: Unclear abbreviations - public async Task> ProcAsync(ReqDto r, CancellationToken ct) - ``` - -Error Handling: - - Use Result types for expected failures: - ```csharp - // Good: Explicit error handling - public sealed record Result - { - public T? Value { get; } - public Error? Error { get; } - - private Result(T value) => Value = value; - private Result(Error error) => Error = error; - - public static Result Success(T value) => new(value); - public static Result Failure(Error error) => new(error); - } - ``` - - Prefer exceptions for exceptional cases: - ```csharp - // Good: Exception for truly exceptional case - public static OrderId From(string value) - { - if (!Guid.TryParse(value, out var guid)) - throw new ArgumentException("Invalid OrderId format", nameof(value)); - - return new OrderId(guid); - } - ``` - -Testing Considerations: - - Design for testability: - ```csharp - // Good: Easy to test pure functions - public static class PriceCalculator - { - public static decimal CalculateDiscount( - decimal price, - int quantity, - CustomerTier tier) => - // Pure calculation - } - - // Avoid: Hard to test due to hidden dependencies - public decimal CalculateDiscount() - { - var user = _userService.GetCurrentUser(); // Hidden dependency - var settings = _configService.GetSettings(); // Hidden dependency - // Calculation - } - ``` - -Immutable Collections: - - Use System.Collections.Immutable with records: - ```csharp - // Good: Immutable collections in records - public sealed record Order( - OrderId Id, - ImmutableList Lines, - ImmutableDictionary Metadata); - - // Avoid: Mutable collections in records - public record Order( - OrderId Id, - List Lines, // Can be modified after creation - Dictionary Metadata); - ``` - - Initialize immutable collections efficiently: - ```csharp - // Good: Using builder pattern - var builder = ImmutableList.CreateBuilder(); - foreach (var line in lines) - { - builder.Add(line); - } - return new Order(id, builder.ToImmutable()); - - // Also Good: Using collection initializer - return new Order( - id, - lines.ToImmutableList(), - metadata.ToImmutableDictionary()); - ``` - -// ... existing code ... - -Error Handling: - - Use Result types for expected failures: - ```csharp - // Good: Explicit error handling - public sealed record Result - { - public T? Value { get; } - public Error? Error { get; } - - private Result(T value) => Value = value; - private Result(Error error) => Error = error; - - public static Result Success(T value) => new(value); - public static Result Failure(Error error) => new(error); - } - ``` - - Prefer exceptions for exceptional cases: - ```csharp - // Good: Exception for truly exceptional case - public static OrderId From(string value) - { - if (!Guid.TryParse(value, out var guid)) - throw new ArgumentException("Invalid OrderId format", nameof(value)); - - return new OrderId(guid); - } - ``` - -Safe Operations: - - Use Try methods for safer operations: - ```csharp - // Good: Using TryGetValue for dictionary access - if (dictionary.TryGetValue(key, out var value)) - { - // Use value safely here - } - else - { - // Handle missing key case - } - - // Avoid: Direct indexing which can throw - var value = dictionary[key]; // Throws if key doesn't exist - - // Good: Using Uri.TryCreate for URL parsing - if (Uri.TryCreate(urlString, UriKind.Absolute, out var uri)) - { - // Use uri safely here - } - else - { - // Handle invalid URL case - } - - // Avoid: Direct Uri creation which can throw - var uri = new Uri(urlString); // Throws on invalid URL - - // Good: Using int.TryParse for number parsing - if (int.TryParse(input, out var number)) - { - // Use number safely here - } - else - { - // Handle invalid number case - } - - // Good: Combining Try methods with null coalescing - var value = dictionary.TryGetValue(key, out var result) - ? result - : defaultValue; - - // Good: Using Try methods in LINQ with pattern matching - var validNumbers = strings - .Select(s => (Success: int.TryParse(s, out var num), Value: num)) - .Where(x => x.Success) - .Select(x => x.Value); - ``` - - - Prefer Try methods over exception handling: - ```csharp - // Good: Using Try method - if (decimal.TryParse(priceString, out var price)) - { - // Process price - } - - // Avoid: Exception handling for expected cases - try - { - var price = decimal.Parse(priceString); - // Process price - } - catch (FormatException) - { - // Handle invalid format - } - ``` - -Asynchronous Programming: - - Avoid async void: - ```csharp - // Good: Async method returns Task - public async Task ProcessOrderAsync(Order order) - { - await _repository.SaveAsync(order); - } - - // Avoid: Async void can crash your application - public async void ProcessOrder(Order order) - { - await _repository.SaveAsync(order); - } - ``` - - Use Task.FromResult for pre-computed values: - ```csharp - // Good: Return pre-computed value - public Task GetDefaultQuantityAsync() => - Task.FromResult(1); - - // Better: Use ValueTask for zero allocations - public ValueTask GetDefaultQuantityAsync() => - new ValueTask(1); - - // Avoid: Unnecessary thread pool usage - public Task GetDefaultQuantityAsync() => - Task.Run(() => 1); - ``` - - Always flow CancellationToken: - ```csharp - // Good: Propagate cancellation - public async Task ProcessOrderAsync( - OrderRequest request, - CancellationToken cancellationToken) - { - var order = await _repository.GetAsync( - request.OrderId, - cancellationToken); - - await _processor.ProcessAsync( - order, - cancellationToken); - - return order; - } - ``` - - Prefer await over ContinueWith: - ```csharp - // Good: Using await - public async Task ProcessOrderAsync(OrderId id) - { - var order = await _repository.GetAsync(id); - await _validator.ValidateAsync(order); - return order; - } - - // Avoid: Using ContinueWith - public Task ProcessOrderAsync(OrderId id) - { - return _repository.GetAsync(id) - .ContinueWith(t => - { - var order = t.Result; // Can deadlock - return _validator.ValidateAsync(order); - }); - } - ``` - - Never use Task.Result or Task.Wait: - ```csharp - // Good: Async all the way - public async Task GetOrderAsync(OrderId id) - { - return await _repository.GetAsync(id); - } - - // Avoid: Blocking on async code - public Order GetOrder(OrderId id) - { - return _repository.GetAsync(id).Result; // Can deadlock - } - ``` - - Use TaskCompletionSource correctly: - ```csharp - // Good: Using RunContinuationsAsynchronously - private readonly TaskCompletionSource _tcs = - new(TaskCreationOptions.RunContinuationsAsynchronously); - - // Avoid: Default TaskCompletionSource can cause deadlocks - private readonly TaskCompletionSource _tcs = new(); - ``` - - Always dispose CancellationTokenSources: - ```csharp - // Good: Proper disposal of CancellationTokenSource - public async Task GetOrderWithTimeout(OrderId id) - { - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); - return await _repository.GetAsync(id, cts.Token); - } - ``` - - Prefer async/await over direct Task return: - ```csharp - // Good: Using async/await - public async Task ProcessOrderAsync(OrderRequest request) - { - await _validator.ValidateAsync(request); - var order = await _factory.CreateAsync(request); - return order; - } - - // Avoid: Manual task composition - public Task ProcessOrderAsync(OrderRequest request) - { - return _validator.ValidateAsync(request) - .ContinueWith(t => _factory.CreateAsync(request)) - .Unwrap(); - } - ``` - -Nullability: - - Enable nullable reference types: - ```xml - - enable - nullable - - ``` - - Mark nullable fields explicitly: - ```csharp - // Good: Explicit nullability - public class OrderProcessor - { - private readonly ILogger? _logger; - private string? _lastError; - - public OrderProcessor(ILogger? logger = null) - { - _logger = logger; - } - } - - // Avoid: Implicit nullability - public class OrderProcessor - { - private readonly ILogger _logger; // Warning: Could be null - private string _lastError; // Warning: Could be null - } - ``` - - Use null checks appropriately: - ```csharp - // Good: Proper null checking - public void ProcessOrder(Order? order) - { - if (order is null) - throw new ArgumentNullException(nameof(order)); - - _logger?.LogInformation("Processing order {Id}", order.Id); - } - - // Good: Using pattern matching for null checks - public decimal CalculateTotal(Order? order) => - order switch - { - null => throw new ArgumentNullException(nameof(order)), - { Lines: null } => throw new ArgumentException("Order lines cannot be null", nameof(order)), - _ => order.Lines.Sum(l => l.Total) - }; - ``` - - Use null-forgiving operator when appropriate: - ```csharp - public class OrderValidator - { - private readonly IValidator _validator; - - public OrderValidator(IValidator validator) - { - _validator = validator ?? throw new ArgumentNullException(nameof(validator)); - } - - public ValidationResult Validate(Order order) - { - // We know _validator can't be null due to constructor check - return _validator!.Validate(order); - } - } - ``` - - Use nullability attributes: - ```csharp - public class StringUtilities - { - // Output is non-null if input is non-null - [return: NotNullIfNotNull(nameof(input))] - public static string? ToUpperCase(string? input) => - input?.ToUpperInvariant(); - - // Method never returns null - [return: NotNull] - public static string EnsureNotNull(string? input) => - input ?? string.Empty; - - // Parameter must not be null when method returns true - public static bool TryParse(string? input, [NotNullWhen(true)] out string? result) - { - result = null; - if (string.IsNullOrEmpty(input)) - return false; - - result = input; - return true; - } - } - ``` - - Use init-only properties with non-null validation: - ```csharp - // Good: Non-null validation in constructor - public sealed record Order - { - public required OrderId Id { get; init; } - public required ImmutableList Lines { get; init; } - - public Order() - { - Id = null!; // Will be set by required property - Lines = null!; // Will be set by required property - } - - private Order(OrderId id, ImmutableList lines) - { - Id = id; - Lines = lines; - } - - public static Order Create(OrderId id, IEnumerable lines) => - new(id, lines.ToImmutableList()); - } - ``` - - Document nullability in interfaces: - ```csharp - public interface IOrderRepository - { - // Explicitly shows that null is a valid return value - Task FindByIdAsync(OrderId id, CancellationToken ct = default); - - // Method will never return null - [return: NotNull] - Task> GetAllAsync(CancellationToken ct = default); - - // Parameter cannot be null - Task SaveAsync([NotNull] Order order, CancellationToken ct = default); - } - ``` - -Symbol References: - - Always use nameof operator: - ```csharp - // Good: Using nameof for parameter names - public void ProcessOrder(Order order) - { - if (order is null) - throw new ArgumentNullException(nameof(order)); - } - - // Good: Using nameof for property names - public class Customer - { - private string _email; - - public string Email - { - get => _email; - set => _email = value ?? throw new ArgumentNullException(nameof(value)); - } - - public void UpdateEmail(string newEmail) - { - if (string.IsNullOrEmpty(newEmail)) - throw new ArgumentException("Email cannot be empty", nameof(newEmail)); - - Email = newEmail; - } - } - - // Good: Using nameof in attributes - public class OrderProcessor - { - [Required(ErrorMessage = "The {0} field is required")] - [Display(Name = nameof(OrderId))] - public string OrderId { get; init; } - - [MemberNotNull(nameof(_repository))] - private void InitializeRepository() - { - _repository = new OrderRepository(); - } - - [NotifyPropertyChangedFor(nameof(FullName))] - public string FirstName - { - get => _firstName; - set => SetProperty(ref _firstName, value); - } - } - - // Avoid: Hard-coded string references - public void ProcessOrder(Order order) - { - if (order is null) - throw new ArgumentNullException("order"); // Breaks refactoring - } - ``` - - Use nameof with exceptions: - ```csharp - public class OrderService - { - public async Task GetOrderAsync(OrderId id, CancellationToken ct) - { - var order = await _repository.FindAsync(id, ct); - - if (order is null) - throw new OrderNotFoundException( - $"Order with {nameof(id)} '{id}' not found"); - - if (!order.Lines.Any()) - throw new InvalidOperationException( - $"{nameof(order.Lines)} cannot be empty"); - - return order; - } - - public void ValidateOrder(Order order) - { - ArgumentNullException.ThrowIfNull(order, nameof(order)); - - if (order.Lines.Count == 0) - throw new ArgumentException( - "Order must have at least one line", - nameof(order)); - } - } - ``` - - Use nameof in logging: - ```csharp - public class OrderProcessor - { - private readonly ILogger _logger; - - public async Task ProcessAsync(Order order) - { - _logger.LogInformation( - "Starting {Method} for order {OrderId}", - nameof(ProcessAsync), - order.Id); - - try - { - await ProcessInternalAsync(order); - } - catch (Exception ex) - { - _logger.LogError( - ex, - "Error in {Method} for {Property} {Value}", - nameof(ProcessAsync), - nameof(order.Id), - order.Id); - throw; - } - } - } - ``` - -# End of Cursor Rules File \ No newline at end of file diff --git a/.cursor/rules/csharp/testing.mdc b/.cursor/rules/csharp/testing.mdc deleted file mode 100644 index d04cc763..00000000 --- a/.cursor/rules/csharp/testing.mdc +++ /dev/null @@ -1,668 +0,0 @@ ---- -description: This file provides guidelines for writing effective, maintainable tests using xUnit and related tools. -globs: *.cs, *.Tests.csproj ---- -Role Definition: - - Test Engineer - - Quality Assurance Specialist - - CI/CD Expert - -General: - Description: > - Tests should be reliable, maintainable, and provide meaningful coverage. - Use xUnit as the primary testing framework, with proper isolation and - clear patterns for test organization and execution. - Requirements: - - Use xUnit as the testing framework - - Ensure test isolation - - Follow consistent patterns - - Maintain high code coverage - - Configure proper CI/CD test execution - - Coverage Requirements: - - ALL code MUST have corresponding unit tests - - Minimum code coverage threshold: 80% (line, branch, and method coverage) - - Coverage is enforced for all projects EXCEPT src/buildingblocks/** - - Every new feature, bug fix, or code change MUST include tests - - Tests must verify both happy paths and error scenarios - - Public APIs must have comprehensive test coverage - - Critical business logic must have near 100% coverage - - Enforcement: - - CI/CD pipelines must fail if coverage drops below 80% - - Pull requests should include coverage reports - - Coverage exclusions are only allowed for: - - Code under src/buildingblocks/** (temporary exclusion) - - Auto-generated code - - Program.cs/Startup.cs boilerplate - - No coverage exclusion attributes should be used without justification - -Project Setup: - - Configure test projects: - ```xml - - - net10.0 - false - true - true - cobertura - line,branch,method - total - 80 - **/buildingblocks/**/*.cs - - - - - - - - - - - ``` - - - Required Coverage Configuration: - - All test projects MUST include coverage collection - - Minimum threshold of 80% for line, branch, and method coverage - - Buildingblocks are temporarily excluded from coverage requirements - - Use ExcludeByFile to exclude buildingblocks: `**/buildingblocks/**/*.cs` - - Coverage reports should be generated in cobertura format for CI/CD integration - -Test Class Structure: - - Use ITestOutputHelper for logging: - ```csharp - public class OrderProcessingTests - { - private readonly ITestOutputHelper _output; - - public OrderProcessingTests(ITestOutputHelper output) - { - _output = output; - } - - [Fact] - public async Task ProcessOrder_ValidOrder_Succeeds() - { - _output.WriteLine("Starting test with valid order"); - // Test implementation - } - } - ``` - - Use fixtures for shared state: - ```csharp - public class DatabaseFixture : IAsyncLifetime - { - public DbConnection Connection { get; private set; } - - public async Task InitializeAsync() - { - Connection = new SqlConnection("connection-string"); - await Connection.OpenAsync(); - } - - public async Task DisposeAsync() - { - await Connection.DisposeAsync(); - } - } - - public class OrderTests : IClassFixture - { - private readonly DatabaseFixture _fixture; - private readonly ITestOutputHelper _output; - - public OrderTests(DatabaseFixture fixture, ITestOutputHelper output) - { - _fixture = fixture; - _output = output; - } - } - ``` - -Test Methods: - - Prefer Theory over multiple Facts: - ```csharp - public class DiscountCalculatorTests - { - public static TheoryData DiscountTestData => - new() - { - { 100m, 1, 0m }, // No discount for single item - { 100m, 5, 5m }, // 5% for 5 items - { 100m, 10, 10m }, // 10% for 10 items - }; - - [Theory] - [MemberData(nameof(DiscountTestData))] - public void CalculateDiscount_ReturnsCorrectAmount( - decimal price, - int quantity, - decimal expectedDiscount) - { - // Arrange - var calculator = new DiscountCalculator(); - - // Act - var discount = calculator.Calculate(price, quantity); - - // Assert - Assert.Equal(expectedDiscount, discount); - } - } - ``` - - Follow Arrange-Act-Assert pattern: - ```csharp - [Fact] - public async Task ProcessOrder_ValidOrder_UpdatesInventory() - { - // Arrange - var order = new Order( - OrderId.New(), - new[] { new OrderLine("SKU123", 5) }); - var processor = new OrderProcessor(_mockRepository.Object); - - // Act - var result = await processor.ProcessAsync(order); - - // Assert - Assert.True(result.IsSuccess); - _mockRepository.Verify( - r => r.UpdateInventoryAsync( - It.IsAny(), - It.IsAny()), - Times.Once); - } - ``` - -Test Isolation: - - Use fresh data for each test: - ```csharp - public class OrderTests - { - private static Order CreateTestOrder() => - new(OrderId.New(), TestData.CreateOrderLines()); - - [Fact] - public async Task ProcessOrder_Success() - { - var order = CreateTestOrder(); - // Test implementation - } - } - ``` - - Clean up resources: - ```csharp - public class IntegrationTests : IAsyncDisposable - { - private readonly TestServer _server; - private readonly HttpClient _client; - - public IntegrationTests() - { - _server = new TestServer(CreateHostBuilder()); - _client = _server.CreateClient(); - } - - public async ValueTask DisposeAsync() - { - _client.Dispose(); - await _server.DisposeAsync(); - } - } - ``` - -CI/CD Configuration: - - Configure test runs with coverage enforcement: - ```yaml - - name: Test - run: | - dotnet test --configuration Release \ - --collect:"XPlat Code Coverage" \ - --logger:trx \ - --results-directory ./coverage \ - /p:CollectCoverage=true \ - /p:CoverletOutputFormat=cobertura \ - /p:Threshold=80 \ - /p:ThresholdType=line,branch,method \ - /p:ExcludeByFile="**/buildingblocks/**/*.cs" - - - name: Upload coverage - uses: actions/upload-artifact@v3 - with: - name: coverage-results - path: coverage/** - - - name: Check coverage threshold - run: | - # Fail the build if coverage is below 80% - # This is automatically enforced by coverlet threshold settings - ``` - - - Enable code coverage with buildingblocks exclusion: - ```xml - - true - cobertura - ./coverage/ - line,branch,method - total - 80 - **/buildingblocks/**/*.cs - total - - ``` - - - Coverage Reporting: - - Generate coverage reports on every test run - - Display coverage metrics in pull request comments - - Fail CI/CD pipeline if coverage falls below 80% - - Track coverage trends over time - - Exclude buildingblocks directory from coverage calculations - -Integration Testing: - - Always use TestContainers for infrastructure: - ```csharp - // Good: Using TestContainers for database testing - public class DatabaseTests : IAsyncLifetime - { - private readonly TestcontainersContainer _dbContainer; - - public DatabaseTests() - { - _dbContainer = new TestcontainersBuilder() - .WithImage("mcr.microsoft.com/mssql/server:2022-latest") - .WithEnvironment("ACCEPT_EULA", "Y") - .WithEnvironment("SA_PASSWORD", "Your_password123") - .WithPortBinding(1433, true) - .Build(); - } - - public async Task InitializeAsync() - { - await _dbContainer.StartAsync(); - } - - public async Task DisposeAsync() - { - await _dbContainer.DisposeAsync(); - } - } - - // Good: Using TestContainers for Redis testing - public class CacheTests : IAsyncLifetime - { - private readonly TestcontainersContainer _redisContainer; - private IConnectionMultiplexer _redis; - - public CacheTests() - { - _redisContainer = new TestcontainersBuilder() - .WithImage("redis:alpine") - .WithPortBinding(6379, true) - .Build(); - } - - public async Task InitializeAsync() - { - await _redisContainer.StartAsync(); - _redis = await ConnectionMultiplexer.ConnectAsync( - $"localhost:{_redisContainer.GetMappedPublicPort(6379)}"); - } - - public async Task DisposeAsync() - { - if (_redis is not null) - await _redis.DisposeAsync(); - await _redisContainer.DisposeAsync(); - } - } - - // Good: Using TestContainers for message queue testing - public class MessageQueueTests : IAsyncLifetime - { - private readonly TestcontainersContainer _rabbitContainer; - private IConnection _connection; - - public MessageQueueTests() - { - _rabbitContainer = new TestcontainersBuilder() - .WithImage("rabbitmq:management-alpine") - .WithPortBinding(5672, true) - .WithPortBinding(15672, true) - .Build(); - } - - public async Task InitializeAsync() - { - await _rabbitContainer.StartAsync(); - var factory = new ConnectionFactory - { - HostName = "localhost", - Port = _rabbitContainer.GetMappedPublicPort(5672) - }; - _connection = await factory.CreateConnectionAsync(); - } - - public async Task DisposeAsync() - { - await _connection.CloseAsync(); - await _rabbitContainer.DisposeAsync(); - } - } - ``` - - - Avoid mocking infrastructure: - ```csharp - // Avoid: Mocking database - public class DatabaseTests - { - private readonly Mock _mockDb = new(); - - [Fact] - public async Task Test_WithMockedDb() - { - _mockDb.Setup(db => db.QueryAsync()) - .ReturnsAsync(new[] { new Order() }); - // Test with mocked database - can miss real database behavior - } - } - - // Good: Real database in container - public class DatabaseTests : IAsyncLifetime - { - private readonly TestcontainersContainer _dbContainer; - private IDbConnection _db; - - // ... TestContainers setup as shown above ... - - [Fact] - public async Task Test_WithRealDb() - { - // Test with real database in container - var order = await _db.QuerySingleAsync( - "SELECT * FROM Orders WHERE Id = @Id", - new { Id = 1 }); - } - } - ``` - - - Use container networks for multi-container scenarios: - ```csharp - public class IntegrationTests : IAsyncLifetime - { - private readonly INetwork _network; - private readonly TestcontainersContainer _dbContainer; - private readonly TestcontainersContainer _redisContainer; - - public IntegrationTests() - { - _network = new TestcontainersNetwork(); - - _dbContainer = new TestcontainersBuilder() - .WithImage("postgres:latest") - .WithNetwork(_network) - .WithNetworkAliases("db") - .Build(); - - _redisContainer = new TestcontainersBuilder() - .WithImage("redis:alpine") - .WithNetwork(_network) - .WithNetworkAliases("redis") - .Build(); - } - - public async Task InitializeAsync() - { - await _network.CreateAsync(); - await Task.WhenAll( - _dbContainer.StartAsync(), - _redisContainer.StartAsync()); - } - - public async Task DisposeAsync() - { - await Task.WhenAll( - _dbContainer.DisposeAsync().AsTask(), - _redisContainer.DisposeAsync().AsTask()); - await _network.DisposeAsync(); - } - } - ``` - -Best Practices: - - Name tests clearly: - ```csharp - // Good: Clear test names - [Fact] - public async Task ProcessOrder_WhenInventoryAvailable_UpdatesStockAndReturnsSuccess() - - // Avoid: Unclear names - [Fact] - public async Task TestProcessOrder() - ``` - - Use meaningful assertions: - ```csharp - // Good: Clear assertions - Assert.Equal(expected, actual); - Assert.Contains(expectedItem, collection); - Assert.Throws(() => processor.Process(invalidOrder)); - - // Avoid: Multiple assertions without context - Assert.NotNull(result); - Assert.True(result.Success); - Assert.Equal(0, result.Errors.Count); - ``` - - Handle async operations properly: - ```csharp - // Good: Async test method - [Fact] - public async Task ProcessOrder_ValidOrder_Succeeds() - { - await using var processor = new OrderProcessor(); - var result = await processor.ProcessAsync(order); - Assert.True(result.IsSuccess); - } - - // Avoid: Sync over async - [Fact] - public void ProcessOrder_ValidOrder_Succeeds() - { - using var processor = new OrderProcessor(); - var result = processor.ProcessAsync(order).Result; // Can deadlock - Assert.True(result.IsSuccess); - } - ``` - -What Must Be Tested: - - All public methods and properties must have unit tests - - All business logic must be tested with multiple scenarios - - Error handling paths must be tested (exceptions, validation failures) - - Boundary conditions and edge cases must be tested - - Integration points between components must have integration tests - - API endpoints must have integration tests - - Domain models and value objects must be tested - - Service classes and their methods must be tested - - Repository implementations must be tested (using TestContainers) - - Event handlers and message processors must be tested - - Background services and workers must be tested - -What Can Be Excluded (Temporarily): - - Code under src/buildingblocks/** (temporary exclusion until buildingblocks are stabilized) - - Auto-generated code (marked with [GeneratedCode] attribute) - - Simple DTOs with no logic (properties-only classes) - - Program.cs/Startup.cs minimal boilerplate - - Migration files - -Coverage Enforcement Rules: - - Every pull request must maintain or improve coverage - - New code must have 100% coverage (no coverage debt) - - Existing code below 80% should be improved when modified - - Coverage reports must be reviewed during code review - - No [ExcludeFromCodeCoverage] attributes without team approval - -Assertions: - - Use xUnit's built-in assertions: - ```csharp - // Good: Using xUnit's built-in assertions - public class OrderTests - { - [Fact] - public void CalculateTotal_WithValidLines_ReturnsCorrectSum() - { - // Arrange - var order = new Order( - OrderId.New(), - new[] - { - new OrderLine("SKU1", 2, 10.0m), - new OrderLine("SKU2", 1, 20.0m) - }); - - // Act - var total = order.CalculateTotal(); - - // Assert - Assert.Equal(40.0m, total); - } - - [Fact] - public void Order_WithInvalidLines_ThrowsException() - { - // Arrange - var invalidLines = new OrderLine[] { }; - - // Act & Assert - var ex = Assert.Throws(() => - new Order(OrderId.New(), invalidLines)); - Assert.Equal("Order must have at least one line", ex.Message); - } - - [Fact] - public void Order_WithValidData_HasExpectedProperties() - { - // Arrange - var id = OrderId.New(); - var lines = new[] { new OrderLine("SKU1", 1, 10.0m) }; - - // Act - var order = new Order(id, lines); - - // Assert - Assert.NotNull(order); - Assert.Equal(id, order.Id); - Assert.Single(order.Lines); - Assert.Collection(order.Lines, - line => - { - Assert.Equal("SKU1", line.Sku); - Assert.Equal(1, line.Quantity); - Assert.Equal(10.0m, line.Price); - }); - } - } - ``` - - - Avoid third-party assertion libraries: - ```csharp - // Avoid: Using FluentAssertions or similar libraries - public class OrderTests - { - [Fact] - public void CalculateTotal_WithValidLines_ReturnsCorrectSum() - { - var order = new Order( - OrderId.New(), - new[] - { - new OrderLine("SKU1", 2, 10.0m), - new OrderLine("SKU2", 1, 20.0m) - }); - - // Avoid: Using FluentAssertions - order.CalculateTotal().Should().Be(40.0m); - order.Lines.Should().HaveCount(2); - order.Should().NotBeNull(); - } - } - ``` - - - Use proper assertion types: - ```csharp - public class CustomerTests - { - [Fact] - public void Customer_WithValidEmail_IsCreated() - { - // Boolean assertions - Assert.True(customer.IsActive); - Assert.False(customer.IsDeleted); - - // Equality assertions - Assert.Equal("john@example.com", customer.Email); - Assert.NotEqual(Guid.Empty, customer.Id); - - // Collection assertions - Assert.Empty(customer.Orders); - Assert.Contains("Admin", customer.Roles); - Assert.DoesNotContain("Guest", customer.Roles); - Assert.All(customer.Orders, o => Assert.NotNull(o.Id)); - - // Type assertions - Assert.IsType(customer); - Assert.IsAssignableFrom(customer); - - // String assertions - Assert.StartsWith("CUST", customer.Reference); - Assert.Contains("Premium", customer.Description); - Assert.Matches("^CUST\\d{6}$", customer.Reference); - - // Range assertions - Assert.InRange(customer.Age, 18, 100); - - // Reference assertions - Assert.Same(expectedCustomer, actualCustomer); - Assert.NotSame(differentCustomer, actualCustomer); - } - } - ``` - - - Use Assert.Collection for complex collections: - ```csharp - [Fact] - public void ProcessOrder_CreatesExpectedEvents() - { - // Arrange - var processor = new OrderProcessor(); - var order = CreateTestOrder(); - - // Act - var events = processor.Process(order); - - // Assert - Assert.Collection(events, - evt => - { - Assert.IsType(evt); - var received = Assert.IsType(evt); - Assert.Equal(order.Id, received.OrderId); - }, - evt => - { - Assert.IsType(evt); - var reserved = Assert.IsType(evt); - Assert.Equal(order.Id, reserved.OrderId); - Assert.NotEmpty(reserved.ReservedItems); - }, - evt => - { - Assert.IsType(evt); - var confirmed = Assert.IsType(evt); - Assert.Equal(order.Id, confirmed.OrderId); - Assert.True(confirmed.IsSuccess); - }); - } - ``` - -# End of Cursor Rules File \ No newline at end of file diff --git a/.cursor/rules/dotnet-sdk/README.md b/.cursor/rules/dotnet-sdk/README.md deleted file mode 100644 index 9867492f..00000000 --- a/.cursor/rules/dotnet-sdk/README.md +++ /dev/null @@ -1,31 +0,0 @@ -# .NET SDK Management Rules - -This directory contains rules for managing .NET solutions with consistent SDK versions, build configurations, and dependencies. - -## [Solution Management](solution-management.mdc) - -Use this when you're: -- Setting up a new .NET solution -- Implementing consistent build properties across projects -- Managing NuGet package versions centrally -- Need to ensure consistent SDK versions across development environments - -These rules help establish: -- SDK version control via `global.json` -- Shared metadata via `Directory.Build.props` -- Centralized package management via `Directory.Packages.props` -- Secure package sources via `nuget.config` - -## [Dependency Management](dependency-management.mdc) - -Use this when you're: -- Installing or updating NuGet packages -- Evaluating package security and licenses -- Setting up automated dependency management -- Implementing dependency scanning in CI/CD - -These rules help ensure: -- Safe and consistent package management via CLI -- License compliance and security scanning -- Version management best practices -- Automated vulnerability detection \ No newline at end of file diff --git a/.cursor/rules/dotnet-sdk/dependency-management.mdc b/.cursor/rules/dotnet-sdk/dependency-management.mdc deleted file mode 100644 index e9d477b8..00000000 --- a/.cursor/rules/dotnet-sdk/dependency-management.mdc +++ /dev/null @@ -1,191 +0,0 @@ ---- -description: This file provides guidelines for safely managing NuGet package dependencies in .NET projects, focusing on security, licensing, and maintainability. -globs: Directory.Package.props, *.csproj, *.fsproj, nuget.config, Directory.Build.props ---- -# Cursor Rules File: Best Practices for .NET Dependency Management -Role Definition: - - Package Management Expert - - Security Analyst - - License Compliance Specialist - -General: - Description: > - .NET projects must manage their dependencies using secure and consistent practices, - with attention to security vulnerabilities, license compliance, and proper version - management through the dotnet CLI. - Requirements: - - Use dotnet CLI for package management - - Verify package licenses before installation - - Monitor for security vulnerabilities - - Maintain consistent versioning strategies - -Package Installation: - - Always use dotnet CLI commands: - - Preferred: `dotnet add package [-v ]` - - Avoid manual .csproj/.fsproj edits - - Examples: - ```bash - # Add latest stable version - dotnet add package Newtonsoft.Json - - # Add specific version - dotnet add package Serilog -v 3.1.1 - - # Add package to specific project - dotnet add MyProject/MyProject.csproj package Microsoft.Extensions.Logging - ``` - - Before installation: - - Check package license compatibility - - Review package download statistics - - Verify package authenticity (signed packages) - - Consider package maintenance status - -Check and Upgrade NuGet Packages: - - To check for outdated packages and upgrade them in your .NET solution: - - 1. **Check for outdated packages** - ```bash - dotnet list package --outdated - ``` - This command will show: - - Currently used version ("Resolved") - - Latest available version ("Latest") - - The version you're requesting ("Requested") - - 2. **Update package versions** - - If using central package management (Directory.Packages.props): - - Update the versions in `Directory.Packages.props`: - ```xml - - ``` - - - If using traditional package references: - ```bash - dotnet add package PackageName --version NewVersion - ``` - - 3. **Restore and verify** - ```bash - dotnet restore - dotnet build - dotnet test - ``` - - Example output of `dotnet list package --outdated`: - ``` - Project `MyProject` has the following updates to its packages - [netstandard2.1]: - Top-level Package Requested Resolved Latest - > Akka.Streams 1.5.13 1.4.45 1.5.38 - ``` - - Note: After updating packages, always: - 1. Check for breaking changes in the package's release notes - 2. Build the solution to catch any compatibility issues - 3. Run tests to ensure everything still works - 4. Review and update any code that needs to be modified for the new versions - -Security Considerations: - - Enable security scanning: - - Run `dotnet restore --use-lock-file` to generate lock file - - Use `dotnet list package --vulnerable` to check for known vulnerabilities - - Configure GitHub Dependabot or similar tools - - Monitor security: - - Subscribe to security advisories - - Regular vulnerability scanning in CI/CD - - Automated security updates for patch versions - - Example workflow: - ```bash - # Generate lock file - dotnet restore --use-lock-file - - # Check for vulnerabilities - dotnet list package --vulnerable - - # Update vulnerable package - dotnet add package VulnerablePackage -v SecureVersion - - # Regenerate lock file - dotnet restore --force-evaluate - ``` - -License Compliance: - - Verify licenses before adding dependencies: - - Check license compatibility with your project - - Document license requirements - - Maintain license inventory - - Common OSS-friendly licenses: - - MIT - - Apache 2.0 - - BSD - - MS-PL - - Warning signs: - - No license specified - - Restrictive licenses (GPL for commercial software) - - License changes between versions - -Version Management: - - Use semantic versioning: - - Lock major versions for stability - - Allow minor updates for features - - Auto-update patches for security - - Version constraints: - - Avoid floating versions (*) - - Use minimum version constraints when needed - - Document version decisions - - Example in Directory.Packages.props: - ```xml - - - - - - - - - - - - - ``` - -Maintenance: - - Regular housekeeping: - - Remove unused packages - - Consolidate duplicate dependencies - - Update documentation - - Automation: - - Implement automated vulnerability scanning - - Set up dependency update workflows - - Configure license compliance checks - - Commands for maintenance: - ```bash - # List all packages - dotnet list package - - # Check for unused dependencies - dotnet remove package UnusedPackage - - # Clean solution - dotnet clean - dotnet restore --force - ``` - -Integration with CI/CD: - - Implement checks: - - Vulnerability scanning - - License compliance - - Package restore verification - - Example GitHub Actions workflow: - ```yaml - - name: Security scan - run: | - dotnet restore --use-lock-file - dotnet list package --vulnerable - - - name: License check - run: dotnet-project-licenses - ``` - -# End of Cursor Rules File \ No newline at end of file diff --git a/.cursor/rules/dotnet-sdk/solution-management.mdc b/.cursor/rules/dotnet-sdk/solution-management.mdc deleted file mode 100644 index 3e8a1d83..00000000 --- a/.cursor/rules/dotnet-sdk/solution-management.mdc +++ /dev/null @@ -1,172 +0,0 @@ ---- -description: This file provides guidelines for maintaining .NET solutions with consistent SDK versions, shared metadata, and centralized package management. -globs: *.sln, global.json, Directory.Build.props, Directory.Package.props, *.csproj, *.fsproj ---- -# Cursor Rules File: Best Practices for .NET Solution Management - -Role Definition: - - .NET Solution Architect - - Build System Expert - - Package Management Specialist - -General: - Description: > - .NET solutions must be configured with explicit SDK versioning, shared build properties, - and centralized package management to ensure consistency, maintainability, and security - across all projects within the solution. - Requirements: - - Maintain a global.json for SDK version control - - Use Directory.Build.props for shared metadata - - Implement centralized package management - - Configure secure and reliable package sources - -SDK Version Management: - - Maintain a global.json file in the solution root: - - Specify exact SDK version to ensure consistent builds - - Include rollForward policy for patch version flexibility - - Example: - ```json - { - "sdk": { - "version": "8.0.100", - "rollForward": "patch" - } - } - ``` - - Update SDK versions through controlled processes: - - Test new SDK versions in development/CI before updating - - Document SDK version changes in source control - - Consider implications for CI/CD pipelines - -Shared Build Properties: - - Implement Directory.Build.props in solution root: - - Define common metadata: - - Company/Author information - - Copyright details - - Project URL - - License information - - Version prefix/suffix strategy - - Example structure: - ```xml - - - Your Company - Your Company - ยฉ $([System.DateTime]::Now.Year) Your Company - MIT - https://github.com/your/project - 1.0.0 - - - ``` - - Consider environment-specific overrides: - - Use Directory.Build.targets for overrides - - Support CI/CD pipeline customization - -Package Management: - - Enable centralized package management: - - Create Directory.Packages.props: - - Define package versions once - - Enforce consistent versions across projects - - Example: - ```xml - - - true - - - - - - ``` - - Configure nuget.config: - - Enable package source mapping - - Define trusted package sources - - Example: - ```xml - - - - - - - - - - - - - ``` - -Maintenance: - - Regular auditing: - - Review SDK versions for security updates - - Validate package versions for vulnerabilities - - Update shared metadata as needed - - Version control: - - Commit all configuration files - - Document changes in commit messages - - Consider using git hooks for validation - -Compilation: - - Use dotnet CLI for builds: - - Prefer `dotnet build` over IDE builds for consistency - - Use `dotnet build -c Release` for release builds - - Enable deterministic builds with `/p:ContinuousIntegrationBuild=true` - - Enforce code quality: - - Enable `TreatWarningsAsErrors` in Directory.Build.props: - ```xml - - true - - CS1591 - - ``` - - Address warnings properly: - - Fix the underlying issue rather than suppressing - - Document any necessary warning suppressions - - Use `#pragma warning disable` sparingly and only with comments - - Build configuration: - - Use conditional compilation symbols purposefully - - Define debug/release-specific behavior clearly - - Example: - ```xml - - TRACE - $(DefineConstants);DEBUG - - ``` - - Performance: - - Enable incremental builds by default - - Use `dotnet build --no-incremental` only when needed - - Consider using Fast Up-to-Date Check: - ```xml - - false - - ``` - - Build output: - - Set consistent output paths - - Configure deterministic output: - ```xml - - true - true - - ``` - - Error handling: - - Log build errors comprehensively - - Use MSBuild binary log for detailed diagnostics: - ```bash - dotnet build -bl:build.binlog - ``` - - Configure error reporting in CI/CD: - ```yaml - - name: Build - run: dotnet build --configuration Release /p:ContinuousIntegrationBuild=true - env: - DOTNET_CLI_TELEMETRY_OPTOUT: 1 - DOTNET_NOLOGO: 1 - ``` - -# End of Cursor Rules File \ No newline at end of file diff --git a/.cursor/rules/dotnet-tools/README.md b/.cursor/rules/dotnet-tools/README.md deleted file mode 100644 index af56eba2..00000000 --- a/.cursor/rules/dotnet-tools/README.md +++ /dev/null @@ -1,17 +0,0 @@ -# .NET Tool Rules - -This directory contains two rule sets for working with .NET tools: - -## [Publishing .NET Tools](publishing-dotnettool.mdc) - -Use this when you're: -- Creating and publishing your own .NET tool -- Packaging an existing application as a .NET tool -- Preparing to publish a tool to NuGet.org - -## [Consuming .NET Tools](consuming-dotnettool.mdc) - -Use this when you're: -- Adding .NET tools to your project -- Managing tool versions across environments -- Setting up tool dependencies in CI/CD \ No newline at end of file diff --git a/.cursor/rules/dotnet-tools/consuming-dotnettool.mdc b/.cursor/rules/dotnet-tools/consuming-dotnettool.mdc deleted file mode 100644 index 6e3e7729..00000000 --- a/.cursor/rules/dotnet-tools/consuming-dotnettool.mdc +++ /dev/null @@ -1,70 +0,0 @@ ---- -description: This tool is used for helping manage and organize the installation of `dotnet tool` instances that are used for .NET projects. -globs: ---- -# Cursor Rules File: Best Practices for Consuming dotnet Tool Instances -# This file provides guidelines for projects that depend on dotnet tools. It ensures that: -# - A tool manifest is maintained for local and CI/CD consistency. -# - Tool versions are explicitly defined. -# - Proper installation, updating, and documentation practices are followed. - -General: - Description: > - Projects consuming dotnet tool instances must manage their tool dependencies through a tool manifest. - This approach promotes reproducible builds and clear dependency management, ensuring that the correct - tool versions are used consistently across different environments. - Requirements: - - Maintain a tool manifest file (typically located at `.config/dotnet-tools.json`) in the project root. - - Use explicit versioning for all dotnet tools to prevent unexpected updates. - - Integrate tool management into the CI/CD pipeline for automated restoration. - -Preparation: - - If a tool manifest does not exist, create one by running: - - `dotnet new tool-manifest` - - Update the tool manifest with the necessary tools by executing: - - `dotnet tool install --version ` - - Ensure the manifest file (`.config/dotnet-tools.json`) is checked into version control for consistency. - -Tool Management: - - Installation: - - Use `dotnet tool install` with an explicit version to add a tool to the manifest. - - Verify that the installed tool versions match the ones specified in the manifest. - - For tools that support both global and local installation, prefer local installation via manifest. - - Updating: - - To update a tool, use `dotnet tool update --version `. - - After updating, validate that the manifest reflects the new version. - - Restoration: - - Ensure that `dotnet tool restore` is executed as part of local setup and CI/CD processes to install all tools defined in the manifest. - - Verification: - - Periodically review the manifest to confirm that all tool versions are current and that deprecated or unused tools are removed. - -Integration & CI/CD: - - Include the following steps in your CI/CD pipeline: - - **Restore Tools:** Run `dotnet tool restore` prior to build or test phases. - - **Version Check:** Validate that the manifest has not drifted from the expected tool versions. - - **Cache Tools:** Consider caching the restored tools between pipeline runs to improve build times. - - Document the usage of these tools in your project README or contributing guidelines for clarity. - -Documentation & Maintenance: - - Document in your project README: - - The purpose of each dotnet tool listed in the manifest. - - Instructions for installing, updating, or troubleshooting the tools. - - Regularly audit the manifest to: - - Remove outdated or unused tools. - - Update tools when new versions become available and are verified for compatibility. - - Maintain clear commit messages when changes are made to the tool manifest. - -Security & Compliance: - - Ensure that all tools come from trusted sources and have proper licensing. - - Avoid using wildcard or range versioning to mitigate the risk of unintentional upgrades. - - Document any security advisories or considerations related to the consumed tools. - -Automation: - - Automate tool restoration by incorporating `dotnet tool restore` into your build scripts. - - Consider using scripts or CI/CD tasks to: - - Check for available updates to the tools. - - Validate the integrity and security compliance of the manifest file. - - Monitor for security advisories related to installed tools. - - Automatically create PRs for tool updates after successful testing. - -# End of Cursor Rules File \ No newline at end of file diff --git a/.cursor/rules/dotnet-tools/publishing-dotnettool.mdc b/.cursor/rules/dotnet-tools/publishing-dotnettool.mdc deleted file mode 100644 index 9deb7f73..00000000 --- a/.cursor/rules/dotnet-tools/publishing-dotnettool.mdc +++ /dev/null @@ -1,44 +0,0 @@ ---- -description: This file serves as a guideline for the Cursor AI Agent to ensure the proper creation, packaging, and publishing of a `dotnet tool`. Follow these rules to maintain consistency, quality, and security across published tools. -globs: ---- -# Cursor Rules File: Best Practices for Publishing a dotnet Tool -# This file serves as a guideline for the Cursor AI Agent to ensure the proper -# creation, packaging, and publishing of a dotnet tool. Follow these rules to -# maintain consistency, quality, and security across published tools. - - -Role Definition: - - .NET Expert - - OSS author - - Aware that users running on multiple versions of .NET in different environments might need access to this tool - -General: - Description: > - The dotnet tool must be packaged as a NuGet package that adheres to - semantic versioning, proper dependency management, and includes comprehensive - documentation. This file outlines the steps and checks that need to be performed. - Requirements: - - Use a project file (.csproj) with the property true - - Follow semantic versioning (MAJOR.MINOR.PATCH) - - Ensure the tool is documented with a README and inline help support - -Preparation: - - Validate that the project file includes: - - true - - Proper versioning and package metadata (e.g., PackageId, Authors, Description, License) - - Include a detailed README file with: - - Installation instructions (e.g., `dotnet tool install -g `) - - Usage examples and command options - - Troubleshooting and FAQ sections - - Confirm that all dependencies are explicitly declared - - Always target the most recent long-term release of .NET (currently .NET 8) unless the project is explicitly set to multi-target or targets and older version of the runtime - - Always include a `LatestMajor` so the tool can automatically be used with newer runtimes without a new version needing to be released - -Packaging: - - Use the `dotnet pack` command to generate the NuGet package: - - Ensure that all necessary files (binaries, assets, configuration files) are included - - Verify that the output .nupkg file contains the expected metadata and assets - - Run tests to verify that the tool functions correctly in a local install scenario - -# End of Cursor Rules File \ No newline at end of file diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..29253562 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,21 @@ +# Teck.Cloud Copilot Instructions + +This repository uses the rule set under `docs/ai/rules` as the canonical AI coding guidance. + +## Instruction Precedence + +1. Repository source of truth and existing code patterns +2. `Directory.Build.props`, `Directory.Packages.props`, `global.json`, `nuget.config`, `stylecop.json`, `.editorconfig` +3. `docs/ai/rules/README.md` and linked rule files +4. Task-specific user instructions + +## Core Expectations + +- Follow clean architecture boundaries used in `src/services/*`. +- Prefer minimal, safe changes; do not refactor unrelated code. +- Keep tests aligned with project conventions in `tests/` and CI workflows. +- Use .NET 10 conventions in this repository unless a project explicitly requires otherwise. + +## Canonical Rule Set + +- `docs/ai/rules/README.md` diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..b2c40eb5 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + "chat.tools.terminal.autoApprove": { + "/^dotnet test tests/unit/SharedKernel\\.Persistence\\.UnitTests/SharedKernel\\.Persistence\\.UnitTests\\.csproj --nologo --verbosity minimal$/": { + "approve": true, + "matchCommandLine": true + } + } +} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..fd072cc8 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,14 @@ +# Teck.Cloud Agent Instructions + +Use `docs/ai/rules` as the canonical repository rule set for AI-assisted coding. + +## Required Behavior + +- Respect architecture and dependency direction by layer. +- Keep edits focused and production-safe. +- Prefer existing abstractions over introducing new patterns. +- Follow repository build/test/tooling constraints. + +## Rule Index + +- `docs/ai/rules/README.md` diff --git a/Directory.Packages.props b/Directory.Packages.props index afe20520..3bcad8f5 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -124,6 +124,7 @@ + @@ -255,14 +256,14 @@ - - - - - - - - + + + + + + + + diff --git a/IMPLEMENTATION_COMPLETE.md b/IMPLEMENTATION_COMPLETE.md deleted file mode 100644 index f24bc50b..00000000 --- a/IMPLEMENTATION_COMPLETE.md +++ /dev/null @@ -1,498 +0,0 @@ -# SLSA L3 & EU CRA Implementation Complete โœ… - -## ๐Ÿ“ฆ What Was Implemented - -### Files Created (4 new files) - -1. **`.github/workflows/reusable-build-sign-sbom.yml`** (660 lines) - - SLSA L3 compliant reusable workflow - - Multi-arch builds (amd64, arm64) - - Dual SBOM generation (SPDX + CycloneDX) - - VEX document generation with Grype - - Comprehensive Cosign signing (images, SBOMs, VEX) - - Build provenance attestations - - GitHub release artifact storage - -2. **`scripts/verify-signatures.sh`** (400 lines) - - Automated verification script - - Checks image signatures, SBOM signatures, VEX signatures - - Validates attestations (SBOM + provenance) - - Color-coded output with detailed statistics - - Usage: `./scripts/verify-signatures.sh 1.0.0 [service]` - -3. **`SECURITY.md`** (400 lines) - - Complete security documentation - - SLSA L3 compliance details - - EU CRA compliance checklist - - Verification instructions - - Incident response procedures - - Harbor configuration guide - -### Files Modified (4 existing files) - -1. **`.github/dependabot.yml`** - - Added GitHub Actions ecosystem - - Weekly SHA pin updates (Mondays 3am) - - Auto-labeled PRs: `dependencies`, `github-actions`, `security` - -2. **`.github/workflows/docker-publish.yaml`** - - Refactored from 440 โ†’ 130 lines (70% reduction!) - - Now calls reusable workflow - - All actions pinned by SHA - - Simplified service discovery logic - -3. **`.github/workflows/docker-publish-auth.yaml`** - - Refactored from 336 โ†’ 68 lines (80% reduction!) - - Uses reusable workflow - - Conditional execution (only if auth Dockerfile exists) - -4. **`.github/workflows/release-services.yaml`** - - All actions pinned by SHA - - Updated: checkout, create-github-app-token, setup-auto - ---- - -## ๐ŸŽฏ Compliance Achieved - -### โœ… SLSA Build Level 3 - -- [x] **Isolated build** - Reusable workflow pattern -- [x] **All dependencies pinned by SHA** - 30+ actions pinned with digests -- [x] **Build provenance attestations** - SLSA v1 format -- [x] **Non-falsifiable provenance** - GitHub OIDC with Sigstore -- [x] **Automated verification** - Dependabot updates SHAs weekly - -### โœ… EU Cyber Resilience Act (CRA) - -- [x] **Machine-readable SBOMs** - SPDX + CycloneDX formats -- [x] **10-year retention** - GitHub Releases (permanent storage) -- [x] **Cryptographic signatures** - Cosign keyless signing -- [x] **Vulnerability tracking** - Trivy + Grype scans -- [x] **Exploitability status** - OpenVEX documents - -### โœ… Harbor Integration - -- [x] **Signature verification** - Enable Cosign checkbox in Harbor UI -- [x] **Auto-SBOM generation** - Harbor generates SPDX SBOMs (UI convenience) -- [x] **Dual SBOM strategy** - Pipeline (compliance) + Harbor (operations) -- [x] **Pull protection** - Only signed images allowed - ---- - -## ๐Ÿš€ Next Steps: Testing & Deployment - -### Step 1: Harbor Configuration (5 minutes) - -**Login to Harbor and configure the `teck-lab` project:** - -1. Navigate to: `https://harbor.tecklab.dk` -2. Login with your credentials -3. Go to: **Projects** โ†’ **teck-lab** โ†’ **Configuration** tab - -**Enable Deployment Security:** -``` -Deployment Security: - โ˜‘ Cosign โ† CHECK THIS BOX - โ˜ Notation (leave unchecked) - -Click: Save -``` - -**Enable Auto-SBOM Generation:** -``` -Vulnerability Scanning: - โ˜‘ Automatically scan images on push โ† CHECK THIS BOX - โ˜‘ SBOM generation โ† CHECK THIS BOX - โ˜ Prevent vulnerable images from running (optional) - -Click: Save -``` - -**Result:** Harbor will now: -- โœ… Reject unsigned images on pull -- โœ… Auto-generate SBOMs for all pushed images -- โœ… Show "Signed" badge in UI for verified images - ---- - -### Step 2: Create Test Release (10 minutes) - -**Create a test pre-release to validate the implementation:** - -```bash -# Create and push test tag -git tag v0.99.0-test -git push origin v0.99.0-test - -# Create GitHub pre-release -gh release create v0.99.0-test \ - --prerelease \ - --title "Test Release - SLSA L3 Validation" \ - --notes "Testing SLSA L3 + EU CRA compliance implementation" -``` - -**Monitor the workflow:** -1. Go to: `https://github.com/Teck/Teck.Cloud/actions` -2. Watch: "Build and Publish Docker Images" workflow -3. Expected duration: ~15-20 minutes (multi-arch builds + signing + SBOMs + VEX) - ---- - -### Step 3: Verify Build Artifacts (15 minutes) - -**Once the workflow completes, verify all artifacts:** - -#### 3.1 Check GitHub Release Assets - -```bash -gh release view v0.99.0-test - -# Expected assets (per service, e.g., catalog): -# - sbom-catalog.spdx.json -# - sbom-catalog.spdx.json.sig -# - sbom-catalog.spdx.json.cert -# - sbom-catalog.spdx.json.bundle -# - sbom-catalog.cyclonedx.json -# - sbom-catalog.cyclonedx.json.sig -# - sbom-catalog.cyclonedx.json.cert -# - sbom-catalog.cyclonedx.json.bundle -# - vex-catalog.openvex.json -# - vex-catalog.openvex.json.sig -# - vex-catalog.openvex.json.cert -# - vex-catalog.openvex.json.bundle -# - trivy-scan-catalog.json -``` - -#### 3.2 Run Automated Verification Script - -```bash -# Verify all services -./scripts/verify-signatures.sh 0.99.0-test - -# Or verify specific service -./scripts/verify-signatures.sh 0.99.0-test catalog -``` - -**Expected output:** -``` -โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ” -๐Ÿ” Verifying Service: catalog v0.99.0-test -โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ” - -โœ“ Image signature verified successfully -โœ“ SPDX SBOM signature verified successfully -โœ“ CycloneDX SBOM signature verified successfully -โœ“ VEX signature verified successfully -โœ“ SBOM attestation verified -โœ“ Build provenance attestation verified (SLSA L3) - -โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ” -โœ… ALL VERIFICATIONS PASSED for catalog -โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ” -``` - -#### 3.3 Verify Image Signature Manually - -```bash -# Install cosign if not already installed -# macOS: brew install cosign -# Linux/Windows: https://github.com/sigstore/cosign/releases - -# Verify image signature -cosign verify \ - --certificate-identity-regexp="^https://github.com/Teck/Teck.Cloud/.*" \ - --certificate-oidc-issuer=https://token.actions.githubusercontent.com \ - harbor.tecklab.dk/teck-lab/catalog:0.99.0-test -``` - -#### 3.4 Verify Attestations - -```bash -# Verify SBOM attestation -gh attestation verify \ - oci://harbor.tecklab.dk/teck-lab/catalog:0.99.0-test \ - --owner Teck - -# Verify build provenance (SLSA) -gh attestation verify \ - oci://harbor.tecklab.dk/teck-lab/catalog:0.99.0-test \ - --predicate-type https://slsa.dev/provenance/v1 \ - --owner Teck -``` - -#### 3.5 Verify SLSA L3 with Official Verifier - -```bash -# Install SLSA verifier -go install github.com/slsa-framework/slsa-verifier/v2/cli/slsa-verifier@latest - -# Verify SLSA provenance -slsa-verifier verify-image \ - harbor.tecklab.dk/teck-lab/catalog:0.99.0-test \ - --source-uri github.com/Teck/Teck.Cloud \ - --source-tag v0.99.0-test - -# Expected output: -# Verified signature against tlog entry index XXX -# Verified build using builder "https://github.com/Teck/Teck.Cloud/.github/workflows/reusable-build-sign-sbom.yml@refs/tags/v0.99.0-test" -# Verifying provenance: PASSED -# SLSA verification: PASSED (Build L3) -``` - ---- - -### Step 4: Test Harbor Signature Enforcement (5 minutes) - -**Verify Harbor is enforcing signature verification:** - -#### 4.1 Check Harbor UI - -1. Login to: `https://harbor.tecklab.dk` -2. Navigate: **Projects** โ†’ **teck-lab** โ†’ **Repositories** โ†’ **catalog** -3. Click: `v0.99.0-test` artifact - -**Verify UI shows:** -- โœ… **"Signed"** badge (green checkmark) -- โœ… **Signature tab** with details (timestamp, signer certificate) -- โœ… **SBOM tab** with package list (Harbor-generated SPDX) -- โœ… **Vulnerabilities tab** with Trivy scan results - -#### 4.2 Test Pull Protection - -```bash -# Pull signed image (should SUCCEED) -docker pull harbor.tecklab.dk/teck-lab/catalog:0.99.0-test -# โœ… Expected: Success - -# Try pulling unsigned image (should FAIL) -# First, create a test unsigned image: -docker tag alpine:latest harbor.tecklab.dk/teck-lab/test-unsigned:latest -docker push harbor.tecklab.dk/teck-lab/test-unsigned:latest - -# Now try to pull it: -docker pull harbor.tecklab.dk/teck-lab/test-unsigned:latest -# โŒ Expected error: "image signature verification failed" -# โŒ Expected: "Only signed images are allowed in this project" -``` - ---- - -### Step 5: Review GitHub Security Tab (2 minutes) - -**Check vulnerability scan results:** - -1. Go to: `https://github.com/Teck/Teck.Cloud/security/code-scanning` -2. Filter by: `trivy-image-catalog` -3. Review: Critical/High vulnerabilities found by Trivy - -**Scan results are also available in release:** -```bash -gh release download v0.99.0-test --pattern "trivy-scan-*.json" -cat trivy-scan-catalog.json | jq '.Results' -``` - ---- - -### Step 6: Production Release (when ready) - -**After successful testing, create production release:** - -```bash -# Normal release process via semantic-release/auto -git checkout main -git commit -m "feat: implement SLSA L3 and EU CRA compliance" -git push origin main - -# Auto will create release (e.g., v1.0.0) -# Workflow will automatically: -# - Build multi-arch images -# - Sign with Cosign -# - Generate dual SBOMs (SPDX + CycloneDX) -# - Generate VEX documents -# - Create attestations -# - Upload to GitHub Releases (10-year retention) -``` - ---- - -## ๐Ÿ“Š Implementation Summary - -### What Changed - -| Category | Before | After | Improvement | -|----------|--------|-------|-------------| -| **SLSA Level** | L1-L2 (unsigned, no provenance) | **L3** (signed, attested, pinned) | โœ… +2 levels | -| **SBOM Formats** | SPDX only | **SPDX + CycloneDX** | โœ… Dual format | -| **SBOM Signing** | None | **Cosign keyless** | โœ… Cryptographic proof | -| **VEX Documents** | None | **OpenVEX with Grype** | โœ… Exploitability tracking | -| **Harbor Verification** | None | **Enforced at pull time** | โœ… Registry-level security | -| **Retention** | 90 days (artifacts) | **10 years (releases)** | โœ… EU CRA compliant | -| **Actions Pinning** | Version tags (`@v4`) | **SHA digests** | โœ… Supply chain hardened | -| **Workflow Size** | 440 lines (monolithic) | **130 lines (reusable)** | โœ… 70% reduction | -| **Auto-updates** | None | **Dependabot (weekly)** | โœ… Automated maintenance | - -### Artifacts Per Release - -**Before:** -- 1 unsigned image -- 1 SPDX SBOM (unsigned, 90-day retention) -- 1 Trivy scan (90-day retention) - -**After:** -- 1 signed multi-arch image (Cosign) -- 2 signed SBOMs (SPDX + CycloneDX, 10-year retention) -- 1 signed VEX document (10-year retention) -- 1 Trivy scan JSON (10-year retention) -- 2 attestations (SBOM + build provenance, SLSA L3) -- Harbor-generated SBOM (SPDX, UI visibility) - ---- - -## ๐Ÿ”ง Troubleshooting - -### Workflow Failures - -**Q: Workflow fails with "permission denied" on Cosign signing** - -A: Check that workflow has `id-token: write` permission (required for OIDC) -```yaml -permissions: - id-token: write # Required for Cosign keyless signing - attestations: write -``` - -**Q: VEX generation fails with Python error** - -A: The VEX generation Python script is inline in the workflow. If it fails: -1. Check Grype output format (should be JSON) -2. Verify jq is installed in runner -3. Check for malformed JSON in grype-results.json - -**Q: Harbor rejects image push** - -A: Check Harbor project quota and storage limits: -```bash -# Check Harbor project info -curl -X GET "https://harbor.tecklab.dk/api/v2.0/projects/teck-lab" \ - -H "Authorization: Basic $(echo -n 'user:pass' | base64)" -``` - -### Verification Failures - -**Q: Signature verification fails with "certificate identity mismatch"** - -A: Ensure the certificate identity regex matches your GitHub repo: -```bash -# Should be: ^https://github.com/Teck/Teck.Cloud/.* -# Not: ^https://github.com/Teck/Teck.Cloud$ (missing trailing wildcard) -``` - -**Q: Harbor shows "unsigned" despite successful Cosign signing** - -A: Harbor needs a few minutes to verify signatures after push. Wait 2-3 minutes and refresh. If still unsigned: -1. Check Harbor logs: `kubectl logs -n harbor harbor-core-xxx` -2. Verify Harbor can reach Sigstore Rekor: `https://rekor.sigstore.dev` - -**Q: SLSA verifier fails with "source mismatch"** - -A: Ensure you're using the exact tag name: -```bash -# Correct -slsa-verifier verify-image ... --source-tag v1.0.0 - -# Wrong (don't use refs/tags/) -slsa-verifier verify-image ... --source-tag refs/tags/v1.0.0 -``` - ---- - -## ๐Ÿ“š Documentation Links - -### Internal -- [SECURITY.md](./SECURITY.md) - Full security policy and procedures -- [Verification Script](./scripts/verify-signatures.sh) - Automated verification tool - -### External -- [SLSA Framework](https://slsa.dev/) - Supply chain security levels -- [Sigstore/Cosign](https://docs.sigstore.dev/cosign/overview/) - Keyless signing -- [OpenVEX Spec](https://github.com/openvex/spec) - VEX format specification -- [Harbor Docs](https://goharbor.io/docs/2.14.0/) - Harbor configuration -- [SPDX Spec](https://spdx.github.io/spdx-spec/v2.3/) - SBOM format -- [CycloneDX Spec](https://cyclonedx.org/specification/overview/) - SBOM format - ---- - -## โœ… Success Criteria Checklist - -### SLSA L3 -- [ ] Build runs in isolated environment (reusable workflow) -- [ ] All actions pinned by SHA256 digest -- [ ] Build provenance attestations generated -- [ ] Provenance signed with OIDC (non-falsifiable) -- [ ] SLSA verifier passes verification -- [ ] Dependabot updates SHAs weekly - -### EU CRA -- [ ] SBOMs in machine-readable format (SPDX + CycloneDX) -- [ ] SBOMs stored for 10 years (GitHub Releases) -- [ ] SBOMs cryptographically signed (Cosign) -- [ ] VEX documents track exploitability -- [ ] All artifacts downloadable and verifiable - -### Harbor -- [ ] Cosign verification enabled (Project โ†’ Configuration) -- [ ] Auto-SBOM generation enabled -- [ ] Unsigned images rejected on pull -- [ ] "Signed" badge visible in UI -- [ ] Harbor SBOM tab populated - -### Security -- [ ] Trivy scans uploaded to GitHub Security tab -- [ ] No CRITICAL/HIGH vulnerabilities (or accepted risks documented) -- [ ] Verification script passes for all services -- [ ] SECURITY.md documentation complete - ---- - -## ๐ŸŽ‰ You're Done! - -Your Teck.Cloud project now has: - -โœ… **SLSA Build Level 3** compliance -โœ… **EU Cyber Resilience Act** compliance -โœ… **Harbor signature enforcement** (only signed images allowed) -โœ… **Dual SBOM strategy** (compliance + operations) -โœ… **VEX exploitability tracking** -โœ… **10-year artifact retention** -โœ… **Automated security updates** -โœ… **Comprehensive verification tooling** - -**Next:** Create test release `v0.99.0-test` and run through verification steps above! - ---- - -**Questions or issues?** Check `SECURITY.md` or open a GitHub issue. - ---- - -## ๐Ÿ”ง Recent Fixes - -### Fixed: Incorrect SHA for create-github-app-token (2026-02-08) - -**Issue:** Workflow failed with "An action could not be found at the URI" - -**Root cause:** Used incorrect SHA `cc048e6...` for `actions/create-github-app-token@v1.12.1` -- v1.12.1 doesn't exist -- Latest v1 is at SHA `d72941d...` - -**Fix applied:** Updated `release-services.yaml` line 27: -```yaml -# Before (incorrect) -uses: actions/create-github-app-token@cc048e667baebf25e8cd6356b82d67e6ffb6671c # v1.12.1 - -# After (correct) -uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1 -``` - -**Status:** โœ… Fixed - workflow should now run successfully - diff --git a/README.md b/README.md index 59ac01c0..bb8fe68c 100644 --- a/README.md +++ b/README.md @@ -120,8 +120,7 @@ dotnet test --collect:"XPlat Code Coverage" ## Development Guidelines -See the `.cursor/rules/` directory for detailed coding standards and best practices: -- **C# Coding Style**: Functional patterns, immutable data, value objects -- **Testing Guidelines**: xUnit patterns, test isolation, TestContainers for integration tests -- **Solution Management**: SDK versioning, build properties, package management -- **Dependency Management**: Security scanning, license compliance, version management \ No newline at end of file +See the canonical AI rule set under `docs/ai/rules/`: +- **Rule Index**: `docs/ai/rules/README.md` +- **Copilot Entrypoint**: `.github/copilot-instructions.md` +- **Agent Entrypoint**: `AGENTS.md` \ No newline at end of file diff --git a/Teck.Cloud.slnx b/Teck.Cloud.slnx index 328b0661..441eefb0 100644 --- a/Teck.Cloud.slnx +++ b/Teck.Cloud.slnx @@ -28,6 +28,7 @@ + @@ -69,6 +70,7 @@ + diff --git a/deployment/README.md b/deployment/README.md new file mode 100644 index 00000000..ef0a2b96 --- /dev/null +++ b/deployment/README.md @@ -0,0 +1,22 @@ +# Deployment + +This folder contains GitOps manifests for ArgoCD. + +## Structure + +- `argocd/` - ArgoCD `AppProject` + root/child `Application` manifests +- `manifests/` - Kubernetes manifests per service in this repository + +## Services in Scope + +- `catalog-api` +- `customer-api` +- `web-bff` + +## Usage + +1. Apply the ArgoCD project and root application: + - `deployment/argocd/project.yaml` + - `deployment/argocd/root-application.yaml` +2. ArgoCD will reconcile child apps from `deployment/argocd/apps/`. +3. Update image tags in each service deployment manifest as part of release promotion. diff --git a/deployment/argocd/apps/catalog-api.yaml b/deployment/argocd/apps/catalog-api.yaml new file mode 100644 index 00000000..c0592ec3 --- /dev/null +++ b/deployment/argocd/apps/catalog-api.yaml @@ -0,0 +1,20 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: catalog-api + namespace: argocd +spec: + project: teck-cloud + source: + repoURL: https://github.com/Teck-Lab/Teck.Cloud.git + targetRevision: main + path: deployment/manifests/catalog-api + destination: + server: https://kubernetes.default.svc + namespace: teck-cloud + syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true diff --git a/deployment/argocd/apps/customer-api.yaml b/deployment/argocd/apps/customer-api.yaml new file mode 100644 index 00000000..be0cd369 --- /dev/null +++ b/deployment/argocd/apps/customer-api.yaml @@ -0,0 +1,20 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: customer-api + namespace: argocd +spec: + project: teck-cloud + source: + repoURL: https://github.com/Teck-Lab/Teck.Cloud.git + targetRevision: main + path: deployment/manifests/customer-api + destination: + server: https://kubernetes.default.svc + namespace: teck-cloud + syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true diff --git a/deployment/argocd/apps/web-bff.yaml b/deployment/argocd/apps/web-bff.yaml new file mode 100644 index 00000000..5b9e22b2 --- /dev/null +++ b/deployment/argocd/apps/web-bff.yaml @@ -0,0 +1,20 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: web-bff + namespace: argocd +spec: + project: teck-cloud + source: + repoURL: https://github.com/Teck-Lab/Teck.Cloud.git + targetRevision: main + path: deployment/manifests/web-bff + destination: + server: https://kubernetes.default.svc + namespace: teck-cloud + syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true diff --git a/deployment/argocd/project.yaml b/deployment/argocd/project.yaml new file mode 100644 index 00000000..d9e91d86 --- /dev/null +++ b/deployment/argocd/project.yaml @@ -0,0 +1,18 @@ +apiVersion: argoproj.io/v1alpha1 +kind: AppProject +metadata: + name: teck-cloud + namespace: argocd +spec: + description: Teck.Cloud services managed from this repository + sourceRepos: + - '*' + destinations: + - namespace: teck-cloud + server: https://kubernetes.default.svc + clusterResourceWhitelist: + - group: '*' + kind: '*' + namespaceResourceWhitelist: + - group: '*' + kind: '*' diff --git a/deployment/argocd/root-application.yaml b/deployment/argocd/root-application.yaml new file mode 100644 index 00000000..6b5e6668 --- /dev/null +++ b/deployment/argocd/root-application.yaml @@ -0,0 +1,20 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: teck-cloud-root + namespace: argocd +spec: + project: teck-cloud + source: + repoURL: https://github.com/Teck-Lab/Teck.Cloud.git + targetRevision: main + path: deployment/argocd/apps + destination: + server: https://kubernetes.default.svc + namespace: argocd + syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true diff --git a/deployment/manifests/catalog-api/deployment.yaml b/deployment/manifests/catalog-api/deployment.yaml new file mode 100644 index 00000000..bcde5b56 --- /dev/null +++ b/deployment/manifests/catalog-api/deployment.yaml @@ -0,0 +1,59 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: catalog-api + labels: + app: catalog-api +spec: + replicas: 2 + selector: + matchLabels: + app: catalog-api + template: + metadata: + labels: + app: catalog-api + spec: + containers: + - name: catalog-api + image: harbor.tecklab.dk/teck-lab/catalog-api:latest + imagePullPolicy: IfNotPresent + ports: + - containerPort: 8080 + env: + - name: ASPNETCORE_HTTP_PORTS + value: "8080" + - name: ConnectionStrings__postgres-write + valueFrom: + secretKeyRef: + name: catalog-api-secrets + key: postgres-write + - name: ConnectionStrings__postgres-read + valueFrom: + secretKeyRef: + name: catalog-api-secrets + key: postgres-read + - name: ConnectionStrings__rabbitmq + valueFrom: + secretKeyRef: + name: shared-platform-secrets + key: rabbitmq + - name: ConnectionStrings__redis + valueFrom: + secretKeyRef: + name: shared-platform-secrets + key: redis + - name: Services__CustomerApi__Url + value: "http://customer-api.teck-cloud.svc.cluster.local" + readinessProbe: + httpGet: + path: /health + port: 8080 + initialDelaySeconds: 10 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /alive + port: 8080 + initialDelaySeconds: 20 + periodSeconds: 20 diff --git a/deployment/manifests/catalog-api/kustomization.yaml b/deployment/manifests/catalog-api/kustomization.yaml new file mode 100644 index 00000000..e4f682b0 --- /dev/null +++ b/deployment/manifests/catalog-api/kustomization.yaml @@ -0,0 +1,6 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: teck-cloud +resources: + - deployment.yaml + - service.yaml diff --git a/deployment/manifests/catalog-api/service.yaml b/deployment/manifests/catalog-api/service.yaml new file mode 100644 index 00000000..23242f4c --- /dev/null +++ b/deployment/manifests/catalog-api/service.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Service +metadata: + name: catalog-api + labels: + app: catalog-api +spec: + selector: + app: catalog-api + ports: + - port: 80 + targetPort: 8080 + protocol: TCP + name: http diff --git a/deployment/manifests/customer-api/deployment.yaml b/deployment/manifests/customer-api/deployment.yaml new file mode 100644 index 00000000..41fb27f4 --- /dev/null +++ b/deployment/manifests/customer-api/deployment.yaml @@ -0,0 +1,52 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: customer-api + labels: + app: customer-api +spec: + replicas: 2 + selector: + matchLabels: + app: customer-api + template: + metadata: + labels: + app: customer-api + spec: + containers: + - name: customer-api + image: harbor.tecklab.dk/teck-lab/customer-api:latest + imagePullPolicy: IfNotPresent + ports: + - containerPort: 8080 + env: + - name: ASPNETCORE_HTTP_PORTS + value: "8080" + - name: ConnectionStrings__postgres-write + valueFrom: + secretKeyRef: + name: customer-api-secrets + key: postgres-write + - name: ConnectionStrings__postgres-read + valueFrom: + secretKeyRef: + name: customer-api-secrets + key: postgres-read + - name: ConnectionStrings__rabbitmq + valueFrom: + secretKeyRef: + name: shared-platform-secrets + key: rabbitmq + readinessProbe: + httpGet: + path: /health + port: 8080 + initialDelaySeconds: 10 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /alive + port: 8080 + initialDelaySeconds: 20 + periodSeconds: 20 diff --git a/deployment/manifests/customer-api/kustomization.yaml b/deployment/manifests/customer-api/kustomization.yaml new file mode 100644 index 00000000..e4f682b0 --- /dev/null +++ b/deployment/manifests/customer-api/kustomization.yaml @@ -0,0 +1,6 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: teck-cloud +resources: + - deployment.yaml + - service.yaml diff --git a/deployment/manifests/customer-api/service.yaml b/deployment/manifests/customer-api/service.yaml new file mode 100644 index 00000000..ede585cd --- /dev/null +++ b/deployment/manifests/customer-api/service.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Service +metadata: + name: customer-api + labels: + app: customer-api +spec: + selector: + app: customer-api + ports: + - port: 80 + targetPort: 8080 + protocol: TCP + name: http diff --git a/deployment/manifests/web-bff/deployment.yaml b/deployment/manifests/web-bff/deployment.yaml new file mode 100644 index 00000000..1ae84c53 --- /dev/null +++ b/deployment/manifests/web-bff/deployment.yaml @@ -0,0 +1,56 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: web-bff + labels: + app: web-bff +spec: + replicas: 2 + selector: + matchLabels: + app: web-bff + template: + metadata: + labels: + app: web-bff + spec: + containers: + - name: web-bff + image: harbor.tecklab.dk/teck-lab/web-bff:latest + imagePullPolicy: IfNotPresent + ports: + - containerPort: 8080 + env: + - name: ASPNETCORE_HTTP_PORTS + value: "8080" + - name: Services__CustomerApi__Url + value: "http://customer-api.teck-cloud.svc.cluster.local" + - name: ReverseProxy__Clusters__catalog__Destinations__cluster1__Address + value: "http://catalog-api.teck-cloud.svc.cluster.local/" + - name: Keycloak__Authority + valueFrom: + secretKeyRef: + name: web-bff-secrets + key: keycloak-authority + - name: Keycloak__GatewayClientId + valueFrom: + secretKeyRef: + name: web-bff-secrets + key: gateway-client-id + - name: Keycloak__GatewayClientSecret + valueFrom: + secretKeyRef: + name: web-bff-secrets + key: gateway-client-secret + readinessProbe: + httpGet: + path: /health + port: 8080 + initialDelaySeconds: 10 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /health + port: 8080 + initialDelaySeconds: 20 + periodSeconds: 20 diff --git a/deployment/manifests/web-bff/kustomization.yaml b/deployment/manifests/web-bff/kustomization.yaml new file mode 100644 index 00000000..e4f682b0 --- /dev/null +++ b/deployment/manifests/web-bff/kustomization.yaml @@ -0,0 +1,6 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: teck-cloud +resources: + - deployment.yaml + - service.yaml diff --git a/deployment/manifests/web-bff/service.yaml b/deployment/manifests/web-bff/service.yaml new file mode 100644 index 00000000..8d33b477 --- /dev/null +++ b/deployment/manifests/web-bff/service.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Service +metadata: + name: web-bff + labels: + app: web-bff +spec: + selector: + app: web-bff + ports: + - port: 80 + targetPort: 8080 + protocol: TCP + name: http diff --git a/docs/MIGRATION_SYSTEM.md b/docs/MIGRATION_SYSTEM.md deleted file mode 100644 index 692170b2..00000000 --- a/docs/MIGRATION_SYSTEM.md +++ /dev/null @@ -1,382 +0,0 @@ -# Multi-Tenant Database Migration System - -This document describes the database migration system for the Teck.Cloud multi-tenant application. - -## Overview - -The migration system handles database schema updates for: -- **Shared Database** - PostgreSQL database used by tenants on the shared tier -- **Dedicated Databases** - Tenant-specific databases (PostgreSQL or SQL Server) -- **External Databases** - Customer-managed databases - -## Architecture Components - -### 1. Secrets Management (`SharedKernel.Secrets`) - -HashiCorp Vault integration for secure credential storage. - -#### Key Files -- `VaultSecretsManager.cs` - Main Vault client implementation -- `DatabaseCredentials.cs` - Credential models with admin/app user separation -- `VaultOptions.cs` - Configuration options - -#### Configuration - -Add to `appsettings.json`: - -```json -{ - "Vault": { - "Address": "https://vault.example.com:8200", - "AuthMethod": "AppRole", // Token, AppRole, or Kubernetes - "RoleId": "your-role-id", - "SecretId": "your-secret-id", - "MountPoint": "secret", - "DatabaseSecretsPath": "database", - "CacheDurationMinutes": 5 - } -} -``` - -#### Vault Secret Structure - -Store credentials at these paths: - -**Shared Database:** -``` -secret/data/database/shared -{ - "admin_username": "postgres_admin", - "admin_password": "admin_password", - "app_username": "app_user", - "app_password": "app_password", - "host": "shared-db.example.com", - "port": "5432", - "database": "teck_shared" -} -``` - -**Tenant Database:** -``` -secret/data/database/{tenantId} -{ - "admin_username": "tenant_admin", - "admin_password": "admin_password", - "app_username": "tenant_app", - "app_password": "app_password", - "host": "tenant-db.example.com", - "port": "5432", - "database": "tenant_db" -} -``` - -### 2. Migration Services (`SharedKernel.Persistence.Database.Migrations`) - -Core migration orchestration logic. - -#### Key Files -- `IMigrationService.cs` - Migration service interface -- `MultiTenantMigrationService.cs` - Multi-tenant migration orchestrator -- `EFCoreMigrationRunner.cs` - EF Core migration runner -- `MigrationServiceExtensions.cs` - Registration extensions - -#### Features - -- **Admin Credential Swapping** - Uses admin credentials from Vault for migrations -- **Runtime Credentials** - Services use app-level credentials at runtime -- **Multi-Provider Support** - PostgreSQL, SQL Server, MySQL -- **Health Checks** - Migration status verification -- **Caching** - Credentials cached for performance - -### 3. Event-Driven Migrations - -#### Integration Event - -`TenantDatabaseProvisionedIntegrationEvent` triggers migrations when a new tenant database is provisioned. - -#### Wolverine Handler - -`TenantDatabaseProvisionedHandler` automatically runs migrations when the event is published. - -## Usage - -### 1. Service Registration - -In your service's `InfrastructureServiceExtensions.cs`: - -```csharp -public static IServiceCollection AddInfrastructureServices( - this IHostApplicationBuilder builder) -{ - // Add Vault secrets management - builder.Services.AddVaultSecretsManagement(builder.Configuration); - - // Add multi-tenant migration services - builder.Services.AddMultiTenantMigrations( - DatabaseProvider.PostgreSQL); - - return builder.Services; -} -``` - -### 2. Startup Migrations - -#### Option A: Migrate All Databases - -In `Program.cs` after `app.Build()`: - -```csharp -var app = builder.Build(); - -// Migrate all databases on startup -if (builder.Configuration.GetValue("Database:MigrateOnStartup")) -{ - var results = await app.Services.MigrateAllDatabasesOnStartupAsync(); - - foreach (var result in results) - { - if (!result.Success) - { - Log.Error("Migration failed for {TenantId}: {Error}", - result.TenantId ?? "shared", - result.ErrorMessage); - } - } -} -``` - -#### Option B: Migrate Shared Database Only - -```csharp -// Migrate shared database only -if (builder.Configuration.GetValue("Database:MigrateSharedOnStartup")) -{ - var result = await app.Services.MigrateSharedDatabaseOnStartupAsync(); - - if (!result.Success) - { - throw new InvalidOperationException( - $"Shared database migration failed: {result.ErrorMessage}"); - } -} -``` - -### 3. Event-Driven Migrations - -When a new tenant is created, publish the integration event: - -```csharp -// In your tenant provisioning code -await _messageBus.PublishAsync(new TenantDatabaseProvisionedIntegrationEvent -{ - TenantId = tenant.Id, - DatabaseStrategy = "Dedicated", - DatabaseProvider = "PostgreSQL", - DatabaseCreated = true -}); -``` - -The `TenantDatabaseProvisionedHandler` will automatically: -1. Detect the event -2. Fetch admin credentials from Vault -3. Run migrations against the tenant's database -4. Log results - -### 4. Manual Migration Trigger - -```csharp -public class MigrationController : ControllerBase -{ - private readonly IMigrationService _migrationService; - - [HttpPost("migrate/{tenantId}")] - public async Task MigrateTenant(string tenantId) - { - var result = await _migrationService.MigrateTenantDatabaseAsync(tenantId); - - return result.Success - ? Ok(result) - : StatusCode(500, result); - } -} -``` - -## Security Model - -### Credential Separation - -- **Admin User** - Used ONLY for migrations (DDL operations) - - CREATE TABLE, ALTER TABLE, DROP TABLE - - CREATE INDEX, etc. - -- **App User** - Used by the application at runtime - - SELECT, INSERT, UPDATE, DELETE - - No DDL permissions - -### Vault Integration - -1. **Development**: Use Token auth with local Vault -2. **Kubernetes**: Use Kubernetes auth with service account -3. **Production**: Use AppRole with secret rotation - -### Credential Rotation - -Vault TTL settings handle automatic credential rotation: - -```hcl -path "secret/data/database/*" { - capabilities = ["read"] - max_ttl = "24h" - ttl = "1h" -} -``` - -## Configuration - -### appsettings.json - -```json -{ - "Database": { - "MigrateOnStartup": false, - "MigrateSharedOnStartup": true, - "ConnectionTimeout": 30 - }, - "Vault": { - "Address": "https://vault.example.com:8200", - "AuthMethod": "Kubernetes", - "KubernetesRole": "teck-cloud-catalog", - "MountPoint": "secret", - "DatabaseSecretsPath": "database", - "CacheDurationMinutes": 5, - "EnableTokenRenewal": true - } -} -``` - -### Environment Variables - -```bash -# Vault configuration -VAULT__ADDRESS=https://vault.example.com:8200 -VAULT__AUTHMETHOD=AppRole -VAULT__ROLEID=your-role-id -VAULT__SECRETID=your-secret-id - -# Migration settings -DATABASE__MIGRATEONSTARTUP=false -DATABASE__MIGRATESHAREDONSTARTUP=true -``` - -## Deployment Workflow - -### 1. Initial Deployment - -```bash -# 1. Deploy Vault and configure secrets -vault kv put secret/database/shared \ - admin_username=postgres_admin \ - admin_password=secure_password \ - app_username=app_user \ - app_password=app_password \ - host=shared-db.internal \ - port=5432 \ - database=teck_shared - -# 2. Deploy service with MigrateSharedOnStartup=true -# The service will automatically migrate the shared database - -# 3. For dedicated tenants, publish the provisioning event -``` - -### 2. Adding a New Tenant - -```bash -# 1. Provision database infrastructure (RDS, Azure SQL, etc.) - -# 2. Store credentials in Vault -vault kv put secret/database/{tenantId} \ - admin_username=tenant_admin \ - admin_password=secure_password \ - app_username=tenant_app \ - app_password=app_password \ - host=tenant-db.internal \ - port=5432 \ - database=tenant_database - -# 3. Publish TenantDatabaseProvisionedIntegrationEvent -# OR manually trigger migration via API -curl -X POST https://api.teck.cloud/admin/migrate/{tenantId} -``` - -### 3. Schema Updates - -```bash -# 1. Create EF Core migration -dotnet ef migrations add AddNewFeature - -# 2. Deploy updated service -# Migrations run automatically on startup (if configured) -# OR publish event to trigger migrations for all tenants -``` - -## Monitoring - -### Logs - -Migrations generate structured logs: - -``` -[INF] Starting migration for tenant abc123 -[INF] Found 3 pending migrations for tenant abc123 -[INF] Successfully applied 3 migrations for tenant abc123 in 1234ms -``` - -### Health Checks - -Add migration health checks: - -```csharp -builder.Services.AddHealthChecks() - .AddCheck("database-migrations"); -``` - -## Troubleshooting - -### Migration Failed - -1. Check Vault connectivity and credentials -2. Verify admin user has DDL permissions -3. Check database connectivity from the service -4. Review migration logs for specific errors - -### Credential Access Denied - -1. Verify Vault token/role permissions -2. Check Vault policy allows reading `secret/data/database/*` -3. Ensure credentials are stored at correct path - -### Timeout Issues - -1. Increase `TimeoutSeconds` in VaultOptions -2. Check network connectivity to Vault -3. Verify database connection limits - -## Best Practices - -1. **Always use Vault** - Never store credentials in appsettings.json in production -2. **Separate credentials** - Admin vs app user for security -3. **Test migrations** - Run migrations in staging first -4. **Monitor failures** - Set up alerts for failed migrations -5. **Rotate credentials** - Use Vault TTL for automatic rotation -6. **Backup databases** - Before running migrations in production -7. **Use transactions** - EF Core migrations are transactional by default - -## Future Enhancements - -- [ ] Blue/green migration strategy -- [ ] Migration rollback support -- [ ] Multi-region database support -- [ ] Automated backup before migration -- [ ] Migration verification tests -- [ ] Prometheus metrics for migration tracking diff --git a/docs/ai/rules/README.md b/docs/ai/rules/README.md new file mode 100644 index 00000000..486f3e96 --- /dev/null +++ b/docs/ai/rules/README.md @@ -0,0 +1,26 @@ +# AI Rule Set + +This directory is the canonical AI coding rule set for Teck.Cloud. + +## Rules + +- `csharp/coding-style.md` +- `csharp/testing.md` +- `architecture/project-structure.md` +- `architecture/inter-service-communication.md` +- `architecture/event-driven-architecture.md` +- `architecture/data-access-patterns.md` +- `containers/container-tooling.md` +- `containers/dockerfile-patterns.md` +- `auth/auth-setup.md` +- `source-control/github-workflow.md` +- `deployment/argocd.md` +- `dotnet-sdk/solution-management.md` +- `dotnet-sdk/dependency-management.md` +- `dotnet-tools/consuming-dotnettool.md` +- `dotnet-tools/publishing-dotnettool.md` +- `benchmarking/benchmarking.md` + +## Notes + +- In case of conflict, repository configuration files and existing code patterns take precedence. diff --git a/docs/ai/rules/architecture/data-access-patterns.md b/docs/ai/rules/architecture/data-access-patterns.md new file mode 100644 index 00000000..a85d890a --- /dev/null +++ b/docs/ai/rules/architecture/data-access-patterns.md @@ -0,0 +1,19 @@ +# Data Access Patterns + +## Intent + +Use CQRS-oriented read/write separation with repository abstractions and tenant-aware resolution. + +## Rules + +- Keep command-side writes on domain models and write repositories. +- Keep query-side reads optimized for read models/DTOs. +- Place repository interfaces in application/domain boundary projects. +- Place repository implementations and EF configuration in infrastructure. +- Keep `DbContext` ownership in infrastructure and wire via DI. +- For multi-tenant services, use shared tenant-resolution extensions and resolvers. + +## Consistency + +- Keep transaction boundaries explicit. +- Avoid bypassing application abstractions for direct data access from API layer. diff --git a/docs/ai/rules/architecture/event-driven-architecture.md b/docs/ai/rules/architecture/event-driven-architecture.md new file mode 100644 index 00000000..f1cc2e43 --- /dev/null +++ b/docs/ai/rules/architecture/event-driven-architecture.md @@ -0,0 +1,23 @@ +# Event-Driven Architecture + +## Intent + +Separate internal domain signaling from cross-service integration messaging. + +## Domain Events + +- Raised by aggregates for internal consistency. +- Handled within service boundaries. +- Keep payloads focused on domain intent and identifiers. + +## Integration Events + +- Used for asynchronous communication across services. +- Contracts live in shared event contracts where needed. +- Handlers must be idempotent and failure-aware. + +## Rules + +- Do not leak transport concerns into domain model. +- Log event publication and handling with correlation identifiers. +- Apply retry/dead-letter policies through messaging infrastructure. diff --git a/docs/ai/rules/architecture/inter-service-communication.md b/docs/ai/rules/architecture/inter-service-communication.md new file mode 100644 index 00000000..5110af0f --- /dev/null +++ b/docs/ai/rules/architecture/inter-service-communication.md @@ -0,0 +1,19 @@ +# Inter-Service Communication + +## Intent + +Use the right communication mode per interaction type. + +## Rules + +- Gateway (BFF) to downstream services: HTTP. +- Direct synchronous service-to-service calls: gRPC when appropriate. +- Asynchronous cross-service propagation: integration events via broker. +- Resolve service locations via service discovery/config, not hardcoded addresses. + +## Reliability and Security + +- Propagate auth context safely and validate per service. +- Apply timeouts/retries/circuit breaking through configured clients. +- Handle and map transport errors explicitly. +- Emit logs/traces for all cross-service calls. diff --git a/docs/ai/rules/architecture/project-structure.md b/docs/ai/rules/architecture/project-structure.md new file mode 100644 index 00000000..b7ea07af --- /dev/null +++ b/docs/ai/rules/architecture/project-structure.md @@ -0,0 +1,27 @@ +# Project Structure + +## Intent + +Apply clean architecture boundaries across services and building blocks. + +## Layer Direction + +- Domain: core business model and rules. +- Application: use cases, commands/queries, interfaces. +- Infrastructure: external concerns, persistence, adapters. +- API: transport contracts/endpoints. + +Dependencies must flow inward toward domain/application abstractions. + +## Service Layout + +- `src/services/{service}/{Service}.Domain` +- `src/services/{service}/{Service}.Application` +- `src/services/{service}/{Service}.Infrastructure` +- `src/services/{service}/{Service}.Api` + +## Conventions + +- Keep business logic out of API and infrastructure edges. +- Keep repositories/interfaces in application/domain boundaries and implementations in infrastructure. +- Keep shared cross-cutting concerns in `src/buildingblocks/*`. diff --git a/docs/ai/rules/auth/auth-setup.md b/docs/ai/rules/auth/auth-setup.md new file mode 100644 index 00000000..283b19e4 --- /dev/null +++ b/docs/ai/rules/auth/auth-setup.md @@ -0,0 +1,28 @@ +# Auth Setup + +## Intent + +Keep authentication and authorization aligned with the repository's Keycloak + BFF token-exchange model. + +## Current Architecture + +- Identity provider: Keycloak. +- Service auth: JWT bearer validation via shared auth extensions. +- BFF behavior: token exchange per downstream audience, then proxy request forwarding. +- Tenant context: resolved from claims and forwarded as `X-TenantId`. + +## Rules + +- Reuse shared auth setup from `SharedKernel.Infrastructure.Auth` for APIs. +- Do not introduce alternate auth stacks per service unless explicitly required. +- Keep protected endpoints behind resource/scope authorization policies. +- In BFF routes, use per-route audience metadata for token exchange. +- Cache exchanged tokens with expiry-aware TTL to reduce token endpoint pressure. +- Propagate tenant context consistently (`tenant_id` claims and `X-TenantId` forwarding). +- Treat inbound tenant headers from external callers as untrusted unless explicitly validated. + +## Key Integration Points + +- Gateway token exchange middleware and service implementation. +- Multi-tenant claim/header resolution in shared multi-tenant extensions. +- Service defaults for organization-claim based tenant resolution. diff --git a/docs/ai/rules/benchmarking/benchmarking.md b/docs/ai/rules/benchmarking/benchmarking.md new file mode 100644 index 00000000..c20f0c43 --- /dev/null +++ b/docs/ai/rules/benchmarking/benchmarking.md @@ -0,0 +1,18 @@ +# Benchmarking + +## Intent + +Use reproducible benchmarks to measure performance and prevent regressions. + +## Rules + +- Use BenchmarkDotNet for microbenchmarks. +- Run benchmarks in `Release` with optimized builds. +- Include memory/allocation diagnostics where relevant. +- Isolate benchmark setup from measured operations. +- Compare baseline vs candidate implementations explicitly. +- Persist benchmark artifacts in CI when running performance checks. + +## Scope + +- Use benchmarks for performance-sensitive code paths, not as unit-test replacements. diff --git a/docs/ai/rules/containers/container-tooling.md b/docs/ai/rules/containers/container-tooling.md new file mode 100644 index 00000000..f2f0fc7e --- /dev/null +++ b/docs/ai/rules/containers/container-tooling.md @@ -0,0 +1,12 @@ +# Container Tooling + +## Intent + +Use Podman as the local container runtime preference, while keeping CI compatibility. + +## Rules + +- Local development examples should use `podman` commands. +- CI workflows may continue to use Docker when runner/tooling requires it. +- Clearly distinguish local-vs-CI command examples in documentation. +- Prefer rootless and least-privilege container execution when available. diff --git a/docs/ai/rules/containers/dockerfile-patterns.md b/docs/ai/rules/containers/dockerfile-patterns.md new file mode 100644 index 00000000..d1552168 --- /dev/null +++ b/docs/ai/rules/containers/dockerfile-patterns.md @@ -0,0 +1,18 @@ +# Dockerfile Patterns + +## Intent + +Keep service images reproducible, secure, and minimal, with consistent multi-stage builds. + +## Rules + +- Use multi-stage builds (`build`, `publish`, `final`). +- Pin to repository-supported .NET major version (currently .NET 10 unless project-specific override). +- Keep runtime image minimal and run as non-root. +- Preserve deterministic publish output and explicit output directories. +- Use layer caching effectively: copy project/props first, then source. + +## Wolverine Codegen Services + +- For services requiring code generation during image build, run codegen from built output with required env/config set. +- Do not rely on implicit `dotnet run` behavior for codegen in container builds. diff --git a/docs/ai/rules/csharp/coding-style.md b/docs/ai/rules/csharp/coding-style.md new file mode 100644 index 00000000..f4753e28 --- /dev/null +++ b/docs/ai/rules/csharp/coding-style.md @@ -0,0 +1,26 @@ +# C# Coding Style + +## Intent + +Write readable, maintainable, modern C# with low coupling and explicit behavior. + +## Rules + +- Prefer clear domain naming and self-documenting code. +- Use records for immutable data shapes and value semantics where appropriate. +- Make classes `sealed` by default; only open inheritance intentionally. +- Prefer value objects over primitive obsession. +- Use pattern matching and expression-based code when it improves clarity. +- Keep functions pure where possible; isolate side effects. +- Minimize constructor dependencies; split responsibilities if dependency count grows. +- Prefer safe APIs (`TryParse`, `TryGetValue`) over exception-driven control flow. +- Flow `CancellationToken` through async call chains. +- Avoid `async void`, `.Result`, `.Wait()`, and `ContinueWith` for app logic. +- Enable and honor nullable reference types. +- Use `nameof(...)` for parameter/property/symbol references. +- Use result/error types for expected failures; exceptions for truly exceptional cases. + +## Repository Alignment + +- Follow `.editorconfig`, `stylecop.json`, and shared build properties. +- Keep changes minimal and bounded to the task scope. diff --git a/docs/ai/rules/csharp/testing.md b/docs/ai/rules/csharp/testing.md new file mode 100644 index 00000000..49ea4f3c --- /dev/null +++ b/docs/ai/rules/csharp/testing.md @@ -0,0 +1,23 @@ +# Testing Guidelines + +## Intent + +Keep tests deterministic, maintainable, and meaningful for change risk. + +## Rules + +- Use xUnit for unit/integration tests. +- Follow Arrange-Act-Assert structure. +- Use clear test names: `Method_WhenCondition_ExpectedResult`. +- Cover happy path, validation path, and failure path for changed logic. +- Prefer theory-based parametrized tests when cases differ only by input/output. +- Keep tests isolated; avoid shared mutable state. +- Dispose external resources and fixtures correctly. +- For infrastructure integration tests, prefer real dependencies (e.g., Testcontainers) over heavy mocking. +- Use built-in xUnit assertions unless project conventions indicate otherwise. + +## Coverage + +- Add/adjust tests with every behavior change. +- Prioritize coverage on domain and application logic. +- Follow effective CI thresholds and coverage scripts defined in repository workflows. diff --git a/docs/ai/rules/deployment/argocd.md b/docs/ai/rules/deployment/argocd.md new file mode 100644 index 00000000..5e1fcc51 --- /dev/null +++ b/docs/ai/rules/deployment/argocd.md @@ -0,0 +1,24 @@ +# Deployment with ArgoCD + +## Intent + +Manage Kubernetes deployments from a repository-root `deployment/` folder using ArgoCD GitOps. + +## Rules + +- Keep deployment manifests in `deployment/` at repository root. +- ArgoCD application definitions must reference only services/projects that exist in this repo. +- Prefer an app-of-apps layout for consistent onboarding and promotion. +- Keep namespace, sync policy, and destination explicit in ArgoCD `Application` manifests. +- Keep runtime configuration externalized via env/config maps/secrets, not hardcoded credentials. +- Use immutable image tags from CI release pipelines whenever possible. + +## Repository Scope + +Current deployable service set in this repository: + +- `catalog-api` +- `customer-api` +- `web-bff` + +Add new deployment apps only when corresponding source projects exist under `src/services` or `src/gateways`. diff --git a/docs/ai/rules/dotnet-sdk/dependency-management.md b/docs/ai/rules/dotnet-sdk/dependency-management.md new file mode 100644 index 00000000..4dda1ab8 --- /dev/null +++ b/docs/ai/rules/dotnet-sdk/dependency-management.md @@ -0,0 +1,21 @@ +# Dependency Management + +## Intent + +Keep dependencies secure, compliant, and maintainable. + +## Rules + +- Add/update dependencies through `dotnet` CLI and centralized package management. +- Prefer explicit versions and controlled upgrades. +- Regularly scan for vulnerable packages. +- Validate package license compatibility for new additions. +- Run restore/build/test after dependency updates. + +## Operational Commands + +- `dotnet list package --outdated` +- `dotnet list package --vulnerable` +- `dotnet restore` +- `dotnet build` +- `dotnet test` diff --git a/docs/ai/rules/dotnet-sdk/solution-management.md b/docs/ai/rules/dotnet-sdk/solution-management.md new file mode 100644 index 00000000..123918b4 --- /dev/null +++ b/docs/ai/rules/dotnet-sdk/solution-management.md @@ -0,0 +1,18 @@ +# .NET Solution Management + +## Intent + +Maintain consistent builds and dependency behavior across all projects. + +## Rules + +- Pin SDK in `global.json`. +- Centralize shared properties in `Directory.Build.props`. +- Centralize package versions in `Directory.Packages.props`. +- Use `nuget.config` with explicit package source mapping. +- Prefer `dotnet` CLI for deterministic local/CI workflows. + +## Build Quality + +- Keep warning/error policy consistent with repository settings. +- Use deterministic CI build flags where configured. diff --git a/docs/ai/rules/dotnet-tools/consuming-dotnettool.md b/docs/ai/rules/dotnet-tools/consuming-dotnettool.md new file mode 100644 index 00000000..55eb9841 --- /dev/null +++ b/docs/ai/rules/dotnet-tools/consuming-dotnettool.md @@ -0,0 +1,12 @@ +# Consuming dotnet Tools + +## Intent + +Ensure reproducible local and CI tool usage through a committed tool manifest. + +## Rules + +- Keep `.config/dotnet-tools.json` in source control. +- Install tools with explicit versions. +- Restore tools with `dotnet tool restore` in local setup and CI. +- Periodically remove unused/deprecated tools and update intentionally. diff --git a/docs/ai/rules/dotnet-tools/publishing-dotnettool.md b/docs/ai/rules/dotnet-tools/publishing-dotnettool.md new file mode 100644 index 00000000..cd5c3d20 --- /dev/null +++ b/docs/ai/rules/dotnet-tools/publishing-dotnettool.md @@ -0,0 +1,13 @@ +# Publishing dotnet Tools + +## Intent + +Publish stable, well-documented tools with explicit compatibility and secure metadata. + +## Rules + +- Set `true` and complete NuGet package metadata. +- Use semantic versioning. +- Provide usage docs and inline help. +- Target repository baseline runtime unless the tool intentionally multi-targets. +- Validate pack/install/run behavior before publishing. diff --git a/docs/ai/rules/source-control/github-workflow.md b/docs/ai/rules/source-control/github-workflow.md new file mode 100644 index 00000000..a597e1b3 --- /dev/null +++ b/docs/ai/rules/source-control/github-workflow.md @@ -0,0 +1,37 @@ +# GitHub Workflow and Releases + +## Scope + +This rule defines the repository Git workflow, commit message convention, and release branching model. + +## Commit Messages + +- Follow the Conventional Commits specification. +- Use clear commit types and scopes when applicable (for example: `feat(catalog): add tenant header forwarding`). +- Keep commit messages compatible with Auto-based release automation. + +## Branching Strategy + +- `main` is the stable branch and should always be deployable. +- New features should be developed on `feature/*` branches. +- Bug fixes should be developed on `fix/*` branches. +- When possible, feature and fix branches should reference a GitHub issue or GitHub Project/board task. + +## Pull Requests + +- Open pull requests from feature/fix branches into `main` (or `master` where applicable). +- Ensure PR descriptions and linked work items provide traceability to the originating issue/task. + +## Pre-release Branches + +Use the following branches for pre-release flows: + +- `next` +- `next-major` +- `alpha` +- `beta` + +## Canary and Automation + +- Canary releases are automatically created when a pull request targets `main`/`master`. +- Release orchestration is handled by Auto; commit style and branch naming must remain compatible with Auto workflows. diff --git a/global.json b/global.json index 54b6d854..3fd85b17 100644 --- a/global.json +++ b/global.json @@ -2,5 +2,8 @@ "sdk": { "version": "10.0.100", "rollForward": "patch" + }, + "msbuild-sdks": { + "Aspire.AppHost.Sdk": "13.1.1" } } diff --git a/keycloak/teck-customer-authz.json b/keycloak/teck-customer-authz.json deleted file mode 100644 index ad8e96bd..00000000 --- a/keycloak/teck-customer-authz.json +++ /dev/null @@ -1,2 +0,0 @@ -// REMOVED: moved to src/aspire/keycloak/teck-customer-authz.json -// Original file intentionally removed from this location to avoid duplication. \ No newline at end of file diff --git a/scripts/count_trx_tests.ps1 b/scripts/count_trx_tests.ps1 deleted file mode 100644 index 3b51b082..00000000 --- a/scripts/count_trx_tests.ps1 +++ /dev/null @@ -1,24 +0,0 @@ -$trx = Get-ChildItem -Path "TestResults" -Recurse -Filter *.trx -File -ErrorAction SilentlyContinue -if(-not $trx){ Write-Output 'No TRX files found'; exit 0 } - -$tot=0; $passed=0; $failed=0; $notExecuted=0 -foreach($f in $trx){ - try { $xml = [xml](Get-Content $f.FullName) } catch { Write-Warning "Failed to parse $($f.FullName): $_"; continue } - $nodes = $xml.SelectNodes('//UnitTestResult') - if($nodes -ne $null){ - $tot += $nodes.Count - foreach($n in $nodes){ - switch($n.outcome){ - 'Passed' { $passed++ } - 'Failed' { $failed++ } - default { $notExecuted++ } - } - } - } -} - -Write-Output "Found $($trx.Count) .trx files." -Write-Output "Total tests: $tot" -Write-Output "Passed: $passed" -Write-Output "Failed: $failed" -Write-Output "NotExecuted/Other: $notExecuted" diff --git a/scripts/parse_coverage.ps1 b/scripts/parse_coverage.ps1 deleted file mode 100644 index 0cae7707..00000000 --- a/scripts/parse_coverage.ps1 +++ /dev/null @@ -1,59 +0,0 @@ -$files = Get-ChildItem -Path "TestResults" -Recurse -Filter "coverage.cobertura.xml" -File -if (-not $files) { - Write-Output "No coverage files found under TestResults" - exit 0 -} - -$pkgTotals = @{} - -foreach ($file in $files) { - try { - $xml = [xml](Get-Content $file.FullName) - } catch { - Write-Warning "Failed to parse $($file.FullName): $_" - continue - } - $packages = $xml.coverage.packages.package - foreach ($package in $packages) { - $pkgName = $package.name - if (-not $pkgTotals.ContainsKey($pkgName)) { - $pkgTotals[$pkgName] = @{Covered=0; Valid=0} - } - $covered = 0 - $valid = 0 - foreach ($class in $package.classes.class) { - foreach ($line in $class.lines.line) { - $valid += 1 - $hits = 0 - if ($line.hits) { $hits = [int]$line.hits } - if ($hits -gt 0) { $covered += 1 } - } - } - $pkgTotals[$pkgName].Covered += $covered - $pkgTotals[$pkgName].Valid += $valid - } -} - -# Prepare output -$rows = @() -foreach ($k in $pkgTotals.Keys) { - $cov = $pkgTotals[$k].Covered - $val = $pkgTotals[$k].Valid - $pct = 0.0 - if ($val -gt 0) { $pct = 100.0 * $cov / $val } - $rows += [PSCustomObject]@{Package=$k; Covered=$cov; Valid=$val; Percent=$pct} -} -$rows = $rows | Sort-Object -Property Percent -Descending - -Write-Output "Found $($files.Count) coverage files. Parsed $($rows.Count) packages.`n" -Write-Output ("{0,9} {1,12} {2,10} {3}" -f 'Coverage%','LinesCovered','LinesValid','Package') -Write-Output ('-'*80) -foreach ($r in $rows) { - Write-Output ("{0,8:N2}% {1,12} {2,10} {3}" -f $r.Percent,$r.Covered,$r.Valid,$r.Package) -} - -$totalCov = ($rows | Measure-Object -Property Covered -Sum).Sum -$totalVal = ($rows | Measure-Object -Property Valid -Sum).Sum -$overall = 0.0 -if ($totalVal -gt 0) { $overall = 100.0 * $totalCov / $totalVal } -Write-Output "`nOverall combined coverage: {0:N2}% ({1} / {2})" -f $overall,$totalCov,$totalVal diff --git a/scripts/verify-signatures.sh b/scripts/verify-signatures.sh deleted file mode 100644 index 9d59c2d8..00000000 --- a/scripts/verify-signatures.sh +++ /dev/null @@ -1,351 +0,0 @@ -#!/usr/bin/env bash -# -# Verify Cosign signatures for Teck Cloud images and artifacts -# -# Usage: -# ./scripts/verify-signatures.sh [service] -# -# Examples: -# ./scripts/verify-signatures.sh 1.0.0 # Verify all services -# ./scripts/verify-signatures.sh 1.0.0 catalog # Verify only catalog service -# - -set -euo pipefail - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color - -# Configuration -HARBOR_URL="${HARBOR_URL:-harbor.tecklab.dk}" -GITHUB_REPO="${GITHUB_REPO:-Teck/Teck.Cloud}" -CERT_IDENTITY_REGEX="^https://github.com/${GITHUB_REPO}/.*" -OIDC_ISSUER="https://token.actions.githubusercontent.com" - -# Functions -print_header() { - echo -e "\n${BLUE}โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”${NC}" - echo -e "${BLUE}$1${NC}" - echo -e "${BLUE}โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”${NC}" -} - -print_success() { - echo -e "${GREEN}โœ“${NC} $1" -} - -print_error() { - echo -e "${RED}โœ—${NC} $1" -} - -print_info() { - echo -e "${YELLOW}โ„น${NC} $1" -} - -check_dependencies() { - print_header "Checking Dependencies" - - local missing_deps=() - - if ! command -v cosign &> /dev/null; then - missing_deps+=("cosign") - fi - - if ! command -v gh &> /dev/null; then - missing_deps+=("gh") - fi - - if ! command -v jq &> /dev/null; then - missing_deps+=("jq") - fi - - if [ ${#missing_deps[@]} -ne 0 ]; then - print_error "Missing required dependencies: ${missing_deps[*]}" - echo "" - echo "Install instructions:" - echo " - cosign: https://docs.sigstore.dev/cosign/installation/" - echo " - gh: https://cli.github.com/manual/installation" - echo " - jq: https://stedolan.github.io/jq/download/" - exit 1 - fi - - print_success "All dependencies installed" - echo " - cosign: $(cosign version | head -n1)" - echo " - gh: $(gh --version | head -n1)" - echo " - jq: $(jq --version)" -} - -verify_image_signature() { - local service=$1 - local version=$2 - local image_ref="${HARBOR_URL}/teck-lab/${service}:${version}" - - print_header "Verifying Image Signature: ${service}" - - print_info "Image: ${image_ref}" - - if cosign verify \ - --certificate-identity-regexp="${CERT_IDENTITY_REGEX}" \ - --certificate-oidc-issuer="${OIDC_ISSUER}" \ - "${image_ref}" > /dev/null 2>&1; then - print_success "Image signature verified successfully" - - # Show signature details - echo "" - echo "Signature details:" - cosign verify \ - --certificate-identity-regexp="${CERT_IDENTITY_REGEX}" \ - --certificate-oidc-issuer="${OIDC_ISSUER}" \ - "${image_ref}" 2>/dev/null | jq -r '.[0] | " Subject: \(.optional.Subject // "N/A")\n Issuer: \(.optional.Issuer // "N/A")\n Integrated Time: \(.optional.SignedCertificateTimestamp // [] | .[0].SignedCertificateTimestamp // "N/A")"' - return 0 - else - print_error "Image signature verification failed" - return 1 - fi -} - -verify_sbom_signature() { - local service=$1 - local version=$2 - local sbom_format=$3 # spdx or cyclonedx - - print_header "Verifying ${sbom_format^^} SBOM Signature: ${service}" - - local sbom_file="sbom-${service}.${sbom_format}.json" - local sig_file="${sbom_file}.sig" - local cert_file="${sbom_file}.cert" - local bundle_file="${sbom_file}.bundle" - - # Create temp directory - local temp_dir=$(mktemp -d) - trap "rm -rf ${temp_dir}" EXIT - - cd "${temp_dir}" - - print_info "Downloading SBOM artifacts from GitHub release v${version}" - - # Download SBOM files - if gh release download "v${version}" \ - --repo "${GITHUB_REPO}" \ - --pattern "${sbom_file}*" 2>/dev/null; then - - print_success "SBOM artifacts downloaded" - - # Verify using bundle if available - if [ -f "${bundle_file}" ]; then - if cosign verify-blob \ - --bundle "${bundle_file}" \ - --certificate-identity-regexp="${CERT_IDENTITY_REGEX}" \ - --certificate-oidc-issuer="${OIDC_ISSUER}" \ - "${sbom_file}" > /dev/null 2>&1; then - print_success "${sbom_format^^} SBOM signature verified successfully" - - # Show SBOM stats - echo "" - echo "SBOM statistics:" - if [ "${sbom_format}" = "spdx" ]; then - echo " SPDX Version: $(jq -r '.spdxVersion' ${sbom_file})" - echo " Packages: $(jq '.packages | length' ${sbom_file})" - elif [ "${sbom_format}" = "cyclonedx" ]; then - echo " CycloneDX Version: $(jq -r '.specVersion' ${sbom_file})" - echo " Components: $(jq '.components | length' ${sbom_file})" - fi - return 0 - else - print_error "${sbom_format^^} SBOM signature verification failed" - return 1 - fi - else - print_error "Bundle file not found: ${bundle_file}" - return 1 - fi - else - print_error "Failed to download SBOM artifacts" - return 1 - fi -} - -verify_vex_signature() { - local service=$1 - local version=$2 - - print_header "Verifying VEX Signature: ${service}" - - local vex_file="vex-${service}.openvex.json" - local bundle_file="${vex_file}.bundle" - - # Create temp directory - local temp_dir=$(mktemp -d) - trap "rm -rf ${temp_dir}" EXIT - - cd "${temp_dir}" - - print_info "Downloading VEX artifacts from GitHub release v${version}" - - # Download VEX files - if gh release download "v${version}" \ - --repo "${GITHUB_REPO}" \ - --pattern "${vex_file}*" 2>/dev/null; then - - print_success "VEX artifacts downloaded" - - if [ -f "${bundle_file}" ]; then - if cosign verify-blob \ - --bundle "${bundle_file}" \ - --certificate-identity-regexp="${CERT_IDENTITY_REGEX}" \ - --certificate-oidc-issuer="${OIDC_ISSUER}" \ - "${vex_file}" > /dev/null 2>&1; then - print_success "VEX signature verified successfully" - - # Show VEX stats - echo "" - echo "VEX statistics:" - echo " Statements: $(jq '.statements | length' ${vex_file})" - echo " Affected: $(jq '[.statements[] | select(.status == "affected")] | length' ${vex_file})" - echo " Not Affected: $(jq '[.statements[] | select(.status == "not_affected")] | length' ${vex_file})" - echo " Under Investigation: $(jq '[.statements[] | select(.status == "under_investigation")] | length' ${vex_file})" - return 0 - else - print_error "VEX signature verification failed" - return 1 - fi - else - print_error "Bundle file not found: ${bundle_file}" - return 1 - fi - else - print_error "Failed to download VEX artifacts (may not exist for this service)" - return 1 - fi -} - -verify_attestations() { - local service=$1 - local version=$2 - local image_ref="${HARBOR_URL}/teck-lab/${service}:${version}" - - print_header "Verifying Attestations: ${service}" - - print_info "Verifying SBOM attestation" - if gh attestation verify "oci://${image_ref}" --owner "$(echo ${GITHUB_REPO} | cut -d'/' -f1)" > /dev/null 2>&1; then - print_success "SBOM attestation verified" - else - print_error "SBOM attestation verification failed" - return 1 - fi - - print_info "Verifying build provenance attestation (SLSA)" - if gh attestation verify "oci://${image_ref}" \ - --predicate-type "https://slsa.dev/provenance/v1" \ - --owner "$(echo ${GITHUB_REPO} | cut -d'/' -f1)" > /dev/null 2>&1; then - print_success "Build provenance attestation verified (SLSA L3)" - else - print_error "Build provenance attestation verification failed" - return 1 - fi - - return 0 -} - -verify_service() { - local service=$1 - local version=$2 - - print_header "๐Ÿ” Verifying Service: ${service} v${version}" - - local failed=0 - - # Verify image signature - verify_image_signature "${service}" "${version}" || failed=1 - - # Verify SPDX SBOM signature - verify_sbom_signature "${service}" "${version}" "spdx" || failed=1 - - # Verify CycloneDX SBOM signature - verify_sbom_signature "${service}" "${version}" "cyclonedx" || failed=1 - - # Verify VEX signature - verify_vex_signature "${service}" "${version}" || failed=1 - - # Verify attestations - verify_attestations "${service}" "${version}" || failed=1 - - if [ $failed -eq 0 ]; then - print_header "โœ… ALL VERIFICATIONS PASSED for ${service}" - return 0 - else - print_header "โŒ SOME VERIFICATIONS FAILED for ${service}" - return 1 - fi -} - -main() { - if [ $# -lt 1 ]; then - echo "Usage: $0 [service]" - echo "" - echo "Examples:" - echo " $0 1.0.0 # Verify all services" - echo " $0 1.0.0 catalog # Verify only catalog service" - exit 1 - fi - - local version=$1 - local specific_service=${2:-} - - # Remove 'v' prefix if present - version="${version#v}" - - check_dependencies - - if [ -n "${specific_service}" ]; then - # Verify specific service - verify_service "${specific_service}" "${version}" - exit $? - else - # Verify all services - print_header "๐Ÿ” Discovering services from release v${version}" - - # Get list of SBOM files from release - local services=() - while IFS= read -r asset; do - if [[ $asset =~ sbom-([^.]+)\.spdx\.json ]]; then - services+=("${BASH_REMATCH[1]}") - fi - done < <(gh release view "v${version}" --repo "${GITHUB_REPO}" --json assets -q '.assets[].name' 2>/dev/null) - - if [ ${#services[@]} -eq 0 ]; then - print_error "No services found in release v${version}" - echo "" - echo "Available releases:" - gh release list --repo "${GITHUB_REPO}" --limit 5 - exit 1 - fi - - print_success "Found ${#services[@]} service(s): ${services[*]}" - - local total_failed=0 - for service in "${services[@]}"; do - verify_service "${service}" "${version}" || total_failed=$((total_failed + 1)) - done - - echo "" - print_header "๐Ÿ“Š Verification Summary" - echo " Total services: ${#services[@]}" - echo " Passed: $((${#services[@]} - total_failed))" - echo " Failed: ${total_failed}" - - if [ $total_failed -eq 0 ]; then - echo "" - print_success "All verifications passed! ๐ŸŽ‰" - exit 0 - else - echo "" - print_error "${total_failed} service(s) failed verification" - exit 1 - fi - fi -} - -main "$@" diff --git a/src/aspire/Teck.Cloud.AppHost/Program.cs b/src/aspire/Teck.Cloud.AppHost/Program.cs index d3213330..ea12b525 100644 --- a/src/aspire/Teck.Cloud.AppHost/Program.cs +++ b/src/aspire/Teck.Cloud.AppHost/Program.cs @@ -24,11 +24,16 @@ var rabbitmq = builder.AddRabbitMQ("rabbitmq", rabbitmqUserName, rabbitmqPassword).WithManagementPlugin(); -var keycloak = builder.AddKeycloakContainer("keycloak", "26.5.1") +var keycloak = builder.AddKeycloakContainer("keycloak", "0.14.1") + .WithImage("ghcr.io/teck-lab/teck-cloud/auth") .WithDataVolume("local"); var realm = keycloak.AddRealm("Teck-Cloud"); +const string edgeTrustSigningKey = "local-edge-trust-signing-key-change-me-0123456789"; +const string edgeTrustIssuer = "teck-edge"; +const string edgeTrustAudience = "teck-web-bff-internal"; + var catalogapi = builder.AddProject("catalog-api") .WithReference(cache) @@ -45,13 +50,8 @@ .WithEnvironment("ConnectionStrings__postgres-read", "${POSTGRES_WRITE_URI}") .WithEnvironment("ConnectionStrings__rabbitmq", "${RABBITMQ_URI}") .WithEnvironment("ConnectionStrings__redis", "${REDIS_URI}") - .WithEnvironment("Services__CustomerApi__Url", "${CUSTOMERAPI_URL}") - .WithEnvironment("ASPIRE_LOCAL", "true") - .WithEnvironment("Vault__Address", "http://host.docker.internal:8200") - .WithEnvironment("Vault__AuthMethod", "UserPass") - .WithEnvironment("Vault__Username", "teck-cloud-local") - .WithEnvironment("Vault__Password", "Multiply4-Musty6-Tradition7-Perennial7-Acclaim4-Never2") - .WithEnvironment("Vault__Namespace", "development"); + .WithEnvironment("Services__CustomerApi__Url", "http://customer-api") + .WithEnvironment("ASPIRE_LOCAL", "true"); var customerapi = builder.AddProject("customer-api") .WithReference(cache) @@ -66,13 +66,7 @@ .WithEnvironment("ConnectionStrings__postgres-read", "${POSTGRES_WRITE_URI}") .WithEnvironment("ConnectionStrings__rabbitmq", "${RABBITMQ_URI}") .WithEnvironment("ConnectionStrings__redis", "${REDIS_URI}") - .WithEnvironment("Services__CustomerApi__Url", "${CUSTOMERAPI_URL}") - .WithEnvironment("ASPIRE_LOCAL", "true") - .WithEnvironment("Vault__Address", "http://host.docker.internal:8200") - .WithEnvironment("Vault__AuthMethod", "UserPass") - .WithEnvironment("Vault__Username", "teck-cloud-local") - .WithEnvironment("Vault__Password", "Multiply4-Musty6-Tradition7-Perennial7-Acclaim4-Never2") - .WithEnvironment("Vault__Namespace", "development"); + .WithEnvironment("ASPIRE_LOCAL", "true"); var webbff = builder.AddProject("web-bff") .WithReference(cache) @@ -85,13 +79,32 @@ .WithEnvironment("ConnectionStrings__postgres-read", "${POSTGRES_WRITE_URI}") .WithEnvironment("ConnectionStrings__rabbitmq", "${RABBITMQ_URI}") .WithEnvironment("ConnectionStrings__redis", "${REDIS_URI}") - .WithEnvironment("Services__CustomerApi__Url", "${CUSTOMERAPI_URL}") - .WithEnvironment("ASPIRE_LOCAL", "true") - .WithEnvironment("Vault__Address", "http://host.docker.internal:8200") - .WithEnvironment("Vault__AuthMethod", "UserPass") - .WithEnvironment("Vault__Username", "teck-cloud-local") - .WithEnvironment("Vault__Password", "Multiply4-Musty6-Tradition7-Perennial7-Acclaim4-Never2") - .WithEnvironment("Vault__Namespace", "development"); + .WithEnvironment("Services__CustomerApi__Url", "http://customer-api") + .WithEnvironment("ReverseProxy__Clusters__catalog__Destinations__cluster1__Address", "http://catalog-api") + .WithEnvironment("EdgeTrust__SigningKey", edgeTrustSigningKey) + .WithEnvironment("EdgeTrust__Issuer", edgeTrustIssuer) + .WithEnvironment("EdgeTrust__Audience", edgeTrustAudience) + .WithEnvironment("EdgeTrust__Enforce", "true") + .WithEnvironment("ASPIRE_LOCAL", "true"); + +var webedge = builder.AddProject("web-edge") + .WithReference(keycloak) + .WithReference(realm) + .WithEnvironment("ReverseProxy__Clusters__bff__Destinations__cluster1__Address", "http://web-bff") + .WithEnvironment("ReverseProxy__Clusters__catalog__Destinations__cluster1__Address", "http://catalog-api") + .WithEnvironment("ReverseProxy__Clusters__customer__Destinations__cluster1__Address", "http://customer-api") + .WithEnvironment("EdgeTrust__SigningKey", edgeTrustSigningKey) + .WithEnvironment("EdgeTrust__Issuer", edgeTrustIssuer) + .WithEnvironment("EdgeTrust__Audience", edgeTrustAudience) + .WithEnvironment("EdgeTrust__LifetimeSeconds", "120") + .WithEnvironment("ASPIRE_LOCAL", "true"); + +catalogapi.WithReference(customerapi).WaitFor(customerapi); +webbff.WithReference(customerapi).WaitFor(customerapi); +webbff.WithReference(catalogapi).WaitFor(catalogapi); +webedge.WithReference(webbff).WaitFor(webbff); +webedge.WithReference(catalogapi).WaitFor(catalogapi); +webedge.WithReference(customerapi).WaitFor(customerapi); // Configure multi-tenant settings for Keycloak nested organization claims // These will be passed to the API projects as environment variables diff --git a/src/aspire/Teck.Cloud.AppHost/Properties/launchSettings.json b/src/aspire/Teck.Cloud.AppHost/Properties/launchSettings.json index e915ee2f..1b278f64 100644 --- a/src/aspire/Teck.Cloud.AppHost/Properties/launchSettings.json +++ b/src/aspire/Teck.Cloud.AppHost/Properties/launchSettings.json @@ -9,8 +9,9 @@ "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development", "DOTNET_ENVIRONMENT": "Development", - "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21010", - "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22241", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:19035", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20108", + "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true", "DOTNET_ASPIRE_CONTAINER_RUNTIME": "docker" } }, @@ -24,6 +25,7 @@ "DOTNET_ENVIRONMENT": "Development", "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19035", "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20108", + "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true", "DOTNET_ASPIRE_CONTAINER_RUNTIME": "docker" } } diff --git a/src/aspire/Teck.Cloud.AppHost/Teck.Cloud.AppHost.csproj b/src/aspire/Teck.Cloud.AppHost/Teck.Cloud.AppHost.csproj index 11edae8b..f931bad5 100644 --- a/src/aspire/Teck.Cloud.AppHost/Teck.Cloud.AppHost.csproj +++ b/src/aspire/Teck.Cloud.AppHost/Teck.Cloud.AppHost.csproj @@ -1,5 +1,5 @@ - + Exe net10.0 @@ -24,6 +24,7 @@ + diff --git a/src/auth/Dockerfile b/src/auth/Dockerfile index 5d3511e7..285f686a 100644 --- a/src/auth/Dockerfile +++ b/src/auth/Dockerfile @@ -1,23 +1,35 @@ # Dockerfile for Teck Keycloak Auth -# Based on Phase Two Keycloak distribution -ARG KEYCLOAK_VERSION=26.5.2 - -FROM quay.io/phasetwo/phasetwo-keycloak:${KEYCLOAK_VERSION} +# Based on official Keycloak distribution +ARG KEYCLOAK_VERSION=26.5.23 ARG VERSION=1.0.0 + +FROM quay.io/keycloak/keycloak:${KEYCLOAK_VERSION} as builder + +USER 1000 +WORKDIR /opt/keycloak + +ADD --chown=1000:0 https://github.com/emirhannaneli/keycloak-shadcn/releases/download/v1.0.25/keycloak-shadcn-26.2-and-above.jar /opt/keycloak/providers/keycloak-shadcn-theme.jar + +RUN /opt/keycloak/bin/kc.sh build + +FROM quay.io/keycloak/keycloak:${KEYCLOAK_VERSION} +ARG VERSION ARG KEYCLOAK_VERSION USER 1000 WORKDIR /opt/keycloak +COPY --from=builder /opt/keycloak/ /opt/keycloak/ + LABEL org.opencontainers.image.title="Teck Keycloak Auth" \ - org.opencontainers.image.description="Keycloak authentication service with Phase Two extensions" \ + org.opencontainers.image.description="Keycloak authentication service" \ org.opencontainers.image.version="${VERSION}" \ org.opencontainers.image.authors="Teck Team" \ org.opencontainers.image.vendor="Teck" \ org.opencontainers.image.licenses="MIT" \ - org.opencontainers.image.base.name="quay.io/phasetwo/phasetwo-keycloak:${KEYCLOAK_VERSION}" + org.opencontainers.image.base.name="quay.io/keycloak/keycloak:${KEYCLOAK_VERSION}" LABEL com.teck.service.name="auth" \ com.teck.service.type="keycloak" \ com.teck.keycloak.version="${KEYCLOAK_VERSION}" \ - com.teck.keycloak.distribution="phasetwo" + com.teck.keycloak.distribution="official" diff --git a/src/auth/README.md b/src/auth/README.md index 8c291407..45f44da6 100644 --- a/src/auth/README.md +++ b/src/auth/README.md @@ -1,11 +1,11 @@ # Teck Auth (Keycloak) -Custom Keycloak image for Teck.Cloud, based on [Phase Two Keycloak](https://github.com/p2-inc/keycloak) with the [Tailcloakify](https://github.com/ALMiG-Kompressoren-GmbH/tailcloakify) theme. +Custom Keycloak image for Teck.Cloud, based on the [official Keycloak image](https://www.keycloak.org/server/containers) with the [keycloak-shadcn](https://github.com/emirhannaneli/keycloak-shadcn) theme extension. ## Image -- **Base:** `quay.io/phasetwo/phasetwo-keycloak:26.5.2` -- **Theme:** Tailcloakify (JAR added to `/opt/keycloak/providers/`) +- **Base:** `quay.io/keycloak/keycloak:26.5.23` +- **Theme:** keycloak-shadcn (JAR added to `/opt/keycloak/providers/`) - **Tags:** Same semantic version as the rest of the repo (e.g. `v1.2.3`), published to GHCR as `ghcr.io///auth:`. ## Build args @@ -16,7 +16,7 @@ Custom Keycloak image for Teck.Cloud, based on [Phase Two Keycloak](https://gith | `BUILD_DATE` | - | OCI image created timestamp | | `VCS_REF` | - | Git commit SHA | | `VCS_URL` | - | Repository URL | -| `KEYCLOAK_VERSION` | 26.5.2 | Base Keycloak version | +| `KEYCLOAK_VERSION` | 26.5.23 | Base Keycloak version | ## Local build @@ -30,4 +30,4 @@ The auth image is built and published by `.github/workflows/docker-publish.yaml` ## Theme configuration -Tailcloakify and Phase Two are configured via Keycloak environment variables. See Phase Two and Tailcloakify documentation for theme and realm options. +The image includes the keycloak-shadcn extension JAR. Configure theme behavior via Keycloak realm/theme settings and relevant environment variables. diff --git a/src/auth/realm-export.json b/src/auth/realm-export.json new file mode 100644 index 00000000..c310b879 --- /dev/null +++ b/src/auth/realm-export.json @@ -0,0 +1,3885 @@ +{ + "id": "c72f77b8-94bd-4019-b8cd-db8e06d21e2a", + "realm": "Teck.Cloud", + "notBefore": 0, + "defaultSignatureAlgorithm": "RS256", + "revokeRefreshToken": false, + "refreshTokenMaxReuse": 0, + "accessTokenLifespan": 300, + "accessTokenLifespanForImplicitFlow": 900, + "ssoSessionIdleTimeout": 1800, + "ssoSessionMaxLifespan": 36000, + "ssoSessionIdleTimeoutRememberMe": 0, + "ssoSessionMaxLifespanRememberMe": 0, + "offlineSessionIdleTimeout": 2592000, + "offlineSessionMaxLifespanEnabled": false, + "offlineSessionMaxLifespan": 5184000, + "clientSessionIdleTimeout": 0, + "clientSessionMaxLifespan": 0, + "clientOfflineSessionIdleTimeout": 0, + "clientOfflineSessionMaxLifespan": 0, + "accessCodeLifespan": 60, + "accessCodeLifespanUserAction": 300, + "accessCodeLifespanLogin": 1800, + "actionTokenGeneratedByAdminLifespan": 43200, + "actionTokenGeneratedByUserLifespan": 300, + "oauth2DeviceCodeLifespan": 600, + "oauth2DevicePollingInterval": 5, + "enabled": true, + "sslRequired": "external", + "registrationAllowed": false, + "registrationEmailAsUsername": false, + "rememberMe": false, + "verifyEmail": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": false, + "editUsernameAllowed": false, + "bruteForceProtected": false, + "permanentLockout": false, + "maxTemporaryLockouts": 0, + "bruteForceStrategy": "MULTIPLE", + "maxFailureWaitSeconds": 900, + "minimumQuickLoginWaitSeconds": 60, + "waitIncrementSeconds": 60, + "quickLoginCheckMilliSeconds": 1000, + "maxDeltaTimeSeconds": 43200, + "failureFactor": 30, + "roles": { + "realm": [ + { + "id": "408228d5-a69f-41ec-a524-96db1fd8a6ac", + "name": "uma_authorization", + "description": "${role_uma_authorization}", + "composite": false, + "clientRole": false, + "containerId": "c72f77b8-94bd-4019-b8cd-db8e06d21e2a", + "attributes": {} + }, + { + "id": "92018e4c-f1d5-42ba-a2af-9c26da71e5bb", + "name": "default-roles-teck.cloud", + "description": "${role_default-roles}", + "composite": true, + "composites": { + "realm": [ + "offline_access", + "uma_authorization" + ], + "client": { + "account": [ + "manage-account", + "view-profile" + ] + } + }, + "clientRole": false, + "containerId": "c72f77b8-94bd-4019-b8cd-db8e06d21e2a", + "attributes": {} + }, + { + "id": "e89b806d-7907-4e18-b002-eff766565a64", + "name": "offline_access", + "description": "${role_offline-access}", + "composite": false, + "clientRole": false, + "containerId": "c72f77b8-94bd-4019-b8cd-db8e06d21e2a", + "attributes": {} + } + ], + "client": { + "realm-management": [ + { + "id": "f4a83440-8c2c-4afb-9d8c-766d47dc9e2c", + "name": "query-clients", + "description": "${role_query-clients}", + "composite": false, + "clientRole": true, + "containerId": "b988d40c-64fa-4f8b-86f5-41f8d44d668c", + "attributes": {} + }, + { + "id": "deb99a17-cbac-461e-959c-83b8562d4f12", + "name": "view-authorization", + "description": "${role_view-authorization}", + "composite": false, + "clientRole": true, + "containerId": "b988d40c-64fa-4f8b-86f5-41f8d44d668c", + "attributes": {} + }, + { + "id": "e5903a85-8250-49ca-b347-ed9760cc39e5", + "name": "manage-realm", + "description": "${role_manage-realm}", + "composite": false, + "clientRole": true, + "containerId": "b988d40c-64fa-4f8b-86f5-41f8d44d668c", + "attributes": {} + }, + { + "id": "fa2eea0d-c39f-4e54-9a18-b9c689d6d96b", + "name": "create-organization", + "description": "${role_create-organization}", + "composite": false, + "clientRole": true, + "containerId": "b988d40c-64fa-4f8b-86f5-41f8d44d668c", + "attributes": {} + }, + { + "id": "962beb29-c232-4728-8328-309035e6a3d9", + "name": "manage-events", + "description": "${role_manage-events}", + "composite": false, + "clientRole": true, + "containerId": "b988d40c-64fa-4f8b-86f5-41f8d44d668c", + "attributes": {} + }, + { + "id": "a458c2fc-13a0-4441-ad02-e24ce3efae91", + "name": "manage-organizations", + "description": "${role_manage-organizations}", + "composite": false, + "clientRole": true, + "containerId": "b988d40c-64fa-4f8b-86f5-41f8d44d668c", + "attributes": {} + }, + { + "id": "fcf45ff7-dfa8-499b-b8f5-870c78e91424", + "name": "create-client", + "description": "${role_create-client}", + "composite": false, + "clientRole": true, + "containerId": "b988d40c-64fa-4f8b-86f5-41f8d44d668c", + "attributes": {} + }, + { + "id": "33c6ab76-fdf5-4749-a291-956d082f5493", + "name": "realm-admin", + "description": "${role_realm-admin}", + "composite": true, + "composites": { + "client": { + "realm-management": [ + "query-clients", + "view-authorization", + "manage-realm", + "manage-events", + "manage-organizations", + "create-client", + "view-organizations", + "manage-identity-providers", + "query-users", + "view-realm", + "view-identity-providers", + "manage-users", + "view-events", + "manage-authorization", + "query-groups", + "impersonation", + "manage-clients", + "query-realms", + "view-users", + "publish-events", + "view-clients" + ] + } + }, + "clientRole": true, + "containerId": "b988d40c-64fa-4f8b-86f5-41f8d44d668c", + "attributes": {} + }, + { + "id": "45eecc3e-9c43-4da4-9c7f-aaea5441560b", + "name": "view-organizations", + "description": "${role_view-organizations}", + "composite": false, + "clientRole": true, + "containerId": "b988d40c-64fa-4f8b-86f5-41f8d44d668c", + "attributes": {} + }, + { + "id": "4768d51b-f824-49c2-8256-8cf9a0c74399", + "name": "manage-identity-providers", + "description": "${role_manage-identity-providers}", + "composite": false, + "clientRole": true, + "containerId": "b988d40c-64fa-4f8b-86f5-41f8d44d668c", + "attributes": {} + }, + { + "id": "743835d1-ca3a-4253-a567-d9bb5dcc99fb", + "name": "query-users", + "description": "${role_query-users}", + "composite": false, + "clientRole": true, + "containerId": "b988d40c-64fa-4f8b-86f5-41f8d44d668c", + "attributes": {} + }, + { + "id": "8f9e2b15-4e94-4867-80d0-6d26e36e514a", + "name": "view-realm", + "description": "${role_view-realm}", + "composite": false, + "clientRole": true, + "containerId": "b988d40c-64fa-4f8b-86f5-41f8d44d668c", + "attributes": {} + }, + { + "id": "ef130ca1-f8d2-4bb1-9f70-08eb527d3c11", + "name": "manage-users", + "description": "${role_manage-users}", + "composite": false, + "clientRole": true, + "containerId": "b988d40c-64fa-4f8b-86f5-41f8d44d668c", + "attributes": {} + }, + { + "id": "26462cb2-15f8-4328-8281-0ae4722a39b1", + "name": "view-identity-providers", + "description": "${role_view-identity-providers}", + "composite": false, + "clientRole": true, + "containerId": "b988d40c-64fa-4f8b-86f5-41f8d44d668c", + "attributes": {} + }, + { + "id": "bc409fb9-0e9d-49a7-ba94-6bec018e54e4", + "name": "view-events", + "description": "${role_view-events}", + "composite": false, + "clientRole": true, + "containerId": "b988d40c-64fa-4f8b-86f5-41f8d44d668c", + "attributes": {} + }, + { + "id": "cdb52395-3bd8-4129-9370-59f876199bdf", + "name": "manage-authorization", + "description": "${role_manage-authorization}", + "composite": false, + "clientRole": true, + "containerId": "b988d40c-64fa-4f8b-86f5-41f8d44d668c", + "attributes": {} + }, + { + "id": "e944c6ec-0a6e-4f0c-8bb4-6accc86f23c5", + "name": "query-groups", + "description": "${role_query-groups}", + "composite": false, + "clientRole": true, + "containerId": "b988d40c-64fa-4f8b-86f5-41f8d44d668c", + "attributes": {} + }, + { + "id": "cbff78db-645c-43a3-8f5c-cef9b661c9c8", + "name": "impersonation", + "description": "${role_impersonation}", + "composite": false, + "clientRole": true, + "containerId": "b988d40c-64fa-4f8b-86f5-41f8d44d668c", + "attributes": {} + }, + { + "id": "828323ef-357f-4ad7-b6e4-68fd5d0d783c", + "name": "manage-clients", + "description": "${role_manage-clients}", + "composite": false, + "clientRole": true, + "containerId": "b988d40c-64fa-4f8b-86f5-41f8d44d668c", + "attributes": {} + }, + { + "id": "eab56501-b600-4a09-bd0c-9f467fe4359a", + "name": "query-realms", + "description": "${role_query-realms}", + "composite": false, + "clientRole": true, + "containerId": "b988d40c-64fa-4f8b-86f5-41f8d44d668c", + "attributes": {} + }, + { + "id": "4aaefa60-f2d5-452c-b327-43b207974368", + "name": "publish-events", + "description": "${role_publish-events}", + "composite": false, + "clientRole": true, + "containerId": "b988d40c-64fa-4f8b-86f5-41f8d44d668c", + "attributes": {} + }, + { + "id": "8b5ca978-ad62-4060-876d-557c043f690a", + "name": "view-users", + "description": "${role_view-users}", + "composite": true, + "composites": { + "client": { + "realm-management": [ + "query-users", + "query-groups" + ] + } + }, + "clientRole": true, + "containerId": "b988d40c-64fa-4f8b-86f5-41f8d44d668c", + "attributes": {} + }, + { + "id": "f0a19dcf-908a-4770-b347-679156ca69ad", + "name": "view-clients", + "description": "${role_view-clients}", + "composite": true, + "composites": { + "client": { + "realm-management": [ + "query-clients" + ] + } + }, + "clientRole": true, + "containerId": "b988d40c-64fa-4f8b-86f5-41f8d44d668c", + "attributes": {} + } + ], + "security-admin-console": [], + "teck-edge-gateway": [], + "account-console": [], + "idp-wizard": [], + "teck-catalog": [ + { + "id": "cd9ef530-9878-40c5-8ee4-09be64c85d69", + "name": "admin", + "description": "", + "composite": false, + "clientRole": true, + "containerId": "8b49cfd9-3aa8-4f85-b0cb-d54e3aa73805", + "attributes": {} + }, + { + "id": "4faa4736-f4f1-4c5b-8b68-0ef5d720ed1e", + "name": "uma_protection", + "composite": false, + "clientRole": true, + "containerId": "8b49cfd9-3aa8-4f85-b0cb-d54e3aa73805", + "attributes": {} + }, + { + "id": "aa4432d0-6e56-4a95-9341-d61bfe4274df", + "name": "writer", + "description": "", + "composite": false, + "clientRole": true, + "containerId": "8b49cfd9-3aa8-4f85-b0cb-d54e3aa73805", + "attributes": {} + }, + { + "id": "00fd8d9f-c77f-4787-8cba-fdeb6e12e99b", + "name": "reader", + "description": "", + "composite": false, + "clientRole": true, + "containerId": "8b49cfd9-3aa8-4f85-b0cb-d54e3aa73805", + "attributes": {} + } + ], + "admin-portal": [], + "broker": [ + { + "id": "6d9f3768-9793-416b-bfed-49afb8e23c59", + "name": "read-token", + "description": "${role_read-token}", + "composite": false, + "clientRole": true, + "containerId": "8da6521f-04ef-49cd-aea1-9f9c528b2c49", + "attributes": {} + } + ], + "teck-customer": [ + { + "id": "c21dd37c-19b9-4f26-98fb-00cbcbd9f721", + "name": "uma_protection", + "composite": false, + "clientRole": true, + "containerId": "12a22065-eda1-4dae-be62-27db3af60ae3", + "attributes": {} + }, + { + "id": "49463cb5-6d9f-433b-96de-31590974a5c7", + "name": "reader", + "description": "", + "composite": false, + "clientRole": true, + "containerId": "12a22065-eda1-4dae-be62-27db3af60ae3", + "attributes": {} + }, + { + "id": "b8c43bc4-6ba3-480f-b109-af0673739f03", + "name": "admin", + "description": "", + "composite": false, + "clientRole": true, + "containerId": "12a22065-eda1-4dae-be62-27db3af60ae3", + "attributes": {} + }, + { + "id": "a1b85649-3e56-4727-a680-dc3cd7accda5", + "name": "writer", + "description": "", + "composite": false, + "clientRole": true, + "containerId": "12a22065-eda1-4dae-be62-27db3af60ae3", + "attributes": {} + } + ], + "admin-cli": [], + "idp-tester": [], + "teck-web-bff": [], + "account": [ + { + "id": "50ae677f-4619-4c5e-8ee0-5ce1c87794e9", + "name": "view-groups", + "description": "${role_view-groups}", + "composite": false, + "clientRole": true, + "containerId": "79c6b706-dfa3-46e3-9bf4-f4fbcfe9806d", + "attributes": {} + }, + { + "id": "fd6eeb05-82c3-4c45-a6a6-7e8c6f4b61c9", + "name": "manage-account", + "description": "${role_manage-account}", + "composite": true, + "composites": { + "client": { + "account": [ + "manage-account-links" + ] + } + }, + "clientRole": true, + "containerId": "79c6b706-dfa3-46e3-9bf4-f4fbcfe9806d", + "attributes": {} + }, + { + "id": "0570ef71-052a-4686-acd5-808b74c4e02e", + "name": "manage-consent", + "description": "${role_manage-consent}", + "composite": true, + "composites": { + "client": { + "account": [ + "view-consent" + ] + } + }, + "clientRole": true, + "containerId": "79c6b706-dfa3-46e3-9bf4-f4fbcfe9806d", + "attributes": {} + }, + { + "id": "ae3a1c80-2f37-4581-8b70-e00bacca3fba", + "name": "view-consent", + "description": "${role_view-consent}", + "composite": false, + "clientRole": true, + "containerId": "79c6b706-dfa3-46e3-9bf4-f4fbcfe9806d", + "attributes": {} + }, + { + "id": "fb858639-f8fc-40c3-bbb6-9c86cd0e6494", + "name": "manage-account-links", + "description": "${role_manage-account-links}", + "composite": false, + "clientRole": true, + "containerId": "79c6b706-dfa3-46e3-9bf4-f4fbcfe9806d", + "attributes": {} + }, + { + "id": "0935dc12-06f2-4798-8130-7e808abaa936", + "name": "delete-account", + "description": "${role_delete-account}", + "composite": false, + "clientRole": true, + "containerId": "79c6b706-dfa3-46e3-9bf4-f4fbcfe9806d", + "attributes": {} + }, + { + "id": "8a161c7e-13e3-4637-85cd-47202637174e", + "name": "view-profile", + "description": "${role_view-profile}", + "composite": false, + "clientRole": true, + "containerId": "79c6b706-dfa3-46e3-9bf4-f4fbcfe9806d", + "attributes": {} + }, + { + "id": "caa8ea5d-7499-424f-8ae5-b1afc0af6f11", + "name": "view-applications", + "description": "${role_view-applications}", + "composite": false, + "clientRole": true, + "containerId": "79c6b706-dfa3-46e3-9bf4-f4fbcfe9806d", + "attributes": {} + } + ] + } + }, + "groups": [], + "defaultRole": { + "id": "92018e4c-f1d5-42ba-a2af-9c26da71e5bb", + "name": "default-roles-teck.cloud", + "description": "${role_default-roles}", + "composite": true, + "clientRole": false, + "containerId": "c72f77b8-94bd-4019-b8cd-db8e06d21e2a" + }, + "requiredCredentials": [ + "password" + ], + "otpPolicyType": "totp", + "otpPolicyAlgorithm": "HmacSHA1", + "otpPolicyInitialCounter": 0, + "otpPolicyDigits": 6, + "otpPolicyLookAheadWindow": 1, + "otpPolicyPeriod": 30, + "otpPolicyCodeReusable": false, + "otpSupportedApplications": [ + "totpAppFreeOTPName", + "totpAppGoogleName", + "totpAppMicrosoftAuthenticatorName" + ], + "localizationTexts": {}, + "webAuthnPolicyRpEntityName": "keycloak", + "webAuthnPolicySignatureAlgorithms": [ + "ES256", + "RS256" + ], + "webAuthnPolicyRpId": "", + "webAuthnPolicyAttestationConveyancePreference": "not specified", + "webAuthnPolicyAuthenticatorAttachment": "not specified", + "webAuthnPolicyRequireResidentKey": "not specified", + "webAuthnPolicyUserVerificationRequirement": "not specified", + "webAuthnPolicyCreateTimeout": 0, + "webAuthnPolicyAvoidSameAuthenticatorRegister": false, + "webAuthnPolicyAcceptableAaguids": [], + "webAuthnPolicyExtraOrigins": [], + "webAuthnPolicyPasswordlessRpEntityName": "keycloak", + "webAuthnPolicyPasswordlessSignatureAlgorithms": [ + "ES256", + "RS256" + ], + "webAuthnPolicyPasswordlessRpId": "", + "webAuthnPolicyPasswordlessAttestationConveyancePreference": "not specified", + "webAuthnPolicyPasswordlessAuthenticatorAttachment": "not specified", + "webAuthnPolicyPasswordlessRequireResidentKey": "Yes", + "webAuthnPolicyPasswordlessUserVerificationRequirement": "required", + "webAuthnPolicyPasswordlessCreateTimeout": 0, + "webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister": false, + "webAuthnPolicyPasswordlessAcceptableAaguids": [], + "webAuthnPolicyPasswordlessExtraOrigins": [], + "users": [ + { + "id": "8f9b8608-ff59-45da-83aa-73e6b0b98088", + "username": "service-account-teck-catalog", + "emailVerified": false, + "enabled": true, + "createdTimestamp": 1770837490414, + "totp": false, + "serviceAccountClientId": "teck-catalog", + "disableableCredentialTypes": [], + "requiredActions": [], + "realmRoles": [ + "default-roles-teck.cloud" + ], + "clientRoles": { + "teck-catalog": [ + "uma_protection" + ] + }, + "notBefore": 0, + "groups": [] + }, + { + "id": "3a94e042-c1e9-4688-b55d-b48f7ac3e5e8", + "username": "service-account-teck-customer", + "emailVerified": false, + "enabled": true, + "createdTimestamp": 1770843778776, + "totp": false, + "serviceAccountClientId": "teck-customer", + "disableableCredentialTypes": [], + "requiredActions": [], + "realmRoles": [ + "default-roles-teck.cloud" + ], + "clientRoles": { + "teck-customer": [ + "uma_protection" + ] + }, + "notBefore": 0, + "groups": [] + }, + { + "id": "2da9acbb-883a-4bdb-b430-fdc3cd7a3d04", + "username": "service-account-teck-web-bff", + "emailVerified": false, + "enabled": true, + "createdTimestamp": 1770844490782, + "totp": false, + "serviceAccountClientId": "teck-web-bff", + "disableableCredentialTypes": [], + "requiredActions": [], + "realmRoles": [ + "default-roles-teck.cloud" + ], + "clientRoles": { + "teck-customer": [ + "reader", + "writer" + ], + "teck-catalog": [ + "writer", + "reader" + ] + }, + "notBefore": 0, + "groups": [] + } + ], + "scopeMappings": [ + { + "clientScope": "offline_access", + "roles": [ + "offline_access" + ] + } + ], + "clientScopeMappings": { + "account": [ + { + "client": "account-console", + "roles": [ + "manage-account", + "view-groups" + ] + } + ], + "catalog.read": [ + { + "client": "teck-catalog", + "roles": [ + "reader" + ] + } + ], + "catalog.write": [ + { + "client": "teck-catalog", + "roles": [ + "writer" + ] + } + ], + "customer.read": [ + { + "client": "teck-customer", + "roles": [ + "reader" + ] + } + ], + "customer.write": [ + { + "client": "teck-customer", + "roles": [ + "writer" + ] + } + ] + }, + "clients": [ + { + "id": "79c6b706-dfa3-46e3-9bf4-f4fbcfe9806d", + "clientId": "account", + "name": "${client_account}", + "rootUrl": "${authBaseUrl}", + "baseUrl": "/realms/Teck.Cloud/account/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "/realms/Teck.Cloud/account/*" + ], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "realm_client": "false", + "post.logout.redirect.uris": "+" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "basic", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "organization", + "microprofile-jwt" + ] + }, + { + "id": "4ef5e783-4063-464a-ab91-78c200b9dc73", + "clientId": "account-console", + "name": "${client_account-console}", + "rootUrl": "${authBaseUrl}", + "baseUrl": "/realms/Teck.Cloud/account/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "/realms/Teck.Cloud/portal/*", + "/realms/Teck.Cloud/account/*" + ], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "realm_client": "false", + "post.logout.redirect.uris": "+", + "pkce.code.challenge.method": "S256" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "12c9c240-b19b-493b-8bf4-6fa5d8e79710", + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": false, + "config": {} + } + ], + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "basic", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "organization", + "microprofile-jwt" + ] + }, + { + "id": "8012016f-d61f-443a-870b-b7395cd0d7cb", + "clientId": "admin-cli", + "name": "${client_admin-cli}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": false, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "realm_client": "false", + "client.use.lightweight.access.token.enabled": "true" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "basic", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "organization", + "microprofile-jwt" + ] + }, + { + "id": "3d6b523f-d9c5-4a5e-abf3-4629b06d0912", + "clientId": "admin-portal", + "name": "Admin portal", + "description": "Portal for self-administration of profile and organizations.", + "rootUrl": "${authBaseUrl}", + "baseUrl": "/realms/Teck.Cloud/portal/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "redirectUris": [ + "/realms/Teck.Cloud/portal/*" + ], + "webOrigins": [ + "/*" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "realm_client": "false", + "post.logout.redirect.uris": "+" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "37e2ad59-d25a-4063-89c1-b716d1041e7d", + "name": "organization", + "protocol": "openid-connect", + "protocolMapper": "oidc-organization-membership-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true", + "claim.name": "organization", + "jsonType.label": "JSON", + "multivalued": "true" + } + } + ], + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "basic", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "organization", + "microprofile-jwt" + ] + }, + { + "id": "8da6521f-04ef-49cd-aea1-9f9c528b2c49", + "clientId": "broker", + "name": "${client_broker}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": true, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "realm_client": "true" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "basic", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "organization", + "microprofile-jwt" + ] + }, + { + "id": "f3b0d457-e5d5-4504-93e3-5586f3a69a15", + "clientId": "idp-tester", + "name": "IdP Tester", + "description": "Testing for validating vendor IdPs.", + "rootUrl": "${authBaseUrl}", + "baseUrl": "/realms/Teck.Cloud/wizard/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "redirectUris": [ + "/realms/Teck.Cloud/wizard/*" + ], + "webOrigins": [ + "/*" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "realm_client": "false", + "post.logout.redirect.uris": "+" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "basic", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "organization", + "microprofile-jwt" + ] + }, + { + "id": "bc77bf77-09a2-486a-ac21-e605677c2ba1", + "clientId": "idp-wizard", + "name": "IdP Config Wizard", + "description": "Wizards for configuring various vendor IdPs.", + "rootUrl": "${authBaseUrl}", + "baseUrl": "/realms/Teck.Cloud/wizard/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "redirectUris": [ + "/realms/Teck.Cloud/wizard/*" + ], + "webOrigins": [ + "/*" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "realm_client": "false", + "post.logout.redirect.uris": "+" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "30868bcb-a50a-46a9-a31b-f857bf30be43", + "name": "organization", + "protocol": "openid-connect", + "protocolMapper": "oidc-organization-membership-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true", + "claim.name": "organization", + "jsonType.label": "JSON", + "multivalued": "true" + } + } + ], + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "basic", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "organization", + "microprofile-jwt" + ] + }, + { + "id": "b988d40c-64fa-4f8b-86f5-41f8d44d668c", + "clientId": "realm-management", + "name": "${client_realm-management}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": true, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "realm_client": "true" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "basic", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "organization", + "microprofile-jwt" + ] + }, + { + "id": "6cc60f21-f79f-4c57-ae01-1f2b8fdb6001", + "clientId": "security-admin-console", + "name": "${client_security-admin-console}", + "rootUrl": "${authAdminUrl}", + "baseUrl": "/admin/Teck.Cloud/console/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "/admin/Teck.Cloud/console/*" + ], + "webOrigins": [ + "+" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "realm_client": "false", + "client.use.lightweight.access.token.enabled": "true", + "post.logout.redirect.uris": "+", + "pkce.code.challenge.method": "S256" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "ce35125c-f6ee-4e13-9475-3dfeb80f39bf", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + } + ], + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "basic", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "organization", + "microprofile-jwt" + ] + }, + { + "id": "8b49cfd9-3aa8-4f85-b0cb-d54e3aa73805", + "clientId": "teck-catalog", + "name": "Catalog service", + "description": "", + "rootUrl": "", + "adminUrl": "", + "baseUrl": "", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "**********", + "redirectUris": [ + "/*" + ], + "webOrigins": [ + "/*" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": true, + "authorizationServicesEnabled": true, + "publicClient": false, + "frontchannelLogout": true, + "protocol": "openid-connect", + "attributes": { + "realm_client": "false", + "oidc.ciba.grant.enabled": "false", + "client.secret.creation.time": "1770837490", + "backchannel.logout.session.required": "true", + "standard.token.exchange.enabled": "true", + "oauth2.device.authorization.grant.enabled": "false", + "use.jwks.url": "false", + "backchannel.logout.revoke.offline.tokens": "false", + "dpop.bound.access.tokens": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "defaultClientScopes": [ + "service_account", + "web-origins", + "acr", + "profile", + "roles", + "basic", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "organization", + "microprofile-jwt" + ], + "authorizationSettings": { + "allowRemoteResourceManagement": true, + "policyEnforcementMode": "ENFORCING", + "resources": [ + { + "name": "brand", + "ownerManagedAccess": false, + "displayName": "Brand", + "attributes": {}, + "uris": [], + "scopes": [ + { + "name": "read" + }, + { + "name": "update" + }, + { + "name": "delete" + }, + { + "name": "create" + } + ], + "icon_uri": "" + } + ], + "policies": [ + { + "name": "Catalog Reader Role Policy", + "description": "", + "type": "role", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "fetchRoles": "false", + "roles": "[{\"id\":\"teck-catalog/reader\",\"required\":false},{\"id\":\"teck-catalog/admin\",\"required\":false}]" + } + }, + { + "name": "Catalog Writer Role Policy", + "description": "", + "type": "role", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "fetchRoles": "false", + "roles": "[{\"id\":\"teck-catalog/reader\",\"required\":false},{\"id\":\"teck-catalog/writer\",\"required\":false}]" + } + }, + { + "name": "Catalog Admin Role Policy", + "description": "", + "type": "role", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "fetchRoles": "false", + "roles": "[{\"id\":\"teck-catalog/admin\",\"required\":false}]" + } + }, + { + "name": "Brand Create Permission", + "description": "", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "defaultResourceType": "", + "applyPolicies": "[\"Catalog Writer Role Policy\"]", + "scopes": "[\"create\"]" + } + }, + { + "name": "Brands Read Permission", + "description": "", + "type": "resource", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"brand\"]", + "applyPolicies": "[\"Catalog Reader Role Policy\"]" + } + }, + { + "name": "Brand Update Permission", + "description": "", + "type": "resource", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"brand\"]", + "applyPolicies": "[\"Catalog Writer Role Policy\"]" + } + }, + { + "name": "Brand List Permission", + "description": "", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "scopes": "[\"read\"]", + "applyPolicies": "[\"Catalog Reader Role Policy\"]" + } + }, + { + "name": "Brand Delete Permission", + "description": "", + "type": "resource", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"brand\"]", + "applyPolicies": "[\"Catalog Writer Role Policy\"]" + } + } + ], + "scopes": [ + { + "name": "read", + "iconUri": "", + "displayName": "Read" + }, + { + "name": "create", + "iconUri": "", + "displayName": "Create" + }, + { + "name": "update", + "iconUri": "", + "displayName": "Update" + }, + { + "name": "delete", + "iconUri": "", + "displayName": "Delete" + } + ], + "decisionStrategy": "UNANIMOUS" + } + }, + { + "id": "12a22065-eda1-4dae-be62-27db3af60ae3", + "clientId": "teck-customer", + "name": "Customer service", + "description": "", + "rootUrl": "", + "adminUrl": "", + "baseUrl": "", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "**********", + "redirectUris": [ + "/*" + ], + "webOrigins": [ + "/*" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": true, + "authorizationServicesEnabled": true, + "publicClient": false, + "frontchannelLogout": true, + "protocol": "openid-connect", + "attributes": { + "realm_client": "false", + "oidc.ciba.grant.enabled": "false", + "client.secret.creation.time": "1770843778", + "backchannel.logout.session.required": "true", + "standard.token.exchange.enabled": "true", + "oauth2.device.authorization.grant.enabled": "false", + "backchannel.logout.revoke.offline.tokens": "false", + "dpop.bound.access.tokens": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "defaultClientScopes": [ + "service_account", + "web-origins", + "acr", + "profile", + "roles", + "basic", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "organization", + "microprofile-jwt" + ], + "authorizationSettings": { + "allowRemoteResourceManagement": true, + "policyEnforcementMode": "ENFORCING", + "resources": [ + { + "name": "tenant", + "ownerManagedAccess": false, + "displayName": "Tenant", + "attributes": {}, + "uris": [], + "scopes": [ + { + "name": "read" + }, + { + "name": "update" + }, + { + "name": "delete" + }, + { + "name": "create" + } + ], + "icon_uri": "" + }, + { + "name": "user", + "ownerManagedAccess": false, + "displayName": "User", + "attributes": {}, + "uris": [], + "scopes": [ + { + "name": "read" + }, + { + "name": "update" + }, + { + "name": "delete" + }, + { + "name": "create" + } + ], + "icon_uri": "" + } + ], + "policies": [ + { + "name": "Customer Reader Role Policy", + "description": "", + "type": "role", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "fetchRoles": "false", + "roles": "[]" + } + }, + { + "name": "Customer Writer Role Policy", + "description": "", + "type": "role", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "fetchRoles": "false", + "roles": "[]" + } + }, + { + "name": "Customer Admin Role Policy", + "description": "", + "type": "role", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "fetchRoles": "false", + "roles": "[]" + } + }, + { + "name": "Tenant Read Permission", + "description": "", + "type": "resource", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"tenant\"]", + "applyPolicies": "[\"Customer Reader Role Policy\"]" + } + }, + { + "name": "Tenant Update Permission", + "description": "", + "type": "resource", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"tenant\"]", + "applyPolicies": "[\"Customer Writer Role Policy\"]" + } + }, + { + "name": "Tenant Delete Permission", + "description": "", + "type": "resource", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"tenant\"]", + "applyPolicies": "[\"Customer Admin Role Policy\"]" + } + }, + { + "name": "User Read Permission", + "description": "", + "type": "resource", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"user\"]", + "applyPolicies": "[\"Customer Reader Role Policy\"]" + } + }, + { + "name": "User Update Permission", + "description": "", + "type": "resource", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"user\"]", + "applyPolicies": "[\"Customer Writer Role Policy\"]" + } + }, + { + "name": "User Delete Permission", + "description": "", + "type": "resource", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"user\"]", + "applyPolicies": "[\"Customer Admin Role Policy\"]" + } + } + ], + "scopes": [ + { + "name": "read", + "iconUri": "", + "displayName": "Read" + }, + { + "name": "create", + "iconUri": "", + "displayName": "Create" + }, + { + "name": "update", + "iconUri": "", + "displayName": "Update" + }, + { + "name": "delete", + "iconUri": "", + "displayName": "Delete" + } + ], + "decisionStrategy": "UNANIMOUS" + } + }, + { + "id": "fcc1a676-4dfd-4614-8d51-4795e225f6ae", + "clientId": "teck-edge-gateway", + "name": "Edge Gateway", + "description": "YARP Edge gateway", + "rootUrl": "", + "adminUrl": "", + "baseUrl": "", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "**********", + "redirectUris": [ + "/*" + ], + "webOrigins": [ + "/*" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": true, + "protocol": "openid-connect", + "attributes": { + "realm_client": "false", + "oidc.ciba.grant.enabled": "false", + "client.secret.creation.time": "1771029449", + "backchannel.logout.session.required": "true", + "standard.token.exchange.enabled": "false", + "oauth2.jwt.authorization.grant.enabled": "false", + "oauth2.device.authorization.grant.enabled": "false", + "pkce.code.challenge.method": "S256", + "backchannel.logout.revoke.offline.tokens": "false", + "dpop.bound.access.tokens": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": -1, + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "basic", + "email", + "aud-teck-web-bff" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "organization", + "microprofile-jwt" + ] + }, + { + "id": "6326d6f8-369b-4eb2-9bb8-ce176b22bd56", + "clientId": "teck-web-bff", + "name": "Web.BFF", + "description": "", + "rootUrl": "", + "adminUrl": "", + "baseUrl": "", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "**********", + "redirectUris": [ + "/*" + ], + "webOrigins": [ + "/*" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": true, + "publicClient": false, + "frontchannelLogout": true, + "protocol": "openid-connect", + "attributes": { + "realm_client": "false", + "oidc.ciba.grant.enabled": "false", + "client.secret.creation.time": "1770844490", + "backchannel.logout.session.required": "true", + "standard.token.exchange.enabled": "true", + "oauth2.device.authorization.grant.enabled": "false", + "backchannel.logout.revoke.offline.tokens": "false", + "dpop.bound.access.tokens": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": -1, + "defaultClientScopes": [ + "service_account", + "web-origins", + "acr", + "profile", + "roles", + "basic", + "email" + ], + "optionalClientScopes": [ + "catalog.read", + "catalog.write", + "customer.read", + "customer.write", + "address", + "phone", + "offline_access", + "organization", + "microprofile-jwt" + ] + } + ], + "clientScopes": [ + { + "id": "a4784e32-caad-4a14-9c3b-93fc71e59941", + "name": "offline_access", + "description": "OpenID Connect built-in scope: offline_access", + "protocol": "openid-connect", + "attributes": { + "consent.screen.text": "${offlineAccessScopeConsentText}", + "display.on.consent.screen": "true" + } + }, + { + "id": "74630c84-e255-4c2f-baaf-95fdd95c75de", + "name": "profile", + "description": "OpenID Connect built-in scope: profile", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "consent.screen.text": "${profileScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "90816b41-7c07-44ec-b784-7f4f4207a1ec", + "name": "zoneinfo", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "zoneinfo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "zoneinfo", + "jsonType.label": "String" + } + }, + { + "id": "ce109a28-a293-43ef-87b1-f05357108405", + "name": "full name", + "protocol": "openid-connect", + "protocolMapper": "oidc-full-name-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + }, + { + "id": "23b13191-e64c-4c9e-811d-43b38ac0c639", + "name": "birthdate", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "birthdate", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "birthdate", + "jsonType.label": "String" + } + }, + { + "id": "2324b218-3d45-44b9-bead-cae6c6c73e46", + "name": "picture", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "picture", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "picture", + "jsonType.label": "String" + } + }, + { + "id": "da97c7fb-d906-4264-9374-63676a96a9fe", + "name": "given name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "firstName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "given_name", + "jsonType.label": "String" + } + }, + { + "id": "eec36dbe-8a42-484e-a80b-9a10ed83ae6a", + "name": "username", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "preferred_username", + "jsonType.label": "String" + } + }, + { + "id": "dc44f30a-4d7f-492c-8ea4-8abbd63ab91d", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + }, + { + "id": "bba9d124-98b4-4b22-a09b-dae40058012b", + "name": "gender", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "gender", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "gender", + "jsonType.label": "String" + } + }, + { + "id": "a3898169-876b-43a6-b90e-bc714ac52de3", + "name": "family name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "lastName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "family_name", + "jsonType.label": "String" + } + }, + { + "id": "65b7ab98-c20a-4821-92c0-fd6be990a06b", + "name": "updated at", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "updatedAt", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "updated_at", + "jsonType.label": "long" + } + }, + { + "id": "5de1ef43-d8b1-4a7e-9d67-88a24c85976e", + "name": "website", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "website", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "website", + "jsonType.label": "String" + } + }, + { + "id": "094d5235-026a-4341-be33-1ccc856f9aff", + "name": "profile", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "profile", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "profile", + "jsonType.label": "String" + } + }, + { + "id": "76f151e5-1c83-49b3-8069-8e94f8dcdd33", + "name": "middle name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "middleName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "middle_name", + "jsonType.label": "String" + } + }, + { + "id": "28cbff33-b944-415d-893c-f438b82d4d54", + "name": "nickname", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "nickname", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "nickname", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "bde091b2-e5dc-4552-8955-5edc24975872", + "name": "service_account", + "description": "Specific scope for a client enabled for service accounts", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "8e0aebe1-7fb9-4119-bdfe-25bec733cf03", + "name": "Client ID", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "client_id", + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true", + "claim.name": "client_id", + "jsonType.label": "String" + } + }, + { + "id": "5af73200-a024-48c6-940f-11c592f2c7c0", + "name": "Client Host", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientHost", + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientHost", + "jsonType.label": "String" + } + }, + { + "id": "43d1a3b0-3b78-496a-862d-2502fed6e44d", + "name": "Client IP Address", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientAddress", + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientAddress", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "d189800c-fd16-4ec0-a7b9-9825830ad7db", + "name": "acr", + "description": "OpenID Connect scope for add acr (authentication context class reference) to the token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "4ab0cdfe-e624-4f4d-91a0-397c2e0910f5", + "name": "acr loa level", + "protocol": "openid-connect", + "protocolMapper": "oidc-acr-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true" + } + } + ] + }, + { + "id": "45c1fbac-38b6-44f8-a556-3888b0917783", + "name": "roles", + "description": "OpenID Connect scope for add user roles to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "consent.screen.text": "${rolesScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "85ec9e8c-cc01-479b-9b75-b921386d938f", + "name": "client roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-client-role-mapper", + "consentRequired": false, + "config": { + "user.attribute": "foo", + "introspection.token.claim": "true", + "access.token.claim": "true", + "claim.name": "resource_access.${client_id}.roles", + "jsonType.label": "String", + "multivalued": "true" + } + }, + { + "id": "e8835c0b-988f-4299-9b52-d5fda9703bb0", + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "access.token.claim": "true" + } + }, + { + "id": "664d727d-c3b3-45f0-aac3-1dc144804844", + "name": "realm roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "user.attribute": "foo", + "introspection.token.claim": "true", + "access.token.claim": "true", + "claim.name": "realm_access.roles", + "jsonType.label": "String", + "multivalued": "true" + } + } + ] + }, + { + "id": "29299243-c0d8-481d-ac58-79eaaed724cd", + "name": "saml_organization", + "description": "Organization Membership", + "protocol": "saml", + "attributes": { + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "6bcdd05d-188f-4f67-8803-51aafe278248", + "name": "organization", + "protocol": "saml", + "protocolMapper": "saml-organization-membership-mapper", + "consentRequired": false, + "config": {} + } + ] + }, + { + "id": "634288b9-b3b6-42d1-b6da-587214e93b2d", + "name": "address", + "description": "OpenID Connect built-in scope: address", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "consent.screen.text": "${addressScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "8012edf8-a51d-44bd-a0b0-4592fdeb310f", + "name": "address", + "protocol": "openid-connect", + "protocolMapper": "oidc-address-mapper", + "consentRequired": false, + "config": { + "user.attribute.formatted": "formatted", + "user.attribute.country": "country", + "introspection.token.claim": "true", + "user.attribute.postal_code": "postal_code", + "userinfo.token.claim": "true", + "user.attribute.street": "street", + "id.token.claim": "true", + "user.attribute.region": "region", + "access.token.claim": "true", + "user.attribute.locality": "locality" + } + } + ] + }, + { + "id": "604d08b4-0b5b-4c00-b859-d93be93b1c14", + "name": "basic", + "description": "OpenID Connect scope for add all basic claims to the token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "1377ac7f-67df-4f00-9cf4-d51374031d21", + "name": "auth_time", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "AUTH_TIME", + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true", + "claim.name": "auth_time", + "jsonType.label": "long" + } + }, + { + "id": "ad41fb94-fcf0-4985-9fa9-deb83abdb407", + "name": "sub", + "protocol": "openid-connect", + "protocolMapper": "oidc-sub-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "access.token.claim": "true" + } + } + ] + }, + { + "id": "1fa60ae3-b4bf-443c-8bb6-dd475674eca9", + "name": "phone", + "description": "OpenID Connect built-in scope: phone", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "consent.screen.text": "${phoneScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "4f80009f-5c53-4c28-8a31-f82960b2a0f3", + "name": "phone number", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "phoneNumber", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number", + "jsonType.label": "String" + } + }, + { + "id": "08bd5d38-58a2-4891-b7ef-d7bf52930d02", + "name": "phone number verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "phoneNumberVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number_verified", + "jsonType.label": "boolean" + } + } + ] + }, + { + "id": "2a50771e-b950-4730-a480-811139e546e8", + "name": "role_list", + "description": "SAML role list", + "protocol": "saml", + "attributes": { + "consent.screen.text": "${samlRoleListScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "f146412e-6256-4902-8c0d-2a9f84b1cd55", + "name": "role list", + "protocol": "saml", + "protocolMapper": "saml-role-list-mapper", + "consentRequired": false, + "config": { + "single": "false", + "attribute.nameformat": "Basic", + "attribute.name": "Role" + } + } + ] + }, + { + "id": "99372e8e-d574-49ba-936a-3437072feee2", + "name": "email", + "description": "OpenID Connect built-in scope: email", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "consent.screen.text": "${emailScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "208a9bb3-1430-4241-8a15-bc0061a2a304", + "name": "email verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "emailVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email_verified", + "jsonType.label": "boolean" + } + }, + { + "id": "96fcb38c-9f99-487d-9a94-a2408819d9f0", + "name": "email", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "email", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "616e1aae-485e-44e5-bb95-9123fea3b821", + "name": "web-origins", + "description": "OpenID Connect scope for add allowed web origins to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "consent.screen.text": "", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "884feb63-5fe6-4daf-be8a-38052f5abf69", + "name": "allowed web origins", + "protocol": "openid-connect", + "protocolMapper": "oidc-allowed-origins-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "access.token.claim": "true" + } + } + ] + }, + { + "id": "397e69bd-5a50-453f-a396-a37d9c8c8ce1", + "name": "organization", + "description": "Additional claims about the organization a subject belongs to", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "consent.screen.text": "${organizationScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "48b46c3c-b907-4fa2-ba8d-56c840f33cef", + "name": "organization", + "protocol": "openid-connect", + "protocolMapper": "oidc-organization-membership-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true", + "claim.name": "organization", + "jsonType.label": "String", + "multivalued": "true" + } + } + ] + }, + { + "id": "c89ffc9f-a2c3-4462-9990-b97ca1943bb2", + "name": "microprofile-jwt", + "description": "Microprofile - JWT built-in scope", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "9e068bb2-14de-4a91-a736-7fc3a3cb4757", + "name": "groups", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "multivalued": "true", + "user.attribute": "foo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "groups", + "jsonType.label": "String" + } + }, + { + "id": "e0384664-334e-458e-be36-ce83ec7a666a", + "name": "upn", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "upn", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "5c8b4ac2-4b99-4ee4-a6c0-8f7b5efb82a2", + "name": "aud-teck-web-bff", + "description": "Adds teck-web-bff as audience for tokens used by edge to call BFF", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "d31f3f12-d7cb-4f29-b9cf-f316d5db52b2", + "name": "aud-teck-web-bff", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "included.custom.audience": "", + "included.client.audience": "teck-web-bff", + "id.token.claim": "false", + "access.token.claim": "true", + "introspection.token.claim": "true" + } + } + ] + }, + { + "id": "a8ddab95-1121-4a4a-963a-cd5054ebf3f0", + "name": "catalog.read", + "description": "Optional scope for teck-catalog reader role", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true" + } + }, + { + "id": "0df91f05-5b0f-4df7-86e5-76cbd4f8d181", + "name": "catalog.write", + "description": "Optional scope for teck-catalog writer role", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true" + } + }, + { + "id": "1ab0df50-bf77-4e8f-b049-a8d2ebf9a2d6", + "name": "customer.read", + "description": "Optional scope for teck-customer reader role", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true" + } + }, + { + "id": "84f0d3e3-0da4-4af8-aae9-4f88f9524676", + "name": "customer.write", + "description": "Optional scope for teck-customer writer role", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true" + } + } + ], + "defaultDefaultClientScopes": [ + "role_list", + "saml_organization", + "profile", + "email", + "roles", + "web-origins", + "acr", + "basic" + ], + "defaultOptionalClientScopes": [ + "offline_access", + "address", + "phone", + "microprofile-jwt", + "organization" + ], + "browserSecurityHeaders": { + "contentSecurityPolicyReportOnly": "", + "xContentTypeOptions": "nosniff", + "referrerPolicy": "no-referrer", + "xRobotsTag": "none", + "xFrameOptions": "SAMEORIGIN", + "contentSecurityPolicy": "frame-src 'self'; frame-ancestors 'self'; object-src 'none';", + "strictTransportSecurity": "max-age=31536000; includeSubDomains" + }, + "smtpServer": {}, + "eventsEnabled": false, + "eventsListeners": [ + "jboss-logging" + ], + "enabledEventTypes": [], + "adminEventsEnabled": false, + "adminEventsDetailsEnabled": false, + "identityProviders": [], + "identityProviderMappers": [], + "components": { + "org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy": [ + { + "id": "a075bfe0-d06d-4011-8cc0-b45e1bb19d6c", + "name": "Max Clients Limit", + "providerId": "max-clients", + "subType": "anonymous", + "subComponents": {}, + "config": { + "max-clients": [ + "200" + ] + } + }, + { + "id": "70b7c78f-d55f-4ef8-bf52-b6f08f3dcdcd", + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "authenticated", + "subComponents": {}, + "config": { + "allow-default-scopes": [ + "true" + ] + } + }, + { + "id": "ad933e60-3282-417e-85d4-d82520015404", + "name": "Allowed Registration Web Origins", + "providerId": "registration-web-origins", + "subType": "anonymous", + "subComponents": {}, + "config": {} + }, + { + "id": "37f61866-dbb3-4b4f-abe5-9b9abd5c33e3", + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "anonymous", + "subComponents": {}, + "config": { + "allowed-protocol-mapper-types": [ + "oidc-usermodel-attribute-mapper", + "oidc-sha256-pairwise-sub-mapper", + "saml-role-list-mapper", + "saml-user-property-mapper", + "saml-user-attribute-mapper", + "oidc-full-name-mapper", + "oidc-usermodel-property-mapper", + "oidc-address-mapper" + ] + } + }, + { + "id": "4e2aab86-4dd7-44ec-8df8-8f58e126f1ff", + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "anonymous", + "subComponents": {}, + "config": { + "allow-default-scopes": [ + "true" + ] + } + }, + { + "id": "0a7f76b4-a195-487a-ae2f-8e3da9225d0d", + "name": "Trusted Hosts", + "providerId": "trusted-hosts", + "subType": "anonymous", + "subComponents": {}, + "config": { + "host-sending-registration-request-must-match": [ + "true" + ], + "client-uris-must-match": [ + "true" + ] + } + }, + { + "id": "2abed079-7338-485a-85d2-f6a9ce8f16a0", + "name": "Consent Required", + "providerId": "consent-required", + "subType": "anonymous", + "subComponents": {}, + "config": {} + }, + { + "id": "811649f9-5080-43ae-9df9-12d1c91d118b", + "name": "Full Scope Disabled", + "providerId": "scope", + "subType": "anonymous", + "subComponents": {}, + "config": {} + }, + { + "id": "89c5f774-eb9e-4228-aba3-65051343b73a", + "name": "Allowed Registration Web Origins", + "providerId": "registration-web-origins", + "subType": "authenticated", + "subComponents": {}, + "config": {} + }, + { + "id": "bc4f9ed5-0174-4a76-b819-dc8ee93aa6f2", + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "authenticated", + "subComponents": {}, + "config": { + "allowed-protocol-mapper-types": [ + "saml-role-list-mapper", + "oidc-sha256-pairwise-sub-mapper", + "saml-user-property-mapper", + "oidc-address-mapper", + "oidc-usermodel-attribute-mapper", + "saml-user-attribute-mapper", + "oidc-usermodel-property-mapper", + "oidc-full-name-mapper" + ] + } + } + ], + "org.keycloak.userprofile.UserProfileProvider": [ + { + "id": "c5262c82-07cd-42b4-aeda-2b57de09a028", + "providerId": "declarative-user-profile", + "subComponents": {}, + "config": { + "kc.user.profile.config": [ + "{\"attributes\":[{\"name\":\"username\",\"displayName\":\"${username}\",\"validations\":{\"length\":{\"min\":3,\"max\":255},\"username-prohibited-characters\":{},\"up-username-not-idn-homograph\":{}},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"email\",\"displayName\":\"${email}\",\"validations\":{\"email\":{},\"length\":{\"max\":255}},\"required\":{\"roles\":[\"user\"]},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"firstName\",\"displayName\":\"${firstName}\",\"validations\":{\"length\":{\"max\":255},\"person-name-prohibited-characters\":{}},\"required\":{\"roles\":[\"user\"]},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"lastName\",\"displayName\":\"${lastName}\",\"validations\":{\"length\":{\"max\":255},\"person-name-prohibited-characters\":{}},\"required\":{\"roles\":[\"user\"]},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"org.ro.active\",\"displayName\":\"Active organization ID\",\"permissions\":{\"view\":[\"admin\"],\"edit\":[\"admin\"]},\"multivalued\":false}],\"groups\":[{\"name\":\"user-metadata\",\"displayHeader\":\"User metadata\",\"displayDescription\":\"Attributes, which refer to user metadata\"}]}" + ] + } + } + ], + "org.keycloak.keys.KeyProvider": [ + { + "id": "acd93ce6-c440-491a-a5a8-4db3902ab421", + "name": "aes-generated", + "providerId": "aes-generated", + "subComponents": {}, + "config": { + "priority": [ + "100" + ] + } + }, + { + "id": "2d880fa6-5fb2-43be-8a1b-3cbcbf19c891", + "name": "rsa-generated", + "providerId": "rsa-generated", + "subComponents": {}, + "config": { + "priority": [ + "100" + ] + } + }, + { + "id": "e1f091a4-c390-4b9b-ae6f-1533172f02a5", + "name": "hmac-generated-hs512", + "providerId": "hmac-generated", + "subComponents": {}, + "config": { + "priority": [ + "100" + ], + "algorithm": [ + "HS512" + ] + } + }, + { + "id": "f4308613-0979-4e6a-98a2-3e731e016efe", + "name": "rsa-enc-generated", + "providerId": "rsa-enc-generated", + "subComponents": {}, + "config": { + "priority": [ + "100" + ], + "algorithm": [ + "RSA-OAEP" + ] + } + } + ] + }, + "internationalizationEnabled": false, + "authenticationFlows": [ + { + "id": "3c83081c-947e-4899-a07f-51841aa83553", + "alias": "Account verification options", + "description": "Method with which to verify the existing account", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-email-verification", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Verify Existing Account by Re-authentication", + "userSetupAllowed": false + } + ] + }, + { + "id": "07fabd8c-0e45-4605-9a4b-4a4eb6db86b5", + "alias": "Browser - Conditional 2FA", + "description": "Flow to determine if any 2FA is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorConfig": "browser-conditional-credential", + "authenticator": "conditional-credential", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-otp-form", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 30, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "webauthn-authenticator", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 40, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-recovery-authn-code-form", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 50, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "1b33309c-a536-4120-97b8-3d75ee96e84b", + "alias": "Browser - Conditional Organization", + "description": "Flow to determine if the organization identity-first login is to be used", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "organization", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "4485d593-123b-447d-be5b-f9e53d4204ec", + "alias": "Cookies Sub-Flow", + "description": "Cookie sub-flow which can be used to switch org.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "auth-cookie", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "ext-select-org", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "5b5c17d6-1ecc-4fa6-8dec-740c2ef25820", + "alias": "Direct Grant - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "direct-grant-validate-otp", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "3b301225-043d-4787-971f-6174092d7c33", + "alias": "First Broker Login - Conditional Organization", + "description": "Flow to determine if the authenticator that adds organization members is to be used", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "idp-add-organization-member", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "30803b18-eef5-4231-93fb-259eb7c697ae", + "alias": "First broker login - Conditional 2FA", + "description": "Flow to determine if any 2FA is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorConfig": "first-broker-login-conditional-credential", + "authenticator": "conditional-credential", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-otp-form", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 30, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "webauthn-authenticator", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 40, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-recovery-authn-code-form", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 50, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "310a31ba-062d-4ea5-b8b3-20c0b1b90e3a", + "alias": "Forms Sub-Flow", + "description": "Username, password, otp and other auth forms.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "auth-username-password-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Org Browser - Conditional OTP", + "userSetupAllowed": false + }, + { + "authenticator": "ext-select-org", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 30, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "1b8099c8-8bcd-4fe2-836a-24420e123951", + "alias": "Handle Existing Account", + "description": "Handle what to do if there is existing account with same email/username like authenticated identity provider", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-confirm-link", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Account verification options", + "userSetupAllowed": false + } + ] + }, + { + "id": "15262054-32ea-4529-b68f-91897950afce", + "alias": "IDP Sub-Flow", + "description": "IDP sub-flow to select org.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "identity-provider-redirector", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "ext-select-org", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "fed4f65f-0c82-44bb-89d5-1b86c5cf0223", + "alias": "Org Browser - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-otp-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "60c3df7f-38c9-44e1-8e8b-a285f61cd442", + "alias": "Org Browser Flow", + "description": "Browser flow with select organization step.", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": true, + "flowAlias": "Cookies Sub-Flow", + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "IDP Sub-Flow", + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 30, + "autheticatorFlow": true, + "flowAlias": "Forms Sub-Flow", + "userSetupAllowed": false + } + ] + }, + { + "id": "765f8f86-0616-4796-809a-1d11278a772a", + "alias": "Org Direct Grant - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-otp-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "a0c15bc1-d648-467c-bbb0-1a12f538b9bd", + "alias": "Org Direct Grant Flow", + "description": "Direct grant flow with select organization step.", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "direct-grant-validate-username", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "direct-grant-validate-password", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 30, + "autheticatorFlow": true, + "flowAlias": "Org Direct Grant - Conditional OTP", + "userSetupAllowed": false + }, + { + "authenticator": "ext-select-org", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 30, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "af685087-ee4c-466b-b089-83ff8210d5c7", + "alias": "Organization", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 10, + "autheticatorFlow": true, + "flowAlias": "Browser - Conditional Organization", + "userSetupAllowed": false + } + ] + }, + { + "id": "9b802980-74f1-4c66-9ef6-71712ce8457b", + "alias": "Reset - Conditional OTP", + "description": "Flow to determine if the OTP should be reset or not. Set to REQUIRED to force.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "reset-otp", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "7ba12aa3-d3cb-455f-b9e2-a78be5af5387", + "alias": "User creation or linking", + "description": "Flow for the existing/non-existing user alternatives", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorConfig": "create unique user config", + "authenticator": "idp-create-user-if-unique", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Handle Existing Account", + "userSetupAllowed": false + } + ] + }, + { + "id": "a5bcc82c-1bc2-4869-9098-551b44ed2559", + "alias": "Verify Existing Account by Re-authentication", + "description": "Reauthentication of existing account", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-username-password-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "First broker login - Conditional 2FA", + "userSetupAllowed": false + } + ] + }, + { + "id": "7ae943b1-b34d-4f55-96fe-c7b7d7bdfe19", + "alias": "browser", + "description": "Browser based authentication", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "auth-cookie", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-spnego", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "identity-provider-redirector", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 25, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 26, + "autheticatorFlow": true, + "flowAlias": "Organization", + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 30, + "autheticatorFlow": true, + "flowAlias": "forms", + "userSetupAllowed": false + } + ] + }, + { + "id": "64cb96ea-0544-440c-bce8-f1ceae572e66", + "alias": "clients", + "description": "Base authentication for clients", + "providerId": "client-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "client-secret", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "client-jwt", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "client-secret-jwt", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 30, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "client-x509", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 40, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "25af9f88-cc1f-46a4-a98d-e938bfa6314e", + "alias": "direct grant", + "description": "OpenID Connect Resource Owner Grant", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "direct-grant-validate-username", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "direct-grant-validate-password", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 30, + "autheticatorFlow": true, + "flowAlias": "Direct Grant - Conditional OTP", + "userSetupAllowed": false + } + ] + }, + { + "id": "09b290ae-1d0e-4f82-99f4-0c130ef99577", + "alias": "docker auth", + "description": "Used by Docker clients to authenticate against the IDP", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "docker-http-basic-authenticator", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "cbac96a8-09b3-4464-a5e9-e584d470ee20", + "alias": "first broker login", + "description": "Actions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorConfig": "review profile config", + "authenticator": "idp-review-profile", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "User creation or linking", + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 60, + "autheticatorFlow": true, + "flowAlias": "First Broker Login - Conditional Organization", + "userSetupAllowed": false + } + ] + }, + { + "id": "98a33d5f-c0c9-447d-8d54-cadbe7d9e918", + "alias": "forms", + "description": "Username, password, otp and other auth forms.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "auth-username-password-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Browser - Conditional 2FA", + "userSetupAllowed": false + } + ] + }, + { + "id": "06121f12-d961-4fca-b210-2e7fbff22e8b", + "alias": "idp validate", + "description": "Authentication flow used to validate newly created organization identity providers.", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "identity-provider-redirector", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 0, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "15827cf4-a7c6-4f74-9f2b-e707a960c595", + "alias": "magic link", + "description": "Simple magic link authentication flow.", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "auth-cookie", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 0, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "identity-provider-redirector", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 0, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 1, + "autheticatorFlow": true, + "flowAlias": "magic link forms", + "userSetupAllowed": false + } + ] + }, + { + "id": "f726e300-07e2-41a6-baaf-8791ad094037", + "alias": "magic link forms", + "description": "Forms for simple magic link authentication flow.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": false, + "authenticationExecutions": [ + { + "authenticator": "ext-magic-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 0, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "df31048c-2fdd-44a7-b7c0-f97c6a788f29", + "alias": "post org broker login", + "description": "Post broker login flow used for organization IdPs.", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "ext-auth-org-id-verifier", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 0, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "ext-auth-org-note", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 0, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "ext-auth-org-add-user", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 0, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "ext-auth-validate-idp", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 0, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "7e92f8e7-76d7-41c4-aabe-62a9a220920a", + "alias": "registration", + "description": "Registration flow", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "registration-page-form", + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": true, + "flowAlias": "registration form", + "userSetupAllowed": false + } + ] + }, + { + "id": "2d079e7d-f62f-4574-8a41-6c5a912c78a3", + "alias": "registration form", + "description": "Registration form", + "providerId": "form-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "registration-user-creation", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "registration-password-action", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 50, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "registration-recaptcha-action", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 60, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "registration-terms-and-conditions", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 70, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "78e26e1c-c476-4a16-a1fb-95d42cf8ec74", + "alias": "reset credentials", + "description": "Reset credentials for a user if they forgot their password or something", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "reset-credentials-choose-user", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "reset-credential-email", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "reset-password", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 30, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 40, + "autheticatorFlow": true, + "flowAlias": "Reset - Conditional OTP", + "userSetupAllowed": false + } + ] + }, + { + "id": "89b637a1-e5f4-4a50-9e8a-6dc37bb92ba9", + "alias": "saml ecp", + "description": "SAML ECP Profile Authentication Flow", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "http-basic-authenticator", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + } + ], + "authenticatorConfig": [ + { + "id": "953cc768-9bd7-4664-b634-82d379d63a97", + "alias": "browser-conditional-credential", + "config": { + "credentials": "webauthn-passwordless" + } + }, + { + "id": "fbea835b-f039-4161-ae0e-1d17f181df50", + "alias": "create unique user config", + "config": { + "require.password.update.after.registration": "false" + } + }, + { + "id": "927618b8-29f8-4f1e-8ce2-09356889a239", + "alias": "first-broker-login-conditional-credential", + "config": { + "credentials": "webauthn-passwordless" + } + }, + { + "id": "deb59836-bbc6-4f1b-b7f4-a95df0230416", + "alias": "review profile config", + "config": { + "update.profile.on.first.login": "missing" + } + } + ], + "requiredActions": [ + { + "alias": "CONFIGURE_TOTP", + "name": "Configure OTP", + "providerId": "CONFIGURE_TOTP", + "enabled": true, + "defaultAction": false, + "priority": 10, + "config": {} + }, + { + "alias": "TERMS_AND_CONDITIONS", + "name": "Terms and Conditions", + "providerId": "TERMS_AND_CONDITIONS", + "enabled": false, + "defaultAction": false, + "priority": 20, + "config": {} + }, + { + "alias": "UPDATE_PASSWORD", + "name": "Update Password", + "providerId": "UPDATE_PASSWORD", + "enabled": true, + "defaultAction": false, + "priority": 30, + "config": {} + }, + { + "alias": "UPDATE_PROFILE", + "name": "Update Profile", + "providerId": "UPDATE_PROFILE", + "enabled": true, + "defaultAction": false, + "priority": 40, + "config": {} + }, + { + "alias": "VERIFY_EMAIL", + "name": "Verify Email", + "providerId": "VERIFY_EMAIL", + "enabled": true, + "defaultAction": false, + "priority": 50, + "config": {} + }, + { + "alias": "delete_account", + "name": "Delete Account", + "providerId": "delete_account", + "enabled": false, + "defaultAction": false, + "priority": 60, + "config": {} + }, + { + "alias": "UPDATE_EMAIL", + "name": "Update Email", + "providerId": "UPDATE_EMAIL", + "enabled": false, + "defaultAction": false, + "priority": 70, + "config": {} + }, + { + "alias": "webauthn-register", + "name": "Webauthn Register", + "providerId": "webauthn-register", + "enabled": true, + "defaultAction": false, + "priority": 80, + "config": {} + }, + { + "alias": "webauthn-register-passwordless", + "name": "Webauthn Register Passwordless", + "providerId": "webauthn-register-passwordless", + "enabled": true, + "defaultAction": false, + "priority": 90, + "config": {} + }, + { + "alias": "VERIFY_PROFILE", + "name": "Verify Profile", + "providerId": "VERIFY_PROFILE", + "enabled": true, + "defaultAction": false, + "priority": 100, + "config": {} + }, + { + "alias": "delete_credential", + "name": "Delete Credential", + "providerId": "delete_credential", + "enabled": true, + "defaultAction": false, + "priority": 110, + "config": {} + }, + { + "alias": "idp_link", + "name": "Linking Identity Provider", + "providerId": "idp_link", + "enabled": true, + "defaultAction": false, + "priority": 120, + "config": {} + }, + { + "alias": "CONFIGURE_RECOVERY_AUTHN_CODES", + "name": "Recovery Authentication Codes", + "providerId": "CONFIGURE_RECOVERY_AUTHN_CODES", + "enabled": true, + "defaultAction": false, + "priority": 130, + "config": {} + }, + { + "alias": "update_user_locale", + "name": "Update User Locale", + "providerId": "update_user_locale", + "enabled": true, + "defaultAction": false, + "priority": 1000, + "config": {} + } + ], + "browserFlow": "browser", + "registrationFlow": "registration", + "directGrantFlow": "direct grant", + "resetCredentialsFlow": "reset credentials", + "clientAuthenticationFlow": "clients", + "dockerAuthenticationFlow": "docker auth", + "firstBrokerLoginFlow": "first broker login", + "attributes": { + "cibaBackchannelTokenDeliveryMode": "poll", + "cibaExpiresIn": "120", + "cibaAuthRequestedUserHint": "login_hint", + "oauth2DeviceCodeLifespan": "600", + "oauth2DevicePollingInterval": "5", + "parRequestUriLifespan": "60", + "cibaInterval": "5", + "realmReusableOtpCode": "false" + }, + "keycloakVersion": "26.5.2", + "userManagedAccessAllowed": false, + "organizationsEnabled": true, + "verifiableCredentialsEnabled": false, + "adminPermissionsEnabled": false, + "clientProfiles": { + "profiles": [] + }, + "clientPolicies": { + "policies": [] + } +} \ No newline at end of file diff --git a/src/buildingblocks/SharedKernel.Persistence/Database/MultiTenant/TenantDbConnectionResolver.cs b/src/buildingblocks/SharedKernel.Persistence/Database/MultiTenant/TenantDbConnectionResolver.cs index cc25df58..6781a758 100644 --- a/src/buildingblocks/SharedKernel.Persistence/Database/MultiTenant/TenantDbConnectionResolver.cs +++ b/src/buildingblocks/SharedKernel.Persistence/Database/MultiTenant/TenantDbConnectionResolver.cs @@ -1,6 +1,7 @@ using System.Collections.Concurrent; using System.Net.Http.Json; using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using SharedKernel.Core.Pricing; @@ -14,6 +15,8 @@ namespace SharedKernel.Persistence.Database.MultiTenant /// public class TenantDbConnectionResolver : ITenantDbConnectionResolver { + private const string TenantIdHeader = "X-TenantId"; + private const string TenantDbStrategyHeader = "X-Tenant-DbStrategy"; // Cache for tenant connection information as a fallback/performance optimization private static readonly ConcurrentDictionary _connectionCache = new ConcurrentDictionary(); @@ -23,6 +26,7 @@ public class TenantDbConnectionResolver : ITenantDbConnectionResolver private readonly ILogger _logger; private readonly Lazy _fusionCache; private readonly Lazy _httpClientFactory; + private readonly Lazy _httpContextAccessor; /// /// Initializes a new instance of the class. @@ -52,6 +56,9 @@ public TenantDbConnectionResolver( _httpClientFactory = new Lazy(() => serviceProvider.GetService() ?? throw new InvalidOperationException("HttpClientFactory service is not registered")); + + _httpContextAccessor = new Lazy(() => + serviceProvider.GetService()); } /// @@ -67,6 +74,11 @@ public TenantDbConnectionResolver( return (_defaultWriteConnectionString, _defaultReadConnectionString, _defaultProvider, DatabaseStrategy.Shared); } + if (TryResolveFromGatewayHint(tenantInfo, out var hintedConnection)) + { + return hintedConnection; + } + // Try to get from static cache first for best performance if (!string.IsNullOrEmpty(tenantInfo.Id) && _connectionCache.TryGetValue(tenantInfo.Id, out var cachedConnection)) { @@ -141,6 +153,59 @@ public TenantDbConnectionResolver( return GetConnectionFromTenantProperties(tenantInfo); } + private bool TryResolveFromGatewayHint( + TenantDetails tenantInfo, + out (string WriteConnectionString, string? ReadConnectionString, DatabaseProvider Provider, DatabaseStrategy Strategy) connection) + { + connection = default; + + var headers = _httpContextAccessor.Value?.HttpContext?.Request?.Headers; + if (headers == null) + { + return false; + } + + if (headers.TryGetValue(TenantIdHeader, out var tenantHeader) && + !string.IsNullOrWhiteSpace(tenantInfo.Id) && + !string.Equals(tenantHeader.ToString(), tenantInfo.Id, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (!headers.TryGetValue(TenantDbStrategyHeader, out var strategyHeader) || + string.IsNullOrWhiteSpace(strategyHeader)) + { + return false; + } + + if (!DatabaseStrategy.TryFromName(strategyHeader.ToString(), true, out var strategy)) + { + return false; + } + + if (strategy == DatabaseStrategy.Shared) + { + connection = (_defaultWriteConnectionString, _defaultReadConnectionString, _defaultProvider, DatabaseStrategy.Shared); + + if (!string.IsNullOrWhiteSpace(tenantInfo.Id)) + { + _connectionCache[tenantInfo.Id] = connection; + } + + return true; + } + + if (!string.IsNullOrWhiteSpace(tenantInfo.Id) && + _connectionCache.TryGetValue(tenantInfo.Id, out var cachedConnection) && + cachedConnection.Strategy == strategy) + { + connection = cachedConnection; + return true; + } + + return false; + } + /// /// Safely resolves the connection string, database provider, and strategy for a tenant. /// This method provides additional safety checks for premium/enterprise tenants. diff --git a/src/gateways/Web.BFF/Controllers/AuthController.cs b/src/gateways/Web.BFF/Controllers/AuthController.cs deleted file mode 100644 index 48b05887..00000000 --- a/src/gateways/Web.BFF/Controllers/AuthController.cs +++ /dev/null @@ -1,30 +0,0 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; - -namespace Web.BFF.Controllers; - -[ApiController] -[Route("auth")] -public class AuthController : ControllerBase -{ - private readonly Services.ITokenExchangeService _exchangeService; - - public AuthController(Services.ITokenExchangeService exchangeService) - { - _exchangeService = exchangeService; - } - - [HttpPut("switch-organization")] - [Authorize] - public async Task SwitchOrganization([FromBody] SwitchOrgRequest req) - { - var auth = HttpContext.Request.Headers["Authorization"].FirstOrDefault(); - if (string.IsNullOrEmpty(auth) || !auth.StartsWith("Bearer ")) return Unauthorized(); - var subjectToken = auth.Substring("Bearer ".Length).Trim(); - - var result = await _exchangeService.SwitchOrganizationAsync(subjectToken, req.Id, HttpContext.RequestAborted); - return Ok(new { access_token = result.AccessToken, expires_at = result.ExpiresAt }); - } - - public record SwitchOrgRequest(string Id); -} diff --git a/src/gateways/Web.BFF/Endpoints/SwitchOrganizationEndpoint.cs b/src/gateways/Web.BFF/Endpoints/SwitchOrganizationEndpoint.cs deleted file mode 100644 index 462d399c..00000000 --- a/src/gateways/Web.BFF/Endpoints/SwitchOrganizationEndpoint.cs +++ /dev/null @@ -1,38 +0,0 @@ -using FastEndpoints; -using System.Linq; -using Microsoft.AspNetCore.Authorization; -using Web.BFF.Services; - -namespace Web.BFF.Endpoints; - -public class SwitchOrgRequest -{ - public string Id { get; set; } = string.Empty; -} - -[Authorize] -public class SwitchOrganizationEndpoint : Endpoint -{ - private readonly ITokenExchangeService _exchangeService; - public SwitchOrganizationEndpoint(ITokenExchangeService exchangeService) => _exchangeService = exchangeService; - - public override void Configure() - { - Put("/auth/switch-organization"); - } - - public override async Task HandleAsync(SwitchOrgRequest req, CancellationToken ct) - { - var auth = HttpContext.Request.Headers["Authorization"].FirstOrDefault(); - if (string.IsNullOrEmpty(auth) || !auth.StartsWith("Bearer ")) - { - HttpContext.Response.StatusCode = StatusCodes.Status401Unauthorized; - return; - } - - var subjectToken = auth.Substring("Bearer ".Length).Trim(); - var result = await _exchangeService.SwitchOrganizationAsync(subjectToken, req.Id, ct); - HttpContext.Response.StatusCode = StatusCodes.Status200OK; - await HttpContext.Response.WriteAsJsonAsync(new { access_token = result.AccessToken, expires_at = result.ExpiresAt }, cancellationToken: ct); - } -} diff --git a/src/gateways/Web.BFF/Middleware/InternalTrust/InternalIdentityValidationMiddleware.cs b/src/gateways/Web.BFF/Middleware/InternalTrust/InternalIdentityValidationMiddleware.cs new file mode 100644 index 00000000..c4c2df10 --- /dev/null +++ b/src/gateways/Web.BFF/Middleware/InternalTrust/InternalIdentityValidationMiddleware.cs @@ -0,0 +1,129 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Microsoft.IdentityModel.Tokens; + +namespace Web.BFF.Middleware.InternalTrust; + +public sealed class InternalIdentityValidationMiddleware +{ + private const string InternalIdentityHeader = "X-Internal-Identity"; + + private readonly RequestDelegate _next; + private readonly IConfiguration _configuration; + private readonly ILogger _logger; + + public InternalIdentityValidationMiddleware( + RequestDelegate next, + IConfiguration configuration, + ILogger logger) + { + _next = next; + _configuration = configuration; + _logger = logger; + } + + public async Task InvokeAsync(HttpContext context) + { + StripClientSpoofableHeaders(context); + + var enforce = bool.TryParse(_configuration["EdgeTrust:Enforce"], out var enforceValue) && enforceValue; + var isHealthRoute = context.Request.Path.StartsWithSegments("/health", StringComparison.OrdinalIgnoreCase); + + if (isHealthRoute) + { + await _next(context); + return; + } + + var token = context.Request.Headers[InternalIdentityHeader].FirstOrDefault(); + if (string.IsNullOrWhiteSpace(token)) + { + if (enforce) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + await context.Response.WriteAsync("Missing internal identity token."); + return; + } + + await _next(context); + return; + } + + if (!TryValidateToken(token, out var principal)) + { + if (enforce) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + await context.Response.WriteAsync("Invalid internal identity token."); + return; + } + + await _next(context); + return; + } + + context.Items["EdgeIdentityPrincipal"] = principal; + + if (context.User?.Identity?.IsAuthenticated != true) + { + context.User = principal; + } + + await _next(context); + } + + private bool TryValidateToken(string token, out ClaimsPrincipal principal) + { + principal = new ClaimsPrincipal(new ClaimsIdentity()); + + var signingKey = _configuration["EdgeTrust:SigningKey"]; + if (string.IsNullOrWhiteSpace(signingKey)) + { + _logger.LogWarning("EdgeTrust:SigningKey is not configured; cannot validate internal identity token."); + return false; + } + + var issuer = _configuration["EdgeTrust:Issuer"] ?? "teck-edge"; + var audience = _configuration["EdgeTrust:Audience"] ?? "teck-web-bff-internal"; + + var validationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = issuer, + ValidateAudience = true, + ValidAudience = audience, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(signingKey)), + ClockSkew = TimeSpan.FromSeconds(15), + }; + + var handler = new JwtSecurityTokenHandler(); + + try + { + principal = handler.ValidateToken(token, validationParameters, out var validatedToken); + + if (validatedToken is not JwtSecurityToken jwtToken || + !string.Equals(jwtToken.Header.Alg, SecurityAlgorithms.HmacSha256, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogWarning("Internal identity token uses an unexpected signing algorithm."); + return false; + } + + return true; + } + catch (Exception exception) + { + _logger.LogWarning(exception, "Failed to validate internal identity token."); + return false; + } + } + + private static void StripClientSpoofableHeaders(HttpContext context) + { + context.Request.Headers.Remove("X-TenantId"); + context.Request.Headers.Remove("X-Tenant-DbStrategy"); + } +} diff --git a/src/gateways/Web.BFF/Middleware/TokenExchangeMiddleware.cs b/src/gateways/Web.BFF/Middleware/TokenExchangeMiddleware.cs index 60b520c1..6b240e7e 100644 --- a/src/gateways/Web.BFF/Middleware/TokenExchangeMiddleware.cs +++ b/src/gateways/Web.BFF/Middleware/TokenExchangeMiddleware.cs @@ -1,5 +1,4 @@ using System.Security.Claims; -using Microsoft.AspNetCore.Http; using Web.BFF.Services; using Yarp.ReverseProxy.Configuration; @@ -7,13 +6,20 @@ namespace Web.BFF.Middleware { public class TokenExchangeMiddleware { + private const string TenantIdHeader = "X-TenantId"; + private const string TenantDbStrategyHeader = "X-Tenant-DbStrategy"; private readonly RequestDelegate _next; private readonly ITokenExchangeService _exchangeService; + private readonly ITenantRoutingMetadataService _tenantRoutingMetadataService; - public TokenExchangeMiddleware(RequestDelegate next, ITokenExchangeService exchangeService) + public TokenExchangeMiddleware( + RequestDelegate next, + ITokenExchangeService exchangeService, + ITenantRoutingMetadataService tenantRoutingMetadataService) { _next = next; _exchangeService = exchangeService; + _tenantRoutingMetadataService = tenantRoutingMetadataService; } public async Task InvokeAsync(HttpContext context) @@ -23,7 +29,7 @@ public async Task InvokeAsync(HttpContext context) if (endpoint != null) { var routeMetadata = endpoint.Metadata.GetMetadata(); - audience = routeMetadata?.Metadata?.GetValueOrDefault("KeycloakAudience") as string; + audience = routeMetadata?.Metadata?.GetValueOrDefault("KeycloakAudience"); } var auth = context.Request.Headers["Authorization"].FirstOrDefault(); @@ -50,7 +56,22 @@ public async Task InvokeAsync(HttpContext context) if (!string.IsNullOrEmpty(tenantId)) { - context.Request.Headers["X-TenantId"] = tenantId; + context.Request.Headers[TenantIdHeader] = tenantId; + + try + { + var routingMetadata = await _tenantRoutingMetadataService + .GetTenantRoutingMetadataAsync(tenantId, context.RequestAborted); + + if (!string.IsNullOrWhiteSpace(routingMetadata?.DatabaseStrategy)) + { + context.Request.Headers[TenantDbStrategyHeader] = routingMetadata.DatabaseStrategy; + } + } + catch + { + // Metadata lookup is best-effort; downstream resolver fallback remains authoritative. + } } await _next(context); @@ -60,6 +81,16 @@ public async Task InvokeAsync(HttpContext context) { if (user == null) return null; + if (TryResolveTenantIdFromOrganizationClaim(user, "organization", out var tenantIdFromOrganizationClaim)) + { + return tenantIdFromOrganizationClaim; + } + + if (TryResolveTenantIdFromOrganizationClaim(user, "organizations", out var tenantIdFromOrganizationsClaim)) + { + return tenantIdFromOrganizationsClaim; + } + var active = user.FindFirst("active_organization")?.Value; if (!string.IsNullOrEmpty(active)) { @@ -93,5 +124,63 @@ public async Task InvokeAsync(HttpContext context) return null; } + + private static bool TryResolveTenantIdFromOrganizationClaim( + ClaimsPrincipal user, + string claimName, + out string? tenantId) + { + tenantId = null; + + var organizationClaim = user.FindFirst(claimName)?.Value; + if (string.IsNullOrWhiteSpace(organizationClaim)) + { + return false; + } + + try + { + using var organizationsDocument = System.Text.Json.JsonDocument.Parse(organizationClaim); + if (organizationsDocument.RootElement.ValueKind != System.Text.Json.JsonValueKind.Object) + { + return false; + } + + foreach (var organization in organizationsDocument.RootElement.EnumerateObject()) + { + if (organization.Value.ValueKind == System.Text.Json.JsonValueKind.Object && + organization.Value.TryGetProperty("id", out var idProperty) && + idProperty.ValueKind == System.Text.Json.JsonValueKind.String) + { + tenantId = idProperty.GetString(); + if (!string.IsNullOrWhiteSpace(tenantId)) + { + return true; + } + } + + if (organization.Value.ValueKind == System.Text.Json.JsonValueKind.String) + { + tenantId = organization.Value.GetString(); + if (!string.IsNullOrWhiteSpace(tenantId)) + { + return true; + } + } + + if (!string.IsNullOrWhiteSpace(organization.Name)) + { + tenantId = organization.Name; + return true; + } + } + } + catch + { + return false; + } + + return false; + } } } diff --git a/src/gateways/Web.BFF/Program.cs b/src/gateways/Web.BFF/Program.cs index be24675c..b3909b6d 100644 --- a/src/gateways/Web.BFF/Program.cs +++ b/src/gateways/Web.BFF/Program.cs @@ -8,9 +8,11 @@ using Microsoft.Extensions.Hosting; using SharedKernel.Infrastructure.Auth; using SharedKernel.Infrastructure.MultiTenant; +using Web.BFF.Middleware.InternalTrust; using ZiggyCreatures.Caching.Fusion; using Yarp.ReverseProxy.Transforms; using FastEndpoints; +using FastEndpoints.Swagger; var builder = WebApplication.CreateBuilder(args); @@ -38,11 +40,30 @@ { }); +builder.Services.AddHttpClient("CustomerApi", client => +{ + var customerApiUrl = builder.Configuration["Services:CustomerApi:Url"]; + if (!string.IsNullOrWhiteSpace(customerApiUrl)) + { + client.BaseAddress = new Uri(customerApiUrl); + } +}); + builder.Services.AddHttpContextAccessor(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); // FastEndpoints builder.Services.AddFastEndpoints(); +builder.Services.SwaggerDocument(options => +{ + options.DocumentSettings = settings => + { + settings.DocumentName = "v1"; + settings.Title = "Teck Web BFF"; + settings.Version = "v1"; + }; +}); // Authentication/Authorization middleware (Keycloak) builder.Services.AddAuthentication(); @@ -56,11 +77,17 @@ app.UseAuthentication(); app.UseAuthorization(); +app.UseMiddleware(); + // Token exchange middleware should run before ReverseProxy so it can mutate headers app.UseMiddleware(); // Use FastEndpoints for small auth endpoints app.UseFastEndpoints(); +app.UseSwaggerGen(swaggerOptions => +{ + swaggerOptions.Path = "/openapi/{documentName}/openapi.json"; +}); app.MapReverseProxy(); diff --git a/src/gateways/Web.BFF/Services/TenantRoutingMetadataService.cs b/src/gateways/Web.BFF/Services/TenantRoutingMetadataService.cs new file mode 100644 index 00000000..497fe10b --- /dev/null +++ b/src/gateways/Web.BFF/Services/TenantRoutingMetadataService.cs @@ -0,0 +1,88 @@ +using System.Text.Json.Serialization; +using ZiggyCreatures.Caching.Fusion; + +namespace Web.BFF.Services; + +public interface ITenantRoutingMetadataService +{ + Task GetTenantRoutingMetadataAsync(string tenantId, CancellationToken ct = default); +} + +public sealed record TenantRoutingMetadata(string TenantId, string DatabaseStrategy); + +public sealed class TenantRoutingMetadataService : ITenantRoutingMetadataService +{ + private readonly IHttpClientFactory _httpClientFactory; + private readonly IFusionCache _fusionCache; + private readonly ILogger _logger; + private readonly string _databaseInfoEndpointTemplate; + + public TenantRoutingMetadataService( + IHttpClientFactory httpClientFactory, + IFusionCache fusionCache, + ILogger logger, + IConfiguration configuration) + { + _httpClientFactory = httpClientFactory; + _fusionCache = fusionCache; + _logger = logger; + _databaseInfoEndpointTemplate = configuration["Services:CustomerApi:TenantDatabaseInfoEndpoint"] + ?? "api/v1/tenants/{tenantId}/database-info"; + } + + public async Task GetTenantRoutingMetadataAsync(string tenantId, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(tenantId)) + { + return null; + } + + var cacheKey = $"tenant-routing:{tenantId}"; + + try + { + return await _fusionCache.GetOrSetAsync( + cacheKey, + async (context, cancellationToken) => + { + var client = _httpClientFactory.CreateClient("CustomerApi"); + var endpoint = _databaseInfoEndpointTemplate.Replace("{tenantId}", tenantId, StringComparison.OrdinalIgnoreCase); + var response = await client.GetAsync(endpoint, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning( + "Failed to resolve tenant routing metadata for tenant {TenantId}. Status code: {StatusCode}", + tenantId, + response.StatusCode); + return null; + } + + var payload = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); + if (payload == null || string.IsNullOrWhiteSpace(payload.Strategy)) + { + return null; + } + + context.Options + .SetDuration(TimeSpan.FromMinutes(5)) + .SetFailSafe(true) + .SetFactoryTimeouts(TimeSpan.FromMilliseconds(300), TimeSpan.FromSeconds(1)); + + return new TenantRoutingMetadata(tenantId, payload.Strategy); + }, + token: ct); + } + catch (Exception exception) + { + _logger.LogWarning(exception, "Error resolving tenant routing metadata for tenant {TenantId}", tenantId); + return null; + } + } + + private sealed class TenantDatabaseInfoResponse + { + [JsonPropertyName("strategy")] + public string Strategy { get; set; } = string.Empty; + } +} \ No newline at end of file diff --git a/src/gateways/Web.BFF/Services/TokenExchangeService.cs b/src/gateways/Web.BFF/Services/TokenExchangeService.cs index 77ff457e..5d4088b1 100644 --- a/src/gateways/Web.BFF/Services/TokenExchangeService.cs +++ b/src/gateways/Web.BFF/Services/TokenExchangeService.cs @@ -1,12 +1,12 @@ using System.Security.Cryptography; using System.Text; +using IdentityModel.Client; namespace Web.BFF.Services { public interface ITokenExchangeService { Task ExchangeTokenAsync(string subjectToken, string audience, string tenantId, CancellationToken ct = default); - Task SwitchOrganizationAsync(string subjectToken, string orgId, CancellationToken ct = default); } public record TokenResult(string AccessToken, DateTime ExpiresAt); @@ -44,23 +44,35 @@ public async Task ExchangeTokenAsync(string subjectToken, string au var client = _httpClientFactory.CreateClient("KeycloakTokenClient"); var tokenEndpoint = _config["Keycloak:TokenEndpoint"] ?? _config["Keycloak:Authority"] + "/protocol/openid-connect/token"; - var req = new HttpRequestMessage(HttpMethod.Post, tokenEndpoint); - req.Content = new FormUrlEncodedContent(new[] + var response = await client.RequestTokenExchangeTokenAsync( + new TokenExchangeTokenRequest + { + Address = tokenEndpoint, + ClientId = _config["Keycloak:GatewayClientId"] ?? string.Empty, + ClientSecret = _config["Keycloak:GatewayClientSecret"] ?? string.Empty, + SubjectToken = subjectToken, + SubjectTokenType = "urn:ietf:params:oauth:token-type:access_token", + Audience = audience + }, + ct2); + + if (response.IsError) { - new KeyValuePair("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange"), - new KeyValuePair("subject_token", subjectToken), - new KeyValuePair("subject_token_type", "urn:ietf:params:oauth:token-type:access_token"), - new KeyValuePair("audience", audience), - new KeyValuePair("client_id", _config["Keycloak:GatewayClientId"] ?? string.Empty), - new KeyValuePair("client_secret", _config["Keycloak:GatewayClientSecret"] ?? string.Empty), - }); - - var res = await client.SendAsync(req, ct2); - res.EnsureSuccessStatusCode(); - var json = await res.Content.ReadAsStringAsync(ct2); - using var doc = System.Text.Json.JsonDocument.Parse(json); - var access = doc.RootElement.GetProperty("access_token").GetString(); - var expiresIn = doc.RootElement.GetProperty("expires_in").GetInt32(); + throw new HttpRequestException($"Token exchange failed: {response.Error}"); + } + + var access = response.AccessToken; + var expiresIn = response.ExpiresIn; + + if (string.IsNullOrWhiteSpace(access)) + { + throw new HttpRequestException("Token exchange failed: access_token is missing"); + } + + if (expiresIn <= 0) + { + throw new HttpRequestException("Token exchange failed: expires_in is missing or invalid"); + } var expiresAt = DateTime.UtcNow.AddSeconds(expiresIn); context.Options.Duration = TimeSpan.FromSeconds(Math.Max(30, expiresIn - 60)); @@ -72,32 +84,5 @@ public async Task ExchangeTokenAsync(string subjectToken, string au } - public async Task SwitchOrganizationAsync(string subjectToken, string orgId, CancellationToken ct = default) - { - var client = _httpClientFactory.CreateClient("KeycloakTokenClient"); - var tokenEndpoint = _config["Keycloak:Authority"] + "/protocol/openid-connect/token"; - - var req = new HttpRequestMessage(HttpMethod.Post, tokenEndpoint); - req.Content = new FormUrlEncodedContent(new[] - { - new KeyValuePair("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange"), - new KeyValuePair("subject_token", subjectToken), - new KeyValuePair("subject_token_type", "urn:ietf:params:oauth:token-type:access_token"), - new KeyValuePair("audience", _config["Keycloak:GatewayClientId"] ?? string.Empty), - new KeyValuePair("client_id", _config["Keycloak:GatewayClientId"] ?? string.Empty), - new KeyValuePair("client_secret", _config["Keycloak:GatewayClientSecret"] ?? string.Empty), - new KeyValuePair("org_id", orgId) - }); - - var res = await client.SendAsync(req, ct); - res.EnsureSuccessStatusCode(); - var json = await res.Content.ReadAsStringAsync(ct); - using var doc = System.Text.Json.JsonDocument.Parse(json); - var access = doc.RootElement.GetProperty("access_token").GetString(); - var expiresIn = doc.RootElement.GetProperty("expires_in").GetInt32(); - - var expiresAt = DateTime.UtcNow.AddSeconds(expiresIn); - return new TokenResult(access!, expiresAt); - } } } diff --git a/src/gateways/Web.BFF/Web.BFF.csproj b/src/gateways/Web.BFF/Web.BFF.csproj index 4796ed27..efc4f404 100644 --- a/src/gateways/Web.BFF/Web.BFF.csproj +++ b/src/gateways/Web.BFF/Web.BFF.csproj @@ -11,10 +11,12 @@ + + diff --git a/src/gateways/Web.BFF/appsettings.Development.json b/src/gateways/Web.BFF/appsettings.Development.json index 6cb1c75e..da03d31a 100644 --- a/src/gateways/Web.BFF/appsettings.Development.json +++ b/src/gateways/Web.BFF/appsettings.Development.json @@ -6,7 +6,7 @@ } }, "Keycloak": { - "Authority": "https://keycloak.local/auth/realms/yourrealm", + "Authority": "http://localhost:8080/realms/Teck-Cloud", "GatewayClientId": "teck-web-bff", "GatewayClientSecret": "767rYKqoHaHPpHlyXwqcxwqrdEdskkhv" }, diff --git a/src/gateways/Web.Edge/Middleware/EdgeRequestSanitizationMiddleware.cs b/src/gateways/Web.Edge/Middleware/EdgeRequestSanitizationMiddleware.cs new file mode 100644 index 00000000..df04e496 --- /dev/null +++ b/src/gateways/Web.Edge/Middleware/EdgeRequestSanitizationMiddleware.cs @@ -0,0 +1,46 @@ +namespace Web.Edge.Middleware; + +using Web.Edge.Security; + +public sealed class EdgeRequestSanitizationMiddleware +{ + private static readonly string[] HeadersToStrip = + { + "X-TenantId", + "X-Tenant-DbStrategy", + "X-Internal-Identity", + "X-Forwarded-User", + "X-Forwarded-Roles", + "X-Forwarded-Tenant" + }; + + private readonly RequestDelegate _next; + private readonly IInternalIdentityTokenService _internalIdentityTokenService; + + public EdgeRequestSanitizationMiddleware( + RequestDelegate next, + IInternalIdentityTokenService internalIdentityTokenService) + { + _next = next; + _internalIdentityTokenService = internalIdentityTokenService; + } + + public async Task InvokeAsync(HttpContext context) + { + foreach (var header in HeadersToStrip) + { + context.Request.Headers.Remove(header); + } + + if (context.User?.Identity?.IsAuthenticated == true) + { + var internalIdentityToken = _internalIdentityTokenService.CreateToken(context.User); + if (!string.IsNullOrWhiteSpace(internalIdentityToken)) + { + context.Request.Headers["X-Internal-Identity"] = internalIdentityToken; + } + } + + await _next(context); + } +} diff --git a/src/gateways/Web.Edge/Program.cs b/src/gateways/Web.Edge/Program.cs new file mode 100644 index 00000000..3c3615c3 --- /dev/null +++ b/src/gateways/Web.Edge/Program.cs @@ -0,0 +1,96 @@ +using System.Security.Claims; +using System.Text.Json; +using Keycloak.AuthServices.Authentication; +using Scalar.AspNetCore; +using SharedKernel.Infrastructure.Auth; +using Web.Edge.Middleware; +using Web.Edge.Security; + +var builder = WebApplication.CreateBuilder(args); + +var keycloakSection = builder.Configuration.GetSection("Keycloak"); +var keycloakOptions = new KeycloakAuthenticationOptions(); +keycloakSection.Bind(keycloakOptions); + +builder.Services.AddKeycloak(builder.Configuration, builder.Environment, keycloakOptions); +builder.Services.AddReverseProxy() + .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy")); +builder.Services.AddSingleton(); + +builder.Services.AddAuthentication(); +builder.Services.AddAuthorization(options => +{ + options.AddPolicy("RealmAdminOnly", policy => + policy.RequireAssertion(context => IsRealmAdmin(context.User))); +}); + +var app = builder.Build(); + +app.MapGet("/health", () => Results.Ok("ok")); + +app.UseRouting(); +app.UseAuthentication(); +app.UseAuthorization(); + +app.UseMiddleware(); + +app.MapScalarApiReference("/docs", options => +{ + options.WithTitle("Teck Web API") + .AddDocument("bff", "Web BFF", "/openapi/bff/v1/openapi.json", isDefault: true); +}).AllowAnonymous(); + +app.MapScalarApiReference("/docs/admin", options => +{ + options.WithTitle("Teck Internal APIs") + .AddDocument("bff", "Web BFF", "/openapi/bff/v1/openapi.json", isDefault: true) + .AddDocument("catalog", "Catalog API", "/openapi/admin/catalog/v1/openapi.json") + .AddDocument("customer", "Customer API", "/openapi/admin/customer/v1/openapi.json"); +}).RequireAuthorization("RealmAdminOnly"); + +app.MapReverseProxy(); + +app.Run(); + +static bool IsRealmAdmin(ClaimsPrincipal user) +{ + if (user.IsInRole("realm-admin")) + { + return true; + } + + foreach (var claim in user.Claims) + { + if ((string.Equals(claim.Type, "roles", StringComparison.OrdinalIgnoreCase) || + string.Equals(claim.Type, "role", StringComparison.OrdinalIgnoreCase) || + string.Equals(claim.Type, "realm_access.roles", StringComparison.OrdinalIgnoreCase)) && + string.Equals(claim.Value, "realm-admin", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (string.Equals(claim.Type, "realm_access", StringComparison.OrdinalIgnoreCase) && + !string.IsNullOrWhiteSpace(claim.Value) && + claim.Value.TrimStart().StartsWith('{')) + { + try + { + using var jsonDocument = JsonDocument.Parse(claim.Value); + if (jsonDocument.RootElement.TryGetProperty("roles", out var rolesElement) && + rolesElement.ValueKind == JsonValueKind.Array && + rolesElement.EnumerateArray().Any(role => + role.ValueKind == JsonValueKind.String && + string.Equals(role.GetString(), "realm-admin", StringComparison.OrdinalIgnoreCase))) + { + return true; + } + } + catch + { + // Ignore malformed claim payloads and continue evaluating remaining claims. + } + } + } + + return false; +} diff --git a/src/gateways/Web.Edge/Security/InternalIdentityTokenService.cs b/src/gateways/Web.Edge/Security/InternalIdentityTokenService.cs new file mode 100644 index 00000000..ec9b48de --- /dev/null +++ b/src/gateways/Web.Edge/Security/InternalIdentityTokenService.cs @@ -0,0 +1,74 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Microsoft.IdentityModel.Tokens; + +namespace Web.Edge.Security; + +public interface IInternalIdentityTokenService +{ + string? CreateToken(ClaimsPrincipal user); +} + +public sealed class InternalIdentityTokenService : IInternalIdentityTokenService +{ + private readonly IConfiguration _configuration; + private readonly ILogger _logger; + + public InternalIdentityTokenService(IConfiguration configuration, ILogger logger) + { + _configuration = configuration; + _logger = logger; + } + + public string? CreateToken(ClaimsPrincipal user) + { + var signingKey = _configuration["EdgeTrust:SigningKey"]; + if (string.IsNullOrWhiteSpace(signingKey)) + { + _logger.LogWarning("EdgeTrust:SigningKey is not configured; internal identity token will not be issued."); + return null; + } + + var issuer = _configuration["EdgeTrust:Issuer"] ?? "teck-edge"; + var audience = _configuration["EdgeTrust:Audience"] ?? "teck-web-bff-internal"; + var lifetimeSeconds = int.TryParse(_configuration["EdgeTrust:LifetimeSeconds"], out var seconds) ? seconds : 120; + + var now = DateTime.UtcNow; + var claims = user.Claims + .Where(claim => + !string.Equals(claim.Type, JwtRegisteredClaimNames.Exp, StringComparison.OrdinalIgnoreCase) && + !string.Equals(claim.Type, JwtRegisteredClaimNames.Nbf, StringComparison.OrdinalIgnoreCase) && + !string.Equals(claim.Type, JwtRegisteredClaimNames.Iat, StringComparison.OrdinalIgnoreCase) && + !string.Equals(claim.Type, JwtRegisteredClaimNames.Aud, StringComparison.OrdinalIgnoreCase) && + !string.Equals(claim.Type, JwtRegisteredClaimNames.Iss, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + if (!claims.Any(claim => string.Equals(claim.Type, ClaimTypes.NameIdentifier, StringComparison.OrdinalIgnoreCase) || string.Equals(claim.Type, "sub", StringComparison.OrdinalIgnoreCase))) + { + var subject = user.FindFirstValue(ClaimTypes.NameIdentifier) ?? user.FindFirstValue("sub"); + if (!string.IsNullOrWhiteSpace(subject)) + { + claims.Add(new Claim("sub", subject)); + } + } + + var credentials = new SigningCredentials( + new SymmetricSecurityKey(Encoding.UTF8.GetBytes(signingKey)), + SecurityAlgorithms.HmacSha256); + + var tokenDescriptor = new SecurityTokenDescriptor + { + Subject = new ClaimsIdentity(claims), + Expires = now.AddSeconds(lifetimeSeconds), + NotBefore = now.AddSeconds(-5), + Issuer = issuer, + Audience = audience, + SigningCredentials = credentials, + }; + + var handler = new JwtSecurityTokenHandler(); + var token = handler.CreateToken(tokenDescriptor); + return handler.WriteToken(token); + } +} diff --git a/src/gateways/Web.Edge/Web.Edge.csproj b/src/gateways/Web.Edge/Web.Edge.csproj new file mode 100644 index 00000000..af5ef92f --- /dev/null +++ b/src/gateways/Web.Edge/Web.Edge.csproj @@ -0,0 +1,20 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + diff --git a/src/gateways/Web.Edge/appsettings.Development.json b/src/gateways/Web.Edge/appsettings.Development.json new file mode 100644 index 00000000..93274a14 --- /dev/null +++ b/src/gateways/Web.Edge/appsettings.Development.json @@ -0,0 +1,79 @@ +{ + "Keycloak": { + "Authority": "http://localhost:8080/realms/Teck-Cloud", + "GatewayClientId": "teck-edge-gateway", + "GatewayClientSecret": "A33OS1oR4uInrsqB99eNPfqnYlj1yba1" + }, + "ReverseProxy": { + "Routes": { + "bff-openapi": { + "ClusterId": "bff", + "Order": 0, + "Match": { + "Path": "/openapi/bff/{documentName}/openapi.json" + }, + "Transforms": [ + { + "PathPattern": "/openapi/{documentName}/openapi.json" + } + ] + }, + "catalog-openapi-admin": { + "ClusterId": "catalog", + "Order": 0, + "AuthorizationPolicy": "RealmAdminOnly", + "Match": { + "Path": "/openapi/admin/catalog/{documentName}/openapi.json" + }, + "Transforms": [ + { + "PathPattern": "/openapi/{documentName}/openapi.json" + } + ] + }, + "customer-openapi-admin": { + "ClusterId": "customer", + "Order": 0, + "AuthorizationPolicy": "RealmAdminOnly", + "Match": { + "Path": "/openapi/admin/customer/{documentName}/openapi.json" + }, + "Transforms": [ + { + "PathPattern": "/openapi/{documentName}/openapi.json" + } + ] + }, + "bff-all": { + "ClusterId": "bff", + "Order": 1000, + "Match": { + "Path": "/{**catch-all}" + } + } + }, + "Clusters": { + "bff": { + "Destinations": { + "cluster1": { + "Address": "http://localhost:5002/" + } + } + }, + "catalog": { + "Destinations": { + "cluster1": { + "Address": "http://localhost:5001/" + } + } + }, + "customer": { + "Destinations": { + "cluster1": { + "Address": "http://localhost:5003/" + } + } + } + } + } +} diff --git a/src/gateways/Web.Edge/appsettings.json b/src/gateways/Web.Edge/appsettings.json new file mode 100644 index 00000000..4e242ff4 --- /dev/null +++ b/src/gateways/Web.Edge/appsettings.json @@ -0,0 +1,85 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "Keycloak": { + "Authority": "https://keycloak.local/auth/realms/yourrealm", + "GatewayClientId": "teck-edge-gateway", + "GatewayClientSecret": "replace-me" + }, + "ReverseProxy": { + "Routes": { + "bff-openapi": { + "ClusterId": "bff", + "Order": 0, + "Match": { + "Path": "/openapi/bff/{documentName}/openapi.json" + }, + "Transforms": [ + { + "PathPattern": "/openapi/{documentName}/openapi.json" + } + ] + }, + "catalog-openapi-admin": { + "ClusterId": "catalog", + "Order": 0, + "AuthorizationPolicy": "RealmAdminOnly", + "Match": { + "Path": "/openapi/admin/catalog/{documentName}/openapi.json" + }, + "Transforms": [ + { + "PathPattern": "/openapi/{documentName}/openapi.json" + } + ] + }, + "customer-openapi-admin": { + "ClusterId": "customer", + "Order": 0, + "AuthorizationPolicy": "RealmAdminOnly", + "Match": { + "Path": "/openapi/admin/customer/{documentName}/openapi.json" + }, + "Transforms": [ + { + "PathPattern": "/openapi/{documentName}/openapi.json" + } + ] + }, + "bff-all": { + "ClusterId": "bff", + "Order": 1000, + "Match": { + "Path": "/{**catch-all}" + } + } + }, + "Clusters": { + "bff": { + "Destinations": { + "cluster1": { + "Address": "http://localhost:5002/" + } + } + }, + "catalog": { + "Destinations": { + "cluster1": { + "Address": "http://localhost:5001/" + } + } + }, + "customer": { + "Destinations": { + "cluster1": { + "Address": "http://localhost:5003/" + } + } + } + } + } +} diff --git a/src/services/catalog/Catalog.Api/appsettings.Development.json b/src/services/catalog/Catalog.Api/appsettings.Development.json index 48c91854..9e624601 100644 --- a/src/services/catalog/Catalog.Api/appsettings.Development.json +++ b/src/services/catalog/Catalog.Api/appsettings.Development.json @@ -7,9 +7,9 @@ "MinimumLogLevel": "Debug" }, "Keycloak": { - "realm": "Teck.Cloud", - "auth-server-url": "https://auth.tecklab.dk", - "ssl-required": "external", + "realm": "Teck-Cloud", + "auth-server-url": "http://localhost:8080/", + "ssl-required": "none", "resource": "teck-catalog", "verify-token-audience": true, "credentials": { diff --git a/src/services/catalog/Catalog.Api/appsettings.json b/src/services/catalog/Catalog.Api/appsettings.json index 9bc89d4d..b81e558c 100644 --- a/src/services/catalog/Catalog.Api/appsettings.json +++ b/src/services/catalog/Catalog.Api/appsettings.json @@ -44,12 +44,8 @@ "catalogdb": "Host=localhost;Database=catalogdb;Username=postgres;Password=postgres;Port=5432" }, "Vault": { - "Address": "http://localhost:8200", - "AuthMethod": "Token", - "Token": "root", - "SecretsPath": "secret/database", - "SharedDatabaseSecretKey": "shared", - "UseDevelopmentDefaults": true + "UseDevelopmentDefaults": false, + "Note": "Vault runtime integration removed. Provide tenant DSNs via ExternalSecrets or environment variables." }, "Database": { "MigrateSharedOnStartup": false, diff --git a/src/services/catalog/Catalog.Infrastructure/DependencyInjection/InfrastructureServiceExtensions.cs b/src/services/catalog/Catalog.Infrastructure/DependencyInjection/InfrastructureServiceExtensions.cs index 7c5c4827..d19f915c 100644 --- a/src/services/catalog/Catalog.Infrastructure/DependencyInjection/InfrastructureServiceExtensions.cs +++ b/src/services/catalog/Catalog.Infrastructure/DependencyInjection/InfrastructureServiceExtensions.cs @@ -1,3 +1,4 @@ +#pragma warning disable IDE0005 using System.Reflection; using Catalog.Infrastructure.Persistence; using JasperFx.CodeGeneration; @@ -10,16 +11,15 @@ using Microsoft.Extensions.Hosting; using RabbitMQ.Client; using Scrutor; +using SharedKernel.Core.Database; using SharedKernel.Core.Domain; using SharedKernel.Core.Exceptions; -using SharedKernel.Core.Database; using SharedKernel.Infrastructure.Auth; using Wolverine; using Wolverine.EntityFrameworkCore; using Wolverine.Postgresql; using Wolverine.RabbitMQ; - namespace Catalog.Infrastructure.DependencyInjection; /// @@ -117,11 +117,10 @@ public static void AddInfrastructureServices(this WebApplicationBuilder builder, rabbit.EnableWolverineControlQueues(); rabbit.UseConventionalRouting(); - opts.Services.AddDbContextWithWolverineManagedMultiTenancy( - (builder, defaultWriteConnectionString, _) => - { - builder.UseNpgsql(defaultWriteConnectionString.Value, assembly => assembly.MigrationsAssembly(dbContextAssembly)); - }); + opts.Services.AddDbContextWithWolverineManagedMultiTenancy((builder, defaultWriteConnectionString, _) => + { + builder.UseNpgsql(defaultWriteConnectionString.Value, assembly => assembly.MigrationsAssembly(dbContextAssembly)); + }); }); } catch (Exception wolverineException) @@ -130,9 +129,7 @@ public static void AddInfrastructureServices(this WebApplicationBuilder builder, throw; } - builder.Services.AddHealthChecks().AddRabbitMQ( - sp => { var factory = new ConnectionFactory @@ -145,8 +142,6 @@ public static void AddInfrastructureServices(this WebApplicationBuilder builder, timeout: TimeSpan.FromSeconds(5), tags: new[] { "messagebus", "rabbitmq" }); - - // Automatically register services. builder.Services.Scan(selector => selector .FromAssemblies(applicationAssembly, dbContextAssembly) diff --git a/src/services/customer/Customer.Api/Endpoints/V1/Tenants/CheckServiceReadiness/CheckServiceReadinessEndpoint.cs b/src/services/customer/Customer.Api/Endpoints/V1/Tenants/CheckServiceReadiness/CheckServiceReadinessEndpoint.cs deleted file mode 100644 index 07fd4d94..00000000 --- a/src/services/customer/Customer.Api/Endpoints/V1/Tenants/CheckServiceReadiness/CheckServiceReadinessEndpoint.cs +++ /dev/null @@ -1,50 +0,0 @@ -using Customer.Application.Tenants.Queries.CheckServiceReadiness; -using ErrorOr; -using FastEndpoints; -using Mediator; -using SharedKernel.Infrastructure.Endpoints; - -namespace Customer.Api.Endpoints.V1.Tenants.CheckServiceReadiness; - -/// -/// The check service readiness endpoint. -/// -/// -/// Initializes a new instance of the class. -/// -/// The mediator. -internal class CheckServiceReadinessEndpoint(ISender mediator) : Endpoint -{ - /// - /// The mediator. - /// - private readonly ISender _mediator = mediator; - - /// - /// Configure the endpoint. - /// - public override void Configure() - { - Get("/Tenants/{TenantId}/services/{ServiceName}/ready"); - AllowAnonymous(); - Version(1); - } - - /// - /// Handle the request. - /// - /// The request. - /// The cancellation token. - /// A task. - public override async Task HandleAsync(CheckServiceReadinessRequest req, CancellationToken ct) - { - CheckServiceReadinessQuery query = new(req.TenantId, req.ServiceName); - ErrorOr queryResponse = await _mediator.Send(query, ct); - - var response = queryResponse.Match>( - value => new ServiceReadinessResponse(value), - errors => errors); - - await this.SendAsync(response, ct); - } -} diff --git a/src/services/customer/Customer.Api/Endpoints/V1/Tenants/CreateTenant/CreateTenantRequest.cs b/src/services/customer/Customer.Api/Endpoints/V1/Tenants/CreateTenant/CreateTenantRequest.cs index 7a707d9f..f0c13be2 100644 --- a/src/services/customer/Customer.Api/Endpoints/V1/Tenants/CreateTenant/CreateTenantRequest.cs +++ b/src/services/customer/Customer.Api/Endpoints/V1/Tenants/CreateTenant/CreateTenantRequest.cs @@ -1,4 +1,4 @@ -using SharedKernel.Core.Database; +using SharedKernel.Core.Models; namespace Customer.Api.Endpoints.V1.Tenants.CreateTenant; diff --git a/src/services/customer/Customer.Api/Endpoints/V1/Tenants/GetTenantDatabaseInfo/GetTenantDatabaseInfoEndpoint.cs b/src/services/customer/Customer.Api/Endpoints/V1/Tenants/GetTenantDatabaseInfo/GetTenantDatabaseInfoEndpoint.cs deleted file mode 100644 index c55aedca..00000000 --- a/src/services/customer/Customer.Api/Endpoints/V1/Tenants/GetTenantDatabaseInfo/GetTenantDatabaseInfoEndpoint.cs +++ /dev/null @@ -1,47 +0,0 @@ -using Customer.Application.Tenants.DTOs; -using Customer.Application.Tenants.Queries.GetTenantDatabaseInfo; -using ErrorOr; -using FastEndpoints; -using Keycloak.AuthServices.Authorization; -using Mediator; -using SharedKernel.Infrastructure.Endpoints; - -namespace Customer.Api.Endpoints.V1.Tenants.GetTenantDatabaseInfo; - -/// -/// The get tenant database info endpoint. -/// -/// -/// Initializes a new instance of the class. -/// -/// The mediator. -internal class GetTenantDatabaseInfoEndpoint(ISender mediator) : Endpoint -{ - /// - /// The mediator. - /// - private readonly ISender _mediator = mediator; - - /// - /// Configure the endpoint. - /// - public override void Configure() - { - Get("/Tenants/{TenantId}/services/{ServiceName}/database"); - Options(ep => ep.RequireProtectedResource("tenant", "read")); - Version(1); - } - - /// - /// Handle the request. - /// - /// The request. - /// The cancellation token. - /// A task. - public override async Task HandleAsync(GetTenantDatabaseInfoRequest req, CancellationToken ct) - { - GetTenantDatabaseInfoQuery query = new(req.TenantId, req.ServiceName); - ErrorOr queryResponse = await _mediator.Send(query, ct); - await this.SendAsync(queryResponse, ct); - } -} diff --git a/src/services/customer/Customer.Api/appsettings.Development.json b/src/services/customer/Customer.Api/appsettings.Development.json index 63cc7839..6e7ac544 100644 --- a/src/services/customer/Customer.Api/appsettings.Development.json +++ b/src/services/customer/Customer.Api/appsettings.Development.json @@ -1,8 +1,8 @@ { "Keycloak": { - "realm": "Teck.Cloud", - "auth-server-url": "https://auth.tecklab.dk", - "ssl-required": "external", + "realm": "Teck-Cloud", + "auth-server-url": "http://localhost:8080/", + "ssl-required": "none", "resource": "teck-customer", "verify-token-audience": true, "credentials": { diff --git a/src/services/customer/Customer.Api/appsettings.json b/src/services/customer/Customer.Api/appsettings.json index a40e484f..46fe0e6a 100644 --- a/src/services/customer/Customer.Api/appsettings.json +++ b/src/services/customer/Customer.Api/appsettings.json @@ -36,13 +36,9 @@ "postgres-write": "Host=localhost;Database=customerdb;Username=postgres;Password=postgres;Port=5432", "postgres-read": "Host=localhost;Database=customerdb;Username=postgres;Password=postgres;Port=5432" }, - "Vault": { - "Address": "http://localhost:8200", - "AuthMethod": "Token", - "Token": "root", - "SecretsPath": "secret/database", - "SharedDatabaseSecretKey": "shared", - "UseDevelopmentDefaults": true + "Vault": { + "UseDevelopmentDefaults": false, + "Note": "Vault runtime integration removed. Provide tenant DSNs via ExternalSecrets or environment variables." }, "Database": { "MigrateSharedOnStartup": true, diff --git a/src/services/customer/Customer.Application/Tenants/Commands/CreateTenant/CreateTenantCommandHandler.cs b/src/services/customer/Customer.Application/Tenants/Commands/CreateTenant/CreateTenantCommandHandler.cs index 12d7838b..57ea71e0 100644 --- a/src/services/customer/Customer.Application/Tenants/Commands/CreateTenant/CreateTenantCommandHandler.cs +++ b/src/services/customer/Customer.Application/Tenants/Commands/CreateTenant/CreateTenantCommandHandler.cs @@ -1,10 +1,15 @@ +#pragma warning disable IDE0005 +using System.Linq; +using System.Threading; +using System.Threading.Tasks; using Customer.Application.Tenants.DTOs; using Customer.Domain.Entities.TenantAggregate; using Customer.Domain.Entities.TenantAggregate.Repositories; using ErrorOr; using SharedKernel.Core.CQRS; -using SharedKernel.Core.Pricing; using SharedKernel.Core.Models; +using SharedKernel.Core.Pricing; + namespace Customer.Application.Tenants.Commands.CreateTenant; /// @@ -12,11 +17,11 @@ namespace Customer.Application.Tenants.Commands.CreateTenant; /// public class CreateTenantCommandHandler : ICommandHandler> { - private static readonly string[] Services = new[] { "catalog", "orders", "customer" }; private readonly ITenantWriteRepository _tenantRepository; private readonly Customer.Application.Common.Interfaces.IUnitOfWork _unitOfWork; + /// /// Initializes a new instance of the class. /// @@ -30,7 +35,6 @@ public CreateTenantCommandHandler( _unitOfWork = unitOfWork; } - /// public async ValueTask> Handle(CreateTenantCommand command, CancellationToken cancellationToken) { @@ -65,7 +69,6 @@ public async ValueTask> Handle(CreateTenantCommand command, C command.DatabaseStrategy, command.CustomCredentials); - if (setupResult.IsError) { return setupResult.Errors; @@ -82,14 +85,11 @@ public async ValueTask> Handle(CreateTenantCommand command, C return dto; } - private static Task> SetupServiceDatabaseAsync( - Tenant tenant, string serviceName, DatabaseStrategy strategy, DatabaseCredentials? customCredentials) - { bool hasSeparateReadDatabase = false; @@ -121,11 +121,8 @@ private static Task> SetupServiceDatabaseAsync( tenant.AddDatabaseMetadata(serviceName, writeEnvVarKey, readEnvVarKey, hasSeparateReadDatabase); return Task.FromResult>(Result.Success); - } - - private static TenantDto MapToDto(Tenant tenant) { return new TenantDto diff --git a/src/services/customer/Customer.Application/Tenants/Queries/CheckServiceReadiness/CheckServiceReadinessQuery.cs b/src/services/customer/Customer.Application/Tenants/Queries/CheckServiceReadiness/CheckServiceReadinessQuery.cs deleted file mode 100644 index ab9fbe51..00000000 --- a/src/services/customer/Customer.Application/Tenants/Queries/CheckServiceReadiness/CheckServiceReadinessQuery.cs +++ /dev/null @@ -1,11 +0,0 @@ -using ErrorOr; -using SharedKernel.Core.CQRS; - -namespace Customer.Application.Tenants.Queries.CheckServiceReadiness; - -/// -/// Query to check if a service is ready for a tenant (migration completed). -/// -/// The tenant identifier. -/// The service name. -public record CheckServiceReadinessQuery(Guid TenantId, string ServiceName) : IQuery>; diff --git a/src/services/customer/Customer.Application/Tenants/Queries/CheckServiceReadiness/CheckServiceReadinessQueryHandler.cs b/src/services/customer/Customer.Application/Tenants/Queries/CheckServiceReadiness/CheckServiceReadinessQueryHandler.cs deleted file mode 100644 index 0b447c14..00000000 --- a/src/services/customer/Customer.Application/Tenants/Queries/CheckServiceReadiness/CheckServiceReadinessQueryHandler.cs +++ /dev/null @@ -1,52 +0,0 @@ -using Customer.Domain.Entities.TenantAggregate.Repositories; -using ErrorOr; -using Microsoft.Extensions.Configuration; -using SharedKernel.Core; -using SharedKernel.Core.CQRS; - -namespace Customer.Application.Tenants.Queries.CheckServiceReadiness; - -/// -/// Handler for CheckServiceReadinessQuery. -/// -public class CheckServiceReadinessQueryHandler : IQueryHandler> -{ - private readonly ITenantWriteRepository _tenantRepository; - - /// - /// Initializes a new instance of the class. - /// - /// The tenant repository. - public CheckServiceReadinessQueryHandler(ITenantWriteRepository tenantRepository) - { - _tenantRepository = tenantRepository; - } - - /// - public async ValueTask> Handle(CheckServiceReadinessQuery query, CancellationToken cancellationToken) - { - var tenant = await _tenantRepository.GetByIdAsync(query.TenantId, cancellationToken); - if (tenant == null) - { - return Error.NotFound("Tenant.NotFound", $"Tenant with ID '{query.TenantId}' not found"); - } - - // Determine readiness by checking if tenant has a DB entry for the service and if the DSN env var is present. - var dbMetadata = tenant.Databases.FirstOrDefault(metadata => metadata.ServiceName == query.ServiceName); - if (dbMetadata == null) - { - return Error.NotFound("Tenant.DatabaseMetadataNotFound", $"Database metadata for service '{query.ServiceName}' not found"); - } - - // Attempt to resolve the write DSN env var for the tenant/service. - try - { - var dsn = TenantConnectionProvider.GetTenantConnection(new ConfigurationBuilder().AddEnvironmentVariables().Build(), tenant.Identifier, readOnly: false); - return !string.IsNullOrWhiteSpace(dsn); - } - catch (Exception exception) - { - return Error.Unexpected("Tenant.DsnResolutionFailed", exception.ToString()); - } - } -} diff --git a/src/services/customer/Customer.Application/Tenants/Queries/GetTenantDatabaseInfo/GetTenantDatabaseInfoQuery.cs b/src/services/customer/Customer.Application/Tenants/Queries/GetTenantDatabaseInfo/GetTenantDatabaseInfoQuery.cs deleted file mode 100644 index 0711b655..00000000 --- a/src/services/customer/Customer.Application/Tenants/Queries/GetTenantDatabaseInfo/GetTenantDatabaseInfoQuery.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Customer.Application.Tenants.DTOs; -using ErrorOr; -using SharedKernel.Core.CQRS; - -namespace Customer.Application.Tenants.Queries.GetTenantDatabaseInfo; - -/// -/// Query to get database information for a specific service. -/// -/// The tenant identifier. -/// The service name. -public record GetTenantDatabaseInfoQuery(Guid TenantId, string ServiceName) : IQuery>; diff --git a/src/services/customer/Customer.Application/Tenants/Queries/GetTenantDatabaseInfo/GetTenantDatabaseInfoQueryHandler.cs b/src/services/customer/Customer.Application/Tenants/Queries/GetTenantDatabaseInfo/GetTenantDatabaseInfoQueryHandler.cs deleted file mode 100644 index 31d16e0f..00000000 --- a/src/services/customer/Customer.Application/Tenants/Queries/GetTenantDatabaseInfo/GetTenantDatabaseInfoQueryHandler.cs +++ /dev/null @@ -1,48 +0,0 @@ -using Customer.Application.Tenants.DTOs; -using Customer.Domain.Entities.TenantAggregate.Repositories; -using ErrorOr; -using SharedKernel.Core.CQRS; - -namespace Customer.Application.Tenants.Queries.GetTenantDatabaseInfo; - -/// -/// Handler for GetTenantDatabaseInfoQuery. -/// -public class GetTenantDatabaseInfoQueryHandler : IQueryHandler> -{ - private readonly ITenantWriteRepository _tenantRepository; - - /// - /// Initializes a new instance of the class. - /// - /// The tenant repository. - public GetTenantDatabaseInfoQueryHandler(ITenantWriteRepository tenantRepository) - { - _tenantRepository = tenantRepository; - } - - /// - public async ValueTask> Handle(GetTenantDatabaseInfoQuery query, CancellationToken cancellationToken) - { - var tenant = await _tenantRepository.GetByIdAsync(query.TenantId, cancellationToken); - if (tenant == null) - { - return Error.NotFound("Tenant.NotFound", $"Tenant with ID '{query.TenantId}' not found"); - } - - var database = tenant.Databases.FirstOrDefault(dbMetadata => dbMetadata.ServiceName == query.ServiceName); - if (database == null) - { - return Error.NotFound("Tenant.DatabaseNotFound", $"Database metadata for service '{query.ServiceName}' not found"); - } - - var dto = new ServiceDatabaseInfoDto - { - WriteEnvVarKey = database.WriteEnvVarKey, - ReadEnvVarKey = database.ReadEnvVarKey, - HasSeparateReadDatabase = database.HasSeparateReadDatabase - }; - - return dto; - } -} diff --git a/src/services/customer/Customer.Infrastructure/DependencyInjection/InfrastructureServiceExtensions.cs b/src/services/customer/Customer.Infrastructure/DependencyInjection/InfrastructureServiceExtensions.cs index 935e4ec0..2a982417 100644 --- a/src/services/customer/Customer.Infrastructure/DependencyInjection/InfrastructureServiceExtensions.cs +++ b/src/services/customer/Customer.Infrastructure/DependencyInjection/InfrastructureServiceExtensions.cs @@ -1,5 +1,5 @@ +#pragma warning disable IDE0005 using System.Reflection; -using Customer.Application.Common.Interfaces; using Customer.Domain.Entities.TenantAggregate.Repositories; using Customer.Infrastructure.Persistence; using Customer.Infrastructure.Persistence.Repositories.Write; @@ -8,9 +8,9 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Hosting; using RabbitMQ.Client; +using SharedKernel.Core.Database; using SharedKernel.Core.Domain; using SharedKernel.Core.Exceptions; -using SharedKernel.Core.Database; using Wolverine; using Wolverine.EntityFrameworkCore; using Wolverine.Postgresql; @@ -54,7 +54,7 @@ public static void AddInfrastructureServices(this WebApplicationBuilder builder, // Register repositories builder.Services.AddScoped(); - builder.Services.AddScoped(); + builder.Services.AddScoped(); // Configure Wolverine builder.UseWolverine(opts => @@ -78,7 +78,7 @@ public static void AddInfrastructureServices(this WebApplicationBuilder builder, // Add health checks builder.Services.AddHealthChecks() - .AddNpgSql(defaultWriteConnectionString, name: "postgres-write", tags: ["database", "postgres"]) + .AddNpgSql(defaultWriteConnectionString, name: "postgres-write", tags: new[] { "database", "postgres" }) .AddRabbitMQ( serviceProvider => { @@ -90,8 +90,7 @@ public static void AddInfrastructureServices(this WebApplicationBuilder builder, return factory.CreateConnectionAsync(); }, timeout: TimeSpan.FromSeconds(5), - tags: ["messagebus", "rabbitmq"]); - + tags: new[] { "messagebus", "rabbitmq" }); } /// diff --git a/tests/unit/Customer.UnitTests/Application/Queries/Tenants/CheckServiceReadinessQueryHandlerTests.cs b/tests/unit/Customer.UnitTests/Application/Queries/Tenants/CheckServiceReadinessQueryHandlerTests.cs deleted file mode 100644 index f31fd4bd..00000000 --- a/tests/unit/Customer.UnitTests/Application/Queries/Tenants/CheckServiceReadinessQueryHandlerTests.cs +++ /dev/null @@ -1,167 +0,0 @@ -using Customer.Application.Tenants.Queries.CheckServiceReadiness; -using Customer.Domain.Entities.TenantAggregate; -using Customer.Domain.Entities.TenantAggregate.Repositories; -using ErrorOr; -using NSubstitute; -using SharedKernel.Core.Pricing; - -using Shouldly; - -namespace Customer.UnitTests.Application.Queries.Tenants; - -public sealed class CheckServiceReadinessQueryHandlerTests -{ - private readonly ITenantWriteRepository _tenantRepository; - private readonly CheckServiceReadinessQueryHandler _handler; - - public CheckServiceReadinessQueryHandlerTests() - { - _tenantRepository = Substitute.For(); - _handler = new CheckServiceReadinessQueryHandler(_tenantRepository); - } - - [Fact] - public async Task Handle_ShouldReturnTrue_WhenMigrationStatusIsCompleted() - { - // Arrange - var tenantId = Guid.NewGuid(); - var serviceName = "CatalogService"; - - var tenantResult = Tenant.Create( - "test-tenant", - "Test Tenant", - "Pro", - DatabaseStrategy.Shared, - DatabaseProvider.PostgreSQL); - - var tenant = tenantResult.Value; - tenant.AddDatabaseMetadata( - serviceName, - "ConnectionStrings__Tenants__test-tenant__Write", - "ConnectionStrings__Tenants__test-tenant__Read", - true); - - // Set environment variable for the tenant write DSN - Environment.SetEnvironmentVariable("ConnectionStrings__Tenants__test-tenant__Write", "Host=localhost;Database=db;Username=user;Password=pass"); - - _tenantRepository.GetByIdAsync(tenantId, Arg.Any()) - .Returns(tenant); - - var query = new CheckServiceReadinessQuery(tenantId, serviceName); - - - // Act - var result = await _handler.Handle(query, TestContext.Current.CancellationToken); - - // Assert - result.IsError.ShouldBeFalse(); - result.Value.ShouldBeTrue(); - - await _tenantRepository.Received(1).GetByIdAsync(tenantId, Arg.Any()); - } - - [Fact] - public async Task Handle_ShouldReturnFalse_WhenDsnEnvVarIsMissing() - { - // Arrange - var tenantId = Guid.NewGuid(); - var serviceName = "CatalogService"; - - var tenantResult = Tenant.Create( - "test-tenant", - "Test Tenant", - "Pro", - DatabaseStrategy.Shared, - DatabaseProvider.PostgreSQL); - - var tenant = tenantResult.Value; - tenant.AddDatabaseMetadata( - serviceName, - "ConnectionStrings__Tenants__test-tenant__Write", - null, - false); - - // Ensure no env var is set for write DSN to simulate non-ready state - Environment.SetEnvironmentVariable("ConnectionStrings__Tenants__test-tenant__Write", null); - - _tenantRepository.GetByIdAsync(tenantId, Arg.Any()) - .Returns(tenant); - - var query = new CheckServiceReadinessQuery(tenantId, serviceName); - - // Act - var result = await _handler.Handle(query, TestContext.Current.CancellationToken); - - // Assert - result.IsError.ShouldBeFalse(); - result.Value.ShouldBeFalse(); - - await _tenantRepository.Received(1).GetByIdAsync(tenantId, Arg.Any()); - } - - - [Fact] - public async Task Handle_ShouldReturnNotFoundError_WhenTenantDoesNotExist() - { - // Arrange - var tenantId = Guid.NewGuid(); - var serviceName = "CatalogService"; - - _tenantRepository.GetByIdAsync(tenantId, Arg.Any()) - .Returns((Tenant?)null); - - var query = new CheckServiceReadinessQuery(tenantId, serviceName); - - // Act - var result = await _handler.Handle(query, TestContext.Current.CancellationToken); - - // Assert - result.IsError.ShouldBeTrue(); - result.FirstError.Type.ShouldBe(ErrorType.NotFound); - result.FirstError.Code.ShouldBe("Tenant.NotFound"); - result.FirstError.Description.ShouldBe($"Tenant with ID '{tenantId}' not found"); - - await _tenantRepository.Received(1).GetByIdAsync(tenantId, Arg.Any()); - } - - [Fact] - public async Task Handle_ShouldReturnNotFoundError_WhenDatabaseMetadataForServiceDoesNotExist() - { - // Arrange - var tenantId = Guid.NewGuid(); - var serviceName = "NonExistentService"; - - var tenantResult = Tenant.Create( - "test-tenant", - "Test Tenant", - "Pro", - DatabaseStrategy.Shared, - DatabaseProvider.PostgreSQL); - - var tenant = tenantResult.Value; - // Add metadata for a different service - tenant.AddDatabaseMetadata( - "CatalogService", - "ConnectionStrings__Tenants__test-tenant__Write", - null, - false); - - _tenantRepository.GetByIdAsync(tenantId, Arg.Any()) - .Returns(tenant); - - var query = new CheckServiceReadinessQuery(tenantId, serviceName); - - - // Act - var result = await _handler.Handle(query, TestContext.Current.CancellationToken); - - // Assert - result.IsError.ShouldBeTrue(); - result.FirstError.Type.ShouldBe(ErrorType.NotFound); - result.FirstError.Code.ShouldBe("Tenant.DatabaseMetadataNotFound"); - result.FirstError.Description.ShouldBe($"Database metadata for service '{serviceName}' not found"); - - await _tenantRepository.Received(1).GetByIdAsync(tenantId, Arg.Any()); - } - -} diff --git a/tests/unit/Customer.UnitTests/Application/Queries/Tenants/GetTenantByIdQueryHandlerTests.cs b/tests/unit/Customer.UnitTests/Application/Queries/Tenants/GetTenantByIdQueryHandlerTests.cs index 959ded71..088c0e11 100644 --- a/tests/unit/Customer.UnitTests/Application/Queries/Tenants/GetTenantByIdQueryHandlerTests.cs +++ b/tests/unit/Customer.UnitTests/Application/Queries/Tenants/GetTenantByIdQueryHandlerTests.cs @@ -111,14 +111,14 @@ public async Task Handle_ShouldMapMultipleDatabases_WhenTenantHasMultipleService // Add multiple database metadata tenant.AddDatabaseMetadata( "CatalogService", - "secret/data/tenants/multi/catalog/write", + "ConnectionStrings__Tenants__multi__Write", null, false); tenant.AddDatabaseMetadata( "CustomerService", - "secret/data/tenants/multi/customer/write", - "secret/data/tenants/multi/customer/read", + "ConnectionStrings__Tenants__multi__Write", + "ConnectionStrings__Tenants__multi__Read", true); diff --git a/tests/unit/Customer.UnitTests/Application/Queries/Tenants/GetTenantDatabaseInfoQueryHandlerTests.cs b/tests/unit/Customer.UnitTests/Application/Queries/Tenants/GetTenantDatabaseInfoQueryHandlerTests.cs deleted file mode 100644 index 03f83d4c..00000000 --- a/tests/unit/Customer.UnitTests/Application/Queries/Tenants/GetTenantDatabaseInfoQueryHandlerTests.cs +++ /dev/null @@ -1,166 +0,0 @@ -using Customer.Application.Tenants.Queries.GetTenantDatabaseInfo; -using Customer.Domain.Entities.TenantAggregate; -using Customer.Domain.Entities.TenantAggregate.Repositories; -using ErrorOr; -using NSubstitute; -using SharedKernel.Core.Pricing; -using Shouldly; - -namespace Customer.UnitTests.Application.Queries.Tenants; - -public sealed class GetTenantDatabaseInfoQueryHandlerTests -{ - private readonly ITenantWriteRepository _tenantRepository; - private readonly GetTenantDatabaseInfoQueryHandler _handler; - - public GetTenantDatabaseInfoQueryHandlerTests() - { - _tenantRepository = Substitute.For(); - _handler = new GetTenantDatabaseInfoQueryHandler(_tenantRepository); - } - - [Fact] - public async Task Handle_ShouldReturnServiceDatabaseInfoDto_WhenDatabaseExists() - { - // Arrange - var tenantId = Guid.NewGuid(); - var serviceName = "CatalogService"; - - var tenantResult = Tenant.Create( - "test-tenant", - "Test Tenant", - "Pro", - DatabaseStrategy.Shared, - DatabaseProvider.PostgreSQL); - - var tenant = tenantResult.Value; - tenant.AddDatabaseMetadata( - serviceName, - "ConnectionStrings__Tenants__test-tenant__Write", - "ConnectionStrings__Tenants__test-tenant__Read", - true); - - _tenantRepository.GetByIdAsync(tenantId, Arg.Any()) - .Returns(tenant); - - var query = new GetTenantDatabaseInfoQuery(tenantId, serviceName); - - // Act - var result = await _handler.Handle(query, TestContext.Current.CancellationToken); - - // Assert - result.IsError.ShouldBeFalse(); - var dto = result.Value; - - dto.ShouldNotBeNull(); - dto.WriteEnvVarKey.ShouldBe("ConnectionStrings__Tenants__test-tenant__Write"); - dto.ReadEnvVarKey.ShouldBe("ConnectionStrings__Tenants__test-tenant__Read"); - dto.HasSeparateReadDatabase.ShouldBeTrue(); - - await _tenantRepository.Received(1).GetByIdAsync(tenantId, Arg.Any()); - } - - [Fact] - public async Task Handle_ShouldReturnServiceDatabaseInfoDto_WhenDatabaseHasNoReadReplica() - { - // Arrange - var tenantId = Guid.NewGuid(); - var serviceName = "CatalogService"; - - var tenantResult = Tenant.Create( - "test-tenant", - "Test Tenant", - "Free", - DatabaseStrategy.Shared, - DatabaseProvider.PostgreSQL); - - var tenant = tenantResult.Value; - tenant.AddDatabaseMetadata( - serviceName, - "secret/data/tenants/test-tenant/catalog/write", - null, - false); - - _tenantRepository.GetByIdAsync(tenantId, Arg.Any()) - .Returns(tenant); - - var query = new GetTenantDatabaseInfoQuery(tenantId, serviceName); - - // Act - var result = await _handler.Handle(query, TestContext.Current.CancellationToken); - - // Assert - result.IsError.ShouldBeFalse(); - var dto = result.Value; - - dto.ShouldNotBeNull(); - dto.WriteEnvVarKey.ShouldBe("ConnectionStrings__Tenants__test-tenant__Write"); - dto.ReadEnvVarKey.ShouldBeNull(); - dto.HasSeparateReadDatabase.ShouldBeFalse(); - - await _tenantRepository.Received(1).GetByIdAsync(tenantId, Arg.Any()); - } - - [Fact] - public async Task Handle_ShouldReturnNotFoundError_WhenTenantDoesNotExist() - { - // Arrange - var tenantId = Guid.NewGuid(); - var serviceName = "CatalogService"; - - _tenantRepository.GetByIdAsync(tenantId, Arg.Any()) - .Returns((Tenant?)null); - - var query = new GetTenantDatabaseInfoQuery(tenantId, serviceName); - - // Act - var result = await _handler.Handle(query, TestContext.Current.CancellationToken); - - // Assert - result.IsError.ShouldBeTrue(); - result.FirstError.Type.ShouldBe(ErrorType.NotFound); - result.FirstError.Code.ShouldBe("Tenant.NotFound"); - result.FirstError.Description.ShouldBe($"Tenant with ID '{tenantId}' not found"); - - await _tenantRepository.Received(1).GetByIdAsync(tenantId, Arg.Any()); - } - - [Fact] - public async Task Handle_ShouldReturnNotFoundError_WhenDatabaseMetadataForServiceDoesNotExist() - { - // Arrange - var tenantId = Guid.NewGuid(); - var serviceName = "NonExistentService"; - - var tenantResult = Tenant.Create( - "test-tenant", - "Test Tenant", - "Pro", - DatabaseStrategy.Shared, - DatabaseProvider.PostgreSQL); - - var tenant = tenantResult.Value; - // Add database metadata for a different service - tenant.AddDatabaseMetadata( - "CatalogService", - "secret/data/tenants/test-tenant/catalog/write", - null, - false); - - _tenantRepository.GetByIdAsync(tenantId, Arg.Any()) - .Returns(tenant); - - var query = new GetTenantDatabaseInfoQuery(tenantId, serviceName); - - // Act - var result = await _handler.Handle(query, TestContext.Current.CancellationToken); - - // Assert - result.IsError.ShouldBeTrue(); - result.FirstError.Type.ShouldBe(ErrorType.NotFound); - result.FirstError.Code.ShouldBe("Tenant.DatabaseNotFound"); - result.FirstError.Description.ShouldBe($"Database metadata for service '{serviceName}' not found"); - - await _tenantRepository.Received(1).GetByIdAsync(tenantId, Arg.Any()); - } -} diff --git a/tests/unit/Customer.UnitTests/Application/Validators/CreateTenantCommandValidatorTests.cs b/tests/unit/Customer.UnitTests/Application/Validators/CreateTenantCommandValidatorTests.cs index f72fd26b..f28a2eb1 100644 --- a/tests/unit/Customer.UnitTests/Application/Validators/CreateTenantCommandValidatorTests.cs +++ b/tests/unit/Customer.UnitTests/Application/Validators/CreateTenantCommandValidatorTests.cs @@ -1,6 +1,6 @@ using Customer.Application.Tenants.Commands.CreateTenant; using FluentValidation.TestHelper; -using SharedKernel.Core.Database; +using SharedKernel.Core.Models; namespace Customer.UnitTests.Application.Validators; diff --git a/tests/unit/SharedKernel.Persistence.UnitTests/Database/MultiTenant/TenantDbConnectionResolverTests.cs b/tests/unit/SharedKernel.Persistence.UnitTests/Database/MultiTenant/TenantDbConnectionResolverTests.cs new file mode 100644 index 00000000..753fe576 --- /dev/null +++ b/tests/unit/SharedKernel.Persistence.UnitTests/Database/MultiTenant/TenantDbConnectionResolverTests.cs @@ -0,0 +1,126 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using Shouldly; +using SharedKernel.Core.Pricing; +using SharedKernel.Infrastructure.MultiTenant; +using SharedKernel.Persistence.Database.MultiTenant; +using ZiggyCreatures.Caching.Fusion; +#pragma warning disable CA2000 + +namespace SharedKernel.Persistence.UnitTests.Database.MultiTenant; + +public sealed class TenantDbConnectionResolverTests +{ + [Fact] + public void ResolveTenantConnection_UsesSharedDefaults_WhenGatewayHintIsShared() + { + // Arrange + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["X-TenantId"] = "tenant-shared-1"; + httpContext.Request.Headers["X-Tenant-DbStrategy"] = "Shared"; + + var serviceProvider = BuildServiceProvider(httpContext); + var resolver = new TenantDbConnectionResolver( + serviceProvider, + "Host=shared-write;", + "Host=shared-read;", + DatabaseProvider.PostgreSQL); + + var tenant = new TenantDetails + { + Id = "tenant-shared-1", + DatabaseStrategy = "Dedicated", + WriteConnectionString = "Host=tenant-dedicated;", + DatabaseProvider = "PostgreSQL", + }; + + // Act + var result = resolver.ResolveTenantConnection(tenant); + + // Assert + result.Strategy.ShouldBe(DatabaseStrategy.Shared); + result.WriteConnectionString.ShouldBe("Host=shared-write;"); + result.ReadConnectionString.ShouldBe("Host=shared-read;"); + } + + [Fact] + public void ResolveTenantConnection_FallsBack_WhenGatewayHintTenantDoesNotMatch() + { + // Arrange + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["X-TenantId"] = "tenant-other"; + httpContext.Request.Headers["X-Tenant-DbStrategy"] = "Shared"; + + var serviceProvider = BuildServiceProvider(httpContext, returnErrorFromCustomerApi: true); + var resolver = new TenantDbConnectionResolver( + serviceProvider, + "Host=shared-write;", + "Host=shared-read;", + DatabaseProvider.PostgreSQL); + + var tenant = new TenantDetails + { + Id = "tenant-dedicated-1", + DatabaseStrategy = "Dedicated", + WriteConnectionString = "Host=tenant-dedicated;", + ReadConnectionString = "Host=tenant-dedicated-read;", + DatabaseProvider = "PostgreSQL", + HasReadReplicas = true, + }; + + // Act + var result = resolver.ResolveTenantConnection(tenant); + + // Assert + result.Strategy.ShouldBe(DatabaseStrategy.Dedicated); + result.WriteConnectionString.ShouldBe("Host=tenant-dedicated;"); + result.ReadConnectionString.ShouldBe("Host=tenant-dedicated-read;"); + } + + private static ServiceProvider BuildServiceProvider(HttpContext httpContext, bool returnErrorFromCustomerApi = false) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(new HttpContextAccessor { HttpContext = httpContext }); + services.AddSingleton(new FusionCache(new FusionCacheOptions())); + + var handler = returnErrorFromCustomerApi + ? new StatusCodeHttpMessageHandler(System.Net.HttpStatusCode.InternalServerError) + : new StatusCodeHttpMessageHandler(System.Net.HttpStatusCode.OK, "{\"strategy\":\"Shared\"}"); + + var customerClient = new HttpClient(handler) + { + BaseAddress = new Uri("https://customer.local/") + }; + + var httpClientFactory = Substitute.For(); + httpClientFactory.CreateClient("CustomerApi").Returns(customerClient); + services.AddSingleton(httpClientFactory); + + return services.BuildServiceProvider(); + } + + private sealed class StatusCodeHttpMessageHandler : HttpMessageHandler + { + private readonly System.Net.HttpStatusCode _statusCode; + private readonly string _body; + + public StatusCodeHttpMessageHandler(System.Net.HttpStatusCode statusCode, string body = "{}") + { + _statusCode = statusCode; + _body = body; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var response = new HttpResponseMessage(_statusCode) + { + Content = new StringContent(_body) + }; + + return Task.FromResult(response); + } + } +} +#pragma warning restore CA2000 diff --git a/tests/unit/Web.BFF.UnitTests/TokenExchangeMiddlewareEdgeTests.cs b/tests/unit/Web.BFF.UnitTests/TokenExchangeMiddlewareEdgeTests.cs index 86df41d8..2fa31b2b 100644 --- a/tests/unit/Web.BFF.UnitTests/TokenExchangeMiddlewareEdgeTests.cs +++ b/tests/unit/Web.BFF.UnitTests/TokenExchangeMiddlewareEdgeTests.cs @@ -1,12 +1,9 @@ using Microsoft.AspNetCore.Http; using Xunit; using NSubstitute; -using Shouldly; using Web.BFF.Middleware; using Web.BFF.Services; using Yarp.ReverseProxy.Configuration; -using System.Threading.Tasks; -using System.Collections.Generic; namespace Web.BFF.UnitTests; @@ -16,25 +13,36 @@ public class TokenExchangeMiddlewareEdgeTests public async Task Middleware_NoAuthorization_DoesNotCallExchange() { var exchange = Substitute.For(); - n var middleware = new TokenExchangeMiddleware(async (ctx) => { await ctx.Response.WriteAsync("ok"); }, exchange); + var tenantRouting = Substitute.For(); + + var middleware = new TokenExchangeMiddleware(async (ctx) => { await ctx.Response.WriteAsync("ok"); }, exchange, tenantRouting); var ctx = new DefaultHttpContext(); - // Provide endpoint with audience but no auth header + + // Provide endpoint with audience but no auth header var route = new RouteConfig { Metadata = new Dictionary { ["KeycloakAudience"] = "aud" } }; var endpoint = new Endpoint((c) => Task.CompletedTask, new EndpointMetadataCollection(route), "route"); ctx.SetEndpoint(endpoint); - await middleware.InvokeAsync(ctx); - exchange.DidNotReceiveWithAnyArgs().ExchangeTokenAsync(default!, default!, default!, default); + + await middleware.InvokeAsync(ctx); + + await exchange.DidNotReceiveWithAnyArgs().ExchangeTokenAsync(default!, default!, default!, TestContext.Current.CancellationToken); } - [Fact] + + [Fact] public async Task Middleware_NoAudience_DoesNotCallExchange() { var exchange = Substitute.For(); - var middleware = new TokenExchangeMiddleware(async (ctx) => { await ctx.Response.WriteAsync("ok"); }, exchange); + var tenantRouting = Substitute.For(); + var middleware = new TokenExchangeMiddleware(async (ctx) => { await ctx.Response.WriteAsync("ok"); }, exchange, tenantRouting); var ctx = new DefaultHttpContext(); ctx.Request.Headers["Authorization"] = "Bearer subj"; - // Endpoint without route metadata - var endpoint = new Endpoint((c) => Task.CompletedTask, new EndpointMetadataCollection(), "route"); ctx.SetEndpoint(endpoint); - await middleware.InvokeAsync(ctx); - exchange.DidNotReceiveWithAnyArgs().ExchangeTokenAsync(default!, default!, default!, default); + + // Endpoint without route metadata + var endpoint = new Endpoint((c) => Task.CompletedTask, new EndpointMetadataCollection(), "route"); + ctx.SetEndpoint(endpoint); + + await middleware.InvokeAsync(ctx); + + await exchange.DidNotReceiveWithAnyArgs().ExchangeTokenAsync(default!, default!, default!, TestContext.Current.CancellationToken); } } diff --git a/tests/unit/Web.BFF.UnitTests/TokenExchangeMiddlewareTenantResolutionTests.cs b/tests/unit/Web.BFF.UnitTests/TokenExchangeMiddlewareTenantResolutionTests.cs new file mode 100644 index 00000000..bcc354b6 --- /dev/null +++ b/tests/unit/Web.BFF.UnitTests/TokenExchangeMiddlewareTenantResolutionTests.cs @@ -0,0 +1,255 @@ +using Microsoft.AspNetCore.Http; +using NSubstitute; +using Shouldly; +using System.Security.Claims; +using Web.BFF.Middleware; +using Web.BFF.Services; +using Xunit; +using Yarp.ReverseProxy.Configuration; + +namespace Web.BFF.UnitTests; + +public class TokenExchangeMiddlewareTenantResolutionTests +{ + [Fact] + public async Task Middleware_UsesOrganizationClaimId_ForTenantResolution() + { + var exchange = Substitute.For(); + var tenantRouting = Substitute.For(); + exchange.ExchangeTokenAsync("subj", "aud", "tenant-org", Arg.Any()) + .Returns(Task.FromResult(new TokenResult("exchanged-token", DateTime.UtcNow.AddMinutes(1)))); + + var middleware = CreateMiddleware(exchange, tenantRouting); + var ctx = CreateContext( + "Bearer subj", + new[] + { + new Claim("organization", "{\"acme\":{\"id\":\"tenant-org\"}}") + }, + "aud"); + + await middleware.InvokeAsync(ctx); + + ctx.Request.Headers["X-TenantId"].ToString().ShouldBe("tenant-org"); + await exchange.Received(1).ExchangeTokenAsync("subj", "aud", "tenant-org", Arg.Any()); + } + + [Fact] + public async Task Middleware_UsesActiveOrganizationFallback_WhenOrganizationClaimsMissing() + { + var exchange = Substitute.For(); + var tenantRouting = Substitute.For(); + exchange.ExchangeTokenAsync("subj", "aud", "tenant-active", Arg.Any()) + .Returns(Task.FromResult(new TokenResult("exchanged-token", DateTime.UtcNow.AddMinutes(1)))); + + var middleware = CreateMiddleware(exchange, tenantRouting); + var ctx = CreateContext( + "Bearer subj", + new[] + { + new Claim("active_organization", "{\"id\":\"tenant-active\"}") + }, + "aud"); + + await middleware.InvokeAsync(ctx); + + ctx.Request.Headers["X-TenantId"].ToString().ShouldBe("tenant-active"); + await exchange.Received(1).ExchangeTokenAsync("subj", "aud", "tenant-active", Arg.Any()); + } + + [Fact] + public async Task Middleware_ContinuesWhenExchangeFails_AndStillSetsTenantHeaders() + { + var exchange = Substitute.For(); + var tenantRouting = Substitute.For(); + exchange.ExchangeTokenAsync("subj", "aud", "tenant-a", Arg.Any()) + .Returns>(_ => throw new Exception("boom")); + tenantRouting.GetTenantRoutingMetadataAsync("tenant-a", Arg.Any()) + .Returns(Task.FromResult(new TenantRoutingMetadata("tenant-a", "Shared"))); + + var middleware = CreateMiddleware(exchange, tenantRouting); + var ctx = CreateContext( + "Bearer subj", + new[] + { + new Claim("tenant_id", "tenant-a") + }, + "aud"); + + await middleware.InvokeAsync(ctx); + + ctx.Request.Headers["Authorization"].ToString().ShouldBe("Bearer subj"); + ctx.Request.Headers["X-TenantId"].ToString().ShouldBe("tenant-a"); + ctx.Request.Headers["X-Tenant-DbStrategy"].ToString().ShouldBe("Shared"); + } + + [Fact] + public async Task Middleware_NoTenant_DoesNotCallTenantRoutingMetadataService() + { + var exchange = Substitute.For(); + var tenantRouting = Substitute.For(); + exchange.ExchangeTokenAsync("subj", "aud", "", Arg.Any()) + .Returns(Task.FromResult(new TokenResult("exchanged-token", DateTime.UtcNow.AddMinutes(1)))); + + var middleware = CreateMiddleware(exchange, tenantRouting); + var ctx = CreateContext("Bearer subj", Array.Empty(), "aud"); + + await middleware.InvokeAsync(ctx); + + await tenantRouting.DidNotReceiveWithAnyArgs().GetTenantRoutingMetadataAsync(default!, TestContext.Current.CancellationToken); + ctx.Request.Headers.ContainsKey("X-TenantId").ShouldBeFalse(); + } + + [Fact] + public async Task Middleware_UsesOrganizationsClaimStringValue_AsTenantId() + { + var exchange = Substitute.For(); + var tenantRouting = Substitute.For(); + exchange.ExchangeTokenAsync("subj", "aud", "tenant-value", Arg.Any()) + .Returns(Task.FromResult(new TokenResult("exchanged-token", DateTime.UtcNow.AddMinutes(1)))); + + var middleware = CreateMiddleware(exchange, tenantRouting); + var ctx = CreateContext( + "Bearer subj", + new[] + { + new Claim("organizations", "{\"acme\":\"tenant-value\"}") + }, + "aud"); + + await middleware.InvokeAsync(ctx); + + ctx.Request.Headers["X-TenantId"].ToString().ShouldBe("tenant-value"); + await exchange.Received(1).ExchangeTokenAsync("subj", "aud", "tenant-value", Arg.Any()); + } + + [Fact] + public async Task Middleware_UsesOrganizationsClaimObjectKey_WhenIdMissing() + { + var exchange = Substitute.For(); + var tenantRouting = Substitute.For(); + exchange.ExchangeTokenAsync("subj", "aud", "acme", Arg.Any()) + .Returns(Task.FromResult(new TokenResult("exchanged-token", DateTime.UtcNow.AddMinutes(1)))); + + var middleware = CreateMiddleware(exchange, tenantRouting); + var ctx = CreateContext( + "Bearer subj", + new[] + { + new Claim("organizations", "{\"acme\":{\"attr\":[\"x\"]}}") + }, + "aud"); + + await middleware.InvokeAsync(ctx); + + ctx.Request.Headers["X-TenantId"].ToString().ShouldBe("acme"); + await exchange.Received(1).ExchangeTokenAsync("subj", "aud", "acme", Arg.Any()); + } + + [Fact] + public async Task Middleware_MalformedOrganizationClaim_FallsBackToTenantIdClaim() + { + var exchange = Substitute.For(); + var tenantRouting = Substitute.For(); + exchange.ExchangeTokenAsync("subj", "aud", "tenant-fallback", Arg.Any()) + .Returns(Task.FromResult(new TokenResult("exchanged-token", DateTime.UtcNow.AddMinutes(1)))); + + var middleware = CreateMiddleware(exchange, tenantRouting); + var ctx = CreateContext( + "Bearer subj", + new[] + { + new Claim("organization", "{not-json"), + new Claim("tenant_id", "tenant-fallback") + }, + "aud"); + + await middleware.InvokeAsync(ctx); + + ctx.Request.Headers["X-TenantId"].ToString().ShouldBe("tenant-fallback"); + await exchange.Received(1).ExchangeTokenAsync("subj", "aud", "tenant-fallback", Arg.Any()); + } + + [Fact] + public async Task Middleware_NoAudience_StillSetsTenantHeader() + { + var exchange = Substitute.For(); + var tenantRouting = Substitute.For(); + + var middleware = CreateMiddleware(exchange, tenantRouting); + var ctx = CreateContext( + "Bearer subj", + new[] + { + new Claim("tenant_id", "tenant-a") + }, + audience: null); + + await middleware.InvokeAsync(ctx); + + ctx.Request.Headers["X-TenantId"].ToString().ShouldBe("tenant-a"); + await exchange.DidNotReceiveWithAnyArgs().ExchangeTokenAsync(default!, default!, default!, TestContext.Current.CancellationToken); + } + + [Fact] + public async Task Middleware_TenantMetadataLookupFails_StillContinuesWithTenantHeader() + { + var exchange = Substitute.For(); + var tenantRouting = Substitute.For(); + exchange.ExchangeTokenAsync("subj", "aud", "tenant-a", Arg.Any()) + .Returns(Task.FromResult(new TokenResult("exchanged-token", DateTime.UtcNow.AddMinutes(1)))); + tenantRouting.GetTenantRoutingMetadataAsync("tenant-a", Arg.Any()) + .Returns>(_ => throw new Exception("metadata unavailable")); + + var middleware = CreateMiddleware(exchange, tenantRouting); + var ctx = CreateContext( + "Bearer subj", + new[] + { + new Claim("tenant_id", "tenant-a") + }, + "aud"); + + await middleware.InvokeAsync(ctx); + + ctx.Request.Headers["Authorization"].ToString().ShouldBe("Bearer exchanged-token"); + ctx.Request.Headers["X-TenantId"].ToString().ShouldBe("tenant-a"); + ctx.Request.Headers.ContainsKey("X-Tenant-DbStrategy").ShouldBeFalse(); + } + + private static TokenExchangeMiddleware CreateMiddleware( + ITokenExchangeService exchange, + ITenantRoutingMetadataService tenantRouting) + { + return new TokenExchangeMiddleware( + async context => await context.Response.WriteAsync("ok"), + exchange, + tenantRouting); + } + + private static DefaultHttpContext CreateContext( + string? authorization, + IEnumerable claims, + string? audience) + { + var ctx = new DefaultHttpContext(); + if (!string.IsNullOrWhiteSpace(authorization)) + { + ctx.Request.Headers["Authorization"] = authorization; + } + + ctx.User = new ClaimsPrincipal(new ClaimsIdentity(claims, "test")); + + var metadata = new Dictionary(); + if (!string.IsNullOrWhiteSpace(audience)) + { + metadata["KeycloakAudience"] = audience; + } + + var route = new RouteConfig { Metadata = metadata }; + var endpoint = new Endpoint(_ => Task.CompletedTask, new EndpointMetadataCollection(route), "route"); + ctx.SetEndpoint(endpoint); + + return ctx; + } +} diff --git a/tests/unit/Web.BFF.UnitTests/TokenExchangeMiddlewareTests.cs b/tests/unit/Web.BFF.UnitTests/TokenExchangeMiddlewareTests.cs index b292d815..8c79ecc9 100644 --- a/tests/unit/Web.BFF.UnitTests/TokenExchangeMiddlewareTests.cs +++ b/tests/unit/Web.BFF.UnitTests/TokenExchangeMiddlewareTests.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Http; +using System.Security.Claims; using Xunit; using NSubstitute; using Shouldly; @@ -14,17 +15,24 @@ public async Task Middleware_ExchangesTokenAndSetsHeaders() { // Arrange var exchange = Substitute.For(); + var tenantRouting = Substitute.For(); exchange.ExchangeTokenAsync("subj","aud",Arg.Any(),Arg.Any()) .Returns(Task.FromResult(new TokenResult("exchanged-token", DateTime.UtcNow.AddMinutes(1)))); + tenantRouting.GetTenantRoutingMetadataAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new TenantRoutingMetadata("tenant-a", "Shared"))); var middleware = new TokenExchangeMiddleware(async (ctx) => { // terminal delegate await ctx.Response.WriteAsync("ok"); - }, exchange); + }, exchange, tenantRouting); var ctx = new DefaultHttpContext(); ctx.Request.Headers["Authorization"] = "Bearer subj"; + ctx.User = new ClaimsPrincipal(new ClaimsIdentity( + [ + new Claim("tenant_id", "tenant-a") + ], "test")); // Provide an endpoint with RouteConfig metadata containing KeycloakAudience var route = new Yarp.ReverseProxy.Configuration.RouteConfig { Metadata = new Dictionary { ["KeycloakAudience"] = "aud" } }; @@ -36,5 +44,7 @@ public async Task Middleware_ExchangesTokenAndSetsHeaders() // Assert ctx.Request.Headers["Authorization"].ToString().ShouldBe("Bearer exchanged-token"); + ctx.Request.Headers["X-TenantId"].ToString().ShouldBe("tenant-a"); + ctx.Request.Headers["X-Tenant-DbStrategy"].ToString().ShouldBe("Shared"); } } diff --git a/tests/unit/Web.BFF.UnitTests/TokenExchangeServiceEdgeTests.cs b/tests/unit/Web.BFF.UnitTests/TokenExchangeServiceEdgeTests.cs index ddb63bc8..fc5d067b 100644 --- a/tests/unit/Web.BFF.UnitTests/TokenExchangeServiceEdgeTests.cs +++ b/tests/unit/Web.BFF.UnitTests/TokenExchangeServiceEdgeTests.cs @@ -1,8 +1,4 @@ using System.Net; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; -using System.Collections.Generic; using Microsoft.Extensions.Configuration; using Xunit; using NSubstitute; @@ -53,4 +49,50 @@ public async Task ExchangeToken_PropagatesHttpErrors() await Should.ThrowAsync(async () => await svc.ExchangeTokenAsync("subj","aud","t")); } + + [Fact] + public async Task ExchangeToken_ThrowsOnNonTokenPayload() + { + // Arrange: body is not OAuth token response + using var handler = new DelegatingHandlerStub("{ \"foo\": \"bar\" }"); + using var client = new HttpClient(handler); + var httpFactory = Substitute.For(); + httpFactory.CreateClient(Arg.Is(s => s == "KeycloakTokenClient")).Returns(client); + + using var fusion = new FusionCache(new FusionCacheOptions()); + var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary() + { + ["Keycloak:Authority"] = "https://example.com", + ["Keycloak:GatewayClientId"] = "gateway", + ["Keycloak:GatewayClientSecret"] = "secret" + }).Build(); + + var svc = new TokenExchangeService(httpFactory, fusion, config); + + await Should.ThrowAsync(async () => + await svc.ExchangeTokenAsync("subj", "aud", "t", TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task ExchangeToken_ThrowsWhenAccessTokenIsEmpty() + { + // Arrange: token response with empty access token should fail + using var handler = new DelegatingHandlerStub("{ \"access_token\": \"\", \"expires_in\": 60 }"); + using var client = new HttpClient(handler); + var httpFactory = Substitute.For(); + httpFactory.CreateClient(Arg.Is(s => s == "KeycloakTokenClient")).Returns(client); + + using var fusion = new FusionCache(new FusionCacheOptions()); + var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary() + { + ["Keycloak:Authority"] = "https://example.com", + ["Keycloak:GatewayClientId"] = "gateway", + ["Keycloak:GatewayClientSecret"] = "secret" + }).Build(); + + var svc = new TokenExchangeService(httpFactory, fusion, config); + + await Should.ThrowAsync(async () => + await svc.ExchangeTokenAsync("subj", "aud", "t", TestContext.Current.CancellationToken)); + } } diff --git a/tests/unit/Web.BFF.UnitTests/TokenExchangeServiceTests.cs b/tests/unit/Web.BFF.UnitTests/TokenExchangeServiceTests.cs index 02a90793..3727cf3e 100644 --- a/tests/unit/Web.BFF.UnitTests/TokenExchangeServiceTests.cs +++ b/tests/unit/Web.BFF.UnitTests/TokenExchangeServiceTests.cs @@ -1,5 +1,6 @@ #pragma warning disable IDE0005 using System.Net; +using System.Net.Http.Headers; using System.Threading; using System.Threading.Tasks; using System.Collections.Generic; @@ -43,22 +44,147 @@ public async Task ExchangeToken_CallsKeycloakAndCaches() var res2 = await service.ExchangeTokenAsync("subj","aud","tenant", TestContext.Current.CancellationToken); res2.AccessToken.ShouldBe("abc123"); } + + [Fact] + public async Task ExchangeToken_SendsExpectedTokenExchangePayload() + { + // Arrange + using var handler = new DelegatingHandlerStub("{ \"access_token\": \"abc123\", \"expires_in\": 60 }"); + using var client = new HttpClient(handler); + var httpFactory = Substitute.For(); + httpFactory.CreateClient(Arg.Is(s => s == "KeycloakTokenClient")).Returns(client); + + using var fusion = new FusionCache(new FusionCacheOptions()); + var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary() + { + ["Keycloak:Authority"] = "https://example.com", + ["Keycloak:GatewayClientId"] = "gateway", + ["Keycloak:GatewayClientSecret"] = "secret" + }).Build(); + + var service = new TokenExchangeService(httpFactory, fusion, config); + + // Act + await service.ExchangeTokenAsync("subj", "aud", "tenant", TestContext.Current.CancellationToken); + + // Assert + handler.LastRequestBody.ShouldNotBeNull(); + handler.LastRequestBody.ShouldContain("grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange"); + handler.LastRequestBody.ShouldContain("subject_token=subj"); + handler.LastRequestBody.ShouldContain("subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token"); + handler.LastRequestBody.ShouldContain("audience=aud"); + } + + [Fact] + public async Task ExchangeToken_UsesConfiguredTokenEndpoint_WhenProvided() + { + // Arrange + using var handler = new DelegatingHandlerStub("{ \"access_token\": \"abc123\", \"expires_in\": 60 }"); + using var client = new HttpClient(handler); + var httpFactory = Substitute.For(); + httpFactory.CreateClient(Arg.Is(s => s == "KeycloakTokenClient")).Returns(client); + + using var fusion = new FusionCache(new FusionCacheOptions()); + var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary() + { + ["Keycloak:Authority"] = "https://example.com", + ["Keycloak:TokenEndpoint"] = "https://example.com/custom-token", + ["Keycloak:GatewayClientId"] = "gateway", + ["Keycloak:GatewayClientSecret"] = "secret" + }).Build(); + + var service = new TokenExchangeService(httpFactory, fusion, config); + + // Act + await service.ExchangeTokenAsync("subj", "aud", "tenant", TestContext.Current.CancellationToken); + + // Assert + handler.LastRequestUri.ShouldBe(new Uri("https://example.com/custom-token")); + } + + [Fact] + public async Task ExchangeToken_CacheKeyIncludesSubjectAudienceAndTenant() + { + // Arrange + using var handler = new DelegatingHandlerStub("{ \"access_token\": \"abc123\", \"expires_in\": 60 }"); + using var client = new HttpClient(handler); + var httpFactory = Substitute.For(); + httpFactory.CreateClient(Arg.Is(s => s == "KeycloakTokenClient")).Returns(client); + + using var fusion = new FusionCache(new FusionCacheOptions()); + var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary() + { + ["Keycloak:Authority"] = "https://example.com", + ["Keycloak:GatewayClientId"] = "gateway", + ["Keycloak:GatewayClientSecret"] = "secret" + }).Build(); + + var service = new TokenExchangeService(httpFactory, fusion, config); + + // Act + await service.ExchangeTokenAsync("subj-1", "aud-1", "tenant-1", TestContext.Current.CancellationToken); // first call + await service.ExchangeTokenAsync("subj-1", "aud-1", "tenant-1", TestContext.Current.CancellationToken); // cached + await service.ExchangeTokenAsync("subj-1", "aud-1", "tenant-2", TestContext.Current.CancellationToken); // new tenant + await service.ExchangeTokenAsync("subj-1", "aud-2", "tenant-1", TestContext.Current.CancellationToken); // new audience + await service.ExchangeTokenAsync("subj-2", "aud-1", "tenant-1", TestContext.Current.CancellationToken); // new subject + + // Assert + handler.SendCount.ShouldBe(4); + } + + [Fact] + public async Task ExchangeToken_ThrowsOnErrorPayloadEvenWithOkStatus() + { + // Arrange: identitymodel recognizes error payload as failed token response + using var handler = new DelegatingHandlerStub("{ \"error\": \"invalid_request\" }"); + using var client = new HttpClient(handler); + var httpFactory = Substitute.For(); + httpFactory.CreateClient(Arg.Is(s => s == "KeycloakTokenClient")).Returns(client); + + using var fusion = new FusionCache(new FusionCacheOptions()); + var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary() + { + ["Keycloak:Authority"] = "https://example.com", + ["Keycloak:GatewayClientId"] = "gateway", + ["Keycloak:GatewayClientSecret"] = "secret" + }).Build(); + + var service = new TokenExchangeService(httpFactory, fusion, config); + + // Act / Assert + await Should.ThrowAsync(async () => + await service.ExchangeTokenAsync("subj", "aud", "tenant", TestContext.Current.CancellationToken)); + } } // Simple DelegatingHandler stub internal class DelegatingHandlerStub : DelegatingHandler { private readonly string _responseBody; - public DelegatingHandlerStub(string responseBody) + private readonly HttpStatusCode _statusCode; + public string? LastRequestBody { get; private set; } + public Uri? LastRequestUri { get; private set; } + public AuthenticationHeaderValue? LastAuthorizationHeader { get; private set; } + public int SendCount { get; private set; } + + public DelegatingHandlerStub(string responseBody, HttpStatusCode statusCode = HttpStatusCode.OK) { _responseBody = responseBody; + _statusCode = statusCode; } - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { - var res = new HttpResponseMessage(HttpStatusCode.OK) + SendCount++; + LastRequestUri = request.RequestUri; + LastAuthorizationHeader = request.Headers.Authorization; + LastRequestBody = request.Content is null + ? null + : await request.Content.ReadAsStringAsync(cancellationToken); + + var res = new HttpResponseMessage(_statusCode) { Content = new StringContent(_responseBody) }; - return Task.FromResult(res); + return res; } } diff --git a/tests/unit/Web.Edge.UnitTests/EdgeRequestSanitizationMiddlewareTests.cs b/tests/unit/Web.Edge.UnitTests/EdgeRequestSanitizationMiddlewareTests.cs new file mode 100644 index 00000000..80279006 --- /dev/null +++ b/tests/unit/Web.Edge.UnitTests/EdgeRequestSanitizationMiddlewareTests.cs @@ -0,0 +1,74 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; +using Web.Edge.Middleware; +using Web.Edge.Security; + +namespace Web.Edge.UnitTests; + +public sealed class EdgeRequestSanitizationMiddlewareTests +{ + [Fact] + public async Task InvokeAsync_RemovesSpoofableHeaders_AndAddsInternalIdentityHeader() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["EdgeTrust:SigningKey"] = "0123456789abcdef0123456789abcdef", + ["EdgeTrust:Issuer"] = "teck-edge", + ["EdgeTrust:Audience"] = "teck-web-bff-internal", + }) + .Build(); + + var tokenService = new InternalIdentityTokenService(configuration, NullLogger.Instance); + + var middleware = new EdgeRequestSanitizationMiddleware( + _ => Task.CompletedTask, + tokenService); + + var context = new DefaultHttpContext(); + context.Request.Headers["X-TenantId"] = "spoofed"; + context.Request.Headers["X-Tenant-DbStrategy"] = "Dedicated"; + context.Request.Headers["X-Forwarded-User"] = "spoofed-user"; + + context.User = new ClaimsPrincipal(new ClaimsIdentity( + [ + new Claim("sub", "user-1"), + new Claim("tenant_id", "tenant-a"), + ], "test-auth")); + + await middleware.InvokeAsync(context); + + context.Request.Headers.ContainsKey("X-TenantId").ShouldBeFalse(); + context.Request.Headers.ContainsKey("X-Tenant-DbStrategy").ShouldBeFalse(); + context.Request.Headers.ContainsKey("X-Forwarded-User").ShouldBeFalse(); + + context.Request.Headers.TryGetValue("X-Internal-Identity", out var internalIdentity).ShouldBeTrue(); + internalIdentity.ToString().ShouldNotBeNullOrWhiteSpace(); + } + + [Fact] + public async Task InvokeAsync_DoesNotAddInternalIdentity_WhenUserIsAnonymous() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["EdgeTrust:SigningKey"] = "0123456789abcdef0123456789abcdef", + }) + .Build(); + + var tokenService = new InternalIdentityTokenService(configuration, NullLogger.Instance); + + var middleware = new EdgeRequestSanitizationMiddleware( + _ => Task.CompletedTask, + tokenService); + + var context = new DefaultHttpContext(); + + await middleware.InvokeAsync(context); + + context.Request.Headers.ContainsKey("X-Internal-Identity").ShouldBeFalse(); + } +} diff --git a/tests/unit/Web.Edge.UnitTests/InternalIdentityTokenServiceTests.cs b/tests/unit/Web.Edge.UnitTests/InternalIdentityTokenServiceTests.cs new file mode 100644 index 00000000..dd4df145 --- /dev/null +++ b/tests/unit/Web.Edge.UnitTests/InternalIdentityTokenServiceTests.cs @@ -0,0 +1,59 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; +using Web.Edge.Security; + +namespace Web.Edge.UnitTests; + +public sealed class InternalIdentityTokenServiceTests +{ + [Fact] + public void CreateToken_ReturnsNull_WhenSigningKeyMissing() + { + var configuration = new ConfigurationBuilder().Build(); + var service = new InternalIdentityTokenService(configuration, NullLogger.Instance); + + var principal = new ClaimsPrincipal(new ClaimsIdentity( + [ + new Claim("sub", "user-1"), + ], "test-auth")); + + var token = service.CreateToken(principal); + + token.ShouldBeNull(); + } + + [Fact] + public void CreateToken_CreatesSignedJwt_WithExpectedIssuerAndAudience() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["EdgeTrust:SigningKey"] = "0123456789abcdef0123456789abcdef", + ["EdgeTrust:Issuer"] = "teck-edge", + ["EdgeTrust:Audience"] = "teck-web-bff-internal", + }) + .Build(); + + var service = new InternalIdentityTokenService(configuration, NullLogger.Instance); + + var principal = new ClaimsPrincipal(new ClaimsIdentity( + [ + new Claim("sub", "user-1"), + new Claim("tenant_id", "tenant-a"), + new Claim("role", "realm-admin"), + ], "test-auth")); + + var token = service.CreateToken(principal); + + token.ShouldNotBeNullOrWhiteSpace(); + + var jwt = new JwtSecurityTokenHandler().ReadJwtToken(token); + jwt.Issuer.ShouldBe("teck-edge"); + jwt.Audiences.ShouldContain("teck-web-bff-internal"); + jwt.Claims.ShouldContain(claim => claim.Type == "sub" && claim.Value == "user-1"); + jwt.Claims.ShouldContain(claim => claim.Type == "tenant_id" && claim.Value == "tenant-a"); + } +} diff --git a/tests/unit/Web.Edge.UnitTests/Web.Edge.UnitTests.csproj b/tests/unit/Web.Edge.UnitTests/Web.Edge.UnitTests.csproj new file mode 100644 index 00000000..478bb188 --- /dev/null +++ b/tests/unit/Web.Edge.UnitTests/Web.Edge.UnitTests.csproj @@ -0,0 +1,28 @@ + + + + enable + enable + false + true + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers + + + + + + + + + + From 3e4fa615cbdb9e2df40775c572fd6d5be7e636fe Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 14 Feb 2026 13:35:20 +0000 Subject: [PATCH 2/3] Update CHANGELOG.md [skip ci] --- CHANGELOG.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bee0e792..9a1b0dcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,19 @@ +# v0.15.0 (Sat Feb 14 2026) + +#### ๐Ÿš€ Enhancement + +- feat: rework ([@CaptainPowerTurtle](https://github.com/CaptainPowerTurtle)) + +#### โš ๏ธ Pushed to `main` + +- Merge branch 'main' of https://github.com/Teck-Lab/Teck.Cloud ([@CaptainPowerTurtle](https://github.com/CaptainPowerTurtle)) + +#### Authors: 1 + +- CptPowerTurtle ([@CaptainPowerTurtle](https://github.com/CaptainPowerTurtle)) + +--- + # v0.14.1 (Sat Feb 14 2026) #### โš ๏ธ Pushed to `main` From 656afd89005a8b5b27505a89a757a5b0f0b8f01c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 14 Feb 2026 13:41:12 +0000 Subject: [PATCH 3/3] chore: Bump Microsoft.Extensions.ServiceDiscovery and 2 others Bumps Microsoft.Extensions.ServiceDiscovery from 9.3.1 to 10.3.0 Bumps Microsoft.Extensions.ServiceDiscovery.Abstractions from 10.2.0 to 10.3.0 Bumps Microsoft.Extensions.ServiceDiscovery.Yarp from 9.3.1 to 10.3.0 --- updated-dependencies: - dependency-name: Microsoft.Extensions.ServiceDiscovery dependency-version: 10.3.0 dependency-type: direct:production update-type: version-update:semver-major - dependency-name: Microsoft.Extensions.ServiceDiscovery.Abstractions dependency-version: 10.3.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: Microsoft.Extensions.ServiceDiscovery.Yarp dependency-version: 10.3.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- Directory.Packages.props | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 3bcad8f5..34cdb99e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -238,10 +238,10 @@ - - + + - +