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);

View File

@@ -0,0 +1,245 @@
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using OTSSignsOrchestrator.Server.Data;
using OTSSignsOrchestrator.Server.Data.Entities;
using OTSSignsOrchestrator.Server.Hubs;
using OTSSignsOrchestrator.Server.Reports;
namespace OTSSignsOrchestrator.Server.Api;
public static class FleetApi
{
public static void MapFleetEndpoints(this WebApplication app)
{
var fleet = app.MapGroup("/api/fleet").RequireAuthorization();
fleet.MapGet("/", GetFleetSummary);
fleet.MapGet("/{id:guid}", GetFleetDetail);
var jobs = app.MapGroup("/api/jobs").RequireAuthorization();
jobs.MapPost("/", CreateJob);
jobs.MapGet("/{id:guid}", GetJob);
app.MapGet("/api/health", () => Results.Ok(new { status = "healthy" }));
// ── Report endpoints (admin only) ────────────────────────────────────
var reports = app.MapGroup("/api/reports").RequireAuthorization()
.RequireAuthorization(policy => policy.RequireRole("admin"));
reports.MapGet("/billing", GetBillingCsv);
reports.MapGet("/version-drift", GetVersionDriftCsv);
reports.MapGet("/fleet-health", GetFleetHealthPdf);
reports.MapGet("/customer/{id:guid}/usage", GetCustomerUsagePdf);
fleet.MapPost("/bulk/export-fleet-report", ExportFleetReport)
.RequireAuthorization(policy => policy.RequireRole("admin"));
}
// ── GET /api/fleet ──────────────────────────────────────────────────────
private static async Task<IResult> GetFleetSummary(OrchestratorDbContext db)
{
var customers = await db.Customers
.AsNoTracking()
.Include(c => c.Instances)
.Include(c => c.Jobs)
.ToListAsync();
// Get latest health event per instance in one query
var latestHealth = await db.HealthEvents
.AsNoTracking()
.GroupBy(h => h.InstanceId)
.Select(g => g.OrderByDescending(h => h.OccurredAt).First())
.ToDictionaryAsync(h => h.InstanceId);
var result = customers.Select(c =>
{
var primaryInstance = c.Instances.FirstOrDefault();
HealthEvent? health = null;
if (primaryInstance is not null)
latestHealth.TryGetValue(primaryInstance.Id, out health);
return new FleetSummaryDto
{
CustomerId = c.Id,
Abbreviation = c.Abbreviation,
CompanyName = c.CompanyName,
Plan = c.Plan.ToString(),
ScreenCount = c.ScreenCount,
HealthStatus = health?.Status.ToString() ?? primaryInstance?.HealthStatus.ToString() ?? "Unknown",
LastHealthCheck = health?.OccurredAt ?? primaryInstance?.LastHealthCheck,
HasRunningJob = c.Jobs.Any(j => j.Status == JobStatus.Running || j.Status == JobStatus.Queued),
};
}).ToArray();
return Results.Ok(result);
}
// ── GET /api/fleet/{id} ─────────────────────────────────────────────────
private static async Task<IResult> GetFleetDetail(Guid id, OrchestratorDbContext db)
{
var customer = await db.Customers
.AsNoTracking()
.Include(c => c.Instances)
.Include(c => c.Jobs.Where(j => j.Status == JobStatus.Running || j.Status == JobStatus.Queued))
.FirstOrDefaultAsync(c => c.Id == id);
if (customer is null)
return Results.NotFound();
return Results.Ok(new
{
customer.Id,
customer.Abbreviation,
customer.CompanyName,
customer.AdminEmail,
Plan = customer.Plan.ToString(),
customer.ScreenCount,
Status = customer.Status.ToString(),
customer.CreatedAt,
Instances = customer.Instances.Select(i => new
{
i.Id,
i.XiboUrl,
i.DockerStackName,
HealthStatus = i.HealthStatus.ToString(),
i.LastHealthCheck,
}),
ActiveJobs = customer.Jobs.Select(j => new
{
j.Id,
j.JobType,
Status = j.Status.ToString(),
j.CreatedAt,
j.StartedAt,
}),
});
}
// ── POST /api/jobs ──────────────────────────────────────────────────────
private static async Task<IResult> CreateJob(
CreateJobRequest req,
OrchestratorDbContext db,
IHubContext<FleetHub, IFleetClient> hub,
ILogger<CreateJobRequest> logger)
{
var customer = await db.Customers.FindAsync(req.CustomerId);
if (customer is null)
return Results.NotFound("Customer not found.");
var job = new Job
{
Id = Guid.NewGuid(),
CustomerId = req.CustomerId,
JobType = req.JobType,
Status = JobStatus.Queued,
TriggeredBy = "operator",
Parameters = req.Parameters,
CreatedAt = DateTime.UtcNow,
};
db.Jobs.Add(job);
await db.SaveChangesAsync();
logger.LogInformation("Job created: {JobId} type={JobType} customer={CustomerId}",
job.Id, job.JobType, job.CustomerId);
await hub.Clients.All.SendJobCreated(job.Id.ToString(), customer.Abbreviation, job.JobType);
return Results.Created($"/api/jobs/{job.Id}", new { job.Id, job.JobType, Status = job.Status.ToString() });
}
// ── GET /api/jobs/{id} ──────────────────────────────────────────────────
private static async Task<IResult> GetJob(Guid id, OrchestratorDbContext db)
{
var job = await db.Jobs
.AsNoTracking()
.Include(j => j.Steps.OrderBy(s => s.StartedAt))
.FirstOrDefaultAsync(j => j.Id == id);
if (job is null)
return Results.NotFound();
return Results.Ok(new
{
job.Id,
job.CustomerId,
job.JobType,
Status = job.Status.ToString(),
job.TriggeredBy,
job.Parameters,
job.CreatedAt,
job.StartedAt,
job.CompletedAt,
job.ErrorMessage,
Steps = job.Steps.Select(s => new
{
s.Id,
s.StepName,
Status = s.Status.ToString(),
s.LogOutput,
s.StartedAt,
s.CompletedAt,
}),
});
}
// ── GET /api/reports/billing?from=&to= ──────────────────────────────────
private static async Task<IResult> GetBillingCsv(
DateOnly from, DateOnly to, BillingReportService billing)
{
var csv = await billing.GenerateBillingCsvAsync(from, to);
return Results.File(csv, "text/csv", $"billing-{from:yyyy-MM-dd}-{to:yyyy-MM-dd}.csv");
}
// ── GET /api/reports/version-drift ──────────────────────────────────────
private static async Task<IResult> GetVersionDriftCsv(BillingReportService billing)
{
var csv = await billing.GenerateVersionDriftCsvAsync();
return Results.File(csv, "text/csv", $"version-drift-{DateTime.UtcNow:yyyy-MM-dd}.csv");
}
// ── GET /api/reports/fleet-health?from=&to= ─────────────────────────────
private static async Task<IResult> GetFleetHealthPdf(
DateOnly from, DateOnly to, FleetHealthPdfService pdfService)
{
var pdf = await pdfService.GenerateFleetHealthPdfAsync(from, to);
return Results.File(pdf, "application/pdf", $"fleet-health-{from:yyyy-MM-dd}-{to:yyyy-MM-dd}.pdf");
}
// ── GET /api/reports/customer/{id}/usage?from=&to= ──────────────────────
private static async Task<IResult> GetCustomerUsagePdf(
Guid id, DateOnly from, DateOnly to, FleetHealthPdfService pdfService)
{
try
{
var pdf = await pdfService.GenerateCustomerUsagePdfAsync(id, from, to);
return Results.File(pdf, "application/pdf", $"customer-usage-{id}-{from:yyyy-MM-dd}-{to:yyyy-MM-dd}.pdf");
}
catch (InvalidOperationException ex)
{
return Results.NotFound(ex.Message);
}
}
// ── POST /api/fleet/bulk/export-fleet-report ────────────────────────────
private static async Task<IResult> ExportFleetReport(FleetHealthPdfService pdfService)
{
var to = DateOnly.FromDateTime(DateTime.UtcNow);
var from = to.AddDays(-7);
var pdf = await pdfService.GenerateFleetHealthPdfAsync(from, to);
return Results.File(pdf, "application/pdf", $"fleet-health-{from:yyyy-MM-dd}-{to:yyyy-MM-dd}.pdf");
}
}
public record FleetSummaryDto
{
public Guid CustomerId { get; init; }
public string Abbreviation { get; init; } = string.Empty;
public string CompanyName { get; init; } = string.Empty;
public string Plan { get; init; } = string.Empty;
public int ScreenCount { get; init; }
public string HealthStatus { get; init; } = "Unknown";
public DateTime? LastHealthCheck { get; init; }
public bool HasRunningJob { get; init; }
}
public record CreateJobRequest(Guid CustomerId, string JobType, string? Parameters);

View File

@@ -0,0 +1,173 @@
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using OTSSignsOrchestrator.Server.Data;
using OTSSignsOrchestrator.Server.Data.Entities;
using OTSSignsOrchestrator.Server.Hubs;
using Stripe.Checkout;
namespace OTSSignsOrchestrator.Server.Api;
public static class SignupApi
{
public static void MapSignupEndpoints(this WebApplication app)
{
app.MapPost("/api/signup/initiate", HandleInitiate)
.RequireRateLimiting("signup");
app.MapGet("/api/signup/status/{token:guid}", HandleStatus);
}
private static async Task<IResult> HandleInitiate(
SignupRequest req,
OrchestratorDbContext db,
IConfiguration config,
ILogger<SignupRequest> logger)
{
// ── Validation ──────────────────────────────────────────────────────
var errors = new List<string>();
if (string.IsNullOrWhiteSpace(req.CompanyName))
errors.Add("companyName is required.");
if (string.IsNullOrWhiteSpace(req.AdminEmail) || !new EmailAddressAttribute().IsValid(req.AdminEmail))
errors.Add("A valid adminEmail is required.");
if (string.IsNullOrWhiteSpace(req.Plan) ||
!req.Plan.Equals("Essentials", StringComparison.OrdinalIgnoreCase) &&
!req.Plan.Equals("Pro", StringComparison.OrdinalIgnoreCase))
errors.Add("plan must be 'Essentials' or 'Pro'.");
if (req.ScreenCount < 1)
errors.Add("screenCount must be at least 1.");
if (req.Plan?.Equals("Essentials", StringComparison.OrdinalIgnoreCase) == true && req.ScreenCount > 50)
errors.Add("Essentials plan supports a maximum of 50 screens.");
if (string.IsNullOrWhiteSpace(req.BillingFrequency) ||
!req.BillingFrequency.Equals("monthly", StringComparison.OrdinalIgnoreCase) &&
!req.BillingFrequency.Equals("annual", StringComparison.OrdinalIgnoreCase))
errors.Add("billingFrequency must be 'monthly' or 'annual'.");
if (errors.Count > 0)
return Results.ValidationProblem(
errors.ToDictionary(e => e, _ => new[] { "Validation failed." }));
// ── Create pending customer ─────────────────────────────────────────
var plan = Enum.Parse<CustomerPlan>(req.Plan!, true);
var customer = new Customer
{
Id = Guid.NewGuid(),
CompanyName = req.CompanyName!.Trim(),
AdminEmail = req.AdminEmail!.Trim().ToLowerInvariant(),
AdminFirstName = req.AdminFirstName?.Trim() ?? string.Empty,
AdminLastName = req.AdminLastName?.Trim() ?? string.Empty,
Plan = plan,
ScreenCount = req.ScreenCount,
Status = CustomerStatus.PendingPayment,
CreatedAt = DateTime.UtcNow,
};
db.Customers.Add(customer);
await db.SaveChangesAsync();
// ── Stripe Checkout Session ─────────────────────────────────────────
var priceKey = $"Stripe:Prices:{req.Plan}:{req.BillingFrequency}".ToLowerInvariant();
var priceId = config[priceKey];
if (string.IsNullOrWhiteSpace(priceId))
{
logger.LogError("Stripe price ID not configured for key {PriceKey}", priceKey);
return Results.Problem("Billing configuration error. Contact support.", statusCode: 500);
}
var sessionOptions = new SessionCreateOptions
{
Mode = "subscription",
CustomerEmail = customer.AdminEmail,
LineItems = new List<SessionLineItemOptions>
{
new()
{
Price = priceId,
Quantity = req.ScreenCount,
},
},
SubscriptionData = new SessionSubscriptionDataOptions
{
TrialPeriodDays = 14,
},
Metadata = new Dictionary<string, string>
{
["ots_customer_id"] = customer.Id.ToString(),
["company_name"] = customer.CompanyName,
["admin_email"] = customer.AdminEmail,
["admin_first_name"] = customer.AdminFirstName,
["admin_last_name"] = customer.AdminLastName,
["plan"] = req.Plan!,
["screen_count"] = req.ScreenCount.ToString(),
["billing_frequency"] = req.BillingFrequency!,
},
SuccessUrl = config["Stripe:SuccessUrl"] ?? "https://app.ots-signs.com/signup/success?session_id={CHECKOUT_SESSION_ID}",
CancelUrl = config["Stripe:CancelUrl"] ?? "https://app.ots-signs.com/signup/cancel",
};
var sessionService = new SessionService();
var session = await sessionService.CreateAsync(sessionOptions);
customer.StripeCheckoutSessionId = session.Id;
await db.SaveChangesAsync();
logger.LogInformation(
"Signup initiated: customer={CustomerId}, company={Company}, plan={Plan}, screens={Screens}",
customer.Id, customer.CompanyName, req.Plan, req.ScreenCount);
return Results.Ok(new { checkoutUrl = session.Url, statusToken = customer.Id });
}
private static async Task<IResult> HandleStatus(
Guid token,
OrchestratorDbContext db)
{
var customer = await db.Customers
.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == token);
if (customer is null)
return Results.NotFound();
// Find latest provisioning job if any
var job = await db.Jobs
.AsNoTracking()
.Where(j => j.CustomerId == customer.Id && j.JobType == "provision")
.OrderByDescending(j => j.CreatedAt)
.FirstOrDefaultAsync();
int pctComplete = customer.Status switch
{
CustomerStatus.PendingPayment => 0,
CustomerStatus.Provisioning => job?.Status switch
{
JobStatus.Running => 50,
JobStatus.Completed => 100,
_ => 10,
},
CustomerStatus.Active => 100,
_ => 0,
};
return Results.Ok(new
{
status = customer.Status.ToString(),
provisioningStep = job?.Steps
.Where(s => s.Status == JobStepStatus.Running)
.Select(s => s.StepName)
.FirstOrDefault() ?? (customer.Status == CustomerStatus.Active ? "complete" : "waiting"),
pctComplete,
});
}
}
public record SignupRequest(
string? CompanyName,
string? AdminFirstName,
string? AdminLastName,
string? AdminEmail,
string? Phone,
string? Plan,
int ScreenCount,
string? BillingFrequency,
string? PromoCode);