| | | 1 | | using AsiBackbone.Core.Results; |
| | | 2 | | |
| | | 3 | | namespace AsiBackbone.Core.Metadata; |
| | | 4 | | |
| | | 5 | | /// <summary> |
| | | 6 | | /// Provides the default provider-neutral governance metadata classification, sanitation, and budget-validation pipeline |
| | | 7 | | /// </summary> |
| | | 8 | | /// <remarks> |
| | | 9 | | /// The pipeline normalizes caller-owned metadata into a new collection, applies classifiers in registration order, |
| | | 10 | | /// removes or redacts values as directed, and then applies <see cref="GovernanceMetadataBudgetValidator" /> to the |
| | | 11 | | /// sanitized collection. Classifier exceptions are not converted into permissive results; they stop the pipeline so |
| | | 12 | | /// hosts do not accidentally persist or emit metadata that was not successfully classified. |
| | | 13 | | /// </remarks> |
| | | 14 | | public sealed class DefaultGovernanceMetadataSanitizer : IGovernanceMetadataSanitizer |
| | | 15 | | { |
| | 3 | 16 | | private static readonly IReadOnlyDictionary<string, string> EmptyMetadata = |
| | 3 | 17 | | GovernanceMetadataBudgetValidator.Normalize(null); |
| | | 18 | | |
| | | 19 | | private readonly IReadOnlyList<IGovernanceMetadataClassifier> classifiers; |
| | | 20 | | private readonly GovernanceMetadataBudget budget; |
| | | 21 | | |
| | | 22 | | /// <summary> |
| | | 23 | | /// Initializes a new instance of the <see cref="DefaultGovernanceMetadataSanitizer" /> class. |
| | | 24 | | /// </summary> |
| | | 25 | | /// <param name="classifiers">The host-owned classifiers applied in registration order.</param> |
| | | 26 | | /// <param name="budget">The metadata budget applied after classification and sanitation.</param> |
| | 17 | 27 | | public DefaultGovernanceMetadataSanitizer( |
| | 17 | 28 | | IEnumerable<IGovernanceMetadataClassifier>? classifiers = null, |
| | 17 | 29 | | GovernanceMetadataBudget? budget = null) |
| | | 30 | | { |
| | 17 | 31 | | IGovernanceMetadataClassifier[] resolvedClassifiers = classifiers?.ToArray() ?? []; |
| | | 32 | | |
| | 52 | 33 | | foreach (IGovernanceMetadataClassifier classifier in resolvedClassifiers) |
| | | 34 | | { |
| | 9 | 35 | | ArgumentNullException.ThrowIfNull(classifier); |
| | | 36 | | } |
| | | 37 | | |
| | 17 | 38 | | this.classifiers = resolvedClassifiers; |
| | 17 | 39 | | this.budget = budget ?? GovernanceMetadataBudget.Recommended; |
| | 17 | 40 | | } |
| | | 41 | | |
| | | 42 | | /// <inheritdoc /> |
| | | 43 | | public async ValueTask<GovernanceMetadataSanitizationResult> SanitizeAsync( |
| | | 44 | | IReadOnlyDictionary<string, string>? metadata, |
| | | 45 | | CancellationToken cancellationToken = default) |
| | | 46 | | { |
| | 15 | 47 | | cancellationToken.ThrowIfCancellationRequested(); |
| | | 48 | | |
| | 15 | 49 | | IReadOnlyDictionary<string, string> normalizedMetadata = |
| | 15 | 50 | | GovernanceMetadataBudgetValidator.Normalize(metadata); |
| | 15 | 51 | | Dictionary<string, string> sanitizedMetadata = new(normalizedMetadata.Count, StringComparer.Ordinal); |
| | 15 | 52 | | List<OperationReason> reasons = []; |
| | 15 | 53 | | GovernanceMetadataSanitizationAction overallAction = GovernanceMetadataSanitizationAction.Allow; |
| | | 54 | | |
| | 62 | 55 | | foreach (KeyValuePair<string, string> item in normalizedMetadata) |
| | | 56 | | { |
| | 16 | 57 | | cancellationToken.ThrowIfCancellationRequested(); |
| | | 58 | | |
| | 16 | 59 | | GovernanceMetadataSanitizationAction entryAction = GovernanceMetadataSanitizationAction.Allow; |
| | 16 | 60 | | string replacementValue = item.Value; |
| | 16 | 61 | | var context = new GovernanceMetadataClassificationContext( |
| | 16 | 62 | | item.Key, |
| | 16 | 63 | | item.Value, |
| | 16 | 64 | | normalizedMetadata); |
| | | 65 | | |
| | 56 | 66 | | foreach (IGovernanceMetadataClassifier classifier in classifiers) |
| | | 67 | | { |
| | 12 | 68 | | GovernanceMetadataClassificationResult classification = await classifier |
| | 12 | 69 | | .ClassifyAsync(context, cancellationToken) |
| | 12 | 70 | | .ConfigureAwait(false); |
| | | 71 | | |
| | 12 | 72 | | ArgumentNullException.ThrowIfNull(classification); |
| | | 73 | | |
| | 12 | 74 | | entryAction = GetStricterAction(entryAction, classification.Action); |
| | | 75 | | |
| | 12 | 76 | | if (classification.Reason is not null) |
| | | 77 | | { |
| | 12 | 78 | | reasons.Add(classification.Reason); |
| | | 79 | | } |
| | | 80 | | |
| | 12 | 81 | | if (classification.Action is GovernanceMetadataSanitizationAction.Redact) |
| | | 82 | | { |
| | 4 | 83 | | replacementValue = classification.ReplacementValue |
| | 4 | 84 | | ?? GovernanceMetadataClassificationResult.DefaultRedactedValue; |
| | | 85 | | } |
| | | 86 | | } |
| | | 87 | | |
| | 16 | 88 | | overallAction = GetStricterAction(overallAction, entryAction); |
| | | 89 | | |
| | 16 | 90 | | if (entryAction is GovernanceMetadataSanitizationAction.Allow |
| | 16 | 91 | | or GovernanceMetadataSanitizationAction.Warn) |
| | | 92 | | { |
| | 4 | 93 | | sanitizedMetadata[item.Key] = item.Value; |
| | | 94 | | } |
| | 12 | 95 | | else if (entryAction is GovernanceMetadataSanitizationAction.Redact) |
| | | 96 | | { |
| | 4 | 97 | | sanitizedMetadata[item.Key] = replacementValue; |
| | | 98 | | } |
| | 16 | 99 | | } |
| | | 100 | | |
| | 15 | 101 | | GovernanceMetadataBudgetValidationResult budgetValidation = |
| | 15 | 102 | | GovernanceMetadataBudgetValidator.Validate(sanitizedMetadata, budget); |
| | | 103 | | |
| | 15 | 104 | | if (!budgetValidation.IsValid) |
| | | 105 | | { |
| | 4 | 106 | | overallAction = GovernanceMetadataSanitizationAction.Deny; |
| | 4 | 107 | | reasons.Add(OperationReason.Create( |
| | 4 | 108 | | GovernanceMetadataSanitizationReasonCodes.BudgetViolation, |
| | 4 | 109 | | "Governance metadata failed the configured shape or reserved-key budget.")); |
| | | 110 | | } |
| | | 111 | | |
| | 15 | 112 | | IReadOnlyDictionary<string, string> forwardMetadata = |
| | 15 | 113 | | overallAction is GovernanceMetadataSanitizationAction.Deny |
| | 15 | 114 | | ? EmptyMetadata |
| | 15 | 115 | | : budgetValidation.NormalizedMetadata; |
| | | 116 | | |
| | 15 | 117 | | IReadOnlyList<OperationReason> resultReasons = reasons.Count == 0 |
| | 15 | 118 | | ? [] |
| | 15 | 119 | | : reasons.AsReadOnly(); |
| | | 120 | | |
| | 15 | 121 | | return GovernanceMetadataSanitizationResult.Create( |
| | 15 | 122 | | overallAction, |
| | 15 | 123 | | forwardMetadata, |
| | 15 | 124 | | resultReasons, |
| | 15 | 125 | | budgetValidation); |
| | 15 | 126 | | } |
| | | 127 | | |
| | | 128 | | private static GovernanceMetadataSanitizationAction GetStricterAction( |
| | | 129 | | GovernanceMetadataSanitizationAction current, |
| | | 130 | | GovernanceMetadataSanitizationAction candidate) |
| | | 131 | | { |
| | 28 | 132 | | return !Enum.IsDefined(candidate) |
| | 28 | 133 | | ? throw new ArgumentOutOfRangeException( |
| | 28 | 134 | | nameof(candidate), |
| | 28 | 135 | | candidate, |
| | 28 | 136 | | "Metadata sanitation action must be defined.") |
| | 28 | 137 | | : candidate > current ? candidate : current; |
| | | 138 | | } |
| | | 139 | | } |