diff --git a/db/migrations/20260818100000_add-environment-to-aws-account.sql b/db/migrations/20260818100000_add-environment-to-aws-account.sql new file mode 100644 index 00000000..82480937 --- /dev/null +++ b/db/migrations/20260818100000_add-environment-to-aws-account.sql @@ -0,0 +1,11 @@ +-- Add Environment column to AwsAccount table +ALTER TABLE "AwsAccount" ADD COLUMN "Environment" varchar(255); + +-- Backfill existing rows with 'prod' as default environment +UPDATE "AwsAccount" SET "Environment" = 'prod' WHERE "Environment" IS NULL; + +-- Make Environment NOT NULL +ALTER TABLE "AwsAccount" ALTER COLUMN "Environment" SET NOT NULL; + +-- Add unique index on (CapabilityId, Environment) +CREATE UNIQUE INDEX "IX_AwsAccount_CapabilityId_Environment" ON "AwsAccount" ("CapabilityId", "Environment"); diff --git a/db/migrations/20260818110000_add-kubernetes-access-request-table.sql b/db/migrations/20260818110000_add-kubernetes-access-request-table.sql new file mode 100644 index 00000000..7a9a53ee --- /dev/null +++ b/db/migrations/20260818110000_add-kubernetes-access-request-table.sql @@ -0,0 +1,10 @@ +CREATE TABLE "KubernetesAccess" ( + "Id" uuid PRIMARY KEY, + "CapabilityId" varchar(255) NOT NULL, + "Environment" varchar(255) NOT NULL, + "AwsAccountId" uuid NULL, + "RequestedAt" timestamp NOT NULL, + "RequestedBy" varchar(255) NOT NULL, + "Namespace" varchar(255) NULL, + "GrantedAt" timestamp NULL +); diff --git a/src/SelfService.Tests/Application/TestComplianceApplicationService.cs b/src/SelfService.Tests/Application/TestComplianceApplicationService.cs index e4bcb360..6287b01c 100644 --- a/src/SelfService.Tests/Application/TestComplianceApplicationService.cs +++ b/src/SelfService.Tests/Application/TestComplianceApplicationService.cs @@ -27,24 +27,25 @@ public class TestComplianceApplicationService private const string EmptyMetadata = "{}"; - private static IAwsAccountRepository AwsAccountRepoWithK8sLinkFor(params CapabilityId[] capabilityIds) + private static IKubernetesAccessRepository KubernetesAccessRepoWithActiveFor(params CapabilityId[] capabilityIds) { - var mock = new Mock(); - mock.Setup(r => r.FindBy(It.IsAny())).ReturnsAsync((AwsAccount?)null); - var linkedAccounts = new List(); - foreach (var capId in capabilityIds) - { - var account = AwsAccount.RequestNew(capId, DateTime.UtcNow, "test@dfds.com"); - account.LinkKubernetesNamespace($"ns-{capId}", DateTime.UtcNow); - linkedAccounts.Add(account); - mock.Setup(r => r.FindBy(capId)).ReturnsAsync(account); - } - mock.Setup(r => r.GetByCapabilityIds(It.IsAny>())) + var mock = new Mock(); + var activeAccesses = capabilityIds + .Select(capId => + { + var access = KubernetesAccess.Request(capId, "prod", null, null, DateTime.UtcNow, "test@dfds.com"); + access.GrantAccess($"ns-{capId}", DateTime.UtcNow); + return access; + }) + .ToList(); + mock.Setup(r => r.GetAllBy(It.IsAny())) + .ReturnsAsync((CapabilityId id) => activeAccesses.Where(a => a.CapabilityId == id).ToList()); + mock.Setup(r => r.GetAllBy(It.IsAny>())) .ReturnsAsync( (IEnumerable ids) => { var idSet = ids.Select(i => i.ToString()).ToHashSet(); - return linkedAccounts.Where(a => idSet.Contains(a.CapabilityId.ToString())).ToList(); + return activeAccesses.Where(a => idSet.Contains(a.CapabilityId.ToString())).ToList(); } ); return mock.Object; @@ -134,7 +135,7 @@ public async Task GetCapabilityCompliance_Stub_ExternalSecretsIsUnknown() var service = A .ComplianceApplicationService.WithCapabilityRepository(repo.Object) - .WithAwsAccountRepository(AwsAccountRepoWithK8sLinkFor(capabilityId)) + .WithKubernetesAccessRepository(KubernetesAccessRepoWithActiveFor(capabilityId)) .Build(); var result = await service.GetCapabilityCompliance(capabilityId); @@ -187,7 +188,7 @@ public async Task GetCapabilityCompliance_HasFiveCategoriesTotal_WhenKubernetesL var service = A .ComplianceApplicationService.WithCapabilityRepository(repo.Object) - .WithAwsAccountRepository(AwsAccountRepoWithK8sLinkFor(capabilityId)) + .WithKubernetesAccessRepository(KubernetesAccessRepoWithActiveFor(capabilityId)) .Build(); var result = await service.GetCapabilityCompliance(capabilityId); @@ -219,23 +220,16 @@ public async Task GetCapabilityCompliance_NoAwsAccount_OnlyTagsCategoryReturned( } [Fact] - public async Task GetCapabilityCompliance_AwsAccountWithoutKubernetesLink_OnlyTagsCategoryReturned() + public async Task GetCapabilityCompliance_NoKubernetesAccess_OnlyTagsCategoryReturned() { var capabilityId = CapabilityId.CreateFrom("test-cap"); var capability = A.Capability.WithId(capabilityId).WithJsonMetadata(AllTagsPresent).Build(); - var unlinkedAccount = AwsAccount.RequestNew(capabilityId, DateTime.UtcNow, "test@dfds.com"); - // No call to LinkKubernetesNamespace — KubernetesLink stays Unlinked. var capabilityRepo = new Mock(); capabilityRepo.Setup(r => r.FindBy(capabilityId)).ReturnsAsync(capability); - var awsRepo = new Mock(); - awsRepo.Setup(r => r.FindBy(capabilityId)).ReturnsAsync(unlinkedAccount); - - var service = A - .ComplianceApplicationService.WithCapabilityRepository(capabilityRepo.Object) - .WithAwsAccountRepository(awsRepo.Object) - .Build(); + // Default builder has no active KubernetesAccess records + var service = A.ComplianceApplicationService.WithCapabilityRepository(capabilityRepo.Object).Build(); var result = await service.GetCapabilityCompliance(capabilityId); @@ -343,7 +337,7 @@ public async Task GetCostCentreCompliance_K8sCategoryCountsOnlyReflectK8sCapabil var service = A .ComplianceApplicationService.WithCapabilityRepository(capabilityRepo.Object) - .WithAwsAccountRepository(AwsAccountRepoWithK8sLinkFor(k8sCapId)) + .WithKubernetesAccessRepository(KubernetesAccessRepoWithActiveFor(k8sCapId)) .Build(); var result = await service.GetCostCentreCompliance("ti-platform"); @@ -379,7 +373,7 @@ public async Task GetCapabilityCompliance_Stub_IrsaMutualTrustIsUnknown() var service = A .ComplianceApplicationService.WithCapabilityRepository(repo.Object) - .WithAwsAccountRepository(AwsAccountRepoWithK8sLinkFor(capabilityId)) + .WithKubernetesAccessRepository(KubernetesAccessRepoWithActiveFor(capabilityId)) .Build(); var result = await service.GetCapabilityCompliance(capabilityId); @@ -399,7 +393,7 @@ public async Task GetCapabilityCompliance_Stub_WorkloadProbesIsUnknown() var service = A .ComplianceApplicationService.WithCapabilityRepository(repo.Object) - .WithAwsAccountRepository(AwsAccountRepoWithK8sLinkFor(capabilityId)) + .WithKubernetesAccessRepository(KubernetesAccessRepoWithActiveFor(capabilityId)) .Build(); var result = await service.GetCapabilityCompliance(capabilityId); @@ -419,7 +413,7 @@ public async Task GetCapabilityCompliance_Stub_EcrPullIsUnknown() var service = A .ComplianceApplicationService.WithCapabilityRepository(repo.Object) - .WithAwsAccountRepository(AwsAccountRepoWithK8sLinkFor(capabilityId)) + .WithKubernetesAccessRepository(KubernetesAccessRepoWithActiveFor(capabilityId)) .Build(); var result = await service.GetCapabilityCompliance(capabilityId); @@ -525,7 +519,7 @@ public async Task GetRequirementsCompliance_ReturnsKnownRequirementsAndCounts() var service = A .ComplianceApplicationService.WithCapabilityRepository(capabilityRepo.Object) - .WithAwsAccountRepository(AwsAccountRepoWithK8sLinkFor(k8sCapId)) + .WithKubernetesAccessRepository(KubernetesAccessRepoWithActiveFor(k8sCapId)) .Build(); var result = await service.GetRequirementsCompliance(); diff --git a/src/SelfService.Tests/Builders/AwsAccountBuilder.cs b/src/SelfService.Tests/Builders/AwsAccountBuilder.cs index 7a0f5e22..bb71b60d 100644 --- a/src/SelfService.Tests/Builders/AwsAccountBuilder.cs +++ b/src/SelfService.Tests/Builders/AwsAccountBuilder.cs @@ -6,6 +6,7 @@ public class AwsAccountBuilder { private AwsAccountId _id; private CapabilityId _capabilityId; + private string _environment; private DateTime _createdAt; private string _createdBy; @@ -13,6 +14,7 @@ public AwsAccountBuilder() { _id = AwsAccountId.New(); _capabilityId = CapabilityId.Parse("foo"); + _environment = "prod"; _createdAt = new DateTime(2000, 1, 1); _createdBy = nameof(AwsAccountBuilder); } @@ -25,7 +27,13 @@ public AwsAccountBuilder WithCapabilityId(CapabilityId capabilityId) public AwsAccount Build() { - return new AwsAccount(id: _id, capabilityId: _capabilityId, requestedAt: _createdAt, requestedBy: _createdBy); + return new AwsAccount( + id: _id, + capabilityId: _capabilityId, + environment: _environment, + requestedAt: _createdAt, + requestedBy: _createdBy + ); } public static implicit operator AwsAccount(AwsAccountBuilder builder) => builder.Build(); diff --git a/src/SelfService.Tests/Builders/ComplianceApplicationServiceBuilder.cs b/src/SelfService.Tests/Builders/ComplianceApplicationServiceBuilder.cs index 42e79911..6331bc91 100644 --- a/src/SelfService.Tests/Builders/ComplianceApplicationServiceBuilder.cs +++ b/src/SelfService.Tests/Builders/ComplianceApplicationServiceBuilder.cs @@ -10,12 +10,14 @@ public class ComplianceApplicationServiceBuilder { private ICapabilityRepository _capabilityRepository; private IAwsAccountRepository _awsAccountRepository; + private IKubernetesAccessRepository _kubernetesAccessRepository; private RequirementsDbContext? _requirementsDbContext; public ComplianceApplicationServiceBuilder() { _capabilityRepository = Dummy.Of(); _awsAccountRepository = DefaultAwsAccountRepository(); + _kubernetesAccessRepository = DefaultKubernetesAccessRepository(); } public ComplianceApplicationServiceBuilder WithCapabilityRepository(ICapabilityRepository capabilityRepository) @@ -30,6 +32,14 @@ public ComplianceApplicationServiceBuilder WithAwsAccountRepository(IAwsAccountR return this; } + public ComplianceApplicationServiceBuilder WithKubernetesAccessRepository( + IKubernetesAccessRepository kubernetesAccessRepository + ) + { + _kubernetesAccessRepository = kubernetesAccessRepository; + return this; + } + public ComplianceApplicationServiceBuilder WithRequirementsDbContext(RequirementsDbContext requirementsDbContext) { _requirementsDbContext = requirementsDbContext; @@ -43,11 +53,16 @@ public IComplianceApplicationService Build() return new ComplianceApplicationService( _capabilityRepository, _awsAccountRepository, + _kubernetesAccessRepository, _requirementsDbContext ); } - return new StubComplianceApplicationService(_capabilityRepository, _awsAccountRepository); + return new StubComplianceApplicationService( + _capabilityRepository, + _awsAccountRepository, + _kubernetesAccessRepository + ); } private static IAwsAccountRepository DefaultAwsAccountRepository() @@ -58,4 +73,12 @@ private static IAwsAccountRepository DefaultAwsAccountRepository() .ReturnsAsync(new List()); return mock.Object; } + + private static IKubernetesAccessRepository DefaultKubernetesAccessRepository() + { + var mock = new Mock(); + mock.Setup(r => r.GetAllBy(It.IsAny())).ReturnsAsync(new List()); + mock.Setup(r => r.GetAllBy(It.IsAny>())).ReturnsAsync(new List()); + return mock.Object; + } } diff --git a/src/SelfService.Tests/Domain/Models/TestAwsAccount.cs b/src/SelfService.Tests/Domain/Models/TestAwsAccount.cs index 35be43ca..60c77126 100644 --- a/src/SelfService.Tests/Domain/Models/TestAwsAccount.cs +++ b/src/SelfService.Tests/Domain/Models/TestAwsAccount.cs @@ -5,18 +5,17 @@ namespace SelfService.Tests.Domain.Models; public class TestAwsAccount { [Fact] - public void requested_account_is_not_registered_nor_linked_to_kubernetes() + public void requested_account_is_not_registered() { - var account = AwsAccount.RequestNew(CapabilityId.Parse("foo"), DateTime.Today, "bar"); + var account = AwsAccount.RequestNew(CapabilityId.Parse("foo"), "prod", DateTime.Today, "bar"); Assert.Equal(AwsAccountRegistration.Incomplete, account.Registration); - Assert.Equal(KubernetesLink.Unlinked, account.KubernetesLink); } [Fact] public void registered_account_as_expected() { - var account = AwsAccount.RequestNew(CapabilityId.Parse("foo"), DateTime.Today, "bar"); + var account = AwsAccount.RequestNew(CapabilityId.Parse("foo"), "prod", DateTime.Today, "bar"); account.RegisterRealAwsAccount(RealAwsAccountId.Empty, "foo@foo.com", DateTime.Today); @@ -24,28 +23,12 @@ public void registered_account_as_expected() new AwsAccountRegistration(RealAwsAccountId.Empty, "foo@foo.com", DateTime.Today), account.Registration ); - Assert.Equal(KubernetesLink.Unlinked, account.KubernetesLink); - } - - [Fact] - public void linked_to_kubernetes_as_expected() - { - var account = AwsAccount.RequestNew(CapabilityId.Parse("foo"), DateTime.Today, "bar"); - - account.RegisterRealAwsAccount(RealAwsAccountId.Empty, "foo@foo.com", DateTime.Today); - account.LinkKubernetesNamespace("dummy-namespace", DateTime.Today); - - Assert.Equal( - new AwsAccountRegistration(RealAwsAccountId.Empty, "foo@foo.com", DateTime.Today), - account.Registration - ); - Assert.Equal(new KubernetesLink("dummy-namespace", DateTime.Today), account.KubernetesLink); } [Fact] public void new_account_has_expected_status() { - var account = AwsAccount.RequestNew(CapabilityId.Parse("foo"), DateTime.Today, "bar"); + var account = AwsAccount.RequestNew(CapabilityId.Parse("foo"), "prod", DateTime.Today, "bar"); Assert.Equal(AwsAccountStatus.Requested, account.Status); } @@ -53,20 +36,9 @@ public void new_account_has_expected_status() [Fact] public void registered_account_has_expected_status() { - var account = AwsAccount.RequestNew(CapabilityId.Parse("foo"), DateTime.Today, "bar"); - - account.RegisterRealAwsAccount(RealAwsAccountId.Empty, "foo@foo.com", DateTime.Today); - - Assert.Equal(AwsAccountStatus.Pending, account.Status); - } - - [Fact] - public void linked_account_has_expected_status() - { - var account = AwsAccount.RequestNew(CapabilityId.Parse("foo"), DateTime.Today, "bar"); + var account = AwsAccount.RequestNew(CapabilityId.Parse("foo"), "prod", DateTime.Today, "bar"); account.RegisterRealAwsAccount(RealAwsAccountId.Empty, "foo@foo.com", DateTime.Today); - account.LinkKubernetesNamespace("dummy-namespace", DateTime.Today); Assert.Equal(AwsAccountStatus.Completed, account.Status); } diff --git a/src/SelfService.Tests/Infrastructure/Api/TestCapabilityAwsAccountRoutes.cs b/src/SelfService.Tests/Infrastructure/Api/TestCapabilityAwsAccountRoutes.cs index 48cb30b4..44a99fd3 100644 --- a/src/SelfService.Tests/Infrastructure/Api/TestCapabilityAwsAccountRoutes.cs +++ b/src/SelfService.Tests/Infrastructure/Api/TestCapabilityAwsAccountRoutes.cs @@ -111,7 +111,7 @@ public async Task get_capability_by_id_returns_expected_allow_on_aws_account_lin .Select(x => x.GetString() ?? "") .ToArray(); - Assert.Equal(new[] { "GET" }, allowValues); + Assert.Equal(new[] { "GET", "POST" }, allowValues); } [Fact] @@ -167,7 +167,7 @@ public async Task get_capability_by_id_returns_expected_allow_on_aws_account_lin .Select(x => x.GetString() ?? "") .ToArray(); - Assert.Equal(new[] { "POST" }, allowValues); + Assert.Equal(new[] { "GET", "POST" }, allowValues); } [Fact] @@ -257,7 +257,7 @@ public async Task get_capability_by_id_returns_expected_allow_on_aws_account_lin .Select(x => x.GetString() ?? "") .ToArray(); - Assert.Equal(new[] { "GET" }, allowValues); + Assert.Equal(new[] { "GET", "POST" }, allowValues); } [Fact] @@ -270,6 +270,7 @@ public async Task pending_deletion_capability_doesnt_have_POST_endpoint_on_aws_a .WithAwsAccountRepository(new StubAwsAccountRepository(stubAwsAccount)) .WithCapabilityRepository(new StubCapabilityRepository(stubCapability)) .WithMembershipQuery(new StubMembershipQuery(hasActiveMembership: true)) + .WithCapabilityDeletionStatusQuery(new StubCapabilityDeletionStatusQuery(isPendingDeletion: true)) .Build(); /* application.ReplaceService( diff --git a/src/SelfService.Tests/Infrastructure/Persistence/TestPostgresMappings.cs b/src/SelfService.Tests/Infrastructure/Persistence/TestPostgresMappings.cs index 2ff0be4a..31bdbb46 100644 --- a/src/SelfService.Tests/Infrastructure/Persistence/TestPostgresMappings.cs +++ b/src/SelfService.Tests/Infrastructure/Persistence/TestPostgresMappings.cs @@ -75,7 +75,6 @@ public async Task awsaccount() "foo@foo.com", new DateTime(2000, 1, 1) ); - stub.LinkKubernetesNamespace("the-namespace", new DateTime(2000, 1, 1)); // write await dbContext.AwsAccounts.AddAsync(stub); diff --git a/src/SelfService.Tests/Infrastructure/Persistence/TestTemplateRenderingService.cs b/src/SelfService.Tests/Infrastructure/Persistence/TestTemplateRenderingService.cs index 1162afeb..96ff072b 100644 --- a/src/SelfService.Tests/Infrastructure/Persistence/TestTemplateRenderingService.cs +++ b/src/SelfService.Tests/Infrastructure/Persistence/TestTemplateRenderingService.cs @@ -254,18 +254,14 @@ public void RenderTemplate_AwsAccount_Present_RendersFields() { var awsAccount = A.AwsAccount.Build(); awsAccount.RegisterRealAwsAccount("123456789012", "aws.role@dfds.com", DateTime.UtcNow); - awsAccount.LinkKubernetesNamespace("my-capability-ns", DateTime.UtcNow); var context = CreateContext(awsAccount: awsAccount); var result = _sut.RenderTemplate( - "Account: {{Aws.AccountId}}, Status: {{Aws.Status}}, NS: {{Aws.Namespace}}, Email: {{Aws.RoleEmail}}", + "Account: {{Aws.AccountId}}, Status: {{Aws.Status}}, Email: {{Aws.RoleEmail}}", context ); - Assert.Equal( - "Account: 123456789012, Status: Completed, NS: my-capability-ns, Email: aws.role@dfds.com", - result - ); + Assert.Equal("Account: 123456789012, Status: Completed, Email: aws.role@dfds.com", result); } [Fact] @@ -274,11 +270,11 @@ public void RenderTemplate_AwsAccount_Null_RendersNA() var context = CreateContext(awsAccount: null); var result = _sut.RenderTemplate( - "Account: {{Aws.AccountId}}, Status: {{Aws.Status}}, NS: {{Aws.Namespace}}, Email: {{Aws.RoleEmail}}", + "Account: {{Aws.AccountId}}, Status: {{Aws.Status}}, Email: {{Aws.RoleEmail}}", context ); - Assert.Equal("Account: N/A, Status: N/A, NS: N/A, Email: N/A", result); + Assert.Equal("Account: N/A, Status: N/A, Email: N/A", result); } [Fact] @@ -607,7 +603,6 @@ public void GetVariableDefinitions_ReturnsExpectedSet() "Requirement..HelpUrl", "Aws.AccountId", "Aws.Status", - "Aws.Namespace", "Aws.RoleEmail", "Azure.ResourceCount", "Azure.Environments", diff --git a/src/SelfService.Tests/TestDoubles/StubAuthenticationService.cs b/src/SelfService.Tests/TestDoubles/StubAuthenticationService.cs index e402d2e0..759989ed 100644 --- a/src/SelfService.Tests/TestDoubles/StubAuthenticationService.cs +++ b/src/SelfService.Tests/TestDoubles/StubAuthenticationService.cs @@ -109,6 +109,16 @@ public async Task CanRequestAwsAccount(UserId userId, CapabilityId capabil return await Task.FromResult(_authorized); } + public async Task CanRequestKubernetesAccess(UserId userId, CapabilityId capabilityId) + { + return await Task.FromResult(_authorized); + } + + public async Task CanViewKubernetesAccess(UserId userId, CapabilityId capabilityId) + { + return await Task.FromResult(_authorized); + } + public async Task CanViewAzureResources(UserId userId, CapabilityId capabilityId) { return await Task.FromResult(_authorized); diff --git a/src/SelfService.Tests/TestDoubles/StubAwsAccountRepository.cs b/src/SelfService.Tests/TestDoubles/StubAwsAccountRepository.cs index 2543aa6d..cefb95cf 100644 --- a/src/SelfService.Tests/TestDoubles/StubAwsAccountRepository.cs +++ b/src/SelfService.Tests/TestDoubles/StubAwsAccountRepository.cs @@ -13,7 +13,21 @@ public StubAwsAccountRepository(AwsAccount? awsAccount = null) public Task FindBy(CapabilityId capabilityId) { - return Task.FromResult(_awsAccount); + return Task.FromResult(_awsAccount); + } + + public Task FindBy(CapabilityId capabilityId, string environment) + { + if (_awsAccount?.Environment == environment) + return Task.FromResult(_awsAccount); + return Task.FromResult(null); + } + + public Task> GetAllBy(CapabilityId capabilityId) + { + if (_awsAccount?.CapabilityId == capabilityId) + return Task.FromResult(new List { _awsAccount }); + return Task.FromResult(new List()); } public Task> GetAll() @@ -40,4 +54,23 @@ public Task Exists(CapabilityId capabilityId) { return Task.FromResult(_awsAccount != null); } + + public Task Exists(CapabilityId capabilityId, string environment) + { + if (_awsAccount == null) + return Task.FromResult(false); + return Task.FromResult(_awsAccount.Environment == environment); + } + + public Task FindBy(AwsAccountId id) + { + if (_awsAccount?.Id == id) + return Task.FromResult(_awsAccount); + return Task.FromResult(null); + } + + public Task CountBy(CapabilityId capabilityId) + { + return Task.FromResult(_awsAccount?.CapabilityId == capabilityId ? 1 : 0); + } } diff --git a/src/SelfService/Application/AwsAccountApplicationService.cs b/src/SelfService/Application/AwsAccountApplicationService.cs index 9237e86f..ac30554f 100644 --- a/src/SelfService/Application/AwsAccountApplicationService.cs +++ b/src/SelfService/Application/AwsAccountApplicationService.cs @@ -37,14 +37,24 @@ IHostEnvironment environment } [TransactionalBoundary, Outboxed] - public async Task RequestAwsAccount(CapabilityId capabilityId, UserId requestedBy) + public async Task RequestAwsAccount(CapabilityId capabilityId, string environment, UserId requestedBy) { - if (await _awsAccountRepository.Exists(capabilityId)) + if (await _awsAccountRepository.Exists(capabilityId, environment)) { - throw new AlreadyHasAwsAccountException($"Capability {capabilityId} already has an AWS account"); + throw new AlreadyHasAwsAccountException( + $"Capability {capabilityId} already has an AWS account for environment {environment}" + ); + } + + var accountCount = await _awsAccountRepository.CountBy(capabilityId); + if (accountCount >= AwsAccountConfiguration.MaxAccountsPerCapability) + { + throw new AwsAccountLimitExceededException( + $"Capability {capabilityId} has reached the maximum limit of {AwsAccountConfiguration.MaxAccountsPerCapability} AWS accounts" + ); } - var account = AwsAccount.RequestNew(capabilityId, _systemTime.Now, requestedBy); + var account = AwsAccount.RequestNew(capabilityId, environment, _systemTime.Now, requestedBy); await _awsAccountRepository.Add(account); @@ -59,14 +69,6 @@ public async Task RegisterRealAwsAccount(AwsAccountId id, RealAwsAccountId realA account.RegisterRealAwsAccount(realAwsAccountId, roleEmail, _systemTime.Now); } - [TransactionalBoundary, Outboxed] - public async Task LinkKubernetesNamespace(AwsAccountId id, string? @namespace) - { - var account = await _awsAccountRepository.Get(id); - - account.LinkKubernetesNamespace(@namespace, _systemTime.Now); - } - private class ContextAddedToCapabilityData { public string CapabilityId { get; set; } diff --git a/src/SelfService/Application/ComplianceApplicationService.cs b/src/SelfService/Application/ComplianceApplicationService.cs index 374f1179..08a859ec 100644 --- a/src/SelfService/Application/ComplianceApplicationService.cs +++ b/src/SelfService/Application/ComplianceApplicationService.cs @@ -12,6 +12,7 @@ public class ComplianceApplicationService : IComplianceApplicationService { private readonly ICapabilityRepository _capabilityRepository; private readonly IAwsAccountRepository _awsAccountRepository; + private readonly IKubernetesAccessRepository _kubernetesAccessRepository; private readonly RequirementsDbContext _requirementsDbContext; private static readonly string[] PlaceholderCategories = Array.Empty(); @@ -38,11 +39,13 @@ public class ComplianceApplicationService : IComplianceApplicationService public ComplianceApplicationService( ICapabilityRepository capabilityRepository, IAwsAccountRepository awsAccountRepository, + IKubernetesAccessRepository kubernetesAccessRepository, RequirementsDbContext requirementsDbContext ) { _capabilityRepository = capabilityRepository; _awsAccountRepository = awsAccountRepository; + _kubernetesAccessRepository = kubernetesAccessRepository; _requirementsDbContext = requirementsDbContext; } @@ -82,8 +85,8 @@ public async Task GetCapabilityCompliance(Capability private async Task HasKubernetesContext(CapabilityId capabilityId) { - var awsAccount = await _awsAccountRepository.FindBy(capabilityId); - return awsAccount?.KubernetesLink.LinkedAt is not null; + var accesses = await _kubernetesAccessRepository.GetAllBy(capabilityId); + return accesses.Any(a => a.Status == KubernetesAccessStatus.Active); } public async Task GetCostCentreCompliance(string costCentre) @@ -171,9 +174,9 @@ Func filter g => g.GroupBy(m => m.RequirementId).ToDictionary(rg => rg.Key, rg => rg.ToList()) ); - var awsAccounts = await _awsAccountRepository.GetByCapabilityIds(matchingCapabilities.Select(c => c.Id)); - var k8sCapabilityIds = awsAccounts - .Where(a => a.KubernetesLink.LinkedAt is not null) + var k8sAccesses = await _kubernetesAccessRepository.GetAllBy(matchingCapabilities.Select(c => c.Id)); + var k8sCapabilityIds = k8sAccesses + .Where(a => a.Status == KubernetesAccessStatus.Active) .Select(a => a.CapabilityId.ToString()) .ToHashSet(); diff --git a/src/SelfService/Application/IAwsAccountApplicationService.cs b/src/SelfService/Application/IAwsAccountApplicationService.cs index a3eecc95..d2f8b126 100644 --- a/src/SelfService/Application/IAwsAccountApplicationService.cs +++ b/src/SelfService/Application/IAwsAccountApplicationService.cs @@ -5,8 +5,7 @@ namespace SelfService.Application; public interface IAwsAccountApplicationService { - Task RequestAwsAccount(CapabilityId capabilityId, UserId requestedBy); + Task RequestAwsAccount(CapabilityId capabilityId, string environment, UserId requestedBy); Task RegisterRealAwsAccount(AwsAccountId id, RealAwsAccountId realAwsAccountId, string? roleEmail); - Task LinkKubernetesNamespace(AwsAccountId id, string? @namespace); public Task PublishResourceManifestToGit(AwsAccountRequested awsAccountRequested); } diff --git a/src/SelfService/Application/IKubernetesAccessApplicationService.cs b/src/SelfService/Application/IKubernetesAccessApplicationService.cs new file mode 100644 index 00000000..ec850e89 --- /dev/null +++ b/src/SelfService/Application/IKubernetesAccessApplicationService.cs @@ -0,0 +1,9 @@ +using SelfService.Domain.Models; + +namespace SelfService.Application; + +public interface IKubernetesAccessApplicationService +{ + Task RequestKubernetesAccess(AwsAccountId awsAccountId, UserId requestedBy); + Task GrantKubernetesAccess(AwsAccountId awsAccountId, string namespaceName); +} diff --git a/src/SelfService/Application/KubernetesAccessApplicationService.cs b/src/SelfService/Application/KubernetesAccessApplicationService.cs new file mode 100644 index 00000000..0717dae5 --- /dev/null +++ b/src/SelfService/Application/KubernetesAccessApplicationService.cs @@ -0,0 +1,60 @@ +using SelfService.Domain; +using SelfService.Domain.Models; +using SelfService.Infrastructure.Persistence; + +namespace SelfService.Application; + +public class KubernetesAccessApplicationService : IKubernetesAccessApplicationService +{ + private readonly IAwsAccountRepository _awsAccountRepository; + private readonly IKubernetesAccessRepository _kubernetesAccessRepository; + private readonly SystemTime _systemTime; + + public KubernetesAccessApplicationService( + IAwsAccountRepository awsAccountRepository, + IKubernetesAccessRepository kubernetesAccessRepository, + SystemTime systemTime + ) + { + _awsAccountRepository = awsAccountRepository; + _kubernetesAccessRepository = kubernetesAccessRepository; + _systemTime = systemTime; + } + + [TransactionalBoundary, Outboxed] + public async Task RequestKubernetesAccess(AwsAccountId awsAccountId, UserId requestedBy) + { + var account = await _awsAccountRepository.Get(awsAccountId); + + if (account.Status != AwsAccountStatus.Completed) + { + throw new InvalidOperationException( + $"AWS account must be completed before requesting Kubernetes access. Current status: {account.Status}" + ); + } + + var access = KubernetesAccess.Request( + capabilityId: account.CapabilityId, + environment: account.Environment, + awsAccountId: awsAccountId, + requestedAt: _systemTime.Now, + requestedBy: requestedBy + ); + + await _kubernetesAccessRepository.Add(access); + } + + [TransactionalBoundary, Outboxed] + public async Task GrantKubernetesAccess(AwsAccountId awsAccountId, string namespaceName) + { + var access = await _kubernetesAccessRepository.FindRequestedByAwsAccountId(awsAccountId); + if (access is null) + { + throw new InvalidOperationException( + $"No pending Kubernetes access request found for AWS account {awsAccountId}" + ); + } + + access.GrantAccess(namespaceName, _systemTime.Now); + } +} diff --git a/src/SelfService/Application/StubComplianceApplicationService.cs b/src/SelfService/Application/StubComplianceApplicationService.cs index a90d9066..4189af75 100644 --- a/src/SelfService/Application/StubComplianceApplicationService.cs +++ b/src/SelfService/Application/StubComplianceApplicationService.cs @@ -9,6 +9,7 @@ public class StubComplianceApplicationService : IComplianceApplicationService { private readonly ICapabilityRepository _capabilityRepository; private readonly IAwsAccountRepository _awsAccountRepository; + private readonly IKubernetesAccessRepository _kubernetesAccessRepository; private static readonly string[] PlaceholderCategories = Array.Empty(); private const string RogueCostCentreName = "rogue"; @@ -24,11 +25,13 @@ public class StubComplianceApplicationService : IComplianceApplicationService public StubComplianceApplicationService( ICapabilityRepository capabilityRepository, - IAwsAccountRepository awsAccountRepository + IAwsAccountRepository awsAccountRepository, + IKubernetesAccessRepository kubernetesAccessRepository ) { _capabilityRepository = capabilityRepository; _awsAccountRepository = awsAccountRepository; + _kubernetesAccessRepository = kubernetesAccessRepository; } public async Task GetCapabilityCompliance(CapabilityId capabilityId) @@ -188,9 +191,9 @@ Func filter var matchingCapabilities = activeCapabilities.Where(filter).ToList(); - var awsAccounts = await _awsAccountRepository.GetByCapabilityIds(matchingCapabilities.Select(c => c.Id)); - var k8sCapabilityIds = awsAccounts - .Where(a => a.KubernetesLink.LinkedAt is not null) + var k8sAccesses = await _kubernetesAccessRepository.GetAllBy(matchingCapabilities.Select(c => c.Id)); + var k8sCapabilityIds = k8sAccesses + .Where(a => a.Status == KubernetesAccessStatus.Active) .Select(a => a.CapabilityId.ToString()) .ToHashSet(); @@ -398,8 +401,8 @@ List capabilities private async Task HasKubernetesContext(CapabilityId capabilityId) { - var awsAccount = await _awsAccountRepository.FindBy(capabilityId); - return awsAccount?.KubernetesLink.LinkedAt is not null; + var accesses = await _kubernetesAccessRepository.GetAllBy(capabilityId); + return accesses.Any(a => a.Status == KubernetesAccessStatus.Active); } private static ComplianceCategoryResult CheckTagCompliance(string? jsonMetadata) diff --git a/src/SelfService/Configuration/Domain.cs b/src/SelfService/Configuration/Domain.cs index 2e495d01..0ae8d006 100644 --- a/src/SelfService/Configuration/Domain.cs +++ b/src/SelfService/Configuration/Domain.cs @@ -27,6 +27,7 @@ public static void AddDomain(this WebApplicationBuilder builder) // application services builder.Services.AddTransient(); builder.Services.AddTransient(); + builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); @@ -71,6 +72,7 @@ public static void AddDomain(this WebApplicationBuilder builder) // domain repositories builder.Services.AddTransient(); builder.Services.AddTransient(); + builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); diff --git a/src/SelfService/Domain/AwsAccountConfiguration.cs b/src/SelfService/Domain/AwsAccountConfiguration.cs new file mode 100644 index 00000000..a95abf9c --- /dev/null +++ b/src/SelfService/Domain/AwsAccountConfiguration.cs @@ -0,0 +1,6 @@ +namespace SelfService.Domain; + +public static class AwsAccountConfiguration +{ + public const int MaxAccountsPerCapability = 10; +} diff --git a/src/SelfService/Domain/Events/AwsAccountRequested.cs b/src/SelfService/Domain/Events/AwsAccountRequested.cs index dcca8b15..23bba31d 100644 --- a/src/SelfService/Domain/Events/AwsAccountRequested.cs +++ b/src/SelfService/Domain/Events/AwsAccountRequested.cs @@ -7,4 +7,5 @@ public class AwsAccountRequested : IDomainEvent public const string EventType = "aws-account-requested"; public string? AccountId { get; set; } + public string? Environment { get; set; } } diff --git a/src/SelfService/Domain/Events/KubernetesAccessRequested.cs b/src/SelfService/Domain/Events/KubernetesAccessRequested.cs new file mode 100644 index 00000000..4614f68f --- /dev/null +++ b/src/SelfService/Domain/Events/KubernetesAccessRequested.cs @@ -0,0 +1,12 @@ +namespace SelfService.Domain.Events; + +public class KubernetesAccessRequested : IDomainEvent +{ + public const string EventType = "kubernetes-access-requested"; + + public string? AccountId { get; set; } + public string? CapabilityId { get; set; } + public string? CapabilityRootId { get; set; } + public string? ContextId { get; set; } + public string? NamespaceName { get; set; } +} diff --git a/src/SelfService/Domain/Exceptions/AwsAccountLimitExceededException.cs b/src/SelfService/Domain/Exceptions/AwsAccountLimitExceededException.cs new file mode 100644 index 00000000..1ff223d7 --- /dev/null +++ b/src/SelfService/Domain/Exceptions/AwsAccountLimitExceededException.cs @@ -0,0 +1,7 @@ +namespace SelfService.Domain.Exceptions; + +public class AwsAccountLimitExceededException : Exception +{ + public AwsAccountLimitExceededException(string message) + : base(message) { } +} diff --git a/src/SelfService/Domain/Models/AwsAccount.cs b/src/SelfService/Domain/Models/AwsAccount.cs index 4f1223d1..17694852 100644 --- a/src/SelfService/Domain/Models/AwsAccount.cs +++ b/src/SelfService/Domain/Models/AwsAccount.cs @@ -4,17 +4,24 @@ namespace SelfService.Domain.Models; public class AwsAccount : AggregateRoot { - public AwsAccount(AwsAccountId id, CapabilityId capabilityId, DateTime requestedAt, string requestedBy) + public AwsAccount( + AwsAccountId id, + CapabilityId capabilityId, + string environment, + DateTime requestedAt, + string requestedBy + ) : base(id) { CapabilityId = capabilityId; + Environment = environment; RequestedAt = requestedAt; RequestedBy = requestedBy; } public CapabilityId CapabilityId { get; private set; } + public string Environment { get; private set; } public AwsAccountRegistration Registration { get; private set; } = AwsAccountRegistration.Incomplete; - public KubernetesLink KubernetesLink { get; private set; } = KubernetesLink.Unlinked; public DateTime RequestedAt { get; private set; } public string RequestedBy { get; private set; } @@ -22,30 +29,31 @@ public AwsAccountStatus Status { get { - if (KubernetesLink.LinkedAt is not null) - { - return AwsAccountStatus.Completed; - } - if (Registration.RegisteredAt is null) { return AwsAccountStatus.Requested; } - return AwsAccountStatus.Pending; + return AwsAccountStatus.Completed; } } - public static AwsAccount RequestNew(CapabilityId capabilityId, DateTime requestedAt, string requestedBy) + public static AwsAccount RequestNew( + CapabilityId capabilityId, + string environment, + DateTime requestedAt, + string requestedBy + ) { var account = new AwsAccount( id: AwsAccountId.New(), capabilityId: capabilityId, + environment: environment, requestedAt: requestedAt, requestedBy: requestedBy ); - account.Raise(new AwsAccountRequested() { AccountId = account.Id }); + account.Raise(new AwsAccountRequested() { AccountId = account.Id, Environment = account.Environment }); return account; } @@ -54,9 +62,4 @@ public void RegisterRealAwsAccount(RealAwsAccountId accountId, string? roleEmail { Registration = new AwsAccountRegistration(accountId, roleEmail, registeredAt); } - - public void LinkKubernetesNamespace(string? @namespace, DateTime connectedAt) - { - KubernetesLink = new KubernetesLink(@namespace, connectedAt); - } } diff --git a/src/SelfService/Domain/Models/IAwsAccountRepository.cs b/src/SelfService/Domain/Models/IAwsAccountRepository.cs index ba7f8db0..791a8fd9 100644 --- a/src/SelfService/Domain/Models/IAwsAccountRepository.cs +++ b/src/SelfService/Domain/Models/IAwsAccountRepository.cs @@ -3,9 +3,14 @@ namespace SelfService.Domain.Models; public interface IAwsAccountRepository { Task FindBy(CapabilityId capabilityId); + Task FindBy(CapabilityId capabilityId, string environment); + Task FindBy(AwsAccountId id); + Task> GetAllBy(CapabilityId capabilityId); Task> GetAll(); Task> GetByCapabilityIds(IEnumerable capabilityIds); Task Get(AwsAccountId id); Task Add(AwsAccount account); Task Exists(CapabilityId capabilityId); + Task Exists(CapabilityId capabilityId, string environment); + Task CountBy(CapabilityId capabilityId); } diff --git a/src/SelfService/Domain/Models/IKubernetesAccessRepository.cs b/src/SelfService/Domain/Models/IKubernetesAccessRepository.cs new file mode 100644 index 00000000..a241c3c6 --- /dev/null +++ b/src/SelfService/Domain/Models/IKubernetesAccessRepository.cs @@ -0,0 +1,9 @@ +namespace SelfService.Domain.Models; + +public interface IKubernetesAccessRepository +{ + Task Add(KubernetesAccess access); + Task> GetAllBy(CapabilityId capabilityId); + Task> GetAllBy(IEnumerable capabilityIds); + Task FindRequestedByAwsAccountId(AwsAccountId awsAccountId); +} diff --git a/src/SelfService/Domain/Models/KubernetesAccess.cs b/src/SelfService/Domain/Models/KubernetesAccess.cs new file mode 100644 index 00000000..59626e44 --- /dev/null +++ b/src/SelfService/Domain/Models/KubernetesAccess.cs @@ -0,0 +1,74 @@ +using SelfService.Domain.Events; + +namespace SelfService.Domain.Models; + +public class KubernetesAccess : AggregateRoot +{ + public KubernetesAccess( + KubernetesAccessId id, + CapabilityId capabilityId, + string environment, + AwsAccountId? awsAccountId, + DateTime requestedAt, + string requestedBy + ) + : base(id) + { + CapabilityId = capabilityId; + Environment = environment; + AwsAccountId = awsAccountId; + RequestedAt = requestedAt; + RequestedBy = requestedBy; + } + + public CapabilityId CapabilityId { get; private set; } + public string Environment { get; private set; } + + // nullable: required while K8s access depends on an AWS account, removable when fully decoupled + public AwsAccountId? AwsAccountId { get; private set; } + public DateTime RequestedAt { get; private set; } + public string RequestedBy { get; private set; } + public string? Namespace { get; private set; } + public DateTime? GrantedAt { get; private set; } + + public KubernetesAccessStatus Status => + GrantedAt is null ? KubernetesAccessStatus.Requested : KubernetesAccessStatus.Active; + + public static KubernetesAccess Request( + CapabilityId capabilityId, + string environment, + AwsAccountId? awsAccountId, + DateTime requestedAt, + string requestedBy + ) + { + var access = new KubernetesAccess( + id: KubernetesAccessId.New(), + capabilityId: capabilityId, + environment: environment, + awsAccountId: awsAccountId, + requestedAt: requestedAt, + requestedBy: requestedBy + ); + + access.Raise( + new KubernetesAccessRequested + { + KubernetesAccessId = access.Id.ToString(), + AwsAccountId = awsAccountId?.ToString(), + CapabilityId = capabilityId.ToString(), + Environment = environment, + RequestedAt = requestedAt, + RequestedBy = requestedBy, + } + ); + + return access; + } + + public void GrantAccess(string @namespace, DateTime grantedAt) + { + Namespace = @namespace; + GrantedAt = grantedAt; + } +} diff --git a/src/SelfService/Domain/Models/KubernetesAccessId.cs b/src/SelfService/Domain/Models/KubernetesAccessId.cs new file mode 100644 index 00000000..db8b5a2c --- /dev/null +++ b/src/SelfService/Domain/Models/KubernetesAccessId.cs @@ -0,0 +1,7 @@ +namespace SelfService.Domain.Models; + +public class KubernetesAccessId : ValueObjectGuid +{ + private KubernetesAccessId(Guid value) + : base(value) { } +} diff --git a/src/SelfService/Domain/Models/KubernetesAccessStatus.cs b/src/SelfService/Domain/Models/KubernetesAccessStatus.cs new file mode 100644 index 00000000..a9e6b605 --- /dev/null +++ b/src/SelfService/Domain/Models/KubernetesAccessStatus.cs @@ -0,0 +1,7 @@ +namespace SelfService.Domain.Models; + +public enum KubernetesAccessStatus +{ + Requested, + Active, +} diff --git a/src/SelfService/Domain/Models/RbacNamespace.cs b/src/SelfService/Domain/Models/RbacNamespace.cs index d7303654..6a1b9a4e 100644 --- a/src/SelfService/Domain/Models/RbacNamespace.cs +++ b/src/SelfService/Domain/Models/RbacNamespace.cs @@ -13,6 +13,7 @@ public class RbacNamespace : ValueObject public static readonly RbacNamespace CapabilityMembershipManagement = new("capability-membership-management"); public static readonly RbacNamespace TagsAndMetadata = new("tags-and-metadata"); public static readonly RbacNamespace Aws = new("aws"); + public static readonly RbacNamespace Kubernetes = new("kubernetes"); public static readonly RbacNamespace Finout = new("finout"); public static readonly RbacNamespace Azure = new("azure"); public static readonly RbacNamespace Rbac = new("rbac"); diff --git a/src/SelfService/Domain/Services/AuthorizationService.cs b/src/SelfService/Domain/Services/AuthorizationService.cs index 881fe5e4..892c89a9 100644 --- a/src/SelfService/Domain/Services/AuthorizationService.cs +++ b/src/SelfService/Domain/Services/AuthorizationService.cs @@ -290,41 +290,27 @@ public async Task CanApproveMembershipApplications(UserId userId, Capabili ); } - public async Task CanViewAwsAccount(UserId userId, CapabilityId capabilityId) + public Task CanViewAwsAccount(UserId userId, CapabilityId capabilityId) { - var canReadAwsAccount = await HasPermission( - userId, - RbacAccessType.Capability, - RbacNamespace.Aws, - "read", - capabilityId - ); - - return (await _awsAccountRepository.Exists(capabilityId)) && canReadAwsAccount; + return HasPermission(userId, RbacAccessType.Capability, RbacNamespace.Aws, "read", capabilityId); } public async Task CanViewAwsAccount(UserId userId, AwsAccountId accountId) { - var account = await _awsAccountRepository.Get(accountId); - if (account == null) - { + var account = await _awsAccountRepository.FindBy(accountId); + if (account is null) return false; - } + return await HasPermission(userId, RbacAccessType.Capability, RbacNamespace.Aws, "read", account.CapabilityId); } public async Task CanViewAwsAccountInformation(UserId userId, CapabilityId capabilityId) { - var account = await _awsAccountRepository.FindBy(capabilityId); - if (account is null) - return false; - - if (!(account.Status == AwsAccountStatus.Completed)) - { + var accounts = await _awsAccountRepository.GetAllBy(capabilityId); + if (!accounts.Any(a => a.Status == AwsAccountStatus.Completed)) return false; - } - return await HasPermission(userId, RbacAccessType.Capability, RbacNamespace.Aws, "read", account.CapabilityId); + return await HasPermission(userId, RbacAccessType.Capability, RbacNamespace.Aws, "read", capabilityId); } public async Task CanRequestAwsAccount(UserId userId, CapabilityId capabilityId) @@ -337,7 +323,32 @@ public async Task CanRequestAwsAccount(UserId userId, CapabilityId capabil capabilityId ); - return (!await _awsAccountRepository.Exists(capabilityId)) && canCreateAwsAccount; + var accountCount = await _awsAccountRepository.CountBy(capabilityId); + return canCreateAwsAccount && accountCount < AwsAccountConfiguration.MaxAccountsPerCapability; + } + + public async Task CanRequestKubernetesAccess(UserId userId, CapabilityId capabilityId) + { + var canCreateKubernetesAccess = await HasPermission( + userId, + RbacAccessType.Capability, + RbacNamespace.Kubernetes, + "create", + capabilityId + ); + + if (!canCreateKubernetesAccess) + { + return false; + } + + var accounts = await _awsAccountRepository.GetAllBy(capabilityId); + return accounts.Any(a => a.Status == AwsAccountStatus.Completed); + } + + public Task CanViewKubernetesAccess(UserId userId, CapabilityId capabilityId) + { + return HasPermission(userId, RbacAccessType.Capability, RbacNamespace.Kubernetes, "read", capabilityId); } public async Task CanViewAzureResources(UserId userId, CapabilityId capabilityId) diff --git a/src/SelfService/Domain/Services/IAuthorizationService.cs b/src/SelfService/Domain/Services/IAuthorizationService.cs index d8844d0f..d73194d5 100644 --- a/src/SelfService/Domain/Services/IAuthorizationService.cs +++ b/src/SelfService/Domain/Services/IAuthorizationService.cs @@ -21,6 +21,8 @@ public interface IAuthorizationService Task CanViewAwsAccount(UserId userId, CapabilityId capabilityId); Task CanViewAwsAccountInformation(UserId userId, CapabilityId capabilityId); Task CanRequestAwsAccount(UserId userId, CapabilityId capabilityId); + Task CanRequestKubernetesAccess(UserId userId, CapabilityId capabilityId); + Task CanViewKubernetesAccess(UserId userId, CapabilityId capabilityId); Task CanViewAzureResources(UserId userId, CapabilityId capabilityId); Task CanRequestAzureResource(UserId userId, CapabilityId capabilityId, string environment); Task CanRequestAzureResources(UserId userId, CapabilityId capabilityId); diff --git a/src/SelfService/Infrastructure/Api/ApiResourceFactory.cs b/src/SelfService/Infrastructure/Api/ApiResourceFactory.cs index 1ec25ab6..cd40f7c4 100644 --- a/src/SelfService/Infrastructure/Api/ApiResourceFactory.cs +++ b/src/SelfService/Infrastructure/Api/ApiResourceFactory.cs @@ -2,6 +2,7 @@ using Amazon.EC2.Model; using Microsoft.AspNetCore.Mvc; using SelfService.Application; +using SelfService.Domain; using SelfService.Domain.Models; using SelfService.Domain.Queries; using SelfService.Domain.Services; @@ -28,6 +29,7 @@ public class ApiResourceFactory private readonly IMembershipQuery _membershipQuery; private readonly ICapabilityDeletionStatusQuery _capabilityDeletionStatusQuery; private readonly IAwsAccountIdQuery _awsAccountIdQuery; + private readonly IAwsAccountRepository _awsAccountRepository; public ApiResourceFactory( IHttpContextAccessor httpContextAccessor, @@ -35,7 +37,8 @@ public ApiResourceFactory( IAuthorizationService authorizationService, IMembershipQuery membershipQuery, ICapabilityDeletionStatusQuery capabilityDeletionStatusQuery, - IAwsAccountIdQuery awsAccountIdQuery + IAwsAccountIdQuery awsAccountIdQuery, + IAwsAccountRepository awsAccountRepository ) { _httpContextAccessor = httpContextAccessor; @@ -44,6 +47,7 @@ IAwsAccountIdQuery awsAccountIdQuery _membershipQuery = membershipQuery; _capabilityDeletionStatusQuery = capabilityDeletionStatusQuery; _awsAccountIdQuery = awsAccountIdQuery; + _awsAccountRepository = awsAccountRepository; } private HttpContext HttpContext => @@ -704,6 +708,36 @@ private async Task CreateAwsAccountInformationLinkFor(Capability c ); } + private async Task CreateKubernetesAccessLinkFor(Capability capability) + { + var allowedInteractions = Allow.None; + var capabilityMarkedForDeletion = await _capabilityDeletionStatusQuery.IsPendingDeletion(capability.Id); + + if (await _authorizationService.CanViewKubernetesAccess(CurrentUser, capability.Id)) + { + allowedInteractions += Get; + } + + if ( + await _authorizationService.CanRequestKubernetesAccess(CurrentUser, capability.Id) + && !capabilityMarkedForDeletion + ) + { + allowedInteractions += Post; + } + + return new ResourceLink( + href: _linkGenerator.GetUriByAction( + httpContext: HttpContext, + action: nameof(CapabilityController.GetKubernetesAccesses), + controller: GetNameOf(), + values: new { id = capability.Id } + ) ?? "", + rel: "related", + allow: allowedInteractions + ); + } + private async Task CreateAzureResourcesLinkFor(Capability capability) { var allowedInteractions = Allow.None; @@ -793,6 +827,7 @@ public async Task Convert(Capability capability, b leaveCapability: await CreateLeaveCapabilityLinkFor(capability), awsAccount: await CreateAwsAccountLinkFor(capability), awsAccountInformation: await CreateAwsAccountInformationLinkFor(capability), + kubernetesAccess: await CreateKubernetesAccessLinkFor(capability), azureResources: await CreateAzureResourcesLinkFor(capability), requestCapabilityDeletion: await CreateRequestDeletionLinkFor(capability), cancelCapabilityDeletionRequest: await CreateCancelDeletionRequestLinkFor(capability), @@ -855,9 +890,9 @@ public async Task Convert(AwsAccount account) return new AwsAccountApiResource( id: account.Id, + environment: account.Environment, accountId: account.Registration.AccountId?.ToString(), roleEmail: account.Registration.RoleEmail, - @namespace: account.KubernetesLink.Namespace, status: Convert(account.Status), links: new AwsAccountApiResource.AwsAccountLinks( self: new ResourceLink( @@ -874,6 +909,106 @@ public async Task Convert(AwsAccount account) ); } + public async Task ConvertToCollection(List accounts, CapabilityId capabilityId) + { + var items = new List(); + foreach (var account in accounts) + { + var allowedInteractions = Allow.None; + if (await _authorizationService.CanViewAwsAccount(CurrentUser, account.CapabilityId)) + { + allowedInteractions += Get; + } + + items.Add( + new AwsAccountItemApiResource( + id: account.Id, + environment: account.Environment, + accountId: account.Registration.AccountId?.ToString(), + roleEmail: account.Registration.RoleEmail, + status: Convert(account.Status), + links: new AwsAccountItemApiResource.AwsAccountItemLinks( + self: new ResourceLink( + href: _linkGenerator.GetUriByAction( + httpContext: HttpContext, + action: nameof(CapabilityController.GetCapabilityAwsAccount), + controller: GetNameOf(), + values: new { id = account.CapabilityId } + ) ?? "", + rel: "self", + allow: allowedInteractions + ) + ) + ) + ); + } + + var canRequest = await _authorizationService.CanRequestAwsAccount(CurrentUser, capabilityId); + var requestAccountLink = new ResourceLink( + href: _linkGenerator.GetUriByAction( + httpContext: HttpContext, + action: nameof(CapabilityController.RequestAwsAccount), + controller: GetNameOf(), + values: new { id = capabilityId } + ) ?? "", + rel: "related", + allow: canRequest ? Allow.Post : Allow.None + ); + + var collectionLinks = new AwsAccountsApiResource.AwsAccountsLinks( + self: new ResourceLink( + href: _linkGenerator.GetUriByAction( + httpContext: HttpContext, + action: nameof(CapabilityController.GetCapabilityAwsAccount), + controller: GetNameOf(), + values: new { id = capabilityId } + ) ?? "", + rel: "self", + allow: Allow.Get + ), + requestAccount: requestAccountLink + ); + + return new AwsAccountsApiResource( + accounts: items, + accountLimit: AwsAccountConfiguration.MaxAccountsPerCapability, + links: collectionLinks + ); + } + + public Task> ConvertKubernetesAccesses( + List accesses, + CapabilityId capabilityId + ) + { + var items = accesses + .Select(a => new KubernetesAccessApiResource( + id: a.Id, + capabilityId: a.CapabilityId, + environment: a.Environment, + awsAccountId: a.AwsAccountId?.ToString(), + @namespace: a.Namespace, + status: a.Status.ToString(), + requestedAt: a.RequestedAt, + requestedBy: a.RequestedBy, + links: new KubernetesAccessApiResource.KubernetesAccessLinks( + self: new ResourceLink( + href: _linkGenerator.GetUriByAction( + httpContext: HttpContext, + action: nameof(CapabilityController.GetKubernetesAccesses), + controller: GetNameOf(), + values: new { id = capabilityId } + ) ?? "", + rel: "self", + allow: Allow.Get + ) + ) + )) + .ToList(); + + return Task.FromResult(items); + } + public async Task Convert(AwsAccountInformation information) { var allowedInteractions = Allow.None; diff --git a/src/SelfService/Infrastructure/Api/Capabilities/AwsAccountApiResource.cs b/src/SelfService/Infrastructure/Api/Capabilities/AwsAccountApiResource.cs index 8c043901..130580d1 100644 --- a/src/SelfService/Infrastructure/Api/Capabilities/AwsAccountApiResource.cs +++ b/src/SelfService/Infrastructure/Api/Capabilities/AwsAccountApiResource.cs @@ -5,9 +5,9 @@ namespace SelfService.Infrastructure.Api.Capabilities; public class AwsAccountApiResource { public string Id { get; set; } + public string? Environment { get; set; } public string? AccountId { get; set; } public string? RoleEmail { get; set; } - public string? Namespace { get; set; } public string? Status { get; set; } [JsonPropertyName("_links")] @@ -25,17 +25,17 @@ public AwsAccountLinks(ResourceLink self) public AwsAccountApiResource( string id, + string? environment, string? accountId, string? roleEmail, - string? @namespace, string? status, AwsAccountLinks links ) { Id = id; + Environment = environment; AccountId = accountId; RoleEmail = roleEmail; - Namespace = @namespace; Status = status; Links = links; } diff --git a/src/SelfService/Infrastructure/Api/Capabilities/AwsAccountsApiResource.cs b/src/SelfService/Infrastructure/Api/Capabilities/AwsAccountsApiResource.cs new file mode 100644 index 00000000..ca4fcfdc --- /dev/null +++ b/src/SelfService/Infrastructure/Api/Capabilities/AwsAccountsApiResource.cs @@ -0,0 +1,70 @@ +using System.Text.Json.Serialization; + +namespace SelfService.Infrastructure.Api.Capabilities; + +public class AwsAccountsApiResource +{ + public List Accounts { get; set; } + public int AccountLimit { get; set; } + + [JsonPropertyName("_links")] + public AwsAccountsLinks Links { get; set; } + + public class AwsAccountsLinks + { + public ResourceLink Self { get; set; } + public ResourceLink RequestAccount { get; set; } + + public AwsAccountsLinks(ResourceLink self, ResourceLink requestAccount) + { + Self = self; + RequestAccount = requestAccount; + } + } + + public AwsAccountsApiResource(List accounts, int accountLimit, AwsAccountsLinks links) + { + Accounts = accounts; + AccountLimit = accountLimit; + Links = links; + } +} + +public class AwsAccountItemApiResource +{ + public string Id { get; set; } + public string? Environment { get; set; } + public string? AccountId { get; set; } + public string? RoleEmail { get; set; } + public string? Status { get; set; } + + [JsonPropertyName("_links")] + public AwsAccountItemLinks Links { get; set; } + + public class AwsAccountItemLinks + { + public ResourceLink Self { get; set; } + + public AwsAccountItemLinks(ResourceLink self) + { + Self = self; + } + } + + public AwsAccountItemApiResource( + string id, + string? environment, + string? accountId, + string? roleEmail, + string? status, + AwsAccountItemLinks links + ) + { + Id = id; + Environment = environment; + AccountId = accountId; + RoleEmail = roleEmail; + Status = status; + Links = links; + } +} diff --git a/src/SelfService/Infrastructure/Api/Capabilities/CapabilityController.cs b/src/SelfService/Infrastructure/Api/Capabilities/CapabilityController.cs index 431ed191..b51ffe14 100644 --- a/src/SelfService/Infrastructure/Api/Capabilities/CapabilityController.cs +++ b/src/SelfService/Infrastructure/Api/Capabilities/CapabilityController.cs @@ -22,6 +22,7 @@ public class CapabilityController : ControllerBase private readonly ApiResourceFactory _apiResourceFactory; private readonly IAuthorizationService _authorizationService; private readonly IAwsAccountApplicationService _awsAccountApplicationService; + private readonly IKubernetesAccessApplicationService _kubernetesAccessApplicationService; private readonly IAzureResourceApplicationService _azureResourceApplicationService; private readonly IAwsAccountRepository _awsAccountRepository; private readonly IAzureResourceRepository _azureResourceRepository; @@ -30,6 +31,7 @@ public class CapabilityController : ControllerBase private readonly IKafkaClusterAccessRepository _kafkaClusterAccessRepository; private readonly IKafkaClusterRepository _kafkaClusterRepository; private readonly IKafkaTopicRepository _kafkaTopicRepository; + private readonly IKubernetesAccessRepository _kubernetesAccessRepository; private readonly ITeamApplicationService _teamApplicationService; private readonly ILogger _logger; private readonly IMembershipApplicationService _membershipApplicationService; @@ -56,6 +58,8 @@ public CapabilityController( IAwsAccountRepository awsAccountRepository, IAzureResourceRepository azureResourceRepository, IAwsAccountApplicationService awsAccountApplicationService, + IKubernetesAccessApplicationService kubernetesAccessApplicationService, + IKubernetesAccessRepository kubernetesAccessRepository, IAzureResourceApplicationService azureResourceApplicationService, IMembershipApplicationService membershipApplicationService, IMembershipRepository membershipRepository, @@ -83,6 +87,8 @@ CatalogApiResourceFactory catalogApiResourceFactory _awsAccountRepository = awsAccountRepository; _azureResourceRepository = azureResourceRepository; _awsAccountApplicationService = awsAccountApplicationService; + _kubernetesAccessApplicationService = kubernetesAccessApplicationService; + _kubernetesAccessRepository = kubernetesAccessRepository; _azureResourceApplicationService = azureResourceApplicationService; _membershipApplicationService = membershipApplicationService; _membershipRepository = membershipRepository; @@ -334,8 +340,8 @@ public async Task GetCapabilityDeployments(string id, Cancellatio return Ok(_catalogApiResourceFactory.ConvertDeployments(capabilityId, result)); } - [HttpGet("{id:required}/awsaccount")] - [ProducesResponseType(typeof(AwsAccountApiResource), StatusCodes.Status200OK)] + [HttpGet("{id:required}/awsaccounts")] + [ProducesResponseType(typeof(AwsAccountsApiResource), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized, "application/problem+json")] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound, "application/problem+json")] [RequiresPermission("aws", "read")] @@ -353,11 +359,8 @@ public async Task GetCapabilityAwsAccount(string id) if (!await _authorizationService.CanViewAwsAccount(userId, capabilityId)) return Unauthorized(); - var account = await _awsAccountRepository.FindBy(capabilityId); - if (account is null) - return NotFound(); - - return Ok(await _apiResourceFactory.Convert(account)); + var accounts = await _awsAccountRepository.GetAllBy(capabilityId); + return Ok(await _apiResourceFactory.ConvertToCollection(accounts, capabilityId)); } [HttpPost("{id:required}/awsaccount")] @@ -366,7 +369,7 @@ public async Task GetCapabilityAwsAccount(string id) [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound, "application/problem+json")] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict, "application/problem+json")] [RequiresPermission("aws", "create")] - public async Task RequestAwsAccount(string id) + public async Task RequestAwsAccount(string id, [FromBody] NewAwsAccountRequest request) { if (!User.TryGetUserId(out var userId)) return Unauthorized(); @@ -377,12 +380,26 @@ public async Task RequestAwsAccount(string id) if (!await _capabilityRepository.Exists(capabilityId)) return NotFound(); + if (request?.environment == null) + return BadRequest( + new ProblemDetails + { + Title = "Invalid metadata", + Detail = "Metadata missing environment", + Status = StatusCodes.Status400BadRequest, + } + ); + if (!await _authorizationService.CanRequestAwsAccount(userId, capabilityId)) return Unauthorized(); try { - var awsAccountId = await _awsAccountApplicationService.RequestAwsAccount(capabilityId, userId); + var awsAccountId = await _awsAccountApplicationService.RequestAwsAccount( + capabilityId, + request.environment, + userId + ); var account = await _awsAccountRepository.Get(awsAccountId); @@ -392,10 +409,21 @@ public async Task RequestAwsAccount(string id) { return Conflict(); } + catch (AwsAccountLimitExceededException ex) + { + return BadRequest( + new ProblemDetails + { + Title = "AWS Account Limit Exceeded", + Detail = ex.Message, + Status = StatusCodes.Status400BadRequest, + } + ); + } } [HttpGet("{id:required}/awsaccount/information")] - [ProducesResponseType(typeof(AwsAccountApiResource), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized, "application/problem+json")] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound, "application/problem+json")] [RequiresPermission("aws", "read")] @@ -410,40 +438,124 @@ public async Task GetCapabilityAwsAccountInformation(string id) if (!await _capabilityRepository.Exists(capabilityId)) return NotFound(); - if (!await _authorizationService.CanViewAwsAccount(userId, capabilityId)) + if (!await _authorizationService.CanViewAwsAccountInformation(userId, capabilityId)) return Unauthorized(); - var account = await _awsAccountRepository.FindBy(capabilityId); - if (account is null) - return NotFound(); + var accounts = await _awsAccountRepository.GetAllBy(capabilityId); + var completedAccounts = accounts + .Where(a => a.Status == AwsAccountStatus.Completed && a.Registration.AccountId is not null) + .ToList(); - if (account.Registration is null || account.Registration?.AccountId is null) + var results = new List(); + foreach (var account in completedAccounts) { - return NotFound(); + try + { + var vpcs = await _awsEC2QueriesApplicationService.GetVPCsAsync( + account.Registration.AccountId!.ToString() + ); + var accountInformation = new AwsAccountInformation(account.Id, capabilityId, vpcs); + results.Add(await _apiResourceFactory.Convert(accountInformation)); + } + catch (Exception ex) + { + return StatusCode( + StatusCodes.Status500InternalServerError, + new ProblemDetails + { + Title = "Error", + Detail = ex.Message, + Status = StatusCodes.Status500InternalServerError, + } + ); + } } - try - { - var vpcs = _awsEC2QueriesApplicationService.GetVPCsAsync(account.Registration.AccountId.ToString()); - var accountInformation = new AwsAccountInformation(account.Id, capabilityId, await vpcs); - return Ok(await _apiResourceFactory.Convert(accountInformation)); - } - catch (Exception ex) - { - return StatusCode( - StatusCodes.Status500InternalServerError, + return Ok(results); + } + + [HttpGet("{id:required}/kubernetes-access")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized, "application/problem+json")] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound, "application/problem+json")] + [RequiresPermission("kubernetes", "read")] + public async Task GetKubernetesAccesses(string id) + { + if (!User.TryGetUserId(out var userId)) + return Unauthorized(); + + if (!CapabilityId.TryParse(id, out var capabilityId)) + return NotFound(); + + if (!await _capabilityRepository.Exists(capabilityId)) + return NotFound(); + + if (!await _authorizationService.CanViewKubernetesAccess(userId, capabilityId)) + return Unauthorized(); + + var accesses = await _kubernetesAccessRepository.GetAllBy(capabilityId); + return Ok(await _apiResourceFactory.ConvertKubernetesAccesses(accesses, capabilityId)); + } + + [HttpPost("{id:required}/kubernetes-access")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest, "application/problem+json")] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized, "application/problem+json")] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound, "application/problem+json")] + [RequiresPermission("kubernetes", "create")] + public async Task RequestKubernetesAccess(string id, [FromBody] NewKubernetesAccessRequest request) + { + if (!User.TryGetUserId(out var userId)) + return Unauthorized(); + + if (!CapabilityId.TryParse(id, out var capabilityId)) + return NotFound(); + + if (!await _capabilityRepository.Exists(capabilityId)) + return NotFound(); + + if (request?.AwsAccountId == null) + return BadRequest( new ProblemDetails { - Title = "Error", - Detail = ex.Message, - Status = StatusCodes.Status500InternalServerError, + Title = "Invalid request", + Detail = "AwsAccountId is required", + Status = StatusCodes.Status400BadRequest, } ); + + if (!AwsAccountId.TryParse(request.AwsAccountId, out var awsAccountId)) + return BadRequest( + new ProblemDetails + { + Title = "Invalid request", + Detail = $"Value \"{request.AwsAccountId}\" is not a valid AWS account id", + Status = StatusCodes.Status400BadRequest, + } + ); + + if (!await _authorizationService.CanRequestKubernetesAccess(userId, capabilityId)) + return Unauthorized(); + + try + { + var account = await _awsAccountRepository.Get(awsAccountId); + + if (account.CapabilityId != capabilityId) + return NotFound(); + + await _kubernetesAccessApplicationService.RequestKubernetesAccess(awsAccountId, userId); + + return Ok(); + } + catch (EntityNotFoundException) + { + return NotFound(); } } [HttpGet("{id:required}/azureresources")] - [ProducesResponseType(typeof(AwsAccountApiResource), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(AzureResourcesApiResource), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized, "application/problem+json")] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound, "application/problem+json")] [RequiresPermission("azure", "read")] @@ -465,7 +577,7 @@ public async Task GetCapabilityAzureResources(string id) } [HttpPost("{id:required}/azureresources")] - [ProducesResponseType(typeof(AwsAccountApiResource), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(AzureResourceApiResource), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized, "application/problem+json")] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound, "application/problem+json")] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict, "application/problem+json")] @@ -539,7 +651,7 @@ [FromBody] NewAzureResourceRequest request } [HttpGet("{id:required}/self-assessments")] - [ProducesResponseType(typeof(AwsAccountApiResource), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(SelfAssessmentListApiResource), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized, "application/problem+json")] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound, "application/problem+json")] [RequiresPermission("capability-management", "read-self-assess")] @@ -564,7 +676,7 @@ public async Task GetSelfAssessments(string id) } [HttpPost("{id:required}/self-assessments")] - [ProducesResponseType(typeof(AwsAccountApiResource), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized, "application/problem+json")] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound, "application/problem+json")] [RequiresPermission("capability-management", "create-self-assess")] @@ -610,7 +722,7 @@ await _capabilityApplicationService.UpdateSelfAssessment( } [HttpGet("{id:required}/azureresources/{rid:required}")] - [ProducesResponseType(typeof(AwsAccountApiResource), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(AzureResourceApiResource), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized, "application/problem+json")] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound, "application/problem+json")] [RequiresPermission("azure", "read")] @@ -1555,7 +1667,7 @@ public async Task GetConfigurationLevel([FromRoute] string id) } [HttpGet("self-assessment-options")] - [ProducesResponseType(typeof(AwsAccountApiResource), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized, "application/problem+json")] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound, "application/problem+json")] [RequiresPermission("capability-management", "read-self-assess")] @@ -1575,7 +1687,7 @@ public async Task GetSelfAssessmentOptions() } [HttpPost("self-assessment-options")] - [ProducesResponseType(typeof(AwsAccountApiResource), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized, "application/problem+json")] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound, "application/problem+json")] public async Task AddSelfAssessmentOption([FromBody] AddSelfAssessmentOptionRequest request) @@ -1601,7 +1713,7 @@ public async Task AddSelfAssessmentOption([FromBody] AddSelfAsses } [HttpPost("self-assessment-options/{id}/update")] - [ProducesResponseType(typeof(AwsAccountApiResource), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest, "application/problem+json")] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized, "application/problem+json")] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound, "application/problem+json")] @@ -1641,7 +1753,7 @@ await _selfAssessmentOptionRepository.UpdateSelfAssessmentOption( } [HttpPost("self-assessment-options/{id}/activate")] - [ProducesResponseType(typeof(AwsAccountApiResource), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest, "application/problem+json")] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized, "application/problem+json")] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound, "application/problem+json")] @@ -1672,7 +1784,7 @@ public async Task ActivateSelfAssessmentOption([FromRoute] string } [HttpPost("self-assessment-options/{id}/deactivate")] - [ProducesResponseType(typeof(AwsAccountApiResource), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest, "application/problem+json")] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized, "application/problem+json")] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound, "application/problem+json")] diff --git a/src/SelfService/Infrastructure/Api/Capabilities/CapabilityDetailsApiResource.cs b/src/SelfService/Infrastructure/Api/Capabilities/CapabilityDetailsApiResource.cs index cebd023d..c1f70dcb 100644 --- a/src/SelfService/Infrastructure/Api/Capabilities/CapabilityDetailsApiResource.cs +++ b/src/SelfService/Infrastructure/Api/Capabilities/CapabilityDetailsApiResource.cs @@ -29,6 +29,7 @@ public class CapabilityDetailsLinks public ResourceLink LeaveCapability { get; set; } public ResourceLink AwsAccount { get; set; } public ResourceLink AwsAccountInformation { get; set; } + public ResourceLink KubernetesAccess { get; set; } public ResourceLink AzureResources { get; set; } public ResourceLink RequestCapabilityDeletion { get; set; } public ResourceLink CancelCapabilityDeletionRequest { get; set; } @@ -51,6 +52,7 @@ public CapabilityDetailsLinks( ResourceLink leaveCapability, ResourceLink awsAccount, ResourceLink awsAccountInformation, + ResourceLink kubernetesAccess, ResourceLink azureResources, ResourceLink requestCapabilityDeletion, ResourceLink cancelCapabilityDeletionRequest, @@ -72,6 +74,7 @@ ResourceLink deployments LeaveCapability = leaveCapability; AwsAccount = awsAccount; AwsAccountInformation = awsAccountInformation; + KubernetesAccess = kubernetesAccess; AzureResources = azureResources; RequestCapabilityDeletion = requestCapabilityDeletion; CancelCapabilityDeletionRequest = cancelCapabilityDeletionRequest; diff --git a/src/SelfService/Infrastructure/Api/Capabilities/KubernetesAccessApiResource.cs b/src/SelfService/Infrastructure/Api/Capabilities/KubernetesAccessApiResource.cs new file mode 100644 index 00000000..b1547cbb --- /dev/null +++ b/src/SelfService/Infrastructure/Api/Capabilities/KubernetesAccessApiResource.cs @@ -0,0 +1,51 @@ +using System.Text.Json.Serialization; + +namespace SelfService.Infrastructure.Api.Capabilities; + +public class KubernetesAccessApiResource +{ + public string Id { get; set; } + public string CapabilityId { get; set; } + public string Environment { get; set; } + public string? AwsAccountId { get; set; } + public string? Namespace { get; set; } + public string? Status { get; set; } + public DateTime RequestedAt { get; set; } + public string RequestedBy { get; set; } + + [JsonPropertyName("_links")] + public KubernetesAccessLinks Links { get; set; } + + public class KubernetesAccessLinks + { + public ResourceLink Self { get; set; } + + public KubernetesAccessLinks(ResourceLink self) + { + Self = self; + } + } + + public KubernetesAccessApiResource( + string id, + string capabilityId, + string environment, + string? awsAccountId, + string? @namespace, + string? status, + DateTime requestedAt, + string requestedBy, + KubernetesAccessLinks links + ) + { + Id = id; + CapabilityId = capabilityId; + Environment = environment; + AwsAccountId = awsAccountId; + Namespace = @namespace; + Status = status; + RequestedAt = requestedAt; + RequestedBy = requestedBy; + Links = links; + } +} diff --git a/src/SelfService/Infrastructure/Api/Capabilities/NewAwsAccountRequest.cs b/src/SelfService/Infrastructure/Api/Capabilities/NewAwsAccountRequest.cs new file mode 100644 index 00000000..8887b267 --- /dev/null +++ b/src/SelfService/Infrastructure/Api/Capabilities/NewAwsAccountRequest.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace SelfService.Infrastructure.Api.Capabilities; + +public class NewAwsAccountRequest +{ + [Required] + public string? environment { get; set; } = null; +} diff --git a/src/SelfService/Infrastructure/Api/Capabilities/NewKubernetesAccessRequest.cs b/src/SelfService/Infrastructure/Api/Capabilities/NewKubernetesAccessRequest.cs new file mode 100644 index 00000000..eed07def --- /dev/null +++ b/src/SelfService/Infrastructure/Api/Capabilities/NewKubernetesAccessRequest.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace SelfService.Infrastructure.Api.Capabilities; + +public class NewKubernetesAccessRequest +{ + [Required] + public string? AwsAccountId { get; set; } = null; +} diff --git a/src/SelfService/Infrastructure/Messaging/ConsumerConfiguration.cs b/src/SelfService/Infrastructure/Messaging/ConsumerConfiguration.cs index 14aba6f2..dfa2a5dd 100644 --- a/src/SelfService/Infrastructure/Messaging/ConsumerConfiguration.cs +++ b/src/SelfService/Infrastructure/Messaging/ConsumerConfiguration.cs @@ -38,6 +38,13 @@ public static void AddMessaging(this WebApplicationBuilder builder) keySelector: x => x.AccountId! ); + options + .ForTopic($"{SelfServicePrefix}.kubernetes") + .Register( + messageType: KubernetesAccessRequested.EventType, + keySelector: x => x.ContextId! + ); + options .ForTopic($"{SelfServicePrefix}.azureresourcegroup") .Register( diff --git a/src/SelfService/Infrastructure/Messaging/Legacy/K8sNamespaceCreatedAndAwsArnConnected.cs b/src/SelfService/Infrastructure/Messaging/Legacy/K8sNamespaceCreatedAndAwsArnConnected.cs index 6572d12b..94e4339f 100644 --- a/src/SelfService/Infrastructure/Messaging/Legacy/K8sNamespaceCreatedAndAwsArnConnected.cs +++ b/src/SelfService/Infrastructure/Messaging/Legacy/K8sNamespaceCreatedAndAwsArnConnected.cs @@ -15,20 +15,27 @@ public class K8sNamespaceCreatedAndAwsArnConnected public class K8sNamespaceCreatedAndAwsArnConnectedHandler : IMessageHandler { - private readonly IAwsAccountApplicationService _awsAccountApplicationService; + private readonly IKubernetesAccessApplicationService _kubernetesAccessApplicationService; - public K8sNamespaceCreatedAndAwsArnConnectedHandler(IAwsAccountApplicationService awsAccountApplicationService) + public K8sNamespaceCreatedAndAwsArnConnectedHandler( + IKubernetesAccessApplicationService kubernetesAccessApplicationService + ) { - _awsAccountApplicationService = awsAccountApplicationService; + _kubernetesAccessApplicationService = kubernetesAccessApplicationService; } public Task Handle(K8sNamespaceCreatedAndAwsArnConnected message, MessageHandlerContext context) { - if (!AwsAccountId.TryParse(message.ContextId, out var id)) + if (!AwsAccountId.TryParse(message.ContextId, out var awsAccountId)) { throw new InvalidOperationException($"Invalid AwsAccountId {message.ContextId}"); } - return _awsAccountApplicationService.LinkKubernetesNamespace(id, message.NamespaceName); + if (message.NamespaceName is null) + { + throw new InvalidOperationException("NamespaceName is required"); + } + + return _kubernetesAccessApplicationService.GrantKubernetesAccess(awsAccountId, message.NamespaceName); } } diff --git a/src/SelfService/Infrastructure/Persistence/AwsAccountRepository.cs b/src/SelfService/Infrastructure/Persistence/AwsAccountRepository.cs index cba8027d..87e60168 100644 --- a/src/SelfService/Infrastructure/Persistence/AwsAccountRepository.cs +++ b/src/SelfService/Infrastructure/Persistence/AwsAccountRepository.cs @@ -13,9 +13,32 @@ public AwsAccountRepository(SelfServiceDbContext dbContext) _dbContext = dbContext; } - public Task FindBy(CapabilityId capabilityId) + public async Task FindBy(CapabilityId capabilityId) { - return _dbContext.AwsAccounts.SingleOrDefaultAsync(x => x.CapabilityId == capabilityId); + var accounts = await _dbContext + .AwsAccounts.Where(x => x.CapabilityId == capabilityId) + .OrderByDescending(x => x.Environment == "prod") + .ThenBy(x => x.RequestedAt) + .ToListAsync(); + + return accounts.FirstOrDefault(); + } + + public Task FindBy(CapabilityId capabilityId, string environment) + { + return _dbContext.AwsAccounts.SingleOrDefaultAsync(x => + x.CapabilityId == capabilityId && x.Environment == environment + ); + } + + public Task FindBy(AwsAccountId id) + { + return _dbContext.AwsAccounts.SingleOrDefaultAsync(x => x.Id == id); + } + + public Task> GetAllBy(CapabilityId capabilityId) + { + return _dbContext.AwsAccounts.Where(x => x.CapabilityId == capabilityId).ToListAsync(); } public async Task> GetAll() @@ -53,4 +76,16 @@ public async Task Exists(CapabilityId capabilityId) { return await _dbContext.AwsAccounts.AnyAsync(x => x.CapabilityId == capabilityId); } + + public async Task Exists(CapabilityId capabilityId, string environment) + { + return await _dbContext.AwsAccounts.AnyAsync(x => + x.CapabilityId == capabilityId && x.Environment == environment + ); + } + + public async Task CountBy(CapabilityId capabilityId) + { + return await _dbContext.AwsAccounts.CountAsync(x => x.CapabilityId == capabilityId); + } } diff --git a/src/SelfService/Infrastructure/Persistence/KubernetesAccessRepository.cs b/src/SelfService/Infrastructure/Persistence/KubernetesAccessRepository.cs new file mode 100644 index 00000000..1209caf6 --- /dev/null +++ b/src/SelfService/Infrastructure/Persistence/KubernetesAccessRepository.cs @@ -0,0 +1,42 @@ +using Microsoft.EntityFrameworkCore; +using SelfService.Domain.Models; + +namespace SelfService.Infrastructure.Persistence; + +public class KubernetesAccessRepository : IKubernetesAccessRepository +{ + private readonly SelfServiceDbContext _dbContext; + + public KubernetesAccessRepository(SelfServiceDbContext dbContext) + { + _dbContext = dbContext; + } + + public async Task Add(KubernetesAccess access) + { + await _dbContext.KubernetesAccesses.AddAsync(access); + } + + public Task> GetAllBy(CapabilityId capabilityId) + { + return _dbContext + .KubernetesAccesses.Where(x => x.CapabilityId == capabilityId) + .OrderBy(x => x.RequestedAt) + .ToListAsync(); + } + + public Task> GetAllBy(IEnumerable capabilityIds) + { + var ids = capabilityIds.ToList(); + return _dbContext.KubernetesAccesses.Where(x => ids.Contains(x.CapabilityId)).ToListAsync(); + } + + // Returns the most recent requested (not yet granted) access for the given AWS account + public Task FindRequestedByAwsAccountId(AwsAccountId awsAccountId) + { + return _dbContext + .KubernetesAccesses.Where(x => x.AwsAccountId == awsAccountId && x.GrantedAt == null) + .OrderByDescending(x => x.RequestedAt) + .FirstOrDefaultAsync(); + } +} diff --git a/src/SelfService/Infrastructure/Persistence/SelfServiceDbContext.cs b/src/SelfService/Infrastructure/Persistence/SelfServiceDbContext.cs index e0eaeb6a..ceebba54 100644 --- a/src/SelfService/Infrastructure/Persistence/SelfServiceDbContext.cs +++ b/src/SelfService/Infrastructure/Persistence/SelfServiceDbContext.cs @@ -52,6 +52,8 @@ public SelfServiceDbContext(DbContextOptions options) public DbSet AwsAccounts => Set(); + public DbSet KubernetesAccesses => Set(); + public DbSet AzureResources => Set(); public DbSet KafkaClusters => Set(); @@ -107,6 +109,10 @@ protected override void ConfigureConventions(ModelConfigurationBuilder configura configurationBuilder.Properties().HaveConversion(); + configurationBuilder + .Properties() + .HaveConversion>(); + configurationBuilder.Properties().HaveConversion(); configurationBuilder.Properties().HaveConversion(); @@ -347,6 +353,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) cfg.HasKey(x => x.Id); cfg.Property(x => x.Id).ValueGeneratedNever(); cfg.Property(x => x.CapabilityId); + cfg.Property(x => x.Environment); + cfg.HasIndex(x => new { x.CapabilityId, x.Environment }).IsUnique(); cfg.OwnsOne( x => x.Registration, o => @@ -356,14 +364,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) o.Property(x => x.RegisteredAt).HasColumnName(nameof(AwsAccountRegistration.RegisteredAt)); } ); - cfg.OwnsOne( - x => x.KubernetesLink, - o => - { - o.Property(x => x.Namespace).HasColumnName(nameof(KubernetesLink.Namespace)); - o.Property(x => x.LinkedAt).HasColumnName(nameof(KubernetesLink.LinkedAt)); - } - ); cfg.Property(x => x.RequestedAt); cfg.Property(x => x.RequestedBy); }); @@ -378,6 +378,20 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) cfg.Property(x => x.RequestedBy); }); + modelBuilder.Entity(cfg => + { + cfg.ToTable("KubernetesAccess"); + cfg.HasKey(x => x.Id); + cfg.Property(x => x.Id).ValueGeneratedNever(); + cfg.Property(x => x.CapabilityId); + cfg.Property(x => x.Environment); + cfg.Property(x => x.AwsAccountId); + cfg.Property(x => x.RequestedAt); + cfg.Property(x => x.RequestedBy); + cfg.Property(x => x.Namespace); + cfg.Property(x => x.GrantedAt); + }); + modelBuilder.Entity(cfg => { cfg.ToTable("KafkaCluster"); diff --git a/src/SelfService/Infrastructure/Persistence/TemplateRenderingService.cs b/src/SelfService/Infrastructure/Persistence/TemplateRenderingService.cs index faf3fa11..4da30429 100644 --- a/src/SelfService/Infrastructure/Persistence/TemplateRenderingService.cs +++ b/src/SelfService/Infrastructure/Persistence/TemplateRenderingService.cs @@ -280,13 +280,6 @@ private VariableEntry[] InitializeVariables() "Completed", ctx => ctx.AwsAccount?.Status.ToString() ?? "N/A" ), - new StaticVariable( - "Aws.Namespace", - "Kubernetes namespace linked to AWS account", - "AwsAccount", - "my-capability-abc12", - ctx => ctx.AwsAccount?.KubernetesLink.Namespace ?? "N/A" - ), new StaticVariable( "Aws.RoleEmail", "AWS account role email",