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:
Matt Batchelder
2026-03-18 10:27:26 -04:00
parent c2e03de8bb
commit c6d46098dd
77 changed files with 9412 additions and 29 deletions

View 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 &lt; 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);