Add channel test providers for Email, Slack, Teams, and Webhook
	
		
			
	
		
	
	
		
	
		
			Some checks failed
		
		
	
	
		
			
				
	
				Docs CI / lint-and-preview (push) Has been cancelled
				
			
		
		
	
	
				
					
				
			
		
			Some checks failed
		
		
	
	Docs CI / lint-and-preview (push) Has been cancelled
				
			- Implemented EmailChannelTestProvider to generate email preview payloads. - Implemented SlackChannelTestProvider to create Slack message previews. - Implemented TeamsChannelTestProvider for generating Teams Adaptive Card previews. - Implemented WebhookChannelTestProvider to create webhook payloads. - Added INotifyChannelTestProvider interface for channel-specific preview generation. - Created ChannelTestPreviewContracts for request and response models. - Developed NotifyChannelTestService to handle test send requests and generate previews. - Added rate limit policies for test sends and delivery history. - Implemented unit tests for service registration and binding. - Updated project files to include necessary dependencies and configurations.
This commit is contained in:
		| @@ -16,7 +16,7 @@ Minimal API host wiring configuration, storage, plugin routines, and job endpoin | ||||
|   - GET /jobs/definitions/{kind}/runs?limit= -> recent runs or 404 if kind unknown. | ||||
|   - GET /jobs/active -> currently running. | ||||
|   - POST /jobs/{*jobKind} with {trigger?,parameters?} -> 202 Accepted (Location:/jobs/{runId}) | 404 | 409 | 423. | ||||
| - PluginHost defaults: BaseDirectory = solution root; PluginsDirectory = "PluginBinaries"; SearchPatterns += "StellaOps.Concelier.Plugin.*.dll"; EnsureDirectoryExists = true. | ||||
| - PluginHost defaults: BaseDirectory = solution root; PluginsDirectory = "StellaOps.Concelier.PluginBinaries"; SearchPatterns += "StellaOps.Concelier.Plugin.*.dll"; EnsureDirectoryExists = true. | ||||
| ## Participants | ||||
| - Core job system; Storage.Mongo; Source.Common HTTP clients; Exporter and Connector plugin routines discover/register jobs. | ||||
| ## Interfaces & contracts | ||||
|   | ||||
| @@ -0,0 +1,181 @@ | ||||
| using System.Globalization; | ||||
| using Microsoft.AspNetCore.Http; | ||||
| using Microsoft.Extensions.Options; | ||||
| using StellaOps.Concelier.WebService.Options; | ||||
| using StellaOps.Concelier.WebService.Services; | ||||
|  | ||||
| namespace StellaOps.Concelier.WebService.Extensions; | ||||
|  | ||||
| internal static class MirrorEndpointExtensions | ||||
| { | ||||
|     private const string IndexScope = "index"; | ||||
|     private const string DownloadScope = "download"; | ||||
|  | ||||
|     public static void MapConcelierMirrorEndpoints(this WebApplication app, bool authorityConfigured, bool enforceAuthority) | ||||
|     { | ||||
|         app.MapGet("/concelier/exports/index.json", async ( | ||||
|             MirrorFileLocator locator, | ||||
|             MirrorRateLimiter limiter, | ||||
|             IOptionsMonitor<ConcelierOptions> optionsMonitor, | ||||
|             HttpContext context, | ||||
|             CancellationToken cancellationToken) => | ||||
|         { | ||||
|             var mirrorOptions = optionsMonitor.CurrentValue.Mirror ?? new ConcelierOptions.MirrorOptions(); | ||||
|             if (!mirrorOptions.Enabled) | ||||
|             { | ||||
|                 return Results.NotFound(); | ||||
|             } | ||||
|  | ||||
|             if (!TryAuthorize(mirrorOptions.RequireAuthentication, enforceAuthority, context, authorityConfigured, out var unauthorizedResult)) | ||||
|             { | ||||
|                 return unauthorizedResult; | ||||
|             } | ||||
|  | ||||
|             if (!limiter.TryAcquire("__index__", IndexScope, mirrorOptions.MaxIndexRequestsPerHour, out var retryAfter)) | ||||
|             { | ||||
|                 ApplyRetryAfter(context.Response, retryAfter); | ||||
|                 return Results.StatusCode(StatusCodes.Status429TooManyRequests); | ||||
|             } | ||||
|  | ||||
|             if (!locator.TryResolveIndex(out var path, out _)) | ||||
|             { | ||||
|                 return Results.NotFound(); | ||||
|             } | ||||
|  | ||||
|             return await WriteFileAsync(path, context.Response, "application/json").ConfigureAwait(false); | ||||
|         }); | ||||
|  | ||||
|         app.MapGet("/concelier/exports/{**relativePath}", async ( | ||||
|             string? relativePath, | ||||
|             MirrorFileLocator locator, | ||||
|             MirrorRateLimiter limiter, | ||||
|             IOptionsMonitor<ConcelierOptions> optionsMonitor, | ||||
|             HttpContext context, | ||||
|             CancellationToken cancellationToken) => | ||||
|         { | ||||
|             var mirrorOptions = optionsMonitor.CurrentValue.Mirror ?? new ConcelierOptions.MirrorOptions(); | ||||
|             if (!mirrorOptions.Enabled) | ||||
|             { | ||||
|                 return Results.NotFound(); | ||||
|             } | ||||
|  | ||||
|             if (string.IsNullOrWhiteSpace(relativePath)) | ||||
|             { | ||||
|                 return Results.NotFound(); | ||||
|             } | ||||
|  | ||||
|             if (!locator.TryResolveRelativePath(relativePath, out var path, out _, out var domainId)) | ||||
|             { | ||||
|                 return Results.NotFound(); | ||||
|             } | ||||
|  | ||||
|             var domain = FindDomain(mirrorOptions, domainId); | ||||
|  | ||||
|             if (!TryAuthorize(domain?.RequireAuthentication ?? mirrorOptions.RequireAuthentication, enforceAuthority, context, authorityConfigured, out var unauthorizedResult)) | ||||
|             { | ||||
|                 return unauthorizedResult; | ||||
|             } | ||||
|  | ||||
|             var limit = domain?.MaxDownloadRequestsPerHour ?? mirrorOptions.MaxIndexRequestsPerHour; | ||||
|             if (!limiter.TryAcquire(domain?.Id ?? "__mirror__", DownloadScope, limit, out var retryAfter)) | ||||
|             { | ||||
|                 ApplyRetryAfter(context.Response, retryAfter); | ||||
|                 return Results.StatusCode(StatusCodes.Status429TooManyRequests); | ||||
|             } | ||||
|  | ||||
|             var contentType = ResolveContentType(path); | ||||
|             return await WriteFileAsync(path, context.Response, contentType).ConfigureAwait(false); | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     private static ConcelierOptions.MirrorDomainOptions? FindDomain(ConcelierOptions.MirrorOptions mirrorOptions, string? domainId) | ||||
|     { | ||||
|         if (domainId is null) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         foreach (var candidate in mirrorOptions.Domains) | ||||
|         { | ||||
|             if (candidate is null) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             if (string.Equals(candidate.Id, domainId, StringComparison.OrdinalIgnoreCase)) | ||||
|             { | ||||
|                 return candidate; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return null; | ||||
|     } | ||||
|  | ||||
|     private static bool TryAuthorize(bool requireAuthentication, bool enforceAuthority, HttpContext context, bool authorityConfigured, out IResult result) | ||||
|     { | ||||
|         result = Results.Empty; | ||||
|         if (!requireAuthentication) | ||||
|         { | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         if (!enforceAuthority || !authorityConfigured) | ||||
|         { | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         if (context.User?.Identity?.IsAuthenticated == true) | ||||
|         { | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         result = Results.StatusCode(StatusCodes.Status401Unauthorized); | ||||
|         return false; | ||||
|     } | ||||
|  | ||||
|     private static Task<IResult> WriteFileAsync(string path, HttpResponse response, string contentType) | ||||
|     { | ||||
|         var fileInfo = new FileInfo(path); | ||||
|         if (!fileInfo.Exists) | ||||
|         { | ||||
|             return Task.FromResult(Results.NotFound()); | ||||
|         } | ||||
|  | ||||
|         var stream = new FileStream( | ||||
|             path, | ||||
|             FileMode.Open, | ||||
|             FileAccess.Read, | ||||
|             FileShare.Read | FileShare.Delete); | ||||
|  | ||||
|         response.Headers.CacheControl = "public, max-age=60"; | ||||
|         response.Headers.LastModified = fileInfo.LastWriteTimeUtc.ToString("R", CultureInfo.InvariantCulture); | ||||
|         response.ContentLength = fileInfo.Length; | ||||
|         return Task.FromResult(Results.Stream(stream, contentType)); | ||||
|     } | ||||
|  | ||||
|     private static string ResolveContentType(string path) | ||||
|     { | ||||
|         if (path.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) | ||||
|         { | ||||
|             return "application/json"; | ||||
|         } | ||||
|  | ||||
|         if (path.EndsWith(".jws", StringComparison.OrdinalIgnoreCase)) | ||||
|         { | ||||
|             return "application/jose+json"; | ||||
|         } | ||||
|  | ||||
|         return "application/octet-stream"; | ||||
|     } | ||||
|  | ||||
|     private static void ApplyRetryAfter(HttpResponse response, TimeSpan? retryAfter) | ||||
|     { | ||||
|         if (retryAfter is null) | ||||
|         { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         var seconds = Math.Max((int)Math.Ceiling(retryAfter.Value.TotalSeconds), 1); | ||||
|         response.Headers.RetryAfter = seconds.ToString(CultureInfo.InvariantCulture); | ||||
|     } | ||||
| } | ||||
| @@ -1,5 +1,6 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Text.Json.Serialization; | ||||
|  | ||||
| namespace StellaOps.Concelier.WebService.Options; | ||||
|  | ||||
| @@ -12,6 +13,8 @@ public sealed class ConcelierOptions | ||||
|     public TelemetryOptions Telemetry { get; set; } = new(); | ||||
|  | ||||
|     public AuthorityOptions Authority { get; set; } = new(); | ||||
|  | ||||
|     public MirrorOptions Mirror { get; set; } = new(); | ||||
|  | ||||
|     public sealed class StorageOptions | ||||
|     { | ||||
| @@ -99,4 +102,37 @@ public sealed class ConcelierOptions | ||||
|             public TimeSpan? OfflineCacheTolerance { get; set; } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public sealed class MirrorOptions | ||||
|     { | ||||
|         public bool Enabled { get; set; } | ||||
|  | ||||
|         public string ExportRoot { get; set; } = System.IO.Path.Combine("exports", "json"); | ||||
|  | ||||
|         public string? ActiveExportId { get; set; } | ||||
|  | ||||
|         public string LatestDirectoryName { get; set; } = "latest"; | ||||
|  | ||||
|         public string MirrorDirectoryName { get; set; } = "mirror"; | ||||
|  | ||||
|         public bool RequireAuthentication { get; set; } | ||||
|  | ||||
|         public int MaxIndexRequestsPerHour { get; set; } = 600; | ||||
|  | ||||
|         public IList<MirrorDomainOptions> Domains { get; } = new List<MirrorDomainOptions>(); | ||||
|  | ||||
|         [JsonIgnore] | ||||
|         public string ExportRootAbsolute { get; internal set; } = string.Empty; | ||||
|     } | ||||
|  | ||||
|     public sealed class MirrorDomainOptions | ||||
|     { | ||||
|         public string Id { get; set; } = string.Empty; | ||||
|  | ||||
|         public string? DisplayName { get; set; } | ||||
|  | ||||
|         public bool RequireAuthentication { get; set; } | ||||
|  | ||||
|         public int MaxDownloadRequestsPerHour { get; set; } = 1200; | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -42,5 +42,31 @@ public static class ConcelierOptionsPostConfigure | ||||
|  | ||||
|             authority.ClientSecret = secret; | ||||
|         } | ||||
|  | ||||
|         options.Mirror ??= new ConcelierOptions.MirrorOptions(); | ||||
|         var mirror = options.Mirror; | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(mirror.ExportRoot)) | ||||
|         { | ||||
|             mirror.ExportRoot = Path.Combine("exports", "json"); | ||||
|         } | ||||
|  | ||||
|         var resolvedRoot = mirror.ExportRoot; | ||||
|         if (!Path.IsPathRooted(resolvedRoot)) | ||||
|         { | ||||
|             resolvedRoot = Path.Combine(contentRootPath, resolvedRoot); | ||||
|         } | ||||
|  | ||||
|         mirror.ExportRootAbsolute = Path.GetFullPath(resolvedRoot); | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(mirror.LatestDirectoryName)) | ||||
|         { | ||||
|             mirror.LatestDirectoryName = "latest"; | ||||
|         } | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(mirror.MirrorDirectoryName)) | ||||
|         { | ||||
|             mirror.MirrorDirectoryName = "mirror"; | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -130,6 +130,9 @@ public static class ConcelierOptionsValidator | ||||
|                 throw new InvalidOperationException("Telemetry OTLP header names must be non-empty."); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         options.Mirror ??= new ConcelierOptions.MirrorOptions(); | ||||
|         ValidateMirror(options.Mirror); | ||||
|     } | ||||
|  | ||||
|     private static void NormalizeList(IList<string> values, bool toLower) | ||||
| @@ -186,4 +189,57 @@ public static class ConcelierOptionsValidator | ||||
|             throw new InvalidOperationException("Authority resilience offlineCacheTolerance must be greater than or equal to zero."); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private static void ValidateMirror(ConcelierOptions.MirrorOptions mirror) | ||||
|     { | ||||
|         if (mirror.MaxIndexRequestsPerHour < 0) | ||||
|         { | ||||
|             throw new InvalidOperationException("Mirror maxIndexRequestsPerHour must be greater than or equal to zero."); | ||||
|         } | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(mirror.ExportRoot)) | ||||
|         { | ||||
|             throw new InvalidOperationException("Mirror exportRoot must be configured."); | ||||
|         } | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(mirror.ExportRootAbsolute)) | ||||
|         { | ||||
|             throw new InvalidOperationException("Mirror export root could not be resolved."); | ||||
|         } | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(mirror.LatestDirectoryName)) | ||||
|         { | ||||
|             throw new InvalidOperationException("Mirror latestDirectoryName must be provided."); | ||||
|         } | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(mirror.MirrorDirectoryName)) | ||||
|         { | ||||
|             throw new InvalidOperationException("Mirror mirrorDirectoryName must be provided."); | ||||
|         } | ||||
|  | ||||
|         var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase); | ||||
|         foreach (var domain in mirror.Domains) | ||||
|         { | ||||
|             if (string.IsNullOrWhiteSpace(domain.Id)) | ||||
|             { | ||||
|                 throw new InvalidOperationException("Mirror domain id must be provided."); | ||||
|             } | ||||
|  | ||||
|             var normalized = domain.Id.Trim(); | ||||
|             if (!seen.Add(normalized)) | ||||
|             { | ||||
|                 throw new InvalidOperationException($"Mirror domain id '{normalized}' is duplicated."); | ||||
|             } | ||||
|  | ||||
|             if (domain.MaxDownloadRequestsPerHour < 0) | ||||
|             { | ||||
|                 throw new InvalidOperationException($"Mirror domain '{normalized}' maxDownloadRequestsPerHour must be greater than or equal to zero."); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         if (mirror.Enabled && mirror.Domains.Count == 0) | ||||
|         { | ||||
|             throw new InvalidOperationException("Mirror distribution requires at least one domain when enabled."); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -4,8 +4,9 @@ using System.Text; | ||||
| using Microsoft.AspNetCore.Diagnostics; | ||||
| using Microsoft.AspNetCore.Http; | ||||
| using Microsoft.AspNetCore.Mvc; | ||||
| using Microsoft.Extensions.DependencyInjection; | ||||
| using Microsoft.Extensions.Hosting; | ||||
| using Microsoft.Extensions.DependencyInjection; | ||||
| using Microsoft.Extensions.DependencyInjection.Extensions; | ||||
| using Microsoft.Extensions.Hosting; | ||||
| using System.Diagnostics; | ||||
| using System.Text.Json; | ||||
| using System.Text.Json.Serialization; | ||||
| @@ -13,7 +14,8 @@ using Microsoft.Extensions.Logging; | ||||
| using Microsoft.Extensions.Options; | ||||
| using MongoDB.Bson; | ||||
| using MongoDB.Driver; | ||||
| using StellaOps.Concelier.Core.Jobs; | ||||
| using StellaOps.Concelier.Core.Events; | ||||
| using StellaOps.Concelier.Core.Jobs; | ||||
| using StellaOps.Concelier.Storage.Mongo; | ||||
| using StellaOps.Concelier.WebService.Diagnostics; | ||||
| using Serilog; | ||||
| @@ -23,6 +25,7 @@ using StellaOps.Concelier.WebService.Extensions; | ||||
| using StellaOps.Concelier.WebService.Jobs; | ||||
| using StellaOps.Concelier.WebService.Options; | ||||
| using StellaOps.Concelier.WebService.Filters; | ||||
| using StellaOps.Concelier.WebService.Services; | ||||
| using Serilog.Events; | ||||
| using StellaOps.Plugin.DependencyInjection; | ||||
| using StellaOps.Plugin.Hosting; | ||||
| @@ -62,10 +65,15 @@ builder.Services.AddOptions<ConcelierOptions>() | ||||
|     .ValidateOnStart(); | ||||
|  | ||||
| builder.ConfigureConcelierTelemetry(concelierOptions); | ||||
|  | ||||
| builder.Services.AddMongoStorage(storageOptions => | ||||
| { | ||||
|     storageOptions.ConnectionString = concelierOptions.Storage.Dsn; | ||||
|  | ||||
| builder.Services.TryAddSingleton<TimeProvider>(_ => TimeProvider.System); | ||||
| builder.Services.AddMemoryCache(); | ||||
| builder.Services.AddSingleton<MirrorRateLimiter>(); | ||||
| builder.Services.AddSingleton<MirrorFileLocator>(); | ||||
|  | ||||
| builder.Services.AddMongoStorage(storageOptions => | ||||
| { | ||||
|     storageOptions.ConnectionString = concelierOptions.Storage.Dsn; | ||||
|     storageOptions.DatabaseName = concelierOptions.Storage.Database; | ||||
|     storageOptions.CommandTimeout = TimeSpan.FromSeconds(concelierOptions.Storage.CommandTimeoutSeconds); | ||||
| }); | ||||
| @@ -174,9 +182,58 @@ if (resolvedAuthority.Enabled && resolvedAuthority.AllowAnonymousFallback) | ||||
|         "Authority authentication is configured but anonymous fallback remains enabled. Set authority.allowAnonymousFallback to false before 2025-12-31 to complete the rollout."); | ||||
| } | ||||
|  | ||||
| app.MapConcelierMirrorEndpoints(authorityConfigured, enforceAuthority); | ||||
|  | ||||
| var jsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web); | ||||
| jsonOptions.Converters.Add(new JsonStringEnumConverter()); | ||||
|  | ||||
| app.MapGet("/concelier/advisories/{vulnerabilityKey}/replay", async ( | ||||
|     string vulnerabilityKey, | ||||
|     DateTimeOffset? asOf, | ||||
|     IAdvisoryEventLog eventLog, | ||||
|     CancellationToken cancellationToken) => | ||||
| { | ||||
|     if (string.IsNullOrWhiteSpace(vulnerabilityKey)) | ||||
|     { | ||||
|         return Results.BadRequest("vulnerabilityKey must be provided."); | ||||
|     } | ||||
|  | ||||
|     var replay = await eventLog.ReplayAsync(vulnerabilityKey.Trim(), asOf, cancellationToken).ConfigureAwait(false); | ||||
|     if (replay.Statements.Length == 0 && replay.Conflicts.Length == 0) | ||||
|     { | ||||
|         return Results.NotFound(); | ||||
|     } | ||||
|  | ||||
|     var response = new | ||||
|     { | ||||
|         replay.VulnerabilityKey, | ||||
|         replay.AsOf, | ||||
|         Statements = replay.Statements.Select(statement => new | ||||
|         { | ||||
|             statement.StatementId, | ||||
|             statement.VulnerabilityKey, | ||||
|             statement.AdvisoryKey, | ||||
|             statement.Advisory, | ||||
|             StatementHash = Convert.ToHexString(statement.StatementHash.ToArray()), | ||||
|             statement.AsOf, | ||||
|             statement.RecordedAt, | ||||
|             InputDocumentIds = statement.InputDocumentIds | ||||
|         }).ToArray(), | ||||
|         Conflicts = replay.Conflicts.Select(conflict => new | ||||
|         { | ||||
|             conflict.ConflictId, | ||||
|             conflict.VulnerabilityKey, | ||||
|             conflict.StatementIds, | ||||
|             ConflictHash = Convert.ToHexString(conflict.ConflictHash.ToArray()), | ||||
|             conflict.AsOf, | ||||
|             conflict.RecordedAt, | ||||
|             Details = conflict.CanonicalJson | ||||
|         }).ToArray() | ||||
|     }; | ||||
|  | ||||
|     return JsonResult(response); | ||||
| }); | ||||
|  | ||||
| var loggingEnabled = concelierOptions.Telemetry?.EnableLogging ?? true; | ||||
|  | ||||
| if (loggingEnabled) | ||||
| @@ -660,8 +717,9 @@ static PluginHostOptions BuildPluginOptions(ConcelierOptions options, string con | ||||
| { | ||||
|     var pluginOptions = new PluginHostOptions | ||||
|     { | ||||
|         BaseDirectory = options.Plugins.BaseDirectory ?? contentRoot, | ||||
|         PluginsDirectory = options.Plugins.Directory ?? Path.Combine(contentRoot, "PluginBinaries"), | ||||
|         BaseDirectory = options.Plugins.BaseDirectory ?? contentRoot, | ||||
|         PluginsDirectory = options.Plugins.Directory ?? Path.Combine(contentRoot, "StellaOps.Concelier.PluginBinaries"), | ||||
|         PrimaryPrefix = "StellaOps.Concelier", | ||||
|         EnsureDirectoryExists = true, | ||||
|         RecursiveSearch = false, | ||||
|     }; | ||||
|   | ||||
							
								
								
									
										184
									
								
								src/StellaOps.Concelier.WebService/Services/MirrorFileLocator.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										184
									
								
								src/StellaOps.Concelier.WebService/Services/MirrorFileLocator.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,184 @@ | ||||
| using System; | ||||
| using System.Diagnostics.CodeAnalysis; | ||||
| using System.Globalization; | ||||
| using System.IO; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using Microsoft.Extensions.Options; | ||||
| using StellaOps.Concelier.WebService.Options; | ||||
|  | ||||
| namespace StellaOps.Concelier.WebService.Services; | ||||
|  | ||||
| internal sealed class MirrorFileLocator | ||||
| { | ||||
|     private readonly IOptionsMonitor<ConcelierOptions> _options; | ||||
|     private readonly ILogger<MirrorFileLocator> _logger; | ||||
|  | ||||
|     public MirrorFileLocator(IOptionsMonitor<ConcelierOptions> options, ILogger<MirrorFileLocator> logger) | ||||
|     { | ||||
|         _options = options ?? throw new ArgumentNullException(nameof(options)); | ||||
|         _logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||
|     } | ||||
|  | ||||
|     public bool TryResolveIndex([NotNullWhen(true)] out string? path, [NotNullWhen(true)] out string? exportId) | ||||
|         => TryResolveRelativePath("index.json", out path, out exportId, out _); | ||||
|  | ||||
|     public bool TryResolveRelativePath(string relativePath, [NotNullWhen(true)] out string? fullPath, [NotNullWhen(true)] out string? exportId, out string? domainId) | ||||
|     { | ||||
|         fullPath = null; | ||||
|         exportId = null; | ||||
|         domainId = null; | ||||
|  | ||||
|         var mirror = _options.CurrentValue.Mirror ?? new ConcelierOptions.MirrorOptions(); | ||||
|         if (!mirror.Enabled) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         if (!TryResolveExportDirectory(mirror, out var exportDirectory, out exportId)) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         var sanitized = SanitizeRelativePath(relativePath); | ||||
|         if (sanitized.Length == 0 || string.Equals(sanitized, "index.json", StringComparison.OrdinalIgnoreCase)) | ||||
|         { | ||||
|             sanitized = $"{mirror.MirrorDirectoryName}/index.json"; | ||||
|         } | ||||
|  | ||||
|         if (!sanitized.StartsWith($"{mirror.MirrorDirectoryName}/", StringComparison.OrdinalIgnoreCase)) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         var candidate = Combine(exportDirectory, sanitized); | ||||
|         if (!CandidateWithinExport(exportDirectory, candidate)) | ||||
|         { | ||||
|             _logger.LogWarning("Rejected mirror export request for path '{RelativePath}' due to traversal attempt.", relativePath); | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         if (!File.Exists(candidate)) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         // Extract domain id from path mirror/<domain>/... | ||||
|         var segments = sanitized.Split('/', StringSplitOptions.RemoveEmptyEntries); | ||||
|         if (segments.Length >= 2) | ||||
|         { | ||||
|             domainId = segments[1]; | ||||
|         } | ||||
|  | ||||
|         fullPath = candidate; | ||||
|         return true; | ||||
|     } | ||||
|  | ||||
|     private bool TryResolveExportDirectory(ConcelierOptions.MirrorOptions mirror, [NotNullWhen(true)] out string? exportDirectory, [NotNullWhen(true)] out string? exportId) | ||||
|     { | ||||
|         exportDirectory = null; | ||||
|         exportId = null; | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(mirror.ExportRootAbsolute)) | ||||
|         { | ||||
|             _logger.LogWarning("Mirror export root is not configured; unable to serve mirror content."); | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         var root = mirror.ExportRootAbsolute; | ||||
|         var candidateSegment = string.IsNullOrWhiteSpace(mirror.ActiveExportId) | ||||
|             ? mirror.LatestDirectoryName | ||||
|             : mirror.ActiveExportId!; | ||||
|  | ||||
|         if (TryResolveCandidate(root, candidateSegment, mirror.MirrorDirectoryName, out exportDirectory, out exportId)) | ||||
|         { | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         if (!string.Equals(candidateSegment, mirror.LatestDirectoryName, StringComparison.OrdinalIgnoreCase) | ||||
|             && TryResolveCandidate(root, mirror.LatestDirectoryName, mirror.MirrorDirectoryName, out exportDirectory, out exportId)) | ||||
|         { | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             var directories = Directory.Exists(root) | ||||
|                 ? Directory.GetDirectories(root) | ||||
|                 : Array.Empty<string>(); | ||||
|  | ||||
|             Array.Sort(directories, StringComparer.Ordinal); | ||||
|             Array.Reverse(directories); | ||||
|  | ||||
|             foreach (var directory in directories) | ||||
|             { | ||||
|                 if (TryResolveCandidate(root, Path.GetFileName(directory), mirror.MirrorDirectoryName, out exportDirectory, out exportId)) | ||||
|                 { | ||||
|                     return true; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) | ||||
|         { | ||||
|             _logger.LogWarning(ex, "Failed to enumerate export directories under {Root}.", root); | ||||
|         } | ||||
|  | ||||
|         return false; | ||||
|     } | ||||
|  | ||||
|     private bool TryResolveCandidate(string root, string segment, string mirrorDirectory, [NotNullWhen(true)] out string? exportDirectory, [NotNullWhen(true)] out string? exportId) | ||||
|     { | ||||
|         exportDirectory = null; | ||||
|         exportId = null; | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(segment)) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         var candidate = Path.Combine(root, segment); | ||||
|         if (!Directory.Exists(candidate)) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         var mirrorPath = Path.Combine(candidate, mirrorDirectory); | ||||
|         if (!Directory.Exists(mirrorPath)) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         exportDirectory = candidate; | ||||
|         exportId = segment; | ||||
|         return true; | ||||
|     } | ||||
|  | ||||
|     private static string SanitizeRelativePath(string relativePath) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(relativePath)) | ||||
|         { | ||||
|             return string.Empty; | ||||
|         } | ||||
|  | ||||
|         var trimmed = relativePath.Replace('\\', '/').Trim().TrimStart('/'); | ||||
|         return trimmed; | ||||
|     } | ||||
|  | ||||
|     private static string Combine(string root, string relativePath) | ||||
|     { | ||||
|         var segments = relativePath.Split('/', StringSplitOptions.RemoveEmptyEntries); | ||||
|         if (segments.Length == 0) | ||||
|         { | ||||
|             return Path.GetFullPath(root); | ||||
|         } | ||||
|  | ||||
|         var combinedRelative = Path.Combine(segments); | ||||
|         return Path.GetFullPath(Path.Combine(root, combinedRelative)); | ||||
|     } | ||||
|  | ||||
|     private static bool CandidateWithinExport(string exportDirectory, string candidate) | ||||
|     { | ||||
|         var exportRoot = Path.GetFullPath(exportDirectory).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); | ||||
|         var candidatePath = Path.GetFullPath(candidate); | ||||
|         return candidatePath.StartsWith(exportRoot, StringComparison.OrdinalIgnoreCase); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,57 @@ | ||||
| using Microsoft.Extensions.Caching.Memory; | ||||
|  | ||||
| namespace StellaOps.Concelier.WebService.Services; | ||||
|  | ||||
| internal sealed class MirrorRateLimiter | ||||
| { | ||||
|     private readonly IMemoryCache _cache; | ||||
|     private readonly TimeProvider _timeProvider; | ||||
|     private static readonly TimeSpan Window = TimeSpan.FromHours(1); | ||||
|  | ||||
|     public MirrorRateLimiter(IMemoryCache cache, TimeProvider timeProvider) | ||||
|     { | ||||
|         _cache = cache ?? throw new ArgumentNullException(nameof(cache)); | ||||
|         _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); | ||||
|     } | ||||
|  | ||||
|     public bool TryAcquire(string domainId, string scope, int limit, out TimeSpan? retryAfter) | ||||
|     { | ||||
|         retryAfter = null; | ||||
|  | ||||
|         if (limit <= 0 || limit == int.MaxValue) | ||||
|         { | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         var key = CreateKey(domainId, scope); | ||||
|         var now = _timeProvider.GetUtcNow(); | ||||
|  | ||||
|         var counter = _cache.Get<Counter>(key); | ||||
|         if (counter is null || now - counter.WindowStart >= Window) | ||||
|         { | ||||
|             counter = new Counter(now, 0); | ||||
|         } | ||||
|  | ||||
|         if (counter.Count >= limit) | ||||
|         { | ||||
|             var windowEnd = counter.WindowStart + Window; | ||||
|             retryAfter = windowEnd > now ? windowEnd - now : TimeSpan.Zero; | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         counter = counter with { Count = counter.Count + 1 }; | ||||
|         var absoluteExpiration = counter.WindowStart + Window; | ||||
|         _cache.Set(key, counter, absoluteExpiration); | ||||
|         return true; | ||||
|     } | ||||
|  | ||||
|     private static string CreateKey(string domainId, string scope) | ||||
|         => string.Create(domainId.Length + scope.Length + 1, (domainId, scope), static (span, state) => | ||||
|         { | ||||
|             state.domainId.AsSpan().CopyTo(span); | ||||
|             span[state.domainId.Length] = '|'; | ||||
|             state.scope.AsSpan().CopyTo(span[(state.domainId.Length + 1)..]); | ||||
|         }); | ||||
|  | ||||
|     private sealed record Counter(DateTimeOffset WindowStart, int Count); | ||||
| } | ||||
| @@ -1,18 +1,19 @@ | ||||
| # TASKS | ||||
| | Task | Owner(s) | Depends on | Notes | | ||||
| |---|---|---|---| | ||||
| |Bind & validate ConcelierOptions|BE-Base|WebService|DONE – options bound/validated with failure logging.| | ||||
| |Mongo service wiring|BE-Base|Storage.Mongo|DONE – wiring delegated to `AddMongoStorage`.| | ||||
| |Bootstrapper execution on start|BE-Base|Storage.Mongo|DONE – startup calls `MongoBootstrapper.InitializeAsync`.| | ||||
| |Plugin host options finalization|BE-Base|Plugins|DONE – default plugin directories/search patterns configured.| | ||||
| |Jobs API contract tests|QA|Core|DONE – WebServiceEndpointsTests now cover success payloads, filtering, and trigger outcome mapping.| | ||||
| |Health/Ready probes|DevOps|Ops|DONE – `/health` and `/ready` endpoints implemented.| | ||||
| |Serilog + OTEL integration hooks|BE-Base|Observability|DONE – `TelemetryExtensions` wires Serilog + OTEL with configurable exporters.| | ||||
| |Register built-in jobs (sources/exporters)|BE-Base|Core|DONE – AddBuiltInConcelierJobs adds fallback scheduler definitions for core connectors and exporters via reflection.| | ||||
| |HTTP problem details consistency|BE-Base|WebService|DONE – API errors now emit RFC7807 responses with trace identifiers and typed problem categories.| | ||||
| |Request logging and metrics|BE-Base|Observability|DONE – Serilog request logging enabled with enriched context and web.jobs counters published via OpenTelemetry.| | ||||
| |Endpoint smoke tests (health/ready/jobs error paths)|QA|WebService|DONE – WebServiceEndpointsTests assert success and problem responses for health, ready, and job trigger error paths.| | ||||
| |Batch job definition last-run lookup|BE-Base|Core|DONE – definitions endpoint now precomputes kinds array and reuses batched last-run dictionary; manual smoke verified via local GET `/jobs/definitions`.| | ||||
| # TASKS | ||||
| | Task | Owner(s) | Depends on | Notes | | ||||
| |---|---|---|---| | ||||
| |FEEDWEB-EVENTS-07-001 Advisory event replay API|Concelier WebService Guild|FEEDCORE-ENGINE-07-001|**DONE (2025-10-19)** – Added `/concelier/advisories/{vulnerabilityKey}/replay` endpoint with optional `asOf`, hex hashes, and conflict payloads; integration covered via `dotnet test src/StellaOps.Concelier.WebService.Tests/StellaOps.Concelier.WebService.Tests.csproj`.| | ||||
| |Bind & validate ConcelierOptions|BE-Base|WebService|DONE – options bound/validated with failure logging.| | ||||
| |Mongo service wiring|BE-Base|Storage.Mongo|DONE – wiring delegated to `AddMongoStorage`.| | ||||
| |Bootstrapper execution on start|BE-Base|Storage.Mongo|DONE – startup calls `MongoBootstrapper.InitializeAsync`.| | ||||
| |Plugin host options finalization|BE-Base|Plugins|DONE – default plugin directories/search patterns configured.| | ||||
| |Jobs API contract tests|QA|Core|DONE – WebServiceEndpointsTests now cover success payloads, filtering, and trigger outcome mapping.| | ||||
| |Health/Ready probes|DevOps|Ops|DONE – `/health` and `/ready` endpoints implemented.| | ||||
| |Serilog + OTEL integration hooks|BE-Base|Observability|DONE – `TelemetryExtensions` wires Serilog + OTEL with configurable exporters.| | ||||
| |Register built-in jobs (sources/exporters)|BE-Base|Core|DONE – AddBuiltInConcelierJobs adds fallback scheduler definitions for core connectors and exporters via reflection.| | ||||
| |HTTP problem details consistency|BE-Base|WebService|DONE – API errors now emit RFC7807 responses with trace identifiers and typed problem categories.| | ||||
| |Request logging and metrics|BE-Base|Observability|DONE – Serilog request logging enabled with enriched context and web.jobs counters published via OpenTelemetry.| | ||||
| |Endpoint smoke tests (health/ready/jobs error paths)|QA|WebService|DONE – WebServiceEndpointsTests assert success and problem responses for health, ready, and job trigger error paths.| | ||||
| |Batch job definition last-run lookup|BE-Base|Core|DONE – definitions endpoint now precomputes kinds array and reuses batched last-run dictionary; manual smoke verified via local GET `/jobs/definitions`.| | ||||
| |Add no-cache headers to health/readiness/jobs APIs|BE-Base|WebService|DONE – helper applies Cache-Control/Pragma/Expires on all health/ready/jobs endpoints; awaiting automated probe tests once connector fixtures stabilize.| | ||||
| |Authority configuration parity (FSR1)|DevEx/Concelier|Authority options schema|**DONE (2025-10-10)** – Options post-config loads clientSecretFile fallback, validators normalize scopes/audiences, and sample config documents issuer/credential/bypass settings.| | ||||
| |Document authority toggle & scope requirements|Docs/Concelier|Authority integration|**DOING (2025-10-10)** – Quickstart updated with staging flag, client credentials, env overrides; operator guide refresh pending Docs guild review.| | ||||
| @@ -20,6 +21,7 @@ | ||||
| |Author ops guidance for resilience tuning|Docs/Concelier|Plumb Authority client resilience options|**DONE (2025-10-12)** – `docs/21_INSTALL_GUIDE.md` + `docs/ops/concelier-authority-audit-runbook.md` document resilience profiles for connected vs air-gapped installs and reference monitoring cues.| | ||||
| |Document authority bypass logging patterns|Docs/Concelier|FSR3 logging|**DONE (2025-10-12)** – Updated operator guides clarify `Concelier.Authorization.Audit` fields (route/status/subject/clientId/scopes/bypass/remote) and SIEM triggers.| | ||||
| |Update Concelier operator guide for enforcement cutoff|Docs/Concelier|FSR1 rollout|**DONE (2025-10-12)** – Installation guide emphasises disabling `allowAnonymousFallback` before 2025-12-31 UTC and connects audit signals to the rollout checklist.|  | ||||
| |Rename plugin drop directory to namespaced path|BE-Base|Plugins|**TODO** – Point Concelier source/exporter build outputs to `StellaOps.Concelier.PluginBinaries`, update PluginHost defaults/search patterns to match, ensure Offline Kit packaging/tests expect the new folder, and document migration guidance for operators.|  | ||||
| |Rename plugin drop directory to namespaced path|BE-Base|Plugins|**DONE (2025-10-19)** – Build outputs now target `StellaOps.Concelier.PluginBinaries`/`StellaOps.Authority.PluginBinaries`, plugin host defaults updated, config/docs refreshed, and `dotnet test src/StellaOps.Concelier.WebService.Tests/StellaOps.Concelier.WebService.Tests.csproj --no-restore` covers the change.|  | ||||
| |Authority resilience adoption|Concelier WebService, Docs|Plumb Authority client resilience options|**BLOCKED (2025-10-10)** – Roll out retry/offline knobs to deployment docs and confirm CLI parity once LIB5 lands; unblock after resilience options wired and tested.|  | ||||
| |CONCELIER-WEB-08-201 – Mirror distribution endpoints|Concelier WebService Guild|CONCELIER-EXPORT-08-201, DEVOPS-MIRROR-08-001|TODO – Add domain-scoped mirror configuration (`*.stella-ops.org`), expose signed export index/download APIs with quota and auth, and document sync workflow for downstream Concelier instances.|  | ||||
| |CONCELIER-WEB-08-201 – Mirror distribution endpoints|Concelier WebService Guild|CONCELIER-EXPORT-08-201, DEVOPS-MIRROR-08-001|DOING (2025-10-19) – HTTP endpoints wired (`/concelier/exports/index.json`, `/concelier/exports/mirror/*`), mirror options bound/validated, and integration tests added; pending auth docs + smoke in ops handbook.|  | ||||
| |Wave 0B readiness checkpoint|Team WebService & Authority|Wave 0A completion|BLOCKED (2025-10-19) – FEEDSTORAGE-MONGO-08-001 closed, but remaining Wave 0A items (AUTH-DPOP-11-001, AUTH-MTLS-11-002, PLUGIN-DI-08-001) still open; maintain current DOING workstreams only.|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user