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:
Matt Batchelder
2026-03-18 10:27:26 -04:00
parent c2e03de8bb
commit c6d46098dd
77 changed files with 9412 additions and 29 deletions

View File

@@ -0,0 +1,146 @@
using Refit;
namespace OTSSignsOrchestrator.Server.Clients;
// ── Configuration ───────────────────────────────────────────────────────────
public sealed class AuthentikOptions
{
public const string Section = "Authentik";
public string BaseUrl { get; set; } = string.Empty;
public string ApiToken { get; set; } = string.Empty;
/// <summary>UUID of the OTS signing certificate-key pair used for all SAML sources.</summary>
public string OtsSigningKpId { get; set; } = string.Empty;
/// <summary>Authentik pre-authentication flow slug for SAML sources (e.g. "default-source-pre-authentication").</summary>
public string SourcePreAuthFlowSlug { get; set; } = "default-source-pre-authentication";
/// <summary>Authentik authentication flow slug for SAML sources (e.g. "default-source-authentication").</summary>
public string SourceAuthFlowSlug { get; set; } = "default-source-authentication";
}
// ── Request DTOs ────────────────────────────────────────────────────────────
public record CreateSamlProviderRequest(
string Name,
string AuthorizationFlow,
string AcsUrl,
string Issuer,
string SpBinding,
string Audience,
string? SigningKp);
public record CreateAuthentikApplicationRequest(
string Name,
string Slug,
string Provider,
string? MetaLaunchUrl);
public record CreateAuthentikGroupRequest(
string Name,
bool? IsSuperuser,
string? Parent);
public record CreateFlowRequest(
string Name,
bool? SingleUse,
DateTimeOffset? Expires);
public record CreateAuthentikUserRequest(
string Username,
string Name,
string Email,
string[] Groups);
public record ImportCertRequest(
string Name,
string CertificateData,
string? KeyData);
public record CreateSamlSourceRequest(
string Name,
string Slug,
string SsoUrl,
string? SloUrl,
string Issuer,
string? SigningKp,
string? VerificationKp,
string BindingType,
string NameIdPolicy,
string PreAuthenticationFlow,
string AuthenticationFlow,
bool AllowIdpInitiated);
// ── Response DTOs ───────────────────────────────────────────────────────────
/// <summary>Authentik paginated list response. Results contain dictionaries with entity fields.</summary>
public record AuthentikPagedResult(
List<Dictionary<string, object>> Results);
// ── Authentik Refit Interface ───────────────────────────────────────────────
// One global Authentik instance serves all tenants.
[Headers("Authorization: Bearer")]
public interface IAuthentikClient
{
// ── SAML Providers ──────────────────────────────────────────────────────
[Post("/api/v3/providers/saml/")]
Task<ApiResponse<Dictionary<string, object>>> CreateSamlProviderAsync(
[Body] CreateSamlProviderRequest body);
[Get("/api/v3/providers/saml/{id}/")]
Task<ApiResponse<Dictionary<string, object>>> GetSamlProviderAsync(int id);
[Delete("/api/v3/providers/saml/{id}/")]
Task DeleteSamlProviderAsync(int id);
// ── Applications ────────────────────────────────────────────────────────
[Post("/api/v3/core/applications/")]
Task<ApiResponse<Dictionary<string, object>>> CreateApplicationAsync(
[Body] CreateAuthentikApplicationRequest body);
[Delete("/api/v3/core/applications/{slug}/")]
Task DeleteApplicationAsync(string slug);
// ── Groups ──────────────────────────────────────────────────────────────
[Get("/api/v3/core/groups/")]
Task<ApiResponse<AuthentikPagedResult>> ListGroupsAsync([AliasAs("search")] string? search = null);
[Post("/api/v3/core/groups/")]
Task<ApiResponse<Dictionary<string, object>>> CreateGroupAsync(
[Body] CreateAuthentikGroupRequest body);
[Delete("/api/v3/core/groups/{id}/")]
Task DeleteGroupAsync(string id);
// ── Invitations ─────────────────────────────────────────────────────────
[Post("/api/v3/stages/invitation/invitations/")]
Task<ApiResponse<Dictionary<string, object>>> CreateInvitationAsync(
[Body] CreateFlowRequest body);
// ── Users ───────────────────────────────────────────────────────────────
[Post("/api/v3/core/users/")]
Task<ApiResponse<Dictionary<string, object>>> CreateUserAsync(
[Body] CreateAuthentikUserRequest body);
// ── Health ──────────────────────────────────────────────────────────────
[Get("/api/v3/-/health/ready/")]
Task<ApiResponse<object>> CheckHealthAsync();
// ── Certificates ────────────────────────────────────────────────────────
[Get("/api/v3/crypto/certificatekeypairs/{kpId}/")]
Task<ApiResponse<Dictionary<string, object>>> GetCertificateKeyPairAsync(string kpId);
[Post("/api/v3/crypto/certificatekeypairs/")]
Task<ApiResponse<Dictionary<string, object>>> ImportCertificateAsync(
[Body] ImportCertRequest body);
// ── SAML Sources ────────────────────────────────────────────────────────
[Post("/api/v3/sources/saml/")]
Task<ApiResponse<Dictionary<string, object>>> CreateSamlSourceAsync(
[Body] CreateSamlSourceRequest body);
[Get("/api/v3/sources/saml/{slug}/metadata/")]
Task<ApiResponse<string>> GetSamlSourceMetadataAsync(string slug);
[Delete("/api/v3/sources/saml/{slug}/")]
Task DeleteSamlSourceAsync(string slug);
}

View File

@@ -0,0 +1,138 @@
using Refit;
namespace OTSSignsOrchestrator.Server.Clients;
// ── Request DTOs ────────────────────────────────────────────────────────────
public record CreateUserRequest(
string UserName,
string Email,
string Password,
int UserTypeId,
int HomePageId);
public record UpdateUserRequest(
string? UserName,
string? Email,
string? Password,
int? UserTypeId,
int? HomePageId,
int? Retired);
public record CreateGroupRequest(string Group, string? Description);
public record AssignMemberRequest(int[] UserId);
public record SetAclRequest(string[] ObjectId, string[] PermissionsId);
public record CreateApplicationRequest(string Name);
public record UpdateSettingsRequest(Dictionary<string, string> Settings);
public record CreateDisplayRequest(string Display, string? Description);
// ── Xibo CMS Refit Interface ────────────────────────────────────────────────
// CRITICAL: GET /api/application is BLOCKED — only POST and DELETE exist.
// All group endpoints use /api/group, NOT /api/usergroup.
// Feature assignment is POST /api/group/{id}/acl, NOT /features.
// Xibo paginates at 10 items by default — always pass start + length params.
[Headers("Authorization: Bearer")]
public interface IXiboApiClient
{
// ── About ───────────────────────────────────────────────────────────────
[Get("/about")]
Task<ApiResponse<object>> GetAboutAsync();
// ── Users ───────────────────────────────────────────────────────────────
[Get("/user")]
Task<List<Dictionary<string, object>>> GetUsersAsync(
[AliasAs("start")] int? start = 0,
[AliasAs("length")] int? length = 200);
[Post("/user")]
Task<ApiResponse<Dictionary<string, object>>> CreateUserAsync(
[Body(BodySerializationMethod.UrlEncoded)] CreateUserRequest body);
[Put("/user/{userId}")]
Task<ApiResponse<Dictionary<string, object>>> UpdateUserAsync(
int userId,
[Body(BodySerializationMethod.UrlEncoded)] UpdateUserRequest body);
[Delete("/user/{userId}")]
Task DeleteUserAsync(int userId);
// ── Groups (NOT /usergroup) ─────────────────────────────────────────────
[Get("/group")]
Task<List<Dictionary<string, object>>> GetGroupsAsync(
[AliasAs("start")] int? start = 0,
[AliasAs("length")] int? length = 200);
[Post("/group")]
Task<ApiResponse<Dictionary<string, object>>> CreateGroupAsync(
[Body(BodySerializationMethod.UrlEncoded)] CreateGroupRequest body);
[Delete("/group/{groupId}")]
Task DeleteGroupAsync(int groupId);
[Post("/group/members/assign/{groupId}")]
Task<ApiResponse<object>> AssignUserToGroupAsync(
int groupId,
[Body(BodySerializationMethod.UrlEncoded)] AssignMemberRequest body);
// ACL — NOT /features
[Post("/group/{groupId}/acl")]
Task<ApiResponse<object>> SetGroupAclAsync(
int groupId,
[Body(BodySerializationMethod.UrlEncoded)] SetAclRequest body);
// ── Displays ────────────────────────────────────────────────────────────
[Get("/display")]
Task<List<Dictionary<string, object>>> GetDisplaysAsync(
[AliasAs("start")] int? start = 0,
[AliasAs("length")] int? length = 200,
[AliasAs("authorised")] int? authorised = null);
// ── Applications (POST + DELETE only — GET is BLOCKED) ──────────────────
[Post("/application")]
Task<ApiResponse<Dictionary<string, object>>> CreateApplicationAsync(
[Body(BodySerializationMethod.UrlEncoded)] CreateApplicationRequest body);
[Delete("/application/{key}")]
Task DeleteApplicationAsync(string key);
// ── Settings ────────────────────────────────────────────────────────────
[Get("/settings")]
Task<ApiResponse<object>> GetSettingsAsync();
[Put("/settings")]
Task<ApiResponse<object>> UpdateSettingsAsync(
[Body(BodySerializationMethod.UrlEncoded)] UpdateSettingsRequest body);
}
// ── Pagination helper ───────────────────────────────────────────────────────
public static class XiboApiClientExtensions
{
/// <summary>
/// Pages through a Xibo list endpoint until a page returns fewer items than pageSize.
/// </summary>
public static async Task<List<T>> GetAllPagesAsync<T>(
this IXiboApiClient client,
Func<int, int, Task<List<T>>> listMethod,
int pageSize = 200)
{
var all = new List<T>();
var start = 0;
while (true)
{
var page = await listMethod(start, pageSize);
all.AddRange(page);
if (page.Count < pageSize)
break;
start += pageSize;
}
return all;
}
}

View File

@@ -0,0 +1,211 @@
using System.Collections.Concurrent;
using System.Net;
using System.Net.Http.Headers;
using System.Text.Json;
using Polly;
using Polly.Retry;
using Refit;
namespace OTSSignsOrchestrator.Server.Clients;
/// <summary>
/// Creates per-instance <see cref="IXiboApiClient"/> Refit proxies with
/// OAuth2 bearer-token caching, auto-refresh on 401, and Polly retry.
/// Registered as a singleton.
/// </summary>
public sealed class XiboClientFactory
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly ConcurrentDictionary<string, TokenEntry> _tokenCache = new();
private readonly SemaphoreSlim _tokenLock = new(1, 1);
private static readonly TimeSpan TokenCacheTtl = TimeSpan.FromMinutes(5);
public XiboClientFactory(IHttpClientFactory httpClientFactory)
{
_httpClientFactory = httpClientFactory;
}
/// <summary>
/// Build a Refit client targeting <paramref name="instanceBaseUrl"/>/api.
/// Tokens are cached per base URL for 5 minutes and auto-refreshed on 401.
/// </summary>
public async Task<IXiboApiClient> CreateAsync(
string instanceBaseUrl,
string clientId,
string clientSecret)
{
// Ensure we have a valid token up-front
var token = await GetOrRefreshTokenAsync(instanceBaseUrl, clientId, clientSecret);
var retryPipeline = new ResiliencePipelineBuilder<HttpResponseMessage>()
.AddRetry(new RetryStrategyOptions<HttpResponseMessage>
{
MaxRetryAttempts = 3,
BackoffType = DelayBackoffType.Exponential,
Delay = TimeSpan.FromSeconds(1),
ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
.HandleResult(r =>
r.StatusCode is HttpStatusCode.RequestTimeout
or HttpStatusCode.TooManyRequests
or >= HttpStatusCode.InternalServerError),
})
.Build();
var handler = new XiboDelegatingHandler(
this, instanceBaseUrl, clientId, clientSecret, token, retryPipeline)
{
InnerHandler = new HttpClientHandler(),
};
var httpClient = new HttpClient(handler)
{
BaseAddress = new Uri(instanceBaseUrl.TrimEnd('/') + "/api"),
};
return RestService.For<IXiboApiClient>(httpClient);
}
// ── Token management ────────────────────────────────────────────────────
internal async Task<string> GetOrRefreshTokenAsync(
string instanceBaseUrl,
string clientId,
string clientSecret,
bool forceRefresh = false)
{
var key = instanceBaseUrl.TrimEnd('/').ToLowerInvariant();
if (!forceRefresh
&& _tokenCache.TryGetValue(key, out var cached)
&& cached.ExpiresAt > DateTimeOffset.UtcNow)
{
return cached.AccessToken;
}
await _tokenLock.WaitAsync();
try
{
// Double-check after acquiring lock
if (!forceRefresh
&& _tokenCache.TryGetValue(key, out cached)
&& cached.ExpiresAt > DateTimeOffset.UtcNow)
{
return cached.AccessToken;
}
var token = await RequestTokenAsync(instanceBaseUrl, clientId, clientSecret);
_tokenCache[key] = new TokenEntry(token, DateTimeOffset.UtcNow.Add(TokenCacheTtl));
return token;
}
finally
{
_tokenLock.Release();
}
}
private static async Task<string> RequestTokenAsync(
string instanceBaseUrl,
string clientId,
string clientSecret)
{
using var http = new HttpClient();
var content = new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "client_credentials",
["client_id"] = clientId,
["client_secret"] = clientSecret,
});
var response = await http.PostAsync(
$"{instanceBaseUrl.TrimEnd('/')}/api/authorize/access_token",
content);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(json);
return doc.RootElement.GetProperty("access_token").GetString()
?? throw new InvalidOperationException("Token response missing access_token.");
}
private sealed record TokenEntry(string AccessToken, DateTimeOffset ExpiresAt);
// ── Delegating handler ──────────────────────────────────────────────────
private sealed class XiboDelegatingHandler : DelegatingHandler
{
private readonly XiboClientFactory _factory;
private readonly string _instanceBaseUrl;
private readonly string _clientId;
private readonly string _clientSecret;
private readonly ResiliencePipeline<HttpResponseMessage> _retryPipeline;
private string _accessToken;
public XiboDelegatingHandler(
XiboClientFactory factory,
string instanceBaseUrl,
string clientId,
string clientSecret,
string accessToken,
ResiliencePipeline<HttpResponseMessage> retryPipeline)
{
_factory = factory;
_instanceBaseUrl = instanceBaseUrl;
_clientId = clientId;
_clientSecret = clientSecret;
_accessToken = accessToken;
_retryPipeline = retryPipeline;
}
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
return await _retryPipeline.ExecuteAsync(async ct =>
{
// Clone the request for retries (original may already be disposed)
using var clone = await CloneRequestAsync(request);
clone.Headers.Authorization =
new AuthenticationHeaderValue("Bearer", _accessToken);
var response = await base.SendAsync(clone, ct);
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
// Force-refresh the token and retry once
_accessToken = await _factory.GetOrRefreshTokenAsync(
_instanceBaseUrl, _clientId, _clientSecret, forceRefresh: true);
using var retry = await CloneRequestAsync(request);
retry.Headers.Authorization =
new AuthenticationHeaderValue("Bearer", _accessToken);
response = await base.SendAsync(retry, ct);
}
return response;
}, cancellationToken);
}
private static async Task<HttpRequestMessage> CloneRequestAsync(
HttpRequestMessage original)
{
var clone = new HttpRequestMessage(original.Method, original.RequestUri);
if (original.Content != null)
{
var body = await original.Content.ReadAsByteArrayAsync();
clone.Content = new ByteArrayContent(body);
foreach (var header in original.Content.Headers)
clone.Content.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
foreach (var header in original.Headers)
clone.Headers.TryAddWithoutValidation(header.Key, header.Value);
return clone;
}
}
}