up
Some checks failed
Build Test Deploy / docs (push) Has been cancelled
Build Test Deploy / deploy (push) Has been cancelled
Build Test Deploy / build-test (push) Has been cancelled
Build Test Deploy / authority-container (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled

This commit is contained in:
2025-10-12 20:37:18 +03:00
parent b97fc7685a
commit 607e72e2a1
306 changed files with 21409 additions and 4449 deletions

View File

@@ -0,0 +1,213 @@
using System;
using System.Buffers.Text;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Auth.Client;
using StellaOps.Cli.Configuration;
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Services;
internal sealed class AuthorityRevocationClient : IAuthorityRevocationClient
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
private static readonly TimeSpan TokenRefreshSkew = TimeSpan.FromSeconds(30);
private readonly HttpClient httpClient;
private readonly StellaOpsCliOptions options;
private readonly ILogger<AuthorityRevocationClient> logger;
private readonly IStellaOpsTokenClient? tokenClient;
private readonly object tokenSync = new();
private string? cachedAccessToken;
private DateTimeOffset cachedAccessTokenExpiresAt = DateTimeOffset.MinValue;
public AuthorityRevocationClient(
HttpClient httpClient,
StellaOpsCliOptions options,
ILogger<AuthorityRevocationClient> logger,
IStellaOpsTokenClient? tokenClient = null)
{
this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
this.options = options ?? throw new ArgumentNullException(nameof(options));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
this.tokenClient = tokenClient;
if (!string.IsNullOrWhiteSpace(options.Authority?.Url) && httpClient.BaseAddress is null && Uri.TryCreate(options.Authority.Url, UriKind.Absolute, out var authorityUri))
{
httpClient.BaseAddress = authorityUri;
}
}
public async Task<AuthorityRevocationExportResult> ExportAsync(bool verbose, CancellationToken cancellationToken)
{
EnsureAuthorityConfigured();
using var request = new HttpRequestMessage(HttpMethod.Get, "internal/revocations/export");
var accessToken = await AcquireAccessTokenAsync(cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(accessToken))
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
}
using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var message = $"Authority export request failed with {(int)response.StatusCode} {response.ReasonPhrase}: {body}";
throw new InvalidOperationException(message);
}
var payload = await JsonSerializer.DeserializeAsync<ExportResponseDto>(
await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false),
SerializerOptions,
cancellationToken).ConfigureAwait(false);
if (payload is null)
{
throw new InvalidOperationException("Authority export response payload was empty.");
}
var bundleBytes = Convert.FromBase64String(payload.Bundle.Data);
var digest = payload.Digest?.Value ?? string.Empty;
if (verbose)
{
logger.LogInformation("Received revocation export sequence {Sequence} (sha256:{Digest}, signing key {KeyId}).", payload.Sequence, digest, payload.SigningKeyId ?? "<unspecified>");
}
return new AuthorityRevocationExportResult
{
BundleBytes = bundleBytes,
Signature = payload.Signature?.Value ?? string.Empty,
Digest = digest,
Sequence = payload.Sequence,
IssuedAt = payload.IssuedAt,
SigningKeyId = payload.SigningKeyId
};
}
private async Task<string?> AcquireAccessTokenAsync(CancellationToken cancellationToken)
{
if (tokenClient is null)
{
return null;
}
lock (tokenSync)
{
if (!string.IsNullOrEmpty(cachedAccessToken) && cachedAccessTokenExpiresAt - TokenRefreshSkew > DateTimeOffset.UtcNow)
{
return cachedAccessToken;
}
}
var scope = AuthorityTokenUtilities.ResolveScope(options);
var token = await RequestAccessTokenAsync(scope, cancellationToken).ConfigureAwait(false);
lock (tokenSync)
{
cachedAccessToken = token.AccessToken;
cachedAccessTokenExpiresAt = token.ExpiresAtUtc;
return cachedAccessToken;
}
}
private async Task<StellaOpsTokenResult> RequestAccessTokenAsync(string scope, CancellationToken cancellationToken)
{
if (options.Authority is null)
{
throw new InvalidOperationException("Authority credentials are not configured.");
}
if (!string.IsNullOrWhiteSpace(options.Authority.Username))
{
if (string.IsNullOrWhiteSpace(options.Authority.Password))
{
throw new InvalidOperationException("Authority password must be configured or run 'auth login'.");
}
return await tokenClient!.RequestPasswordTokenAsync(
options.Authority.Username,
options.Authority.Password!,
scope,
cancellationToken).ConfigureAwait(false);
}
return await tokenClient!.RequestClientCredentialsTokenAsync(scope, cancellationToken).ConfigureAwait(false);
}
private void EnsureAuthorityConfigured()
{
if (string.IsNullOrWhiteSpace(options.Authority?.Url))
{
throw new InvalidOperationException("Authority URL is not configured. Set STELLAOPS_AUTHORITY_URL or update stellaops.yaml.");
}
if (httpClient.BaseAddress is null)
{
if (!Uri.TryCreate(options.Authority.Url, UriKind.Absolute, out var baseUri))
{
throw new InvalidOperationException("Authority URL is invalid.");
}
httpClient.BaseAddress = baseUri;
}
}
private sealed class ExportResponseDto
{
[JsonPropertyName("schemaVersion")]
public string SchemaVersion { get; set; } = string.Empty;
[JsonPropertyName("bundleId")]
public string BundleId { get; set; } = string.Empty;
[JsonPropertyName("sequence")]
public long Sequence { get; set; }
[JsonPropertyName("issuedAt")]
public DateTimeOffset IssuedAt { get; set; }
[JsonPropertyName("signingKeyId")]
public string? SigningKeyId { get; set; }
[JsonPropertyName("bundle")]
public ExportPayloadDto Bundle { get; set; } = new();
[JsonPropertyName("signature")]
public ExportSignatureDto? Signature { get; set; }
[JsonPropertyName("digest")]
public ExportDigestDto? Digest { get; set; }
}
private sealed class ExportPayloadDto
{
[JsonPropertyName("data")]
public string Data { get; set; } = string.Empty;
}
private sealed class ExportSignatureDto
{
[JsonPropertyName("algorithm")]
public string Algorithm { get; set; } = string.Empty;
[JsonPropertyName("keyId")]
public string KeyId { get; set; } = string.Empty;
[JsonPropertyName("value")]
public string Value { get; set; } = string.Empty;
}
private sealed class ExportDigestDto
{
[JsonPropertyName("value")]
public string Value { get; set; } = string.Empty;
}
}