up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
This commit is contained in:
@@ -1,223 +1,223 @@
|
||||
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}, provider {Provider}).",
|
||||
payload.Sequence,
|
||||
digest,
|
||||
payload.SigningKeyId ?? "<unspecified>",
|
||||
string.IsNullOrWhiteSpace(payload.Signature?.Provider) ? "default" : payload.Signature!.Provider);
|
||||
}
|
||||
|
||||
return new AuthorityRevocationExportResult
|
||||
{
|
||||
BundleBytes = bundleBytes,
|
||||
Signature = payload.Signature?.Value ?? string.Empty,
|
||||
Digest = digest,
|
||||
Sequence = payload.Sequence,
|
||||
IssuedAt = payload.IssuedAt,
|
||||
SigningKeyId = payload.SigningKeyId,
|
||||
SigningProvider = payload.Signature?.Provider
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
null,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return await tokenClient!.RequestClientCredentialsTokenAsync(scope, null, 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("provider")]
|
||||
public string Provider { 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;
|
||||
}
|
||||
}
|
||||
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}, provider {Provider}).",
|
||||
payload.Sequence,
|
||||
digest,
|
||||
payload.SigningKeyId ?? "<unspecified>",
|
||||
string.IsNullOrWhiteSpace(payload.Signature?.Provider) ? "default" : payload.Signature!.Provider);
|
||||
}
|
||||
|
||||
return new AuthorityRevocationExportResult
|
||||
{
|
||||
BundleBytes = bundleBytes,
|
||||
Signature = payload.Signature?.Value ?? string.Empty,
|
||||
Digest = digest,
|
||||
Sequence = payload.Sequence,
|
||||
IssuedAt = payload.IssuedAt,
|
||||
SigningKeyId = payload.SigningKeyId,
|
||||
SigningProvider = payload.Signature?.Provider
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
null,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return await tokenClient!.RequestClientCredentialsTokenAsync(scope, null, 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("provider")]
|
||||
public string Provider { 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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user