Files
OTSSignsOrchestrator/OTSSignsOrchestrator.Server/Program.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

258 lines
13 KiB
C#

using System.Text;
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using Microsoft.Extensions.Options;
using OTSSignsOrchestrator.Server.Api;
using OTSSignsOrchestrator.Server.Auth;
using OTSSignsOrchestrator.Server.Clients;
using OTSSignsOrchestrator.Server.Data;
using OTSSignsOrchestrator.Server.Hubs;
using OTSSignsOrchestrator.Server.Reports;
using OTSSignsOrchestrator.Server.Services;
using OTSSignsOrchestrator.Server.Webhooks;
using OTSSignsOrchestrator.Server.Workers;
using Refit;
using Quartz;
using OTSSignsOrchestrator.Server.Jobs;
using OTSSignsOrchestrator.Server.Health;
using OTSSignsOrchestrator.Server.Health.Checks;
using Serilog;
using Stripe;
var builder = WebApplication.CreateBuilder(args);
// ── Serilog ──────────────────────────────────────────────────────────────────
builder.Host.UseSerilog((context, config) =>
config.ReadFrom.Configuration(context.Configuration));
// ── EF Core — PostgreSQL ─────────────────────────────────────────────────────
builder.Services.AddDbContext<OrchestratorDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("OrchestratorDb")));
// ── JWT Authentication ──────────────────────────────────────────────────────
builder.Services.Configure<JwtOptions>(builder.Configuration.GetSection(JwtOptions.Section));
var jwtKey = builder.Configuration[$"{JwtOptions.Section}:Key"]
?? throw new InvalidOperationException("Jwt:Key must be configured.");
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = builder.Configuration[$"{JwtOptions.Section}:Issuer"] ?? "OTSSignsOrchestrator",
ValidAudience = builder.Configuration[$"{JwtOptions.Section}:Audience"] ?? "OTSSignsOrchestrator",
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)),
ClockSkew = TimeSpan.FromSeconds(30),
};
// Allow SignalR to receive the JWT via query string
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
var accessToken = context.Request.Query["access_token"];
var path = context.HttpContext.Request.Path;
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs"))
context.Token = accessToken;
return Task.CompletedTask;
},
};
});
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("CustomerPortal", policy =>
policy.RequireClaim("customer_id"));
});
// ── Application services ────────────────────────────────────────────────────
builder.Services.AddScoped<OperatorAuthService>();
builder.Services.AddScoped<AbbreviationService>();
builder.Services.Configure<EmailOptions>(builder.Configuration.GetSection(EmailOptions.Section));
builder.Services.AddSingleton<EmailService>();
// ── Report services ─────────────────────────────────────────────────────────
builder.Services.AddScoped<BillingReportService>();
builder.Services.AddScoped<FleetHealthPdfService>();
// ── Provisioning pipelines + worker ─────────────────────────────────────────
builder.Services.AddScoped<IProvisioningPipeline, Phase1Pipeline>();
builder.Services.AddScoped<IProvisioningPipeline, Phase2Pipeline>();
builder.Services.AddScoped<IProvisioningPipeline, ByoiSamlPipeline>();
builder.Services.AddScoped<IProvisioningPipeline, SuspendPipeline>();
builder.Services.AddScoped<IProvisioningPipeline, ReactivatePipeline>();
builder.Services.AddScoped<IProvisioningPipeline, UpdateScreenLimitPipeline>();
builder.Services.AddScoped<IProvisioningPipeline, DecommissionPipeline>();
builder.Services.AddScoped<IProvisioningPipeline, RotateCredentialsPipeline>();
builder.Services.AddHostedService<ProvisioningWorker>();
// ── External API clients ────────────────────────────────────────────────────
builder.Services.AddHttpClient();
builder.Services.AddSingleton<XiboClientFactory>();
builder.Services.Configure<AuthentikOptions>(
builder.Configuration.GetSection(AuthentikOptions.Section));
builder.Services.AddRefitClient<IAuthentikClient>()
.ConfigureHttpClient((sp, client) =>
{
var opts = sp.GetRequiredService<IOptions<AuthentikOptions>>().Value;
client.BaseAddress = new Uri(opts.BaseUrl.TrimEnd('/'));
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", opts.ApiToken);
});
// ── Stripe ──────────────────────────────────────────────────────────────────
StripeConfiguration.ApiKey = builder.Configuration["Stripe:SecretKey"];
// ── Health check engine + individual checks ─────────────────────────────────
builder.Services.AddHostedService<HealthCheckEngine>();
builder.Services.AddScoped<IHealthCheck, XiboApiHealthCheck>();
builder.Services.AddScoped<IHealthCheck, AdminIntegrityHealthCheck>();
builder.Services.AddScoped<IHealthCheck, GroupStructureHealthCheck>();
builder.Services.AddScoped<IHealthCheck, OauthAppHealthCheck>();
builder.Services.AddScoped<IHealthCheck, DisplayAuthorisedHealthCheck>();
builder.Services.AddScoped<IHealthCheck, StackHealthCheck>();
builder.Services.AddScoped<IHealthCheck, MySqlConnectHealthCheck>();
builder.Services.AddScoped<IHealthCheck, NfsAccessHealthCheck>();
builder.Services.AddScoped<IHealthCheck, ThemeHealthCheck>();
builder.Services.AddScoped<IHealthCheck, XiboVersionHealthCheck>();
builder.Services.AddScoped<IHealthCheck, OauthAppAgeHealthCheck>();
builder.Services.AddScoped<IHealthCheck, ByoiCertExpiryHealthCheck>();
builder.Services.AddScoped<IHealthCheck, AuthentikGlobalHealthCheck>();
builder.Services.AddScoped<IHealthCheck, AuthentikSamlProviderHealthCheck>();
builder.Services.AddScoped<IHealthCheck, InvitationFlowHealthCheck>();
// AuthentikGlobalHealthCheck also registered as concrete type for the Quartz job
builder.Services.AddScoped<AuthentikGlobalHealthCheck>();
// ── Quartz scheduler ─────────────────────────────────────────────────────────
builder.Services.AddQuartz(q =>
{
var certExpiryKey = new JobKey("byoi-cert-expiry-global", "byoi-cert-expiry");
q.AddJob<ByoiCertExpiryJob>(opts => opts.WithIdentity(certExpiryKey).StoreDurably());
q.AddTrigger(opts => opts
.ForJob(certExpiryKey)
.WithIdentity("byoi-cert-expiry-global-trigger", "byoi-cert-expiry")
.WithSimpleSchedule(s => s
.WithIntervalInHours(24)
.RepeatForever())
.StartNow());
// Authentik global health check — every 2 minutes
var authentikHealthKey = new JobKey("authentik-global-health", "health-checks");
q.AddJob<AuthentikGlobalHealthJob>(opts => opts.WithIdentity(authentikHealthKey).StoreDurably());
q.AddTrigger(opts => opts
.ForJob(authentikHealthKey)
.WithIdentity("authentik-global-health-trigger", "health-checks")
.WithSimpleSchedule(s => s
.WithIntervalInMinutes(2)
.RepeatForever())
.StartNow());
// Daily screen snapshot — 2 AM UTC
var dailySnapshotKey = new JobKey("daily-snapshot", "snapshots");
q.AddJob<OTSSignsOrchestrator.Server.Jobs.DailySnapshotJob>(opts => opts.WithIdentity(dailySnapshotKey).StoreDurably());
q.AddTrigger(opts => opts
.ForJob(dailySnapshotKey)
.WithIdentity("daily-snapshot-trigger", "snapshots")
.WithCronSchedule("0 0 2 * * ?"));
// Scheduled reports — weekly (Monday 08:00 UTC) + monthly (1st 08:00 UTC)
var reportJobKey = new JobKey("scheduled-report", "reports");
q.AddJob<ScheduledReportJob>(opts => opts.WithIdentity(reportJobKey).StoreDurably());
q.AddTrigger(opts => opts
.ForJob(reportJobKey)
.WithIdentity("weekly-report-trigger", "reports")
.UsingJobData(ScheduledReportJob.IsMonthlyKey, false)
.WithCronSchedule("0 0 8 ? * MON *"));
q.AddTrigger(opts => opts
.ForJob(reportJobKey)
.WithIdentity("monthly-report-trigger", "reports")
.UsingJobData(ScheduledReportJob.IsMonthlyKey, true)
.WithCronSchedule("0 0 8 1 * ? *"));
});
builder.Services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true);
// ── SignalR ──────────────────────────────────────────────────────────────────
builder.Services.AddSignalR();
// ── Rate limiting ────────────────────────────────────────────────────────────
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = 429;
options.AddFixedWindowLimiter("fixed", limiter =>
{
limiter.PermitLimit = 60;
limiter.Window = TimeSpan.FromMinutes(1);
});
options.AddSlidingWindowLimiter("signup", limiter =>
{
limiter.PermitLimit = 3;
limiter.Window = TimeSpan.FromMinutes(10);
limiter.SegmentsPerWindow = 2;
});
});
var app = builder.Build();
// ── Middleware ────────────────────────────────────────────────────────────────
app.UseSerilogRequestLogging();
app.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();
// ── Auth endpoints (no auth required) ────────────────────────────────────────
app.MapPost("/api/auth/login", async (LoginRequest req, OperatorAuthService auth) =>
{
try
{
var (jwt, refresh) = await auth.LoginAsync(req.Email, req.Password);
return Results.Ok(new { token = jwt, refreshToken = refresh });
}
catch (UnauthorizedAccessException)
{
return Results.Unauthorized();
}
});
app.MapPost("/api/auth/refresh", async (RefreshRequest req, OperatorAuthService auth) =>
{
try
{
var jwt = await auth.RefreshAsync(req.RefreshToken);
return Results.Ok(new { token = jwt });
}
catch (UnauthorizedAccessException)
{
return Results.Unauthorized();
}
});
// ── Signup endpoints (no auth) ──────────────────────────────────────────────
app.MapSignupEndpoints();
// ── Stripe webhook (no auth, no rate limit) ─────────────────────────────────
app.MapStripeWebhook();
// ── Fleet + Jobs REST endpoints (auth required) ─────────────────────────────
app.MapFleetEndpoints();
// ── Customer Portal BYOI endpoints (customer JWT required) ──────────────
app.MapCustomerPortalEndpoints();
// ── SignalR hub ─────────────────────────────────────────────────────────────
app.MapHub<FleetHub>("/hubs/fleet");
app.Run();
// ── Request DTOs for auth endpoints ─────────────────────────────────────────
public record LoginRequest(string Email, string Password);
public record RefreshRequest(string RefreshToken);