feat: Implement provisioning pipelines for subscription management
- Add ReactivatePipeline to handle subscription reactivation, including scaling Docker services, health verification, status updates, audit logging, and broadcasting status changes. - Introduce RotateCredentialsPipeline for OAuth2 credential rotation, managing the deletion of old apps, creation of new ones, credential storage, access verification, and audit logging. - Create StepRunner to manage job step execution, including lifecycle management and progress broadcasting via SignalR. - Implement SuspendPipeline for subscription suspension, scaling down services, updating statuses, logging audits, and broadcasting changes. - Add UpdateScreenLimitPipeline to update Xibo CMS screen limits and record snapshots. - Introduce XiboFeatureManifests for hardcoded feature ACLs per role. - Add docker-compose.dev.yml for local development with PostgreSQL setup.
This commit is contained in:
248
OTSSignsOrchestrator.Server/Api/CustomerPortalApi.cs
Normal file
248
OTSSignsOrchestrator.Server/Api/CustomerPortalApi.cs
Normal file
@@ -0,0 +1,248 @@
|
||||
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);
|
||||
Reference in New Issue
Block a user