Add Authority Advisory AI and API Lifecycle Configuration
- Introduced AuthorityAdvisoryAiOptions and related classes for managing advisory AI configurations, including remote inference options and tenant-specific settings. - Added AuthorityApiLifecycleOptions to control API lifecycle settings, including legacy OAuth endpoint configurations. - Implemented validation and normalization methods for both advisory AI and API lifecycle options to ensure proper configuration. - Created AuthorityNotificationsOptions and its related classes for managing notification settings, including ack tokens, webhooks, and escalation options. - Developed IssuerDirectoryClient and related models for interacting with the issuer directory service, including caching mechanisms and HTTP client configurations. - Added support for dependency injection through ServiceCollectionExtensions for the Issuer Directory Client. - Updated project file to include necessary package references for the new Issuer Directory Client library.
This commit is contained in:
@@ -101,6 +101,7 @@ builder.Services.AddSingleton<IAdvisoryObservationQueryService, AdvisoryObservat
|
||||
builder.Services.AddMergeModule(builder.Configuration);
|
||||
builder.Services.AddJobScheduler();
|
||||
builder.Services.AddBuiltInConcelierJobs();
|
||||
builder.Services.AddSingleton<OpenApiDiscoveryDocumentProvider>();
|
||||
|
||||
builder.Services.AddSingleton<ServiceStatus>(sp => new ServiceStatus(sp.GetRequiredService<TimeProvider>()));
|
||||
builder.Services.AddAocGuard();
|
||||
@@ -209,6 +210,50 @@ if (resolvedAuthority.Enabled && resolvedAuthority.AllowAnonymousFallback)
|
||||
|
||||
app.MapConcelierMirrorEndpoints(authorityConfigured, enforceAuthority);
|
||||
|
||||
app.MapGet("/.well-known/openapi", (OpenApiDiscoveryDocumentProvider provider, HttpContext context) =>
|
||||
{
|
||||
var (payload, etag) = provider.GetDocument();
|
||||
|
||||
if (context.Request.Headers.IfNoneMatch.Count > 0)
|
||||
{
|
||||
foreach (var candidate in context.Request.Headers.IfNoneMatch)
|
||||
{
|
||||
if (Matches(candidate, etag))
|
||||
{
|
||||
context.Response.Headers.ETag = etag;
|
||||
context.Response.Headers.CacheControl = "public, max-age=300, immutable";
|
||||
return Results.StatusCode(StatusCodes.Status304NotModified);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
context.Response.Headers.ETag = etag;
|
||||
context.Response.Headers.CacheControl = "public, max-age=300, immutable";
|
||||
return Results.Text(payload, "application/vnd.oai.openapi+json;version=3.1");
|
||||
|
||||
static bool Matches(string? candidate, string expected)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(candidate))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var trimmed = candidate.Trim();
|
||||
if (string.Equals(trimmed, expected, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (trimmed.StartsWith("W/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var weakValue = trimmed[2..].TrimStart();
|
||||
return string.Equals(weakValue, expected, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}).WithName("GetConcelierOpenApiDocument");
|
||||
|
||||
var jsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web);
|
||||
jsonOptions.Converters.Add(new JsonStringEnumConverter());
|
||||
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Linq;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.AspNetCore.Routing.Patterns;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Services;
|
||||
|
||||
internal sealed class OpenApiDiscoveryDocumentProvider
|
||||
{
|
||||
private static readonly string[] MethodPreference =
|
||||
[
|
||||
HttpMethods.Get,
|
||||
HttpMethods.Post,
|
||||
HttpMethods.Put,
|
||||
HttpMethods.Patch,
|
||||
HttpMethods.Delete,
|
||||
HttpMethods.Options,
|
||||
HttpMethods.Head,
|
||||
HttpMethods.Trace
|
||||
];
|
||||
|
||||
private readonly EndpointDataSource _endpointDataSource;
|
||||
private readonly object _syncRoot = new();
|
||||
|
||||
private string? _cachedDocumentJson;
|
||||
private string? _cachedEtag;
|
||||
|
||||
public OpenApiDiscoveryDocumentProvider(EndpointDataSource endpointDataSource)
|
||||
{
|
||||
_endpointDataSource = endpointDataSource;
|
||||
}
|
||||
|
||||
public (string Payload, string ETag) GetDocument()
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
if (_cachedDocumentJson is { } cached && _cachedEtag is { } etag)
|
||||
{
|
||||
return (cached, etag);
|
||||
}
|
||||
|
||||
var document = BuildDocument();
|
||||
|
||||
var json = JsonSerializer.Serialize(
|
||||
document,
|
||||
new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = null,
|
||||
WriteIndented = true
|
||||
});
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(json);
|
||||
var hash = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
|
||||
var computedEtag = $"\"{hash}\"";
|
||||
|
||||
_cachedDocumentJson = json;
|
||||
_cachedEtag = computedEtag;
|
||||
|
||||
return (json, computedEtag);
|
||||
}
|
||||
}
|
||||
|
||||
private JsonObject BuildDocument()
|
||||
{
|
||||
var info = new JsonObject
|
||||
{
|
||||
["title"] = "StellaOps Concelier API",
|
||||
["version"] = ResolveAssemblyVersion(),
|
||||
["description"] = "Programmatic contract for Concelier advisory ingestion, observation replay, evidence exports, and job orchestration."
|
||||
};
|
||||
|
||||
var servers = new JsonArray
|
||||
{
|
||||
new JsonObject
|
||||
{
|
||||
["url"] = "/",
|
||||
["description"] = "Relative base path (API Gateway rewrites in production)."
|
||||
}
|
||||
};
|
||||
|
||||
var pathGroups = CollectEndpointMetadata();
|
||||
var pathsObject = new JsonObject();
|
||||
|
||||
foreach (var (path, entries) in pathGroups)
|
||||
{
|
||||
var pathItem = new JsonObject();
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
pathItem[entry.Method.ToLowerInvariant()] = BuildOperation(entry);
|
||||
}
|
||||
|
||||
pathsObject[path] = pathItem;
|
||||
}
|
||||
|
||||
return new JsonObject
|
||||
{
|
||||
["openapi"] = "3.1.0",
|
||||
["info"] = info,
|
||||
["servers"] = servers,
|
||||
["paths"] = pathsObject,
|
||||
["components"] = new JsonObject() // ready for future schemas
|
||||
};
|
||||
}
|
||||
|
||||
private static string ResolveAssemblyVersion()
|
||||
{
|
||||
var assembly = typeof(OpenApiDiscoveryDocumentProvider).Assembly;
|
||||
|
||||
var informationalVersion = assembly
|
||||
.GetCustomAttributes(typeof(System.Reflection.AssemblyInformationalVersionAttribute), inherit: false)
|
||||
.OfType<System.Reflection.AssemblyInformationalVersionAttribute>()
|
||||
.FirstOrDefault()
|
||||
?.InformationalVersion;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(informationalVersion))
|
||||
{
|
||||
return informationalVersion!;
|
||||
}
|
||||
|
||||
var version = assembly.GetName().Version;
|
||||
return version is { } v ? $"{v.Major}.{v.Minor}.{v.Build}" : "0.0.0";
|
||||
}
|
||||
|
||||
private IReadOnlyDictionary<string, IReadOnlyList<EndpointEntry>> CollectEndpointMetadata()
|
||||
{
|
||||
var endpointEntries = new List<EndpointEntry>();
|
||||
|
||||
foreach (var endpoint in _endpointDataSource.Endpoints.OfType<RouteEndpoint>())
|
||||
{
|
||||
var httpMetadata = endpoint.Metadata.GetMetadata<HttpMethodMetadata>();
|
||||
if (httpMetadata is null || httpMetadata.HttpMethods is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalizedPath = NormalizeRoutePattern(endpoint.RoutePattern);
|
||||
if (normalizedPath is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var method in httpMetadata.HttpMethods)
|
||||
{
|
||||
endpointEntries.Add(new EndpointEntry(normalizedPath, method, endpoint));
|
||||
}
|
||||
}
|
||||
|
||||
var comparer = StringComparer.OrdinalIgnoreCase;
|
||||
var grouped = endpointEntries
|
||||
.OrderBy(e => e.Path, comparer)
|
||||
.ThenBy(e => GetMethodOrder(e.Method))
|
||||
.GroupBy(e => e.Path, comparer)
|
||||
.ToDictionary(
|
||||
group => group.Key,
|
||||
group => (IReadOnlyList<EndpointEntry>)group.ToList(),
|
||||
comparer);
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
private static int GetMethodOrder(string method)
|
||||
{
|
||||
var index = Array.IndexOf(MethodPreference, method);
|
||||
return index >= 0 ? index : MethodPreference.Length;
|
||||
}
|
||||
|
||||
private static JsonObject BuildOperation(EndpointEntry entry)
|
||||
{
|
||||
var endpoint = entry.Endpoint;
|
||||
var operationId = BuildOperationId(entry.Method, entry.Path);
|
||||
var summary = NormalizeSummary(endpoint.DisplayName, entry.Method, entry.Path);
|
||||
|
||||
var operation = new JsonObject
|
||||
{
|
||||
["operationId"] = operationId,
|
||||
["summary"] = summary
|
||||
};
|
||||
|
||||
var tags = BuildTags(entry.Path);
|
||||
if (tags is { Count: > 0 })
|
||||
{
|
||||
var tagArray = new JsonArray(tags.Select(tag => JsonValue.Create(tag)!).ToArray());
|
||||
operation["tags"] = tagArray;
|
||||
}
|
||||
|
||||
var parameters = BuildParameters(endpoint.RoutePattern);
|
||||
if (parameters.Count > 0)
|
||||
{
|
||||
operation["parameters"] = new JsonArray(parameters.ToArray());
|
||||
}
|
||||
|
||||
if (RequiresBody(entry.Method))
|
||||
{
|
||||
operation["requestBody"] = new JsonObject
|
||||
{
|
||||
["required"] = true,
|
||||
["content"] = new JsonObject
|
||||
{
|
||||
["application/json"] = new JsonObject
|
||||
{
|
||||
["schema"] = new JsonObject { ["type"] = "object" }
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
operation["responses"] = BuildResponses(entry.Method);
|
||||
|
||||
return operation;
|
||||
}
|
||||
|
||||
private static JsonObject BuildResponses(string method)
|
||||
{
|
||||
var responses = new JsonObject
|
||||
{
|
||||
["200"] = new JsonObject
|
||||
{
|
||||
["description"] = "Request processed successfully."
|
||||
}
|
||||
};
|
||||
|
||||
if (string.Equals(method, HttpMethods.Post, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
responses["202"] = new JsonObject
|
||||
{
|
||||
["description"] = "Accepted for asynchronous processing."
|
||||
};
|
||||
}
|
||||
|
||||
responses["401"] = new JsonObject
|
||||
{
|
||||
["description"] = "Authentication required."
|
||||
};
|
||||
responses["403"] = new JsonObject
|
||||
{
|
||||
["description"] = "Authorization failed for the requested scope."
|
||||
};
|
||||
|
||||
return responses;
|
||||
}
|
||||
|
||||
private static bool RequiresBody(string method) =>
|
||||
string.Equals(method, HttpMethods.Post, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(method, HttpMethods.Put, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(method, HttpMethods.Patch, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static List<JsonNode> BuildParameters(RoutePattern pattern)
|
||||
{
|
||||
var results = new List<JsonNode>();
|
||||
|
||||
foreach (var parameter in pattern.Parameters)
|
||||
{
|
||||
var schema = new JsonObject
|
||||
{
|
||||
["type"] = "string"
|
||||
};
|
||||
|
||||
if (parameter.ParameterKind == RoutePatternParameterKind.CatchAll)
|
||||
{
|
||||
schema["description"] = "Catch-all segment";
|
||||
}
|
||||
|
||||
var parameterObject = new JsonObject
|
||||
{
|
||||
["name"] = parameter.Name,
|
||||
["in"] = "path",
|
||||
["required"] = true,
|
||||
["schema"] = schema
|
||||
};
|
||||
|
||||
results.Add(parameterObject);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> BuildTags(string path)
|
||||
{
|
||||
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (segments.Length == 0)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
// Tag by top-level segment (e.g., "concelier", "advisories", "jobs").
|
||||
return new[] { CultureInfo.InvariantCulture.TextInfo.ToTitleCase(segments[0]) };
|
||||
}
|
||||
|
||||
private static string NormalizeSummary(string? displayName, string method, string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(displayName))
|
||||
{
|
||||
return $"{method.ToUpperInvariant()} {path}";
|
||||
}
|
||||
|
||||
var summary = displayName!;
|
||||
if (summary.StartsWith("HTTP:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
summary = summary[5..].Trim();
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
private static string BuildOperationId(string method, string path)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.Append(method.ToLowerInvariant());
|
||||
builder.Append('_');
|
||||
|
||||
foreach (var ch in path)
|
||||
{
|
||||
if (char.IsLetterOrDigit(ch))
|
||||
{
|
||||
builder.Append(char.ToLowerInvariant(ch));
|
||||
}
|
||||
else if (ch == '{' || ch == '}' || ch == '/' || ch == '-')
|
||||
{
|
||||
builder.Append('_');
|
||||
}
|
||||
}
|
||||
|
||||
return Regex.Replace(builder.ToString(), "_{2,}", "_").TrimEnd('_');
|
||||
}
|
||||
|
||||
private static string? NormalizeRoutePattern(RoutePattern pattern)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(pattern.RawText))
|
||||
{
|
||||
return NormalizeRawPattern(pattern.RawText!);
|
||||
}
|
||||
|
||||
var segments = new List<string>();
|
||||
foreach (var segment in pattern.PathSegments)
|
||||
{
|
||||
var segmentBuilder = new StringBuilder();
|
||||
foreach (var part in segment.Parts)
|
||||
{
|
||||
switch (part)
|
||||
{
|
||||
case RoutePatternLiteralPart literal:
|
||||
segmentBuilder.Append(literal.Content);
|
||||
break;
|
||||
case RoutePatternParameterPart parameter:
|
||||
segmentBuilder.Append('{');
|
||||
segmentBuilder.Append(parameter.Name);
|
||||
segmentBuilder.Append('}');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
segments.Add(segmentBuilder.ToString());
|
||||
}
|
||||
|
||||
var combined = "/" + string.Join('/', segments);
|
||||
return NormalizeRawPattern(combined);
|
||||
}
|
||||
|
||||
private static string NormalizeRawPattern(string raw)
|
||||
{
|
||||
var normalized = raw;
|
||||
if (!normalized.StartsWith('/'))
|
||||
{
|
||||
normalized = "/" + normalized;
|
||||
}
|
||||
|
||||
normalized = normalized.Replace("**", "*", StringComparison.Ordinal);
|
||||
normalized = Regex.Replace(normalized, @"\{(\*?)([A-Za-z0-9_]+)(:[^}]+)?\}", "{$2}", RegexOptions.Compiled);
|
||||
normalized = Regex.Replace(normalized, "/{0,}$", string.Empty, RegexOptions.Compiled);
|
||||
|
||||
return string.IsNullOrWhiteSpace(normalized) ? "/" : normalized;
|
||||
}
|
||||
|
||||
private readonly record struct EndpointEntry(string Path, string Method, RouteEndpoint Endpoint);
|
||||
}
|
||||
@@ -88,7 +88,7 @@
|
||||
## SDKs & OpenAPI (Epic 17)
|
||||
| ID | Status | Owner(s) | Depends on | Notes |
|
||||
|----|--------|----------|------------|-------|
|
||||
| CONCELIER-WEB-OAS-61-001 `/.well-known/openapi` | TODO | Concelier WebService Guild | OAS-61-001 | Implement discovery endpoint emitting Concelier spec with version metadata and ETag. |
|
||||
| CONCELIER-WEB-OAS-61-001 `/.well-known/openapi` | DONE (2025-11-02) | Concelier WebService Guild | OAS-61-001 | Implement discovery endpoint emitting Concelier spec with version metadata and ETag. |
|
||||
| CONCELIER-WEB-OAS-61-002 `Error envelope migration` | TODO | Concelier WebService Guild | APIGOV-61-001 | Ensure all API responses use standardized error envelope; update controllers/tests. |
|
||||
| CONCELIER-WEB-OAS-62-001 `Examples expansion` | TODO | Concelier WebService Guild | CONCELIER-OAS-61-002 | Add curated examples for advisory observations/linksets/conflicts; integrate into dev portal. |
|
||||
| CONCELIER-WEB-OAS-63-001 `Deprecation headers` | TODO | Concelier WebService Guild, API Governance Guild | APIGOV-63-001 | Add Sunset/Deprecation headers for retiring endpoints and update documentation/notifications. |
|
||||
|
||||
Reference in New Issue
Block a user