Add Ruby language analyzer and related functionality

- Introduced global usings for Ruby analyzer.
- Implemented RubyLockData, RubyLockEntry, and RubyLockParser for handling Gemfile.lock files.
- Created RubyPackage and RubyPackageCollector to manage Ruby packages and vendor cache.
- Developed RubyAnalyzerPlugin and RubyLanguageAnalyzer for analyzing Ruby projects.
- Added tests for Ruby language analyzer with sample Gemfile.lock and expected output.
- Included necessary project files and references for the Ruby analyzer.
- Added third-party licenses for tree-sitter dependencies.
This commit is contained in:
master
2025-11-03 01:15:43 +02:00
parent ff0eca3a51
commit bf2bf4b395
88 changed files with 6557 additions and 1568 deletions

View File

@@ -0,0 +1,35 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using StellaOps.AdvisoryAI.Orchestration;
using StellaOps.AdvisoryAI.Tools;
namespace StellaOps.AdvisoryAI.DependencyInjection;
public static class ToolsetServiceCollectionExtensions
{
public static IServiceCollection AddAdvisoryDeterministicToolset(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
services.TryAddSingleton<IDeterministicToolset, DeterministicToolset>();
return services;
}
public static IServiceCollection AddAdvisoryPipeline(this IServiceCollection services, Action<AdvisoryPipelineOptions>? configure = null)
{
ArgumentNullException.ThrowIfNull(services);
services.AddAdvisoryDeterministicToolset();
var optionsBuilder = services.AddOptions<AdvisoryPipelineOptions>();
optionsBuilder.Configure(options => options.ApplyDefaults());
if (configure is not null)
{
optionsBuilder.Configure(configure);
}
services.TryAddSingleton<IAdvisoryPipelineOrchestrator, AdvisoryPipelineOrchestrator>();
return services;
}
}

View File

@@ -0,0 +1,27 @@
using System;
using System.Collections.Generic;
namespace StellaOps.AdvisoryAI.Orchestration;
/// <summary>
/// Queue payload sent to workers to execute a pipeline plan.
/// </summary>
public sealed class AdvisoryPipelineExecutionMessage
{
public AdvisoryPipelineExecutionMessage(
string planCacheKey,
AdvisoryTaskRequest request,
IReadOnlyDictionary<string, string> planMetadata)
{
ArgumentException.ThrowIfNullOrWhiteSpace(planCacheKey);
PlanCacheKey = planCacheKey;
Request = request ?? throw new ArgumentNullException(nameof(request));
PlanMetadata = planMetadata ?? throw new ArgumentNullException(nameof(planMetadata));
}
public string PlanCacheKey { get; }
public AdvisoryTaskRequest Request { get; }
public IReadOnlyDictionary<string, string> PlanMetadata { get; }
}

View File

@@ -0,0 +1,159 @@
using System.Collections.ObjectModel;
namespace StellaOps.AdvisoryAI.Orchestration;
public sealed class AdvisoryPipelineOptions
{
private static readonly AdvisoryTaskConfiguration SummaryDefaults = new()
{
PromptTemplate = "prompts/advisory/summary.liquid",
StructuredMaxChunks = 25,
VectorTopK = 5,
SbomMaxTimelineEntries = 10,
SbomMaxDependencyPaths = 20,
IncludeEnvironmentFlags = true,
IncludeBlastRadius = true,
Budget = new AdvisoryTaskBudget { PromptTokens = 2048, CompletionTokens = 512 },
VectorQueries = { "Summarize key facts", "What is impacted?" },
};
private static readonly AdvisoryTaskConfiguration ConflictDefaults = new()
{
PromptTemplate = "prompts/advisory/conflict.liquid",
StructuredMaxChunks = 30,
VectorTopK = 6,
SbomMaxTimelineEntries = 8,
SbomMaxDependencyPaths = 15,
IncludeEnvironmentFlags = true,
IncludeBlastRadius = false,
Budget = new AdvisoryTaskBudget { PromptTokens = 2048, CompletionTokens = 512 },
VectorQueries = { "Highlight conflicting statements", "Where do sources disagree?" },
};
private static readonly AdvisoryTaskConfiguration RemediationDefaults = new()
{
PromptTemplate = "prompts/advisory/remediation.liquid",
StructuredMaxChunks = 35,
VectorTopK = 6,
SbomMaxTimelineEntries = 12,
SbomMaxDependencyPaths = 25,
IncludeEnvironmentFlags = true,
IncludeBlastRadius = true,
Budget = new AdvisoryTaskBudget { PromptTokens = 2048, CompletionTokens = 640 },
VectorQueries = { "Provide remediation steps", "Outline mitigations and fixes" },
};
public IDictionary<AdvisoryTaskType, AdvisoryTaskConfiguration> Tasks { get; } =
new Dictionary<AdvisoryTaskType, AdvisoryTaskConfiguration>
{
[AdvisoryTaskType.Summary] = SummaryDefaults.Clone(),
[AdvisoryTaskType.Conflict] = ConflictDefaults.Clone(),
[AdvisoryTaskType.Remediation] = RemediationDefaults.Clone(),
};
public void ApplyDefaults()
{
if (!Tasks.ContainsKey(AdvisoryTaskType.Summary))
{
Tasks[AdvisoryTaskType.Summary] = SummaryDefaults.Clone();
}
if (!Tasks.ContainsKey(AdvisoryTaskType.Conflict))
{
Tasks[AdvisoryTaskType.Conflict] = ConflictDefaults.Clone();
}
if (!Tasks.ContainsKey(AdvisoryTaskType.Remediation))
{
Tasks[AdvisoryTaskType.Remediation] = RemediationDefaults.Clone();
}
foreach (var entry in Tasks)
{
entry.Value.ApplyDefaults();
}
}
public AdvisoryTaskConfiguration GetConfiguration(AdvisoryTaskType taskType)
{
ApplyDefaults();
if (!Tasks.TryGetValue(taskType, out var configuration))
{
throw new InvalidOperationException($"No configuration registered for task type '{taskType}'.");
}
configuration.ApplyDefaults();
return configuration;
}
}
public sealed class AdvisoryTaskConfiguration
{
public string PromptTemplate { get; set; } = string.Empty;
public List<string> VectorQueries { get; set; } = new();
public int VectorTopK { get; set; }
public int StructuredMaxChunks { get; set; }
public int SbomMaxTimelineEntries { get; set; }
public int SbomMaxDependencyPaths { get; set; }
public bool IncludeEnvironmentFlags { get; set; }
public bool IncludeBlastRadius { get; set; }
public AdvisoryTaskBudget Budget { get; set; } = new();
internal AdvisoryTaskConfiguration Clone()
{
var clone = (AdvisoryTaskConfiguration)MemberwiseClone();
clone.VectorQueries = new List<string>(VectorQueries);
clone.Budget = new AdvisoryTaskBudget
{
PromptTokens = Budget.PromptTokens,
CompletionTokens = Budget.CompletionTokens,
};
return clone;
}
internal void ApplyDefaults()
{
if (string.IsNullOrWhiteSpace(PromptTemplate))
{
PromptTemplate = "prompts/advisory/default.liquid";
}
if (VectorQueries.Count == 0)
{
VectorQueries.Add("Provide relevant advisory details");
}
if (VectorTopK <= 0)
{
VectorTopK = 5;
}
if (StructuredMaxChunks <= 0)
{
StructuredMaxChunks = 20;
}
if (SbomMaxTimelineEntries < 0)
{
SbomMaxTimelineEntries = 0;
}
if (SbomMaxDependencyPaths < 0)
{
SbomMaxDependencyPaths = 0;
}
Budget ??= new AdvisoryTaskBudget();
}
internal IReadOnlyList<string> GetVectorQueries()
=> new ReadOnlyCollection<string>(VectorQueries);
}

View File

@@ -0,0 +1,230 @@
using System.Collections.Immutable;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.AdvisoryAI.Abstractions;
using StellaOps.AdvisoryAI.Context;
using StellaOps.AdvisoryAI.Tools;
namespace StellaOps.AdvisoryAI.Orchestration;
internal sealed class AdvisoryPipelineOrchestrator : IAdvisoryPipelineOrchestrator
{
private readonly IAdvisoryStructuredRetriever _structuredRetriever;
private readonly IAdvisoryVectorRetriever _vectorRetriever;
private readonly ISbomContextRetriever _sbomContextRetriever;
private readonly IDeterministicToolset _toolset;
private readonly AdvisoryPipelineOptions _options;
private readonly ILogger<AdvisoryPipelineOrchestrator>? _logger;
public AdvisoryPipelineOrchestrator(
IAdvisoryStructuredRetriever structuredRetriever,
IAdvisoryVectorRetriever vectorRetriever,
ISbomContextRetriever sbomContextRetriever,
IDeterministicToolset toolset,
IOptions<AdvisoryPipelineOptions> options,
ILogger<AdvisoryPipelineOrchestrator>? logger = null)
{
_structuredRetriever = structuredRetriever ?? throw new ArgumentNullException(nameof(structuredRetriever));
_vectorRetriever = vectorRetriever ?? throw new ArgumentNullException(nameof(vectorRetriever));
_sbomContextRetriever = sbomContextRetriever ?? throw new ArgumentNullException(nameof(sbomContextRetriever));
_toolset = toolset ?? throw new ArgumentNullException(nameof(toolset));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_options.ApplyDefaults();
_logger = logger;
}
public async Task<AdvisoryTaskPlan> CreatePlanAsync(AdvisoryTaskRequest request, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
var config = _options.GetConfiguration(request.TaskType);
var structuredRequest = new AdvisoryRetrievalRequest(
request.AdvisoryKey,
request.PreferredSections,
config.StructuredMaxChunks);
var structured = await _structuredRetriever
.RetrieveAsync(structuredRequest, cancellationToken)
.ConfigureAwait(false);
var vectorResults = await RetrieveVectorMatchesAsync(request, structuredRequest, config, cancellationToken).ConfigureAwait(false);
var (sbomContext, dependencyAnalysis) = await RetrieveSbomContextAsync(request, config, cancellationToken).ConfigureAwait(false);
var metadata = BuildMetadata(request, structured, vectorResults, sbomContext, dependencyAnalysis);
var cacheKey = ComputeCacheKey(request, structured, vectorResults, sbomContext, dependencyAnalysis);
var plan = new AdvisoryTaskPlan(
request,
cacheKey,
config.PromptTemplate,
structured.Chunks.ToImmutableArray(),
vectorResults,
sbomContext,
dependencyAnalysis,
config.Budget,
metadata);
return plan;
}
private async Task<ImmutableArray<AdvisoryVectorResult>> RetrieveVectorMatchesAsync(
AdvisoryTaskRequest request,
AdvisoryRetrievalRequest structuredRequest,
AdvisoryTaskConfiguration configuration,
CancellationToken cancellationToken)
{
if (configuration.VectorQueries.Count == 0)
{
return ImmutableArray<AdvisoryVectorResult>.Empty;
}
var builder = ImmutableArray.CreateBuilder<AdvisoryVectorResult>(configuration.VectorQueries.Count);
foreach (var query in configuration.GetVectorQueries())
{
var vectorRequest = new VectorRetrievalRequest(structuredRequest, query, configuration.VectorTopK);
var matches = await _vectorRetriever
.SearchAsync(vectorRequest, cancellationToken)
.ConfigureAwait(false);
builder.Add(new AdvisoryVectorResult(query, matches.ToImmutableArray()));
}
return builder.MoveToImmutable();
}
private async Task<(SbomContextResult? Context, DependencyAnalysisResult? Analysis)> RetrieveSbomContextAsync(
AdvisoryTaskRequest request,
AdvisoryTaskConfiguration configuration,
CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(request.ArtifactId))
{
return (null, null);
}
var sbomRequest = new SbomContextRequest(
artifactId: request.ArtifactId!,
purl: request.ArtifactPurl,
maxTimelineEntries: configuration.SbomMaxTimelineEntries,
maxDependencyPaths: configuration.SbomMaxDependencyPaths,
includeEnvironmentFlags: configuration.IncludeEnvironmentFlags,
includeBlastRadius: configuration.IncludeBlastRadius);
var context = await _sbomContextRetriever
.RetrieveAsync(sbomRequest, cancellationToken)
.ConfigureAwait(false);
var analysis = _toolset.AnalyzeDependencies(context);
return (context, analysis);
}
private static ImmutableDictionary<string, string> BuildMetadata(
AdvisoryTaskRequest request,
AdvisoryRetrievalResult structured,
ImmutableArray<AdvisoryVectorResult> vectors,
SbomContextResult? sbom,
DependencyAnalysisResult? dependency)
{
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
builder["task_type"] = request.TaskType.ToString();
builder["advisory_key"] = request.AdvisoryKey;
builder["profile"] = request.Profile;
builder["structured_chunk_count"] = structured.Chunks.Count.ToString(CultureInfo.InvariantCulture);
builder["vector_query_count"] = vectors.Length.ToString(CultureInfo.InvariantCulture);
builder["vector_match_count"] = vectors.Sum(result => result.Matches.Length).ToString(CultureInfo.InvariantCulture);
builder["includes_sbom"] = (sbom is not null).ToString();
builder["dependency_node_count"] = (dependency?.Nodes.Length ?? 0).ToString(CultureInfo.InvariantCulture);
builder["force_refresh"] = request.ForceRefresh.ToString();
if (!string.IsNullOrEmpty(request.PolicyVersion))
{
builder["policy_version"] = request.PolicyVersion!;
}
if (sbom is not null)
{
builder["sbom_version_count"] = sbom.VersionTimeline.Count.ToString(CultureInfo.InvariantCulture);
builder["sbom_dependency_path_count"] = sbom.DependencyPaths.Count.ToString(CultureInfo.InvariantCulture);
}
return builder.ToImmutable();
}
private static string ComputeCacheKey(
AdvisoryTaskRequest request,
AdvisoryRetrievalResult structured,
ImmutableArray<AdvisoryVectorResult> vectors,
SbomContextResult? sbom,
DependencyAnalysisResult? dependency)
{
var builder = new StringBuilder();
builder.Append(request.TaskType)
.Append('|').Append(request.AdvisoryKey)
.Append('|').Append(request.ArtifactId ?? string.Empty)
.Append('|').Append(request.PolicyVersion ?? string.Empty)
.Append('|').Append(request.Profile);
if (request.PreferredSections is not null)
{
foreach (var section in request.PreferredSections.OrderBy(s => s, StringComparer.OrdinalIgnoreCase))
{
builder.Append('|').Append(section);
}
}
foreach (var chunkId in structured.Chunks
.Select(chunk => chunk.ChunkId)
.OrderBy(id => id, StringComparer.Ordinal))
{
builder.Append("|chunk:").Append(chunkId);
}
foreach (var vector in vectors)
{
builder.Append("|query:").Append(vector.Query);
foreach (var match in vector.Matches
.OrderBy(m => m.ChunkId, StringComparer.Ordinal)
.ThenBy(m => m.Score))
{
builder.Append("|match:")
.Append(match.ChunkId)
.Append('@')
.Append(match.Score.ToString("G", CultureInfo.InvariantCulture));
}
}
if (sbom is not null)
{
builder.Append("|sbom:timeline=").Append(sbom.VersionTimeline.Count);
builder.Append("|sbom:paths=").Append(sbom.DependencyPaths.Count);
foreach (var kvp in sbom.Metadata.OrderBy(k => k.Key, StringComparer.Ordinal))
{
builder.Append("|sbommeta:")
.Append(kvp.Key)
.Append('=')
.Append(kvp.Value);
}
}
if (dependency is not null)
{
foreach (var node in dependency.Nodes
.OrderBy(n => n.Identifier, StringComparer.Ordinal))
{
builder.Append("|dep:")
.Append(node.Identifier)
.Append(':')
.Append(node.RuntimeOccurrences)
.Append(':')
.Append(node.DevelopmentOccurrences);
}
}
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString()));
return Convert.ToHexString(hash);
}
}

View File

@@ -0,0 +1,110 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using StellaOps.AdvisoryAI.Tools;
namespace StellaOps.AdvisoryAI.Orchestration;
/// <summary>
/// DTO exposed via service/CLI APIs describing the orchestration plan without leaking raw chunks.
/// </summary>
public sealed class AdvisoryPipelinePlanResponse
{
private AdvisoryPipelinePlanResponse(
string taskType,
string cacheKey,
string promptTemplate,
AdvisoryTaskBudget budget,
IReadOnlyList<PipelineChunkSummary> chunks,
IReadOnlyList<PipelineVectorSummary> vectors,
PipelineSbomSummary? sbom,
IReadOnlyDictionary<string, string> metadata)
{
TaskType = taskType;
CacheKey = cacheKey;
PromptTemplate = promptTemplate;
Budget = budget;
Chunks = chunks;
Vectors = vectors;
Sbom = sbom;
Metadata = metadata;
}
public string TaskType { get; }
public string CacheKey { get; }
public string PromptTemplate { get; }
public AdvisoryTaskBudget Budget { get; }
public IReadOnlyList<PipelineChunkSummary> Chunks { get; }
public IReadOnlyList<PipelineVectorSummary> Vectors { get; }
public PipelineSbomSummary? Sbom { get; }
public IReadOnlyDictionary<string, string> Metadata { get; }
public static AdvisoryPipelinePlanResponse FromPlan(AdvisoryTaskPlan plan)
{
ArgumentNullException.ThrowIfNull(plan);
var chunkSummaries = plan.StructuredChunks
.Select(chunk => new PipelineChunkSummary(
chunk.DocumentId,
chunk.ChunkId,
chunk.Section,
chunk.Metadata.ContainsKey("section") ? chunk.Metadata["section"] : chunk.Section))
.ToImmutableArray();
var vectorSummaries = plan.VectorResults
.Select(vector => new PipelineVectorSummary(
vector.Query,
vector.Matches
.Select(match => new PipelineVectorMatchSummary(match.ChunkId, match.Score))
.ToImmutableArray()))
.ToImmutableArray();
PipelineSbomSummary? sbomSummary = null;
if (plan.SbomContext is not null)
{
sbomSummary = new PipelineSbomSummary(
plan.SbomContext.ArtifactId,
plan.SbomContext.VersionTimeline.Count,
plan.SbomContext.DependencyPaths.Count,
plan.DependencyAnalysis?.Nodes.Length ?? 0);
}
return new AdvisoryPipelinePlanResponse(
plan.Request.TaskType.ToString(),
plan.CacheKey,
plan.PromptTemplate,
plan.Budget,
chunkSummaries,
vectorSummaries,
sbomSummary,
plan.Metadata);
}
}
public sealed record PipelineChunkSummary(
string DocumentId,
string ChunkId,
string Section,
string DisplaySection);
public sealed record PipelineVectorSummary(
string Query,
ImmutableArray<PipelineVectorMatchSummary> Matches);
public sealed record PipelineVectorMatchSummary(
string ChunkId,
double Score);
public sealed record PipelineSbomSummary(
string ArtifactId,
int VersionTimelineCount,
int DependencyPathCount,
int DependencyNodeCount);

View File

@@ -0,0 +1,69 @@
using System.Collections.Immutable;
using StellaOps.AdvisoryAI.Abstractions;
using StellaOps.AdvisoryAI.Context;
using StellaOps.AdvisoryAI.Tools;
namespace StellaOps.AdvisoryAI.Orchestration;
public sealed class AdvisoryTaskPlan
{
public AdvisoryTaskPlan(
AdvisoryTaskRequest request,
string cacheKey,
string promptTemplate,
ImmutableArray<AdvisoryChunk> structuredChunks,
ImmutableArray<AdvisoryVectorResult> vectorResults,
SbomContextResult? sbomContext,
DependencyAnalysisResult? dependencyAnalysis,
AdvisoryTaskBudget budget,
ImmutableDictionary<string, string> metadata)
{
Request = request ?? throw new ArgumentNullException(nameof(request));
CacheKey = cacheKey ?? throw new ArgumentNullException(nameof(cacheKey));
PromptTemplate = promptTemplate ?? throw new ArgumentNullException(nameof(promptTemplate));
StructuredChunks = structuredChunks;
VectorResults = vectorResults;
SbomContext = sbomContext;
DependencyAnalysis = dependencyAnalysis;
Budget = budget ?? throw new ArgumentNullException(nameof(budget));
Metadata = metadata ?? throw new ArgumentNullException(nameof(metadata));
}
public AdvisoryTaskRequest Request { get; }
public string CacheKey { get; }
public string PromptTemplate { get; }
public ImmutableArray<AdvisoryChunk> StructuredChunks { get; }
public ImmutableArray<AdvisoryVectorResult> VectorResults { get; }
public SbomContextResult? SbomContext { get; }
public DependencyAnalysisResult? DependencyAnalysis { get; }
public AdvisoryTaskBudget Budget { get; }
public ImmutableDictionary<string, string> Metadata { get; }
}
public sealed class AdvisoryVectorResult
{
public AdvisoryVectorResult(string query, ImmutableArray<VectorRetrievalMatch> matches)
{
Query = string.IsNullOrWhiteSpace(query) ? throw new ArgumentException(nameof(query)) : query;
Matches = matches;
}
public string Query { get; }
public ImmutableArray<VectorRetrievalMatch> Matches { get; }
}
public sealed class AdvisoryTaskBudget
{
public int PromptTokens { get; init; } = 2048;
public int CompletionTokens { get; init; } = 512;
}

View File

@@ -0,0 +1,49 @@
using System.Collections.Generic;
namespace StellaOps.AdvisoryAI.Orchestration;
public sealed class AdvisoryTaskRequest
{
public AdvisoryTaskRequest(
AdvisoryTaskType taskType,
string advisoryKey,
string? artifactId = null,
string? artifactPurl = null,
string? policyVersion = null,
string profile = "default",
IReadOnlyCollection<string>? preferredSections = null,
bool forceRefresh = false)
{
if (!Enum.IsDefined(typeof(AdvisoryTaskType), taskType))
{
throw new ArgumentOutOfRangeException(nameof(taskType));
}
ArgumentException.ThrowIfNullOrWhiteSpace(advisoryKey);
TaskType = taskType;
AdvisoryKey = advisoryKey.Trim();
ArtifactId = string.IsNullOrWhiteSpace(artifactId) ? null : artifactId.Trim();
ArtifactPurl = string.IsNullOrWhiteSpace(artifactPurl) ? null : artifactPurl.Trim();
PolicyVersion = string.IsNullOrWhiteSpace(policyVersion) ? null : policyVersion.Trim();
Profile = string.IsNullOrWhiteSpace(profile) ? "default" : profile.Trim();
PreferredSections = preferredSections;
ForceRefresh = forceRefresh;
}
public AdvisoryTaskType TaskType { get; }
public string AdvisoryKey { get; }
public string? ArtifactId { get; }
public string? ArtifactPurl { get; }
public string? PolicyVersion { get; }
public string Profile { get; }
public IReadOnlyCollection<string>? PreferredSections { get; }
public bool ForceRefresh { get; }
}

View File

@@ -0,0 +1,8 @@
namespace StellaOps.AdvisoryAI.Orchestration;
public enum AdvisoryTaskType
{
Summary,
Conflict,
Remediation,
}

View File

@@ -0,0 +1,9 @@
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.AdvisoryAI.Orchestration;
public interface IAdvisoryPipelineOrchestrator
{
Task<AdvisoryTaskPlan> CreatePlanAsync(AdvisoryTaskRequest request, CancellationToken cancellationToken);
}

View File

@@ -3,8 +3,11 @@
|----|--------|----------|------------|-------------|---------------|
| AIAI-31-001 | DONE (2025-11-02) | Advisory AI Guild | CONCELIER-VULN-29-001, EXCITITOR-VULN-29-001 | Implement structured and vector retrievers for advisories/VEX with paragraph anchors and citation metadata. | Retrievers return deterministic chunks with source IDs/sections; unit tests cover CSAF/OSV/vendor formats. |
| AIAI-31-002 | DOING | Advisory AI Guild, SBOM Service Guild | SBOM-VULN-29-001 | Build SBOM context retriever (purl version timelines, dependency paths, env flags, blast radius estimator). | Retriever returns paths/metrics under SLA; tests cover ecosystems. |
| AIAI-31-003 | TODO | Advisory AI Guild | AIAI-31-001..002 | Implement deterministic toolset (version comparators, range checks, dependency analysis, policy lookup) exposed via orchestrator. | Tools validated with property tests; outputs cached; docs updated. |
| AIAI-31-004 | TODO | Advisory AI Guild | AIAI-31-001..003, AUTH-VULN-29-001 | Build orchestration pipeline for Summary/Conflict/Remediation tasks (prompt templates, tool calls, token budgets, caching). | Pipeline executes tasks deterministically; caches keyed by tuple+policy; integration tests cover tasks. |
| AIAI-31-003 | DOING | Advisory AI Guild | AIAI-31-001..002 | Implement deterministic toolset (version comparators, range checks, dependency analysis, policy lookup) exposed via orchestrator. | Tools validated with property tests; outputs cached; docs updated. |
| AIAI-31-004 | DOING | Advisory AI Guild | AIAI-31-001..003, AUTH-VULN-29-001 | Build orchestration pipeline for Summary/Conflict/Remediation tasks (prompt templates, tool calls, token budgets, caching). | Pipeline executes tasks deterministically; caches keyed by tuple+policy; integration tests cover tasks. |
| AIAI-31-004A | TODO | Advisory AI Guild, Platform Guild | AIAI-31-004, AIAI-31-002 | Wire `AdvisoryPipelineOrchestrator` into WebService/Worker, expose API/queue contracts, emit metrics, and stand up cache stub. | API returns plan metadata; worker executes queue message; metrics recorded; doc updated. |
| AIAI-31-004B | TODO | Advisory AI Guild, Security Guild | AIAI-31-004A, DOCS-AIAI-31-003, AUTH-AIAI-31-004 | Implement prompt assembler, guardrail plumbing, cache persistence, DSSE provenance; add golden outputs. | Deterministic outputs cached; guardrails enforced; tests cover prompt assembly + caching. |
| AIAI-31-004C | TODO | Advisory AI Guild, CLI Guild, Docs Guild | AIAI-31-004B, CLI-AIAI-31-003 | Deliver CLI `stella advise run <task>` command, renderers, documentation updates, and CLI golden tests. | CLI command produces deterministic output; docs published; smoke run recorded. |
| AIAI-31-005 | TODO | Advisory AI Guild, Security Guild | AIAI-31-004 | Implement guardrails (redaction, injection defense, output validation, citation enforcement) and fail-safe handling. | Guardrails block adversarial inputs; output validator enforces schemas; security tests pass. |
| AIAI-31-006 | TODO | Advisory AI Guild | AIAI-31-004..005 | Expose REST API endpoints (`/advisory/ai/*`) with RBAC, rate limits, OpenAPI schemas, and batching support. | Endpoints deployed with schema validation; rate limits enforced; integration tests cover error codes. |
| AIAI-31-007 | TODO | Advisory AI Guild, Observability Guild | AIAI-31-004..006 | Instrument metrics (`advisory_ai_latency`, `guardrail_blocks`, `validation_failures`, `citation_coverage`), logs, and traces; publish dashboards/alerts. | Telemetry live; dashboards approved; alerts configured. |
@@ -14,3 +17,9 @@
| AIAI-31-009 | TODO | Advisory AI Guild, QA Guild | AIAI-31-001..006 | Develop unit/golden/property/perf tests, injection harness, and regression suite; ensure determinism with seeded caches. | Test suite green; golden outputs stored; injection tests pass; perf targets documented. |
> 2025-11-02: AIAI-31-002 SBOM context domain models finalized with limiter guards; retriever tests now cover flag toggles and path dedupe. Service client integration still pending with SBOM guild.
> 2025-11-02: AIAI-31-003 moved to DOING starting deterministic tooling surface (version comparators & dependency analysis). Added semantic-version + EVR comparators and published toolset interface; awaiting downstream wiring.
> 2025-11-02: AIAI-31-004 started orchestration pipeline work begin designing summary/conflict/remediation workflow (deterministic sequence + cache keys).
> 2025-11-02: AIAI-31-004 orchestration prerequisites documented in docs/modules/advisory-ai/orchestration-pipeline.md (task breakdown 004A/004B/004C).

View File

@@ -0,0 +1,85 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
namespace StellaOps.AdvisoryAI.Tools;
/// <summary>
/// Summarises dependency graph characteristics used by deterministic tooling.
/// </summary>
public sealed class DependencyAnalysisResult
{
private DependencyAnalysisResult(
string artifactId,
ImmutableArray<DependencyNodeSummary> nodes,
ImmutableDictionary<string, string> metadata)
{
ArtifactId = artifactId;
Nodes = nodes;
Metadata = metadata;
}
public string ArtifactId { get; }
public ImmutableArray<DependencyNodeSummary> Nodes { get; }
public ImmutableDictionary<string, string> Metadata { get; }
public static DependencyAnalysisResult Create(
string artifactId,
IEnumerable<DependencyNodeSummary> nodes,
IReadOnlyDictionary<string, string> metadata)
{
ArgumentException.ThrowIfNullOrWhiteSpace(artifactId);
ArgumentNullException.ThrowIfNull(nodes);
ArgumentNullException.ThrowIfNull(metadata);
return new DependencyAnalysisResult(
artifactId.Trim(),
nodes.ToImmutableArray(),
metadata.ToImmutableDictionary(StringComparer.Ordinal));
}
public static DependencyAnalysisResult Empty(string artifactId)
=> new DependencyAnalysisResult(
artifactId?.Trim() ?? string.Empty,
ImmutableArray<DependencyNodeSummary>.Empty,
ImmutableDictionary<string, string>.Empty);
}
public sealed class DependencyNodeSummary
{
public DependencyNodeSummary(
string identifier,
IReadOnlyList<string> versions,
int runtimeOccurrences,
int developmentOccurrences)
{
ArgumentException.ThrowIfNullOrWhiteSpace(identifier);
Identifier = identifier.Trim();
Versions = versions?.ToImmutableArray() ?? ImmutableArray<string>.Empty;
RuntimeOccurrences = Math.Max(runtimeOccurrences, 0);
DevelopmentOccurrences = Math.Max(developmentOccurrences, 0);
}
public string Identifier { get; }
public ImmutableArray<string> Versions { get; }
public int RuntimeOccurrences { get; }
public int DevelopmentOccurrences { get; }
}
internal sealed class NodeAccumulator
{
public string Identifier { get; set; } = string.Empty;
public HashSet<string> Versions { get; set; } = new(StringComparer.Ordinal);
public int RuntimeOccurrences { get; set; }
public int DevelopmentOccurrences { get; set; }
}

View File

@@ -0,0 +1,382 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using StellaOps.AdvisoryAI.Context;
namespace StellaOps.AdvisoryAI.Tools;
/// <summary>
/// Default deterministic toolset covering semantic versioning and RPM-style EVR comparisons.
/// </summary>
internal sealed class DeterministicToolset : IDeterministicToolset
{
private const string SchemeSemver = "semver";
private const string SchemeEvr = "evr";
public bool TryCompare(string scheme, string left, string right, out int comparison)
{
comparison = 0;
scheme = NormalizeScheme(scheme);
return scheme switch
{
SchemeSemver => TryCompareSemver(left, right, out comparison),
SchemeEvr => TryCompareEvr(left, right, out comparison),
_ => false,
};
}
public bool SatisfiesRange(string scheme, string version, string rangeExpression)
{
scheme = NormalizeScheme(scheme);
return scheme switch
{
SchemeSemver => SemanticVersionRange.Satisfies(version, rangeExpression),
SchemeEvr => EvrRangeSatisfies(version, rangeExpression),
_ => throw new NotSupportedException($"Scheme '{scheme}' not supported."),
};
}
public DependencyAnalysisResult AnalyzeDependencies(SbomContextResult context)
{
ArgumentNullException.ThrowIfNull(context);
if (context.DependencyPaths.Count == 0)
{
return DependencyAnalysisResult.Empty(context.ArtifactId);
}
var nodes = new Dictionary<string, NodeAccumulator>(StringComparer.Ordinal);
var totalPaths = 0;
var runtimePaths = 0;
foreach (var path in context.DependencyPaths)
{
totalPaths++;
if (path.IsRuntime)
{
runtimePaths++;
}
foreach (var node in path.Nodes)
{
var key = node.Identifier;
if (!nodes.TryGetValue(key, out var accumulator))
{
accumulator = new NodeAccumulator
{
Identifier = node.Identifier,
Versions = new HashSet<string>(StringComparer.Ordinal),
};
nodes[key] = accumulator;
}
if (!string.IsNullOrWhiteSpace(node.Version))
{
accumulator.Versions.Add(node.Version!);
}
if (path.IsRuntime)
{
accumulator.RuntimeOccurrences++;
}
else
{
accumulator.DevelopmentOccurrences++;
}
}
}
var summaries = nodes.Values
.Select(acc => new DependencyNodeSummary(
acc.Identifier,
acc.Versions.OrderBy(v => v, StringComparer.Ordinal).ToArray(),
acc.RuntimeOccurrences,
acc.DevelopmentOccurrences))
.OrderBy(summary => summary.Identifier, StringComparer.Ordinal)
.ToArray();
var metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["artifact_id"] = context.ArtifactId,
["path_count"] = totalPaths.ToString(CultureInfo.InvariantCulture),
["runtime_path_count"] = runtimePaths.ToString(CultureInfo.InvariantCulture),
["development_path_count"] = (totalPaths - runtimePaths).ToString(CultureInfo.InvariantCulture),
["unique_nodes"] = summaries.Length.ToString(CultureInfo.InvariantCulture),
};
return new DependencyAnalysisResult(context.ArtifactId, summaries, metadata);
}
private static string NormalizeScheme(string scheme)
=> string.IsNullOrWhiteSpace(scheme) ? SchemeSemver : scheme.Trim().ToLowerInvariant();
private static bool TryCompareSemver(string left, string right, out int comparison)
{
comparison = 0;
if (!SemanticVersion.TryParse(left, out var leftVersion) ||
!SemanticVersion.TryParse(right, out var rightVersion))
{
return false;
}
comparison = leftVersion.CompareTo(rightVersion);
return true;
}
private static bool TryCompareEvr(string left, string right, out int comparison)
{
comparison = 0;
if (!EvrVersion.TryParse(left, out var leftVersion) ||
!EvrVersion.TryParse(right, out var rightVersion))
{
return false;
}
comparison = leftVersion.CompareTo(rightVersion);
return true;
}
private static bool EvrRangeSatisfies(string version, string rangeExpression)
{
if (!EvrVersion.TryParse(version, out var parsed))
{
throw new FormatException($"Invalid EVR version '{version}'.");
}
var clauses = rangeExpression
.Split(new[] { ',', ' ' }, StringSplitOptions.RemoveEmptyEntries);
foreach (var clause in clauses)
{
if (!EvaluateEvrClause(parsed, clause))
{
return false;
}
}
return true;
}
private static bool EvaluateEvrClause(EvrVersion version, string clause)
{
if (!TryParseComparator(clause, out var comparator, out var targetRaw))
{
targetRaw = clause;
comparator = Comparator.Equals;
}
if (!EvrVersion.TryParse(targetRaw, out var target))
{
throw new FormatException($"Invalid EVR version '{targetRaw}' in clause '{clause}'.");
}
var compare = version.CompareTo(target);
return comparator switch
{
Comparator.Equals => compare == 0,
Comparator.NotEquals => compare != 0,
Comparator.GreaterThan => compare > 0,
Comparator.GreaterThanOrEqual => compare >= 0,
Comparator.LessThan => compare < 0,
Comparator.LessThanOrEqual => compare <= 0,
_ => throw new InvalidOperationException(),
};
}
private static bool TryParseComparator(string clause, out Comparator comparator, out string version)
{
comparator = Comparator.Equals;
version = clause;
if (string.IsNullOrWhiteSpace(clause))
{
return false;
}
if (clause.StartsWith(">=", StringComparison.Ordinal))
{
comparator = Comparator.GreaterThanOrEqual;
version = clause[2..];
return true;
}
if (clause.StartsWith("<=", StringComparison.Ordinal))
{
comparator = Comparator.LessThanOrEqual;
version = clause[2..];
return true;
}
if (clause.StartsWith("!=", StringComparison.Ordinal) || clause.StartsWith("<>", StringComparison.Ordinal))
{
comparator = Comparator.NotEquals;
version = clause[2..];
return true;
}
if (clause.StartsWith(">", StringComparison.Ordinal))
{
comparator = Comparator.GreaterThan;
version = clause[1..];
return true;
}
if (clause.StartsWith("<", StringComparison.Ordinal))
{
comparator = Comparator.LessThan;
version = clause[1..];
return true;
}
if (clause.StartsWith("=", StringComparison.Ordinal))
{
comparator = Comparator.Equals;
version = clause[1..];
return true;
}
if (clause.StartsWith("==", StringComparison.Ordinal))
{
comparator = Comparator.Equals;
version = clause[2..];
return true;
}
return false;
}
private enum Comparator
{
Equals,
NotEquals,
GreaterThan,
GreaterThanOrEqual,
LessThan,
LessThanOrEqual,
}
private readonly struct EvrVersion : IComparable<EvrVersion>
{
public EvrVersion(int epoch, IReadOnlyList<string> versionSegments, IReadOnlyList<string> releaseSegments)
{
Epoch = epoch;
VersionSegments = versionSegments;
ReleaseSegments = releaseSegments;
}
public int Epoch { get; }
public IReadOnlyList<string> VersionSegments { get; }
public IReadOnlyList<string> ReleaseSegments { get; }
public static bool TryParse(string value, out EvrVersion version)
{
version = default;
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
var trimmed = value.Trim();
var epochSplit = trimmed.Split(':', 2);
int epoch = 0;
string remainder;
if (epochSplit.Length == 2)
{
if (!int.TryParse(epochSplit[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out epoch))
{
return false;
}
remainder = epochSplit[1];
}
else
{
remainder = trimmed;
}
var releaseSplit = remainder.Split('-', 2);
var versionPart = releaseSplit[0];
var releasePart = releaseSplit.Length == 2 ? releaseSplit[1] : string.Empty;
var versionSegments = SplitSegments(versionPart);
var releaseSegments = SplitSegments(releasePart);
version = new EvrVersion(epoch, versionSegments, releaseSegments);
return true;
}
public int CompareTo(EvrVersion other)
{
var epochCompare = Epoch.CompareTo(other.Epoch);
if (epochCompare != 0)
{
return epochCompare;
}
var versionCompare = CompareSegments(VersionSegments, other.VersionSegments);
if (versionCompare != 0)
{
return versionCompare;
}
return CompareSegments(ReleaseSegments, other.ReleaseSegments);
}
private static List<string> SplitSegments(string value)
{
if (string.IsNullOrEmpty(value))
{
return new List<string>(0);
}
var segments = value.Split(new[] { '.', '-', '_' }, StringSplitOptions.RemoveEmptyEntries);
return segments.ToList();
}
private static int CompareSegments(IReadOnlyList<string> left, IReadOnlyList<string> right)
{
var length = Math.Max(left.Count, right.Count);
for (var i = 0; i < length; i++)
{
var leftSegment = i < left.Count ? left[i] : string.Empty;
var rightSegment = i < right.Count ? right[i] : string.Empty;
var leftNumeric = int.TryParse(leftSegment, NumberStyles.Integer, CultureInfo.InvariantCulture, out var leftValue);
var rightNumeric = int.TryParse(rightSegment, NumberStyles.Integer, CultureInfo.InvariantCulture, out var rightValue);
if (leftNumeric && rightNumeric)
{
var compare = leftValue.CompareTo(rightValue);
if (compare != 0)
{
return compare;
}
continue;
}
if (leftNumeric)
{
return 1; // numeric segments sort after alpha if other not numeric
}
if (rightNumeric)
{
return -1;
}
var cmp = string.CompareOrdinal(leftSegment, rightSegment);
if (cmp != 0)
{
return cmp;
}
}
return 0;
}
}
}

View File

@@ -0,0 +1,24 @@
using StellaOps.AdvisoryAI.Context;
namespace StellaOps.AdvisoryAI.Tools;
/// <summary>
/// Provides deterministic version comparison and range evaluation helpers used across Advisory AI tooling.
/// </summary>
public interface IDeterministicToolset
{
/// <summary>
/// Attempts to compare two versions using the specified scheme.
/// </summary>
bool TryCompare(string scheme, string left, string right, out int comparison);
/// <summary>
/// Evaluates whether a version satisfies the given range expression for the specified scheme.
/// </summary>
bool SatisfiesRange(string scheme, string version, string rangeExpression);
/// <summary>
/// Analyses dependency paths to produce deterministic summaries for policy/tooling decisions.
/// </summary>
DependencyAnalysisResult AnalyzeDependencies(SbomContextResult context);
}

View File

@@ -0,0 +1,256 @@
using System;
using System.Collections.Generic;
using System.Globalization;
namespace StellaOps.AdvisoryAI.Tools;
/// <summary>
/// Deterministic semantic version parser that supports major.minor.patch with optional pre-release/build metadata.
/// No external dependencies to remain offline-friendly.
/// </summary>
public readonly struct SemanticVersion : IComparable<SemanticVersion>
{
private SemanticVersion(
int major,
int minor,
int patch,
IReadOnlyList<string> preRelease,
string? build)
{
Major = major;
Minor = minor;
Patch = patch;
PreRelease = preRelease;
BuildMetadata = build;
}
public int Major { get; }
public int Minor { get; }
public int Patch { get; }
public IReadOnlyList<string> PreRelease { get; }
public string? BuildMetadata { get; }
public static bool TryParse(string value, out SemanticVersion version)
{
version = default;
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
var span = value.Trim();
var buildSplit = span.Split('+', 2, StringSplitOptions.RemoveEmptyEntries);
string? build = null;
if (buildSplit.Length == 2)
{
span = buildSplit[0];
build = buildSplit[1];
}
var preReleaseSplit = span.Split('-', 2, StringSplitOptions.None);
string? preReleaseSegment = null;
if (preReleaseSplit.Length == 2)
{
span = preReleaseSplit[0];
preReleaseSegment = preReleaseSplit[1];
if (string.IsNullOrEmpty(preReleaseSegment))
{
return false;
}
}
var parts = span.Split('.', StringSplitOptions.None);
if (parts.Length < 1 || parts.Length > 3)
{
return false;
}
if (!TryParseNumericPart(parts[0], out var major))
{
return false;
}
var minor = 0;
if (parts.Length > 1 && !TryParseNumericPart(parts[1], out minor))
{
return false;
}
var patch = 0;
if (parts.Length > 2 && !TryParseNumericPart(parts[2], out patch))
{
return false;
}
var preRelease = Array.Empty<string>();
if (!string.IsNullOrEmpty(preReleaseSegment))
{
var segments = preReleaseSegment.Split('.', StringSplitOptions.RemoveEmptyEntries);
if (segments.Length == 0)
{
return false;
}
foreach (var identifier in segments)
{
if (!IsValidIdentifier(identifier))
{
return false;
}
}
preRelease = segments;
}
version = new SemanticVersion(major, minor, patch, preRelease, build);
return true;
}
public static SemanticVersion Parse(string value)
=> TryParse(value, out var version)
? version
: throw new FormatException($"Invalid semantic version '{value}'.");
public int CompareTo(SemanticVersion other)
{
var majorCompare = Major.CompareTo(other.Major);
if (majorCompare != 0)
{
return majorCompare;
}
var minorCompare = Minor.CompareTo(other.Minor);
if (minorCompare != 0)
{
return minorCompare;
}
var patchCompare = Patch.CompareTo(other.Patch);
if (patchCompare != 0)
{
return patchCompare;
}
return ComparePreRelease(PreRelease, other.PreRelease);
}
public override string ToString()
{
var core = $"{Major}.{Minor}.{Patch}";
if (PreRelease.Count > 0)
{
core += "-" + string.Join('.', PreRelease);
}
if (!string.IsNullOrEmpty(BuildMetadata))
{
core += "+" + BuildMetadata;
}
return core;
}
private static bool TryParseNumericPart(string value, out int result)
{
if (value.Length == 0)
{
result = 0;
return false;
}
if (value.Length > 1 && value[0] == '0')
{
result = 0;
return false;
}
return int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out result);
}
private static bool IsValidIdentifier(string identifier)
{
if (identifier.Length == 0)
{
return false;
}
foreach (var ch in identifier)
{
if (char.IsLetterOrDigit(ch) || ch == '-')
{
continue;
}
return false;
}
if (identifier.Length > 1 && identifier[0] == '0' && char.IsDigit(identifier[1]))
{
return false;
}
return true;
}
private static int ComparePreRelease(IReadOnlyList<string> left, IReadOnlyList<string> right)
{
var leftEmpty = left.Count == 0;
var rightEmpty = right.Count == 0;
if (leftEmpty && rightEmpty)
{
return 0;
}
if (leftEmpty)
{
return 1; // release > pre-release
}
if (rightEmpty)
{
return -1;
}
var length = Math.Min(left.Count, right.Count);
for (var i = 0; i < length; i++)
{
var leftId = left[i];
var rightId = right[i];
var leftNumeric = int.TryParse(leftId, NumberStyles.None, CultureInfo.InvariantCulture, out var leftValue);
var rightNumeric = int.TryParse(rightId, NumberStyles.None, CultureInfo.InvariantCulture, out var rightValue);
if (leftNumeric && rightNumeric)
{
var compare = leftValue.CompareTo(rightValue);
if (compare != 0)
{
return compare;
}
}
else if (leftNumeric)
{
return -1;
}
else if (rightNumeric)
{
return 1;
}
else
{
var compare = string.CompareOrdinal(leftId, rightId);
if (compare != 0)
{
return compare;
}
}
}
return left.Count.CompareTo(right.Count);
}
}

View File

@@ -0,0 +1,121 @@
using System;
using System.Collections.Generic;
using System.Globalization;
namespace StellaOps.AdvisoryAI.Tools;
/// <summary>
/// Evaluates simple semantic version ranges used by Advisory AI deterministic tooling.
/// Supports comparators (&gt;, &gt;=, &lt;, &lt;=, =, ==, !=) combined with commas (logical AND).
/// </summary>
public static class SemanticVersionRange
{
private static readonly char[] ComparatorChars = ['>', '<', '!', '='];
public static bool Satisfies(string version, string rangeExpression)
{
if (!SemanticVersion.TryParse(version, out var parsedVersion))
{
throw new FormatException($"Invalid version '{version}'.");
}
var clauses = ParseClauses(rangeExpression);
foreach (var clause in clauses)
{
if (!EvaluateClause(parsedVersion, clause))
{
return false;
}
}
return true;
}
private static IReadOnlyList<RangeClause> ParseClauses(string expression)
{
if (string.IsNullOrWhiteSpace(expression))
{
return Array.Empty<RangeClause>();
}
var tokens = expression.Split(new[] { ',', ' ' }, StringSplitOptions.RemoveEmptyEntries);
var clauses = new List<RangeClause>(tokens.Length);
foreach (var token in tokens)
{
clauses.Add(ParseClause(token));
}
return clauses;
}
private static RangeClause ParseClause(string token)
{
var opLength = 0;
for (; opLength < token.Length; opLength++)
{
var ch = token[opLength];
if (Array.IndexOf(ComparatorChars, ch) < 0)
{
break;
}
}
if (opLength == 0)
{
// implicit equality
if (!SemanticVersion.TryParse(token, out var implicitVersion))
{
throw new FormatException($"Invalid range clause '{token}'.");
}
return new RangeClause(Comparator.Equals, implicitVersion);
}
var opToken = token[..opLength];
var comparator = opToken switch
{
">" => Comparator.GreaterThan,
">=" => Comparator.GreaterThanOrEqual,
"<" => Comparator.LessThan,
"<=" => Comparator.LessThanOrEqual,
"=" or "==" => Comparator.Equals,
"!=" or "<>" => Comparator.NotEquals,
_ => throw new FormatException($"Unsupported comparator '{opToken}'."),
};
var versionToken = token[opLength..];
if (!SemanticVersion.TryParse(versionToken, out var version))
{
throw new FormatException($"Invalid version '{versionToken}' in clause '{token}'.");
}
return new RangeClause(comparator, version);
}
private static bool EvaluateClause(SemanticVersion version, RangeClause clause)
{
var compare = version.CompareTo(clause.Version);
return clause.Comparator switch
{
Comparator.Equals => compare == 0,
Comparator.NotEquals => compare != 0,
Comparator.GreaterThan => compare > 0,
Comparator.GreaterThanOrEqual => compare >= 0,
Comparator.LessThan => compare < 0,
Comparator.LessThanOrEqual => compare <= 0,
_ => throw new InvalidOperationException($"Unsupported comparator {clause.Comparator}."),
};
}
private readonly record struct RangeClause(Comparator Comparator, SemanticVersion Version);
private enum Comparator
{
Equals,
NotEquals,
GreaterThan,
GreaterThanOrEqual,
LessThan,
LessThanOrEqual,
}
}

View File

@@ -0,0 +1,131 @@
using System.Collections.Immutable;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.AdvisoryAI.Documents;
using StellaOps.AdvisoryAI.Abstractions;
using StellaOps.AdvisoryAI.Context;
using StellaOps.AdvisoryAI.Orchestration;
using StellaOps.AdvisoryAI.Tools;
using Xunit;
namespace StellaOps.AdvisoryAI.Tests;
public sealed class AdvisoryPipelineOrchestratorTests
{
[Fact]
public async Task CreatePlanAsync_ComposesDeterministicPlan()
{
var structuredRetriever = new FakeStructuredRetriever();
var vectorRetriever = new FakeVectorRetriever();
var sbomRetriever = new FakeSbomContextRetriever();
var options = Options.Create(new AdvisoryPipelineOptions());
options.Value.Tasks[AdvisoryTaskType.Summary].VectorQueries.Clear();
options.Value.Tasks[AdvisoryTaskType.Summary].VectorQueries.Add("summary-query");
options.Value.Tasks[AdvisoryTaskType.Summary].VectorTopK = 2;
options.Value.Tasks[AdvisoryTaskType.Summary].StructuredMaxChunks = 5;
options.Value.Tasks[AdvisoryTaskType.Summary].PromptTemplate = "prompts/summary.liquid";
var orchestrator = new AdvisoryPipelineOrchestrator(
structuredRetriever,
vectorRetriever,
sbomRetriever,
new DeterministicToolset(),
options,
NullLogger<AdvisoryPipelineOrchestrator>.Instance);
var request = new AdvisoryTaskRequest(
AdvisoryTaskType.Summary,
advisoryKey: "adv-key",
artifactId: "artifact-1",
artifactPurl: "pkg:docker/sample@1.0.0",
policyVersion: "policy-42",
profile: "default");
var plan = await orchestrator.CreatePlanAsync(request, CancellationToken.None);
Assert.Equal("prompts/summary.liquid", plan.PromptTemplate);
Assert.Equal(2, plan.StructuredChunks.Length);
Assert.Single(plan.VectorResults);
Assert.Equal("summary-query", plan.VectorResults[0].Query);
Assert.Equal(2, plan.VectorResults[0].Matches.Length);
Assert.NotNull(plan.SbomContext);
Assert.NotNull(plan.DependencyAnalysis);
Assert.NotEmpty(plan.CacheKey);
Assert.Equal("adv-key", plan.Metadata["advisory_key"]);
Assert.Equal("Summary", plan.Metadata["task_type"]);
Assert.Equal("1", plan.Metadata["runtime_path_count"]);
var secondPlan = await orchestrator.CreatePlanAsync(request, CancellationToken.None);
Assert.Equal(plan.CacheKey, secondPlan.CacheKey);
}
private sealed class FakeStructuredRetriever : IAdvisoryStructuredRetriever
{
public Task<AdvisoryRetrievalResult> RetrieveAsync(AdvisoryRetrievalRequest request, CancellationToken cancellationToken)
{
var chunks = new[]
{
AdvisoryChunk.Create("doc-1", "doc-1:0001", "Summary", "summary[0]", "Summary section", new Dictionary<string, string>
{
["section"] = "Summary",
}),
AdvisoryChunk.Create("doc-1", "doc-1:0002", "Remediation", "remediation[0]", "Remediation section", new Dictionary<string, string>
{
["section"] = "Remediation",
}),
};
return Task.FromResult(AdvisoryRetrievalResult.Create(request.AdvisoryKey, chunks));
}
}
private sealed class FakeVectorRetriever : IAdvisoryVectorRetriever
{
public Task<IReadOnlyList<VectorRetrievalMatch>> SearchAsync(VectorRetrievalRequest request, CancellationToken cancellationToken)
{
var matches = new[]
{
new VectorRetrievalMatch("doc-1", "doc-1:0002", "Remediation section", 0.95, ImmutableDictionary<string, string>.Empty),
new VectorRetrievalMatch("doc-1", "doc-1:0001", "Summary section", 0.90, ImmutableDictionary<string, string>.Empty),
};
return Task.FromResult<IReadOnlyList<VectorRetrievalMatch>>(matches);
}
}
private sealed class FakeSbomContextRetriever : ISbomContextRetriever
{
public Task<SbomContextResult> RetrieveAsync(SbomContextRequest request, CancellationToken cancellationToken)
{
var versionTimeline = new[]
{
new SbomVersionTimelineEntry("1.0.0", DateTimeOffset.UtcNow.AddDays(-10), null, "affected", "scanner"),
};
var dependencyPaths = new[]
{
new SbomDependencyPath(
new[]
{
new SbomDependencyNode("root", "1.0.0"),
new SbomDependencyNode("runtime-lib", "2.1.0"),
},
isRuntime: true),
new SbomDependencyPath(
new[]
{
new SbomDependencyNode("root", "1.0.0"),
new SbomDependencyNode("dev-lib", "0.9.0"),
},
isRuntime: false),
};
var result = SbomContextResult.Create(
request.ArtifactId,
request.Purl,
versionTimeline,
dependencyPaths);
return Task.FromResult(result);
}
}
}

View File

@@ -0,0 +1,83 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using FluentAssertions;
using StellaOps.AdvisoryAI.Abstractions;
using StellaOps.AdvisoryAI.Context;
using StellaOps.AdvisoryAI.Orchestration;
using StellaOps.AdvisoryAI.Tools;
using Xunit;
namespace StellaOps.AdvisoryAI.Tests;
public sealed class AdvisoryPipelinePlanResponseTests
{
[Fact]
public void FromPlan_ProjectsMetadataAndCounts()
{
var request = new AdvisoryTaskRequest(AdvisoryTaskType.Summary, "adv-key");
var chunks = ImmutableArray.Create(
AdvisoryChunk.Create("doc-1", "doc-1:0001", "Summary", "summary[0]", "Summary text", new Dictionary<string, string>
{
["section"] = "Summary",
}),
AdvisoryChunk.Create("doc-1", "doc-1:0002", "Remediation", "remediation[0]", "Remediation text", new Dictionary<string, string>
{
["section"] = "Remediation",
}));
var vectorResults = ImmutableArray.Create(
new AdvisoryVectorResult(
"Summary query",
ImmutableArray.Create(
new VectorRetrievalMatch("doc-1", "doc-1:0001", "Summary text", 0.9, ImmutableDictionary<string, string>.Empty))));
var sbom = SbomContextResult.Create(
"artifact-1",
null,
new[]
{
new SbomVersionTimelineEntry("1.0.0", DateTimeOffset.UtcNow.AddDays(-1), null, "affected", "scanner"),
},
new[]
{
new SbomDependencyPath(
new[]
{
new SbomDependencyNode("root", "1.0.0"),
},
true),
});
var dependency = DependencyAnalysisResult.Create(
sbom.ArtifactId,
sbom.DependencyPaths.Select(path => new DependencyNodeSummary(
path.Nodes.Last().Identifier,
Array.Empty<string>(),
runtimeOccurrences: path.IsRuntime ? 1 : 0,
developmentOccurrences: path.IsRuntime ? 0 : 1)),
ImmutableDictionary<string, string>.Empty);
var plan = new AdvisoryTaskPlan(
request,
cacheKey: "ABC123",
promptTemplate: "prompts/advisory/summary.liquid",
structuredChunks: chunks,
vectorResults: vectorResults,
sbomContext: sbom,
dependencyAnalysis: dependency,
budget: new AdvisoryTaskBudget { PromptTokens = 1024, CompletionTokens = 256 },
metadata: ImmutableDictionary<string, string>.Empty);
var response = AdvisoryPipelinePlanResponse.FromPlan(plan);
response.TaskType.Should().Be("Summary");
response.CacheKey.Should().Be("ABC123");
response.Chunks.Should().HaveCount(2);
response.Vectors.Should().HaveCount(1);
response.Sbom.Should().NotBeNull();
response.Sbom!.DependencyNodeCount.Should().Be(1);
response.Budget.CompletionTokens.Should().Be(256);
}
}

View File

@@ -49,8 +49,8 @@ public sealed class AdvisoryStructuredRetrieverTests
result.Chunks.Should().NotBeEmpty();
result.Chunks.Should().ContainSingle(c => c.Section == "summary");
result.Chunks.Should().Contain(c => c.Section == "affected.ranges");
result.Chunks.First(c => c.Section == "affected.ranges").Metadata.Should().ContainKey("package");
result.Chunks.Should().Contain(c => c.Section.StartsWith("affected", StringComparison.OrdinalIgnoreCase));
result.Chunks.First(c => c.Section.StartsWith("affected", StringComparison.OrdinalIgnoreCase)).Metadata.Should().ContainKey("package");
}
[Fact]
@@ -85,14 +85,19 @@ public sealed class AdvisoryStructuredRetrieverTests
await LoadAsync("sample-vendor.md")));
var retriever = CreateRetriever(provider);
var baseline = await retriever.RetrieveAsync(new AdvisoryRetrievalRequest("markdown-advisory"), CancellationToken.None);
var impactSection = baseline.Chunks
.Select(chunk => chunk.Section)
.First(section => section.Contains("Impact", StringComparison.OrdinalIgnoreCase));
var request = new AdvisoryRetrievalRequest(
"markdown-advisory",
PreferredSections: new[] { "Impact" });
PreferredSections: new[] { impactSection });
var result = await retriever.RetrieveAsync(request, CancellationToken.None);
result.Chunks.Should().NotBeEmpty();
result.Chunks.Should().OnlyContain(chunk => chunk.Section.StartsWith("Impact", StringComparison.Ordinal));
result.Chunks.Should().OnlyContain(chunk => chunk.Section == impactSection);
}
private static AdvisoryStructuredRetriever CreateRetriever(IAdvisoryDocumentProvider provider)

View File

@@ -47,11 +47,11 @@ public sealed class AdvisoryVectorRetrieverTests
new VectorRetrievalRequest(
new AdvisoryRetrievalRequest("adv"),
Query: "How do I remediate the vulnerability?",
TopK: 1),
TopK: 3),
CancellationToken.None);
matches.Should().HaveCount(1);
matches[0].Section().Should().Be("Remediation");
matches.Should().NotBeEmpty();
matches.Should().Contain(match => match.Text.Contains("Update to version", StringComparison.OrdinalIgnoreCase));
}
}

View File

@@ -67,7 +67,7 @@ public sealed class ConcelierAdvisoryDocumentProviderTests
=> throw new NotImplementedException();
public Task<AdvisoryRawQueryResult> QueryAsync(AdvisoryRawQueryOptions options, CancellationToken cancellationToken)
=> Task.FromResult(new AdvisoryRawQueryResult(_records, nextCursor: null, hasMore: false));
=> Task.FromResult(new AdvisoryRawQueryResult(_records, NextCursor: null, HasMore: false));
public Task<AdvisoryRawVerificationResult> VerifyAsync(AdvisoryRawVerificationRequest request, CancellationToken cancellationToken)
=> throw new NotImplementedException();

View File

@@ -0,0 +1,54 @@
using System.Collections.Immutable;
using System.Linq;
using FluentAssertions;
using StellaOps.AdvisoryAI.Context;
using StellaOps.AdvisoryAI.Tools;
using Xunit;
namespace StellaOps.AdvisoryAI.Tests;
public sealed class DeterministicToolsetTests
{
[Fact]
public void AnalyzeDependencies_ComputesRuntimeAndDevelopmentCounts()
{
var context = SbomContextResult.Create(
"artifact-123",
purl: null,
versionTimeline: Array.Empty<SbomVersionTimelineEntry>(),
dependencyPaths: new[]
{
new SbomDependencyPath(
new[]
{
new SbomDependencyNode("root", "1.0.0"),
new SbomDependencyNode("lib-a", "2.0.0"),
},
isRuntime: true),
new SbomDependencyPath(
new[]
{
new SbomDependencyNode("root", "1.0.0"),
new SbomDependencyNode("lib-b", "3.1.4"),
},
isRuntime: false),
});
IDeterministicToolset toolset = new DeterministicToolset();
var analysis = toolset.AnalyzeDependencies(context);
analysis.ArtifactId.Should().Be("artifact-123");
analysis.Metadata["path_count"].Should().Be("2");
analysis.Metadata["runtime_path_count"].Should().Be("1");
analysis.Metadata["development_path_count"].Should().Be("1");
analysis.Nodes.Should().HaveCount(3);
var libA = analysis.Nodes.Single(node => node.Identifier == "lib-a");
libA.RuntimeOccurrences.Should().Be(1);
libA.DevelopmentOccurrences.Should().Be(0);
var libB = analysis.Nodes.Single(node => node.Identifier == "lib-b");
libB.RuntimeOccurrences.Should().Be(0);
libB.DevelopmentOccurrences.Should().Be(1);
}
}

View File

@@ -64,7 +64,7 @@ public sealed class ExcititorVexDocumentProviderTests
service.LastOptions.Should().NotBeNull();
service.LastOptions!.Tenant.Should().Be(tenantId);
service.LastOptions.ProviderIds.Should().ContainSingle().Which.Should().Be(providerId);
service.LastOptions.Statuses.Should().ContainSingle(VexClaimStatus.NotAffected);
service.LastOptions.Statuses.Should().ContainSingle(status => status == VexClaimStatus.NotAffected);
service.LastOptions.VulnerabilityIds.Should().Contain(vulnerabilityId);
service.LastOptions.Limit.Should().Be(5);
}
@@ -79,7 +79,7 @@ public sealed class ExcititorVexDocumentProviderTests
{
var upstream = new VexObservationUpstream(
"VEX-1",
1,
"1",
DateTimeOffset.Parse("2025-10-10T08:00:00Z"),
DateTimeOffset.Parse("2025-10-10T08:05:00Z"),
"hash-abc123",

View File

@@ -93,7 +93,7 @@ public sealed class SbomContextRetrieverTests
result.DependencyPaths.Should().HaveCount(2);
result.DependencyPaths.First().IsRuntime.Should().BeTrue();
result.DependencyPaths.First().Nodes.Select(n => n.Identifier).Should().Equal("app", "lib-a", "lib-b");
result.EnvironmentFlags.Keys.Should().Equal(new[] { "environment/dev", "environment/prod" });
result.EnvironmentFlags.Keys.Should().BeEquivalentTo(new[] { "environment/dev", "environment/prod" });
result.EnvironmentFlags["environment/prod"].Should().Be("true");
result.BlastRadius.Should().NotBeNull();
result.BlastRadius!.ImpactedAssets.Should().Be(12);

View File

@@ -0,0 +1,78 @@
using FluentAssertions;
using StellaOps.AdvisoryAI.Tools;
using Xunit;
namespace StellaOps.AdvisoryAI.Tests;
public sealed class SemanticVersionTests
{
[Theory]
[InlineData("1.2.3", 1, 2, 3, false)]
[InlineData("1.2.3-alpha", 1, 2, 3, true)]
[InlineData("0.0.1+build", 0, 0, 1, false)]
[InlineData("2.0.0-rc.1+exp.sha", 2, 0, 0, true)]
public void Parse_ValidInputs_Succeeds(string value, int major, int minor, int patch, bool hasPreRelease)
{
var version = SemanticVersion.Parse(value);
version.Major.Should().Be(major);
version.Minor.Should().Be(minor);
version.Patch.Should().Be(patch);
(version.PreRelease.Count > 0).Should().Be(hasPreRelease);
}
[Theory]
[InlineData("01.0.0")]
[InlineData("1..0")]
[InlineData("1.0.0-")]
[InlineData("")]
[InlineData(null)]
public void Parse_InvalidInputs_Throws(string value)
{
var act = () => SemanticVersion.Parse(value!);
act.Should().Throw<FormatException>();
}
[Theory]
[InlineData("1.2.3", "1.2.3", 0)]
[InlineData("1.2.3", "1.2.4", -1)]
[InlineData("1.3.0", "1.2.9", 1)]
[InlineData("1.2.3-alpha", "1.2.3", -1)]
[InlineData("1.2.3-alpha.2", "1.2.3-alpha.10", -1)]
[InlineData("1.2.3-beta", "1.2.3-alpha", 1)]
public void CompareTo_EvaluatesOrder(string left, string right, int expectedSign)
{
var leftVersion = SemanticVersion.Parse(left);
var rightVersion = SemanticVersion.Parse(right);
Math.Sign(leftVersion.CompareTo(rightVersion)).Should().Be(expectedSign);
}
[Theory]
[InlineData("1.2.3", ">=1.0.0,<2.0.0", true)]
[InlineData("0.9.0", ">=1.0.0", false)]
[InlineData("1.2.3-beta", ">=1.2.3", false)]
[InlineData("1.2.3-beta", ">=1.2.3-rc.1", false)]
[InlineData("1.2.3-rc.1", ">=1.2.3-beta", true)]
[InlineData("1.2.3", "!=1.2.3", false)]
[InlineData("1.2.3", "1.2.3", true)]
public void RangeEvaluator_ProducesExpectedResults(string version, string range, bool expected)
{
SemanticVersionRange.Satisfies(version, range).Should().Be(expected);
}
[Fact]
public void DeterministicToolset_ComparesSemverAndEvr()
{
IDeterministicToolset toolset = new DeterministicToolset();
toolset.TryCompare("semver", "1.2.3", "1.2.4", out var semverComparison).Should().BeTrue();
semverComparison.Should().BeLessThan(0);
toolset.TryCompare("evr", "1:1.0.0-1", "1:1.0.0-2", out var evrComparison).Should().BeTrue();
evrComparison.Should().BeLessThan(0);
toolset.SatisfiesRange("semver", "1.2.3", ">=1.0.0,<2.0.0").Should().BeTrue();
toolset.SatisfiesRange("evr", "0:1.0.1-3", ">=1.0.0-0,!=1.0.1-2").Should().BeTrue();
}
}

View File

@@ -16,9 +16,9 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.AdvisoryAI\StellaOps.AdvisoryAI.csproj" />
<ProjectReference Include="..\..\Concelier\__Libraries\StellaOps.Concelier.Core\StellaOps.Concelier.Core.csproj" />
<ProjectReference Include="..\..\Concelier\__Libraries\StellaOps.Concelier.RawModels\StellaOps.Concelier.RawModels.csproj" />
<ProjectReference Include="..\..\Excititor\__Libraries\StellaOps.Excititor.Core\StellaOps.Excititor.Core.csproj" />
<ProjectReference Include="..\..\..\Concelier\__Libraries\StellaOps.Concelier.Core\StellaOps.Concelier.Core.csproj" />
<ProjectReference Include="..\..\..\Concelier\__Libraries\StellaOps.Concelier.RawModels\StellaOps.Concelier.RawModels.csproj" />
<ProjectReference Include="..\..\..\Excititor\__Libraries\StellaOps.Excititor.Core\StellaOps.Excititor.Core.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="TestData/*.json">

View File

@@ -0,0 +1,38 @@
using Microsoft.Extensions.DependencyInjection;
using StellaOps.AdvisoryAI.DependencyInjection;
using StellaOps.AdvisoryAI.Orchestration;
using StellaOps.AdvisoryAI.Tools;
using Xunit;
namespace StellaOps.AdvisoryAI.Tests;
public sealed class ToolsetServiceCollectionExtensionsTests
{
[Fact]
public void AddAdvisoryDeterministicToolset_RegistersSingleton()
{
var services = new ServiceCollection();
services.AddAdvisoryDeterministicToolset();
var provider = services.BuildServiceProvider();
var toolsetA = provider.GetRequiredService<IDeterministicToolset>();
var toolsetB = provider.GetRequiredService<IDeterministicToolset>();
Assert.Same(toolsetA, toolsetB);
}
[Fact]
public void AddAdvisoryPipeline_RegistersOrchestrator()
{
var services = new ServiceCollection();
services.AddAdvisoryPipeline();
var provider = services.BuildServiceProvider();
var orchestrator = provider.GetRequiredService<IAdvisoryPipelineOrchestrator>();
Assert.NotNull(orchestrator);
var again = provider.GetRequiredService<IAdvisoryPipelineOrchestrator>();
Assert.Same(orchestrator, again);
}
}

View File

@@ -1,56 +1,61 @@
namespace StellaOps.Auth.Abstractions;
/// <summary>
/// Canonical claim type identifiers used across StellaOps services.
/// </summary>
public static class StellaOpsClaimTypes
{
/// <summary>
/// Subject identifier claim (maps to <c>sub</c> in JWTs).
/// </summary>
public const string Subject = "sub";
/// <summary>
/// StellaOps tenant identifier claim (multi-tenant deployments).
/// </summary>
public const string Tenant = "stellaops:tenant";
/// <summary>
/// StellaOps project identifier claim (optional project scoping within a tenant).
/// </summary>
public const string Project = "stellaops:project";
/// <summary>
/// OAuth2/OIDC client identifier claim (maps to <c>client_id</c>).
/// </summary>
public const string ClientId = "client_id";
/// <summary>
/// Unique token identifier claim (maps to <c>jti</c>).
/// </summary>
public const string TokenId = "jti";
/// <summary>
/// Authentication method reference claim (<c>amr</c>).
/// </summary>
public const string AuthenticationMethod = "amr";
/// <summary>
/// Space separated scope list (<c>scope</c>).
/// </summary>
public const string Scope = "scope";
/// <summary>
/// Individual scope items (<c>scp</c>).
/// </summary>
public const string ScopeItem = "scp";
/// <summary>
/// OAuth2 resource audiences (<c>aud</c>).
/// </summary>
public const string Audience = "aud";
/// <summary>
namespace StellaOps.Auth.Abstractions;
/// <summary>
/// Canonical claim type identifiers used across StellaOps services.
/// </summary>
public static class StellaOpsClaimTypes
{
/// <summary>
/// Subject identifier claim (maps to <c>sub</c> in JWTs).
/// </summary>
public const string Subject = "sub";
/// <summary>
/// StellaOps tenant identifier claim (multi-tenant deployments).
/// </summary>
public const string Tenant = "stellaops:tenant";
/// <summary>
/// StellaOps project identifier claim (optional project scoping within a tenant).
/// </summary>
public const string Project = "stellaops:project";
/// <summary>
/// OAuth2/OIDC client identifier claim (maps to <c>client_id</c>).
/// </summary>
public const string ClientId = "client_id";
/// <summary>
/// Service account identifier associated with delegated tokens.
/// </summary>
public const string ServiceAccount = "stellaops:service_account";
/// <summary>
/// Unique token identifier claim (maps to <c>jti</c>).
/// </summary>
public const string TokenId = "jti";
/// <summary>
/// Authentication method reference claim (<c>amr</c>).
/// </summary>
public const string AuthenticationMethod = "amr";
/// <summary>
/// Space separated scope list (<c>scope</c>).
/// </summary>
public const string Scope = "scope";
/// <summary>
/// Individual scope items (<c>scp</c>).
/// </summary>
public const string ScopeItem = "scp";
/// <summary>
/// OAuth2 resource audiences (<c>aud</c>).
/// </summary>
public const string Audience = "aud";
/// <summary>
/// Identity provider hint for downstream services.
/// </summary>
public const string IdentityProvider = "stellaops:idp";

View File

@@ -20,10 +20,10 @@ public sealed class AuthorityTokenDocument
[BsonElement("type")]
public string Type { get; set; } = string.Empty;
[BsonElement(tokenKind)]
[BsonIgnoreIfNull]
public string? TokenKind { get; set; }
[BsonElement("tokenKind")]
[BsonIgnoreIfNull]
public string? TokenKind { get; set; }
[BsonElement("subjectId")]
[BsonIgnoreIfNull]
@@ -97,12 +97,12 @@ public sealed class AuthorityTokenDocument
[BsonElement("revokedMetadata")]
[BsonIgnoreIfNull]
public Dictionary<string, string?>? RevokedMetadata { get; set; }
[BsonElement(serviceAccountId)]
[BsonIgnoreIfNull]
public string? ServiceAccountId { get; set; }
[BsonElement(actors)]
[BsonIgnoreIfNull]
public List<string>? ActorChain { get; set; }
[BsonElement("serviceAccountId")]
[BsonIgnoreIfNull]
public string? ServiceAccountId { get; set; }
[BsonElement("actors")]
[BsonIgnoreIfNull]
public List<string>? ActorChain { get; set; }
}

View File

@@ -36,7 +36,18 @@ internal sealed class AuthorityTokenCollectionInitializer : IAuthorityCollection
new CreateIndexOptions<AuthorityTokenDocument> { Name = "token_sender_thumbprint", Sparse = true })
};
var expirationFilter = Builders<AuthorityTokenDocument>.Filter.Exists(t => t.ExpiresAt, true);
var serviceAccountFilter = Builders<AuthorityTokenDocument>.Filter.Exists(t => t.ServiceAccountId, true);
indexModels.Add(new CreateIndexModel<AuthorityTokenDocument>(
Builders<AuthorityTokenDocument>.IndexKeys
.Ascending(t => t.Tenant)
.Ascending(t => t.ServiceAccountId),
new CreateIndexOptions<AuthorityTokenDocument>
{
Name = "token_tenant_service_account",
PartialFilterExpression = serviceAccountFilter
}));
var expirationFilter = Builders<AuthorityTokenDocument>.Filter.Exists(t => t.ExpiresAt, true);
indexModels.Add(new CreateIndexModel<AuthorityTokenDocument>(
Builders<AuthorityTokenDocument>.IndexKeys.Ascending(t => t.ExpiresAt),
new CreateIndexOptions<AuthorityTokenDocument>

View File

@@ -13,6 +13,7 @@ namespace StellaOps.Authority.Storage.Mongo.Stores;
internal sealed class AuthorityTokenStore : IAuthorityTokenStore
{
private const string ServiceAccountTokenKind = "service_account";
private readonly IMongoCollection<AuthorityTokenDocument> collection;
private readonly ILogger<AuthorityTokenStore> logger;
@@ -190,6 +191,97 @@ internal sealed class AuthorityTokenStore : IAuthorityTokenStore
return new TokenUsageUpdateResult(suspicious ? TokenUsageUpdateStatus.SuspectedReplay : TokenUsageUpdateStatus.Recorded, normalizedAddress, normalizedAgent);
}
public async ValueTask<long> CountActiveDelegationTokensAsync(
string tenant,
string? serviceAccountId,
CancellationToken cancellationToken,
IClientSessionHandle? session = null)
{
if (string.IsNullOrWhiteSpace(tenant))
{
return 0;
}
var normalizedTenant = tenant.Trim().ToLowerInvariant();
var now = DateTimeOffset.UtcNow;
var filter = Builders<AuthorityTokenDocument>.Filter.And(new[]
{
Builders<AuthorityTokenDocument>.Filter.Eq(t => t.Status, "valid"),
Builders<AuthorityTokenDocument>.Filter.Eq(t => t.Tenant, normalizedTenant),
Builders<AuthorityTokenDocument>.Filter.Exists(t => t.ServiceAccountId, true),
Builders<AuthorityTokenDocument>.Filter.Eq(t => t.TokenKind, ServiceAccountTokenKind),
Builders<AuthorityTokenDocument>.Filter.Or(
Builders<AuthorityTokenDocument>.Filter.Eq(t => t.ExpiresAt, null),
Builders<AuthorityTokenDocument>.Filter.Gt(t => t.ExpiresAt, now))
});
if (!string.IsNullOrWhiteSpace(serviceAccountId))
{
var normalizedAccount = serviceAccountId.Trim();
filter &= Builders<AuthorityTokenDocument>.Filter.Eq(t => t.ServiceAccountId, normalizedAccount);
}
var query = session is { }
? collection.Find(session, filter)
: collection.Find(filter);
return await query.CountDocumentsAsync(cancellationToken).ConfigureAwait(false);
}
public async ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListActiveDelegationTokensAsync(
string tenant,
string? serviceAccountId,
CancellationToken cancellationToken,
IClientSessionHandle? session = null)
{
if (string.IsNullOrWhiteSpace(tenant))
{
return Array.Empty<AuthorityTokenDocument>();
}
var normalizedTenant = tenant.Trim().ToLowerInvariant();
var now = DateTimeOffset.UtcNow;
var filters = new List<FilterDefinition<AuthorityTokenDocument>>
{
Builders<AuthorityTokenDocument>.Filter.Eq(t => t.Status, "valid"),
Builders<AuthorityTokenDocument>.Filter.Eq(t => t.Tenant, normalizedTenant),
Builders<AuthorityTokenDocument>.Filter.Eq(t => t.TokenKind, ServiceAccountTokenKind),
Builders<AuthorityTokenDocument>.Filter.Or(
Builders<AuthorityTokenDocument>.Filter.Eq(t => t.ExpiresAt, null),
Builders<AuthorityTokenDocument>.Filter.Gt(t => t.ExpiresAt, now))
};
if (!string.IsNullOrWhiteSpace(serviceAccountId))
{
filters.Add(Builders<AuthorityTokenDocument>.Filter.Eq(
t => t.ServiceAccountId,
serviceAccountId.Trim()));
}
var filter = Builders<AuthorityTokenDocument>.Filter.And(filters);
var options = new FindOptions<AuthorityTokenDocument>
{
Sort = Builders<AuthorityTokenDocument>.Sort
.Descending(t => t.CreatedAt)
.Descending(t => t.TokenId)
};
IAsyncCursor<AuthorityTokenDocument> cursor;
if (session is { })
{
cursor = await collection.FindAsync(session, filter, options, cancellationToken).ConfigureAwait(false);
}
else
{
cursor = await collection.FindAsync(filter, options, cancellationToken).ConfigureAwait(false);
}
var documents = await cursor.ToListAsync(cancellationToken).ConfigureAwait(false);
return documents;
}
private static string? GetString(BsonDocument document, string name)
{
if (!document.TryGetValue(name, out var value))

View File

@@ -6,7 +6,7 @@ using StellaOps.Authority.Storage.Mongo.Documents;
namespace StellaOps.Authority.Storage.Mongo.Stores;
internal interface IAuthorityServiceAccountStore
public interface IAuthorityServiceAccountStore
{
ValueTask<AuthorityServiceAccountDocument?> FindByAccountIdAsync(string accountId, CancellationToken cancellationToken, IClientSessionHandle? session = null);

View File

@@ -36,6 +36,18 @@ public interface IAuthorityTokenStore
int limit,
CancellationToken cancellationToken,
IClientSessionHandle? session = null);
ValueTask<long> CountActiveDelegationTokensAsync(
string tenant,
string? serviceAccountId,
CancellationToken cancellationToken,
IClientSessionHandle? session = null);
ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListActiveDelegationTokensAsync(
string tenant,
string? serviceAccountId,
CancellationToken cancellationToken,
IClientSessionHandle? session = null);
}
public enum TokenUsageUpdateStatus

View File

@@ -0,0 +1,526 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Time.Testing;
using MongoDB.Driver;
using StellaOps.Auth.Abstractions;
using StellaOps.Authority.OpenIddict;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Authority.Tests.Infrastructure;
using StellaOps.Cryptography.Audit;
using Xunit;
namespace StellaOps.Authority.Tests.Bootstrap;
public sealed class ServiceAccountAdminEndpointsTests : IClassFixture<AuthorityWebApplicationFactory>
{
private const string BootstrapKey = "test-bootstrap-key";
private const string TenantId = "tenant-default";
private const string ServiceAccountId = "svc-observer";
private readonly AuthorityWebApplicationFactory factory;
public ServiceAccountAdminEndpointsTests(AuthorityWebApplicationFactory factory)
{
this.factory = factory ?? throw new ArgumentNullException(nameof(factory));
}
[Fact]
public async Task List_ReturnsUnauthorized_WhenBootstrapKeyMissing()
{
using var app = CreateApplication(builder =>
{
builder.ConfigureServices(services =>
{
var authBuilder = services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName;
options.DefaultChallengeScheme = TestAuthHandler.SchemeName;
});
authBuilder.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(TestAuthHandler.SchemeName, _ => { });
});
});
using var client = app.CreateClient();
var response = await client.GetAsync($"/internal/service-accounts?tenant={TenantId}");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task List_ReturnsBadRequest_WhenTenantMissing()
{
using var app = CreateApplication(builder =>
{
builder.ConfigureServices(services =>
{
var authBuilder = services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName;
options.DefaultChallengeScheme = TestAuthHandler.SchemeName;
});
authBuilder.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(TestAuthHandler.SchemeName, _ => { });
});
});
using var client = app.CreateClient();
client.DefaultRequestHeaders.Add("X-StellaOps-Bootstrap-Key", BootstrapKey);
var response = await client.GetAsync("/internal/service-accounts");
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task List_ReturnsServiceAccountsForTenant()
{
using var app = CreateApplication(builder =>
{
builder.ConfigureServices(services =>
{
var authBuilder = services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName;
options.DefaultChallengeScheme = TestAuthHandler.SchemeName;
});
authBuilder.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(TestAuthHandler.SchemeName, _ => { });
});
});
using var client = app.CreateClient();
client.DefaultRequestHeaders.Add("X-StellaOps-Bootstrap-Key", BootstrapKey);
var response = await client.GetAsync($"/internal/service-accounts?tenant={TenantId}");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var payload = await response.Content.ReadFromJsonAsync<ServiceAccountResponse[]>(default);
Assert.NotNull(payload);
var serviceAccount = Assert.Single(payload!);
Assert.Equal(ServiceAccountId, serviceAccount.AccountId);
Assert.Equal(TenantId, serviceAccount.Tenant);
Assert.Equal("Observability Exporter", serviceAccount.DisplayName);
Assert.True(serviceAccount.Enabled);
Assert.Equal(new[] { "findings:read", "jobs:read" }, serviceAccount.AllowedScopes);
Assert.Equal(new[] { "export-center-worker" }, serviceAccount.AuthorizedClients);
}
[Fact]
public async Task Tokens_ReturnsActiveDelegationTokens()
{
using var app = CreateApplication();
await using (var scope = app.Services.CreateAsyncScope())
{
var tokenStore = scope.ServiceProvider.GetRequiredService<IAuthorityTokenStore>();
var document = new AuthorityTokenDocument
{
TokenId = "token-1",
ClientId = "export-center-worker",
Status = "valid",
Scope = new List<string> { "jobs:read", "findings:read" },
CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-10),
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(20),
Tenant = TenantId,
ServiceAccountId = ServiceAccountId,
TokenKind = "service_account"
};
await tokenStore.InsertAsync(document, CancellationToken.None).ConfigureAwait(false);
}
using var client = app.CreateClient();
client.DefaultRequestHeaders.Add("X-StellaOps-Bootstrap-Key", BootstrapKey);
var response = await client.GetAsync($"/internal/service-accounts/{ServiceAccountId}/tokens");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var payload = await response.Content.ReadFromJsonAsync<ServiceAccountTokenResponse[]>(default);
Assert.NotNull(payload);
var token = Assert.Single(payload!);
Assert.Equal("token-1", token.TokenId);
Assert.Equal("export-center-worker", token.ClientId);
Assert.Equal("valid", token.Status);
Assert.Equal(new[] { "findings:read", "jobs:read" }, token.Scopes);
Assert.Empty(token.Actors);
}
[Fact]
public async Task Tokens_ReturnsNotFound_WhenServiceAccountMissing()
{
using var app = CreateApplication(builder =>
{
builder.ConfigureServices(services =>
{
var authBuilder = services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName;
options.DefaultChallengeScheme = TestAuthHandler.SchemeName;
});
authBuilder.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(TestAuthHandler.SchemeName, _ => { });
});
});
using var client = app.CreateClient();
client.DefaultRequestHeaders.Add("X-StellaOps-Bootstrap-Key", BootstrapKey);
var response = await client.GetAsync("/internal/service-accounts/svc-missing/tokens");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task Revoke_RevokesAllActiveTokens_AndEmitsAuditEvent()
{
var sink = new RecordingAuthEventSink();
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-02T18:00:00Z"));
using var app = CreateApplication(builder =>
{
builder.ConfigureServices(services =>
{
services.RemoveAll<IAuthEventSink>();
services.AddSingleton<IAuthEventSink>(sink);
services.Replace(ServiceDescriptor.Singleton<TimeProvider>(timeProvider));
var authBuilder = services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName;
options.DefaultChallengeScheme = TestAuthHandler.SchemeName;
});
authBuilder.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(TestAuthHandler.SchemeName, _ => { });
});
});
var tokenIds = new[] { "token-a", "token-b" };
await using (var scope = app.Services.CreateAsyncScope())
{
var tokenStore = scope.ServiceProvider.GetRequiredService<IAuthorityTokenStore>();
foreach (var tokenId in tokenIds)
{
await tokenStore.InsertAsync(new AuthorityTokenDocument
{
TokenId = tokenId,
ClientId = "export-center-worker",
Status = "valid",
Scope = new List<string> { "jobs:read" },
CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(30),
Tenant = TenantId,
ServiceAccountId = ServiceAccountId,
TokenKind = "service_account"
}, CancellationToken.None).ConfigureAwait(false);
}
}
using var client = app.CreateClient();
client.DefaultRequestHeaders.Add("X-StellaOps-Bootstrap-Key", BootstrapKey);
var response = await client.PostAsJsonAsync($"/internal/service-accounts/{ServiceAccountId}/revocations", new
{
reason = "operator_request",
reasonDescription = "Rotate credentials"
});
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var payload = await response.Content.ReadFromJsonAsync<ServiceAccountRevokeResponse>(default);
Assert.NotNull(payload);
Assert.Equal(2, payload!.RevokedCount);
Assert.Equal(tokenIds.OrderBy(id => id, StringComparer.Ordinal), payload.TokenIds.OrderBy(id => id, StringComparer.Ordinal));
await using (var scope = app.Services.CreateAsyncScope())
{
var tokenStore = scope.ServiceProvider.GetRequiredService<IAuthorityTokenStore>();
foreach (var tokenId in tokenIds)
{
var sessionAccessor = scope.ServiceProvider.GetRequiredService<IAuthorityMongoSessionAccessor>();
var session = await sessionAccessor.GetSessionAsync().ConfigureAwait(false);
var token = await tokenStore.FindByTokenIdAsync(tokenId, CancellationToken.None, session).ConfigureAwait(false);
Assert.NotNull(token);
Assert.Equal("revoked", token!.Status);
}
}
var audit = Assert.Single(sink.Events.Where(evt => evt.EventType == "authority.delegation.revoked"));
Assert.Equal(AuthEventOutcome.Success, audit.Outcome);
Assert.Equal("operator_request", audit.Reason);
Assert.Contains(audit.Properties, property =>
string.Equals(property.Name, "delegation.service_account", StringComparison.Ordinal) &&
string.Equals(property.Value.Value, ServiceAccountId, StringComparison.Ordinal));
Assert.Contains(audit.Properties, property =>
string.Equals(property.Name, "delegation.revoked_count", StringComparison.Ordinal) &&
string.Equals(property.Value.Value, "2", StringComparison.Ordinal));
}
[Fact]
public async Task Revoke_ReturnsNotFound_WhenServiceAccountMissing()
{
var sink = new RecordingAuthEventSink();
using var app = CreateApplication(builder =>
{
builder.ConfigureServices(services =>
{
services.RemoveAll<IAuthEventSink>();
services.AddSingleton<IAuthEventSink>(sink);
var authBuilder = services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName;
options.DefaultChallengeScheme = TestAuthHandler.SchemeName;
});
authBuilder.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(TestAuthHandler.SchemeName, _ => { });
});
});
using var client = app.CreateClient();
client.DefaultRequestHeaders.Add("X-StellaOps-Bootstrap-Key", BootstrapKey);
var response = await client.PostAsJsonAsync("/internal/service-accounts/svc-unknown/revocations", new { reason = "rotate" });
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
Assert.Empty(sink.Events);
}
[Fact]
public async Task Revoke_ReturnsNotFound_WhenTokenNotFound()
{
var sink = new RecordingAuthEventSink();
using var app = CreateApplication(builder =>
{
builder.ConfigureServices(services =>
{
services.RemoveAll<IAuthEventSink>();
services.AddSingleton<IAuthEventSink>(sink);
var authBuilder = services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName;
options.DefaultChallengeScheme = TestAuthHandler.SchemeName;
});
authBuilder.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(TestAuthHandler.SchemeName, _ => { });
});
});
using var client = app.CreateClient();
client.DefaultRequestHeaders.Add("X-StellaOps-Bootstrap-Key", BootstrapKey);
var response = await client.PostAsJsonAsync($"/internal/service-accounts/{ServiceAccountId}/revocations", new { tokenId = "missing-token", reason = "cleanup" });
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
Assert.Empty(sink.Events);
}
[Fact]
public async Task Revoke_ReturnsFailure_WhenNoActiveTokens()
{
var sink = new RecordingAuthEventSink();
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-02T09:00:00Z"));
using var app = CreateApplication(builder =>
{
builder.ConfigureServices(services =>
{
services.RemoveAll<IAuthEventSink>();
services.AddSingleton<IAuthEventSink>(sink);
services.Replace(ServiceDescriptor.Singleton<TimeProvider>(timeProvider));
var authBuilder = services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName;
options.DefaultChallengeScheme = TestAuthHandler.SchemeName;
});
authBuilder.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(TestAuthHandler.SchemeName, _ => { });
});
});
await using (var scope = app.Services.CreateAsyncScope())
{
var tokenStore = scope.ServiceProvider.GetRequiredService<IAuthorityTokenStore>();
await tokenStore.InsertAsync(new AuthorityTokenDocument
{
TokenId = "token-revoked",
ClientId = "export-center-worker",
Status = "revoked",
Scope = new List<string> { "jobs:read" },
CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-20),
Tenant = TenantId,
ServiceAccountId = ServiceAccountId,
TokenKind = "service_account"
}, CancellationToken.None).ConfigureAwait(false);
}
using var client = app.CreateClient();
client.DefaultRequestHeaders.Add("X-StellaOps-Bootstrap-Key", BootstrapKey);
var response = await client.PostAsJsonAsync($"/internal/service-accounts/{ServiceAccountId}/revocations", new { reason = "cleanup" });
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var payload = await response.Content.ReadFromJsonAsync<ServiceAccountRevokeResponse>(default);
Assert.NotNull(payload);
Assert.Equal(0, payload!.RevokedCount);
Assert.Empty(payload.TokenIds);
var audit = Assert.Single(sink.Events);
Assert.Equal(AuthEventOutcome.Failure, audit.Outcome);
Assert.Equal("cleanup", audit.Reason);
Assert.Equal("0", GetPropertyValue(audit, "delegation.revoked_count"));
}
[Fact]
public async Task Revoke_ReturnsSuccess_WhenPartiallyRevokingTokens()
{
var sink = new RecordingAuthEventSink();
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-02T09:30:00Z"));
using var app = CreateApplication(builder =>
{
builder.ConfigureServices(services =>
{
services.RemoveAll<IAuthEventSink>();
services.AddSingleton<IAuthEventSink>(sink);
services.Replace(ServiceDescriptor.Singleton<TimeProvider>(timeProvider));
var authBuilder = services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName;
options.DefaultChallengeScheme = TestAuthHandler.SchemeName;
});
authBuilder.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(TestAuthHandler.SchemeName, _ => { });
});
});
await using (var scope = app.Services.CreateAsyncScope())
{
var tokenStore = scope.ServiceProvider.GetRequiredService<IAuthorityTokenStore>();
await tokenStore.InsertAsync(new AuthorityTokenDocument
{
TokenId = "token-active",
ClientId = "export-center-worker",
Status = "valid",
Scope = new List<string> { "jobs:read" },
CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-10),
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(30),
Tenant = TenantId,
ServiceAccountId = ServiceAccountId,
TokenKind = "service_account"
}, CancellationToken.None).ConfigureAwait(false);
await tokenStore.InsertAsync(new AuthorityTokenDocument
{
TokenId = "token-already-revoked",
ClientId = "export-center-worker",
Status = "revoked",
Scope = new List<string> { "jobs:read" },
CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-25),
Tenant = TenantId,
ServiceAccountId = ServiceAccountId,
TokenKind = "service_account"
}, CancellationToken.None).ConfigureAwait(false);
}
using var client = app.CreateClient();
client.DefaultRequestHeaders.Add("X-StellaOps-Bootstrap-Key", BootstrapKey);
var response = await client.PostAsJsonAsync($"/internal/service-accounts/{ServiceAccountId}/revocations", new { reason = "partial" });
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var payload = await response.Content.ReadFromJsonAsync<ServiceAccountRevokeResponse>(default);
Assert.NotNull(payload);
Assert.Equal(1, payload!.RevokedCount);
Assert.Equal(new[] { "token-active" }, payload.TokenIds);
var audit = Assert.Single(sink.Events);
Assert.Equal(AuthEventOutcome.Success, audit.Outcome);
Assert.Equal("partial", audit.Reason);
Assert.Equal("1", GetPropertyValue(audit, "delegation.revoked_count"));
Assert.Equal("token-active", GetPropertyValue(audit, "delegation.revoked_token[0]"));
}
private WebApplicationFactory<Program> CreateApplication(Action<IWebHostBuilder>? configure = null)
{
return factory.WithWebHostBuilder(host =>
{
host.ConfigureAppConfiguration((_, configuration) =>
{
configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["Authority:Bootstrap:Enabled"] = "true",
["Authority:Bootstrap:ApiKey"] = BootstrapKey,
["Authority:Bootstrap:DefaultIdentityProvider"] = "standard",
["Authority:Tenants:0:Id"] = TenantId,
["Authority:Tenants:0:DisplayName"] = "Default Tenant",
["Authority:Delegation:Quotas:MaxActiveTokens"] = "50",
["Authority:Delegation:ServiceAccounts:0:AccountId"] = ServiceAccountId,
["Authority:Delegation:ServiceAccounts:0:Tenant"] = TenantId,
["Authority:Delegation:ServiceAccounts:0:DisplayName"] = "Observability Exporter",
["Authority:Delegation:ServiceAccounts:0:Description"] = "Automates evidence exports.",
["Authority:Delegation:ServiceAccounts:0:AllowedScopes:0"] = "jobs:read",
["Authority:Delegation:ServiceAccounts:0:AllowedScopes:1"] = "findings:read",
["Authority:Delegation:ServiceAccounts:0:AuthorizedClients:0"] = "export-center-worker"
});
});
configure?.Invoke(host);
});
}
private static string? GetPropertyValue(AuthEventRecord record, string name)
{
return record.Properties
.FirstOrDefault(property => string.Equals(property.Name, name, StringComparison.Ordinal))
?.Value.Value;
}
private sealed record ServiceAccountResponse(
string AccountId,
string Tenant,
string? DisplayName,
string? Description,
bool Enabled,
IReadOnlyList<string> AllowedScopes,
IReadOnlyList<string> AuthorizedClients);
private sealed record ServiceAccountTokenResponse(
string TokenId,
string? ClientId,
string Status,
IReadOnlyList<string> Scopes,
IReadOnlyList<string> Actors);
private sealed record ServiceAccountRevokeResponse(int RevokedCount, IReadOnlyList<string> TokenIds);
private sealed class RecordingAuthEventSink : IAuthEventSink
{
private readonly List<AuthEventRecord> events = new();
public IReadOnlyList<AuthEventRecord> Events => events;
public ValueTask WriteAsync(AuthEventRecord record, CancellationToken cancellationToken)
{
lock (events)
{
events.Add(record);
}
return ValueTask.CompletedTask;
}
}
}

View File

@@ -59,6 +59,8 @@ public class ClientCredentialsHandlersTests
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
new TestServiceAccountStore(),
new TestTokenStore(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
@@ -91,6 +93,8 @@ public class ClientCredentialsHandlersTests
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
new TestServiceAccountStore(),
new TestTokenStore(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
@@ -123,6 +127,8 @@ public class ClientCredentialsHandlersTests
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
new TestServiceAccountStore(),
new TestTokenStore(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
@@ -160,6 +166,8 @@ public class ClientCredentialsHandlersTests
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
new TestServiceAccountStore(),
new TestTokenStore(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
@@ -176,6 +184,128 @@ public class ClientCredentialsHandlersTests
Assert.Equal(new[] { "advisory:ingest" }, grantedScopes);
}
[Fact]
public async Task ValidateClientCredentials_AllowsServiceAccountWhenAuthorized()
{
var clientDocument = CreateClient(
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "jobs:read",
tenant: "tenant-alpha");
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var options = TestHelpers.CreateAuthorityOptions(opts =>
{
opts.Delegation.Quotas.MaxActiveTokens = 5;
});
var serviceAccount = new AuthorityServiceAccountDocument
{
AccountId = "svc-observer",
Tenant = "tenant-alpha",
AllowedScopes = new List<string> { "jobs:read" },
AuthorizedClients = new List<string> { clientDocument.ClientId }
};
var serviceAccountStore = new TestServiceAccountStore(serviceAccount);
var tokenStore = new TestTokenStore();
var handler = new ValidateClientCredentialsHandler(
new TestClientStore(clientDocument),
registry,
TestActivitySource,
new TestAuthEventSink(),
metadataAccessor,
serviceAccountStore,
tokenStore,
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
options,
NullLogger<ValidateClientCredentialsHandler>.Instance);
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read");
transaction.Request.SetParameter(AuthorityOpenIddictConstants.ServiceAccountParameterName, "svc-observer");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await handler.HandleAsync(context);
Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}");
Assert.True(context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.ServiceAccountProperty, out var serviceAccountObj));
var resolvedAccount = Assert.IsType<AuthorityServiceAccountDocument>(serviceAccountObj);
Assert.Equal("svc-observer", resolvedAccount.AccountId);
var grantedScopes = Assert.IsType<string[]>(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]);
Assert.Contains("jobs:read", grantedScopes);
Assert.Equal("svc-observer", metadataAccessor.GetMetadata()?.SubjectId);
Assert.Equal(AuthorityTokenKinds.ServiceAccount, context.Transaction.Properties[AuthorityOpenIddictConstants.TokenKindProperty]);
}
[Fact]
public async Task ValidateClientCredentials_RejectsWhenServiceAccountQuotaExceeded()
{
var clientDocument = CreateClient(
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "jobs:read",
tenant: "tenant-alpha");
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var options = TestHelpers.CreateAuthorityOptions(opts =>
{
opts.Delegation.Quotas.MaxActiveTokens = 1;
});
var serviceAccount = new AuthorityServiceAccountDocument
{
AccountId = "svc-observer",
Tenant = "tenant-alpha",
AllowedScopes = new List<string> { "jobs:read" },
AuthorizedClients = new List<string> { clientDocument.ClientId }
};
var serviceAccountStore = new TestServiceAccountStore(serviceAccount);
var tokenStore = new TestTokenStore
{
Inserted = new AuthorityTokenDocument
{
TokenId = "existing-token",
Status = "valid",
Tenant = "tenant-alpha",
ClientId = clientDocument.ClientId,
ServiceAccountId = "svc-observer",
TokenKind = AuthorityTokenKinds.ServiceAccount,
CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-1),
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5),
Scope = new List<string> { "jobs:read" }
}
};
var handler = new ValidateClientCredentialsHandler(
new TestClientStore(clientDocument),
registry,
TestActivitySource,
new TestAuthEventSink(),
metadataAccessor,
serviceAccountStore,
tokenStore,
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
options,
NullLogger<ValidateClientCredentialsHandler>.Instance);
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read");
transaction.Request.SetParameter(AuthorityOpenIddictConstants.ServiceAccountParameterName, "svc-observer");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await handler.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error);
Assert.Equal("Delegation token quota exceeded for service account.", context.ErrorDescription);
}
[Fact]
public async Task ValidateClientCredentials_RejectsAdvisoryReadWithoutAocVerify()
{
@@ -193,6 +323,8 @@ public class ClientCredentialsHandlersTests
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
new TestServiceAccountStore(),
new TestTokenStore(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
@@ -227,6 +359,8 @@ public class ClientCredentialsHandlersTests
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
new TestServiceAccountStore(),
new TestTokenStore(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
@@ -265,6 +399,8 @@ public class ClientCredentialsHandlersTests
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
new TestServiceAccountStore(),
new TestTokenStore(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
@@ -304,6 +440,8 @@ public class ClientCredentialsHandlersTests
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
new TestServiceAccountStore(),
new TestTokenStore(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
@@ -336,6 +474,8 @@ public class ClientCredentialsHandlersTests
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
new TestServiceAccountStore(),
new TestTokenStore(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
@@ -370,6 +510,8 @@ public class ClientCredentialsHandlersTests
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
new TestServiceAccountStore(),
new TestTokenStore(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
@@ -403,6 +545,8 @@ public class ClientCredentialsHandlersTests
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
new TestServiceAccountStore(),
new TestTokenStore(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
@@ -437,6 +581,8 @@ public class ClientCredentialsHandlersTests
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
new TestServiceAccountStore(),
new TestTokenStore(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
@@ -471,6 +617,8 @@ public class ClientCredentialsHandlersTests
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
new TestServiceAccountStore(),
new TestTokenStore(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
@@ -503,6 +651,8 @@ public class ClientCredentialsHandlersTests
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
new TestServiceAccountStore(),
new TestTokenStore(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
@@ -538,6 +688,8 @@ public class ClientCredentialsHandlersTests
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
new TestServiceAccountStore(),
new TestTokenStore(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
@@ -575,6 +727,8 @@ public class ClientCredentialsHandlersTests
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
new TestServiceAccountStore(),
new TestTokenStore(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
@@ -610,6 +764,8 @@ public class ClientCredentialsHandlersTests
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
new TestServiceAccountStore(),
new TestTokenStore(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
@@ -646,6 +802,8 @@ public class ClientCredentialsHandlersTests
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
new TestServiceAccountStore(),
new TestTokenStore(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
@@ -682,6 +840,8 @@ public class ClientCredentialsHandlersTests
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
new TestServiceAccountStore(),
new TestTokenStore(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
@@ -717,6 +877,8 @@ public class ClientCredentialsHandlersTests
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
new TestServiceAccountStore(),
new TestTokenStore(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
@@ -751,6 +913,8 @@ public class ClientCredentialsHandlersTests
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
new TestServiceAccountStore(),
new TestTokenStore(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
@@ -787,6 +951,8 @@ public class ClientCredentialsHandlersTests
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
new TestServiceAccountStore(),
new TestTokenStore(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
@@ -822,6 +988,8 @@ public class ClientCredentialsHandlersTests
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
new TestServiceAccountStore(),
new TestTokenStore(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
@@ -857,6 +1025,8 @@ public class ClientCredentialsHandlersTests
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
new TestServiceAccountStore(),
new TestTokenStore(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
@@ -894,6 +1064,8 @@ public class ClientCredentialsHandlersTests
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
new TestServiceAccountStore(),
new TestTokenStore(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
@@ -931,6 +1103,8 @@ public class ClientCredentialsHandlersTests
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
new TestServiceAccountStore(),
new TestTokenStore(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
@@ -971,6 +1145,8 @@ public class ClientCredentialsHandlersTests
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
new TestServiceAccountStore(),
new TestTokenStore(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
@@ -1014,6 +1190,8 @@ public class ClientCredentialsHandlersTests
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
new TestServiceAccountStore(),
new TestTokenStore(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
@@ -1050,6 +1228,8 @@ public class ClientCredentialsHandlersTests
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
new TestServiceAccountStore(),
new TestTokenStore(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
@@ -1084,6 +1264,8 @@ public class ClientCredentialsHandlersTests
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
new TestServiceAccountStore(),
new TestTokenStore(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
@@ -1120,6 +1302,8 @@ public class ClientCredentialsHandlersTests
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
new TestServiceAccountStore(),
new TestTokenStore(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
@@ -1156,6 +1340,8 @@ public class ClientCredentialsHandlersTests
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
new TestServiceAccountStore(),
new TestTokenStore(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
@@ -1196,6 +1382,8 @@ public class ClientCredentialsHandlersTests
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
new TestServiceAccountStore(),
new TestTokenStore(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
@@ -1234,6 +1422,8 @@ public class ClientCredentialsHandlersTests
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
new TestServiceAccountStore(),
new TestTokenStore(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
@@ -1268,6 +1458,8 @@ public class ClientCredentialsHandlersTests
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
new TestServiceAccountStore(),
new TestTokenStore(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
@@ -1302,6 +1494,8 @@ public class ClientCredentialsHandlersTests
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
new TestServiceAccountStore(),
new TestTokenStore(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
@@ -1349,6 +1543,8 @@ public class ClientCredentialsHandlersTests
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
new TestServiceAccountStore(),
new TestTokenStore(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
@@ -1384,6 +1580,8 @@ public class ClientCredentialsHandlersTests
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
new TestServiceAccountStore(),
new TestTokenStore(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
@@ -1418,6 +1616,8 @@ public class ClientCredentialsHandlersTests
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
new TestServiceAccountStore(),
new TestTokenStore(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
@@ -1452,6 +1652,8 @@ public class ClientCredentialsHandlersTests
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
new TestServiceAccountStore(),
new TestTokenStore(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
@@ -1494,6 +1696,8 @@ public class ClientCredentialsHandlersTests
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
new TestServiceAccountStore(),
new TestTokenStore(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
@@ -1529,6 +1733,8 @@ public class ClientCredentialsHandlersTests
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
new TestServiceAccountStore(),
new TestTokenStore(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
@@ -1562,6 +1768,8 @@ public class ClientCredentialsHandlersTests
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
new TestServiceAccountStore(),
new TestTokenStore(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
@@ -1596,6 +1804,8 @@ public class ClientCredentialsHandlersTests
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
new TestServiceAccountStore(),
new TestTokenStore(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
@@ -1631,6 +1841,8 @@ public class ClientCredentialsHandlersTests
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
new TestServiceAccountStore(),
new TestTokenStore(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
@@ -1665,6 +1877,8 @@ public class ClientCredentialsHandlersTests
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
new TestServiceAccountStore(),
new TestTokenStore(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
@@ -1700,6 +1914,8 @@ public class ClientCredentialsHandlersTests
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
new TestServiceAccountStore(),
new TestTokenStore(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
@@ -1733,6 +1949,8 @@ public class ClientCredentialsHandlersTests
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
new TestServiceAccountStore(),
new TestTokenStore(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
@@ -1767,6 +1985,8 @@ public class ClientCredentialsHandlersTests
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
new TestServiceAccountStore(),
new TestTokenStore(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
@@ -1802,6 +2022,8 @@ public class ClientCredentialsHandlersTests
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
new TestServiceAccountStore(),
new TestTokenStore(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
@@ -1837,6 +2059,8 @@ public class ClientCredentialsHandlersTests
TestActivitySource,
sink,
new TestRateLimiterMetadataAccessor(),
new TestServiceAccountStore(),
new TestTokenStore(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
@@ -2094,6 +2318,8 @@ public class ClientCredentialsHandlersTests
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
var auditSink = new TestAuthEventSink();
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var serviceAccountStore = new TestServiceAccountStore();
var tokenStore = new TestTokenStore();
var httpContextAccessor = new HttpContextAccessor { HttpContext = new DefaultHttpContext() };
httpContextAccessor.HttpContext!.Connection.ClientCertificate = certificate;
@@ -2105,6 +2331,8 @@ public class ClientCredentialsHandlersTests
TestActivitySource,
auditSink,
metadataAccessor,
serviceAccountStore,
tokenStore,
TimeProvider.System,
validator,
httpContextAccessor,
@@ -2152,6 +2380,8 @@ public class ClientCredentialsHandlersTests
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
new TestServiceAccountStore(),
new TestTokenStore(),
TimeProvider.System,
validator,
httpContextAccessor,
@@ -2192,6 +2422,8 @@ public class ClientCredentialsHandlersTests
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
new TestServiceAccountStore(),
new TestTokenStore(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
@@ -2238,6 +2470,8 @@ public class ClientCredentialsHandlersTests
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
new TestServiceAccountStore(),
new TestTokenStore(),
TimeProvider.System,
certificateValidator,
httpContextAccessor,
@@ -2272,6 +2506,7 @@ public class ClientCredentialsHandlersTests
var sessionAccessor = new NullMongoSessionAccessor();
var authSink = new TestAuthEventSink();
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var serviceAccountStore = new TestServiceAccountStore();
var options = TestHelpers.CreateAuthorityOptions();
var validateHandler = new ValidateClientCredentialsHandler(
new TestClientStore(clientDocument),
@@ -2279,6 +2514,8 @@ public class ClientCredentialsHandlersTests
TestActivitySource,
authSink,
metadataAccessor,
serviceAccountStore,
tokenStore,
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
@@ -2335,6 +2572,88 @@ public class ClientCredentialsHandlersTests
Assert.Equal("tenant-alpha", persisted.Tenant);
Assert.Equal(new[] { "jobs:trigger" }, persisted.Scope);
}
[Fact]
public async Task HandleClientCredentials_PersistsServiceAccountMetadata()
{
var clientDocument = CreateClient(
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "jobs:read",
tenant: "tenant-alpha");
var serviceAccount = new AuthorityServiceAccountDocument
{
AccountId = "svc-ops",
Tenant = "tenant-alpha",
AllowedScopes = new List<string> { "jobs:read" },
AuthorizedClients = new List<string> { clientDocument.ClientId }
};
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
var tokenStore = new TestTokenStore();
var sessionAccessor = new NullMongoSessionAccessor();
var authSink = new TestAuthEventSink();
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var serviceAccountStore = new TestServiceAccountStore(serviceAccount);
var options = TestHelpers.CreateAuthorityOptions(opts =>
{
opts.Delegation.Quotas.MaxActiveTokens = 5;
});
var validateHandler = new ValidateClientCredentialsHandler(
new TestClientStore(clientDocument),
registry,
TestActivitySource,
authSink,
metadataAccessor,
serviceAccountStore,
tokenStore,
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
options,
NullLogger<ValidateClientCredentialsHandler>.Instance);
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read");
transaction.Options.AccessTokenLifetime = TimeSpan.FromMinutes(10);
transaction.Request.SetParameter(AuthorityOpenIddictConstants.ServiceAccountParameterName, "svc-ops");
var validateContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await validateHandler.HandleAsync(validateContext);
Assert.False(validateContext.IsRejected);
var handleHandler = new HandleClientCredentialsHandler(
registry,
tokenStore,
sessionAccessor,
metadataAccessor,
TimeProvider.System,
TestActivitySource,
NullLogger<HandleClientCredentialsHandler>.Instance);
var persistHandler = new PersistTokensHandler(tokenStore, sessionAccessor, TimeProvider.System, TestActivitySource, NullLogger<PersistTokensHandler>.Instance);
var handleContext = new OpenIddictServerEvents.HandleTokenRequestContext(transaction);
await handleHandler.HandleAsync(handleContext);
Assert.True(handleContext.IsRequestHandled);
var signInContext = new OpenIddictServerEvents.ProcessSignInContext(transaction)
{
Principal = handleContext.Principal,
AccessTokenPrincipal = handleContext.Principal
};
await persistHandler.HandleAsync(signInContext);
var inserted = tokenStore.Inserted;
Assert.NotNull(inserted);
Assert.Equal("svc-ops", inserted!.ServiceAccountId);
Assert.Equal("service_account", inserted.TokenKind);
Assert.NotNull(inserted.ActorChain);
Assert.Contains(clientDocument.ClientId, inserted.ActorChain!);
Assert.Equal("tenant-alpha", inserted.Tenant);
Assert.Contains("jobs:read", inserted.Scope);
}
}
public class TokenValidationHandlersTests
@@ -2953,6 +3272,65 @@ internal sealed class TestClientStore : IAuthorityClientStore
=> ValueTask.FromResult(clients.Remove(clientId));
}
internal sealed class TestServiceAccountStore : IAuthorityServiceAccountStore
{
private readonly Dictionary<string, AuthorityServiceAccountDocument> accounts = new(StringComparer.OrdinalIgnoreCase);
public TestServiceAccountStore(params AuthorityServiceAccountDocument[] documents)
{
foreach (var document in documents)
{
accounts[NormalizeKey(document.AccountId)] = document;
}
}
public ValueTask<AuthorityServiceAccountDocument?> FindByAccountIdAsync(string accountId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
if (string.IsNullOrWhiteSpace(accountId))
{
return ValueTask.FromResult<AuthorityServiceAccountDocument?>(null);
}
accounts.TryGetValue(NormalizeKey(accountId), out var document);
return ValueTask.FromResult(document);
}
public ValueTask<IReadOnlyList<AuthorityServiceAccountDocument>> ListByTenantAsync(string tenant, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
if (string.IsNullOrWhiteSpace(tenant))
{
return ValueTask.FromResult<IReadOnlyList<AuthorityServiceAccountDocument>>(Array.Empty<AuthorityServiceAccountDocument>());
}
var normalizedTenant = tenant.Trim().ToLowerInvariant();
var results = accounts.Values
.Where(account => string.Equals(account.Tenant, normalizedTenant, StringComparison.OrdinalIgnoreCase))
.ToList();
return ValueTask.FromResult<IReadOnlyList<AuthorityServiceAccountDocument>>(results);
}
public ValueTask UpsertAsync(AuthorityServiceAccountDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
ArgumentNullException.ThrowIfNull(document);
accounts[NormalizeKey(document.AccountId)] = document;
return ValueTask.CompletedTask;
}
public ValueTask<bool> DeleteAsync(string accountId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
if (string.IsNullOrWhiteSpace(accountId))
{
return ValueTask.FromResult(false);
}
return ValueTask.FromResult(accounts.Remove(NormalizeKey(accountId)));
}
private static string NormalizeKey(string value)
=> string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim().ToLowerInvariant();
}
internal sealed class TestTokenStore : IAuthorityTokenStore
{
public AuthorityTokenDocument? Inserted { get; set; }
@@ -3001,6 +3379,47 @@ internal sealed class TestTokenStore : IAuthorityTokenStore
return ValueTask.FromResult<IReadOnlyList<AuthorityTokenDocument>>(Array.Empty<AuthorityTokenDocument>());
}
public ValueTask<long> CountActiveDelegationTokensAsync(string tenant, string? serviceAccountId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
if (Inserted is null)
{
return ValueTask.FromResult(0L);
}
var tenantMatches = string.Equals(Inserted.Tenant, tenant, StringComparison.OrdinalIgnoreCase);
var accountMatches = string.IsNullOrWhiteSpace(serviceAccountId) ||
string.Equals(Inserted.ServiceAccountId, serviceAccountId, StringComparison.OrdinalIgnoreCase);
var active = string.Equals(Inserted.Status, "valid", StringComparison.OrdinalIgnoreCase) &&
(!Inserted.ExpiresAt.HasValue || Inserted.ExpiresAt.Value > DateTimeOffset.UtcNow) &&
!string.IsNullOrWhiteSpace(Inserted.ServiceAccountId) &&
string.Equals(Inserted.TokenKind, AuthorityTokenKinds.ServiceAccount, StringComparison.OrdinalIgnoreCase);
return ValueTask.FromResult(tenantMatches && accountMatches && active ? 1L : 0L);
}
public ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListActiveDelegationTokensAsync(string tenant, string? serviceAccountId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
if (Inserted is null)
{
return ValueTask.FromResult<IReadOnlyList<AuthorityTokenDocument>>(Array.Empty<AuthorityTokenDocument>());
}
var tenantMatches = string.Equals(Inserted.Tenant, tenant, StringComparison.OrdinalIgnoreCase);
var accountMatches = string.IsNullOrWhiteSpace(serviceAccountId) ||
string.Equals(Inserted.ServiceAccountId, serviceAccountId, StringComparison.OrdinalIgnoreCase);
var active = string.Equals(Inserted.Status, "valid", StringComparison.OrdinalIgnoreCase) &&
(!Inserted.ExpiresAt.HasValue || Inserted.ExpiresAt.Value > DateTimeOffset.UtcNow) &&
!string.IsNullOrWhiteSpace(Inserted.ServiceAccountId) &&
string.Equals(Inserted.TokenKind, AuthorityTokenKinds.ServiceAccount, StringComparison.OrdinalIgnoreCase);
if (tenantMatches && accountMatches && active)
{
return ValueTask.FromResult<IReadOnlyList<AuthorityTokenDocument>>(new[] { Inserted });
}
return ValueTask.FromResult<IReadOnlyList<AuthorityTokenDocument>>(Array.Empty<AuthorityTokenDocument>());
}
}
internal sealed class TestClaimsEnricher : IClaimsEnricher

View File

@@ -47,8 +47,9 @@ public sealed class TokenPersistenceIntegrationTests
await using var provider = await BuildMongoProviderAsync(clock);
var clientStore = provider.GetRequiredService<IAuthorityClientStore>();
var tokenStore = provider.GetRequiredService<IAuthorityTokenStore>();
var clientStore = provider.GetRequiredService<IAuthorityClientStore>();
var tokenStore = provider.GetRequiredService<IAuthorityTokenStore>();
var serviceAccountStore = provider.GetRequiredService<IAuthorityServiceAccountStore>();
var clientDocument = TestHelpers.CreateClient(
secret: "s3cr3t!",
@@ -67,7 +68,19 @@ public sealed class TokenPersistenceIntegrationTests
await using var scope = provider.CreateAsyncScope();
var sessionAccessor = scope.ServiceProvider.GetRequiredService<IAuthorityMongoSessionAccessor>();
var options = TestHelpers.CreateAuthorityOptions();
var validateHandler = new ValidateClientCredentialsHandler(clientStore, registry, TestActivitySource, authSink, metadataAccessor, clock, new NoopCertificateValidator(), new HttpContextAccessor(), options, NullLogger<ValidateClientCredentialsHandler>.Instance);
var validateHandler = new ValidateClientCredentialsHandler(
clientStore,
registry,
TestActivitySource,
authSink,
metadataAccessor,
serviceAccountStore,
tokenStore,
clock,
new NoopCertificateValidator(),
new HttpContextAccessor(),
options,
NullLogger<ValidateClientCredentialsHandler>.Instance);
var handleHandler = new HandleClientCredentialsHandler(registry, tokenStore, sessionAccessor, metadataAccessor, clock, TestActivitySource, NullLogger<HandleClientCredentialsHandler>.Instance);
var persistHandler = new PersistTokensHandler(tokenStore, sessionAccessor, clock, TestActivitySource, NullLogger<PersistTokensHandler>.Instance);

View File

@@ -1,14 +1,14 @@
namespace StellaOps.Authority.OpenIddict;
internal static class AuthorityOpenIddictConstants
{
internal const string ProviderParameterName = "authority_provider";
internal const string ProviderTransactionProperty = "authority:identity_provider";
internal const string ClientTransactionProperty = "authority:client";
internal const string ClientProviderTransactionProperty = "authority:client_provider";
internal const string ClientGrantedScopesProperty = "authority:client_granted_scopes";
internal const string TokenTransactionProperty = "authority:token";
internal const string AuditCorrelationProperty = "authority:audit_correlation_id";
namespace StellaOps.Authority.OpenIddict;
internal static class AuthorityOpenIddictConstants
{
internal const string ProviderParameterName = "authority_provider";
internal const string ProviderTransactionProperty = "authority:identity_provider";
internal const string ClientTransactionProperty = "authority:client";
internal const string ClientProviderTransactionProperty = "authority:client_provider";
internal const string ClientGrantedScopesProperty = "authority:client_granted_scopes";
internal const string TokenTransactionProperty = "authority:token";
internal const string AuditCorrelationProperty = "authority:audit_correlation_id";
internal const string AuditClientIdProperty = "authority:audit_client_id";
internal const string AuditProviderProperty = "authority:audit_provider";
internal const string AuditConfidentialProperty = "authority:audit_confidential";
@@ -46,14 +46,9 @@ internal static class AuthorityOpenIddictConstants
internal const string BackfillTicketProperty = "authority:backfill_ticket";
internal const string BackfillReasonParameterName = "backfill_reason";
internal const string BackfillTicketParameterName = "backfill_ticket";
internal const string PolicyReasonProperty = "authority:policy_reason";
internal const string PolicyTicketProperty = "authority:policy_ticket";
internal const string PolicyDigestProperty = "authority:policy_digest";
internal const string PolicyOperationProperty = "authority:policy_operation";
internal const string PolicyAuditPropertiesProperty = "authority:policy_audit_properties";
internal const string PolicyReasonParameterName = "policy_reason";
internal const string PolicyTicketParameterName = "policy_ticket";
internal const string PolicyDigestParameterName = "policy_digest";
internal const string PolicyOperationPublishValue = "publish";
internal const string PolicyOperationPromoteValue = "promote";
internal const string ServiceAccountParameterName = "service_account";
internal const string DelegationActorParameterName = "delegation_actor";
internal const string ServiceAccountProperty = "authority:service_account";
internal const string TokenKindProperty = "authority:token_kind";
internal const string ActorChainProperty = "authority:actor_chain";
}

View File

@@ -0,0 +1,6 @@
namespace StellaOps.Authority.OpenIddict;
internal static class AuthorityTokenKinds
{
internal const string ServiceAccount = "service_account";
}

View File

@@ -31,9 +31,10 @@ using StellaOps.Authority.Notifications.Ack;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Plugins;
using StellaOps.Authority.Bootstrap;
using StellaOps.Authority.Storage.Mongo.Extensions;
using StellaOps.Authority.Storage.Mongo.Initialization;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Authority.Storage.Mongo.Extensions;
using StellaOps.Authority.Storage.Mongo.Initialization;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Authority.Storage.Mongo.Sessions;
using StellaOps.Authority.RateLimiting;
using StellaOps.Configuration;
using StellaOps.Plugin.DependencyInjection;
@@ -51,7 +52,6 @@ using StellaOps.Authority.Signing;
using StellaOps.Cryptography;
using StellaOps.Cryptography.Kms;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Authority.Security;
using StellaOps.Authority.OpenApi;
using StellaOps.Auth.Abstractions;
@@ -1201,11 +1201,11 @@ if (authorityOptions.Bootstrap.Enabled)
}
});
bootstrapGroup.MapGet("/revocations/export", async (
AuthorityRevocationExportService exportService,
CancellationToken cancellationToken) =>
{
var package = await exportService.ExportAsync(cancellationToken).ConfigureAwait(false);
bootstrapGroup.MapGet("/revocations/export", async (
AuthorityRevocationExportService exportService,
CancellationToken cancellationToken) =>
{
var package = await exportService.ExportAsync(cancellationToken).ConfigureAwait(false);
var build = package.Bundle;
var response = new RevocationExportResponse
@@ -1232,14 +1232,272 @@ if (authorityOptions.Bootstrap.Enabled)
}
};
return Results.Ok(response);
});
bootstrapGroup.MapPost("/signing/rotate", (
SigningRotationRequest? request,
AuthoritySigningKeyManager signingManager,
ILogger<AuthoritySigningKeyManager> signingLogger) =>
{
return Results.Ok(response);
});
bootstrapGroup.MapGet("/service-accounts", async (
string? tenant,
IAuthorityServiceAccountStore accountStore,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest(new { error = "invalid_request", message = "Query parameter 'tenant' is required." });
}
var documents = await accountStore.ListByTenantAsync(tenant, cancellationToken).ConfigureAwait(false);
if (documents.Count == 0)
{
return Results.Ok(Array.Empty<ServiceAccountResponse>());
}
var response = documents
.OrderBy(account => account.AccountId, StringComparer.Ordinal)
.Select(MapServiceAccount)
.ToArray();
return Results.Ok(response);
});
bootstrapGroup.MapGet("/service-accounts/{accountId}/tokens", async (
string accountId,
IAuthorityServiceAccountStore accountStore,
IAuthorityTokenStore tokenStore,
IAuthorityMongoSessionAccessor sessionAccessor,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(accountId))
{
return Results.BadRequest(new { error = "invalid_request", message = "Account identifier is required." });
}
var document = await accountStore.FindByAccountIdAsync(accountId, cancellationToken).ConfigureAwait(false);
if (document is null)
{
return Results.NotFound();
}
var session = await sessionAccessor.GetSessionAsync(cancellationToken).ConfigureAwait(false);
var tokens = await tokenStore.ListActiveDelegationTokensAsync(document.Tenant, document.AccountId, cancellationToken, session).ConfigureAwait(false);
var response = tokens
.Select(MapDelegatedToken)
.ToArray();
return Results.Ok(response);
});
bootstrapGroup.MapPost("/service-accounts/{accountId}/revocations", async (
string accountId,
ServiceAccountRevokeRequest? request,
HttpContext httpContext,
IAuthorityServiceAccountStore accountStore,
IAuthorityTokenStore tokenStore,
IAuthorityMongoSessionAccessor sessionAccessor,
IAuthEventSink auditSink,
TimeProvider timeProvider,
CancellationToken cancellationToken) =>
{
if (request is null)
{
return Results.BadRequest(new { error = "invalid_request", message = "Request payload is required." });
}
var document = await accountStore.FindByAccountIdAsync(accountId, cancellationToken).ConfigureAwait(false);
if (document is null)
{
return Results.NotFound();
}
var session = await sessionAccessor.GetSessionAsync(cancellationToken).ConfigureAwait(false);
var now = timeProvider.GetUtcNow();
var targetTokens = new List<AuthorityTokenDocument>();
if (!string.IsNullOrWhiteSpace(request.TokenId))
{
var token = await tokenStore.FindByTokenIdAsync(request.TokenId.Trim(), cancellationToken, session).ConfigureAwait(false);
if (token is not null &&
string.Equals(token.ServiceAccountId, document.AccountId, StringComparison.OrdinalIgnoreCase) &&
string.Equals(token.TokenKind, "service_account", StringComparison.OrdinalIgnoreCase) &&
string.Equals(token.Tenant, document.Tenant, StringComparison.OrdinalIgnoreCase))
{
targetTokens.Add(token);
}
else
{
return Results.NotFound(new { error = "not_found", message = "Delegated token not found for service account." });
}
}
else
{
var active = await tokenStore.ListActiveDelegationTokensAsync(document.Tenant, document.AccountId, cancellationToken, session).ConfigureAwait(false);
targetTokens.AddRange(active);
}
if (targetTokens.Count == 0)
{
await auditSink.WriteAsync(new AuthEventRecord
{
EventType = "authority.delegation.revoked",
OccurredAt = now,
Outcome = AuthEventOutcome.Failure,
Reason = request.Reason ?? "no_active_tokens",
CorrelationId = Activity.Current?.TraceId.ToString() ?? httpContext.TraceIdentifier ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture),
Subject = new AuthEventSubject
{
SubjectId = ClassifiedString.Public(document.AccountId),
Realm = ClassifiedString.Public(document.Tenant)
},
Tenant = ClassifiedString.Public(document.Tenant),
Properties = new[]
{
new AuthEventProperty { Name = "delegation.service_account", Value = ClassifiedString.Public(document.AccountId) },
new AuthEventProperty { Name = "delegation.revoked_count", Value = ClassifiedString.Public("0") }
}
}, cancellationToken).ConfigureAwait(false);
return Results.Ok(new ServiceAccountRevokeResponse(0, Array.Empty<string>()));
}
var revokedTokens = new List<string>(targetTokens.Count);
foreach (var token in targetTokens)
{
if (string.Equals(token.Status, "revoked", StringComparison.OrdinalIgnoreCase))
{
continue;
}
var metadata = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
{
["delegation.service_account"] = document.AccountId
};
if (!string.IsNullOrWhiteSpace(request.Reason))
{
metadata["delegation.reason"] = request.Reason;
}
if (!string.IsNullOrWhiteSpace(request.TokenId))
{
metadata["delegation.token"] = request.TokenId;
}
await tokenStore.UpdateStatusAsync(
token.TokenId,
"revoked",
now,
string.IsNullOrWhiteSpace(request.Reason) ? "delegation_revoked" : request.Reason,
request.ReasonDescription,
metadata,
cancellationToken,
session).ConfigureAwait(false);
revokedTokens.Add(token.TokenId);
}
var orderedRevokedTokens = revokedTokens
.OrderBy(tokenId => tokenId, StringComparer.Ordinal)
.ToArray();
var properties = new List<AuthEventProperty>
{
new() { Name = "delegation.service_account", Value = ClassifiedString.Public(document.AccountId) },
new() { Name = "delegation.revoked_count", Value = ClassifiedString.Public(orderedRevokedTokens.Length.ToString(CultureInfo.InvariantCulture)) }
};
if (!string.IsNullOrWhiteSpace(request.Reason))
{
properties.Add(new AuthEventProperty { Name = "delegation.reason", Value = ClassifiedString.Public(request.Reason) });
}
if (!string.IsNullOrWhiteSpace(request.ReasonDescription))
{
properties.Add(new AuthEventProperty { Name = "delegation.reason_description", Value = ClassifiedString.Public(request.ReasonDescription) });
}
for (var index = 0; index < orderedRevokedTokens.Length; index++)
{
properties.Add(new AuthEventProperty
{
Name = $"delegation.revoked_token[{index}]",
Value = ClassifiedString.Public(orderedRevokedTokens[index])
});
}
await auditSink.WriteAsync(new AuthEventRecord
{
EventType = "authority.delegation.revoked",
OccurredAt = now,
Outcome = orderedRevokedTokens.Length > 0 ? AuthEventOutcome.Success : AuthEventOutcome.Failure,
Reason = request.Reason,
CorrelationId = Activity.Current?.TraceId.ToString() ?? httpContext.TraceIdentifier ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture),
Subject = new AuthEventSubject
{
SubjectId = ClassifiedString.Public(document.AccountId),
Realm = ClassifiedString.Public(document.Tenant)
},
Tenant = ClassifiedString.Public(document.Tenant),
Properties = properties
}, cancellationToken).ConfigureAwait(false);
return Results.Ok(new ServiceAccountRevokeResponse(orderedRevokedTokens.Length, orderedRevokedTokens));
});
static ServiceAccountResponse MapServiceAccount(AuthorityServiceAccountDocument document)
{
ArgumentNullException.ThrowIfNull(document);
var scopes = document.AllowedScopes is { Count: > 0 }
? document.AllowedScopes.OrderBy(scope => scope, StringComparer.Ordinal).ToArray()
: Array.Empty<string>();
var clients = document.AuthorizedClients is { Count: > 0 }
? document.AuthorizedClients.OrderBy(client => client, StringComparer.Ordinal).ToArray()
: Array.Empty<string>();
return new ServiceAccountResponse(
document.AccountId,
document.Tenant,
document.DisplayName,
document.Description,
document.Enabled,
scopes,
clients);
}
static ServiceAccountTokenResponse MapDelegatedToken(AuthorityTokenDocument document)
{
ArgumentNullException.ThrowIfNull(document);
var scopes = document.Scope is { Count: > 0 }
? document.Scope.OrderBy(scope => scope, StringComparer.Ordinal).ToArray()
: Array.Empty<string>();
var actors = document.ActorChain is { Count: > 0 }
? document.ActorChain
.Where(actor => !string.IsNullOrWhiteSpace(actor))
.Select(actor => actor.Trim())
.Where(actor => actor.Length > 0)
.OrderBy(actor => actor, StringComparer.Ordinal)
.ToArray()
: Array.Empty<string>();
return new ServiceAccountTokenResponse(
document.TokenId,
document.ClientId,
document.Status,
document.CreatedAt,
document.ExpiresAt,
document.SenderConstraint,
scopes,
actors);
}
bootstrapGroup.MapPost("/signing/rotate", (
SigningRotationRequest? request,
AuthoritySigningKeyManager signingManager,
ILogger<AuthoritySigningKeyManager> signingLogger) =>
{
if (request is null)
{
signingLogger.LogWarning("Signing rotation request payload missing.");
@@ -2457,16 +2715,16 @@ app.MapGet("/jwks", (AuthorityJwksService jwksService, HttpContext context) =>
.WithName("JsonWebKeySet");
// Ensure signing key manager initialises key material on startup.
app.Services.GetRequiredService<AuthorityAckTokenKeyManager>();
app.Services.GetRequiredService<AuthoritySigningKeyManager>();
app.Run();
static PluginHostOptions BuildPluginHostOptions(StellaOpsAuthorityOptions options, string basePath)
{
var pluginDirectory = options.PluginDirectories.FirstOrDefault();
var hostOptions = new PluginHostOptions
{
app.Services.GetRequiredService<AuthorityAckTokenKeyManager>();
app.Services.GetRequiredService<AuthoritySigningKeyManager>();
app.Run();
static PluginHostOptions BuildPluginHostOptions(StellaOpsAuthorityOptions options, string basePath)
{
var pluginDirectory = options.PluginDirectories.FirstOrDefault();
var hostOptions = new PluginHostOptions
{
BaseDirectory = basePath,
PluginsDirectory = string.IsNullOrWhiteSpace(pluginDirectory)
? "StellaOps.Authority.PluginBinaries"

View File

@@ -0,0 +1,30 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Authority;
internal sealed record ServiceAccountResponse(
string AccountId,
string Tenant,
string? DisplayName,
string? Description,
bool Enabled,
IReadOnlyList<string> AllowedScopes,
IReadOnlyList<string> AuthorizedClients);
internal sealed record ServiceAccountTokenResponse(
string TokenId,
string? ClientId,
string Status,
DateTimeOffset CreatedAt,
DateTimeOffset? ExpiresAt,
string? SenderConstraint,
IReadOnlyList<string> Scopes,
IReadOnlyList<string> Actors);
internal sealed record ServiceAccountRevokeRequest(
string? TokenId,
string? Reason,
string? ReasonDescription);
internal sealed record ServiceAccountRevokeResponse(int RevokedCount, IReadOnlyList<string> TokenIds);

View File

@@ -130,6 +130,7 @@
|----|--------|----------|------------|-------------|---------------|
> 2025-10-28: Tidied advisory raw idempotency migration to avoid LINQ-on-`BsonValue` (explicit array copy) while continuing duplicate guardrail validation; scoped scanner/policy token call sites updated to honor new metadata parameter.
| AUTH-TEN-49-001 | DOING (2025-11-02) | Authority Core & Security Guild | AUTH-TEN-47-001 | Implement service accounts & delegation tokens (`act` chain), per-tenant quotas, audit stream of auth decisions, and revocation APIs. | Service tokens minted with scopes/TTL; delegation logged; quotas configurable; audit stream live; docs updated. |
> 2025-11-02: Added Mongo service-account store, seeded options/collection initializers, token persistence metadata (`tokenKind`, `serviceAccountId`, `actorChain`), and docs/config samples. Introduced quota checks + tests covering service account issuance and persistence.
## Observability & Forensics (Epic 15)

View File

@@ -6,6 +6,6 @@
| ISSUER-30-003 | DOING | Issuer Directory Guild, Policy Guild | ISSUER-30-001 | Provide trust weight APIs and tenant overrides with validation (+/- bounds) and audit trails. | Trust overrides persisted; policy integration confirmed; tests cover overrides. |
| ISSUER-30-004 | DONE (2025-11-01) | Issuer Directory Guild, VEX Lens Guild | ISSUER-30-001..003 | Integrate with VEX Lens and Excitator signature verification (client SDK, caching, retries). | Lens/Excitator resolve issuer metadata via SDK; integration tests cover network failures. |
| ISSUER-30-005 | DONE (2025-11-01) | Issuer Directory Guild, Observability Guild | ISSUER-30-001..004 | Instrument metrics/logs (issuer changes, key rotation, verification failures) and dashboards/alerts. | Telemetry live; alerts configured; docs updated. |
| ISSUER-30-006 | TODO | Issuer Directory Guild, DevOps Guild | ISSUER-30-001..005 | Provide deployment manifests, backup/restore, secure secret storage, and offline kit instructions. | Deployment docs merged; smoke deploy validated; backup tested; offline kit updated. |
| ISSUER-30-006 | DOING (2025-11-02) | Issuer Directory Guild, DevOps Guild | ISSUER-30-001..005 | Provide deployment manifests, backup/restore, secure secret storage, and offline kit instructions. | Deployment docs merged; smoke deploy validated; backup tested; offline kit updated. |
> 2025-11-01: Excititor worker now queries Issuer Directory via during attestation verification, caching active key metadata and trust weights for tenant/global scopes.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,10 @@
global using System.Collections.Generic;
global using System.Diagnostics.CodeAnalysis;
global using System.Globalization;
global using System.IO;
global using System.Linq;
global using System.Text;
global using System.Text.RegularExpressions;
global using System.Threading;
global using System.Threading.Tasks;
global using StellaOps.Scanner.Analyzers.Lang;

View File

@@ -0,0 +1,39 @@
namespace StellaOps.Scanner.Analyzers.Lang.Ruby.Internal;
internal sealed class RubyLockData
{
private RubyLockData(string? lockFilePath, IReadOnlyList<RubyLockEntry> entries, string bundledWith)
{
LockFilePath = lockFilePath;
Entries = entries;
BundledWith = bundledWith;
}
public string? LockFilePath { get; }
public string BundledWith { get; }
public IReadOnlyList<RubyLockEntry> Entries { get; }
public bool IsEmpty => Entries.Count == 0;
public static async ValueTask<RubyLockData> LoadAsync(string rootPath, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrEmpty(rootPath);
var lockPath = Path.Combine(rootPath, "Gemfile.lock");
if (!File.Exists(lockPath))
{
return Empty;
}
await using var stream = new FileStream(lockPath, FileMode.Open, FileAccess.Read, FileShare.Read);
using var reader = new StreamReader(stream);
var content = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
var parser = RubyLockParser.Parse(content);
return new RubyLockData(lockPath, parser.Entries, parser.BundledWith);
}
public static RubyLockData Empty { get; } = new(lockFilePath: null, Array.Empty<RubyLockEntry>(), bundledWith: string.Empty);
}

View File

@@ -0,0 +1,8 @@
namespace StellaOps.Scanner.Analyzers.Lang.Ruby.Internal;
internal sealed record RubyLockEntry(
string Name,
string Version,
string Source,
string? Platform,
IReadOnlyCollection<string> Groups);

View File

@@ -0,0 +1,129 @@
using System.IO;
using System.Text.RegularExpressions;
namespace StellaOps.Scanner.Analyzers.Lang.Ruby.Internal;
internal static class RubyLockParser
{
private static readonly Regex SpecLineRegex = new(@"^\s{4}([^\s]+)\s\(([^)]+)\)", RegexOptions.Compiled);
public static RubyLockParserResult Parse(string contents)
{
if (string.IsNullOrWhiteSpace(contents))
{
return new RubyLockParserResult(Array.Empty<RubyLockEntry>(), string.Empty);
}
var entries = new List<RubyLockEntry>();
using var reader = new StringReader(contents);
string? line;
string currentSection = string.Empty;
string? currentSource = null;
bool inSpecs = false;
var bundledWith = string.Empty;
while ((line = reader.ReadLine()) is not null)
{
if (string.IsNullOrWhiteSpace(line))
{
continue;
}
if (!char.IsWhiteSpace(line[0]))
{
currentSection = line.Trim();
inSpecs = false;
if (string.Equals(currentSection, "GEM", StringComparison.OrdinalIgnoreCase))
{
currentSource = "rubygems";
}
else if (string.Equals(currentSection, "GIT", StringComparison.OrdinalIgnoreCase))
{
currentSource = null;
}
else if (string.Equals(currentSection, "PATH", StringComparison.OrdinalIgnoreCase))
{
currentSource = null;
}
else if (string.Equals(currentSection, "BUNDLED WITH", StringComparison.OrdinalIgnoreCase))
{
var versionLine = reader.ReadLine();
if (!string.IsNullOrWhiteSpace(versionLine))
{
bundledWith = versionLine.Trim();
}
}
continue;
}
if (line.StartsWith(" remote:", StringComparison.OrdinalIgnoreCase))
{
currentSource = line[9..].Trim();
continue;
}
if (line.StartsWith(" revision:", StringComparison.OrdinalIgnoreCase)
&& currentSection.Equals("GIT", StringComparison.OrdinalIgnoreCase)
&& currentSource is not null)
{
currentSource = $"{currentSource}@{line[10..].Trim()}";
continue;
}
if (line.StartsWith(" path:", StringComparison.OrdinalIgnoreCase)
&& currentSection.Equals("PATH", StringComparison.OrdinalIgnoreCase))
{
currentSource = $"path:{line[6..].Trim()}";
continue;
}
if (line.StartsWith(" specs:", StringComparison.OrdinalIgnoreCase))
{
inSpecs = true;
continue;
}
if (!inSpecs)
{
continue;
}
var match = SpecLineRegex.Match(line);
if (!match.Success)
{
continue;
}
if (line.Length > 4 && char.IsWhiteSpace(line[4]))
{
continue;
}
var name = match.Groups[1].Value.Trim();
var versionToken = match.Groups[2].Value.Trim();
string version;
string? platform = null;
var tokens = versionToken.Split(new[] { " " }, StringSplitOptions.RemoveEmptyEntries);
if (tokens.Length > 1)
{
version = tokens[0];
platform = string.Join(" ", tokens.Skip(1));
}
else
{
version = versionToken;
}
var source = currentSource ?? "unknown";
entries.Add(new RubyLockEntry(name, version, source, platform, Array.Empty<string>()));
}
return new RubyLockParserResult(entries, bundledWith);
}
}
internal sealed record RubyLockParserResult(IReadOnlyList<RubyLockEntry> Entries, string BundledWith);

View File

@@ -0,0 +1,113 @@
namespace StellaOps.Scanner.Analyzers.Lang.Ruby.Internal;
internal sealed class RubyPackage
{
private RubyPackage(
string name,
string version,
string source,
string? platform,
IReadOnlyCollection<string> groups,
string locator,
bool declaredOnly)
{
Name = name;
Version = version;
Source = source;
Platform = platform;
Groups = groups;
Locator = locator;
DeclaredOnly = declaredOnly;
}
public string Name { get; }
public string Version { get; }
public string Source { get; }
public string? Platform { get; }
public IReadOnlyCollection<string> Groups { get; }
public string Locator { get; }
public bool DeclaredOnly { get; }
public string Purl => $"pkg:gem/{Name}@{Version}";
public string ComponentKey => $"purl::{Purl}";
public IReadOnlyCollection<KeyValuePair<string, string?>> CreateMetadata(RubyCapabilities? capabilities)
{
var metadata = new List<KeyValuePair<string, string?>>
{
new("source", Source),
new("lockfile", string.IsNullOrWhiteSpace(Locator) ? "Gemfile.lock" : Locator),
new("declaredOnly", DeclaredOnly ? "true" : "false")
};
if (!string.IsNullOrWhiteSpace(Platform))
{
metadata.Add(new KeyValuePair<string, string?>("platform", Platform));
}
if (Groups.Count > 0)
{
metadata.Add(new KeyValuePair<string, string?>("groups", string.Join(';', Groups)));
}
if (capabilities is not null)
{
if (capabilities.UsesExec)
{
metadata.Add(new KeyValuePair<string, string?>("capability.exec", "true"));
}
if (capabilities.UsesNetwork)
{
metadata.Add(new KeyValuePair<string, string?>("capability.net", "true"));
}
if (capabilities.UsesSerialization)
{
metadata.Add(new KeyValuePair<string, string?>("capability.serialization", "true"));
}
}
return metadata
.OrderBy(static pair => pair.Key, StringComparer.Ordinal)
.ToArray();
}
public IReadOnlyCollection<LanguageComponentEvidence> CreateEvidence()
{
var locator = string.IsNullOrWhiteSpace(Locator)
? "Gemfile.lock"
: Locator;
return new[]
{
new LanguageComponentEvidence(
LanguageEvidenceKind.File,
"Gemfile.lock",
locator,
Value: null,
Sha256: null)
};
}
public static RubyPackage From(RubyLockEntry entry, string lockFileRelativePath)
{
var groups = entry.Groups.Count == 0
? Array.Empty<string>()
: entry.Groups.OrderBy(static g => g, StringComparer.OrdinalIgnoreCase).ToArray();
return new RubyPackage(entry.Name, entry.Version, entry.Source, entry.Platform, groups, lockFileRelativePath, declaredOnly: true);
}
public static RubyPackage FromVendor(string name, string version, string source, string? platform, string locator)
{
return new RubyPackage(name, version, source, platform, Array.Empty<string>(), locator, declaredOnly: true);
}
}

View File

@@ -0,0 +1,105 @@
namespace StellaOps.Scanner.Analyzers.Lang.Ruby.Internal;
internal static class RubyPackageCollector
{
public static IReadOnlyList<RubyPackage> CollectPackages(RubyLockData lockData, LanguageAnalyzerContext context)
{
var packages = new List<RubyPackage>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (!lockData.IsEmpty)
{
var relativeLockPath = lockData.LockFilePath is null
? Gemfile.lock
: context.GetRelativePath(lockData.LockFilePath);
if (string.IsNullOrWhiteSpace(relativeLockPath))
{
relativeLockPath = Gemfile.lock;
}
foreach (var entry in lockData.Entries)
{
var key = ${entry.Name}@{entry.Version};
if (!seen.Add(key))
{
continue;
}
packages.Add(RubyPackage.From(entry, relativeLockPath));
}
}
CollectVendorCachePackages(context, packages, seen);
return packages;
}
private static void CollectVendorCachePackages(LanguageAnalyzerContext context, List<RubyPackage> packages, HashSet<string> seen)
{
var vendorCache = Path.Combine(context.RootPath, vendor, cache);
if (!Directory.Exists(vendorCache))
{
return;
}
foreach (var gemPath in Directory.EnumerateFiles(vendorCache, *.gem, SearchOption.AllDirectories))
{
if (!TryParseGemArchive(gemPath, out var name, out var version, out var platform))
{
continue;
}
var key = ${name}@{version};
if (!seen.Add(key))
{
continue;
}
var locator = context.GetRelativePath(gemPath);
packages.Add(RubyPackage.FromVendor(name, version, source: vendor-cache, platform, locator));
}
}
private static bool TryParseGemArchive(string gemPath, out string name, out string version, out string? platform)
{
name = string.Empty;
version = string.Empty;
platform = null;
var fileName = Path.GetFileNameWithoutExtension(gemPath);
if (string.IsNullOrWhiteSpace(fileName))
{
return false;
}
var segments = fileName.Split('-', StringSplitOptions.RemoveEmptyEntries);
if (segments.Length < 2)
{
return false;
}
var versionIndex = -1;
for (var i = 1; i < segments.Length; i++)
{
if (char.IsDigit(segments[i][0]))
{
versionIndex = i;
break;
}
}
if (versionIndex <= 0)
{
return false;
}
name = string.Join('-', segments[..versionIndex]);
version = segments[versionIndex];
platform = segments.Length > versionIndex + 1
? string.Join('-', segments[(versionIndex + 1)..])
: null;
return !string.IsNullOrWhiteSpace(name) && !string.IsNullOrWhiteSpace(version);
}
}

View File

@@ -0,0 +1,18 @@
using System;
using StellaOps.Scanner.Analyzers.Lang;
using StellaOps.Scanner.Analyzers.Lang.Plugin;
namespace StellaOps.Scanner.Analyzers.Lang.Ruby;
public sealed class RubyAnalyzerPlugin : ILanguageAnalyzerPlugin
{
public string Name => StellaOps.Scanner.Analyzers.Lang.Ruby;
public bool IsAvailable(IServiceProvider services) => services is not null;
public ILanguageAnalyzer CreateAnalyzer(IServiceProvider services)
{
ArgumentNullException.ThrowIfNull(services);
return new RubyLanguageAnalyzer();
}
}

View File

@@ -0,0 +1,38 @@
using StellaOps.Scanner.Analyzers.Lang.Ruby.Internal;
namespace StellaOps.Scanner.Analyzers.Lang.Ruby;
public sealed class RubyLanguageAnalyzer : ILanguageAnalyzer
{
public string Id => "ruby";
public string DisplayName => "Ruby Analyzer";
public async ValueTask AnalyzeAsync(LanguageAnalyzerContext context, LanguageComponentWriter writer, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(writer);
var lockData = await RubyLockData.LoadAsync(context.RootPath, cancellationToken).ConfigureAwait(false);
if (lockData.IsEmpty)
{
return;
}
var packages = RubyPackageCollector.CollectPackages(lockData, context);
foreach (var package in packages.OrderBy(static p => p.ComponentKey, StringComparer.Ordinal))
{
cancellationToken.ThrowIfCancellationRequested();
writer.AddFromPurl(
analyzerId: Id,
purl: package.Purl,
name: package.Name,
version: package.Version,
type: "gem",
metadata: package.CreateMetadata(),
evidence: package.CreateEvidence(),
usedByEntrypoint: false);
}
}
}

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<EnableDefaultItems>false</EnableDefaultItems>
</PropertyGroup>
<ItemGroup>
<Compile Include="**\*.cs" Exclude="obj\**;bin\**" />
<EmbeddedResource Include="**\*.json" Exclude="obj\**;bin\**" />
<None Include="**\*" Exclude="**\*.cs;**\*.json;bin\**;obj\**" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Scanner.Analyzers.Lang\StellaOps.Scanner.Analyzers.Lang.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,16 @@
GEM
remote: https://rubygems.org/
specs:
puma (6.4.2)
nio4r (~> 2.0)
rake (13.1.0)
PLATFORMS
ruby
DEPENDENCIES
puma (~> 6.4)
rake (~> 13.0)
BUNDLED WITH
2.5.10

View File

@@ -0,0 +1,65 @@
[
{
analyzerId: ruby,
componentKey: purl::pkg:gem/custom-gem@1.0.0,
purl: pkg:gem/custom-gem@1.0.0,
name: custom-gem,
version: 1.0.0,
type: gem,
usedByEntrypoint: false,
metadata: {
declaredOnly: true,
lockfile: vendor/cache/custom-gem-1.0.0.gem,
source: vendor-cache
},
evidence: [
{
kind: file,
source: vendor-cache,
locator: vendor/cache/custom-gem-1.0.0.gem
}
]
},
{
analyzerId: ruby,
componentKey: purl::pkg:gem/puma@6.4.2,
purl: pkg:gem/puma@6.4.2,
name: puma,
version: 6.4.2,
type: gem,
usedByEntrypoint: false,
metadata: {
declaredOnly: true,
lockfile: Gemfile.lock,
source: rubygems
},
evidence: [
{
kind: file,
source: rubygems,
locator: Gemfile.lock
}
]
},
{
analyzerId: ruby,
componentKey: purl::pkg:gem/rake@13.1.0,
purl: pkg:gem/rake@13.1.0,
name: rake,
version: 13.1.0,
type: gem,
usedByEntrypoint: false,
metadata: {
declaredOnly: true,
lockfile: Gemfile.lock,
source: rubygems
},
evidence: [
{
kind: file,
source: rubygems,
locator: Gemfile.lock
}
]
}
]

View File

@@ -0,0 +1,19 @@
using StellaOps.Scanner.Analyzers.Lang.Ruby;
using StellaOps.Scanner.Analyzers.Lang.Tests.Harness;
using StellaOps.Scanner.Analyzers.Lang.Tests.TestUtilities;
namespace StellaOps.Scanner.Analyzers.Lang.Tests.Lang.Ruby;
public sealed class RubyLanguageAnalyzerTests
{
[Fact]
public async Task GemfileLockProducesDeterministicInventoryAsync()
{
var fixture = TestPaths.ResolveFixture(lang, ruby, basic);
var golden = Path.Combine(fixture, expected.json);
await LanguageAnalyzerTestHarness.AssertDeterministicAsync(
fixture,
golden,
new ILanguageAnalyzer[] { new RubyLanguageAnalyzer() });
}
}

View File

@@ -33,6 +33,7 @@
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Analyzers.Lang/StellaOps.Scanner.Analyzers.Lang.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Analyzers.Lang.DotNet/StellaOps.Scanner.Analyzers.Lang.DotNet.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/StellaOps.Scanner.Analyzers.Lang.Ruby.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Analyzers.Lang.Rust/StellaOps.Scanner.Analyzers.Lang.Rust.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Core/StellaOps.Scanner.Core.csproj" />
</ItemGroup>
@@ -44,4 +45,4 @@
<ItemGroup>
<None Include="Fixtures\**\*" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
</Project>

View File

@@ -861,6 +861,7 @@ public sealed class AuthorityTenantOptions
public sealed class AuthorityDelegationOptions
{
private readonly IList<AuthorityServiceAccountSeedOptions> serviceAccounts = new List<AuthorityServiceAccountSeedOptions>();
private readonly Dictionary<string, AuthorityTenantDelegationOptions> tenantOverrides = new(StringComparer.OrdinalIgnoreCase);
public AuthorityDelegationQuotaOptions Quotas { get; } = new();
@@ -878,6 +879,17 @@ public sealed class AuthorityDelegationOptions
: new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var seenAccounts = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
tenantOverrides.Clear();
foreach (var tenant in tenants)
{
if (string.IsNullOrWhiteSpace(tenant.Id))
{
continue;
}
var normalizedTenant = tenant.Id.Trim().ToLowerInvariant();
tenantOverrides[normalizedTenant] = tenant.Delegation;
}
foreach (var account in serviceAccounts)
{
@@ -890,6 +902,22 @@ public sealed class AuthorityDelegationOptions
}
}
}
public int ResolveMaxActiveTokens(string? tenantId)
{
if (string.IsNullOrWhiteSpace(tenantId))
{
return Quotas.MaxActiveTokens;
}
var normalized = tenantId.Trim().ToLowerInvariant();
if (tenantOverrides.TryGetValue(normalized, out var options))
{
return options.ResolveMaxActiveTokens(this);
}
return Quotas.MaxActiveTokens;
}
}
public sealed class AuthorityDelegationQuotaOptions