Refactor and enhance LDAP plugin configuration and validation
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

- Updated `LdapPluginOptions` to enforce TLS and client certificate requirements.
- Added validation checks for TLS configuration in `LdapPluginOptionsTests`.
- Improved error handling in `DirectoryServicesLdapConnectionFactory` for StartTLS negotiation.
- Enhanced logging in `LdapCredentialStore` to include detailed audit properties for credential verification.
- Introduced `StubStructuredRetriever` and `StubVectorRetriever` for testing in `ToolsetServiceCollectionExtensionsTests`.
- Refactored `AdvisoryGuardrailPipelineTests` to improve test clarity and structure.
- Added `FileSystemAdvisoryTaskQueueTests` for testing queue functionality.
- Updated JSON test data for consistency with new requirements.
- Modified `AdvisoryPipelineOrchestratorTests` to reflect changes in metadata keys.
This commit is contained in:
master
2025-11-05 09:29:51 +02:00
parent 3bd0955202
commit 40e7f827da
37 changed files with 744 additions and 315 deletions

View File

@@ -1,22 +1,52 @@
using System.Linq;
using System.Net;
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using StellaOps.AdvisoryAI.Caching;
using StellaOps.AdvisoryAI.Hosting;
using StellaOps.AdvisoryAI.Metrics;
using StellaOps.AdvisoryAI.Orchestration;
using StellaOps.AdvisoryAI.Queue;
var builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
.AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true, reloadOnChange: true)
.AddEnvironmentVariables(prefix: "ADVISORYAI_");
builder.Configuration
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
.AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true, reloadOnChange: true)
.AddEnvironmentVariables(prefix: "ADVISORYAI_");
builder.Services.AddAdvisoryAiCore(builder.Configuration);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddProblemDetails();
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddPolicy("advisory-ai", context =>
{
var clientId = context.Request.Headers.TryGetValue("X-StellaOps-Client", out var value)
? value.ToString()
: "anonymous";
return RateLimitPartition.GetTokenBucketLimiter(
clientId,
_ => new TokenBucketRateLimiterOptions
{
TokenLimit = 30,
TokensPerPeriod = 30,
ReplenishmentPeriod = TimeSpan.FromMinutes(1),
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 0,
AutoReplenishment = true
});
});
});
var app = builder.Build();
@@ -26,35 +56,135 @@ app.UseExceptionHandler(static options => options.Run(async context =>
await problem.ExecuteAsync(context);
}));
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseRateLimiter();
app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
app.MapPost("/v1/advisory-ai/pipeline/{taskType}", async (
app.MapPost("/v1/advisory-ai/pipeline/{taskType}", HandleSinglePlan)
.RequireRateLimiting("advisory-ai");
app.MapPost("/v1/advisory-ai/pipeline:batch", HandleBatchPlans)
.RequireRateLimiting("advisory-ai");
app.Run();
static async Task<IResult> HandleSinglePlan(
HttpContext httpContext,
string taskType,
PipelinePlanRequest request,
IAdvisoryPipelineOrchestrator orchestrator,
IAdvisoryPipelineQueuePublisher queue,
AdvisoryAiMetrics metrics,
ILoggerFactory loggerFactory,
CancellationToken cancellationToken) =>
IAdvisoryPlanCache planCache,
IAdvisoryTaskQueue taskQueue,
AdvisoryAiMetrics requestMetrics,
AdvisoryPipelineMetrics pipelineMetrics,
CancellationToken cancellationToken)
{
if (!Enum.TryParse<AdvisoryTaskType>(taskType, ignoreCase: true, out var parsedType))
{
return Results.BadRequest(new { error = $"Unknown task type {taskType}." });
return Results.BadRequest(new { error = $"Unknown task type '{taskType}'." });
}
var httpRequest = request with { TaskType = parsedType };
var orchestratorRequest = httpRequest.ToTaskRequest();
if (!EnsureAuthorized(httpContext, parsedType))
{
return Results.StatusCode(StatusCodes.Status403Forbidden);
}
var plan = await orchestrator.CreatePlanAsync(orchestratorRequest, cancellationToken).ConfigureAwait(false);
metrics.RecordRequest(plan.Request.TaskType.ToString());
if (string.IsNullOrWhiteSpace(request.AdvisoryKey))
{
return Results.BadRequest(new { error = "AdvisoryKey is required." });
}
await queue.EnqueueAsync(new AdvisoryPipelineExecutionMessage(plan.CacheKey, plan.Request, plan.Metadata), cancellationToken).ConfigureAwait(false);
metrics.RecordEnqueued(plan.Request.TaskType.ToString());
var normalizedRequest = request with { TaskType = parsedType };
var taskRequest = normalizedRequest.ToTaskRequest();
var plan = await orchestrator.CreatePlanAsync(taskRequest, cancellationToken).ConfigureAwait(false);
return Results.Ok(AdvisoryPipelinePlanResponse.FromPlan(plan));
});
await planCache.SetAsync(plan.CacheKey, plan, cancellationToken).ConfigureAwait(false);
await taskQueue.EnqueueAsync(new AdvisoryTaskQueueMessage(plan.CacheKey, plan.Request), cancellationToken).ConfigureAwait(false);
app.Run();
requestMetrics.RecordRequest(plan.Request.TaskType.ToString());
requestMetrics.RecordEnqueued(plan.Request.TaskType.ToString());
pipelineMetrics.RecordPlanQueued(plan.Request.TaskType);
var response = AdvisoryPipelinePlanResponse.FromPlan(plan);
return Results.Ok(response);
}
static async Task<IResult> HandleBatchPlans(
HttpContext httpContext,
BatchPipelinePlanRequest batchRequest,
IAdvisoryPipelineOrchestrator orchestrator,
IAdvisoryPlanCache planCache,
IAdvisoryTaskQueue taskQueue,
AdvisoryAiMetrics requestMetrics,
AdvisoryPipelineMetrics pipelineMetrics,
CancellationToken cancellationToken)
{
if (batchRequest.Requests.Count == 0)
{
return Results.BadRequest(new { error = "At least one request must be supplied." });
}
var results = new List<AdvisoryPipelinePlanResponse>(batchRequest.Requests.Count);
foreach (var item in batchRequest.Requests)
{
var taskType = item.TaskType?.ToString() ?? "summary";
if (!Enum.TryParse<AdvisoryTaskType>(taskType, ignoreCase: true, out var parsedType))
{
return Results.BadRequest(new { error = $"Unknown task type '{taskType}' in batch item." });
}
if (!EnsureAuthorized(httpContext, parsedType))
{
return Results.StatusCode(StatusCodes.Status403Forbidden);
}
if (string.IsNullOrWhiteSpace(item.AdvisoryKey))
{
return Results.BadRequest(new { error = "AdvisoryKey is required for every batch item." });
}
var normalizedRequest = item with { TaskType = parsedType };
var taskRequest = normalizedRequest.ToTaskRequest();
var plan = await orchestrator.CreatePlanAsync(taskRequest, cancellationToken).ConfigureAwait(false);
await planCache.SetAsync(plan.CacheKey, plan, cancellationToken).ConfigureAwait(false);
await taskQueue.EnqueueAsync(new AdvisoryTaskQueueMessage(plan.CacheKey, plan.Request), cancellationToken).ConfigureAwait(false);
requestMetrics.RecordRequest(plan.Request.TaskType.ToString());
requestMetrics.RecordEnqueued(plan.Request.TaskType.ToString());
pipelineMetrics.RecordPlanQueued(plan.Request.TaskType);
results.Add(AdvisoryPipelinePlanResponse.FromPlan(plan));
}
return Results.Ok(results);
}
static bool EnsureAuthorized(HttpContext context, AdvisoryTaskType taskType)
{
if (!context.Request.Headers.TryGetValue("X-StellaOps-Scopes", out var scopes))
{
return false;
}
var allowed = scopes
.SelectMany(value => value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
if (allowed.Contains("advisory:run"))
{
return true;
}
return allowed.Contains($"advisory:{taskType.ToString().ToLowerInvariant()}");
}
internal sealed record PipelinePlanRequest(
AdvisoryTaskType? TaskType,
@@ -84,3 +214,8 @@ internal sealed record PipelinePlanRequest(
ForceRefresh);
}
}
internal sealed record BatchPipelinePlanRequest
{
public IReadOnlyList<PipelinePlanRequest> Requests { get; init; } = Array.Empty<PipelinePlanRequest>();
}