Add post-quantum cryptography support with PqSoftCryptoProvider
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
wine-csp-build / Build Wine CSP Image (push) Has been cancelled
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
wine-csp-build / Build Wine CSP Image (push) Has been cancelled
- Implemented PqSoftCryptoProvider for software-only post-quantum algorithms (Dilithium3, Falcon512) using BouncyCastle. - Added PqSoftProviderOptions and PqSoftKeyOptions for configuration. - Created unit tests for Dilithium3 and Falcon512 signing and verification. - Introduced EcdsaPolicyCryptoProvider for compliance profiles (FIPS/eIDAS) with explicit allow-lists. - Added KcmvpHashOnlyProvider for KCMVP baseline compliance. - Updated project files and dependencies for new libraries and testing frameworks.
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tenancy;
|
||||
|
||||
/// <summary>
|
||||
/// Middleware that extracts tenant context from request headers and validates tenant access.
|
||||
/// Per RLS design at docs/modules/policy/prep/tenant-rls.md.
|
||||
/// </summary>
|
||||
public sealed partial class TenantContextMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly TenantContextOptions _options;
|
||||
private readonly ILogger<TenantContextMiddleware> _logger;
|
||||
|
||||
// Valid tenant/project ID pattern: alphanumeric, dashes, underscores
|
||||
[GeneratedRegex("^[a-zA-Z0-9_-]+$", RegexOptions.Compiled)]
|
||||
private static partial Regex ValidIdPattern();
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
public TenantContextMiddleware(
|
||||
RequestDelegate next,
|
||||
IOptions<TenantContextOptions> options,
|
||||
ILogger<TenantContextMiddleware> logger)
|
||||
{
|
||||
_next = next ?? throw new ArgumentNullException(nameof(next));
|
||||
_options = options?.Value ?? new TenantContextOptions();
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context, ITenantContextAccessor tenantContextAccessor)
|
||||
{
|
||||
// Skip tenant validation for excluded paths
|
||||
if (!_options.Enabled || IsExcludedPath(context.Request.Path))
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
var validationResult = ValidateTenantContext(context);
|
||||
|
||||
if (!validationResult.IsValid)
|
||||
{
|
||||
await WriteTenantErrorResponse(context, validationResult);
|
||||
return;
|
||||
}
|
||||
|
||||
// Set tenant context for the request
|
||||
tenantContextAccessor.TenantContext = validationResult.Context;
|
||||
|
||||
using (_logger.BeginScope(new Dictionary<string, object?>
|
||||
{
|
||||
["tenant_id"] = validationResult.Context?.TenantId,
|
||||
["project_id"] = validationResult.Context?.ProjectId
|
||||
}))
|
||||
{
|
||||
await _next(context);
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsExcludedPath(PathString path)
|
||||
{
|
||||
var pathValue = path.Value ?? string.Empty;
|
||||
return _options.ExcludedPaths.Any(excluded =>
|
||||
pathValue.StartsWith(excluded, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private TenantValidationResult ValidateTenantContext(HttpContext context)
|
||||
{
|
||||
// Extract tenant header
|
||||
var tenantHeader = context.Request.Headers[TenantContextConstants.TenantHeader].FirstOrDefault();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tenantHeader))
|
||||
{
|
||||
if (_options.RequireTenantHeader)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Missing required {Header} header for {Path}",
|
||||
TenantContextConstants.TenantHeader,
|
||||
context.Request.Path);
|
||||
|
||||
return TenantValidationResult.Failure(
|
||||
TenantContextConstants.MissingTenantHeaderErrorCode,
|
||||
$"The {TenantContextConstants.TenantHeader} header is required.");
|
||||
}
|
||||
|
||||
// Use default tenant ID when header is not required
|
||||
tenantHeader = TenantContextConstants.DefaultTenantId;
|
||||
}
|
||||
|
||||
// Validate tenant ID format
|
||||
if (!IsValidTenantId(tenantHeader))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Invalid tenant ID format: {TenantId}",
|
||||
tenantHeader);
|
||||
|
||||
return TenantValidationResult.Failure(
|
||||
TenantContextConstants.InvalidTenantIdErrorCode,
|
||||
"Invalid tenant ID format. Must be alphanumeric with dashes and underscores.");
|
||||
}
|
||||
|
||||
// Extract project header (optional)
|
||||
var projectHeader = context.Request.Headers[TenantContextConstants.ProjectHeader].FirstOrDefault();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(projectHeader) && !IsValidProjectId(projectHeader))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Invalid project ID format: {ProjectId}",
|
||||
projectHeader);
|
||||
|
||||
return TenantValidationResult.Failure(
|
||||
TenantContextConstants.InvalidTenantIdErrorCode,
|
||||
"Invalid project ID format. Must be alphanumeric with dashes and underscores.");
|
||||
}
|
||||
|
||||
// Determine write permission from scopes/claims
|
||||
var canWrite = DetermineWritePermission(context);
|
||||
|
||||
// Extract actor ID
|
||||
var actorId = ExtractActorId(context);
|
||||
|
||||
var tenantContext = TenantContext.ForTenant(
|
||||
tenantHeader,
|
||||
string.IsNullOrWhiteSpace(projectHeader) ? null : projectHeader,
|
||||
canWrite,
|
||||
actorId);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Tenant context established: tenant={TenantId}, project={ProjectId}, canWrite={CanWrite}, actor={ActorId}",
|
||||
tenantContext.TenantId,
|
||||
tenantContext.ProjectId ?? "(none)",
|
||||
tenantContext.CanWrite,
|
||||
tenantContext.ActorId ?? "(anonymous)");
|
||||
|
||||
return TenantValidationResult.Success(tenantContext);
|
||||
}
|
||||
|
||||
private bool IsValidTenantId(string tenantId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (tenantId.Length > _options.MaxTenantIdLength)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return ValidIdPattern().IsMatch(tenantId);
|
||||
}
|
||||
|
||||
private bool IsValidProjectId(string projectId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(projectId))
|
||||
{
|
||||
return true; // Project ID is optional
|
||||
}
|
||||
|
||||
if (projectId.Length > _options.MaxProjectIdLength)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return ValidIdPattern().IsMatch(projectId);
|
||||
}
|
||||
|
||||
private static bool DetermineWritePermission(HttpContext context)
|
||||
{
|
||||
var user = context.User;
|
||||
if (user?.Identity?.IsAuthenticated != true)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for write-related scopes
|
||||
var hasWriteScope = user.Claims.Any(c =>
|
||||
c.Type == "scope" &&
|
||||
(c.Value.Contains("policy:write", StringComparison.OrdinalIgnoreCase) ||
|
||||
c.Value.Contains("policy:edit", StringComparison.OrdinalIgnoreCase) ||
|
||||
c.Value.Contains("policy:activate", StringComparison.OrdinalIgnoreCase)));
|
||||
|
||||
if (hasWriteScope)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for admin role
|
||||
var hasAdminRole = user.IsInRole("admin") ||
|
||||
user.IsInRole("policy-admin") ||
|
||||
user.HasClaim("role", "admin") ||
|
||||
user.HasClaim("role", "policy-admin");
|
||||
|
||||
return hasAdminRole;
|
||||
}
|
||||
|
||||
private static string? ExtractActorId(HttpContext context)
|
||||
{
|
||||
var user = context.User;
|
||||
|
||||
// Try standard claims
|
||||
var actorId = user?.FindFirst(ClaimTypes.NameIdentifier)?.Value
|
||||
?? user?.FindFirst(ClaimTypes.Upn)?.Value
|
||||
?? user?.FindFirst("sub")?.Value
|
||||
?? user?.FindFirst("client_id")?.Value;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(actorId))
|
||||
{
|
||||
return actorId;
|
||||
}
|
||||
|
||||
// Fall back to header
|
||||
if (context.Request.Headers.TryGetValue("X-StellaOps-Actor", out var header) &&
|
||||
!string.IsNullOrWhiteSpace(header))
|
||||
{
|
||||
return header.ToString();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static async Task WriteTenantErrorResponse(HttpContext context, TenantValidationResult result)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status400BadRequest;
|
||||
context.Response.ContentType = "application/json";
|
||||
|
||||
var errorResponse = new TenantErrorResponse(
|
||||
result.ErrorCode ?? "UNKNOWN_ERROR",
|
||||
result.ErrorMessage ?? "An unknown error occurred.",
|
||||
context.Request.Path.Value ?? "/");
|
||||
|
||||
await context.Response.WriteAsync(
|
||||
JsonSerializer.Serialize(errorResponse, JsonOptions));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Error response for tenant validation failures.
|
||||
/// </summary>
|
||||
internal sealed record TenantErrorResponse(
|
||||
string ErrorCode,
|
||||
string Message,
|
||||
string Path);
|
||||
Reference in New Issue
Block a user