consolidation of some of the modules, localization fixes, product advisories work, qa work
This commit is contained in:
@@ -0,0 +1,570 @@
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
|
||||
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.OpsMemory.Models;
|
||||
using StellaOps.OpsMemory.Playbook;
|
||||
using StellaOps.OpsMemory.Storage;
|
||||
using StellaOps.OpsMemory.WebService.Security;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.OpsMemory.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// OpsMemory API endpoints for decision recording and playbook suggestions.
|
||||
/// Sprint: SPRINT_20260107_006_004 Task OM-006
|
||||
/// </summary>
|
||||
public static class OpsMemoryEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps OpsMemory endpoints.
|
||||
/// </summary>
|
||||
public static void MapOpsMemoryEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/opsmemory")
|
||||
.WithTags("OpsMemory")
|
||||
.RequireAuthorization(OpsMemoryPolicies.Read)
|
||||
.RequireTenant();
|
||||
|
||||
group.MapPost("/decisions", RecordDecisionAsync)
|
||||
.WithName("RecordDecision")
|
||||
.WithDescription("Records a security decision (accept, suppress, mitigate, escalate) for a CVE and component combination. The decision is stored with situational context for future playbook learning and similarity matching. Returns 201 Created with the new memory ID.")
|
||||
.RequireAuthorization(OpsMemoryPolicies.Write);
|
||||
|
||||
group.MapGet("/decisions/{memoryId}", GetDecisionAsync)
|
||||
.WithName("GetDecision")
|
||||
.WithDescription("Returns the full decision record for a specific memory ID including situational context, decision details, mitigation information, and outcome if recorded. Returns 404 if not found.");
|
||||
|
||||
group.MapPost("/decisions/{memoryId}/outcome", RecordOutcomeAsync)
|
||||
.WithName("RecordOutcome")
|
||||
.WithDescription("Records the observed outcome of a previously stored security decision, capturing resolution time, actual impact, lessons learned, and whether the decision would be repeated. Returns 200 with the updated memory ID.")
|
||||
.RequireAuthorization(OpsMemoryPolicies.Write);
|
||||
|
||||
group.MapGet("/suggestions", GetSuggestionsAsync)
|
||||
.WithName("GetPlaybookSuggestions")
|
||||
.WithDescription("Returns ranked playbook suggestions for a given situational context by matching against historical decisions using similarity scoring. Each suggestion includes confidence, success rate, and evidence from past decisions.");
|
||||
|
||||
group.MapGet("/decisions", QueryDecisionsAsync)
|
||||
.WithName("QueryDecisions")
|
||||
.WithDescription("Queries stored security decisions with optional filters by CVE ID, component prefix, action type, and outcome status. Supports cursor-based pagination.");
|
||||
|
||||
group.MapGet("/stats", GetStatsAsync)
|
||||
.WithName("GetOpsMemoryStats")
|
||||
.WithDescription("Returns aggregated decision statistics for the tenant including total decision count, decisions with recorded outcomes, and overall success rate.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record a new security decision.
|
||||
/// </summary>
|
||||
private static async Task<Results<Created<RecordDecisionResponse>, BadRequest<ProblemDetails>>> RecordDecisionAsync(
|
||||
RecordDecisionRequest request,
|
||||
IOpsMemoryStore store,
|
||||
TimeProvider timeProvider,
|
||||
IGuidProvider guidProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.TenantId))
|
||||
{
|
||||
return TypedResults.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "TenantId is required"
|
||||
});
|
||||
}
|
||||
|
||||
if (!Enum.TryParse<DecisionAction>(request.Action, ignoreCase: true, out var action))
|
||||
{
|
||||
return TypedResults.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = $"Invalid action: {request.Action}. Valid values: {string.Join(", ", Enum.GetNames<DecisionAction>())}"
|
||||
});
|
||||
}
|
||||
|
||||
var record = new OpsMemoryRecord
|
||||
{
|
||||
MemoryId = guidProvider.NewGuid().ToString("N"),
|
||||
TenantId = request.TenantId,
|
||||
RecordedAt = timeProvider.GetUtcNow(),
|
||||
Situation = new SituationContext
|
||||
{
|
||||
CveId = request.CveId,
|
||||
Component = request.ComponentPurl,
|
||||
Severity = request.Severity,
|
||||
Reachability = ParseReachability(request.Reachability),
|
||||
EpssScore = request.EpssScore,
|
||||
CvssScore = request.CvssScore,
|
||||
ContextTags = request.ContextTags?.ToImmutableArray() ?? ImmutableArray<string>.Empty
|
||||
},
|
||||
Decision = new DecisionRecord
|
||||
{
|
||||
Action = action,
|
||||
Rationale = request.Rationale ?? string.Empty,
|
||||
DecidedBy = request.DecidedBy ?? "unknown",
|
||||
DecidedAt = timeProvider.GetUtcNow(),
|
||||
PolicyReference = request.PolicyReference,
|
||||
Mitigation = !string.IsNullOrEmpty(request.MitigationType)
|
||||
? new MitigationDetails
|
||||
{
|
||||
Type = request.MitigationType,
|
||||
Description = request.MitigationDetails ?? string.Empty
|
||||
}
|
||||
: null
|
||||
}
|
||||
};
|
||||
|
||||
var saved = await store.RecordDecisionAsync(record, cancellationToken);
|
||||
|
||||
return TypedResults.Created(
|
||||
$"/api/v1/opsmemory/decisions/{saved.MemoryId}",
|
||||
new RecordDecisionResponse
|
||||
{
|
||||
MemoryId = saved.MemoryId,
|
||||
RecordedAt = saved.RecordedAt
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get a specific decision.
|
||||
/// </summary>
|
||||
private static async Task<Results<Ok<DecisionDetailsResponse>, NotFound>> GetDecisionAsync(
|
||||
string memoryId,
|
||||
string tenantId,
|
||||
IOpsMemoryStore store,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var record = await store.GetByIdAsync(memoryId, tenantId, cancellationToken);
|
||||
|
||||
if (record == null)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
return TypedResults.Ok(MapToDetailsResponse(record));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record the outcome of a decision.
|
||||
/// </summary>
|
||||
private static async Task<Results<Ok<OutcomeRecordedResponse>, NotFound, BadRequest<ProblemDetails>>> RecordOutcomeAsync(
|
||||
string memoryId,
|
||||
string tenantId,
|
||||
RecordOutcomeRequest request,
|
||||
IOpsMemoryStore store,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!Enum.TryParse<OutcomeStatus>(request.Status, ignoreCase: true, out var status))
|
||||
{
|
||||
return TypedResults.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = $"Invalid status: {request.Status}. Valid values: {string.Join(", ", Enum.GetNames<OutcomeStatus>())}"
|
||||
});
|
||||
}
|
||||
|
||||
var outcome = new OutcomeRecord
|
||||
{
|
||||
Status = status,
|
||||
ResolutionTime = request.ResolutionTimeMinutes.HasValue
|
||||
? TimeSpan.FromMinutes(request.ResolutionTimeMinutes.Value)
|
||||
: null,
|
||||
ActualImpact = request.ActualImpact,
|
||||
LessonsLearned = request.LessonsLearned,
|
||||
RecordedBy = request.RecordedBy ?? "unknown",
|
||||
RecordedAt = timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
var updated = await store.RecordOutcomeAsync(memoryId, tenantId, outcome, cancellationToken);
|
||||
|
||||
if (updated == null)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
return TypedResults.Ok(new OutcomeRecordedResponse
|
||||
{
|
||||
MemoryId = memoryId,
|
||||
Status = status.ToString(),
|
||||
RecordedAt = outcome.RecordedAt
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get playbook suggestions for a situation.
|
||||
/// </summary>
|
||||
private static async Task<Ok<PlaybookSuggestionsResponse>> GetSuggestionsAsync(
|
||||
string tenantId,
|
||||
PlaybookSuggestionService suggestionService,
|
||||
string? cveId,
|
||||
string? componentPurl,
|
||||
string? severity,
|
||||
string? reachability,
|
||||
double? epssScore,
|
||||
double? cvssScore,
|
||||
int? limit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var request = new PlaybookSuggestionRequest
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Situation = new SituationContext
|
||||
{
|
||||
CveId = cveId,
|
||||
Component = componentPurl,
|
||||
Severity = severity,
|
||||
Reachability = ParseReachability(reachability),
|
||||
EpssScore = epssScore,
|
||||
CvssScore = cvssScore
|
||||
},
|
||||
MaxSuggestions = limit ?? 3
|
||||
};
|
||||
|
||||
var result = await suggestionService.GetSuggestionsAsync(request, cancellationToken);
|
||||
|
||||
return TypedResults.Ok(new PlaybookSuggestionsResponse
|
||||
{
|
||||
Suggestions = result.Suggestions.Select(s => new PlaybookSuggestionDto
|
||||
{
|
||||
SuggestedAction = s.Action.ToString(),
|
||||
Confidence = s.Confidence,
|
||||
Rationale = s.Rationale,
|
||||
SuccessRate = s.SuccessRate,
|
||||
SimilarDecisionCount = s.SimilarDecisionCount,
|
||||
AverageResolutionTimeMinutes = s.AverageResolutionTime?.TotalMinutes,
|
||||
Evidence = s.Evidence.Select(e => new PlaybookEvidenceDto
|
||||
{
|
||||
MemoryId = e.MemoryId,
|
||||
Similarity = e.Similarity,
|
||||
Action = e.Action.ToString(),
|
||||
Outcome = e.Outcome.ToString(),
|
||||
DecidedAt = e.DecidedAt,
|
||||
CveId = e.Cve,
|
||||
ComponentPurl = e.Component
|
||||
}).ToList(),
|
||||
MatchingFactors = s.MatchingFactors.ToList()
|
||||
}).ToList(),
|
||||
AnalyzedRecords = result.AnalyzedRecords,
|
||||
TopSimilarity = result.TopSimilarity
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Query past decisions.
|
||||
/// </summary>
|
||||
private static async Task<Ok<QueryDecisionsResponse>> QueryDecisionsAsync(
|
||||
string tenantId,
|
||||
IOpsMemoryStore store,
|
||||
string? cveId,
|
||||
string? componentPrefix,
|
||||
string? action,
|
||||
string? outcomeStatus,
|
||||
int? pageSize,
|
||||
string? cursor,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
DecisionAction? actionFilter = null;
|
||||
if (!string.IsNullOrEmpty(action) && Enum.TryParse<DecisionAction>(action, ignoreCase: true, out var a))
|
||||
{
|
||||
actionFilter = a;
|
||||
}
|
||||
|
||||
OutcomeStatus? statusFilter = null;
|
||||
if (!string.IsNullOrEmpty(outcomeStatus) && Enum.TryParse<OutcomeStatus>(outcomeStatus, ignoreCase: true, out var s))
|
||||
{
|
||||
statusFilter = s;
|
||||
}
|
||||
|
||||
var query = new OpsMemoryQuery
|
||||
{
|
||||
TenantId = tenantId,
|
||||
CveId = cveId,
|
||||
ComponentPrefix = componentPrefix,
|
||||
Action = actionFilter,
|
||||
OutcomeStatus = statusFilter,
|
||||
PageSize = pageSize ?? 20,
|
||||
Cursor = cursor
|
||||
};
|
||||
|
||||
var result = await store.QueryAsync(query, cancellationToken);
|
||||
|
||||
return TypedResults.Ok(new QueryDecisionsResponse
|
||||
{
|
||||
Decisions = result.Items.Select(MapToSummary).ToList(),
|
||||
TotalCount = result.TotalCount,
|
||||
NextCursor = result.NextCursor,
|
||||
HasMore = result.HasMore
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get statistics for a tenant.
|
||||
/// </summary>
|
||||
private static async Task<Ok<OpsMemoryStatsResponse>> GetStatsAsync(
|
||||
string tenantId,
|
||||
IOpsMemoryStore store,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var stats = await store.GetStatsAsync(tenantId, null, cancellationToken);
|
||||
|
||||
return TypedResults.Ok(new OpsMemoryStatsResponse
|
||||
{
|
||||
TenantId = tenantId,
|
||||
TotalDecisions = stats.TotalDecisions,
|
||||
DecisionsWithOutcomes = stats.DecisionsWithOutcomes,
|
||||
SuccessRate = stats.SuccessRate
|
||||
});
|
||||
}
|
||||
|
||||
private static ReachabilityStatus ParseReachability(string? value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return ReachabilityStatus.Unknown;
|
||||
}
|
||||
|
||||
return Enum.TryParse<ReachabilityStatus>(value, ignoreCase: true, out var result)
|
||||
? result
|
||||
: ReachabilityStatus.Unknown;
|
||||
}
|
||||
|
||||
private static DecisionDetailsResponse MapToDetailsResponse(OpsMemoryRecord record)
|
||||
{
|
||||
return new DecisionDetailsResponse
|
||||
{
|
||||
MemoryId = record.MemoryId,
|
||||
TenantId = record.TenantId,
|
||||
RecordedAt = record.RecordedAt,
|
||||
Situation = new SituationDto
|
||||
{
|
||||
CveId = record.Situation.CveId,
|
||||
Component = record.Situation.Component,
|
||||
ComponentName = record.Situation.ComponentName,
|
||||
Severity = record.Situation.Severity,
|
||||
Reachability = record.Situation.Reachability.ToString(),
|
||||
EpssScore = record.Situation.EpssScore,
|
||||
CvssScore = record.Situation.CvssScore,
|
||||
IsKev = record.Situation.IsKev,
|
||||
ContextTags = record.Situation.ContextTags.ToList()
|
||||
},
|
||||
Decision = new DecisionDto
|
||||
{
|
||||
Action = record.Decision.Action.ToString(),
|
||||
Rationale = record.Decision.Rationale,
|
||||
DecidedBy = record.Decision.DecidedBy,
|
||||
DecidedAt = record.Decision.DecidedAt,
|
||||
PolicyReference = record.Decision.PolicyReference,
|
||||
VexStatementId = record.Decision.VexStatementId,
|
||||
Mitigation = record.Decision.Mitigation != null
|
||||
? new MitigationDto
|
||||
{
|
||||
Type = record.Decision.Mitigation.Type,
|
||||
Description = record.Decision.Mitigation.Description,
|
||||
Effectiveness = record.Decision.Mitigation.Effectiveness,
|
||||
ExpiresAt = record.Decision.Mitigation.ExpiresAt
|
||||
}
|
||||
: null
|
||||
},
|
||||
Outcome = record.Outcome != null
|
||||
? new OutcomeDto
|
||||
{
|
||||
Status = record.Outcome.Status.ToString(),
|
||||
ResolutionTimeMinutes = record.Outcome.ResolutionTime?.TotalMinutes,
|
||||
ActualImpact = record.Outcome.ActualImpact,
|
||||
LessonsLearned = record.Outcome.LessonsLearned,
|
||||
RecordedBy = record.Outcome.RecordedBy,
|
||||
RecordedAt = record.Outcome.RecordedAt,
|
||||
WouldRepeat = record.Outcome.WouldRepeat,
|
||||
AlternativeActions = record.Outcome.AlternativeActions
|
||||
}
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
private static DecisionSummaryDto MapToSummary(OpsMemoryRecord record)
|
||||
{
|
||||
return new DecisionSummaryDto
|
||||
{
|
||||
MemoryId = record.MemoryId,
|
||||
RecordedAt = record.RecordedAt,
|
||||
CveId = record.Situation.CveId,
|
||||
Component = record.Situation.Component,
|
||||
Action = record.Decision.Action.ToString(),
|
||||
OutcomeStatus = record.Outcome?.Status.ToString(),
|
||||
DecidedBy = record.Decision.DecidedBy
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#region Request/Response DTOs
|
||||
|
||||
/// <summary>Request to record a decision.</summary>
|
||||
public sealed record RecordDecisionRequest
|
||||
{
|
||||
public required string TenantId { get; init; }
|
||||
public string? CveId { get; init; }
|
||||
public string? ComponentPurl { get; init; }
|
||||
public string? Severity { get; init; }
|
||||
public string? Reachability { get; init; }
|
||||
public double? EpssScore { get; init; }
|
||||
public double? CvssScore { get; init; }
|
||||
public List<string>? ContextTags { get; init; }
|
||||
public required string Action { get; init; }
|
||||
public string? Rationale { get; init; }
|
||||
public string? DecidedBy { get; init; }
|
||||
public string? PolicyReference { get; init; }
|
||||
public string? MitigationType { get; init; }
|
||||
public string? MitigationDetails { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Response after recording a decision.</summary>
|
||||
public sealed record RecordDecisionResponse
|
||||
{
|
||||
public required string MemoryId { get; init; }
|
||||
public DateTimeOffset RecordedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Request to record an outcome.</summary>
|
||||
public sealed record RecordOutcomeRequest
|
||||
{
|
||||
public required string Status { get; init; }
|
||||
public int? ResolutionTimeMinutes { get; init; }
|
||||
public string? ActualImpact { get; init; }
|
||||
public string? LessonsLearned { get; init; }
|
||||
public string? RecordedBy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Response after recording an outcome.</summary>
|
||||
public sealed record OutcomeRecordedResponse
|
||||
{
|
||||
public required string MemoryId { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public DateTimeOffset RecordedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Detailed decision information.</summary>
|
||||
public sealed record DecisionDetailsResponse
|
||||
{
|
||||
public required string MemoryId { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public DateTimeOffset RecordedAt { get; init; }
|
||||
public required SituationDto Situation { get; init; }
|
||||
public required DecisionDto Decision { get; init; }
|
||||
public OutcomeDto? Outcome { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Situation context DTO.</summary>
|
||||
public sealed record SituationDto
|
||||
{
|
||||
public string? CveId { get; init; }
|
||||
public string? Component { get; init; }
|
||||
public string? ComponentName { get; init; }
|
||||
public string? Severity { get; init; }
|
||||
public string? Reachability { get; init; }
|
||||
public double? EpssScore { get; init; }
|
||||
public double? CvssScore { get; init; }
|
||||
public bool IsKev { get; init; }
|
||||
public List<string>? ContextTags { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Decision DTO.</summary>
|
||||
public sealed record DecisionDto
|
||||
{
|
||||
public required string Action { get; init; }
|
||||
public required string Rationale { get; init; }
|
||||
public required string DecidedBy { get; init; }
|
||||
public DateTimeOffset DecidedAt { get; init; }
|
||||
public string? PolicyReference { get; init; }
|
||||
public string? VexStatementId { get; init; }
|
||||
public MitigationDto? Mitigation { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Mitigation DTO.</summary>
|
||||
public sealed record MitigationDto
|
||||
{
|
||||
public required string Type { get; init; }
|
||||
public required string Description { get; init; }
|
||||
public double? Effectiveness { get; init; }
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Outcome DTO.</summary>
|
||||
public sealed record OutcomeDto
|
||||
{
|
||||
public required string Status { get; init; }
|
||||
public double? ResolutionTimeMinutes { get; init; }
|
||||
public string? ActualImpact { get; init; }
|
||||
public string? LessonsLearned { get; init; }
|
||||
public required string RecordedBy { get; init; }
|
||||
public DateTimeOffset RecordedAt { get; init; }
|
||||
public bool? WouldRepeat { get; init; }
|
||||
public string? AlternativeActions { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Playbook suggestions response.</summary>
|
||||
public sealed record PlaybookSuggestionsResponse
|
||||
{
|
||||
public required List<PlaybookSuggestionDto> Suggestions { get; init; }
|
||||
public int AnalyzedRecords { get; init; }
|
||||
public double? TopSimilarity { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>A single playbook suggestion.</summary>
|
||||
public sealed record PlaybookSuggestionDto
|
||||
{
|
||||
public required string SuggestedAction { get; init; }
|
||||
public double Confidence { get; init; }
|
||||
public required string Rationale { get; init; }
|
||||
public double SuccessRate { get; init; }
|
||||
public int SimilarDecisionCount { get; init; }
|
||||
public double? AverageResolutionTimeMinutes { get; init; }
|
||||
public required List<PlaybookEvidenceDto> Evidence { get; init; }
|
||||
public List<string>? MatchingFactors { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Evidence for a suggestion.</summary>
|
||||
public sealed record PlaybookEvidenceDto
|
||||
{
|
||||
public required string MemoryId { get; init; }
|
||||
public double Similarity { get; init; }
|
||||
public required string Action { get; init; }
|
||||
public required string Outcome { get; init; }
|
||||
public DateTimeOffset DecidedAt { get; init; }
|
||||
public string? CveId { get; init; }
|
||||
public string? ComponentPurl { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Query decisions response.</summary>
|
||||
public sealed record QueryDecisionsResponse
|
||||
{
|
||||
public required List<DecisionSummaryDto> Decisions { get; init; }
|
||||
public int? TotalCount { get; init; }
|
||||
public string? NextCursor { get; init; }
|
||||
public bool HasMore { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Decision summary for lists.</summary>
|
||||
public sealed record DecisionSummaryDto
|
||||
{
|
||||
public required string MemoryId { get; init; }
|
||||
public DateTimeOffset RecordedAt { get; init; }
|
||||
public string? CveId { get; init; }
|
||||
public string? Component { get; init; }
|
||||
public required string Action { get; init; }
|
||||
public string? OutcomeStatus { get; init; }
|
||||
public string? DecidedBy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Statistics response.</summary>
|
||||
public sealed record OpsMemoryStatsResponse
|
||||
{
|
||||
public required string TenantId { get; init; }
|
||||
public int TotalDecisions { get; init; }
|
||||
public int DecisionsWithOutcomes { get; init; }
|
||||
public double SuccessRate { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
110
src/AdvisoryAI/StellaOps.OpsMemory.WebService/Program.cs
Normal file
110
src/AdvisoryAI/StellaOps.OpsMemory.WebService/Program.cs
Normal file
@@ -0,0 +1,110 @@
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using Npgsql;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.OpsMemory.Playbook;
|
||||
using StellaOps.OpsMemory.Similarity;
|
||||
using StellaOps.OpsMemory.Storage;
|
||||
using StellaOps.Localization;
|
||||
using StellaOps.Router.AspNet;
|
||||
using StellaOps.OpsMemory.WebService.Endpoints;
|
||||
using StellaOps.OpsMemory.WebService.Security;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Add PostgreSQL data source
|
||||
var connectionString = ResolveOpsMemoryConnectionString(builder);
|
||||
builder.Services.AddSingleton<NpgsqlDataSource>(_ => NpgsqlDataSource.Create(connectionString));
|
||||
|
||||
// Add determinism abstractions (TimeProvider + IGuidProvider for endpoint parameter binding)
|
||||
builder.Services.AddDeterminismDefaults();
|
||||
|
||||
// Add OpsMemory services
|
||||
builder.Services.AddSingleton<IOpsMemoryStore, PostgresOpsMemoryStore>();
|
||||
builder.Services.AddSingleton<SimilarityVectorGenerator>();
|
||||
builder.Services.AddSingleton<PlaybookSuggestionService>();
|
||||
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen(options =>
|
||||
{
|
||||
options.SwaggerDoc("v1", new()
|
||||
{
|
||||
Title = "StellaOps OpsMemory API",
|
||||
Version = "v1",
|
||||
Description = "Decision ledger and playbook suggestions API for security operations learning"
|
||||
});
|
||||
});
|
||||
|
||||
builder.Services.AddHealthChecks();
|
||||
|
||||
// Authentication and authorization
|
||||
builder.Services.AddStellaOpsResourceServerAuthentication(builder.Configuration);
|
||||
builder.Services.AddAuthorization(options =>
|
||||
{
|
||||
options.AddStellaOpsScopePolicy(OpsMemoryPolicies.Read, StellaOpsScopes.OpsMemoryRead);
|
||||
options.AddStellaOpsScopePolicy(OpsMemoryPolicies.Write, StellaOpsScopes.OpsMemoryWrite);
|
||||
});
|
||||
|
||||
builder.Services.AddStellaOpsTenantServices();
|
||||
builder.Services.AddStellaOpsCors(builder.Environment, builder.Configuration);
|
||||
builder.Services.AddStellaOpsLocalization(builder.Configuration);
|
||||
builder.Services.AddTranslationBundle(System.Reflection.Assembly.GetExecutingAssembly());
|
||||
|
||||
// Stella Router integration
|
||||
var routerEnabled = builder.Services.AddRouterMicroservice(
|
||||
builder.Configuration,
|
||||
serviceName: "opsmemory",
|
||||
version: System.Reflection.CustomAttributeExtensions.GetCustomAttribute<System.Reflection.AssemblyInformationalVersionAttribute>(System.Reflection.Assembly.GetExecutingAssembly())?.InformationalVersion ?? "1.0.0",
|
||||
routerOptionsSection: "Router");
|
||||
builder.TryAddStellaOpsLocalBinding("opsmemory");
|
||||
var app = builder.Build();
|
||||
app.LogStellaOpsLocalHostname("opsmemory");
|
||||
|
||||
// Configure the HTTP request pipeline
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
}
|
||||
|
||||
app.UseStellaOpsCors();
|
||||
app.UseStellaOpsLocalization();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.UseStellaOpsTenantMiddleware();
|
||||
app.TryUseStellaRouter(routerEnabled);
|
||||
|
||||
// Map endpoints
|
||||
app.MapOpsMemoryEndpoints();
|
||||
app.MapHealthChecks("/health");
|
||||
|
||||
app.TryRefreshStellaRouterEndpoints(routerEnabled);
|
||||
await app.LoadTranslationsAsync();
|
||||
app.Run();
|
||||
|
||||
static string ResolveOpsMemoryConnectionString(WebApplicationBuilder builder)
|
||||
{
|
||||
// Explicit service connection has priority; shared default is the compose-compatible fallback.
|
||||
var configuredConnectionString =
|
||||
builder.Configuration.GetConnectionString("OpsMemory")
|
||||
?? builder.Configuration["ConnectionStrings:OpsMemory"]
|
||||
?? builder.Configuration.GetConnectionString("Default")
|
||||
?? builder.Configuration["ConnectionStrings:Default"];
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(configuredConnectionString))
|
||||
{
|
||||
return configuredConnectionString.Trim();
|
||||
}
|
||||
|
||||
if (builder.Environment.IsDevelopment())
|
||||
{
|
||||
return "Host=localhost;Port=5432;Database=stellaops;Username=stellaops;Password=stellaops";
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
"OpsMemory database connection string is required in non-development environments. Configure ConnectionStrings:OpsMemory or ConnectionStrings:Default.");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"profiles": {
|
||||
"StellaOps.OpsMemory.WebService": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"STELLAOPS_WEBSERVICES_CORS": "true",
|
||||
"STELLAOPS_WEBSERVICES_CORS_ORIGIN": "https://stella-ops.local,https://stella-ops.local:10000,https://localhost:10000"
|
||||
},
|
||||
"applicationUrl": "https://localhost:10270;http://localhost:10271"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
|
||||
namespace StellaOps.OpsMemory.WebService.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Named authorization policy constants for the OpsMemory service.
|
||||
/// Policies are registered via AddStellaOpsScopePolicy in Program.cs.
|
||||
/// </summary>
|
||||
internal static class OpsMemoryPolicies
|
||||
{
|
||||
/// <summary>Policy for reading decisions and suggestions. Requires ops-memory:read scope.</summary>
|
||||
public const string Read = "OpsMemory.Read";
|
||||
|
||||
/// <summary>Policy for recording decisions and outcomes. Requires ops-memory:write scope.</summary>
|
||||
public const string Write = "OpsMemory.Write";
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<RootNamespace>StellaOps.OpsMemory.WebService</RootNamespace>
|
||||
<Description>StellaOps OpsMemory Service - Decision ledger and playbook suggestions API</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\__Libraries\StellaOps.OpsMemory\StellaOps.OpsMemory.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Determinism.Abstractions\StellaOps.Determinism.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\..\Authority\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Localization\StellaOps.Localization.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Translations\*.json" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" />
|
||||
<PackageReference Include="Npgsql" />
|
||||
</ItemGroup>
|
||||
<PropertyGroup Label="StellaOpsReleaseVersion">
|
||||
<Version>1.0.0-alpha1</Version>
|
||||
<InformationalVersion>1.0.0-alpha1</InformationalVersion>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
9
src/AdvisoryAI/StellaOps.OpsMemory.WebService/TASKS.md
Normal file
9
src/AdvisoryAI/StellaOps.OpsMemory.WebService/TASKS.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# StellaOps.OpsMemory.WebService Task Board
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| S312-OPSMEMORY-CONNECTION | DONE | Sprint `docs/implplan/SPRINT_20260305_312_DOCS_storage_policy_postgres_rustfs_alignment.md` TASK-312-007: aligned connection resolution with compose defaults (`ConnectionStrings:Default` fallback) and added fail-fast behavior for non-development when DB config is missing. |
|
||||
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/OpsMemory/StellaOps.OpsMemory.WebService/StellaOps.OpsMemory.WebService.md. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"_meta": { "locale": "en-US", "namespace": "opsmemory", "version": "1.0" }
|
||||
}
|
||||
Reference in New Issue
Block a user