- 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.
208 lines
9.2 KiB
C#
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; }
|
|
}
|