using System; using System.Collections.Generic; using System.Globalization; using System.Net.Http; using System.Net.Http.Headers; using System.Net.Http.Json; 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 forensic snapshot and evidence locker APIs. /// Per CLI-FORENSICS-53-001. /// internal sealed class ForensicSnapshotClient : IForensicSnapshotClient { 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 ForensicSnapshotClient( HttpClient httpClient, StellaOpsCliOptions options, ILogger logger, IStellaOpsTokenClient? tokenClient = null) { _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); _options = options ?? throw new ArgumentNullException(nameof(options)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _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 CreateSnapshotAsync( string tenant, ForensicSnapshotCreateRequest request, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrWhiteSpace(tenant); ArgumentNullException.ThrowIfNull(request); EnsureConfigured(); var requestUri = $"/forensic/snapshots?tenant={Uri.EscapeDataString(tenant)}"; using var httpRequest = new HttpRequestMessage(HttpMethod.Post, requestUri); await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false); httpRequest.Content = JsonContent.Create(request, options: SerializerOptions); 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 forensic snapshot (status {StatusCode}). Response: {Payload}", (int)response.StatusCode, string.IsNullOrWhiteSpace(payload) ? "" : payload); response.EnsureSuccessStatusCode(); } await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); var result = await JsonSerializer .DeserializeAsync(stream, SerializerOptions, cancellationToken) .ConfigureAwait(false); return result ?? throw new InvalidOperationException("Invalid response from forensic API."); } public async Task ListSnapshotsAsync( ForensicSnapshotListQuery query, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(query); EnsureConfigured(); var requestUri = BuildListRequestUri(query); using var request = new HttpRequestMessage(HttpMethod.Get, requestUri); await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false); using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); _logger.LogError( "Failed to list forensic snapshots (status {StatusCode}). Response: {Payload}", (int)response.StatusCode, string.IsNullOrWhiteSpace(payload) ? "" : payload); response.EnsureSuccessStatusCode(); } await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); var result = await JsonSerializer .DeserializeAsync(stream, SerializerOptions, cancellationToken) .ConfigureAwait(false); return result ?? new ForensicSnapshotListResponse(); } public async Task GetSnapshotAsync( string tenant, string snapshotId, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrWhiteSpace(tenant); ArgumentException.ThrowIfNullOrWhiteSpace(snapshotId); EnsureConfigured(); var requestUri = $"/forensic/snapshots/{Uri.EscapeDataString(snapshotId)}?tenant={Uri.EscapeDataString(tenant)}"; using var request = new HttpRequestMessage(HttpMethod.Get, requestUri); await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false); using var response = await _httpClient.SendAsync(request, 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 forensic snapshot (status {StatusCode}). Response: {Payload}", (int)response.StatusCode, string.IsNullOrWhiteSpace(payload) ? "" : payload); response.EnsureSuccessStatusCode(); } await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); return await JsonSerializer .DeserializeAsync(stream, SerializerOptions, cancellationToken) .ConfigureAwait(false); } public async Task GetSnapshotManifestAsync( string tenant, string snapshotId, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrWhiteSpace(tenant); ArgumentException.ThrowIfNullOrWhiteSpace(snapshotId); EnsureConfigured(); var requestUri = $"/forensic/snapshots/{Uri.EscapeDataString(snapshotId)}/manifest?tenant={Uri.EscapeDataString(tenant)}"; using var request = new HttpRequestMessage(HttpMethod.Get, requestUri); await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false); using var response = await _httpClient.SendAsync(request, 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 forensic snapshot manifest (status {StatusCode}). Response: {Payload}", (int)response.StatusCode, string.IsNullOrWhiteSpace(payload) ? "" : payload); response.EnsureSuccessStatusCode(); } await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); return await JsonSerializer .DeserializeAsync(stream, SerializerOptions, cancellationToken) .ConfigureAwait(false); } private static string BuildListRequestUri(ForensicSnapshotListQuery query) { var builder = new StringBuilder("/forensic/snapshots?tenant="); builder.Append(Uri.EscapeDataString(query.Tenant)); if (!string.IsNullOrWhiteSpace(query.CaseId)) { builder.Append("&caseId="); builder.Append(Uri.EscapeDataString(query.CaseId)); } if (!string.IsNullOrWhiteSpace(query.Status)) { builder.Append("&status="); builder.Append(Uri.EscapeDataString(query.Status)); } if (query.Tags is { Count: > 0 }) { foreach (var tag in query.Tags) { if (!string.IsNullOrWhiteSpace(tag)) { builder.Append("&tag="); builder.Append(Uri.EscapeDataString(tag)); } } } if (query.CreatedAfter.HasValue) { builder.Append("&createdAfter="); builder.Append(Uri.EscapeDataString(query.CreatedAfter.Value.ToString("O", CultureInfo.InvariantCulture))); } if (query.CreatedBefore.HasValue) { builder.Append("&createdBefore="); builder.Append(Uri.EscapeDataString(query.CreatedBefore.Value.ToString("O", CultureInfo.InvariantCulture))); } if (query.Limit.HasValue && query.Limit.Value > 0) { builder.Append("&limit="); builder.Append(query.Limit.Value.ToString(CultureInfo.InvariantCulture)); } if (query.Offset.HasValue && query.Offset.Value > 0) { builder.Append("&offset="); builder.Append(query.Offset.Value.ToString(CultureInfo.InvariantCulture)); } return builder.ToString(); } private void EnsureConfigured() { if (!string.IsNullOrWhiteSpace(_options.BackendUrl)) { return; } throw new InvalidOperationException( "BackendUrl is not configured. Set StellaOps:BackendUrl or STELLAOPS_BACKEND_URL."); } private async Task AuthorizeRequestAsync(HttpRequestMessage request, CancellationToken cancellationToken) { var token = await ResolveAccessTokenAsync(cancellationToken).ConfigureAwait(false); if (!string.IsNullOrWhiteSpace(token)) { request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); } } private async Task ResolveAccessTokenAsync(CancellationToken cancellationToken) { if (!string.IsNullOrWhiteSpace(_options.ApiKey)) { return _options.ApiKey; } if (_tokenClient is null || string.IsNullOrWhiteSpace(_options.Authority.Url)) { return null; } var now = DateTimeOffset.UtcNow; lock (_tokenSync) { if (!string.IsNullOrEmpty(_cachedAccessToken) && now < _cachedAccessTokenExpiresAt - TokenRefreshSkew) { return _cachedAccessToken; } } var (scope, cacheKey) = BuildScopeAndCacheKey(_options); var cachedEntry = await _tokenClient.GetCachedTokenAsync(cacheKey, cancellationToken).ConfigureAwait(false); if (cachedEntry is not null && now < cachedEntry.ExpiresAtUtc - TokenRefreshSkew) { lock (_tokenSync) { _cachedAccessToken = cachedEntry.AccessToken; _cachedAccessTokenExpiresAt = cachedEntry.ExpiresAtUtc; return _cachedAccessToken; } } StellaOpsTokenResult token; if (!string.IsNullOrWhiteSpace(_options.Authority.Username)) { if (string.IsNullOrWhiteSpace(_options.Authority.Password)) { throw new InvalidOperationException("Authority password must be configured when username is provided."); } token = await _tokenClient.RequestPasswordTokenAsync( _options.Authority.Username, _options.Authority.Password!, scope, null, cancellationToken).ConfigureAwait(false); } else { token = await _tokenClient.RequestClientCredentialsTokenAsync(scope, null, cancellationToken).ConfigureAwait(false); } await _tokenClient.CacheTokenAsync(cacheKey, token.ToCacheEntry(), cancellationToken).ConfigureAwait(false); lock (_tokenSync) { _cachedAccessToken = token.AccessToken; _cachedAccessTokenExpiresAt = token.ExpiresAtUtc; return _cachedAccessToken; } } private static (string Scope, string CacheKey) BuildScopeAndCacheKey(StellaOpsCliOptions options) { var baseScope = AuthorityTokenUtilities.ResolveScope(options); var finalScope = EnsureScope(baseScope, StellaOpsScopes.EvidenceRead); var credential = !string.IsNullOrWhiteSpace(options.Authority.Username) ? $"user:{options.Authority.Username}" : $"client:{options.Authority.ClientId}"; var cacheKey = $"{options.Authority.Url}|{credential}|{finalScope}"; return (finalScope, cacheKey); } private static string EnsureScope(string scopes, string required) { if (string.IsNullOrWhiteSpace(scopes)) { return required; } var parts = scopes .Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(static scope => scope.ToLowerInvariant()) .Distinct(StringComparer.Ordinal) .ToList(); if (!parts.Contains(required, StringComparer.Ordinal)) { parts.Add(required); } return string.Join(' ', parts); } }