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

- 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:
StellaOps Bot
2025-12-07 15:04:19 +02:00
parent 862bb6ed80
commit 98e6b76584
119 changed files with 11436 additions and 1732 deletions

View File

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

View File

@@ -0,0 +1,233 @@
namespace StellaOps.Policy.Engine.Tenancy;
/// <summary>
/// Constants for tenant context headers and GUCs (PostgreSQL Grand Unified Configuration).
/// Per RLS design at docs/modules/policy/prep/tenant-rls.md.
/// </summary>
public static class TenantContextConstants
{
/// <summary>
/// HTTP header for tenant ID (mandatory).
/// </summary>
public const string TenantHeader = "X-Stella-Tenant";
/// <summary>
/// HTTP header for project ID (optional).
/// </summary>
public const string ProjectHeader = "X-Stella-Project";
/// <summary>
/// PostgreSQL GUC for tenant ID.
/// </summary>
public const string TenantGuc = "app.tenant_id";
/// <summary>
/// PostgreSQL GUC for project ID.
/// </summary>
public const string ProjectGuc = "app.project_id";
/// <summary>
/// PostgreSQL GUC for write permission.
/// </summary>
public const string CanWriteGuc = "app.can_write";
/// <summary>
/// Default tenant ID for legacy data migration.
/// </summary>
public const string DefaultTenantId = "public";
/// <summary>
/// Error code for missing tenant header (deterministic).
/// </summary>
public const string MissingTenantHeaderErrorCode = "POLICY_TENANT_HEADER_REQUIRED";
/// <summary>
/// Error code for invalid tenant ID format.
/// </summary>
public const string InvalidTenantIdErrorCode = "POLICY_TENANT_ID_INVALID";
/// <summary>
/// Error code for tenant access denied (403).
/// </summary>
public const string TenantAccessDeniedErrorCode = "POLICY_TENANT_ACCESS_DENIED";
}
/// <summary>
/// Represents the current tenant and project context for a request.
/// </summary>
public sealed record TenantContext
{
/// <summary>
/// The tenant ID for the current request.
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// The project ID for the current request (optional; null for tenant-wide operations).
/// </summary>
public string? ProjectId { get; init; }
/// <summary>
/// Whether the current request has write permission.
/// </summary>
public bool CanWrite { get; init; }
/// <summary>
/// The actor ID (user or system) making the request.
/// </summary>
public string? ActorId { get; init; }
/// <summary>
/// Timestamp when the context was created.
/// </summary>
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
/// <summary>
/// Creates a tenant context for a specific tenant.
/// </summary>
public static TenantContext ForTenant(string tenantId, string? projectId = null, bool canWrite = false, string? actorId = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
return new TenantContext
{
TenantId = tenantId,
ProjectId = projectId,
CanWrite = canWrite,
ActorId = actorId,
CreatedAt = DateTimeOffset.UtcNow
};
}
}
/// <summary>
/// Options for tenant context middleware configuration.
/// </summary>
public sealed class TenantContextOptions
{
/// <summary>
/// Configuration section name.
/// </summary>
public const string SectionName = "PolicyEngine:Tenancy";
/// <summary>
/// Whether tenant validation is enabled (default: true).
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Whether to require tenant header on all endpoints (default: true).
/// When false, missing tenant header defaults to <see cref="TenantContextConstants.DefaultTenantId"/>.
/// </summary>
public bool RequireTenantHeader { get; set; } = true;
/// <summary>
/// Paths to exclude from tenant validation (e.g., health checks).
/// </summary>
public List<string> ExcludedPaths { get; set; } = new()
{
"/healthz",
"/readyz",
"/.well-known"
};
/// <summary>
/// Maximum length for tenant ID (default: 256).
/// </summary>
public int MaxTenantIdLength { get; set; } = 256;
/// <summary>
/// Maximum length for project ID (default: 256).
/// </summary>
public int MaxProjectIdLength { get; set; } = 256;
/// <summary>
/// Whether to allow multi-tenant queries (default: false).
/// When true, users with appropriate scopes can query across tenants.
/// </summary>
public bool AllowMultiTenantQueries { get; set; } = false;
}
/// <summary>
/// Interface for accessing the current tenant context.
/// </summary>
public interface ITenantContextAccessor
{
/// <summary>
/// Gets or sets the current tenant context.
/// </summary>
TenantContext? TenantContext { get; set; }
}
/// <summary>
/// Default implementation of <see cref="ITenantContextAccessor"/> using AsyncLocal.
/// </summary>
public sealed class TenantContextAccessor : ITenantContextAccessor
{
private static readonly AsyncLocal<TenantContextHolder> _tenantContextCurrent = new();
/// <inheritdoc />
public TenantContext? TenantContext
{
get => _tenantContextCurrent.Value?.Context;
set
{
var holder = _tenantContextCurrent.Value;
if (holder is not null)
{
// Clear current context trapped in the AsyncLocals, as its done.
holder.Context = null;
}
if (value is not null)
{
// Use an object to hold the context in the AsyncLocal,
// so it can be cleared in all ExecutionContexts when its cleared.
_tenantContextCurrent.Value = new TenantContextHolder { Context = value };
}
}
}
private sealed class TenantContextHolder
{
public TenantContext? Context;
}
}
/// <summary>
/// Result of tenant context validation.
/// </summary>
public sealed record TenantValidationResult
{
/// <summary>
/// Whether the validation succeeded.
/// </summary>
public bool IsValid { get; init; }
/// <summary>
/// Error code if validation failed.
/// </summary>
public string? ErrorCode { get; init; }
/// <summary>
/// Error message if validation failed.
/// </summary>
public string? ErrorMessage { get; init; }
/// <summary>
/// The validated tenant context if successful.
/// </summary>
public TenantContext? Context { get; init; }
/// <summary>
/// Creates a successful validation result.
/// </summary>
public static TenantValidationResult Success(TenantContext context) =>
new() { IsValid = true, Context = context };
/// <summary>
/// Creates a failed validation result.
/// </summary>
public static TenantValidationResult Failure(string errorCode, string errorMessage) =>
new() { IsValid = false, ErrorCode = errorCode, ErrorMessage = errorMessage };
}

View File

@@ -0,0 +1,109 @@
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace StellaOps.Policy.Engine.Tenancy;
/// <summary>
/// Extension methods for registering tenant context services.
/// </summary>
public static class TenantContextServiceCollectionExtensions
{
/// <summary>
/// Adds tenant context services to the service collection.
/// </summary>
public static IServiceCollection AddTenantContext(this IServiceCollection services)
{
services.TryAddSingleton<ITenantContextAccessor, TenantContextAccessor>();
return services;
}
/// <summary>
/// Adds tenant context services with configuration.
/// </summary>
public static IServiceCollection AddTenantContext(
this IServiceCollection services,
Action<TenantContextOptions> configure)
{
services.Configure(configure);
return services.AddTenantContext();
}
/// <summary>
/// Adds tenant context services with configuration from configuration section.
/// </summary>
public static IServiceCollection AddTenantContext(
this IServiceCollection services,
IConfiguration configuration,
string sectionName = TenantContextOptions.SectionName)
{
services.Configure<TenantContextOptions>(configuration.GetSection(sectionName));
return services.AddTenantContext();
}
}
/// <summary>
/// Extension methods for configuring tenant context middleware.
/// </summary>
public static class TenantContextApplicationBuilderExtensions
{
/// <summary>
/// Adds the tenant context middleware to the application pipeline.
/// This middleware extracts tenant/project headers and validates tenant access.
/// </summary>
public static IApplicationBuilder UseTenantContext(this IApplicationBuilder app)
{
return app.UseMiddleware<TenantContextMiddleware>();
}
}
/// <summary>
/// Extension methods for endpoint routing to apply tenant requirements.
/// </summary>
public static class TenantContextEndpointExtensions
{
/// <summary>
/// Requires tenant context for the endpoint group.
/// </summary>
public static RouteGroupBuilder RequireTenantContext(this RouteGroupBuilder group)
{
group.AddEndpointFilter<TenantContextEndpointFilter>();
return group;
}
/// <summary>
/// Adds a tenant context requirement filter to a route handler.
/// </summary>
public static RouteHandlerBuilder RequireTenantContext(this RouteHandlerBuilder builder)
{
builder.AddEndpointFilter<TenantContextEndpointFilter>();
return builder;
}
}
/// <summary>
/// Endpoint filter that validates tenant context is present.
/// </summary>
internal sealed class TenantContextEndpointFilter : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
var tenantAccessor = context.HttpContext.RequestServices
.GetService<ITenantContextAccessor>();
if (tenantAccessor?.TenantContext is null)
{
return Results.Problem(
title: "Tenant context required",
detail: $"The {TenantContextConstants.TenantHeader} header is required for this endpoint.",
statusCode: StatusCodes.Status400BadRequest,
extensions: new Dictionary<string, object?>
{
["error_code"] = TenantContextConstants.MissingTenantHeaderErrorCode
});
}
return await next(context);
}
}