Files
OTSSignsOrchestrator/OTSSignsOrchestrator.Server/Workers/RotateCredentialsPipeline.cs

275 lines
14 KiB
C#
Raw Normal View History

using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using OTSSignsOrchestrator.Core.Services;
using OTSSignsOrchestrator.Server.Clients;
using OTSSignsOrchestrator.Server.Data;
using OTSSignsOrchestrator.Server.Data.Entities;
using OTSSignsOrchestrator.Server.Hubs;
namespace OTSSignsOrchestrator.Server.Workers;
/// <summary>
/// OAuth2 credential rotation pipeline — deletes the old Xibo OAuth app, creates a new one,
/// stores the new credentials, and verifies access. Handles <c>JobType = "rotate-oauth2"</c>.
///
/// CRITICAL: OAuth2 clientId CHANGES on rotation — there is no in-place secret refresh.
/// The secret is returned ONLY in the <c>POST /api/application</c> response.
///
/// Steps:
/// 1. delete-old-app — Delete current OAuth2 application via Xibo API
/// 2. create-new-app — POST /api/application — IMMEDIATELY capture secret
/// 3. store-credentials — Update Bitwarden + OauthAppRegistry with new clientId
/// 4. verify-access — Test new credentials via client_credentials flow
/// 5. audit-log — Write AuditLog (or CRITICAL error with emergency creds if steps 3-4 fail)
/// </summary>
public sealed class RotateCredentialsPipeline : IProvisioningPipeline
{
public string HandlesJobType => "rotate-oauth2";
private const int TotalSteps = 5;
private readonly IServiceProvider _services;
private readonly ILogger<RotateCredentialsPipeline> _logger;
public RotateCredentialsPipeline(
IServiceProvider services,
ILogger<RotateCredentialsPipeline> 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 xiboFactory = scope.ServiceProvider.GetRequiredService<XiboClientFactory>();
var bws = scope.ServiceProvider.GetRequiredService<IBitwardenSecretService>();
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
var ctx = await BuildContextAsync(job, db, ct);
var runner = new StepRunner(db, hub, _logger, job.Id, TotalSteps);
var abbrev = ctx.Abbreviation;
// Load current OAuth2 credentials
var currentReg = await db.OauthAppRegistries
.Where(r => r.InstanceId == ctx.InstanceId)
.OrderByDescending(r => r.CreatedAt)
.FirstOrDefaultAsync(ct)
?? throw new InvalidOperationException(
$"No OauthAppRegistry found for instance {ctx.InstanceId}. Cannot rotate.");
var currentSecret = await settings.GetAsync(SettingsService.InstanceOAuthSecretId(abbrev))
?? throw new InvalidOperationException(
$"OAuth secret not found in settings for instance '{abbrev}'. Cannot rotate.");
var currentClientId = currentReg.ClientId;
// Get a Xibo API client using the current (about-to-be-deleted) credentials
var xiboClient = await xiboFactory.CreateAsync(ctx.InstanceUrl, currentClientId, currentSecret);
// Mutable state — captured across steps
string newClientId = string.Empty;
string newSecret = string.Empty;
// ── Step 1: delete-old-app ──────────────────────────────────────────
await runner.RunAsync("delete-old-app", async () =>
{
await xiboClient.DeleteApplicationAsync(currentClientId);
return $"Old OAuth2 application deleted. ClientId={currentClientId}.";
}, ct);
// ── Step 2: create-new-app ──────────────────────────────────────────
// CRITICAL: After this step, the old credentials are gone. If subsequent steps fail,
// the new clientId + secret MUST be logged for manual recovery.
await runner.RunAsync("create-new-app", async () =>
{
// Need a client with some auth to create the new app. Use the instance's bootstrap
// admin credentials (ots-admin user) via direct login if available, or we can
// re-authenticate using the base URL. Since the old app is deleted, we need a
// different auth mechanism. The Xibo CMS allows creating apps as any authenticated
// super-admin. Use the ots-admin password from Bitwarden.
//
// However, the simplest path: the DELETE above invalidated the old token, but the
// Xibo CMS still has the ots-admin and ots-svc users. We stored the admin password
// in Bitwarden. Retrieve it and create a new factory client.
var adminPassBwsId = await settings.GetAsync(SettingsService.InstanceAdminPasswordSecretId(abbrev));
if (string.IsNullOrEmpty(adminPassBwsId))
throw new InvalidOperationException(
$"Admin password Bitwarden secret ID not found for '{abbrev}'. Cannot create new OAuth app.");
var adminPassSecret = await bws.GetSecretAsync(adminPassBwsId);
var bootstrapClientId = await settings.GetAsync(SettingsService.XiboBootstrapClientId)
?? throw new InvalidOperationException("Bootstrap OAuth client ID not configured.");
var bootstrapClientSecret = await settings.GetAsync(SettingsService.XiboBootstrapClientSecret)
?? throw new InvalidOperationException("Bootstrap OAuth client secret not configured.");
// Re-create client using bootstrap credentials
var bootstrapClient = await xiboFactory.CreateAsync(ctx.InstanceUrl, bootstrapClientId, bootstrapClientSecret);
var appName = $"OTS Orchestrator — {abbrev.ToUpperInvariant()}";
var response = await bootstrapClient.CreateApplicationAsync(new CreateApplicationRequest(appName));
EnsureSuccess(response);
var data = response.Content
?? throw new InvalidOperationException("CreateApplication returned empty response.");
// CRITICAL: Capture secret IMMEDIATELY — it is returned ONLY in this response.
newClientId = data["key"]?.ToString()
?? throw new InvalidOperationException("OAuth application response missing 'key'.");
newSecret = data["secret"]?.ToString()
?? throw new InvalidOperationException("OAuth application response missing 'secret'.");
return $"New OAuth2 application created. ClientId={newClientId}. Secret captured (stored in next step).";
}, ct);
// Steps 3-5: if ANY of these fail after step 2, we must log the credentials for recovery.
// The old app is already deleted — there is no rollback path.
try
{
// ── Step 3: store-credentials ───────────────────────────────────
await runner.RunAsync("store-credentials", async () =>
{
// Update Bitwarden with new secret
var existingBwsId = await settings.GetAsync(SettingsService.InstanceOAuthSecretId(abbrev));
if (!string.IsNullOrEmpty(existingBwsId))
{
await bws.UpdateSecretAsync(
existingBwsId,
$"{abbrev}/xibo-oauth-secret",
newSecret,
$"Xibo CMS OAuth2 client secret (rotated). ClientId: {newClientId}");
}
else
{
await bws.CreateInstanceSecretAsync(
key: $"{abbrev}/xibo-oauth-secret",
value: newSecret,
note: $"Xibo CMS OAuth2 client secret (rotated). ClientId: {newClientId}");
}
// Update OauthAppRegistry with new clientId
db.OauthAppRegistries.Add(new OauthAppRegistry
{
Id = Guid.NewGuid(),
InstanceId = ctx.InstanceId,
ClientId = newClientId,
CreatedAt = DateTime.UtcNow,
});
// Also update the settings cache with the new client ID
await settings.SetAsync(
SettingsService.InstanceOAuthClientId(abbrev),
newClientId,
SettingsService.CatInstance,
isSensitive: false);
await db.SaveChangesAsync(ct);
return $"Credentials stored. ClientId={newClientId} in OauthAppRegistry + Bitwarden.";
}, ct);
// ── Step 4: verify-access ───────────────────────────────────────
await runner.RunAsync("verify-access", async () =>
{
var verifyClient = await xiboFactory.CreateAsync(ctx.InstanceUrl, newClientId, newSecret);
var aboutResp = await verifyClient.GetAboutAsync();
EnsureSuccess(aboutResp);
return $"New credentials verified. GET /about succeeded on {ctx.InstanceUrl}.";
}, ct);
// ── Step 5: audit-log ───────────────────────────────────────────
await runner.RunAsync("audit-log", async () =>
{
db.AuditLogs.Add(new AuditLog
{
Id = Guid.NewGuid(),
InstanceId = ctx.InstanceId,
Actor = "system/credential-rotation",
Action = "rotate-oauth2",
Target = $"xibo-{abbrev}",
Outcome = "success",
Detail = $"OAuth2 credentials rotated. Old clientId={currentClientId} → new clientId={newClientId}. Job {job.Id}.",
OccurredAt = DateTime.UtcNow,
});
await db.SaveChangesAsync(ct);
return $"AuditLog entry written. OAuth2 rotation complete.";
}, ct);
}
catch (Exception ex)
{
// CRITICAL: Steps 3-5 failed AFTER step 2 created the new app.
// The old credentials are already deleted. Log new credentials for manual recovery.
_logger.LogCritical(ex,
"CRITICAL — OAuth2 rotation for '{Abbrev}' failed after new app creation. " +
"Old clientId={OldClientId} (DELETED). " +
"New clientId={NewClientId}, New secret={NewSecret}. " +
"EMERGENCY RECOVERY DATA — store these credentials manually.",
abbrev, currentClientId, newClientId, newSecret);
// Also persist to a JobStep for operator visibility
var emergencyStep = new JobStep
{
Id = Guid.NewGuid(),
JobId = job.Id,
StepName = "emergency-credential-log",
Status = JobStepStatus.Failed,
StartedAt = DateTime.UtcNow,
CompletedAt = DateTime.UtcNow,
LogOutput = $"CRITICAL EMERGENCY RECOVERY DATA — OAuth2 rotation partial failure. " +
$"Old clientId={currentClientId} (DELETED). " +
$"New clientId={newClientId}. New secret={newSecret}. " +
$"These credentials must be stored manually. Error: {ex.Message}",
};
db.JobSteps.Add(emergencyStep);
await db.SaveChangesAsync(CancellationToken.None);
throw; // Re-throw to fail the job
}
_logger.LogInformation(
"RotateCredentialsPipeline completed for job {JobId} (abbrev={Abbrev}, newClientId={ClientId})",
job.Id, abbrev, newClientId);
}
// ─────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────
private static async Task<PipelineContext> BuildContextAsync(Job job, OrchestratorDbContext db, CancellationToken ct)
{
var customer = await db.Customers
.Include(c => c.Instances)
.FirstOrDefaultAsync(c => c.Id == job.CustomerId, ct)
?? throw new InvalidOperationException($"Customer {job.CustomerId} not found for job {job.Id}.");
var instance = customer.Instances.FirstOrDefault()
?? throw new InvalidOperationException($"No instance found for customer {job.CustomerId}.");
var abbrev = customer.Abbreviation.ToLowerInvariant();
return new PipelineContext
{
JobId = job.Id,
CustomerId = customer.Id,
InstanceId = instance.Id,
Abbreviation = abbrev,
CompanyName = customer.CompanyName,
AdminEmail = customer.AdminEmail,
AdminFirstName = customer.AdminFirstName,
InstanceUrl = instance.XiboUrl,
DockerStackName = instance.DockerStackName,
ParametersJson = job.Parameters,
};
}
private static void EnsureSuccess<T>(Refit.IApiResponse<T> response)
{
if (!response.IsSuccessStatusCode)
throw new InvalidOperationException(
$"API call failed with status {response.StatusCode}: {response.Error?.Content}");
}
}