using System; using System.Collections.Generic; using System.IO; using System.Net.Http; using System.Net.Http.Headers; using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using StellaOps.Auth.Abstractions; using StellaOps.Auth.Client; using StellaOps.Cli.Configuration; using StellaOps.Cli.Services.Models; namespace StellaOps.Cli.Services; /// /// Client for exception governance API operations. /// Per CLI-EXC-25-001. /// internal sealed class ExceptionClient : IExceptionClient { 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 logger; private readonly IStellaOpsTokenClient? tokenClient; private readonly object tokenSync = new(); private string? cachedAccessToken; private DateTimeOffset cachedAccessTokenExpiresAt = DateTimeOffset.MinValue; public ExceptionClient( HttpClient httpClient, StellaOpsCliOptions options, ILogger 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.BackendUrl) && httpClient.BaseAddress is null) { if (Uri.TryCreate(options.BackendUrl, UriKind.Absolute, out var baseUri)) { httpClient.BaseAddress = baseUri; } } } public async Task ListAsync( ExceptionListRequest request, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); try { EnsureConfigured(); var uri = BuildListUri(request); using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri); await AuthorizeRequestAsync(httpRequest, "exceptions.read", cancellationToken).ConfigureAwait(false); using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); logger.LogError( "Failed to list exceptions (status {StatusCode}). Response: {Payload}", (int)response.StatusCode, string.IsNullOrWhiteSpace(payload) ? "" : payload); return new ExceptionListResponse(); } await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); var result = await JsonSerializer .DeserializeAsync(stream, SerializerOptions, cancellationToken) .ConfigureAwait(false); return result ?? new ExceptionListResponse(); } catch (HttpRequestException ex) { logger.LogError(ex, "HTTP error while listing exceptions"); return new ExceptionListResponse(); } catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested) { logger.LogError(ex, "Request timed out while listing exceptions"); return new ExceptionListResponse(); } } public async Task GetAsync( string exceptionId, string? tenant, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrWhiteSpace(exceptionId); try { EnsureConfigured(); var uri = $"/api/v1/exceptions/{Uri.EscapeDataString(exceptionId)}"; if (!string.IsNullOrWhiteSpace(tenant)) { uri += $"?tenant={Uri.EscapeDataString(tenant)}"; } using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri); await AuthorizeRequestAsync(httpRequest, "exceptions.read", cancellationToken).ConfigureAwait(false); using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); if (response.StatusCode == System.Net.HttpStatusCode.NotFound) { return null; } if (!response.IsSuccessStatusCode) { var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); logger.LogError( "Failed to get exception (status {StatusCode}). Response: {Payload}", (int)response.StatusCode, string.IsNullOrWhiteSpace(payload) ? "" : payload); return null; } await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); return await JsonSerializer .DeserializeAsync(stream, SerializerOptions, cancellationToken) .ConfigureAwait(false); } catch (HttpRequestException ex) { logger.LogError(ex, "HTTP error while getting exception"); return null; } catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested) { logger.LogError(ex, "Request timed out while getting exception"); return null; } } public async Task CreateAsync( ExceptionCreateRequest request, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); try { EnsureConfigured(); var json = JsonSerializer.Serialize(request, SerializerOptions); using var content = new StringContent(json, Encoding.UTF8, "application/json"); using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/exceptions") { Content = content }; await AuthorizeRequestAsync(httpRequest, "exceptions.write", cancellationToken).ConfigureAwait(false); using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); logger.LogError( "Failed to create exception (status {StatusCode}). Response: {Payload}", (int)response.StatusCode, string.IsNullOrWhiteSpace(payload) ? "" : payload); return new ExceptionOperationResult { Success = false, Errors = [$"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"] }; } await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); var result = await JsonSerializer .DeserializeAsync(stream, SerializerOptions, cancellationToken) .ConfigureAwait(false); return result ?? new ExceptionOperationResult { Success = false, Errors = ["Empty response"] }; } catch (HttpRequestException ex) { logger.LogError(ex, "HTTP error while creating exception"); return new ExceptionOperationResult { Success = false, Errors = [$"Connection error: {ex.Message}"] }; } catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested) { logger.LogError(ex, "Request timed out while creating exception"); return new ExceptionOperationResult { Success = false, Errors = ["Request timed out"] }; } } public async Task PromoteAsync( ExceptionPromoteRequest request, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); try { EnsureConfigured(); var json = JsonSerializer.Serialize(request, SerializerOptions); using var content = new StringContent(json, Encoding.UTF8, "application/json"); using var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/exceptions/{Uri.EscapeDataString(request.ExceptionId)}/promote") { Content = content }; await AuthorizeRequestAsync(httpRequest, "exceptions.approve", cancellationToken).ConfigureAwait(false); using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); logger.LogError( "Failed to promote exception (status {StatusCode}). Response: {Payload}", (int)response.StatusCode, string.IsNullOrWhiteSpace(payload) ? "" : payload); return new ExceptionOperationResult { Success = false, Errors = [$"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"] }; } await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); var result = await JsonSerializer .DeserializeAsync(stream, SerializerOptions, cancellationToken) .ConfigureAwait(false); return result ?? new ExceptionOperationResult { Success = false, Errors = ["Empty response"] }; } catch (HttpRequestException ex) { logger.LogError(ex, "HTTP error while promoting exception"); return new ExceptionOperationResult { Success = false, Errors = [$"Connection error: {ex.Message}"] }; } catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested) { logger.LogError(ex, "Request timed out while promoting exception"); return new ExceptionOperationResult { Success = false, Errors = ["Request timed out"] }; } } public async Task RevokeAsync( ExceptionRevokeRequest request, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); try { EnsureConfigured(); var json = JsonSerializer.Serialize(request, SerializerOptions); using var content = new StringContent(json, Encoding.UTF8, "application/json"); using var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/exceptions/{Uri.EscapeDataString(request.ExceptionId)}/revoke") { Content = content }; await AuthorizeRequestAsync(httpRequest, "exceptions.write", cancellationToken).ConfigureAwait(false); using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); logger.LogError( "Failed to revoke exception (status {StatusCode}). Response: {Payload}", (int)response.StatusCode, string.IsNullOrWhiteSpace(payload) ? "" : payload); return new ExceptionOperationResult { Success = false, Errors = [$"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"] }; } await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); var result = await JsonSerializer .DeserializeAsync(stream, SerializerOptions, cancellationToken) .ConfigureAwait(false); return result ?? new ExceptionOperationResult { Success = false, Errors = ["Empty response"] }; } catch (HttpRequestException ex) { logger.LogError(ex, "HTTP error while revoking exception"); return new ExceptionOperationResult { Success = false, Errors = [$"Connection error: {ex.Message}"] }; } catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested) { logger.LogError(ex, "Request timed out while revoking exception"); return new ExceptionOperationResult { Success = false, Errors = ["Request timed out"] }; } } public async Task ImportAsync( ExceptionImportRequest request, Stream ndjsonStream, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(ndjsonStream); try { EnsureConfigured(); using var content = new MultipartFormDataContent(); var streamContent = new StreamContent(ndjsonStream); streamContent.Headers.ContentType = new MediaTypeHeaderValue("application/x-ndjson"); content.Add(streamContent, "file", "exceptions.ndjson"); content.Add(new StringContent(request.Tenant), "tenant"); content.Add(new StringContent(request.Stage.ToString().ToLowerInvariant()), "stage"); if (!string.IsNullOrWhiteSpace(request.Source)) { content.Add(new StringContent(request.Source), "source"); } using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/exceptions/import") { Content = content }; await AuthorizeRequestAsync(httpRequest, "exceptions.write", cancellationToken).ConfigureAwait(false); using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); logger.LogError( "Failed to import exceptions (status {StatusCode}). Response: {Payload}", (int)response.StatusCode, string.IsNullOrWhiteSpace(payload) ? "" : payload); return new ExceptionImportResult { Success = false, Errors = [new ExceptionImportError { Line = 0, Message = $"API returned {(int)response.StatusCode}: {response.ReasonPhrase}" }] }; } await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); var result = await JsonSerializer .DeserializeAsync(stream, SerializerOptions, cancellationToken) .ConfigureAwait(false); return result ?? new ExceptionImportResult { Success = false, Errors = [new ExceptionImportError { Line = 0, Message = "Empty response" }] }; } catch (HttpRequestException ex) { logger.LogError(ex, "HTTP error while importing exceptions"); return new ExceptionImportResult { Success = false, Errors = [new ExceptionImportError { Line = 0, Message = $"Connection error: {ex.Message}" }] }; } catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested) { logger.LogError(ex, "Request timed out while importing exceptions"); return new ExceptionImportResult { Success = false, Errors = [new ExceptionImportError { Line = 0, Message = "Request timed out" }] }; } } public async Task<(Stream Content, ExceptionExportManifest? Manifest)> ExportAsync( ExceptionExportRequest request, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); try { EnsureConfigured(); var queryParams = new List(); if (!string.IsNullOrWhiteSpace(request.Tenant)) { queryParams.Add($"tenant={Uri.EscapeDataString(request.Tenant)}"); } if (request.Statuses is { Count: > 0 }) { foreach (var status in request.Statuses) { queryParams.Add($"status={Uri.EscapeDataString(status)}"); } } queryParams.Add($"format={Uri.EscapeDataString(request.Format)}"); queryParams.Add($"includeManifest={request.IncludeManifest.ToString().ToLowerInvariant()}"); queryParams.Add($"signed={request.Signed.ToString().ToLowerInvariant()}"); var query = queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : string.Empty; var uri = $"/api/v1/exceptions/export{query}"; using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri); await AuthorizeRequestAsync(httpRequest, "exceptions.read", cancellationToken).ConfigureAwait(false); var response = await httpClient.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); logger.LogError( "Failed to export exceptions (status {StatusCode}). Response: {Payload}", (int)response.StatusCode, string.IsNullOrWhiteSpace(payload) ? "" : payload); return (Stream.Null, null); } // Parse manifest from header if present ExceptionExportManifest? manifest = null; if (response.Headers.TryGetValues("X-Export-Manifest", out var manifestValues)) { var manifestJson = string.Join("", manifestValues); if (!string.IsNullOrWhiteSpace(manifestJson)) { try { manifest = JsonSerializer.Deserialize(manifestJson, SerializerOptions); } catch (JsonException) { // Ignore parse errors for optional header } } } var contentStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); return (contentStream, manifest); } catch (HttpRequestException ex) { logger.LogError(ex, "HTTP error while exporting exceptions"); return (Stream.Null, null); } catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested) { logger.LogError(ex, "Request timed out while exporting exceptions"); return (Stream.Null, null); } } private static string BuildListUri(ExceptionListRequest request) { var queryParams = new List(); if (!string.IsNullOrWhiteSpace(request.Tenant)) { queryParams.Add($"tenant={Uri.EscapeDataString(request.Tenant)}"); } if (!string.IsNullOrWhiteSpace(request.Vuln)) { queryParams.Add($"vuln={Uri.EscapeDataString(request.Vuln)}"); } if (!string.IsNullOrWhiteSpace(request.ScopeType)) { queryParams.Add($"scopeType={Uri.EscapeDataString(request.ScopeType)}"); } if (!string.IsNullOrWhiteSpace(request.ScopeValue)) { queryParams.Add($"scopeValue={Uri.EscapeDataString(request.ScopeValue)}"); } if (request.Statuses is { Count: > 0 }) { foreach (var status in request.Statuses) { queryParams.Add($"status={Uri.EscapeDataString(status)}"); } } if (!string.IsNullOrWhiteSpace(request.Owner)) { queryParams.Add($"owner={Uri.EscapeDataString(request.Owner)}"); } if (!string.IsNullOrWhiteSpace(request.EffectType)) { queryParams.Add($"effectType={Uri.EscapeDataString(request.EffectType)}"); } if (request.ExpiringBefore.HasValue) { queryParams.Add($"expiringBefore={Uri.EscapeDataString(request.ExpiringBefore.Value.ToString("O"))}"); } if (request.IncludeExpired) { queryParams.Add("includeExpired=true"); } queryParams.Add($"pageSize={request.PageSize}"); if (!string.IsNullOrWhiteSpace(request.PageToken)) { queryParams.Add($"pageToken={Uri.EscapeDataString(request.PageToken)}"); } var query = queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : string.Empty; return $"/api/v1/exceptions{query}"; } private void EnsureConfigured() { if (string.IsNullOrWhiteSpace(options.BackendUrl) && httpClient.BaseAddress is null) { throw new InvalidOperationException( "Backend URL not configured. Set STELLAOPS_BACKEND_URL or use --backend-url."); } } private async Task AuthorizeRequestAsync(HttpRequestMessage request, string scope, CancellationToken cancellationToken) { var token = await GetAccessTokenAsync(scope, cancellationToken).ConfigureAwait(false); if (!string.IsNullOrWhiteSpace(token)) { request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); } } private async Task GetAccessTokenAsync(string scope, CancellationToken cancellationToken) { if (tokenClient is null) { return null; } lock (tokenSync) { if (cachedAccessToken is not null && DateTimeOffset.UtcNow < cachedAccessTokenExpiresAt - TokenRefreshSkew) { return cachedAccessToken; } } var result = await tokenClient.GetTokenAsync( new StellaOpsTokenRequest { Scopes = [scope] }, cancellationToken).ConfigureAwait(false); if (result.IsSuccess) { lock (tokenSync) { cachedAccessToken = result.AccessToken; cachedAccessTokenExpiresAt = result.ExpiresAt; } return result.AccessToken; } logger.LogWarning("Token acquisition failed: {Error}", result.Error); return null; } }