Files
OTSSignsOrchestrator/OTSSignsOrchestrator.Server/Workers/ByoiSamlPipeline.cs
Matt Batchelder c6d46098dd 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.
2026-03-18 10:27:26 -04:00

208 lines
9.2 KiB
C#

using System.Security.Cryptography.X509Certificates;
using System.Text.Json;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Quartz;
using OTSSignsOrchestrator.Server.Clients;
using OTSSignsOrchestrator.Server.Data;
using OTSSignsOrchestrator.Server.Data.Entities;
using OTSSignsOrchestrator.Server.Hubs;
using OTSSignsOrchestrator.Server.Jobs;
namespace OTSSignsOrchestrator.Server.Workers;
/// <summary>
/// BYOI SAML provisioning pipeline — configures an upstream SAML source in Authentik
/// so a Pro-tier customer can federate their own Identity Provider.
///
/// Handles <c>JobType = "provision-byoi"</c>.
///
/// Steps:
/// 1. import-cert — Import the customer's public cert PEM into Authentik
/// 2. create-saml-source — Create the upstream SAML source in Authentik
/// 3. store-metadata — Persist slug, entity_id, sso_url, cert info to ByoiConfig
/// 4. schedule-cert-monitor — Register a Quartz ByoiCertExpiryJob for daily cert expiry checks
///
/// IMPORTANT: Xibo's settings-custom.php is IDENTICAL for Pro BYOI and Essentials.
/// Xibo always authenticates through Authentik. BYOI is entirely handled within
/// Authentik's upstream SAML Source config — no Xibo changes are needed.
/// </summary>
public sealed class ByoiSamlPipeline : IProvisioningPipeline
{
public string HandlesJobType => "provision-byoi";
private const int TotalSteps = 4;
private readonly IServiceProvider _services;
private readonly ILogger<ByoiSamlPipeline> _logger;
public ByoiSamlPipeline(
IServiceProvider services,
ILogger<ByoiSamlPipeline> logger)
{
_services = services;
_logger = logger;
}
public async Task ExecuteAsync(Job job, CancellationToken ct)
{
await using var scope = _services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<OrchestratorDbContext>();
var hub = scope.ServiceProvider.GetRequiredService<IHubContext<FleetHub, IFleetClient>>();
var authentikClient = scope.ServiceProvider.GetRequiredService<IAuthentikClient>();
var authentikOpts = scope.ServiceProvider.GetRequiredService<IOptions<AuthentikOptions>>().Value;
var schedulerFactory = scope.ServiceProvider.GetRequiredService<ISchedulerFactory>();
// Load customer + instance
var customer = await db.Customers
.Include(c => c.Instances)
.FirstOrDefaultAsync(c => c.Id == job.CustomerId, ct)
?? throw new InvalidOperationException($"Customer {job.CustomerId} not found.");
if (customer.Plan != CustomerPlan.Pro)
throw new InvalidOperationException("BYOI SAML is only available for Pro tier customers.");
var instance = customer.Instances.FirstOrDefault()
?? throw new InvalidOperationException($"No instance found for customer {job.CustomerId}.");
var abbrev = customer.Abbreviation;
var companyName = customer.CompanyName;
// Parse BYOI parameters from Job.Parameters JSON
var parms = JsonSerializer.Deserialize<ByoiParameters>(
job.Parameters ?? throw new InvalidOperationException("Job.Parameters is null."),
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
?? throw new InvalidOperationException("Failed to deserialize BYOI parameters.");
var runner = new StepRunner(db, hub, _logger, job.Id, TotalSteps);
// Mutable state accumulated across steps
string? verificationKpId = null;
string slug = $"byoi-{abbrev}";
DateTime certExpiry;
// Parse the cert up front to get expiry and validate
using var cert = X509CertificateLoader.LoadCertificate(
Convert.FromBase64String(ExtractBase64FromPem(parms.CustomerCertPem)));
certExpiry = cert.NotAfter.ToUniversalTime();
// ── Step 1: import-cert ─────────────────────────────────────────────
await runner.RunAsync("import-cert", async () =>
{
var response = await authentikClient.ImportCertificateAsync(new ImportCertRequest(
Name: $"byoi-{abbrev}-cert",
CertificateData: parms.CustomerCertPem,
KeyData: null)); // PUBLIC cert only — no private key
if (!response.IsSuccessStatusCode || response.Content is null)
throw new InvalidOperationException(
$"Failed to import certificate: {response.Error?.Content ?? response.ReasonPhrase}");
verificationKpId = response.Content["pk"]?.ToString()
?? throw new InvalidOperationException("Authentik cert import returned no pk.");
return $"Imported customer cert as keypair '{verificationKpId}'.";
}, ct);
// ── Step 2: create-saml-source ──────────────────────────────────────
await runner.RunAsync("create-saml-source", async () =>
{
var response = await authentikClient.CreateSamlSourceAsync(new CreateSamlSourceRequest(
Name: $"{companyName} IdP",
Slug: slug,
SsoUrl: parms.CustomerSsoUrl,
SloUrl: parms.CustomerSloUrl,
Issuer: parms.CustomerIdpEntityId,
SigningKp: string.IsNullOrWhiteSpace(authentikOpts.OtsSigningKpId) ? null : authentikOpts.OtsSigningKpId,
VerificationKp: verificationKpId,
BindingType: "redirect",
NameIdPolicy: "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
PreAuthenticationFlow: authentikOpts.SourcePreAuthFlowSlug,
AuthenticationFlow: authentikOpts.SourceAuthFlowSlug,
AllowIdpInitiated: false)); // REQUIRED: IdP-initiated SSO is a CSRF risk
if (!response.IsSuccessStatusCode || response.Content is null)
throw new InvalidOperationException(
$"Failed to create SAML source: {response.Error?.Content ?? response.ReasonPhrase}");
return $"SAML source '{slug}' created with AllowIdpInitiated=false.";
}, ct);
// ── Step 3: store-metadata ──────────────────────────────────────────
await runner.RunAsync("store-metadata", async () =>
{
var byoiConfig = new ByoiConfig
{
Id = Guid.NewGuid(),
InstanceId = instance.Id,
Slug = slug,
EntityId = parms.CustomerIdpEntityId,
SsoUrl = parms.CustomerSsoUrl,
CertPem = parms.CustomerCertPem,
CertExpiry = certExpiry,
Enabled = true,
CreatedAt = DateTime.UtcNow,
};
db.ByoiConfigs.Add(byoiConfig);
await db.SaveChangesAsync(ct);
return $"ByoiConfig stored: slug={slug}, certExpiry={certExpiry:O}.";
}, ct);
// ── Step 4: schedule-cert-monitor ───────────────────────────────────
await runner.RunAsync("schedule-cert-monitor", async () =>
{
var scheduler = await schedulerFactory.GetScheduler(ct);
var jobKey = new JobKey($"byoi-cert-expiry-{abbrev}", "byoi-cert-expiry");
// Only schedule if not already present (idempotent)
if (!await scheduler.CheckExists(jobKey, ct))
{
var quartzJob = JobBuilder.Create<ByoiCertExpiryJob>()
.WithIdentity(jobKey)
.UsingJobData("instanceId", instance.Id.ToString())
.Build();
var trigger = TriggerBuilder.Create()
.WithIdentity($"byoi-cert-expiry-{abbrev}-trigger", "byoi-cert-expiry")
.WithDailyTimeIntervalSchedule(s => s
.OnEveryDay()
.StartingDailyAt(TimeOfDay.HourAndMinuteOfDay(6, 0))
.WithRepeatCount(1))
.StartNow()
.Build();
await scheduler.ScheduleJob(quartzJob, trigger, ct);
}
return $"Quartz ByoiCertExpiryJob scheduled daily for instance {instance.Id}.";
}, ct);
}
/// <summary>
/// Extracts the Base64 payload from a PEM string, stripping headers/footers.
/// </summary>
private static string ExtractBase64FromPem(string pem)
{
return pem
.Replace("-----BEGIN CERTIFICATE-----", "")
.Replace("-----END CERTIFICATE-----", "")
.Replace("\r", "")
.Replace("\n", "")
.Trim();
}
}
/// <summary>
/// Deserialized from Job.Parameters JSON for provision-byoi jobs.
/// </summary>
public sealed record ByoiParameters
{
public string CustomerCertPem { get; init; } = string.Empty;
public string CustomerSsoUrl { get; init; } = string.Empty;
public string CustomerIdpEntityId { get; init; } = string.Empty;
public string? CustomerSloUrl { get; init; }
}