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:
207
OTSSignsOrchestrator.Server/Workers/ByoiSamlPipeline.cs
Normal file
207
OTSSignsOrchestrator.Server/Workers/ByoiSamlPipeline.cs
Normal file
@@ -0,0 +1,207 @@
|
||||
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; }
|
||||
}
|
||||
Reference in New Issue
Block a user