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:
		| @@ -1,6 +1,11 @@ | ||||
| using System.Collections.Generic; | ||||
| using System.IO; | ||||
| using System.Linq; | ||||
| using System.Security.Authentication; | ||||
| using System.Security.Cryptography; | ||||
| using System.Security.Claims; | ||||
| using System.Security.Cryptography.X509Certificates; | ||||
| using System.Threading.RateLimiting; | ||||
| using Serilog; | ||||
| using Serilog.Events; | ||||
| using StellaOps.Attestor.Core.Options; | ||||
| @@ -13,6 +18,7 @@ using OpenTelemetry.Metrics; | ||||
| using StellaOps.Attestor.Core.Observability; | ||||
| using StellaOps.Attestor.Core.Verification; | ||||
| using Microsoft.AspNetCore.Server.Kestrel.Https; | ||||
| using Serilog.Context; | ||||
|  | ||||
| const string ConfigurationSection = "attestor"; | ||||
|  | ||||
| @@ -36,9 +42,45 @@ builder.Host.UseSerilog((context, services, loggerConfiguration) => | ||||
|  | ||||
| var attestorOptions = builder.Configuration.BindOptions<AttestorOptions>(ConfigurationSection); | ||||
|  | ||||
| var clientCertificateAuthorities = LoadClientCertificateAuthorities(attestorOptions.Security.Mtls.CaBundle); | ||||
|  | ||||
| builder.Services.AddSingleton(TimeProvider.System); | ||||
| builder.Services.AddSingleton(attestorOptions); | ||||
|  | ||||
| builder.Services.AddRateLimiter(options => | ||||
| { | ||||
|     options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; | ||||
|     options.OnRejected = static (context, _) => | ||||
|     { | ||||
|         context.HttpContext.Response.Headers.TryAdd("Retry-After", "1"); | ||||
|         return ValueTask.CompletedTask; | ||||
|     }; | ||||
|  | ||||
|     options.AddPolicy("attestor-submissions", httpContext => | ||||
|     { | ||||
|         var identity = httpContext.Connection.ClientCertificate?.Thumbprint | ||||
|             ?? httpContext.User.FindFirst("sub")?.Value | ||||
|             ?? httpContext.User.FindFirst("client_id")?.Value | ||||
|             ?? httpContext.Connection.RemoteIpAddress?.ToString() | ||||
|             ?? "anonymous"; | ||||
|  | ||||
|         var quota = attestorOptions.Quotas.PerCaller; | ||||
|         var tokensPerPeriod = Math.Max(1, quota.Qps); | ||||
|         var tokenLimit = Math.Max(tokensPerPeriod, quota.Burst); | ||||
|         var queueLimit = Math.Max(quota.Burst, tokensPerPeriod); | ||||
|  | ||||
|         return RateLimitPartition.GetTokenBucketLimiter(identity, _ => new TokenBucketRateLimiterOptions | ||||
|         { | ||||
|             TokenLimit = tokenLimit, | ||||
|             TokensPerPeriod = tokensPerPeriod, | ||||
|             ReplenishmentPeriod = TimeSpan.FromSeconds(1), | ||||
|             QueueLimit = queueLimit, | ||||
|             QueueProcessingOrder = QueueProcessingOrder.OldestFirst, | ||||
|             AutoReplenishment = true | ||||
|         }); | ||||
|     }); | ||||
| }); | ||||
|  | ||||
| builder.Services.AddOptions<AttestorOptions>() | ||||
|     .Bind(builder.Configuration.GetSection(ConfigurationSection)) | ||||
|     .ValidateOnStart(); | ||||
| @@ -105,6 +147,61 @@ builder.WebHost.ConfigureKestrel(kestrel => | ||||
|         { | ||||
|             https.ClientCertificateMode = ClientCertificateMode.RequireCertificate; | ||||
|         } | ||||
|  | ||||
|         https.SslProtocols = SslProtocols.Tls13 | SslProtocols.Tls12; | ||||
|  | ||||
|         https.ClientCertificateValidation = (certificate, _, _) => | ||||
|         { | ||||
|             if (!attestorOptions.Security.Mtls.RequireClientCertificate) | ||||
|             { | ||||
|                 return true; | ||||
|             } | ||||
|  | ||||
|             if (certificate is null) | ||||
|             { | ||||
|                 Log.Warning("Client certificate missing"); | ||||
|                 return false; | ||||
|             } | ||||
|  | ||||
|             if (clientCertificateAuthorities.Count > 0) | ||||
|             { | ||||
|                 using var chain = new X509Chain | ||||
|                 { | ||||
|                     ChainPolicy = | ||||
|                     { | ||||
|                         RevocationMode = X509RevocationMode.NoCheck, | ||||
|                         TrustMode = X509ChainTrustMode.CustomRootTrust | ||||
|                     } | ||||
|                 }; | ||||
|  | ||||
|                 foreach (var authority in clientCertificateAuthorities) | ||||
|                 { | ||||
|                     chain.ChainPolicy.CustomTrustStore.Add(authority); | ||||
|                 } | ||||
|  | ||||
|                 if (!chain.Build(certificate)) | ||||
|                 { | ||||
|                     Log.Warning("Client certificate chain validation failed for {Subject}", certificate.Subject); | ||||
|                     return false; | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             if (attestorOptions.Security.Mtls.AllowedThumbprints.Count > 0 && | ||||
|                 !attestorOptions.Security.Mtls.AllowedThumbprints.Contains(certificate.Thumbprint ?? string.Empty, StringComparer.OrdinalIgnoreCase)) | ||||
|             { | ||||
|                 Log.Warning("Client certificate thumbprint {Thumbprint} rejected", certificate.Thumbprint); | ||||
|                 return false; | ||||
|             } | ||||
|  | ||||
|             if (attestorOptions.Security.Mtls.AllowedSubjects.Count > 0 && | ||||
|                 !attestorOptions.Security.Mtls.AllowedSubjects.Contains(certificate.Subject, StringComparer.OrdinalIgnoreCase)) | ||||
|             { | ||||
|                 Log.Warning("Client certificate subject {Subject} rejected", certificate.Subject); | ||||
|                 return false; | ||||
|             } | ||||
|  | ||||
|             return true; | ||||
|         }; | ||||
|     }); | ||||
| }); | ||||
|  | ||||
| @@ -112,6 +209,22 @@ var app = builder.Build(); | ||||
|  | ||||
| app.UseSerilogRequestLogging(); | ||||
|  | ||||
| app.Use(async (context, next) => | ||||
| { | ||||
|     var correlationId = context.Request.Headers["X-Correlation-Id"].FirstOrDefault(); | ||||
|     if (string.IsNullOrWhiteSpace(correlationId)) | ||||
|     { | ||||
|         correlationId = Guid.NewGuid().ToString("N"); | ||||
|     } | ||||
|  | ||||
|     context.Response.Headers["X-Correlation-Id"] = correlationId; | ||||
|  | ||||
|     using (LogContext.PushProperty("CorrelationId", correlationId)) | ||||
|     { | ||||
|         await next().ConfigureAwait(false); | ||||
|     } | ||||
| }); | ||||
|  | ||||
| app.UseExceptionHandler(static handler => | ||||
| { | ||||
|     handler.Run(async context => | ||||
| @@ -121,6 +234,8 @@ app.UseExceptionHandler(static handler => | ||||
|     }); | ||||
| }); | ||||
|  | ||||
| app.UseRateLimiter(); | ||||
|  | ||||
| app.UseAuthentication(); | ||||
| app.UseAuthorization(); | ||||
|  | ||||
| @@ -156,7 +271,8 @@ app.MapPost("/api/v1/rekor/entries", async (AttestorSubmissionRequest request, H | ||||
|         }); | ||||
|     } | ||||
| }) | ||||
| .RequireAuthorization("attestor:write"); | ||||
| .RequireAuthorization("attestor:write") | ||||
| .RequireRateLimiting("attestor-submissions"); | ||||
|  | ||||
| app.MapGet("/api/v1/rekor/entries/{uuid}", async (string uuid, bool? refresh, IAttestorVerificationService verificationService, CancellationToken cancellationToken) => | ||||
| { | ||||
| @@ -170,6 +286,7 @@ app.MapGet("/api/v1/rekor/entries/{uuid}", async (string uuid, bool? refresh, IA | ||||
|     { | ||||
|         uuid = entry.RekorUuid, | ||||
|         index = entry.Index, | ||||
|         backend = entry.Log.Backend, | ||||
|         proof = entry.Proof is null ? null : new | ||||
|         { | ||||
|             checkpoint = entry.Proof.Checkpoint is null ? null : new | ||||
| @@ -187,6 +304,30 @@ app.MapGet("/api/v1/rekor/entries/{uuid}", async (string uuid, bool? refresh, IA | ||||
|         }, | ||||
|         logURL = entry.Log.Url, | ||||
|         status = entry.Status, | ||||
|         mirror = entry.Mirror is null ? null : new | ||||
|         { | ||||
|             backend = entry.Mirror.Backend, | ||||
|             uuid = entry.Mirror.Uuid, | ||||
|             index = entry.Mirror.Index, | ||||
|             logURL = entry.Mirror.Url, | ||||
|             status = entry.Mirror.Status, | ||||
|             proof = entry.Mirror.Proof is null ? null : new | ||||
|             { | ||||
|                 checkpoint = entry.Mirror.Proof.Checkpoint is null ? null : new | ||||
|                 { | ||||
|                     origin = entry.Mirror.Proof.Checkpoint.Origin, | ||||
|                     size = entry.Mirror.Proof.Checkpoint.Size, | ||||
|                     rootHash = entry.Mirror.Proof.Checkpoint.RootHash, | ||||
|                     timestamp = entry.Mirror.Proof.Checkpoint.Timestamp?.ToString("O") | ||||
|                 }, | ||||
|                 inclusion = entry.Mirror.Proof.Inclusion is null ? null : new | ||||
|                 { | ||||
|                     leafHash = entry.Mirror.Proof.Inclusion.LeafHash, | ||||
|                     path = entry.Mirror.Proof.Inclusion.Path | ||||
|                 } | ||||
|             }, | ||||
|             error = entry.Mirror.Error | ||||
|         }, | ||||
|         artifact = new | ||||
|         { | ||||
|             sha256 = entry.Artifact.Sha256, | ||||
| @@ -232,3 +373,33 @@ static SubmissionContext BuildSubmissionContext(ClaimsPrincipal user, X509Certif | ||||
|         MtlsThumbprint = certificate.Thumbprint | ||||
|     }; | ||||
| } | ||||
|  | ||||
| static List<X509Certificate2> LoadClientCertificateAuthorities(string? path) | ||||
| { | ||||
|     var certificates = new List<X509Certificate2>(); | ||||
|  | ||||
|     if (string.IsNullOrWhiteSpace(path)) | ||||
|     { | ||||
|         return certificates; | ||||
|     } | ||||
|  | ||||
|     try | ||||
|     { | ||||
|         if (!File.Exists(path)) | ||||
|         { | ||||
|             Log.Warning("Client CA bundle '{Path}' not found", path); | ||||
|             return certificates; | ||||
|         } | ||||
|  | ||||
|         var collection = new X509Certificate2Collection(); | ||||
|         collection.ImportFromPemFile(path); | ||||
|  | ||||
|         certificates.AddRange(collection.Cast<X509Certificate2>()); | ||||
|     } | ||||
|     catch (Exception ex) when (ex is IOException or CryptographicException) | ||||
|     { | ||||
|         Log.Warning(ex, "Failed to load client CA bundle from {Path}", path); | ||||
|     } | ||||
|  | ||||
|     return certificates; | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user