Add channel test providers for Email, Slack, Teams, and Webhook
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:
2025-10-19 23:29:34 +03:00
parent 8e7ce55542
commit 5fd4032c7c
239 changed files with 17245 additions and 3155 deletions

View File

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