Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -288,4 +288,9 @@ public bool CanGetUserEmails(PortalUser portalUser)
{
return _authorized;
}

public bool CanBatchCreateCapabilities(PortalUser portalUser, UserId userId)
{
return _authorized;
}
}
19 changes: 19 additions & 0 deletions src/SelfService/Domain/Services/AuthorizationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> BatchCapabilityAllowList = new HashSet<string>(
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<bool> CanDeleteMembershipApplication(
PortalUser portalUser,
UserId userId,
Expand Down
1 change: 1 addition & 0 deletions src/SelfService/Domain/Services/IAuthorizationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ public interface IAuthorizationService
Task<bool> CanGetCapabilityJsonMetadata(PortalUser portalUser, CapabilityId capabilityId);
Task<bool> CanSetCapabilityJsonMetadata(PortalUser portalUser, CapabilityId capabilityId);
bool CanBypassMembershipApprovals(PortalUser portalUser);
bool CanBatchCreateCapabilities(PortalUser portalUser, UserId userId);
Task<bool> CanDeleteMembershipApplication(
PortalUser portalUser,
UserId userId,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
using System.ComponentModel.DataAnnotations;

namespace SelfService.Infrastructure.Api.Capabilities;

/// <summary>
/// Request body for batch capability creation.
/// </summary>
public class BatchCapabilityRequest
{
/// <summary>List of proto-capabilities to create.</summary>
[Required]
public List<ProtoCapabilityRequest> ProtoCapabilities { get; set; } = new();
}

/// <summary>
/// Describes a single capability to create as part of a batch operation.
/// </summary>
public class ProtoCapabilityRequest
{
/// <summary>The name of the capability. Used to derive the capability ID.</summary>
[Required]
public string? Name { get; set; }

/// <summary>Optional description of the capability.</summary>
public string? Description { get; set; }

/// <summary>The @dfds.com email address of the capability owner. Will be granted the Owner role.</summary>
[Required]
public string? Owner { get; set; }

/// <summary>
/// @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.
/// </summary>
public List<string> Members { get; set; } = new();

/// <summary>
/// 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'.
/// </summary>
[Required]
public Dictionary<string, string> Tags { get; set; } = new();

/// <summary>Azure resource groups to request for this capability. Optional.</summary>
public List<ProtoAzureResourceGroupRequest> AzureResourceGroups { get; set; } = new();
}

/// <summary>Describes an Azure resource group to request for a capability.</summary>
public class ProtoAzureResourceGroupRequest
{
/// <summary>The target environment (e.g. "prod", "dev").</summary>
[Required]
public string? Environment { get; set; }

/// <summary>
/// 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.
/// </summary>
[Required]
public string? Purpose { get; set; }

/// <summary>Required when Purpose is 'ai'.</summary>
public string? CatalogueId { get; set; }

/// <summary>Required when Purpose is 'ai'.</summary>
public string? Risk { get; set; }

/// <summary>Required when Purpose is 'ai'.</summary>
public bool? Gdpr { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
namespace SelfService.Infrastructure.Api.Capabilities;

/// <summary>Result of a batch capability creation request.</summary>
public class BatchCapabilityResponse
{
/// <summary>Capabilities that were successfully created.</summary>
public List<CreatedCapabilityResult> Created { get; set; } = new();

/// <summary>Proto-capabilities that failed validation or could not be created, with error details.</summary>
public List<FailedCapabilityResult> Failed { get; set; } = new();
}

/// <summary>A capability that was successfully created.</summary>
public class CreatedCapabilityResult
{
public string CapabilityId { get; set; } = "";
public string Name { get; set; } = "";
}

/// <summary>A proto-capability that could not be created.</summary>
public class FailedCapabilityResult
{
public string Name { get; set; } = "";
public List<string> Errors { get; set; } = new();
}
Original file line number Diff line number Diff line change
Expand Up @@ -1587,4 +1587,256 @@ public async Task<IActionResult> GetRequirementScore(string id)
var resource = RequirementsMetricConverter.Convert(id, totalScore, scores);
return Ok(resource);
}

/// <summary>
/// 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.
/// </summary>
[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<IActionResult> 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<CreatedCapabilityResult>();
var failed = new List<FailedCapabilityResult>();

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<string> { $"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<string> { $"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<string> { $"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<string> 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<string> LegalServiceAvailabilities = new[] { "low", "medium", "high" };

private static readonly IReadOnlyList<string> LegalAzurePurposes = new[]
{
"toolaccess",
"ai",
"thirdpartylimitations",
"thirdpartylimitations → partnerpackagelimitation",
"other",
};

private async Task<List<string>> ValidateProtoCapabilityRequest(ProtoCapabilityRequest proto)
{
var errors = new List<string>();

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;
}
}
Loading