using System; using System.Collections.Generic; using System.Linq; using System.Net.Http; using System.Net.Http.Headers; using System.Text; using System.Text.Json; using System.Globalization; 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; internal sealed class ConcelierObservationsClient : IConcelierObservationsClient { 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 ConcelierObservationsClient( 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.ConcelierUrl) && httpClient.BaseAddress is null) { if (Uri.TryCreate(options.ConcelierUrl, UriKind.Absolute, out var baseUri)) { httpClient.BaseAddress = baseUri; } } } public async Task GetObservationsAsync( AdvisoryObservationsQuery query, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(query); EnsureConfigured(); var requestUri = BuildRequestUri(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 query observations (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 AdvisoryObservationsResponse(); } /// /// Gets advisory linkset with conflict information. /// Per CLI-LNM-22-001. /// public async Task GetLinksetAsync( AdvisoryLinksetQuery query, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(query); EnsureConfigured(); var requestUri = BuildLinksetRequestUri(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 query linkset (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 AdvisoryLinksetResponse(); } /// /// Gets a single observation by ID. /// Per CLI-LNM-22-001. /// public async Task GetObservationByIdAsync( string tenant, string observationId, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrWhiteSpace(tenant); ArgumentException.ThrowIfNullOrWhiteSpace(observationId); EnsureConfigured(); var requestUri = $"/concelier/observations/{Uri.EscapeDataString(observationId)}?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 observation (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 BuildRequestUri(AdvisoryObservationsQuery query) { var builder = new StringBuilder("/concelier/observations?tenant="); builder.Append(Uri.EscapeDataString(query.Tenant)); AppendValues(builder, "observationId", query.ObservationIds); AppendValues(builder, "alias", query.Aliases); AppendValues(builder, "purl", query.Purls); AppendValues(builder, "cpe", query.Cpes); if (query.Limit.HasValue && query.Limit.Value > 0) { builder.Append('&'); builder.Append("limit="); builder.Append(query.Limit.Value.ToString(CultureInfo.InvariantCulture)); } if (!string.IsNullOrWhiteSpace(query.Cursor)) { builder.Append('&'); builder.Append("cursor="); builder.Append(Uri.EscapeDataString(query.Cursor)); } return builder.ToString(); static void AppendValues(StringBuilder builder, string name, IReadOnlyList values) { if (values is null || values.Count == 0) { return; } foreach (var value in values) { if (string.IsNullOrWhiteSpace(value)) { continue; } builder.Append('&'); builder.Append(name); builder.Append('='); builder.Append(Uri.EscapeDataString(value)); } } } private static string BuildLinksetRequestUri(AdvisoryLinksetQuery query) { var builder = new StringBuilder("/concelier/linkset?tenant="); builder.Append(Uri.EscapeDataString(query.Tenant)); AppendValues(builder, "observationId", query.ObservationIds); AppendValues(builder, "alias", query.Aliases); AppendValues(builder, "purl", query.Purls); AppendValues(builder, "cpe", query.Cpes); AppendValues(builder, "source", query.Sources); if (!string.IsNullOrWhiteSpace(query.Severity)) { builder.Append("&severity="); builder.Append(Uri.EscapeDataString(query.Severity)); } if (query.KevOnly.HasValue) { builder.Append("&kevOnly="); builder.Append(query.KevOnly.Value ? "true" : "false"); } if (query.HasFix.HasValue) { builder.Append("&hasFix="); builder.Append(query.HasFix.Value ? "true" : "false"); } if (query.Limit.HasValue && query.Limit.Value > 0) { builder.Append("&limit="); builder.Append(query.Limit.Value.ToString(CultureInfo.InvariantCulture)); } if (!string.IsNullOrWhiteSpace(query.Cursor)) { builder.Append("&cursor="); builder.Append(Uri.EscapeDataString(query.Cursor)); } return builder.ToString(); static void AppendValues(StringBuilder builder, string name, IReadOnlyList values) { if (values is null || values.Count == 0) { return; } foreach (var value in values) { if (string.IsNullOrWhiteSpace(value)) { continue; } builder.Append('&'); builder.Append(name); builder.Append('='); builder.Append(Uri.EscapeDataString(value)); } } } private void EnsureConfigured() { if (!string.IsNullOrWhiteSpace(options.ConcelierUrl)) { return; } throw new InvalidOperationException( "ConcelierUrl is not configured. Set StellaOps:ConcelierUrl or STELLAOPS_CONCELIER_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.VulnRead); 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); } }