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:
master
2025-11-02 13:40:38 +02:00
parent 66cb6c4b8a
commit f98cea3bcf
516 changed files with 68157 additions and 24754 deletions

View File

@@ -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());

View File

@@ -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);
}

View File

@@ -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. |