part #2
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Aoc;
|
||||
|
||||
namespace StellaOps.Aoc.AspNetCore.Routing;
|
||||
|
||||
public sealed partial class AocGuardEndpointFilter<TRequest>
|
||||
{
|
||||
private AocGuardOptions ResolveOptions()
|
||||
{
|
||||
if (_guardOptions is not null)
|
||||
{
|
||||
return _guardOptions;
|
||||
}
|
||||
|
||||
return _options?.Value ?? AocGuardOptions.Default;
|
||||
}
|
||||
|
||||
private static bool TryGetArgument(EndpointFilterInvocationContext context, out TRequest argument)
|
||||
{
|
||||
for (var i = 0; i < context.Arguments.Count; i++)
|
||||
{
|
||||
if (context.Arguments[i] is TRequest typedArgument)
|
||||
{
|
||||
argument = typedArgument;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
argument = default!;
|
||||
return false;
|
||||
}
|
||||
|
||||
private void ValidatePayload(object payload, IAocGuard guard, AocGuardOptions options)
|
||||
{
|
||||
if (payload is JsonElement jsonElement)
|
||||
{
|
||||
guard.ValidateOrThrow(jsonElement, options);
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload is JsonDocument jsonDocument)
|
||||
{
|
||||
using (jsonDocument)
|
||||
{
|
||||
guard.ValidateOrThrow(jsonDocument.RootElement, options);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var element = JsonSerializer.SerializeToElement(payload, _serializerOptions);
|
||||
guard.ValidateOrThrow(element, options);
|
||||
}
|
||||
}
|
||||
@@ -1,32 +1,38 @@
|
||||
|
||||
using HttpResults = Microsoft.AspNetCore.Http.Results;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Aoc;
|
||||
using StellaOps.Aoc.AspNetCore.Results;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Aoc;
|
||||
using StellaOps.Aoc.AspNetCore.Results;
|
||||
using HttpResults = Microsoft.AspNetCore.Http.Results;
|
||||
|
||||
namespace StellaOps.Aoc.AspNetCore.Routing;
|
||||
|
||||
public sealed class AocGuardEndpointFilter<TRequest> : IEndpointFilter
|
||||
public sealed partial class AocGuardEndpointFilter<TRequest> : IEndpointFilter
|
||||
{
|
||||
private readonly IAocGuard _guard;
|
||||
private readonly ILogger<AocGuardEndpointFilter<TRequest>> _logger;
|
||||
private readonly Func<TRequest, IEnumerable<object?>> _payloadSelector;
|
||||
private readonly JsonSerializerOptions _serializerOptions;
|
||||
private readonly IOptions<AocGuardOptions>? _options;
|
||||
private readonly AocGuardOptions? _guardOptions;
|
||||
|
||||
public AocGuardEndpointFilter(
|
||||
IAocGuard guard,
|
||||
ILogger<AocGuardEndpointFilter<TRequest>> logger,
|
||||
Func<TRequest, IEnumerable<object?>> payloadSelector,
|
||||
JsonSerializerOptions? serializerOptions,
|
||||
IOptions<AocGuardOptions>? options,
|
||||
AocGuardOptions? guardOptions)
|
||||
{
|
||||
_guard = guard ?? throw new ArgumentNullException(nameof(guard));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_payloadSelector = payloadSelector ?? throw new ArgumentNullException(nameof(payloadSelector));
|
||||
_serializerOptions = serializerOptions ?? new JsonSerializerOptions(JsonSerializerDefaults.Web);
|
||||
_options = options;
|
||||
_guardOptions = guardOptions;
|
||||
}
|
||||
|
||||
@@ -39,8 +45,7 @@ public sealed class AocGuardEndpointFilter<TRequest> : IEndpointFilter
|
||||
|
||||
if (!TryGetArgument(context, out var request))
|
||||
{
|
||||
var logger = context.HttpContext.RequestServices.GetService<ILogger<AocGuardEndpointFilter<TRequest>>>();
|
||||
logger?.LogWarning("AOC guard filter did not find request argument of type {RequestType}.", typeof(TRequest).FullName);
|
||||
_logger.LogWarning("AOC guard filter did not find request argument of type {RequestType}.", typeof(TRequest).FullName);
|
||||
return HttpResults.Problem(
|
||||
statusCode: StatusCodes.Status400BadRequest,
|
||||
title: "AOC guard payload missing",
|
||||
@@ -54,16 +59,14 @@ public sealed class AocGuardEndpointFilter<TRequest> : IEndpointFilter
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var logger = context.HttpContext.RequestServices.GetService<ILogger<AocGuardEndpointFilter<TRequest>>>();
|
||||
logger?.LogError(ex, "AOC guard payload selector failed for {RequestType}.", typeof(TRequest).FullName);
|
||||
_logger.LogError(ex, "AOC guard payload selector failed for {RequestType}.", typeof(TRequest).FullName);
|
||||
return HttpResults.Problem(
|
||||
statusCode: StatusCodes.Status400BadRequest,
|
||||
title: "AOC guard payload selector failed",
|
||||
detail: "Request payload could not be extracted for validation.");
|
||||
}
|
||||
|
||||
var guard = context.HttpContext.RequestServices.GetRequiredService<IAocGuard>();
|
||||
var options = ResolveOptions(context.HttpContext.RequestServices);
|
||||
var options = ResolveOptions();
|
||||
|
||||
foreach (var payload in payloads)
|
||||
{
|
||||
@@ -74,7 +77,7 @@ public sealed class AocGuardEndpointFilter<TRequest> : IEndpointFilter
|
||||
|
||||
try
|
||||
{
|
||||
ValidatePayload(payload, guard, options);
|
||||
ValidatePayload(payload, _guard, options);
|
||||
}
|
||||
catch (AocGuardException exception)
|
||||
{
|
||||
@@ -82,8 +85,7 @@ public sealed class AocGuardEndpointFilter<TRequest> : IEndpointFilter
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var logger = context.HttpContext.RequestServices.GetService<ILogger<AocGuardEndpointFilter<TRequest>>>();
|
||||
logger?.LogError(ex, "AOC guard payload validation failed for {RequestType}.", typeof(TRequest).FullName);
|
||||
_logger.LogError(ex, "AOC guard payload validation failed for {RequestType}.", typeof(TRequest).FullName);
|
||||
return HttpResults.Problem(
|
||||
statusCode: StatusCodes.Status400BadRequest,
|
||||
title: "AOC guard payload invalid",
|
||||
@@ -93,51 +95,4 @@ public sealed class AocGuardEndpointFilter<TRequest> : IEndpointFilter
|
||||
|
||||
return await next(context).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private AocGuardOptions ResolveOptions(IServiceProvider services)
|
||||
{
|
||||
if (_guardOptions is not null)
|
||||
{
|
||||
return _guardOptions;
|
||||
}
|
||||
|
||||
var options = services.GetService<IOptions<AocGuardOptions>>();
|
||||
return options?.Value ?? AocGuardOptions.Default;
|
||||
}
|
||||
|
||||
private static bool TryGetArgument(EndpointFilterInvocationContext context, out TRequest argument)
|
||||
{
|
||||
for (var i = 0; i < context.Arguments.Count; i++)
|
||||
{
|
||||
if (context.Arguments[i] is TRequest typedArgument)
|
||||
{
|
||||
argument = typedArgument;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
argument = default!;
|
||||
return false;
|
||||
}
|
||||
|
||||
private void ValidatePayload(object payload, IAocGuard guard, AocGuardOptions options)
|
||||
{
|
||||
if (payload is JsonElement jsonElement)
|
||||
{
|
||||
guard.ValidateOrThrow(jsonElement, options);
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload is JsonDocument jsonDocument)
|
||||
{
|
||||
using (jsonDocument)
|
||||
{
|
||||
guard.ValidateOrThrow(jsonDocument.RootElement, options);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var element = JsonSerializer.SerializeToElement(payload, _serializerOptions);
|
||||
guard.ValidateOrThrow(element, options);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Aoc;
|
||||
|
||||
namespace StellaOps.Aoc.AspNetCore.Routing;
|
||||
|
||||
@@ -28,7 +31,17 @@ public static class AocGuardEndpointFilterExtensions
|
||||
{
|
||||
endpointBuilder.FilterFactories.Add((routeContext, next) =>
|
||||
{
|
||||
var filter = new AocGuardEndpointFilter<TRequest>(payloadSelector, serializerOptions, guardOptions);
|
||||
var services = routeContext.ApplicationServices;
|
||||
var guard = services.GetRequiredService<IAocGuard>();
|
||||
var logger = services.GetRequiredService<ILogger<AocGuardEndpointFilter<TRequest>>>();
|
||||
var options = services.GetService<IOptions<AocGuardOptions>>();
|
||||
var filter = new AocGuardEndpointFilter<TRequest>(
|
||||
guard,
|
||||
logger,
|
||||
payloadSelector,
|
||||
serializerOptions,
|
||||
options,
|
||||
guardOptions);
|
||||
return invocationContext => filter.InvokeAsync(invocationContext, next);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,3 +8,5 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0039-M | DONE | Revalidated maintainability for StellaOps.Aoc.AspNetCore (2026-01-06). |
|
||||
| AUDIT-0039-T | DONE | Revalidated test coverage for StellaOps.Aoc.AspNetCore (2026-01-06). |
|
||||
| AUDIT-0039-A | DONE | Hardened guard filter error handling and added tests. |
|
||||
| REMED-06 | DONE | SOLID review notes refreshed 2026-02-04. |
|
||||
| REMED-08 | DONE | AocGuardEndpointFilter uses constructor injection with helper partials; service locator removed; `dotnet test src/Aoc/__Tests/StellaOps.Aoc.AspNetCore.Tests/StellaOps.Aoc.AspNetCore.Tests.csproj` passed (8 tests) 2026-02-04. |
|
||||
|
||||
@@ -40,6 +40,6 @@ public sealed record AocError(
|
||||
throw new ArgumentNullException(nameof(exception));
|
||||
}
|
||||
|
||||
return FromResult(exception.Result, message);
|
||||
return FromResult(new AocGuardResult(false, exception.Violations), message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace StellaOps.Aoc;
|
||||
|
||||
public static class AocForbiddenKeys
|
||||
{
|
||||
private static readonly ImmutableHashSet<string> ForbiddenTopLevel = new[]
|
||||
private static readonly ImmutableHashSet<string> _forbiddenTopLevel = new[]
|
||||
{
|
||||
"severity",
|
||||
"cvss",
|
||||
@@ -18,7 +18,7 @@ public static class AocForbiddenKeys
|
||||
// handled separately by IsDerivedField() and produce ERR_AOC_006
|
||||
}.ToImmutableHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public static bool IsForbiddenTopLevel(string propertyName) => ForbiddenTopLevel.Contains(propertyName);
|
||||
public static bool IsForbiddenTopLevel(string propertyName) => _forbiddenTopLevel.Contains(propertyName);
|
||||
|
||||
public static bool IsDerivedField(string propertyName)
|
||||
=> propertyName.StartsWith("effective_", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace StellaOps.Aoc;
|
||||
|
||||
public sealed record AocGuardOptions
|
||||
{
|
||||
private static readonly ImmutableHashSet<string> DefaultRequiredTopLevel = new[]
|
||||
private static readonly ImmutableHashSet<string> _defaultRequiredTopLevel = new[]
|
||||
{
|
||||
"tenant",
|
||||
"source",
|
||||
@@ -14,7 +14,7 @@ public sealed record AocGuardOptions
|
||||
"linkset",
|
||||
}.ToImmutableHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private static readonly ImmutableHashSet<string> DefaultAllowedTopLevel = DefaultRequiredTopLevel
|
||||
private static readonly ImmutableHashSet<string> _defaultAllowedTopLevel = _defaultRequiredTopLevel
|
||||
.Union(new[]
|
||||
{
|
||||
"_id",
|
||||
@@ -33,12 +33,12 @@ public sealed record AocGuardOptions
|
||||
|
||||
public static AocGuardOptions Default { get; } = new();
|
||||
|
||||
public ImmutableHashSet<string> RequiredTopLevelFields { get; init; } = DefaultRequiredTopLevel;
|
||||
public ImmutableHashSet<string> RequiredTopLevelFields { get; init; } = _defaultRequiredTopLevel;
|
||||
|
||||
/// <summary>
|
||||
/// Optional allowlist for top-level fields. Unknown fields trigger ERR_AOC_007.
|
||||
/// </summary>
|
||||
public ImmutableHashSet<string> AllowedTopLevelFields { get; init; } = DefaultAllowedTopLevel;
|
||||
public ImmutableHashSet<string> AllowedTopLevelFields { get; init; } = _defaultAllowedTopLevel;
|
||||
|
||||
/// <summary>
|
||||
/// When true, signature metadata is required under upstream.signature.
|
||||
|
||||
@@ -14,21 +14,3 @@ public enum AocViolationCode
|
||||
InvalidTenant,
|
||||
InvalidSignatureMetadata,
|
||||
}
|
||||
|
||||
public static class AocViolationCodeExtensions
|
||||
{
|
||||
public static string ToErrorCode(this AocViolationCode code) => code switch
|
||||
{
|
||||
AocViolationCode.ForbiddenField => "ERR_AOC_001",
|
||||
AocViolationCode.MergeAttempt => "ERR_AOC_002",
|
||||
AocViolationCode.IdempotencyViolation => "ERR_AOC_003",
|
||||
AocViolationCode.MissingProvenance => "ERR_AOC_004",
|
||||
AocViolationCode.SignatureInvalid => "ERR_AOC_005",
|
||||
AocViolationCode.DerivedFindingDetected => "ERR_AOC_006",
|
||||
AocViolationCode.UnknownField => "ERR_AOC_007",
|
||||
AocViolationCode.MissingRequiredField => "ERR_AOC_008",
|
||||
AocViolationCode.InvalidTenant => "ERR_AOC_009",
|
||||
AocViolationCode.InvalidSignatureMetadata => "ERR_AOC_010",
|
||||
_ => "ERR_AOC_000",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace StellaOps.Aoc;
|
||||
|
||||
public static class AocViolationCodeExtensions
|
||||
{
|
||||
public static string ToErrorCode(this AocViolationCode code) => code switch
|
||||
{
|
||||
AocViolationCode.ForbiddenField => "ERR_AOC_001",
|
||||
AocViolationCode.MergeAttempt => "ERR_AOC_002",
|
||||
AocViolationCode.IdempotencyViolation => "ERR_AOC_003",
|
||||
AocViolationCode.MissingProvenance => "ERR_AOC_004",
|
||||
AocViolationCode.SignatureInvalid => "ERR_AOC_005",
|
||||
AocViolationCode.DerivedFindingDetected => "ERR_AOC_006",
|
||||
AocViolationCode.UnknownField => "ERR_AOC_007",
|
||||
AocViolationCode.MissingRequiredField => "ERR_AOC_008",
|
||||
AocViolationCode.InvalidTenant => "ERR_AOC_009",
|
||||
AocViolationCode.InvalidSignatureMetadata => "ERR_AOC_010",
|
||||
_ => "ERR_AOC_000",
|
||||
};
|
||||
}
|
||||
45
src/Aoc/__Libraries/StellaOps.Aoc/AocWriteGuard.Base64.cs
Normal file
45
src/Aoc/__Libraries/StellaOps.Aoc/AocWriteGuard.Base64.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Aoc;
|
||||
|
||||
public sealed partial class AocWriteGuard
|
||||
{
|
||||
private static bool IsBase64Payload(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (TryDecodeBase64(value))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var normalized = value.Replace('-', '+').Replace('_', '/');
|
||||
switch (normalized.Length % 4)
|
||||
{
|
||||
case 2:
|
||||
normalized += "==";
|
||||
break;
|
||||
case 3:
|
||||
normalized += "=";
|
||||
break;
|
||||
}
|
||||
|
||||
return TryDecodeBase64(normalized);
|
||||
}
|
||||
|
||||
private static bool TryDecodeBase64(string value)
|
||||
{
|
||||
try
|
||||
{
|
||||
Convert.FromBase64String(value);
|
||||
return true;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
43
src/Aoc/__Libraries/StellaOps.Aoc/AocWriteGuard.Content.cs
Normal file
43
src/Aoc/__Libraries/StellaOps.Aoc/AocWriteGuard.Content.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Aoc;
|
||||
|
||||
public sealed partial class AocWriteGuard
|
||||
{
|
||||
private static void ValidateContent(
|
||||
JsonElement document,
|
||||
ImmutableArray<AocViolation>.Builder violations)
|
||||
{
|
||||
if (document.TryGetProperty("content", out var content) && content.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
if (!content.TryGetProperty("raw", out var raw) || raw.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)
|
||||
{
|
||||
violations.Add(AocViolation.Create(
|
||||
AocViolationCode.MissingProvenance,
|
||||
"/content/raw",
|
||||
"Raw upstream payload must be preserved."));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
violations.Add(AocViolation.Create(
|
||||
AocViolationCode.MissingRequiredField,
|
||||
"/content",
|
||||
"Content metadata is required."));
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateLinkset(
|
||||
JsonElement document,
|
||||
ImmutableArray<AocViolation>.Builder violations)
|
||||
{
|
||||
if (!document.TryGetProperty("linkset", out var linkset) || linkset.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
violations.Add(AocViolation.Create(
|
||||
AocViolationCode.MissingRequiredField,
|
||||
"/linkset",
|
||||
"Linkset metadata is required."));
|
||||
}
|
||||
}
|
||||
}
|
||||
79
src/Aoc/__Libraries/StellaOps.Aoc/AocWriteGuard.Signature.cs
Normal file
79
src/Aoc/__Libraries/StellaOps.Aoc/AocWriteGuard.Signature.cs
Normal file
@@ -0,0 +1,79 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Aoc;
|
||||
|
||||
public sealed partial class AocWriteGuard
|
||||
{
|
||||
private static void ValidateSignature(
|
||||
JsonElement signature,
|
||||
ImmutableArray<AocViolation>.Builder violations,
|
||||
AocGuardOptions options)
|
||||
{
|
||||
if (!signature.TryGetProperty("present", out var presentElement) ||
|
||||
presentElement.ValueKind is not (JsonValueKind.True or JsonValueKind.False))
|
||||
{
|
||||
violations.Add(AocViolation.Create(
|
||||
AocViolationCode.InvalidSignatureMetadata,
|
||||
"/upstream/signature/present",
|
||||
"Signature metadata must include 'present' boolean."));
|
||||
return;
|
||||
}
|
||||
|
||||
var signaturePresent = presentElement.GetBoolean();
|
||||
|
||||
if (!signaturePresent)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!signature.TryGetProperty("format", out var formatElement) ||
|
||||
formatElement.ValueKind != JsonValueKind.String ||
|
||||
string.IsNullOrWhiteSpace(formatElement.GetString()))
|
||||
{
|
||||
violations.Add(AocViolation.Create(
|
||||
AocViolationCode.InvalidSignatureMetadata,
|
||||
"/upstream/signature/format",
|
||||
"Signature format is required when signature is present."));
|
||||
}
|
||||
else
|
||||
{
|
||||
var format = formatElement.GetString()!.Trim();
|
||||
if (options.AllowedSignatureFormats.Count > 0 &&
|
||||
!options.AllowedSignatureFormats.Contains(format))
|
||||
{
|
||||
violations.Add(AocViolation.Create(
|
||||
AocViolationCode.InvalidSignatureMetadata,
|
||||
"/upstream/signature/format",
|
||||
$"Signature format '{format}' is not permitted."));
|
||||
}
|
||||
}
|
||||
|
||||
if (!signature.TryGetProperty("sig", out var sigElement) ||
|
||||
sigElement.ValueKind != JsonValueKind.String ||
|
||||
string.IsNullOrWhiteSpace(sigElement.GetString()))
|
||||
{
|
||||
violations.Add(AocViolation.Create(
|
||||
AocViolationCode.SignatureInvalid,
|
||||
"/upstream/signature/sig",
|
||||
"Signature payload is required when signature is present."));
|
||||
}
|
||||
else if (!IsBase64Payload(sigElement.GetString()!))
|
||||
{
|
||||
violations.Add(AocViolation.Create(
|
||||
AocViolationCode.InvalidSignatureMetadata,
|
||||
"/upstream/signature/sig",
|
||||
"Signature payload must be base64 or base64url encoded."));
|
||||
}
|
||||
|
||||
if (!signature.TryGetProperty("key_id", out var keyIdElement) ||
|
||||
keyIdElement.ValueKind != JsonValueKind.String ||
|
||||
string.IsNullOrWhiteSpace(keyIdElement.GetString()))
|
||||
{
|
||||
violations.Add(AocViolation.Create(
|
||||
AocViolationCode.InvalidSignatureMetadata,
|
||||
"/upstream/signature/key_id",
|
||||
"Signature key identifier is required when signature is present."));
|
||||
}
|
||||
}
|
||||
}
|
||||
87
src/Aoc/__Libraries/StellaOps.Aoc/AocWriteGuard.TopLevel.cs
Normal file
87
src/Aoc/__Libraries/StellaOps.Aoc/AocWriteGuard.TopLevel.cs
Normal file
@@ -0,0 +1,87 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Aoc;
|
||||
|
||||
public sealed partial class AocWriteGuard
|
||||
{
|
||||
private static void ValidateTopLevelFields(
|
||||
JsonElement document,
|
||||
IEnumerable<string> allowedTopLevelFields,
|
||||
ImmutableArray<AocViolation>.Builder violations)
|
||||
{
|
||||
foreach (var property in document.EnumerateObject())
|
||||
{
|
||||
if (AocForbiddenKeys.IsForbiddenTopLevel(property.Name))
|
||||
{
|
||||
violations.Add(AocViolation.Create(
|
||||
AocViolationCode.ForbiddenField,
|
||||
$"/{property.Name}",
|
||||
$"Field '{property.Name}' is forbidden in AOC documents."));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (AocForbiddenKeys.IsDerivedField(property.Name))
|
||||
{
|
||||
violations.Add(AocViolation.Create(
|
||||
AocViolationCode.DerivedFindingDetected,
|
||||
$"/{property.Name}",
|
||||
$"Derived field '{property.Name}' must not be written during ingestion."));
|
||||
}
|
||||
|
||||
if (!allowedTopLevelFields.Contains(property.Name))
|
||||
{
|
||||
violations.Add(AocViolation.Create(
|
||||
AocViolationCode.UnknownField,
|
||||
$"/{property.Name}",
|
||||
$"Field '{property.Name}' is not allowed in AOC documents."));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateRequiredFields(
|
||||
JsonElement document,
|
||||
IEnumerable<string> requiredTopLevelFields,
|
||||
AocGuardOptions options,
|
||||
ImmutableArray<AocViolation>.Builder violations)
|
||||
{
|
||||
foreach (var required in requiredTopLevelFields.OrderBy(name => name, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
if (options.RequireTenant && string.Equals(required, "tenant", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!document.TryGetProperty(required, out var element) || element.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)
|
||||
{
|
||||
violations.Add(AocViolation.Create(
|
||||
AocViolationCode.MissingRequiredField,
|
||||
$"/{required}",
|
||||
$"Required field '{required}' is missing."));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateTenant(
|
||||
JsonElement document,
|
||||
AocGuardOptions options,
|
||||
ImmutableArray<AocViolation>.Builder violations)
|
||||
{
|
||||
if (!options.RequireTenant)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!document.TryGetProperty("tenant", out var tenantElement) ||
|
||||
tenantElement.ValueKind != JsonValueKind.String ||
|
||||
string.IsNullOrWhiteSpace(tenantElement.GetString()))
|
||||
{
|
||||
violations.Add(AocViolation.Create(
|
||||
AocViolationCode.InvalidTenant,
|
||||
"/tenant",
|
||||
"Tenant must be a non-empty string."));
|
||||
}
|
||||
}
|
||||
}
|
||||
48
src/Aoc/__Libraries/StellaOps.Aoc/AocWriteGuard.Upstream.cs
Normal file
48
src/Aoc/__Libraries/StellaOps.Aoc/AocWriteGuard.Upstream.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Aoc;
|
||||
|
||||
public sealed partial class AocWriteGuard
|
||||
{
|
||||
private static void ValidateUpstream(
|
||||
JsonElement document,
|
||||
AocGuardOptions options,
|
||||
ImmutableArray<AocViolation>.Builder violations)
|
||||
{
|
||||
if (document.TryGetProperty("upstream", out var upstream) && upstream.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
if (!upstream.TryGetProperty("content_hash", out var contentHash) ||
|
||||
contentHash.ValueKind != JsonValueKind.String ||
|
||||
string.IsNullOrWhiteSpace(contentHash.GetString()))
|
||||
{
|
||||
violations.Add(AocViolation.Create(
|
||||
AocViolationCode.MissingProvenance,
|
||||
"/upstream/content_hash",
|
||||
"Upstream content hash is required."));
|
||||
}
|
||||
|
||||
if (!upstream.TryGetProperty("signature", out var signature) || signature.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
if (options.RequireSignatureMetadata)
|
||||
{
|
||||
violations.Add(AocViolation.Create(
|
||||
AocViolationCode.MissingProvenance,
|
||||
"/upstream/signature",
|
||||
"Signature metadata is required."));
|
||||
}
|
||||
}
|
||||
else if (options.RequireSignatureMetadata)
|
||||
{
|
||||
ValidateSignature(signature, violations, options);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
violations.Add(AocViolation.Create(
|
||||
AocViolationCode.MissingRequiredField,
|
||||
"/upstream",
|
||||
"Upstream metadata is required."));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,10 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Aoc;
|
||||
|
||||
public interface IAocGuard
|
||||
{
|
||||
AocGuardResult Validate(JsonElement document, AocGuardOptions? options = null);
|
||||
}
|
||||
|
||||
public sealed class AocWriteGuard : IAocGuard
|
||||
public sealed partial class AocWriteGuard : IAocGuard
|
||||
{
|
||||
public AocGuardResult Validate(JsonElement document, AocGuardOptions? options = null)
|
||||
{
|
||||
@@ -20,174 +14,13 @@ public sealed class AocWriteGuard : IAocGuard
|
||||
var allowedTopLevelFields = (options.AllowedTopLevelFields ?? AocGuardOptions.Default.AllowedTopLevelFields)
|
||||
.Union(requiredTopLevelFields);
|
||||
|
||||
foreach (var property in document.EnumerateObject())
|
||||
{
|
||||
if (AocForbiddenKeys.IsForbiddenTopLevel(property.Name))
|
||||
{
|
||||
violations.Add(AocViolation.Create(AocViolationCode.ForbiddenField, $"/{property.Name}", $"Field '{property.Name}' is forbidden in AOC documents."));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (AocForbiddenKeys.IsDerivedField(property.Name))
|
||||
{
|
||||
violations.Add(AocViolation.Create(AocViolationCode.DerivedFindingDetected, $"/{property.Name}", $"Derived field '{property.Name}' must not be written during ingestion."));
|
||||
}
|
||||
|
||||
if (!allowedTopLevelFields.Contains(property.Name))
|
||||
{
|
||||
violations.Add(AocViolation.Create(AocViolationCode.UnknownField, $"/{property.Name}", $"Field '{property.Name}' is not allowed in AOC documents."));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var required in requiredTopLevelFields.OrderBy(name => name, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
if (options.RequireTenant && string.Equals(required, "tenant", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!document.TryGetProperty(required, out var element) || element.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)
|
||||
{
|
||||
violations.Add(AocViolation.Create(AocViolationCode.MissingRequiredField, $"/{required}", $"Required field '{required}' is missing."));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (options.RequireTenant)
|
||||
{
|
||||
if (!document.TryGetProperty("tenant", out var tenantElement) ||
|
||||
tenantElement.ValueKind != JsonValueKind.String ||
|
||||
string.IsNullOrWhiteSpace(tenantElement.GetString()))
|
||||
{
|
||||
violations.Add(AocViolation.Create(AocViolationCode.InvalidTenant, "/tenant", "Tenant must be a non-empty string."));
|
||||
}
|
||||
}
|
||||
|
||||
if (document.TryGetProperty("upstream", out var upstream) && upstream.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
if (!upstream.TryGetProperty("content_hash", out var contentHash) || contentHash.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(contentHash.GetString()))
|
||||
{
|
||||
violations.Add(AocViolation.Create(AocViolationCode.MissingProvenance, "/upstream/content_hash", "Upstream content hash is required."));
|
||||
}
|
||||
|
||||
if (!upstream.TryGetProperty("signature", out var signature) || signature.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
if (options.RequireSignatureMetadata)
|
||||
{
|
||||
violations.Add(AocViolation.Create(AocViolationCode.MissingProvenance, "/upstream/signature", "Signature metadata is required."));
|
||||
}
|
||||
}
|
||||
else if (options.RequireSignatureMetadata)
|
||||
{
|
||||
ValidateSignature(signature, violations, options);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
violations.Add(AocViolation.Create(AocViolationCode.MissingRequiredField, "/upstream", "Upstream metadata is required."));
|
||||
}
|
||||
|
||||
if (document.TryGetProperty("content", out var content) && content.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
if (!content.TryGetProperty("raw", out var raw) || raw.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)
|
||||
{
|
||||
violations.Add(AocViolation.Create(AocViolationCode.MissingProvenance, "/content/raw", "Raw upstream payload must be preserved."));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
violations.Add(AocViolation.Create(AocViolationCode.MissingRequiredField, "/content", "Content metadata is required."));
|
||||
}
|
||||
|
||||
if (!document.TryGetProperty("linkset", out var linkset) || linkset.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
violations.Add(AocViolation.Create(AocViolationCode.MissingRequiredField, "/linkset", "Linkset metadata is required."));
|
||||
}
|
||||
ValidateTopLevelFields(document, allowedTopLevelFields, violations);
|
||||
ValidateRequiredFields(document, requiredTopLevelFields, options, violations);
|
||||
ValidateTenant(document, options, violations);
|
||||
ValidateUpstream(document, options, violations);
|
||||
ValidateContent(document, violations);
|
||||
ValidateLinkset(document, violations);
|
||||
|
||||
return AocGuardResult.FromViolations(violations);
|
||||
}
|
||||
|
||||
private static void ValidateSignature(JsonElement signature, ImmutableArray<AocViolation>.Builder violations, AocGuardOptions options)
|
||||
{
|
||||
if (!signature.TryGetProperty("present", out var presentElement) || presentElement.ValueKind is not (JsonValueKind.True or JsonValueKind.False))
|
||||
{
|
||||
violations.Add(AocViolation.Create(AocViolationCode.InvalidSignatureMetadata, "/upstream/signature/present", "Signature metadata must include 'present' boolean."));
|
||||
return;
|
||||
}
|
||||
|
||||
var signaturePresent = presentElement.GetBoolean();
|
||||
|
||||
if (!signaturePresent)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!signature.TryGetProperty("format", out var formatElement) || formatElement.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(formatElement.GetString()))
|
||||
{
|
||||
violations.Add(AocViolation.Create(AocViolationCode.InvalidSignatureMetadata, "/upstream/signature/format", "Signature format is required when signature is present."));
|
||||
}
|
||||
else
|
||||
{
|
||||
var format = formatElement.GetString()!.Trim();
|
||||
if (options.AllowedSignatureFormats.Count > 0 &&
|
||||
!options.AllowedSignatureFormats.Contains(format))
|
||||
{
|
||||
violations.Add(AocViolation.Create(AocViolationCode.InvalidSignatureMetadata, "/upstream/signature/format", $"Signature format '{format}' is not permitted."));
|
||||
}
|
||||
}
|
||||
|
||||
if (!signature.TryGetProperty("sig", out var sigElement) || sigElement.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(sigElement.GetString()))
|
||||
{
|
||||
violations.Add(AocViolation.Create(AocViolationCode.SignatureInvalid, "/upstream/signature/sig", "Signature payload is required when signature is present."));
|
||||
}
|
||||
else if (!IsBase64Payload(sigElement.GetString()!))
|
||||
{
|
||||
violations.Add(AocViolation.Create(AocViolationCode.InvalidSignatureMetadata, "/upstream/signature/sig", "Signature payload must be base64 or base64url encoded."));
|
||||
}
|
||||
|
||||
if (!signature.TryGetProperty("key_id", out var keyIdElement) || keyIdElement.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(keyIdElement.GetString()))
|
||||
{
|
||||
violations.Add(AocViolation.Create(AocViolationCode.InvalidSignatureMetadata, "/upstream/signature/key_id", "Signature key identifier is required when signature is present."));
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsBase64Payload(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (TryDecodeBase64(value))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var normalized = value.Replace('-', '+').Replace('_', '/');
|
||||
switch (normalized.Length % 4)
|
||||
{
|
||||
case 2:
|
||||
normalized += "==";
|
||||
break;
|
||||
case 3:
|
||||
normalized += "=";
|
||||
break;
|
||||
}
|
||||
|
||||
return TryDecodeBase64(normalized);
|
||||
}
|
||||
|
||||
private static bool TryDecodeBase64(string value)
|
||||
{
|
||||
try
|
||||
{
|
||||
Convert.FromBase64String(value);
|
||||
return true;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
8
src/Aoc/__Libraries/StellaOps.Aoc/IAocGuard.cs
Normal file
8
src/Aoc/__Libraries/StellaOps.Aoc/IAocGuard.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Aoc;
|
||||
|
||||
public interface IAocGuard
|
||||
{
|
||||
AocGuardResult Validate(JsonElement document, AocGuardOptions? options = null);
|
||||
}
|
||||
@@ -8,3 +8,5 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0036-M | DONE | Revalidated maintainability for StellaOps.Aoc (2026-01-06). |
|
||||
| AUDIT-0036-T | DONE | Revalidated test coverage for StellaOps.Aoc (2026-01-06). |
|
||||
| AUDIT-0036-A | DONE | Applied error code fixes, deterministic ordering, and guard validation hardening. |
|
||||
| REMED-06 | DONE | SOLID review notes refreshed 2026-02-04. |
|
||||
| REMED-08 | DONE | Private field naming fixed; blocking async removed; IAocGuard extracted; AocWriteGuard and violation code mapping split into partials/files; `dotnet test src/Aoc/__Tests/StellaOps.Aoc.Tests/StellaOps.Aoc.Tests.csproj` passed (11 tests) 2026-02-04. |
|
||||
|
||||
@@ -4,7 +4,7 @@ using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Aoc;
|
||||
using StellaOps.Aoc.AspNetCore.Routing;
|
||||
using StellaOps.TestKit;
|
||||
@@ -14,11 +14,18 @@ namespace StellaOps.Aoc.AspNetCore.Tests;
|
||||
public sealed class AocGuardEndpointFilterTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task ReturnsProblem_WhenRequestMissing()
|
||||
{
|
||||
var httpContext = BuildHttpContext(new TestAocGuard());
|
||||
var filter = new AocGuardEndpointFilter<GuardPayload>(_ => Array.Empty<object?>(), null, null);
|
||||
var guard = new TestAocGuard();
|
||||
var httpContext = BuildHttpContext();
|
||||
var filter = new AocGuardEndpointFilter<GuardPayload>(
|
||||
guard,
|
||||
NullLogger<AocGuardEndpointFilter<GuardPayload>>.Instance,
|
||||
_ => Array.Empty<object?>(),
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
var context = new TestEndpointFilterInvocationContext(httpContext, Array.Empty<object?>());
|
||||
|
||||
var result = await filter.InvokeAsync(context, _ => new ValueTask<object?>(TypedResults.Ok()));
|
||||
@@ -28,11 +35,18 @@ public sealed class AocGuardEndpointFilterTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task ReturnsProblem_WhenPayloadSelectorThrows()
|
||||
{
|
||||
var httpContext = BuildHttpContext(new TestAocGuard());
|
||||
var filter = new AocGuardEndpointFilter<GuardPayload>(_ => throw new InvalidOperationException("boom"), null, null);
|
||||
var guard = new TestAocGuard();
|
||||
var httpContext = BuildHttpContext();
|
||||
var filter = new AocGuardEndpointFilter<GuardPayload>(
|
||||
guard,
|
||||
NullLogger<AocGuardEndpointFilter<GuardPayload>>.Instance,
|
||||
_ => throw new InvalidOperationException("boom"),
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
var context = new TestEndpointFilterInvocationContext(httpContext, new object?[] { new GuardPayload(new JsonElement()) });
|
||||
|
||||
var result = await filter.InvokeAsync(context, _ => new ValueTask<object?>(TypedResults.Ok()));
|
||||
@@ -42,11 +56,18 @@ public sealed class AocGuardEndpointFilterTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task ReturnsProblem_WhenSerializationFails()
|
||||
{
|
||||
var httpContext = BuildHttpContext(new TestAocGuard());
|
||||
var filter = new AocGuardEndpointFilter<GuardPayload>(_ => new object?[] { new SelfReferencingPayload() }, null, null);
|
||||
var guard = new TestAocGuard();
|
||||
var httpContext = BuildHttpContext();
|
||||
var filter = new AocGuardEndpointFilter<GuardPayload>(
|
||||
guard,
|
||||
NullLogger<AocGuardEndpointFilter<GuardPayload>>.Instance,
|
||||
_ => new object?[] { new SelfReferencingPayload() },
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
var context = new TestEndpointFilterInvocationContext(httpContext, new object?[] { new GuardPayload(new JsonElement()) });
|
||||
|
||||
var result = await filter.InvokeAsync(context, _ => new ValueTask<object?>(TypedResults.Ok()));
|
||||
@@ -56,16 +77,22 @@ public sealed class AocGuardEndpointFilterTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task ValidatesJsonDocumentPayloads()
|
||||
{
|
||||
var guard = new TestAocGuard();
|
||||
var httpContext = BuildHttpContext(guard);
|
||||
var filter = new AocGuardEndpointFilter<GuardPayload>(_ =>
|
||||
{
|
||||
using var doc = JsonDocument.Parse("""{"tenant":"default","source":{},"upstream":{"content_hash":"sha256:abc","signature":{"present":false}},"content":{"raw":{}},"linkset":{}}""");
|
||||
return new object?[] { JsonDocument.Parse(doc.RootElement.GetRawText()) };
|
||||
}, null, null);
|
||||
var httpContext = BuildHttpContext();
|
||||
var filter = new AocGuardEndpointFilter<GuardPayload>(
|
||||
guard,
|
||||
NullLogger<AocGuardEndpointFilter<GuardPayload>>.Instance,
|
||||
_ =>
|
||||
{
|
||||
using var doc = JsonDocument.Parse("""{"tenant":"default","source":{},"upstream":{"content_hash":"sha256:abc","signature":{"present":false}},"content":{"raw":{}},"linkset":{}}""");
|
||||
return new object?[] { JsonDocument.Parse(doc.RootElement.GetRawText()) };
|
||||
},
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
var context = new TestEndpointFilterInvocationContext(httpContext, new object?[] { new GuardPayload(new JsonElement()) });
|
||||
|
||||
var result = await filter.InvokeAsync(context, _ => new ValueTask<object?>(TypedResults.Ok()));
|
||||
@@ -75,18 +102,19 @@ public sealed class AocGuardEndpointFilterTests
|
||||
Assert.True(guard.WasValidated);
|
||||
}
|
||||
|
||||
private static DefaultHttpContext BuildHttpContext(IAocGuard guard)
|
||||
private static DefaultHttpContext BuildHttpContext()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddSingleton(guard);
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
return new DefaultHttpContext { RequestServices = provider, Response = { Body = new MemoryStream() } };
|
||||
return new DefaultHttpContext { Response = { Body = new MemoryStream() } };
|
||||
}
|
||||
|
||||
private static async Task<int> ExecuteAsync(object? result, HttpContext context)
|
||||
{
|
||||
if (result is IStatusCodeHttpResult statusResult)
|
||||
{
|
||||
context.Response.StatusCode = statusResult.StatusCode ?? StatusCodes.Status200OK;
|
||||
return context.Response.StatusCode;
|
||||
}
|
||||
|
||||
if (result is IResult httpResult)
|
||||
{
|
||||
await httpResult.ExecuteAsync(context);
|
||||
|
||||
Reference in New Issue
Block a user