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