Add channel test providers for Email, Slack, Teams, and Webhook
- 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