From 65692edfcca72194d0b25d60f383363549fa3913 Mon Sep 17 00:00:00 2001 From: Andreas Frisch Date: Wed, 3 Jun 2026 15:27:24 +0200 Subject: [PATCH 1/5] initial --- .../Domain/Services/AuthorizationService.cs | 11 + .../Domain/Services/IAuthorizationService.cs | 1 + .../Capabilities/BatchCapabilityRequest.cs | 71 ++++++ .../Capabilities/BatchCapabilityResponse.cs | 25 ++ .../Api/Capabilities/CapabilityController.cs | 238 ++++++++++++++++++ 5 files changed, 346 insertions(+) create mode 100644 src/SelfService/Infrastructure/Api/Capabilities/BatchCapabilityRequest.cs create mode 100644 src/SelfService/Infrastructure/Api/Capabilities/BatchCapabilityResponse.cs diff --git a/src/SelfService/Domain/Services/AuthorizationService.cs b/src/SelfService/Domain/Services/AuthorizationService.cs index 25afe470..b740b422 100644 --- a/src/SelfService/Domain/Services/AuthorizationService.cs +++ b/src/SelfService/Domain/Services/AuthorizationService.cs @@ -646,6 +646,17 @@ public bool CanBypassMembershipApprovals(PortalUser portalUser) return IsCloudEngineerEnabled(portalUser); } + private static readonly IReadOnlySet BatchCapabilityAllowList = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "andfris@dfds.com", + }; + + public bool CanBatchCreateCapabilities(PortalUser portalUser) + { + return IsCloudEngineerEnabled(portalUser) + || BatchCapabilityAllowList.Contains(portalUser.Id.ToString()); + } + public async Task CanDeleteMembershipApplication( PortalUser portalUser, UserId userId, diff --git a/src/SelfService/Domain/Services/IAuthorizationService.cs b/src/SelfService/Domain/Services/IAuthorizationService.cs index 4d86b809..2b62fb2c 100644 --- a/src/SelfService/Domain/Services/IAuthorizationService.cs +++ b/src/SelfService/Domain/Services/IAuthorizationService.cs @@ -36,6 +36,7 @@ public interface IAuthorizationService Task CanGetCapabilityJsonMetadata(PortalUser portalUser, CapabilityId capabilityId); Task CanSetCapabilityJsonMetadata(PortalUser portalUser, CapabilityId capabilityId); bool CanBypassMembershipApprovals(PortalUser portalUser); + bool CanBatchCreateCapabilities(PortalUser portalUser); Task CanDeleteMembershipApplication( PortalUser portalUser, UserId userId, diff --git a/src/SelfService/Infrastructure/Api/Capabilities/BatchCapabilityRequest.cs b/src/SelfService/Infrastructure/Api/Capabilities/BatchCapabilityRequest.cs new file mode 100644 index 00000000..27857068 --- /dev/null +++ b/src/SelfService/Infrastructure/Api/Capabilities/BatchCapabilityRequest.cs @@ -0,0 +1,71 @@ +using System.ComponentModel.DataAnnotations; + +namespace SelfService.Infrastructure.Api.Capabilities; + +/// +/// Request body for batch capability creation. +/// +public class BatchCapabilityRequest +{ + /// List of proto-capabilities to create. + [Required] + public List ProtoCapabilities { get; set; } = new(); +} + +/// +/// Describes a single capability to create as part of a batch operation. +/// +public class ProtoCapabilityRequest +{ + /// The name of the capability. Used to derive the capability ID. + [Required] + public string? Name { get; set; } + + /// Optional description of the capability. + public string? Description { get; set; } + + /// The @dfds.com email address of the capability owner. Will be granted the Owner role. + [Required] + public string? Owner { get; set; } + + /// + /// @dfds.com email addresses to add as members. At least one is required. + /// The owner is always added as a member regardless of whether they appear here. + /// + public List Members { get; set; } = new(); + + /// + /// Metadata tags for the capability. Must include 'dfds.cost.centre'. + /// If AzureResourceGroups are specified, must also include 'dfds.service.availability', + /// 'dfds.azure.purpose', and 'dfds.service.capabilities'. + /// + [Required] + public Dictionary Tags { get; set; } = new(); + + /// Azure resource groups to request for this capability. Optional. + public List AzureResourceGroups { get; set; } = new(); +} + +/// Describes an Azure resource group to request for a capability. +public class ProtoAzureResourceGroupRequest +{ + /// The target environment (e.g. "prod", "dev"). + [Required] + public string? Environment { get; set; } + + /// + /// The purpose of the resource group. Must be one of the legal azure purpose values. + /// When purpose is 'ai', CatalogueId, Risk, and Gdpr are all required. + /// + [Required] + public string? Purpose { get; set; } + + /// Required when Purpose is 'ai'. + public string? CatalogueId { get; set; } + + /// Required when Purpose is 'ai'. + public string? Risk { get; set; } + + /// Required when Purpose is 'ai'. + public bool? Gdpr { get; set; } +} diff --git a/src/SelfService/Infrastructure/Api/Capabilities/BatchCapabilityResponse.cs b/src/SelfService/Infrastructure/Api/Capabilities/BatchCapabilityResponse.cs new file mode 100644 index 00000000..507e7464 --- /dev/null +++ b/src/SelfService/Infrastructure/Api/Capabilities/BatchCapabilityResponse.cs @@ -0,0 +1,25 @@ +namespace SelfService.Infrastructure.Api.Capabilities; + +/// Result of a batch capability creation request. +public class BatchCapabilityResponse +{ + /// Capabilities that were successfully created. + public List Created { get; set; } = new(); + + /// Proto-capabilities that failed validation or could not be created, with error details. + public List Failed { get; set; } = new(); +} + +/// A capability that was successfully created. +public class CreatedCapabilityResult +{ + public string CapabilityId { get; set; } = ""; + public string Name { get; set; } = ""; +} + +/// A proto-capability that could not be created. +public class FailedCapabilityResult +{ + public string Name { get; set; } = ""; + public List Errors { get; set; } = new(); +} diff --git a/src/SelfService/Infrastructure/Api/Capabilities/CapabilityController.cs b/src/SelfService/Infrastructure/Api/Capabilities/CapabilityController.cs index a4d8d9be..9f162eda 100644 --- a/src/SelfService/Infrastructure/Api/Capabilities/CapabilityController.cs +++ b/src/SelfService/Infrastructure/Api/Capabilities/CapabilityController.cs @@ -1587,4 +1587,242 @@ public async Task GetRequirementScore(string id) var resource = RequirementsMetricConverter.Convert(id, totalScore, scores); return Ok(resource); } + + /// + /// Creates multiple capabilities from a list of proto-capabilities. + /// Each proto-capability is validated individually; invalid ones are skipped and reported in the response. + /// Returns 201 if all were created successfully, 207 if some failed, or 400 if all failed. + /// + [HttpPost("batch")] + [ProducesResponseType(typeof(BatchCapabilityResponse), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(BatchCapabilityResponse), StatusCodes.Status207MultiStatus)] + [ProducesResponseType(typeof(BatchCapabilityResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized, "application/problem+json")] + public async Task CreateCapabilitiesFromBatch([FromBody] BatchCapabilityRequest request) + { + if (!User.TryGetUserId(out var userId)) + return Unauthorized(); + + if (!_authorizationService.CanBatchCreateCapabilities(HttpContext.User.ToPortalUser())) + return Unauthorized( + new ProblemDetails + { + Title = "User unauthorized", + Detail = "Only cloud engineers can create capabilities in batch.", + } + ); + + var created = new List(); + var failed = new List(); + + foreach (var proto in request.ProtoCapabilities) + { + var errors = ValidateProtoCapabilityRequest(proto); + if (errors.Count > 0) + { + failed.Add(new FailedCapabilityResult { Name = proto.Name ?? "(unknown)", Errors = errors }); + continue; + } + + if (!CapabilityId.TryCreateFrom(proto.Name!, out var capabilityId)) + { + failed.Add(new FailedCapabilityResult + { + Name = proto.Name!, + Errors = new List { $"Unable to derive a capability ID from name \"{proto.Name}\"." }, + }); + continue; + } + + var jsonMetadata = JsonSerializer.Serialize(proto.Tags); + try + { + await _capabilityApplicationService.CreateNewCapability( + capabilityId, + proto.Name!, + proto.Description ?? "", + proto.Owner!, + jsonMetadata, + jsonSchemaVersion: 0 + ); + + var ownerRoleId = (await _rbacApplicationService.GetAssignableRoles()) + .FirstOrDefault(r => r.Name == "Owner") + ?.Id; + if (ownerRoleId is not null) + { + var ownerRoleGrant = Domain.Models.RbacRoleGrant.New( + ownerRoleId, + AssignedEntityType.User, + proto.Owner!, + RbacAccessType.Capability, + capabilityId.ToString() + ); + await _rbacApplicationService.GrantRoleGrant(userId, ownerRoleGrant); + } + + var membersToAdd = proto + .Members.Append(proto.Owner!) + .Distinct(StringComparer.OrdinalIgnoreCase); + foreach (var member in membersToAdd) + await _membershipApplicationService.JoinCapability(capabilityId, UserId.Parse(member)); + + foreach (var rg in proto.AzureResourceGroups) + await _azureResourceApplicationService.RequestAzureResource( + capabilityId, + rg.Environment!, + UserId.Parse(proto.Owner!), + rg.Purpose!, + rg.CatalogueId, + rg.Risk, + rg.Gdpr + ); + + created.Add(new CreatedCapabilityResult { CapabilityId = capabilityId.ToString(), Name = proto.Name! }); + } + catch (EntityAlreadyExistsException) + { + failed.Add(new FailedCapabilityResult + { + Name = proto.Name!, + Errors = new List { $"A capability with name \"{proto.Name}\" already exists." }, + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Unexpected error creating capability {CapabilityName} in batch", proto.Name); + failed.Add(new FailedCapabilityResult + { + Name = proto.Name!, + Errors = new List { $"Unexpected error: {ex.Message}" }, + }); + } + } + + var response = new BatchCapabilityResponse { Created = created, Failed = failed }; + if (created.Count == 0) + return BadRequest(response); + if (failed.Count > 0) + return StatusCode(StatusCodes.Status207MultiStatus, response); + return StatusCode(StatusCodes.Status201Created, response); + } + + private static readonly IReadOnlyList LegalCostCentres = new[] + { + "ti-cae", + "ti-ferry", + "ti-logistics", + "ti-gtad", + "ti-sao", + "ti-arch", + "ti-tes", + "ti-ctoo", + "ferry", + "finance", + "logistics", + "people", + }; + + private static readonly IReadOnlyList LegalServiceAvailabilities = new[] { "low", "medium", "high" }; + + private static readonly IReadOnlyList LegalAzurePurposes = new[] + { + "toolaccess", + "ai", + "thirdpartylimitations", + "thirdpartylimitations → partnerpackagelimitation", + "other", + }; + + private static List ValidateProtoCapabilityRequest(ProtoCapabilityRequest proto) + { + var errors = new List(); + + if (string.IsNullOrWhiteSpace(proto.Name)) + errors.Add("Name is required."); + + if (string.IsNullOrWhiteSpace(proto.Owner)) + errors.Add("Owner is required."); + else if (!proto.Owner.EndsWith("@dfds.com", StringComparison.OrdinalIgnoreCase)) + errors.Add($"Owner '{proto.Owner}' must be a @dfds.com email address."); + + if (proto.Members.Count == 0) + errors.Add("At least one member is required."); + + // dfds.cost.centre: always required, must be a legal value + if (!proto.Tags.TryGetValue("dfds.cost.centre", out var costCentre) || string.IsNullOrWhiteSpace(costCentre)) + errors.Add("Tag 'dfds.cost.centre' is required."); + else if (!LegalCostCentres.Contains(costCentre, StringComparer.OrdinalIgnoreCase)) + errors.Add( + $"Tag 'dfds.cost.centre' value '{costCentre}' is not valid. Legal values: {string.Join(", ", LegalCostCentres)}." + ); + + // dfds.service.availability: validate value if present + if ( + proto.Tags.TryGetValue("dfds.service.availability", out var availability) + && !string.IsNullOrWhiteSpace(availability) + && !LegalServiceAvailabilities.Contains(availability, StringComparer.OrdinalIgnoreCase) + ) + errors.Add( + $"Tag 'dfds.service.availability' value '{availability}' is not valid. Legal values: {string.Join(", ", LegalServiceAvailabilities)}." + ); + + // dfds.azure.purpose: validate value if present + if ( + proto.Tags.TryGetValue("dfds.azure.purpose", out var azurePurpose) + && !string.IsNullOrWhiteSpace(azurePurpose) + && !LegalAzurePurposes.Contains(azurePurpose, StringComparer.OrdinalIgnoreCase) + ) + errors.Add( + $"Tag 'dfds.azure.purpose' value '{azurePurpose}' is not valid. Legal values: {string.Join(", ", LegalAzurePurposes)}." + ); + + if (proto.AzureResourceGroups.Count > 0) + { + // Additional tags required when requesting Azure resources + if ( + !proto.Tags.TryGetValue("dfds.service.availability", out var svcAvail) + || string.IsNullOrWhiteSpace(svcAvail) + ) + errors.Add("Tag 'dfds.service.availability' is required when azure resource groups are specified."); + + if ( + !proto.Tags.TryGetValue("dfds.azure.purpose", out var azPurpose) + || string.IsNullOrWhiteSpace(azPurpose) + ) + errors.Add("Tag 'dfds.azure.purpose' is required when azure resource groups are specified."); + + if ( + !proto.Tags.TryGetValue("dfds.service.capabilities", out var svcCaps) + || string.IsNullOrWhiteSpace(svcCaps) + ) + errors.Add("Tag 'dfds.service.capabilities' is required when azure resource groups are specified."); + + for (int i = 0; i < proto.AzureResourceGroups.Count; i++) + { + var rg = proto.AzureResourceGroups[i]; + + if (string.IsNullOrWhiteSpace(rg.Environment)) + errors.Add($"Azure resource group at index {i}: environment is required."); + + if (string.IsNullOrWhiteSpace(rg.Purpose)) + errors.Add($"Azure resource group at index {i}: purpose is required."); + else if (!LegalAzurePurposes.Contains(rg.Purpose, StringComparer.OrdinalIgnoreCase)) + errors.Add( + $"Azure resource group at index {i}: purpose '{rg.Purpose}' is not valid. Legal values: {string.Join(", ", LegalAzurePurposes)}." + ); + else if (rg.Purpose.Equals("ai", StringComparison.OrdinalIgnoreCase)) + { + if (string.IsNullOrWhiteSpace(rg.CatalogueId)) + errors.Add($"Azure resource group at index {i}: catalogueId is required when purpose is 'ai'."); + if (string.IsNullOrWhiteSpace(rg.Risk)) + errors.Add($"Azure resource group at index {i}: risk is required when purpose is 'ai'."); + if (!rg.Gdpr.HasValue) + errors.Add($"Azure resource group at index {i}: gdpr is required when purpose is 'ai'."); + } + } + } + + return errors; + } } From 7f10885b1b492150fca141d93c567900e1f8105d Mon Sep 17 00:00:00 2001 From: Andreas Frisch Date: Wed, 3 Jun 2026 15:27:36 +0200 Subject: [PATCH 2/5] format --- .../Domain/Services/AuthorizationService.cs | 7 +-- .../Api/Capabilities/CapabilityController.cs | 43 ++++++++++--------- 2 files changed, 27 insertions(+), 23 deletions(-) diff --git a/src/SelfService/Domain/Services/AuthorizationService.cs b/src/SelfService/Domain/Services/AuthorizationService.cs index b740b422..cd150970 100644 --- a/src/SelfService/Domain/Services/AuthorizationService.cs +++ b/src/SelfService/Domain/Services/AuthorizationService.cs @@ -646,15 +646,16 @@ public bool CanBypassMembershipApprovals(PortalUser portalUser) return IsCloudEngineerEnabled(portalUser); } - private static readonly IReadOnlySet BatchCapabilityAllowList = new HashSet(StringComparer.OrdinalIgnoreCase) + private static readonly IReadOnlySet BatchCapabilityAllowList = new HashSet( + StringComparer.OrdinalIgnoreCase + ) { "andfris@dfds.com", }; public bool CanBatchCreateCapabilities(PortalUser portalUser) { - return IsCloudEngineerEnabled(portalUser) - || BatchCapabilityAllowList.Contains(portalUser.Id.ToString()); + return IsCloudEngineerEnabled(portalUser) || BatchCapabilityAllowList.Contains(portalUser.Id.ToString()); } public async Task CanDeleteMembershipApplication( diff --git a/src/SelfService/Infrastructure/Api/Capabilities/CapabilityController.cs b/src/SelfService/Infrastructure/Api/Capabilities/CapabilityController.cs index 9f162eda..dd42ef63 100644 --- a/src/SelfService/Infrastructure/Api/Capabilities/CapabilityController.cs +++ b/src/SelfService/Infrastructure/Api/Capabilities/CapabilityController.cs @@ -1626,11 +1626,13 @@ public async Task CreateCapabilitiesFromBatch([FromBody] BatchCap if (!CapabilityId.TryCreateFrom(proto.Name!, out var capabilityId)) { - failed.Add(new FailedCapabilityResult - { - Name = proto.Name!, - Errors = new List { $"Unable to derive a capability ID from name \"{proto.Name}\"." }, - }); + failed.Add( + new FailedCapabilityResult + { + Name = proto.Name!, + Errors = new List { $"Unable to derive a capability ID from name \"{proto.Name}\"." }, + } + ); continue; } @@ -1661,9 +1663,7 @@ await _capabilityApplicationService.CreateNewCapability( await _rbacApplicationService.GrantRoleGrant(userId, ownerRoleGrant); } - var membersToAdd = proto - .Members.Append(proto.Owner!) - .Distinct(StringComparer.OrdinalIgnoreCase); + var membersToAdd = proto.Members.Append(proto.Owner!).Distinct(StringComparer.OrdinalIgnoreCase); foreach (var member in membersToAdd) await _membershipApplicationService.JoinCapability(capabilityId, UserId.Parse(member)); @@ -1682,20 +1682,24 @@ await _azureResourceApplicationService.RequestAzureResource( } catch (EntityAlreadyExistsException) { - failed.Add(new FailedCapabilityResult - { - Name = proto.Name!, - Errors = new List { $"A capability with name \"{proto.Name}\" already exists." }, - }); + failed.Add( + new FailedCapabilityResult + { + Name = proto.Name!, + Errors = new List { $"A capability with name \"{proto.Name}\" already exists." }, + } + ); } catch (Exception ex) { _logger.LogError(ex, "Unexpected error creating capability {CapabilityName} in batch", proto.Name); - failed.Add(new FailedCapabilityResult - { - Name = proto.Name!, - Errors = new List { $"Unexpected error: {ex.Message}" }, - }); + failed.Add( + new FailedCapabilityResult + { + Name = proto.Name!, + Errors = new List { $"Unexpected error: {ex.Message}" }, + } + ); } } @@ -1787,8 +1791,7 @@ private static List ValidateProtoCapabilityRequest(ProtoCapabilityReques errors.Add("Tag 'dfds.service.availability' is required when azure resource groups are specified."); if ( - !proto.Tags.TryGetValue("dfds.azure.purpose", out var azPurpose) - || string.IsNullOrWhiteSpace(azPurpose) + !proto.Tags.TryGetValue("dfds.azure.purpose", out var azPurpose) || string.IsNullOrWhiteSpace(azPurpose) ) errors.Add("Tag 'dfds.azure.purpose' is required when azure resource groups are specified."); From 2e793cfcb2a88b8546de09015859a16b15e3db7b Mon Sep 17 00:00:00 2001 From: Andreas Frisch Date: Tue, 9 Jun 2026 12:27:30 +0200 Subject: [PATCH 3/5] Authorized curated list of emails for batch processing --- .../TestDoubles/StubAuthenticationService.cs | 5 ++++ .../Domain/Services/AuthorizationService.cs | 13 ++++++++--- .../Domain/Services/IAuthorizationService.cs | 2 +- .../Api/Capabilities/CapabilityController.cs | 23 ++++++++++++------- 4 files changed, 31 insertions(+), 12 deletions(-) diff --git a/src/SelfService.Tests/TestDoubles/StubAuthenticationService.cs b/src/SelfService.Tests/TestDoubles/StubAuthenticationService.cs index 99a6bbf7..2cb5e564 100644 --- a/src/SelfService.Tests/TestDoubles/StubAuthenticationService.cs +++ b/src/SelfService.Tests/TestDoubles/StubAuthenticationService.cs @@ -288,4 +288,9 @@ public bool CanGetUserEmails(PortalUser portalUser) { return _authorized; } + + public bool CanBatchCreateCapabilities(PortalUser portalUser) + { + return _authorized; + } } diff --git a/src/SelfService/Domain/Services/AuthorizationService.cs b/src/SelfService/Domain/Services/AuthorizationService.cs index cd150970..5b5081e3 100644 --- a/src/SelfService/Domain/Services/AuthorizationService.cs +++ b/src/SelfService/Domain/Services/AuthorizationService.cs @@ -646,16 +646,23 @@ public bool CanBypassMembershipApprovals(PortalUser portalUser) return IsCloudEngineerEnabled(portalUser); } + /* + * This is a temporary solution to allow certain users to create capabilities in batch until we have a proper RBAC solution in place for this. + * User ids are extracted from Azure AD + */ private static readonly IReadOnlySet BatchCapabilityAllowList = new HashSet( StringComparer.OrdinalIgnoreCase ) { - "andfris@dfds.com", + "noesimo@dfds.com", + "jakstr.ptr@partner.dfds.com", + "joakkei@dfds.com", + "jonlars@dfds.com" }; - public bool CanBatchCreateCapabilities(PortalUser portalUser) + public bool CanBatchCreateCapabilities(PortalUser portalUser, UserId userId) { - return IsCloudEngineerEnabled(portalUser) || BatchCapabilityAllowList.Contains(portalUser.Id.ToString()); + return BatchCapabilityAllowList.Contains(userId.ToString()) || IsCloudEngineerEnabled(portalUser); } public async Task CanDeleteMembershipApplication( diff --git a/src/SelfService/Domain/Services/IAuthorizationService.cs b/src/SelfService/Domain/Services/IAuthorizationService.cs index 2b62fb2c..f6f83062 100644 --- a/src/SelfService/Domain/Services/IAuthorizationService.cs +++ b/src/SelfService/Domain/Services/IAuthorizationService.cs @@ -36,7 +36,7 @@ public interface IAuthorizationService Task CanGetCapabilityJsonMetadata(PortalUser portalUser, CapabilityId capabilityId); Task CanSetCapabilityJsonMetadata(PortalUser portalUser, CapabilityId capabilityId); bool CanBypassMembershipApprovals(PortalUser portalUser); - bool CanBatchCreateCapabilities(PortalUser portalUser); + bool CanBatchCreateCapabilities(PortalUser portalUser, UserId userId); Task CanDeleteMembershipApplication( PortalUser portalUser, UserId userId, diff --git a/src/SelfService/Infrastructure/Api/Capabilities/CapabilityController.cs b/src/SelfService/Infrastructure/Api/Capabilities/CapabilityController.cs index dd42ef63..11863ed6 100644 --- a/src/SelfService/Infrastructure/Api/Capabilities/CapabilityController.cs +++ b/src/SelfService/Infrastructure/Api/Capabilities/CapabilityController.cs @@ -1598,17 +1598,18 @@ public async Task GetRequirementScore(string id) [ProducesResponseType(typeof(BatchCapabilityResponse), StatusCodes.Status207MultiStatus)] [ProducesResponseType(typeof(BatchCapabilityResponse), StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized, "application/problem+json")] - public async Task CreateCapabilitiesFromBatch([FromBody] BatchCapabilityRequest request) + public async Task de([FromBody] BatchCapabilityRequest request) { if (!User.TryGetUserId(out var userId)) return Unauthorized(); - if (!_authorizationService.CanBatchCreateCapabilities(HttpContext.User.ToPortalUser())) + var portalUser = HttpContext.User.ToPortalUser(); + if (!_authorizationService.CanBatchCreateCapabilities(portalUser, userId)) return Unauthorized( new ProblemDetails { Title = "User unauthorized", - Detail = "Only cloud engineers can create capabilities in batch.", + Detail = "Only cloud engineers and selected users can create capabilities in batch.", } ); @@ -1617,7 +1618,7 @@ public async Task CreateCapabilitiesFromBatch([FromBody] BatchCap foreach (var proto in request.ProtoCapabilities) { - var errors = ValidateProtoCapabilityRequest(proto); + var errors = await ValidateProtoCapabilityRequest(proto); if (errors.Count > 0) { failed.Add(new FailedCapabilityResult { Name = proto.Name ?? "(unknown)", Errors = errors }); @@ -1738,12 +1739,18 @@ await _azureResourceApplicationService.RequestAzureResource( "other", }; - private static List ValidateProtoCapabilityRequest(ProtoCapabilityRequest proto) + private async Task> ValidateProtoCapabilityRequest(ProtoCapabilityRequest proto) { var errors = new List(); if (string.IsNullOrWhiteSpace(proto.Name)) errors.Add("Name is required."); + else + { + var existingCapabilities = await _capabilityRepository.GetAll(); + if (existingCapabilities.Any(capability => capability.Name.Equals(proto.Name, StringComparison.OrdinalIgnoreCase))) + errors.Add($"A capability with name \"{proto.Name}\" already exists."); + } if (string.IsNullOrWhiteSpace(proto.Owner)) errors.Add("Owner is required."); @@ -1796,10 +1803,10 @@ private static List ValidateProtoCapabilityRequest(ProtoCapabilityReques errors.Add("Tag 'dfds.azure.purpose' is required when azure resource groups are specified."); if ( - !proto.Tags.TryGetValue("dfds.service.capabilities", out var svcCaps) - || string.IsNullOrWhiteSpace(svcCaps) + !proto.Tags.TryGetValue("dfds.businessCapability", out var businessCapability) + || string.IsNullOrWhiteSpace(businessCapability) ) - errors.Add("Tag 'dfds.service.capabilities' is required when azure resource groups are specified."); + errors.Add("Tag 'dfds.businessCapability' is required when azure resource groups are specified."); for (int i = 0; i < proto.AzureResourceGroups.Count; i++) { From 6bcb1d65ac60757d1b0d5f23793ed163d8cfddfc Mon Sep 17 00:00:00 2001 From: Andreas Frisch Date: Tue, 9 Jun 2026 13:14:22 +0200 Subject: [PATCH 4/5] format --- src/SelfService/Domain/Services/AuthorizationService.cs | 2 +- .../Infrastructure/Api/Capabilities/CapabilityController.cs | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/SelfService/Domain/Services/AuthorizationService.cs b/src/SelfService/Domain/Services/AuthorizationService.cs index 5b5081e3..23ff66df 100644 --- a/src/SelfService/Domain/Services/AuthorizationService.cs +++ b/src/SelfService/Domain/Services/AuthorizationService.cs @@ -657,7 +657,7 @@ public bool CanBypassMembershipApprovals(PortalUser portalUser) "noesimo@dfds.com", "jakstr.ptr@partner.dfds.com", "joakkei@dfds.com", - "jonlars@dfds.com" + "jonlars@dfds.com", }; public bool CanBatchCreateCapabilities(PortalUser portalUser, UserId userId) diff --git a/src/SelfService/Infrastructure/Api/Capabilities/CapabilityController.cs b/src/SelfService/Infrastructure/Api/Capabilities/CapabilityController.cs index 11863ed6..a92fc40b 100644 --- a/src/SelfService/Infrastructure/Api/Capabilities/CapabilityController.cs +++ b/src/SelfService/Infrastructure/Api/Capabilities/CapabilityController.cs @@ -1748,7 +1748,11 @@ private async Task> ValidateProtoCapabilityRequest(ProtoCapabilityR else { var existingCapabilities = await _capabilityRepository.GetAll(); - if (existingCapabilities.Any(capability => capability.Name.Equals(proto.Name, StringComparison.OrdinalIgnoreCase))) + if ( + existingCapabilities.Any(capability => + capability.Name.Equals(proto.Name, StringComparison.OrdinalIgnoreCase) + ) + ) errors.Add($"A capability with name \"{proto.Name}\" already exists."); } From 11c17785d7faa2f8a5db674496ab756a1fb8915d Mon Sep 17 00:00:00 2001 From: Andreas Frisch Date: Tue, 9 Jun 2026 13:19:15 +0200 Subject: [PATCH 5/5] fix missing stub --- src/SelfService.Tests/TestDoubles/StubAuthenticationService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SelfService.Tests/TestDoubles/StubAuthenticationService.cs b/src/SelfService.Tests/TestDoubles/StubAuthenticationService.cs index 2cb5e564..f657130d 100644 --- a/src/SelfService.Tests/TestDoubles/StubAuthenticationService.cs +++ b/src/SelfService.Tests/TestDoubles/StubAuthenticationService.cs @@ -289,7 +289,7 @@ public bool CanGetUserEmails(PortalUser portalUser) return _authorized; } - public bool CanBatchCreateCapabilities(PortalUser portalUser) + public bool CanBatchCreateCapabilities(PortalUser portalUser, UserId userId) { return _authorized; }