- 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.
258 lines
13 KiB
C#
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);
|