audit, advisories and doctors/setup work

This commit is contained in:
master
2026-01-13 18:53:39 +02:00
parent 9ca7cb183e
commit d7be6ba34b
811 changed files with 54242 additions and 4056 deletions

View File

@@ -17,6 +17,7 @@ using StellaOps.AdvisoryAI.Chat.Models;
using StellaOps.AdvisoryAI.Chat.Options;
using StellaOps.AdvisoryAI.Chat.Routing;
using StellaOps.AdvisoryAI.Chat.Services;
using StellaOps.AdvisoryAI.Chat.Settings;
using StellaOps.AdvisoryAI.WebService.Contracts;
namespace StellaOps.AdvisoryAI.WebService.Endpoints;
@@ -76,6 +77,29 @@ public static class ChatEndpoints
.Produces<EvidenceBundlePreviewResponse>(StatusCodes.Status200OK)
.Produces<ErrorResponse>(StatusCodes.Status400BadRequest);
// Settings endpoints
group.MapGet("/settings", GetChatSettingsAsync)
.WithName("GetChatSettings")
.WithSummary("Gets effective chat settings for the caller")
.Produces<ChatSettingsResponse>(StatusCodes.Status200OK);
group.MapPut("/settings", UpdateChatSettingsAsync)
.WithName("UpdateChatSettings")
.WithSummary("Updates chat settings overrides (tenant or user)")
.Produces<ChatSettingsResponse>(StatusCodes.Status200OK)
.Produces<ErrorResponse>(StatusCodes.Status400BadRequest);
group.MapDelete("/settings", ClearChatSettingsAsync)
.WithName("ClearChatSettings")
.WithSummary("Clears chat settings overrides (tenant or user)")
.Produces(StatusCodes.Status204NoContent);
// Doctor endpoint
group.MapGet("/doctor", GetChatDoctorAsync)
.WithName("GetChatDoctor")
.WithSummary("Returns chat limit status and tool access diagnostics")
.Produces<ChatDoctorResponse>(StatusCodes.Status200OK);
// Health/status endpoint for chat service
group.MapGet("/status", GetChatStatusAsync)
.WithName("GetChatStatus")
@@ -131,16 +155,48 @@ public static class ChatEndpoints
{
var statusCode = result.GuardrailBlocked
? StatusCodes.Status400BadRequest
: StatusCodes.Status500InternalServerError;
: result.QuotaBlocked
? StatusCodes.Status429TooManyRequests
: result.ToolAccessDenied
? StatusCodes.Status403Forbidden
: StatusCodes.Status500InternalServerError;
var code = result.GuardrailBlocked
? "GUARDRAIL_BLOCKED"
: result.QuotaBlocked
? result.QuotaCode ?? "QUOTA_EXCEEDED"
: result.ToolAccessDenied
? "TOOL_DENIED"
: "PROCESSING_FAILED";
Dictionary<string, object>? details = null;
if (result.GuardrailBlocked)
{
details = result.GuardrailViolations.ToDictionary(v => v, _ => (object)"violation");
}
else if (result.QuotaBlocked && result.QuotaStatus is not null)
{
details = new Dictionary<string, object> { ["quota"] = result.QuotaStatus };
}
else if (result.ToolAccessDenied)
{
details = new Dictionary<string, object>
{
["reason"] = result.ToolAccessReason ?? "Tool access denied"
};
}
var doctor = result.QuotaBlocked || result.ToolAccessDenied
? CreateDoctorAction(code)
: null;
return Results.Json(
new ErrorResponse
{
Error = result.Error ?? "Query processing failed",
Code = result.GuardrailBlocked ? "GUARDRAIL_BLOCKED" : "PROCESSING_FAILED",
Details = result.GuardrailBlocked
? result.GuardrailViolations.ToDictionary(v => v, _ => (object)"violation")
: null
Code = code,
Details = details,
Doctor = doctor
},
statusCode: statusCode);
}
@@ -154,6 +210,8 @@ public static class ChatEndpoints
[FromServices] IEvidenceBundleAssembler evidenceAssembler,
[FromServices] IAdvisoryChatInferenceClient inferenceClient,
[FromServices] IOptions<AdvisoryChatOptions> options,
[FromServices] IAdvisoryChatSettingsService settingsService,
[FromServices] IAdvisoryChatQuotaService quotaService,
[FromServices] ILogger<AdvisoryChatQueryRequest> logger,
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
[FromHeader(Name = "X-User-Id")] string? userId,
@@ -180,6 +238,7 @@ public static class ChatEndpoints
}
tenantId ??= "default";
userId ??= "anonymous";
httpContext.Response.ContentType = "text/event-stream";
httpContext.Response.Headers.CacheControl = "no-cache";
@@ -214,6 +273,55 @@ public static class ChatEndpoints
return;
}
var settings = await settingsService.GetEffectiveSettingsAsync(
tenantId,
userId,
ct);
var toolPolicy = AdvisoryChatToolPolicy.Resolve(
settings.Tools,
options.Value.DataProviders,
includeReachability: true,
includeBinaryPatch: true,
includeOpsMemory: true);
if (!toolPolicy.AllowSbom)
{
await WriteStreamEventAsync(httpContext, "error", new
{
code = "TOOL_DENIED",
message = "Tool access denied: sbom.read",
doctor = CreateDoctorAction("TOOL_DENIED")
}, ct);
await WriteStreamEventAsync(httpContext, "done", new { success = false }, ct);
return;
}
var quotaDecision = await quotaService.TryConsumeAsync(
new ChatQuotaRequest
{
TenantId = tenantId,
UserId = userId,
EstimatedTokens = options.Value.Inference.MaxTokens,
ToolCalls = toolPolicy.ToolCallCount
},
settings.Quotas,
ct);
if (!quotaDecision.Allowed)
{
var quotaCode = quotaDecision.Code ?? "QUOTA_EXCEEDED";
await WriteStreamEventAsync(httpContext, "error", new
{
code = quotaCode,
message = quotaDecision.Message ?? "Quota exceeded",
quota = quotaDecision.Status,
doctor = CreateDoctorAction(quotaCode)
}, ct);
await WriteStreamEventAsync(httpContext, "done", new { success = false }, ct);
return;
}
// Step 3: Assemble evidence bundle
await WriteStreamEventAsync(httpContext, "status", new { phase = "assembling_evidence" }, ct);
@@ -226,6 +334,15 @@ public static class ChatEndpoints
Environment = request.Environment ?? "unknown",
FindingId = findingId,
PackagePurl = routingResult.Parameters.Package,
IncludeSbom = toolPolicy.AllowSbom,
IncludeVex = toolPolicy.AllowVex,
IncludePolicy = toolPolicy.AllowPolicy,
IncludeProvenance = toolPolicy.AllowProvenance,
IncludeFix = toolPolicy.AllowFix,
IncludeContext = toolPolicy.AllowContext,
IncludeReachability = toolPolicy.AllowReachability,
IncludeBinaryPatch = toolPolicy.AllowBinaryPatch,
IncludeOpsMemory = toolPolicy.AllowOpsMemory,
CorrelationId = correlationId
},
ct);
@@ -324,7 +441,11 @@ public static class ChatEndpoints
private static async Task<IResult> PreviewEvidenceBundleAsync(
[FromBody] EvidencePreviewRequest request,
[FromServices] IEvidenceBundleAssembler evidenceAssembler,
[FromServices] IOptions<AdvisoryChatOptions> options,
[FromServices] IAdvisoryChatSettingsService settingsService,
[FromServices] IAdvisoryChatQuotaService quotaService,
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
[FromHeader(Name = "X-User-Id")] string? userId,
[FromHeader(Name = "X-Correlation-Id")] string? correlationId,
CancellationToken ct)
{
@@ -334,6 +455,52 @@ public static class ChatEndpoints
}
tenantId ??= "default";
userId ??= "anonymous";
var settings = await settingsService.GetEffectiveSettingsAsync(tenantId, userId, ct);
var toolPolicy = AdvisoryChatToolPolicy.Resolve(
settings.Tools,
options.Value.DataProviders,
includeReachability: true,
includeBinaryPatch: true,
includeOpsMemory: true);
if (!toolPolicy.AllowSbom)
{
return Results.Json(
new ErrorResponse
{
Error = "Tool access denied: sbom.read",
Code = "TOOL_DENIED",
Doctor = CreateDoctorAction("TOOL_DENIED")
},
statusCode: StatusCodes.Status403Forbidden);
}
var quotaDecision = await quotaService.TryConsumeAsync(
new ChatQuotaRequest
{
TenantId = tenantId,
UserId = userId,
EstimatedTokens = 0,
ToolCalls = toolPolicy.ToolCallCount
},
settings.Quotas,
ct);
if (!quotaDecision.Allowed)
{
var quotaCode = quotaDecision.Code ?? "QUOTA_EXCEEDED";
return Results.Json(
new ErrorResponse
{
Error = quotaDecision.Message ?? "Quota exceeded",
Code = quotaCode,
Details = new Dictionary<string, object> { ["quota"] = quotaDecision.Status },
Doctor = CreateDoctorAction(quotaCode)
},
statusCode: StatusCodes.Status429TooManyRequests);
}
var assemblyResult = await evidenceAssembler.AssembleAsync(
new EvidenceBundleAssemblyRequest
@@ -344,6 +511,15 @@ public static class ChatEndpoints
Environment = request.Environment ?? "unknown",
FindingId = request.FindingId,
PackagePurl = request.PackagePurl,
IncludeSbom = toolPolicy.AllowSbom,
IncludeVex = toolPolicy.AllowVex,
IncludePolicy = toolPolicy.AllowPolicy,
IncludeProvenance = toolPolicy.AllowProvenance,
IncludeFix = toolPolicy.AllowFix,
IncludeContext = toolPolicy.AllowContext,
IncludeReachability = toolPolicy.AllowReachability,
IncludeBinaryPatch = toolPolicy.AllowBinaryPatch,
IncludeOpsMemory = toolPolicy.AllowOpsMemory,
CorrelationId = correlationId
},
ct);
@@ -379,6 +555,126 @@ public static class ChatEndpoints
});
}
private static async Task<IResult> GetChatSettingsAsync(
[FromServices] IAdvisoryChatSettingsService settingsService,
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
[FromHeader(Name = "X-User-Id")] string? userId,
CancellationToken ct)
{
tenantId ??= "default";
userId ??= "anonymous";
var settings = await settingsService.GetEffectiveSettingsAsync(tenantId, userId, ct);
return Results.Ok(ChatSettingsResponse.FromSettings(settings));
}
private static async Task<IResult> UpdateChatSettingsAsync(
[FromBody] ChatSettingsUpdateRequest request,
[FromServices] IAdvisoryChatSettingsService settingsService,
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
[FromHeader(Name = "X-User-Id")] string? userId,
[FromQuery(Name = "scope")] string? scope,
CancellationToken ct)
{
tenantId ??= "default";
userId ??= "anonymous";
if (request is null)
{
return Results.BadRequest(new ErrorResponse { Error = "Settings payload is required", Code = "INVALID_SETTINGS" });
}
var overrides = new AdvisoryChatSettingsOverrides
{
Quotas = new ChatQuotaOverrides
{
RequestsPerMinute = request.Quotas?.RequestsPerMinute,
RequestsPerDay = request.Quotas?.RequestsPerDay,
TokensPerDay = request.Quotas?.TokensPerDay,
ToolCallsPerDay = request.Quotas?.ToolCallsPerDay
},
Tools = new ChatToolAccessOverrides
{
AllowAll = request.Tools?.AllowAll,
AllowedTools = request.Tools?.AllowedTools is null
? null
: request.Tools.AllowedTools.ToImmutableArray()
}
};
if (string.Equals(scope, "user", StringComparison.OrdinalIgnoreCase))
{
await settingsService.SetUserOverridesAsync(tenantId, userId, overrides, ct);
}
else
{
await settingsService.SetTenantOverridesAsync(tenantId, overrides, ct);
}
var effective = await settingsService.GetEffectiveSettingsAsync(tenantId, userId, ct);
return Results.Ok(ChatSettingsResponse.FromSettings(effective));
}
private static async Task<IResult> ClearChatSettingsAsync(
[FromServices] IAdvisoryChatSettingsService settingsService,
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
[FromHeader(Name = "X-User-Id")] string? userId,
[FromQuery(Name = "scope")] string? scope,
CancellationToken ct)
{
tenantId ??= "default";
userId ??= "anonymous";
if (string.Equals(scope, "user", StringComparison.OrdinalIgnoreCase))
{
await settingsService.ClearUserOverridesAsync(tenantId, userId, ct);
}
else
{
await settingsService.ClearTenantOverridesAsync(tenantId, ct);
}
return Results.NoContent();
}
private static async Task<IResult> GetChatDoctorAsync(
[FromServices] IAdvisoryChatSettingsService settingsService,
[FromServices] IAdvisoryChatQuotaService quotaService,
[FromServices] IOptions<AdvisoryChatOptions> options,
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
[FromHeader(Name = "X-User-Id")] string? userId,
CancellationToken ct)
{
tenantId ??= "default";
userId ??= "anonymous";
var settings = await settingsService.GetEffectiveSettingsAsync(tenantId, userId, ct);
var toolPolicy = AdvisoryChatToolPolicy.Resolve(
settings.Tools,
options.Value.DataProviders,
includeReachability: true,
includeBinaryPatch: true,
includeOpsMemory: true);
var quotaStatus = quotaService.GetStatus(tenantId, userId, settings.Quotas);
return Results.Ok(new ChatDoctorResponse
{
TenantId = tenantId,
UserId = userId,
Quotas = ChatQuotaStatusResponse.FromStatus(quotaStatus),
Tools = ChatToolAccessResponse.FromPolicy(settings.Tools, toolPolicy),
LastDenied = quotaStatus.LastDenied is null
? null
: new ChatDenialResponse
{
Code = quotaStatus.LastDenied.Code,
Message = quotaStatus.LastDenied.Message,
DeniedAt = quotaStatus.LastDenied.DeniedAt
}
});
}
private static Task<IResult> GetChatStatusAsync(
[FromServices] IOptions<AdvisoryChatOptions> options)
{
@@ -395,6 +691,16 @@ public static class ChatEndpoints
}));
}
private static ChatDoctorAction CreateDoctorAction(string? reason)
{
return new ChatDoctorAction
{
Endpoint = "/api/v1/chat/doctor",
SuggestedCommand = "stella advise doctor",
Reason = reason
};
}
private static async Task WriteStreamEventAsync<T>(
HttpContext context,
string eventType,
@@ -741,12 +1047,178 @@ public sealed record ChatServiceStatusResponse
public required bool AuditEnabled { get; init; }
}
/// <summary>Chat settings update request.</summary>
public sealed record ChatSettingsUpdateRequest
{
public ChatQuotaSettingsUpdateRequest? Quotas { get; init; }
public ChatToolAccessUpdateRequest? Tools { get; init; }
}
/// <summary>Quota update request.</summary>
public sealed record ChatQuotaSettingsUpdateRequest
{
public int? RequestsPerMinute { get; init; }
public int? RequestsPerDay { get; init; }
public int? TokensPerDay { get; init; }
public int? ToolCallsPerDay { get; init; }
}
/// <summary>Tool access update request.</summary>
public sealed record ChatToolAccessUpdateRequest
{
public bool? AllowAll { get; init; }
public List<string>? AllowedTools { get; init; }
}
/// <summary>Chat settings response.</summary>
public sealed record ChatSettingsResponse
{
public required ChatQuotaSettingsResponse Quotas { get; init; }
public required ChatToolAccessResponse Tools { get; init; }
public static ChatSettingsResponse FromSettings(AdvisoryChatSettings settings)
{
return new ChatSettingsResponse
{
Quotas = new ChatQuotaSettingsResponse
{
RequestsPerMinute = settings.Quotas.RequestsPerMinute,
RequestsPerDay = settings.Quotas.RequestsPerDay,
TokensPerDay = settings.Quotas.TokensPerDay,
ToolCallsPerDay = settings.Quotas.ToolCallsPerDay
},
Tools = new ChatToolAccessResponse
{
AllowAll = settings.Tools.AllowAll,
AllowedTools = settings.Tools.AllowedTools.ToList()
}
};
}
}
/// <summary>Quota settings response.</summary>
public sealed record ChatQuotaSettingsResponse
{
public required int RequestsPerMinute { get; init; }
public required int RequestsPerDay { get; init; }
public required int TokensPerDay { get; init; }
public required int ToolCallsPerDay { get; init; }
}
/// <summary>Tool access response.</summary>
public sealed record ChatToolAccessResponse
{
public required bool AllowAll { get; init; }
public List<string> AllowedTools { get; init; } = [];
public ChatToolProviderResponse? Providers { get; init; }
public static ChatToolAccessResponse FromPolicy(
ChatToolAccessSettings settings,
ChatToolPolicyResult policy)
{
return new ChatToolAccessResponse
{
AllowAll = settings.AllowAll,
AllowedTools = policy.AllowedTools.ToList(),
Providers = new ChatToolProviderResponse
{
Sbom = policy.AllowSbom,
Vex = policy.AllowVex,
Reachability = policy.AllowReachability,
BinaryPatch = policy.AllowBinaryPatch,
OpsMemory = policy.AllowOpsMemory,
Policy = policy.AllowPolicy,
Provenance = policy.AllowProvenance,
Fix = policy.AllowFix,
Context = policy.AllowContext
}
};
}
}
/// <summary>Tool provider availability response.</summary>
public sealed record ChatToolProviderResponse
{
public bool Sbom { get; init; }
public bool Vex { get; init; }
public bool Reachability { get; init; }
public bool BinaryPatch { get; init; }
public bool OpsMemory { get; init; }
public bool Policy { get; init; }
public bool Provenance { get; init; }
public bool Fix { get; init; }
public bool Context { get; init; }
}
/// <summary>Chat doctor response.</summary>
public sealed record ChatDoctorResponse
{
public required string TenantId { get; init; }
public required string UserId { get; init; }
public required ChatQuotaStatusResponse Quotas { get; init; }
public required ChatToolAccessResponse Tools { get; init; }
public ChatDenialResponse? LastDenied { get; init; }
}
/// <summary>Doctor action hint.</summary>
public sealed record ChatDoctorAction
{
public required string Endpoint { get; init; }
public required string SuggestedCommand { get; init; }
public string? Reason { get; init; }
}
/// <summary>Quota status response.</summary>
public sealed record ChatQuotaStatusResponse
{
public required int RequestsPerMinuteLimit { get; init; }
public required int RequestsPerMinuteRemaining { get; init; }
public required DateTimeOffset RequestsPerMinuteResetsAt { get; init; }
public required int RequestsPerDayLimit { get; init; }
public required int RequestsPerDayRemaining { get; init; }
public required DateTimeOffset RequestsPerDayResetsAt { get; init; }
public required int TokensPerDayLimit { get; init; }
public required int TokensPerDayRemaining { get; init; }
public required DateTimeOffset TokensPerDayResetsAt { get; init; }
public required int ToolCallsPerDayLimit { get; init; }
public required int ToolCallsPerDayRemaining { get; init; }
public required DateTimeOffset ToolCallsPerDayResetsAt { get; init; }
public static ChatQuotaStatusResponse FromStatus(ChatQuotaStatus status)
{
return new ChatQuotaStatusResponse
{
RequestsPerMinuteLimit = status.RequestsPerMinuteLimit,
RequestsPerMinuteRemaining = status.RequestsPerMinuteRemaining,
RequestsPerMinuteResetsAt = status.RequestsPerMinuteResetsAt,
RequestsPerDayLimit = status.RequestsPerDayLimit,
RequestsPerDayRemaining = status.RequestsPerDayRemaining,
RequestsPerDayResetsAt = status.RequestsPerDayResetsAt,
TokensPerDayLimit = status.TokensPerDayLimit,
TokensPerDayRemaining = status.TokensPerDayRemaining,
TokensPerDayResetsAt = status.TokensPerDayResetsAt,
ToolCallsPerDayLimit = status.ToolCallsPerDayLimit,
ToolCallsPerDayRemaining = status.ToolCallsPerDayRemaining,
ToolCallsPerDayResetsAt = status.ToolCallsPerDayResetsAt
};
}
}
/// <summary>Quota denial response.</summary>
public sealed record ChatDenialResponse
{
public required string Code { get; init; }
public required string Message { get; init; }
public required DateTimeOffset DeniedAt { get; init; }
}
/// <summary>Error response.</summary>
public sealed record ErrorResponse
{
public required string Error { get; init; }
public string? Code { get; init; }
public Dictionary<string, object>? Details { get; init; }
public ChatDoctorAction? Doctor { get; init; }
}
#endregion