using System.Net.Http.Json; using System.Text.Json; using Microsoft.Extensions.Options; using StellaOps.Policy.Registry.Contracts; namespace StellaOps.Policy.Registry; /// /// HTTP client implementation for Policy Registry API. /// public sealed class PolicyRegistryClient : IPolicyRegistryClient { private readonly HttpClient _httpClient; private readonly JsonSerializerOptions _jsonOptions; public PolicyRegistryClient(HttpClient httpClient, IOptions? options = null) { _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); _jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, PropertyNameCaseInsensitive = true }; if (options?.Value?.BaseUrl is not null && _httpClient.BaseAddress is null) { _httpClient.BaseAddress = new Uri(options.Value.BaseUrl); } } private static void AddTenantHeader(HttpRequestMessage request, Guid tenantId) { request.Headers.Add("X-Tenant-Id", tenantId.ToString()); } private static string BuildQueryString(PaginationParams? pagination, params (string name, string? value)[] additional) { var parts = new List(); if (pagination is not null) { if (pagination.PageSize != 20) { parts.Add($"page_size={pagination.PageSize}"); } if (!string.IsNullOrWhiteSpace(pagination.PageToken)) { parts.Add($"page_token={Uri.EscapeDataString(pagination.PageToken)}"); } } foreach (var (name, value) in additional) { if (!string.IsNullOrWhiteSpace(value)) { parts.Add($"{name}={Uri.EscapeDataString(value)}"); } } return parts.Count > 0 ? "?" + string.Join("&", parts) : string.Empty; } // ============================================================ // VERIFICATION POLICY OPERATIONS // ============================================================ public async Task ListVerificationPoliciesAsync( Guid tenantId, PaginationParams? pagination = null, CancellationToken cancellationToken = default) { var query = BuildQueryString(pagination); using var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/policy/verification-policies{query}"); AddTenantHeader(request, tenantId); var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("Failed to deserialize response"); } public async Task CreateVerificationPolicyAsync( Guid tenantId, CreateVerificationPolicyRequest request, CancellationToken cancellationToken = default) { using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/policy/verification-policies"); AddTenantHeader(httpRequest, tenantId); httpRequest.Content = JsonContent.Create(request, options: _jsonOptions); var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("Failed to deserialize response"); } public async Task GetVerificationPolicyAsync( Guid tenantId, string policyId, CancellationToken cancellationToken = default) { using var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/policy/verification-policies/{Uri.EscapeDataString(policyId)}"); AddTenantHeader(request, tenantId); var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("Failed to deserialize response"); } public async Task UpdateVerificationPolicyAsync( Guid tenantId, string policyId, UpdateVerificationPolicyRequest request, CancellationToken cancellationToken = default) { using var httpRequest = new HttpRequestMessage(HttpMethod.Put, $"/api/v1/policy/verification-policies/{Uri.EscapeDataString(policyId)}"); AddTenantHeader(httpRequest, tenantId); httpRequest.Content = JsonContent.Create(request, options: _jsonOptions); var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("Failed to deserialize response"); } public async Task DeleteVerificationPolicyAsync( Guid tenantId, string policyId, CancellationToken cancellationToken = default) { using var request = new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/policy/verification-policies/{Uri.EscapeDataString(policyId)}"); AddTenantHeader(request, tenantId); var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); } // ============================================================ // POLICY PACK OPERATIONS // ============================================================ public async Task ListPolicyPacksAsync( Guid tenantId, PolicyPackStatus? status = null, PaginationParams? pagination = null, CancellationToken cancellationToken = default) { var query = BuildQueryString(pagination, ("status", status?.ToString().ToLowerInvariant())); using var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/policy/packs{query}"); AddTenantHeader(request, tenantId); var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("Failed to deserialize response"); } public async Task CreatePolicyPackAsync( Guid tenantId, CreatePolicyPackRequest request, CancellationToken cancellationToken = default) { using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/policy/packs"); AddTenantHeader(httpRequest, tenantId); httpRequest.Content = JsonContent.Create(request, options: _jsonOptions); var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("Failed to deserialize response"); } public async Task GetPolicyPackAsync( Guid tenantId, Guid packId, CancellationToken cancellationToken = default) { using var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/policy/packs/{packId}"); AddTenantHeader(request, tenantId); var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("Failed to deserialize response"); } public async Task UpdatePolicyPackAsync( Guid tenantId, Guid packId, UpdatePolicyPackRequest request, CancellationToken cancellationToken = default) { using var httpRequest = new HttpRequestMessage(HttpMethod.Put, $"/api/v1/policy/packs/{packId}"); AddTenantHeader(httpRequest, tenantId); httpRequest.Content = JsonContent.Create(request, options: _jsonOptions); var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("Failed to deserialize response"); } public async Task DeletePolicyPackAsync( Guid tenantId, Guid packId, CancellationToken cancellationToken = default) { using var request = new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/policy/packs/{packId}"); AddTenantHeader(request, tenantId); var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); } public async Task CompilePolicyPackAsync( Guid tenantId, Guid packId, CancellationToken cancellationToken = default) { using var request = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/policy/packs/{packId}/compile"); AddTenantHeader(request, tenantId); var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); // Note: 422 also returns CompilationResult, so we read regardless of status return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("Failed to deserialize response"); } public async Task SimulatePolicyPackAsync( Guid tenantId, Guid packId, SimulationRequest request, CancellationToken cancellationToken = default) { using var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/policy/packs/{packId}/simulate"); AddTenantHeader(httpRequest, tenantId); httpRequest.Content = JsonContent.Create(request, options: _jsonOptions); var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("Failed to deserialize response"); } public async Task PublishPolicyPackAsync( Guid tenantId, Guid packId, PublishRequest? request = null, CancellationToken cancellationToken = default) { using var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/policy/packs/{packId}/publish"); AddTenantHeader(httpRequest, tenantId); if (request is not null) { httpRequest.Content = JsonContent.Create(request, options: _jsonOptions); } var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("Failed to deserialize response"); } public async Task PromotePolicyPackAsync( Guid tenantId, Guid packId, PromoteRequest? request = null, CancellationToken cancellationToken = default) { using var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/policy/packs/{packId}/promote"); AddTenantHeader(httpRequest, tenantId); if (request is not null) { httpRequest.Content = JsonContent.Create(request, options: _jsonOptions); } var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("Failed to deserialize response"); } // ============================================================ // SNAPSHOT OPERATIONS // ============================================================ public async Task ListSnapshotsAsync( Guid tenantId, PaginationParams? pagination = null, CancellationToken cancellationToken = default) { var query = BuildQueryString(pagination); using var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/policy/snapshots{query}"); AddTenantHeader(request, tenantId); var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("Failed to deserialize response"); } public async Task CreateSnapshotAsync( Guid tenantId, CreateSnapshotRequest request, CancellationToken cancellationToken = default) { using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/policy/snapshots"); AddTenantHeader(httpRequest, tenantId); httpRequest.Content = JsonContent.Create(request, options: _jsonOptions); var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("Failed to deserialize response"); } public async Task GetSnapshotAsync( Guid tenantId, Guid snapshotId, CancellationToken cancellationToken = default) { using var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/policy/snapshots/{snapshotId}"); AddTenantHeader(request, tenantId); var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("Failed to deserialize response"); } public async Task DeleteSnapshotAsync( Guid tenantId, Guid snapshotId, CancellationToken cancellationToken = default) { using var request = new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/policy/snapshots/{snapshotId}"); AddTenantHeader(request, tenantId); var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); } public async Task GetSnapshotByDigestAsync( Guid tenantId, string digest, CancellationToken cancellationToken = default) { using var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/policy/snapshots/by-digest/{Uri.EscapeDataString(digest)}"); AddTenantHeader(request, tenantId); var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("Failed to deserialize response"); } // ============================================================ // VIOLATION OPERATIONS // ============================================================ public async Task ListViolationsAsync( Guid tenantId, Severity? severity = null, PaginationParams? pagination = null, CancellationToken cancellationToken = default) { var query = BuildQueryString(pagination, ("severity", severity?.ToString().ToLowerInvariant())); using var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/policy/violations{query}"); AddTenantHeader(request, tenantId); var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("Failed to deserialize response"); } public async Task AppendViolationAsync( Guid tenantId, CreateViolationRequest request, CancellationToken cancellationToken = default) { using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/policy/violations"); AddTenantHeader(httpRequest, tenantId); httpRequest.Content = JsonContent.Create(request, options: _jsonOptions); var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("Failed to deserialize response"); } public async Task AppendViolationBatchAsync( Guid tenantId, ViolationBatchRequest request, CancellationToken cancellationToken = default) { using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/policy/violations/batch"); AddTenantHeader(httpRequest, tenantId); httpRequest.Content = JsonContent.Create(request, options: _jsonOptions); var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("Failed to deserialize response"); } public async Task GetViolationAsync( Guid tenantId, Guid violationId, CancellationToken cancellationToken = default) { using var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/policy/violations/{violationId}"); AddTenantHeader(request, tenantId); var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("Failed to deserialize response"); } // ============================================================ // OVERRIDE OPERATIONS // ============================================================ public async Task CreateOverrideAsync( Guid tenantId, CreateOverrideRequest request, CancellationToken cancellationToken = default) { using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/policy/overrides"); AddTenantHeader(httpRequest, tenantId); httpRequest.Content = JsonContent.Create(request, options: _jsonOptions); var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("Failed to deserialize response"); } public async Task GetOverrideAsync( Guid tenantId, Guid overrideId, CancellationToken cancellationToken = default) { using var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/policy/overrides/{overrideId}"); AddTenantHeader(request, tenantId); var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("Failed to deserialize response"); } public async Task DeleteOverrideAsync( Guid tenantId, Guid overrideId, CancellationToken cancellationToken = default) { using var request = new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/policy/overrides/{overrideId}"); AddTenantHeader(request, tenantId); var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); } public async Task ApproveOverrideAsync( Guid tenantId, Guid overrideId, ApproveOverrideRequest? request = null, CancellationToken cancellationToken = default) { using var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/policy/overrides/{overrideId}:approve"); AddTenantHeader(httpRequest, tenantId); if (request is not null) { httpRequest.Content = JsonContent.Create(request, options: _jsonOptions); } var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("Failed to deserialize response"); } public async Task DisableOverrideAsync( Guid tenantId, Guid overrideId, CancellationToken cancellationToken = default) { using var request = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/policy/overrides/{overrideId}:disable"); AddTenantHeader(request, tenantId); var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("Failed to deserialize response"); } // ============================================================ // SEALED MODE OPERATIONS // ============================================================ public async Task GetSealedModeStatusAsync( Guid tenantId, CancellationToken cancellationToken = default) { using var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/policy/sealed-mode/status"); AddTenantHeader(request, tenantId); var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("Failed to deserialize response"); } public async Task SealAsync( Guid tenantId, SealRequest? request = null, CancellationToken cancellationToken = default) { using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/policy/sealed-mode/seal"); AddTenantHeader(httpRequest, tenantId); if (request is not null) { httpRequest.Content = JsonContent.Create(request, options: _jsonOptions); } var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("Failed to deserialize response"); } public async Task UnsealAsync( Guid tenantId, UnsealRequest request, CancellationToken cancellationToken = default) { using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/policy/sealed-mode/unseal"); AddTenantHeader(httpRequest, tenantId); httpRequest.Content = JsonContent.Create(request, options: _jsonOptions); var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("Failed to deserialize response"); } public async Task VerifyBundleAsync( Guid tenantId, VerifyBundleRequest request, CancellationToken cancellationToken = default) { using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/policy/sealed-mode/verify"); AddTenantHeader(httpRequest, tenantId); httpRequest.Content = JsonContent.Create(request, options: _jsonOptions); var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("Failed to deserialize response"); } // ============================================================ // STALENESS OPERATIONS // ============================================================ public async Task GetStalenessStatusAsync( Guid tenantId, CancellationToken cancellationToken = default) { using var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/policy/staleness/status"); AddTenantHeader(request, tenantId); var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("Failed to deserialize response"); } public async Task EvaluateStalenessAsync( Guid tenantId, EvaluateStalenessRequest request, CancellationToken cancellationToken = default) { using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/policy/staleness/evaluate"); AddTenantHeader(httpRequest, tenantId); httpRequest.Content = JsonContent.Create(request, options: _jsonOptions); var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("Failed to deserialize response"); } } /// /// Configuration options for Policy Registry client. /// public sealed class PolicyRegistryClientOptions { public string? BaseUrl { get; set; } }