feat: Implement policy attestation features and service account delegation
- Added new policy scopes: `policy:publish` and `policy:promote` with interactive-only enforcement. - Introduced metadata parameters for policy actions: `policy_reason`, `policy_ticket`, and `policy_digest`. - Enhanced token validation to require fresh authentication for policy attestation tokens. - Updated grant handlers to enforce policy scope checks and log audit information. - Implemented service account delegation configuration, including quotas and validation. - Seeded service accounts during application initialization based on configuration. - Updated documentation and tasks to reflect new features and changes.
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.AdvisoryAI.Abstractions;
|
||||
using StellaOps.AdvisoryAI.Chunking;
|
||||
using StellaOps.AdvisoryAI.Documents;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Retrievers;
|
||||
|
||||
internal sealed class AdvisoryStructuredRetriever : IAdvisoryStructuredRetriever
|
||||
{
|
||||
private readonly IAdvisoryDocumentProvider _documentProvider;
|
||||
private readonly DocumentChunkerFactory _chunkerFactory;
|
||||
private readonly ILogger<AdvisoryStructuredRetriever>? _logger;
|
||||
|
||||
public AdvisoryStructuredRetriever(
|
||||
IAdvisoryDocumentProvider documentProvider,
|
||||
IEnumerable<IDocumentChunker> chunkers,
|
||||
ILogger<AdvisoryStructuredRetriever>? logger = null)
|
||||
{
|
||||
_documentProvider = documentProvider ?? throw new ArgumentNullException(nameof(documentProvider));
|
||||
_chunkerFactory = new DocumentChunkerFactory(chunkers ?? throw new ArgumentNullException(nameof(chunkers)));
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<AdvisoryRetrievalResult> RetrieveAsync(AdvisoryRetrievalRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var documents = await _documentProvider.GetDocumentsAsync(request.AdvisoryKey, cancellationToken).ConfigureAwait(false);
|
||||
if (documents.Count == 0)
|
||||
{
|
||||
_logger?.LogWarning("No documents returned for advisory {AdvisoryKey}", request.AdvisoryKey);
|
||||
return AdvisoryRetrievalResult.Create(request.AdvisoryKey, Array.Empty<AdvisoryChunk>());
|
||||
}
|
||||
|
||||
var preferredSections = request.PreferredSections is null
|
||||
? null
|
||||
: request.PreferredSections.Select(section => section.Trim()).Where(static s => s.Length > 0).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var chunks = new List<AdvisoryChunk>();
|
||||
foreach (var document in documents)
|
||||
{
|
||||
var chunker = _chunkerFactory.Resolve(document.Format);
|
||||
foreach (var chunk in chunker.Chunk(document))
|
||||
{
|
||||
if (preferredSections is not null && !preferredSections.Contains(chunk.Section))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
chunks.Add(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
chunks.Sort(static (left, right) => string.CompareOrdinal(left.ChunkId, right.ChunkId));
|
||||
|
||||
if (request.MaxChunks is int max && max > 0 && chunks.Count > max)
|
||||
{
|
||||
chunks = chunks.Take(max).ToList();
|
||||
}
|
||||
|
||||
var metadata = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["documents"] = string.Join(",", documents.Select(d => d.DocumentId)),
|
||||
["chunk_count"] = chunks.Count.ToString(),
|
||||
};
|
||||
|
||||
return AdvisoryRetrievalResult.Create(request.AdvisoryKey, chunks, metadata);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using StellaOps.AdvisoryAI.Abstractions;
|
||||
using StellaOps.AdvisoryAI.Vectorization;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Retrievers;
|
||||
|
||||
internal sealed class AdvisoryVectorRetriever : IAdvisoryVectorRetriever
|
||||
{
|
||||
private readonly IAdvisoryStructuredRetriever _structuredRetriever;
|
||||
private readonly IVectorEncoder _encoder;
|
||||
|
||||
public AdvisoryVectorRetriever(IAdvisoryStructuredRetriever structuredRetriever, IVectorEncoder encoder)
|
||||
{
|
||||
_structuredRetriever = structuredRetriever ?? throw new ArgumentNullException(nameof(structuredRetriever));
|
||||
_encoder = encoder ?? throw new ArgumentNullException(nameof(encoder));
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<VectorRetrievalMatch>> SearchAsync(VectorRetrievalRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
if (request.TopK <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(request.TopK), "TopK must be a positive integer.");
|
||||
}
|
||||
|
||||
var retrieval = await _structuredRetriever.RetrieveAsync(request.Retrieval, cancellationToken).ConfigureAwait(false);
|
||||
if (retrieval.Chunks.Count == 0)
|
||||
{
|
||||
return Array.Empty<VectorRetrievalMatch>();
|
||||
}
|
||||
|
||||
var queryVector = _encoder.Encode(request.Query);
|
||||
var matches = new List<VectorRetrievalMatch>(retrieval.Chunks.Count);
|
||||
|
||||
foreach (var chunk in retrieval.Chunks)
|
||||
{
|
||||
var vector = chunk.Embedding ?? _encoder.Encode(chunk.Text);
|
||||
var score = CosineSimilarity(queryVector, vector);
|
||||
matches.Add(new VectorRetrievalMatch(chunk.DocumentId, chunk.ChunkId, chunk.Text, score, chunk.Metadata));
|
||||
}
|
||||
|
||||
matches.Sort(static (left, right) => right.Score.CompareTo(left.Score));
|
||||
if (matches.Count > request.TopK)
|
||||
{
|
||||
matches.RemoveRange(request.TopK, matches.Count - request.TopK);
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
private static double CosineSimilarity(float[] left, float[] right)
|
||||
{
|
||||
var length = Math.Min(left.Length, right.Length);
|
||||
double dot = 0;
|
||||
double leftNorm = 0;
|
||||
double rightNorm = 0;
|
||||
|
||||
for (var i = 0; i < length; i++)
|
||||
{
|
||||
var l = left[i];
|
||||
var r = right[i];
|
||||
dot += l * r;
|
||||
leftNorm += l * l;
|
||||
rightNorm += r * r;
|
||||
}
|
||||
|
||||
if (leftNorm <= 0 || rightNorm <= 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return dot / Math.Sqrt(leftNorm * rightNorm);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.AdvisoryAI.Abstractions;
|
||||
using StellaOps.AdvisoryAI.Context;
|
||||
using StellaOps.AdvisoryAI.Providers;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Retrievers;
|
||||
|
||||
internal sealed class SbomContextRetriever : ISbomContextRetriever
|
||||
{
|
||||
private readonly ISbomContextClient _client;
|
||||
private readonly ILogger<SbomContextRetriever>? _logger;
|
||||
|
||||
public SbomContextRetriever(ISbomContextClient client, ILogger<SbomContextRetriever>? logger = null)
|
||||
{
|
||||
_client = client ?? throw new ArgumentNullException(nameof(client));
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<SbomContextResult> RetrieveAsync(SbomContextRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var query = new SbomContextQuery(
|
||||
request.ArtifactId,
|
||||
request.Purl,
|
||||
request.MaxTimelineEntries,
|
||||
request.MaxDependencyPaths,
|
||||
request.IncludeEnvironmentFlags,
|
||||
request.IncludeBlastRadius);
|
||||
|
||||
SbomContextDocument? document;
|
||||
try
|
||||
{
|
||||
document = await _client.GetContextAsync(query, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogError(ex, "Failed to retrieve SBOM context for artifact {ArtifactId}", request.ArtifactId);
|
||||
document = null;
|
||||
}
|
||||
|
||||
if (document is null)
|
||||
{
|
||||
_logger?.LogWarning("No SBOM context returned for artifact {ArtifactId}", request.ArtifactId);
|
||||
return SbomContextResult.Empty(request.ArtifactId, request.Purl);
|
||||
}
|
||||
|
||||
var timeline = ShapeTimeline(document.Versions, request.MaxTimelineEntries);
|
||||
var paths = ShapeDependencyPaths(document.DependencyPaths, request.MaxDependencyPaths);
|
||||
var environmentFlags = request.IncludeEnvironmentFlags
|
||||
? NormalizeEnvironmentFlags(document.EnvironmentFlags)
|
||||
: ImmutableDictionary<string, string>.Empty;
|
||||
var blastRadius = request.IncludeBlastRadius ? ShapeBlastRadius(document.BlastRadius) : null;
|
||||
var metadata = BuildMetadata(document, timeline.Count, paths.Count, environmentFlags.Count, blastRadius is not null);
|
||||
|
||||
return SbomContextResult.Create(
|
||||
document.ArtifactId,
|
||||
document.Purl,
|
||||
timeline,
|
||||
paths,
|
||||
environmentFlags,
|
||||
blastRadius,
|
||||
metadata);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<SbomVersionTimelineEntry> ShapeTimeline(ImmutableArray<SbomVersionRecord> versions, int max)
|
||||
{
|
||||
if (versions.IsDefaultOrEmpty || max == 0)
|
||||
{
|
||||
return Array.Empty<SbomVersionTimelineEntry>();
|
||||
}
|
||||
|
||||
return versions
|
||||
.OrderBy(static v => v.FirstObserved)
|
||||
.ThenBy(static v => v.Version, StringComparer.Ordinal)
|
||||
.Take(max > 0 ? max : int.MaxValue)
|
||||
.Select(static v => new SbomVersionTimelineEntry(
|
||||
v.Version,
|
||||
v.FirstObserved,
|
||||
v.LastObserved,
|
||||
string.IsNullOrWhiteSpace(v.Status)
|
||||
? (v.IsFixAvailable ? "fixed" : "unknown")
|
||||
: v.Status.Trim(),
|
||||
string.IsNullOrWhiteSpace(v.Source) ? "sbom" : v.Source.Trim()))
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<SbomDependencyPath> ShapeDependencyPaths(ImmutableArray<SbomDependencyPathRecord> paths, int max)
|
||||
{
|
||||
if (paths.IsDefaultOrEmpty || max == 0)
|
||||
{
|
||||
return Array.Empty<SbomDependencyPath>();
|
||||
}
|
||||
|
||||
var distinct = new SortedDictionary<string, SbomDependencyPath>(StringComparer.Ordinal);
|
||||
foreach (var path in paths)
|
||||
{
|
||||
if (path.Nodes.IsDefaultOrEmpty)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var nodeList = path.Nodes
|
||||
.Select(static node => new SbomDependencyNode(node.Identifier, node.Version))
|
||||
.ToImmutableArray();
|
||||
|
||||
if (nodeList.IsDefaultOrEmpty)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = string.Join(
|
||||
"|",
|
||||
nodeList.Select(static n => string.Concat(n.Identifier, "@", n.Version ?? string.Empty)));
|
||||
|
||||
if (distinct.ContainsKey(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var dependencyPath = new SbomDependencyPath(
|
||||
nodeList,
|
||||
path.IsRuntime,
|
||||
string.IsNullOrWhiteSpace(path.Source) ? null : path.Source.Trim());
|
||||
|
||||
distinct[key] = dependencyPath;
|
||||
}
|
||||
|
||||
return distinct.Values
|
||||
.OrderBy(p => p.IsRuntime ? 0 : 1)
|
||||
.ThenBy(p => p.Nodes.Length)
|
||||
.ThenBy(p => string.Join("|", p.Nodes.Select(n => n.Identifier)), StringComparer.Ordinal)
|
||||
.Take(max > 0 ? max : int.MaxValue)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string> NormalizeEnvironmentFlags(ImmutableDictionary<string, string> flags)
|
||||
{
|
||||
if (flags == default || flags.IsEmpty)
|
||||
{
|
||||
return ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
var builder = ImmutableSortedDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var pair in flags)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pair.Key) || pair.Value is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
builder[pair.Key.Trim()] = pair.Value.Trim();
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static SbomBlastRadiusSummary? ShapeBlastRadius(SbomBlastRadiusRecord? record)
|
||||
{
|
||||
if (record is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var metadata = record.Metadata == default
|
||||
? ImmutableDictionary<string, string>.Empty
|
||||
: record.Metadata.ToImmutableDictionary(StringComparer.Ordinal);
|
||||
|
||||
return new SbomBlastRadiusSummary(
|
||||
record.ImpactedAssets,
|
||||
record.ImpactedWorkloads,
|
||||
record.ImpactedNamespaces,
|
||||
record.ImpactedPercentage,
|
||||
metadata);
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string> BuildMetadata(
|
||||
SbomContextDocument document,
|
||||
int timelineCount,
|
||||
int pathCount,
|
||||
int environmentFlagCount,
|
||||
bool hasBlastRadius)
|
||||
{
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var pair in document.Metadata)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pair.Key) || pair.Value is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
builder[pair.Key.Trim()] = pair.Value.Trim();
|
||||
}
|
||||
|
||||
builder["version_count"] = timelineCount.ToString();
|
||||
builder["dependency_path_count"] = pathCount.ToString();
|
||||
builder["environment_flag_count"] = environmentFlagCount.ToString();
|
||||
builder["blast_radius_present"] = hasBlastRadius.ToString();
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user