diff --git a/src/SelfService.Tests/TestDoubles/StubAuthenticationService.cs b/src/SelfService.Tests/TestDoubles/StubAuthenticationService.cs index 99a6bbf7..f657130d 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, UserId userId) + { + return _authorized; + } } diff --git a/src/SelfService/Domain/Services/AuthorizationService.cs b/src/SelfService/Domain/Services/AuthorizationService.cs index 25afe470..23ff66df 100644 --- a/src/SelfService/Domain/Services/AuthorizationService.cs +++ b/src/SelfService/Domain/Services/AuthorizationService.cs @@ -646,6 +646,25 @@ 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 + ) + { + "noesimo@dfds.com", + "jakstr.ptr@partner.dfds.com", + "joakkei@dfds.com", + "jonlars@dfds.com", + }; + + public bool CanBatchCreateCapabilities(PortalUser portalUser, UserId userId) + { + return BatchCapabilityAllowList.Contains(userId.ToString()) || IsCloudEngineerEnabled(portalUser); + } + 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..f6f83062 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, UserId userId); 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..a92fc40b 100644 --- a/src/SelfService/Infrastructure/Api/Capabilities/CapabilityController.cs +++ b/src/SelfService/Infrastructure/Api/Capabilities/CapabilityController.cs @@ -1587,4 +1587,256 @@ 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 de([FromBody] BatchCapabilityRequest request) + { + if (!User.TryGetUserId(out var userId)) + return Unauthorized(); + + var portalUser = HttpContext.User.ToPortalUser(); + if (!_authorizationService.CanBatchCreateCapabilities(portalUser, userId)) + return Unauthorized( + new ProblemDetails + { + Title = "User unauthorized", + Detail = "Only cloud engineers and selected users can create capabilities in batch.", + } + ); + + var created = new List(); + var failed = new List(); + + foreach (var proto in request.ProtoCapabilities) + { + var errors = await 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 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."); + 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.businessCapability", out var businessCapability) + || string.IsNullOrWhiteSpace(businessCapability) + ) + errors.Add("Tag 'dfds.businessCapability' 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; + } }