249 lines
9.9 KiB
C#
249 lines
9.9 KiB
C#
|
|
using System.Security.Cryptography.X509Certificates;
|
||
|
|
using System.Text.Json;
|
||
|
|
using Microsoft.AspNetCore.SignalR;
|
||
|
|
using Microsoft.EntityFrameworkCore;
|
||
|
|
using OTSSignsOrchestrator.Server.Clients;
|
||
|
|
using OTSSignsOrchestrator.Server.Data;
|
||
|
|
using OTSSignsOrchestrator.Server.Data.Entities;
|
||
|
|
using OTSSignsOrchestrator.Server.Hubs;
|
||
|
|
using OTSSignsOrchestrator.Server.Workers;
|
||
|
|
|
||
|
|
namespace OTSSignsOrchestrator.Server.Api;
|
||
|
|
|
||
|
|
public static class CustomerPortalApi
|
||
|
|
{
|
||
|
|
private const int MinCertDaysRemaining = 30;
|
||
|
|
|
||
|
|
public static void MapCustomerPortalEndpoints(this WebApplication app)
|
||
|
|
{
|
||
|
|
var portal = app.MapGroup("/api/portal/byoi")
|
||
|
|
.RequireAuthorization("CustomerPortal");
|
||
|
|
|
||
|
|
portal.MapPost("/configure", HandleConfigureByoi);
|
||
|
|
portal.MapGet("/sp-metadata", HandleGetSpMetadata);
|
||
|
|
portal.MapPost("/rotate-cert", HandleRotateCert);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── POST /api/portal/byoi/configure ─────────────────────────────────────
|
||
|
|
private static async Task<IResult> HandleConfigureByoi(
|
||
|
|
ConfigureByoiRequest req,
|
||
|
|
OrchestratorDbContext db,
|
||
|
|
IHubContext<FleetHub, IFleetClient> hub,
|
||
|
|
HttpContext httpContext,
|
||
|
|
ILogger<ConfigureByoiRequest> logger)
|
||
|
|
{
|
||
|
|
// Resolve customer from the authenticated JWT
|
||
|
|
var customer = await ResolveCustomerAsync(httpContext, db);
|
||
|
|
if (customer is null)
|
||
|
|
return Results.Forbid();
|
||
|
|
|
||
|
|
if (customer.Plan != CustomerPlan.Pro)
|
||
|
|
return Results.Problem("BYOI SAML is only available for Pro tier customers.", statusCode: 403);
|
||
|
|
|
||
|
|
// Validate cert PEM
|
||
|
|
var certValidation = ValidateCertPem(req.CertPem);
|
||
|
|
if (certValidation is not null)
|
||
|
|
return Results.ValidationProblem(
|
||
|
|
new Dictionary<string, string[]> { ["certPem"] = [certValidation] });
|
||
|
|
|
||
|
|
// Validate required fields
|
||
|
|
var errors = new Dictionary<string, string[]>();
|
||
|
|
if (string.IsNullOrWhiteSpace(req.SsoUrl))
|
||
|
|
errors["ssoUrl"] = ["ssoUrl is required."];
|
||
|
|
if (string.IsNullOrWhiteSpace(req.IdpEntityId))
|
||
|
|
errors["idpEntityId"] = ["idpEntityId is required."];
|
||
|
|
if (errors.Count > 0)
|
||
|
|
return Results.ValidationProblem(errors);
|
||
|
|
|
||
|
|
// Create a provision-byoi Job
|
||
|
|
var parametersJson = JsonSerializer.Serialize(new ByoiParameters
|
||
|
|
{
|
||
|
|
CustomerCertPem = req.CertPem!,
|
||
|
|
CustomerSsoUrl = req.SsoUrl!,
|
||
|
|
CustomerIdpEntityId = req.IdpEntityId!,
|
||
|
|
CustomerSloUrl = req.SloUrl,
|
||
|
|
});
|
||
|
|
|
||
|
|
var job = new Job
|
||
|
|
{
|
||
|
|
Id = Guid.NewGuid(),
|
||
|
|
CustomerId = customer.Id,
|
||
|
|
JobType = "provision-byoi",
|
||
|
|
Status = JobStatus.Queued,
|
||
|
|
TriggeredBy = $"customer-portal:{customer.AdminEmail}",
|
||
|
|
Parameters = parametersJson,
|
||
|
|
CreatedAt = DateTime.UtcNow,
|
||
|
|
};
|
||
|
|
|
||
|
|
db.Jobs.Add(job);
|
||
|
|
await db.SaveChangesAsync();
|
||
|
|
|
||
|
|
logger.LogInformation("BYOI configure job {JobId} created for customer {CustomerId}",
|
||
|
|
job.Id, customer.Id);
|
||
|
|
|
||
|
|
await hub.Clients.All.SendJobCreated(
|
||
|
|
job.Id.ToString(), customer.Abbreviation, job.JobType);
|
||
|
|
|
||
|
|
return Results.Created($"/api/jobs/{job.Id}", new { jobId = job.Id });
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── GET /api/portal/byoi/sp-metadata ────────────────────────────────────
|
||
|
|
private static async Task<IResult> HandleGetSpMetadata(
|
||
|
|
OrchestratorDbContext db,
|
||
|
|
IAuthentikClient authentikClient,
|
||
|
|
HttpContext httpContext,
|
||
|
|
ILogger<ConfigureByoiRequest> logger)
|
||
|
|
{
|
||
|
|
var customer = await ResolveCustomerAsync(httpContext, db);
|
||
|
|
if (customer is null)
|
||
|
|
return Results.Forbid();
|
||
|
|
|
||
|
|
var instance = customer.Instances.FirstOrDefault();
|
||
|
|
if (instance is null)
|
||
|
|
return Results.NotFound("No instance found for this customer.");
|
||
|
|
|
||
|
|
var byoiConfig = await db.ByoiConfigs
|
||
|
|
.AsNoTracking()
|
||
|
|
.FirstOrDefaultAsync(b => b.InstanceId == instance.Id && b.Enabled);
|
||
|
|
|
||
|
|
if (byoiConfig is null)
|
||
|
|
return Results.NotFound("No BYOI configuration found for this instance.");
|
||
|
|
|
||
|
|
var metadataResponse = await authentikClient.GetSamlSourceMetadataAsync(byoiConfig.Slug);
|
||
|
|
if (!metadataResponse.IsSuccessStatusCode || metadataResponse.Content is null)
|
||
|
|
{
|
||
|
|
logger.LogError("Failed to fetch SP metadata for slug {Slug}: {Error}",
|
||
|
|
byoiConfig.Slug, metadataResponse.Error?.Content ?? metadataResponse.ReasonPhrase);
|
||
|
|
return Results.Problem("Failed to retrieve SP metadata from Authentik.", statusCode: 502);
|
||
|
|
}
|
||
|
|
|
||
|
|
return Results.Content(metadataResponse.Content, "application/xml");
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── POST /api/portal/byoi/rotate-cert ───────────────────────────────────
|
||
|
|
private static async Task<IResult> HandleRotateCert(
|
||
|
|
RotateCertRequest req,
|
||
|
|
OrchestratorDbContext db,
|
||
|
|
IHubContext<FleetHub, IFleetClient> hub,
|
||
|
|
HttpContext httpContext,
|
||
|
|
ILogger<RotateCertRequest> logger)
|
||
|
|
{
|
||
|
|
var customer = await ResolveCustomerAsync(httpContext, db);
|
||
|
|
if (customer is null)
|
||
|
|
return Results.Forbid();
|
||
|
|
|
||
|
|
if (customer.Plan != CustomerPlan.Pro)
|
||
|
|
return Results.Problem("BYOI SAML is only available for Pro tier customers.", statusCode: 403);
|
||
|
|
|
||
|
|
// Validate cert PEM
|
||
|
|
var certValidation = ValidateCertPem(req.CertPem);
|
||
|
|
if (certValidation is not null)
|
||
|
|
return Results.ValidationProblem(
|
||
|
|
new Dictionary<string, string[]> { ["certPem"] = [certValidation] });
|
||
|
|
|
||
|
|
var instance = customer.Instances.FirstOrDefault();
|
||
|
|
if (instance is null)
|
||
|
|
return Results.NotFound("No instance found for this customer.");
|
||
|
|
|
||
|
|
var existingConfig = await db.ByoiConfigs
|
||
|
|
.FirstOrDefaultAsync(b => b.InstanceId == instance.Id && b.Enabled);
|
||
|
|
|
||
|
|
if (existingConfig is null)
|
||
|
|
return Results.NotFound("No active BYOI configuration found to rotate.");
|
||
|
|
|
||
|
|
// Create a re-provisioning job with the new cert
|
||
|
|
var parametersJson = JsonSerializer.Serialize(new ByoiParameters
|
||
|
|
{
|
||
|
|
CustomerCertPem = req.CertPem!,
|
||
|
|
CustomerSsoUrl = existingConfig.SsoUrl,
|
||
|
|
CustomerIdpEntityId = existingConfig.EntityId,
|
||
|
|
CustomerSloUrl = null,
|
||
|
|
});
|
||
|
|
|
||
|
|
var job = new Job
|
||
|
|
{
|
||
|
|
Id = Guid.NewGuid(),
|
||
|
|
CustomerId = customer.Id,
|
||
|
|
JobType = "provision-byoi",
|
||
|
|
Status = JobStatus.Queued,
|
||
|
|
TriggeredBy = $"customer-portal:cert-rotate:{customer.AdminEmail}",
|
||
|
|
Parameters = parametersJson,
|
||
|
|
CreatedAt = DateTime.UtcNow,
|
||
|
|
};
|
||
|
|
|
||
|
|
db.Jobs.Add(job);
|
||
|
|
await db.SaveChangesAsync();
|
||
|
|
|
||
|
|
logger.LogInformation("BYOI cert rotate job {JobId} created for customer {CustomerId}",
|
||
|
|
job.Id, customer.Id);
|
||
|
|
|
||
|
|
await hub.Clients.All.SendJobCreated(
|
||
|
|
job.Id.ToString(), customer.Abbreviation, job.JobType);
|
||
|
|
|
||
|
|
return Results.Ok(new { jobId = job.Id });
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Helpers ─────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Validates a PEM certificate string. Returns an error message on failure, or null if valid.
|
||
|
|
/// Rejects self-signed, expired, and certs expiring in < 30 days.
|
||
|
|
/// </summary>
|
||
|
|
private static string? ValidateCertPem(string? certPem)
|
||
|
|
{
|
||
|
|
if (string.IsNullOrWhiteSpace(certPem))
|
||
|
|
return "certPem is required.";
|
||
|
|
|
||
|
|
X509Certificate2 cert;
|
||
|
|
try
|
||
|
|
{
|
||
|
|
var base64 = certPem
|
||
|
|
.Replace("-----BEGIN CERTIFICATE-----", "")
|
||
|
|
.Replace("-----END CERTIFICATE-----", "")
|
||
|
|
.Replace("\r", "")
|
||
|
|
.Replace("\n", "")
|
||
|
|
.Trim();
|
||
|
|
|
||
|
|
cert = X509CertificateLoader.LoadCertificate(Convert.FromBase64String(base64));
|
||
|
|
}
|
||
|
|
catch (Exception)
|
||
|
|
{
|
||
|
|
return "Invalid certificate PEM format.";
|
||
|
|
}
|
||
|
|
|
||
|
|
using (cert)
|
||
|
|
{
|
||
|
|
if (cert.NotAfter.ToUniversalTime() < DateTime.UtcNow)
|
||
|
|
return "Certificate has already expired.";
|
||
|
|
|
||
|
|
if ((cert.NotAfter.ToUniversalTime() - DateTime.UtcNow).TotalDays < MinCertDaysRemaining)
|
||
|
|
return $"Certificate expires in less than {MinCertDaysRemaining} days. Provide a certificate with a longer validity period.";
|
||
|
|
|
||
|
|
// Reject self-signed: issuer == subject
|
||
|
|
if (string.Equals(cert.Issuer, cert.Subject, StringComparison.OrdinalIgnoreCase))
|
||
|
|
return "Self-signed certificates are not accepted. Provide a CA-signed certificate.";
|
||
|
|
}
|
||
|
|
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Resolves the current customer from the authenticated JWT claims.
|
||
|
|
/// Expects a "customer_id" claim in the token.
|
||
|
|
/// </summary>
|
||
|
|
private static async Task<Customer?> ResolveCustomerAsync(HttpContext httpContext, OrchestratorDbContext db)
|
||
|
|
{
|
||
|
|
var customerIdClaim = httpContext.User.FindFirst("customer_id")?.Value;
|
||
|
|
if (customerIdClaim is null || !Guid.TryParse(customerIdClaim, out var customerId))
|
||
|
|
return null;
|
||
|
|
|
||
|
|
return await db.Customers
|
||
|
|
.Include(c => c.Instances)
|
||
|
|
.FirstOrDefaultAsync(c => c.Id == customerId);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Request DTOs ────────────────────────────────────────────────────────────
|
||
|
|
public record ConfigureByoiRequest(string? CertPem, string? SsoUrl, string? IdpEntityId, string? SloUrl);
|
||
|
|
public record RotateCertRequest(string? CertPem);
|