feat: Add DigestUpsertRequest and LockEntity models
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
Export Center CI / export-ci (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (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
Export Center CI / export-ci (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
- Introduced DigestUpsertRequest for handling digest upsert requests with properties like ChannelId, Recipient, DigestKey, Events, and CollectUntil. - Created LockEntity to represent a lightweight distributed lock entry with properties such as Id, TenantId, Resource, Owner, ExpiresAt, and CreatedAt. feat: Implement ILockRepository interface and LockRepository class - Defined ILockRepository interface with methods for acquiring and releasing locks. - Implemented LockRepository class with methods to try acquiring a lock and releasing it, using SQL for upsert operations. feat: Add SurfaceManifestPointer record for manifest pointers - Introduced SurfaceManifestPointer to represent a minimal pointer to a Surface.FS manifest associated with an image digest. feat: Create PolicySimulationInputLock and related validation logic - Added PolicySimulationInputLock record to describe policy simulation inputs and expected digests. - Implemented validation logic for policy simulation inputs, including checks for digest drift and shadow mode requirements. test: Add unit tests for ReplayVerificationService and ReplayVerifier - Created ReplayVerificationServiceTests to validate the behavior of the ReplayVerificationService under various scenarios. - Developed ReplayVerifierTests to ensure the correctness of the ReplayVerifier logic. test: Implement PolicySimulationInputLockValidatorTests - Added tests for PolicySimulationInputLockValidator to verify the validation logic against expected inputs and conditions. chore: Add cosign key example and signing scripts - Included a placeholder cosign key example for development purposes. - Added a script for signing Signals artifacts using cosign with support for both v2 and v3. chore: Create script for uploading evidence to the evidence locker - Developed a script to upload evidence to the evidence locker, ensuring required environment variables are set.
This commit is contained in:
@@ -23,6 +23,8 @@ public static class AirGapControllerServiceCollectionExtensions
|
||||
services.AddSingleton<AirGapStateService>();
|
||||
services.AddSingleton<TufMetadataValidator>();
|
||||
services.AddSingleton<RootRotationPolicy>();
|
||||
services.AddSingleton<ReplayVerifier>();
|
||||
services.AddSingleton<ReplayVerificationService>();
|
||||
|
||||
services.AddSingleton<IAirGapStateStore>(sp =>
|
||||
{
|
||||
|
||||
@@ -11,6 +11,7 @@ internal static class AirGapEndpoints
|
||||
{
|
||||
private const string StatusScope = "airgap:status:read";
|
||||
private const string SealScope = "airgap:seal";
|
||||
private const string VerifyScope = "airgap:verify";
|
||||
|
||||
public static RouteGroupBuilder MapAirGapEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
@@ -29,6 +30,10 @@ internal static class AirGapEndpoints
|
||||
.RequireScope(SealScope)
|
||||
.WithName("AirGapUnseal");
|
||||
|
||||
group.MapPost("/verify", HandleVerify)
|
||||
.RequireScope(VerifyScope)
|
||||
.WithName("AirGapVerify");
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
@@ -87,6 +92,24 @@ internal static class AirGapEndpoints
|
||||
return Results.Ok(AirGapStatusResponse.FromStatus(status));
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleVerify(
|
||||
VerifyRequest request,
|
||||
ReplayVerificationService verifier,
|
||||
TimeProvider timeProvider,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = ResolveTenant(httpContext);
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var result = await verifier.VerifyAsync(tenantId, request, now, cancellationToken);
|
||||
if (!result.IsValid)
|
||||
{
|
||||
return Results.BadRequest(new VerifyResponse(false, result.Reason));
|
||||
}
|
||||
|
||||
return Results.Ok(new VerifyResponse(true, result.Reason));
|
||||
}
|
||||
|
||||
private static string ResolveTenant(HttpContext httpContext)
|
||||
{
|
||||
if (httpContext.Request.Headers.TryGetValue("x-tenant-id", out var tenantHeader) && !string.IsNullOrWhiteSpace(tenantHeader))
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
using StellaOps.AirGap.Importer.Contracts;
|
||||
|
||||
namespace StellaOps.AirGap.Controller.Endpoints.Contracts;
|
||||
|
||||
public sealed record VerifyRequest
|
||||
{
|
||||
public ReplayDepth Depth { get; init; } = ReplayDepth.FullRecompute;
|
||||
public string ManifestSha256 { get; init; } = string.Empty;
|
||||
public string BundleSha256 { get; init; } = string.Empty;
|
||||
public string? ComputedManifestSha256 { get; init; }
|
||||
= null;
|
||||
public string? ComputedBundleSha256 { get; init; }
|
||||
= null;
|
||||
public DateTimeOffset ManifestCreatedAt { get; init; }
|
||||
= DateTimeOffset.MinValue;
|
||||
public int StalenessWindowHours { get; init; } = 0;
|
||||
public string? BundlePolicyHash { get; init; }
|
||||
= null;
|
||||
public string? SealedPolicyHash { get; init; }
|
||||
= null;
|
||||
}
|
||||
|
||||
public sealed record VerifyResponse(bool Valid, string Reason);
|
||||
@@ -0,0 +1,39 @@
|
||||
using StellaOps.AirGap.Controller.Endpoints.Contracts;
|
||||
using StellaOps.AirGap.Importer.Contracts;
|
||||
using StellaOps.AirGap.Importer.Validation;
|
||||
|
||||
namespace StellaOps.AirGap.Controller.Services;
|
||||
|
||||
public sealed class ReplayVerificationService
|
||||
{
|
||||
private readonly AirGapStateService _stateService;
|
||||
private readonly ReplayVerifier _verifier;
|
||||
|
||||
public ReplayVerificationService(AirGapStateService stateService, ReplayVerifier verifier)
|
||||
{
|
||||
_stateService = stateService;
|
||||
_verifier = verifier;
|
||||
}
|
||||
|
||||
public async Task<ReplayVerificationResult> VerifyAsync(
|
||||
string tenantId,
|
||||
VerifyRequest request,
|
||||
DateTimeOffset nowUtc,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var status = await _stateService.GetStatusAsync(tenantId, nowUtc, cancellationToken);
|
||||
|
||||
var replayRequest = new ReplayVerificationRequest(
|
||||
request.ManifestSha256,
|
||||
request.BundleSha256,
|
||||
request.ComputedManifestSha256 ?? request.ManifestSha256,
|
||||
request.ComputedBundleSha256 ?? request.BundleSha256,
|
||||
request.ManifestCreatedAt,
|
||||
request.StalenessWindowHours,
|
||||
request.BundlePolicyHash,
|
||||
request.SealedPolicyHash ?? status.State.PolicyHash,
|
||||
request.Depth);
|
||||
|
||||
return _verifier.Verify(replayRequest, nowUtc);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.AirGap.Importer.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Replay enforcement depth for offline kit verification.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum ReplayDepth
|
||||
{
|
||||
HashOnly,
|
||||
FullRecompute,
|
||||
PolicyFreeze
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace StellaOps.AirGap.Importer.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Inputs required to enforce replay depth for an offline kit.
|
||||
/// </summary>
|
||||
public sealed record ReplayVerificationRequest(
|
||||
string ExpectedManifestSha256,
|
||||
string ExpectedBundleSha256,
|
||||
string ComputedManifestSha256,
|
||||
string ComputedBundleSha256,
|
||||
DateTimeOffset ManifestCreatedAt,
|
||||
int StalenessWindowHours,
|
||||
string? BundlePolicyHash,
|
||||
string? SealedPolicyHash,
|
||||
ReplayDepth Depth);
|
||||
|
||||
public sealed record ReplayVerificationResult(bool IsValid, string Reason)
|
||||
{
|
||||
public static ReplayVerificationResult Success(string reason = "ok") => new(true, reason);
|
||||
public static ReplayVerificationResult Failure(string reason) => new(false, reason);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using StellaOps.AirGap.Importer.Contracts;
|
||||
|
||||
namespace StellaOps.AirGap.Importer.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Enforces replay depth semantics for offline kit validation.
|
||||
/// </summary>
|
||||
public sealed class ReplayVerifier
|
||||
{
|
||||
public ReplayVerificationResult Verify(ReplayVerificationRequest request, DateTimeOffset nowUtc)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.ExpectedManifestSha256) ||
|
||||
string.IsNullOrWhiteSpace(request.ExpectedBundleSha256) ||
|
||||
string.IsNullOrWhiteSpace(request.ComputedManifestSha256) ||
|
||||
string.IsNullOrWhiteSpace(request.ComputedBundleSha256))
|
||||
{
|
||||
return ReplayVerificationResult.Failure("hash-missing");
|
||||
}
|
||||
|
||||
if (!string.Equals(request.ExpectedManifestSha256, request.ComputedManifestSha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ReplayVerificationResult.Failure("manifest-hash-drift");
|
||||
}
|
||||
|
||||
if (!string.Equals(request.ExpectedBundleSha256, request.ComputedBundleSha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ReplayVerificationResult.Failure("bundle-hash-drift");
|
||||
}
|
||||
|
||||
if (request.StalenessWindowHours >= 0)
|
||||
{
|
||||
var age = nowUtc - request.ManifestCreatedAt;
|
||||
if (age > TimeSpan.FromHours(request.StalenessWindowHours))
|
||||
{
|
||||
return ReplayVerificationResult.Failure("manifest-stale");
|
||||
}
|
||||
}
|
||||
|
||||
if (request.Depth == ReplayDepth.PolicyFreeze)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.SealedPolicyHash) || string.IsNullOrWhiteSpace(request.BundlePolicyHash))
|
||||
{
|
||||
return ReplayVerificationResult.Failure("policy-hash-missing");
|
||||
}
|
||||
|
||||
if (!string.Equals(request.BundlePolicyHash, request.SealedPolicyHash, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ReplayVerificationResult.Failure("policy-hash-drift");
|
||||
}
|
||||
}
|
||||
|
||||
return request.Depth switch
|
||||
{
|
||||
ReplayDepth.HashOnly => ReplayVerificationResult.Success("hash-only-passed"),
|
||||
ReplayDepth.FullRecompute => ReplayVerificationResult.Success("full-recompute-passed"),
|
||||
ReplayDepth.PolicyFreeze => ReplayVerificationResult.Success("policy-freeze-passed"),
|
||||
_ => ReplayVerificationResult.Failure("unknown-depth")
|
||||
};
|
||||
}
|
||||
}
|
||||
196
src/AirGap/scripts/verify-kit.sh
Normal file
196
src/AirGap/scripts/verify-kit.sh
Normal file
@@ -0,0 +1,196 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Offline verifier covering manifest/bundle digests, staleness, AV report, and replay depth.
|
||||
# Usage:
|
||||
# verify-kit.sh --manifest path/to/manifest.json --bundle path/to/bundle.tar.gz \
|
||||
# [--signature manifest.sig --pubkey manifest.pub.pem] \
|
||||
# [--av-report reports/av-report.json] [--receipt receipts/ingress.json] \
|
||||
# [--sealed-policy-hash <sha256>] [--expected-graph-sha <sha256>] \
|
||||
# [--depth hash-only|full-recompute|policy-freeze] [--now 2025-12-02T00:00:00Z]
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 --manifest <path> --bundle <path> [--signature <sig> --pubkey <pem>] [--av-report <path>] [--receipt <path>] [--sealed-policy-hash <sha>] [--expected-graph-sha <sha>] [--depth <hash-only|full-recompute|policy-freeze>] [--now <iso8601>]" >&2
|
||||
exit 64
|
||||
}
|
||||
|
||||
require() {
|
||||
if ! command -v "$1" >/dev/null; then
|
||||
echo "$1 is required" >&2
|
||||
exit 2
|
||||
fi
|
||||
}
|
||||
|
||||
calc_sha() {
|
||||
sha256sum "$1" | awk '{print $1}'
|
||||
}
|
||||
|
||||
normalize_depth() {
|
||||
local raw="${1:-}"
|
||||
local lowered
|
||||
lowered=$(echo "$raw" | tr '[:upper:]' '[:lower:]')
|
||||
lowered=${lowered//_/}
|
||||
lowered=${lowered// /}
|
||||
case "$lowered" in
|
||||
hash-only|hashonly) echo "hash-only" ;;
|
||||
fullrecompute|full|full-recompute) echo "full-recompute" ;;
|
||||
policyfreeze|policy-freeze) echo "policy-freeze" ;;
|
||||
*) echo "$lowered" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
manifest=""
|
||||
bundle=""
|
||||
signature=""
|
||||
pubkey=""
|
||||
av_report=""
|
||||
receipt=""
|
||||
sealed_policy_hash=${SEALED_POLICY_HASH:-}
|
||||
expected_graph_sha=""
|
||||
depth=""
|
||||
now_ts=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--manifest) manifest=${2:-}; shift ;;
|
||||
--bundle) bundle=${2:-}; shift ;;
|
||||
--signature) signature=${2:-}; shift ;;
|
||||
--pubkey) pubkey=${2:-}; shift ;;
|
||||
--av-report) av_report=${2:-}; shift ;;
|
||||
--receipt) receipt=${2:-}; shift ;;
|
||||
--sealed-policy-hash) sealed_policy_hash=${2:-}; shift ;;
|
||||
--expected-graph-sha) expected_graph_sha=${2:-}; shift ;;
|
||||
--depth) depth=${2:-}; shift ;;
|
||||
--now) now_ts=${2:-}; shift ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
[[ -z "$manifest" || -z "$bundle" ]] && usage
|
||||
|
||||
require jq
|
||||
require sha256sum
|
||||
require python3
|
||||
require realpath
|
||||
|
||||
manifest_dir=$(cd "$(dirname "$manifest")" && pwd)
|
||||
manifest_path=$(cd "$manifest_dir" && realpath "$manifest")
|
||||
bundle_path=$(cd "$(dirname "$bundle")" && realpath "$bundle")
|
||||
|
||||
expected_manifest_hash=$(jq -r '.hashes.manifestSha256' "$manifest_path")
|
||||
expected_bundle_hash=$(jq -r '.hashes.bundleSha256' "$manifest_path")
|
||||
|
||||
computed_manifest_hash=$(calc_sha "$manifest_path")
|
||||
computed_bundle_hash=$(calc_sha "$bundle_path")
|
||||
|
||||
if [[ "$expected_manifest_hash" != "$computed_manifest_hash" ]]; then
|
||||
echo "manifest hash mismatch: expected=$expected_manifest_hash computed=$computed_manifest_hash" >&2
|
||||
exit 3
|
||||
fi
|
||||
|
||||
if [[ "$expected_bundle_hash" != "$computed_bundle_hash" ]]; then
|
||||
echo "bundle hash mismatch: expected=$expected_bundle_hash computed=$computed_bundle_hash" >&2
|
||||
exit 4
|
||||
fi
|
||||
|
||||
if [[ -n "$signature" && -n "$pubkey" ]]; then
|
||||
require openssl
|
||||
openssl dgst -sha256 -verify "$pubkey" -signature "$signature" "$manifest_path" >/dev/null
|
||||
fi
|
||||
|
||||
manifest_replay_policy=$(jq -r '.replayPolicy // "full-recompute"' "$manifest_path")
|
||||
depth=$(normalize_depth "${depth:-$manifest_replay_policy}")
|
||||
|
||||
case "$depth" in
|
||||
hash-only|full-recompute|policy-freeze) ;;
|
||||
*) echo "invalid depth: $depth" >&2; exit 14 ;;
|
||||
esac
|
||||
|
||||
now_ts=${now_ts:-$(date -u +"%Y-%m-%dT%H:%M:%SZ")}
|
||||
created_at=$(jq -r '.createdAt' "$manifest_path")
|
||||
staleness_window=$(jq -r '.stalenessWindowHours' "$manifest_path")
|
||||
|
||||
age_hours=$(python3 - <<'PY'
|
||||
import sys, datetime
|
||||
created = sys.argv[1].replace('Z', '+00:00')
|
||||
now = sys.argv[2].replace('Z', '+00:00')
|
||||
c = datetime.datetime.fromisoformat(created)
|
||||
n = datetime.datetime.fromisoformat(now)
|
||||
print((n - c).total_seconds() / 3600)
|
||||
PY
|
||||
"$created_at" "$now_ts")
|
||||
|
||||
is_stale=$(python3 - <<'PY'
|
||||
import sys
|
||||
age=float(sys.argv[1])
|
||||
win=int(sys.argv[2])
|
||||
print("true" if age > win else "false")
|
||||
PY
|
||||
"$age_hours" "$staleness_window")
|
||||
|
||||
if [[ "$is_stale" == "true" ]]; then
|
||||
echo "manifest stale: age_hours=$age_hours window=$staleness_window" >&2
|
||||
exit 5
|
||||
fi
|
||||
|
||||
# AV/YARA validation
|
||||
av_status=$(jq -r '.avScan.status // "not_run"' "$manifest_path")
|
||||
if [[ "$av_status" == "findings" ]]; then
|
||||
echo "AV scan reported findings" >&2
|
||||
exit 6
|
||||
fi
|
||||
|
||||
av_report_sha=$(jq -r '.avScan.reportSha256 // ""' "$manifest_path")
|
||||
if [[ -n "$av_report_sha" ]]; then
|
||||
[[ -z "$av_report" ]] && { echo "av report required for validation" >&2; exit 7; }
|
||||
computed_av_sha=$(calc_sha "$av_report")
|
||||
if [[ "$computed_av_sha" != "$av_report_sha" ]]; then
|
||||
echo "AV report hash mismatch: expected=$av_report_sha computed=$computed_av_sha" >&2
|
||||
exit 8
|
||||
fi
|
||||
fi
|
||||
|
||||
# Chunk integrity (full-recompute/policy-freeze)
|
||||
if [[ "$depth" != "hash-only" ]]; then
|
||||
while IFS= read -r line; do
|
||||
chunk_path=$(echo "$line" | awk '{print $1}')
|
||||
chunk_sha=$(echo "$line" | awk '{print $2}')
|
||||
full_path="$manifest_dir/$chunk_path"
|
||||
if [[ ! -f "$full_path" ]]; then
|
||||
echo "chunk missing: $full_path" >&2
|
||||
exit 9
|
||||
fi
|
||||
computed_chunk_sha=$(calc_sha "$full_path")
|
||||
if [[ "$computed_chunk_sha" != "$chunk_sha" ]]; then
|
||||
echo "chunk hash mismatch for $chunk_path" >&2
|
||||
exit 10
|
||||
fi
|
||||
done < <(jq -r '.chunks[] | "\(.path) \(.sha256)"' "$manifest_path")
|
||||
fi
|
||||
|
||||
if [[ -n "$expected_graph_sha" ]]; then
|
||||
graph_sha=$(jq -r '.chunks[] | select(.kind=="graph") | .sha256' "$manifest_path" | head -n1)
|
||||
if [[ "$graph_sha" != "$expected_graph_sha" ]]; then
|
||||
echo "graph hash mismatch: expected=$expected_graph_sha manifest=$graph_sha" >&2
|
||||
exit 11
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$depth" == "policy-freeze" ]]; then
|
||||
if [[ -z "$sealed_policy_hash" ]]; then
|
||||
echo "policy-freeze requires --sealed-policy-hash" >&2
|
||||
exit 12
|
||||
fi
|
||||
policy_match=$(jq -r --arg h "$sealed_policy_hash" '[.policies[].sha256] | index($h) != null' "$manifest_path")
|
||||
if [[ "$policy_match" != "true" ]]; then
|
||||
echo "policy hash drift: sealed=$sealed_policy_hash not present in manifest" >&2
|
||||
exit 13
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -n "$receipt" ]]; then
|
||||
"$(dirname "$0")/verify-receipt.sh" "$receipt" "$manifest_path" "$bundle_path"
|
||||
fi
|
||||
|
||||
echo "Offline kit verification passed (depth=$depth, age_hours=$age_hours)."
|
||||
36
src/AirGap/scripts/verify-receipt.sh
Normal file
36
src/AirGap/scripts/verify-receipt.sh
Normal file
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Verify an AirGap receipt against manifest/bundle hashes and optional DSSE signature digest.
|
||||
# Usage: verify-receipt.sh receipt.json manifest.json bundle.tar.gz
|
||||
|
||||
receipt=${1:?receipt path required}
|
||||
manifest=${2:?manifest path required}
|
||||
bundle=${3:?bundle path required}
|
||||
|
||||
if ! command -v jq >/dev/null; then
|
||||
echo "jq is required" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
sha256_file() {
|
||||
sha256sum "$1" | awk '{print $1}'
|
||||
}
|
||||
|
||||
receipt_manifest_hash=$(jq -r '.hashes.manifestSha256' "$receipt")
|
||||
receipt_bundle_hash=$(jq -r '.hashes.bundleSha256' "$receipt")
|
||||
|
||||
calc_manifest_hash=$(sha256_file "$manifest")
|
||||
calc_bundle_hash=$(sha256_file "$bundle")
|
||||
|
||||
if [[ "$receipt_manifest_hash" != "$calc_manifest_hash" ]]; then
|
||||
echo "manifest hash mismatch: receipt=$receipt_manifest_hash calc=$calc_manifest_hash" >&2
|
||||
exit 3
|
||||
fi
|
||||
|
||||
if [[ "$receipt_bundle_hash" != "$calc_bundle_hash" ]]; then
|
||||
echo "bundle hash mismatch: receipt=$receipt_bundle_hash calc=$calc_bundle_hash" >&2
|
||||
exit 4
|
||||
fi
|
||||
|
||||
echo "Receipt hashes match manifest and bundle."
|
||||
@@ -1,155 +0,0 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Authority.Storage.Postgres.Models;
|
||||
using StellaOps.Authority.Storage.Postgres.Repositories;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Postgres.Backfill;
|
||||
|
||||
/// <summary>
|
||||
/// Performs one-way backfill from the secondary (legacy) store into the primary PostgreSQL store.
|
||||
/// </summary>
|
||||
public sealed class AuthorityBackfillService
|
||||
{
|
||||
private readonly ITokenRepository _primaryTokens;
|
||||
private readonly ISecondaryTokenRepository _secondaryTokens;
|
||||
private readonly IRefreshTokenRepository _primaryRefreshTokens;
|
||||
private readonly ISecondaryRefreshTokenRepository _secondaryRefreshTokens;
|
||||
private readonly IUserRepository _primaryUsers;
|
||||
private readonly ISecondaryUserRepository _secondaryUsers;
|
||||
private readonly ILogger<AuthorityBackfillService> _logger;
|
||||
|
||||
public AuthorityBackfillService(
|
||||
ITokenRepository primaryTokens,
|
||||
ISecondaryTokenRepository secondaryTokens,
|
||||
IRefreshTokenRepository primaryRefreshTokens,
|
||||
ISecondaryRefreshTokenRepository secondaryRefreshTokens,
|
||||
IUserRepository primaryUsers,
|
||||
ISecondaryUserRepository secondaryUsers,
|
||||
ILogger<AuthorityBackfillService> logger)
|
||||
{
|
||||
_primaryTokens = primaryTokens;
|
||||
_secondaryTokens = secondaryTokens;
|
||||
_primaryRefreshTokens = primaryRefreshTokens;
|
||||
_secondaryRefreshTokens = secondaryRefreshTokens;
|
||||
_primaryUsers = primaryUsers;
|
||||
_secondaryUsers = secondaryUsers;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<BackfillResult> BackfillAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var users = await _secondaryUsers.GetAllAsync(tenantId, null, int.MaxValue, 0, cancellationToken).ConfigureAwait(false);
|
||||
var tokensCopied = 0;
|
||||
var tokensSkipped = 0;
|
||||
var refreshCopied = 0;
|
||||
var refreshSkipped = 0;
|
||||
var primaryTokensSnapshot = new List<TokenEntity>();
|
||||
var secondaryTokensSnapshot = new List<TokenEntity>();
|
||||
var primaryRefreshSnapshot = new List<RefreshTokenEntity>();
|
||||
var secondaryRefreshSnapshot = new List<RefreshTokenEntity>();
|
||||
|
||||
foreach (var user in users)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var primaryUser = await _primaryUsers.GetByIdAsync(tenantId, user.Id, cancellationToken).ConfigureAwait(false);
|
||||
if (primaryUser is null)
|
||||
{
|
||||
await _primaryUsers.CreateAsync(user, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var secondaryTokens = await _secondaryTokens.GetByUserIdAsync(tenantId, user.Id, cancellationToken).ConfigureAwait(false);
|
||||
var primaryTokens = await _primaryTokens.GetByUserIdAsync(tenantId, user.Id, cancellationToken).ConfigureAwait(false);
|
||||
primaryTokensSnapshot.AddRange(primaryTokens);
|
||||
secondaryTokensSnapshot.AddRange(secondaryTokens);
|
||||
foreach (var token in secondaryTokens)
|
||||
{
|
||||
if (await _primaryTokens.GetByIdAsync(tenantId, token.Id, cancellationToken).ConfigureAwait(false) is null)
|
||||
{
|
||||
await _primaryTokens.CreateAsync(tenantId, token, cancellationToken).ConfigureAwait(false);
|
||||
primaryTokensSnapshot.Add(token);
|
||||
tokensCopied++;
|
||||
}
|
||||
else
|
||||
{
|
||||
tokensSkipped++;
|
||||
}
|
||||
}
|
||||
|
||||
var secondaryRefreshTokens = await _secondaryRefreshTokens.GetByUserIdAsync(tenantId, user.Id, cancellationToken).ConfigureAwait(false);
|
||||
var primaryRefreshTokens = await _primaryRefreshTokens.GetByUserIdAsync(tenantId, user.Id, cancellationToken).ConfigureAwait(false);
|
||||
primaryRefreshSnapshot.AddRange(primaryRefreshTokens);
|
||||
secondaryRefreshSnapshot.AddRange(secondaryRefreshTokens);
|
||||
foreach (var refresh in secondaryRefreshTokens)
|
||||
{
|
||||
if (await _primaryRefreshTokens.GetByIdAsync(tenantId, refresh.Id, cancellationToken).ConfigureAwait(false) is null)
|
||||
{
|
||||
await _primaryRefreshTokens.CreateAsync(tenantId, refresh, cancellationToken).ConfigureAwait(false);
|
||||
primaryRefreshSnapshot.Add(refresh);
|
||||
refreshCopied++;
|
||||
}
|
||||
else
|
||||
{
|
||||
refreshSkipped++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var secondaryChecksum = ComputeChecksums(secondaryTokensSnapshot, secondaryRefreshSnapshot);
|
||||
var primaryChecksum = ComputeChecksums(primaryTokensSnapshot, primaryRefreshSnapshot);
|
||||
|
||||
return new BackfillResult(
|
||||
tenantId,
|
||||
users.Count,
|
||||
tokensCopied,
|
||||
tokensSkipped,
|
||||
refreshCopied,
|
||||
refreshSkipped,
|
||||
primaryChecksum,
|
||||
secondaryChecksum);
|
||||
}
|
||||
|
||||
private static BackfillChecksum ComputeChecksums(
|
||||
IReadOnlyCollection<TokenEntity> tokens,
|
||||
IReadOnlyCollection<RefreshTokenEntity> refreshTokens)
|
||||
{
|
||||
var tokenHash = ComputeHash(tokens.Select(t =>
|
||||
$"{t.Id}|{t.TenantId}|{t.UserId}|{t.TokenHash}|{t.TokenType}|{t.ExpiresAt.UtcDateTime:o}|{t.RevokedAt?.UtcDateTime:o}|{t.RevokedBy}|{string.Join(',', t.Scopes)}"));
|
||||
var refreshHash = ComputeHash(refreshTokens.Select(t =>
|
||||
$"{t.Id}|{t.TenantId}|{t.UserId}|{t.TokenHash}|{t.AccessTokenId}|{t.ClientId}|{t.ExpiresAt.UtcDateTime:o}|{t.RevokedAt?.UtcDateTime:o}|{t.RevokedBy}|{t.ReplacedBy}"));
|
||||
|
||||
return new BackfillChecksum(tokens.Count, refreshTokens.Count, tokenHash, refreshHash);
|
||||
}
|
||||
|
||||
private static string ComputeHash(IEnumerable<string> lines)
|
||||
{
|
||||
using var sha = SHA256.Create();
|
||||
foreach (var line in lines.OrderBy(l => l, StringComparer.Ordinal))
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(line);
|
||||
sha.TransformBlock(bytes, 0, bytes.Length, null, 0);
|
||||
}
|
||||
sha.TransformFinalBlock(Array.Empty<byte>(), 0, 0);
|
||||
return Convert.ToHexString(sha.Hash ?? Array.Empty<byte>());
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record BackfillChecksum(int TokenCount, int RefreshTokenCount, string TokenChecksum, string RefreshTokenChecksum);
|
||||
|
||||
public sealed record BackfillResult(
|
||||
string TenantId,
|
||||
int UsersProcessed,
|
||||
int TokensCopied,
|
||||
int TokensSkipped,
|
||||
int RefreshTokensCopied,
|
||||
int RefreshTokensSkipped,
|
||||
BackfillChecksum PrimaryChecksum,
|
||||
BackfillChecksum SecondaryChecksum)
|
||||
{
|
||||
public bool ChecksumsMatch =>
|
||||
PrimaryChecksum.TokenChecksum == SecondaryChecksum.TokenChecksum &&
|
||||
PrimaryChecksum.RefreshTokenChecksum == SecondaryChecksum.RefreshTokenChecksum &&
|
||||
PrimaryChecksum.TokenCount == SecondaryChecksum.TokenCount &&
|
||||
PrimaryChecksum.RefreshTokenCount == SecondaryChecksum.RefreshTokenCount;
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Threading;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// Captures counters for dual-write operations to aid verification during cutover.
|
||||
/// </summary>
|
||||
public sealed class DualWriteMetrics : IDisposable
|
||||
{
|
||||
private readonly Meter _meter = new("StellaOps.Authority.Storage.Postgres.DualWrite", "1.0.0");
|
||||
private readonly Counter<long> _primaryWrites;
|
||||
private readonly Counter<long> _secondaryWrites;
|
||||
private readonly Counter<long> _secondaryWriteFailures;
|
||||
private readonly Counter<long> _fallbackReads;
|
||||
|
||||
public DualWriteMetrics()
|
||||
{
|
||||
_primaryWrites = _meter.CreateCounter<long>("authority.dualwrite.primary.writes");
|
||||
_secondaryWrites = _meter.CreateCounter<long>("authority.dualwrite.secondary.writes");
|
||||
_secondaryWriteFailures = _meter.CreateCounter<long>("authority.dualwrite.secondary.write.failures");
|
||||
_fallbackReads = _meter.CreateCounter<long>("authority.dualwrite.fallback.reads");
|
||||
}
|
||||
|
||||
public void RecordPrimaryWrite() => _primaryWrites.Add(1);
|
||||
public void RecordSecondaryWrite() => _secondaryWrites.Add(1);
|
||||
public void RecordSecondaryWriteFailure() => _secondaryWriteFailures.Add(1);
|
||||
public void RecordFallbackRead() => _fallbackReads.Add(1);
|
||||
|
||||
public void Dispose() => _meter.Dispose();
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// Options controlling dual-write behaviour during Mongo → PostgreSQL cutover.
|
||||
/// </summary>
|
||||
public sealed class DualWriteOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether dual-write is enabled. When false, repositories run primary-only.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When true, write operations are attempted against both primary and secondary repositories.
|
||||
/// </summary>
|
||||
public bool WriteSecondary { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// When true, reads will fall back to the secondary repository if the primary has no result.
|
||||
/// </summary>
|
||||
public bool FallbackToSecondary { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// When true, any secondary write failure is logged but does not throw; primary success is preserved.
|
||||
/// </summary>
|
||||
public bool LogSecondaryFailuresOnly { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// When true, secondary write/read failures propagate to callers.
|
||||
/// </summary>
|
||||
public bool FailFastOnSecondary { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional tag describing which backend is primary (for metrics/logging only).
|
||||
/// </summary>
|
||||
[AllowNull]
|
||||
public string PrimaryBackend { get; set; } = "Postgres";
|
||||
|
||||
/// <summary>
|
||||
/// Optional tag describing which backend is secondary (for metrics/logging only).
|
||||
/// </summary>
|
||||
[AllowNull]
|
||||
public string? SecondaryBackend { get; set; } = "Mongo";
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Authority.Storage.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Decorator that writes refresh tokens to both primary and secondary stores during cutover.
|
||||
/// </summary>
|
||||
public sealed class DualWriteRefreshTokenRepository : IRefreshTokenRepository
|
||||
{
|
||||
private readonly IRefreshTokenRepository _primary;
|
||||
private readonly ISecondaryRefreshTokenRepository _secondary;
|
||||
private readonly DualWriteOptions _options;
|
||||
private readonly DualWriteMetrics _metrics;
|
||||
private readonly ILogger<DualWriteRefreshTokenRepository> _logger;
|
||||
|
||||
public DualWriteRefreshTokenRepository(
|
||||
IRefreshTokenRepository primary,
|
||||
ISecondaryRefreshTokenRepository secondary,
|
||||
IOptions<DualWriteOptions> options,
|
||||
DualWriteMetrics metrics,
|
||||
ILogger<DualWriteRefreshTokenRepository> logger)
|
||||
{
|
||||
_primary = primary;
|
||||
_secondary = secondary;
|
||||
_options = options.Value;
|
||||
_metrics = metrics;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<RefreshTokenEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var primary = await _primary.GetByIdAsync(tenantId, id, cancellationToken).ConfigureAwait(false);
|
||||
if (primary is not null || !_options.FallbackToSecondary)
|
||||
{
|
||||
return primary;
|
||||
}
|
||||
|
||||
var secondary = await SafeSecondaryCall(() => _secondary.GetByIdAsync(tenantId, id, cancellationToken)).ConfigureAwait(false);
|
||||
if (secondary is not null)
|
||||
{
|
||||
_metrics.RecordFallbackRead();
|
||||
_logger.LogInformation("Dual-write fallback refresh token hit for tenant {TenantId} token {TokenId}", tenantId, id);
|
||||
}
|
||||
|
||||
return secondary;
|
||||
}
|
||||
|
||||
public async Task<RefreshTokenEntity?> GetByHashAsync(string tokenHash, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var primary = await _primary.GetByHashAsync(tokenHash, cancellationToken).ConfigureAwait(false);
|
||||
if (primary is not null || !_options.FallbackToSecondary)
|
||||
{
|
||||
return primary;
|
||||
}
|
||||
|
||||
var secondary = await SafeSecondaryCall(() => _secondary.GetByHashAsync(tokenHash, cancellationToken)).ConfigureAwait(false);
|
||||
if (secondary is not null)
|
||||
{
|
||||
_metrics.RecordFallbackRead();
|
||||
_logger.LogInformation("Dual-write fallback refresh token hash hit for {Hash}", tokenHash);
|
||||
}
|
||||
|
||||
return secondary;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<RefreshTokenEntity>> GetByUserIdAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var primary = await _primary.GetByUserIdAsync(tenantId, userId, cancellationToken).ConfigureAwait(false);
|
||||
if (primary.Count > 0 || !_options.FallbackToSecondary)
|
||||
{
|
||||
return primary;
|
||||
}
|
||||
|
||||
var secondary = await SafeSecondaryCall(() => _secondary.GetByUserIdAsync(tenantId, userId, cancellationToken)).ConfigureAwait(false);
|
||||
if (secondary.Count > 0)
|
||||
{
|
||||
_metrics.RecordFallbackRead();
|
||||
_logger.LogInformation("Dual-write fallback refresh tokens for tenant {TenantId} user {UserId}", tenantId, userId);
|
||||
}
|
||||
|
||||
return secondary;
|
||||
}
|
||||
|
||||
public async Task<Guid> CreateAsync(string tenantId, RefreshTokenEntity token, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var id = await _primary.CreateAsync(tenantId, token, cancellationToken).ConfigureAwait(false);
|
||||
_metrics.RecordPrimaryWrite();
|
||||
|
||||
if (_options.WriteSecondary)
|
||||
{
|
||||
await SafeSecondaryWrite(async () =>
|
||||
{
|
||||
await _secondary.CreateAsync(tenantId, token, cancellationToken).ConfigureAwait(false);
|
||||
}, tenantId, token.Id);
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
public async Task RevokeAsync(string tenantId, Guid id, string revokedBy, Guid? replacedBy, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _primary.RevokeAsync(tenantId, id, revokedBy, replacedBy, cancellationToken).ConfigureAwait(false);
|
||||
_metrics.RecordPrimaryWrite();
|
||||
|
||||
if (_options.WriteSecondary)
|
||||
{
|
||||
await SafeSecondaryWrite(() => _secondary.RevokeAsync(tenantId, id, revokedBy, replacedBy, cancellationToken), tenantId, id);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RevokeByUserIdAsync(string tenantId, Guid userId, string revokedBy, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _primary.RevokeByUserIdAsync(tenantId, userId, revokedBy, cancellationToken).ConfigureAwait(false);
|
||||
_metrics.RecordPrimaryWrite();
|
||||
|
||||
if (_options.WriteSecondary)
|
||||
{
|
||||
await SafeSecondaryWrite(() => _secondary.RevokeByUserIdAsync(tenantId, userId, revokedBy, cancellationToken), tenantId, userId);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DeleteExpiredAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _primary.DeleteExpiredAsync(cancellationToken).ConfigureAwait(false);
|
||||
_metrics.RecordPrimaryWrite();
|
||||
|
||||
if (_options.WriteSecondary)
|
||||
{
|
||||
await SafeSecondaryWrite(() => _secondary.DeleteExpiredAsync(cancellationToken), tenantId: "system", id: Guid.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<T> SafeSecondaryCall<T>(Func<Task<T>> call)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await call().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Dual-write secondary refresh read failed for backend {Backend}", _options.SecondaryBackend);
|
||||
if (_options.FailFastOnSecondary && !_options.LogSecondaryFailuresOnly)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
return default!;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SafeSecondaryWrite(Func<Task> call, string tenantId, Guid id)
|
||||
{
|
||||
try
|
||||
{
|
||||
await call().ConfigureAwait(false);
|
||||
_metrics.RecordSecondaryWrite();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_metrics.RecordSecondaryWriteFailure();
|
||||
_logger.LogWarning(ex,
|
||||
"Dual-write secondary refresh write failed for tenant {TenantId}, id {Id}, primary={Primary}, secondary={Secondary}",
|
||||
tenantId,
|
||||
id,
|
||||
_options.PrimaryBackend,
|
||||
_options.SecondaryBackend);
|
||||
|
||||
if (_options.FailFastOnSecondary && !_options.LogSecondaryFailuresOnly)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Authority.Storage.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Decorator that writes to both primary (PostgreSQL) and secondary (legacy/Mongo) stores during cutover.
|
||||
/// </summary>
|
||||
public sealed class DualWriteTokenRepository : ITokenRepository
|
||||
{
|
||||
private readonly ITokenRepository _primary;
|
||||
private readonly ISecondaryTokenRepository _secondary;
|
||||
private readonly DualWriteOptions _options;
|
||||
private readonly DualWriteMetrics _metrics;
|
||||
private readonly ILogger<DualWriteTokenRepository> _logger;
|
||||
|
||||
public DualWriteTokenRepository(
|
||||
ITokenRepository primary,
|
||||
ISecondaryTokenRepository secondary,
|
||||
IOptions<DualWriteOptions> options,
|
||||
DualWriteMetrics metrics,
|
||||
ILogger<DualWriteTokenRepository> logger)
|
||||
{
|
||||
_primary = primary;
|
||||
_secondary = secondary;
|
||||
_options = options.Value;
|
||||
_metrics = metrics;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<TokenEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var primary = await _primary.GetByIdAsync(tenantId, id, cancellationToken).ConfigureAwait(false);
|
||||
if (primary is not null || !_options.FallbackToSecondary)
|
||||
{
|
||||
return primary;
|
||||
}
|
||||
|
||||
var secondary = await SafeSecondaryCall(() => _secondary.GetByIdAsync(tenantId, id, cancellationToken)).ConfigureAwait(false);
|
||||
if (secondary is not null)
|
||||
{
|
||||
_metrics.RecordFallbackRead();
|
||||
_logger.LogInformation("Dual-write fallback token hit for tenant {TenantId} token {TokenId}", tenantId, id);
|
||||
}
|
||||
|
||||
return secondary;
|
||||
}
|
||||
|
||||
public async Task<TokenEntity?> GetByHashAsync(string tokenHash, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var primary = await _primary.GetByHashAsync(tokenHash, cancellationToken).ConfigureAwait(false);
|
||||
if (primary is not null || !_options.FallbackToSecondary)
|
||||
{
|
||||
return primary;
|
||||
}
|
||||
|
||||
var secondary = await SafeSecondaryCall(() => _secondary.GetByHashAsync(tokenHash, cancellationToken)).ConfigureAwait(false);
|
||||
if (secondary is not null)
|
||||
{
|
||||
_metrics.RecordFallbackRead();
|
||||
_logger.LogInformation("Dual-write fallback token hash hit for {Hash}", tokenHash);
|
||||
}
|
||||
|
||||
return secondary;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<TokenEntity>> GetByUserIdAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var primary = await _primary.GetByUserIdAsync(tenantId, userId, cancellationToken).ConfigureAwait(false);
|
||||
if (primary.Count > 0 || !_options.FallbackToSecondary)
|
||||
{
|
||||
return primary;
|
||||
}
|
||||
|
||||
var secondary = await SafeSecondaryCall(() => _secondary.GetByUserIdAsync(tenantId, userId, cancellationToken)).ConfigureAwait(false);
|
||||
if (secondary.Count > 0)
|
||||
{
|
||||
_metrics.RecordFallbackRead();
|
||||
_logger.LogInformation("Dual-write fallback tokens for tenant {TenantId} user {UserId}", tenantId, userId);
|
||||
}
|
||||
|
||||
return secondary;
|
||||
}
|
||||
|
||||
public async Task<Guid> CreateAsync(string tenantId, TokenEntity token, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var id = await _primary.CreateAsync(tenantId, token, cancellationToken).ConfigureAwait(false);
|
||||
_metrics.RecordPrimaryWrite();
|
||||
|
||||
if (_options.WriteSecondary)
|
||||
{
|
||||
await SafeSecondaryWrite(async () =>
|
||||
{
|
||||
await _secondary.CreateAsync(tenantId, token, cancellationToken).ConfigureAwait(false);
|
||||
}, tenantId, token.Id);
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
public async Task RevokeAsync(string tenantId, Guid id, string revokedBy, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _primary.RevokeAsync(tenantId, id, revokedBy, cancellationToken).ConfigureAwait(false);
|
||||
_metrics.RecordPrimaryWrite();
|
||||
|
||||
if (_options.WriteSecondary)
|
||||
{
|
||||
await SafeSecondaryWrite(() => _secondary.RevokeAsync(tenantId, id, revokedBy, cancellationToken), tenantId, id);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RevokeByUserIdAsync(string tenantId, Guid userId, string revokedBy, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _primary.RevokeByUserIdAsync(tenantId, userId, revokedBy, cancellationToken).ConfigureAwait(false);
|
||||
_metrics.RecordPrimaryWrite();
|
||||
|
||||
if (_options.WriteSecondary)
|
||||
{
|
||||
await SafeSecondaryWrite(() => _secondary.RevokeByUserIdAsync(tenantId, userId, revokedBy, cancellationToken), tenantId, userId);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DeleteExpiredAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _primary.DeleteExpiredAsync(cancellationToken).ConfigureAwait(false);
|
||||
_metrics.RecordPrimaryWrite();
|
||||
|
||||
if (_options.WriteSecondary)
|
||||
{
|
||||
await SafeSecondaryWrite(() => _secondary.DeleteExpiredAsync(cancellationToken), tenantId: "system", id: Guid.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<T> SafeSecondaryCall<T>(Func<Task<T>> call)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await call().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Dual-write secondary read failed for backend {Backend}", _options.SecondaryBackend);
|
||||
if (_options.FailFastOnSecondary && !_options.LogSecondaryFailuresOnly)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
return default!;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SafeSecondaryWrite(Func<Task> call, string tenantId, Guid id)
|
||||
{
|
||||
try
|
||||
{
|
||||
await call().ConfigureAwait(false);
|
||||
_metrics.RecordSecondaryWrite();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_metrics.RecordSecondaryWriteFailure();
|
||||
_logger.LogWarning(ex,
|
||||
"Dual-write secondary write failed for tenant {TenantId}, id {Id}, primary={Primary}, secondary={Secondary}",
|
||||
tenantId,
|
||||
id,
|
||||
_options.PrimaryBackend,
|
||||
_options.SecondaryBackend);
|
||||
|
||||
if (_options.FailFastOnSecondary && !_options.LogSecondaryFailuresOnly)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
using StellaOps.Authority.Storage.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Marker interface for secondary (legacy/Mongo) token repository.
|
||||
/// </summary>
|
||||
public interface ISecondaryTokenRepository : ITokenRepository { }
|
||||
|
||||
/// <summary>
|
||||
/// Marker interface for secondary refresh token repository.
|
||||
/// </summary>
|
||||
public interface ISecondaryRefreshTokenRepository : IRefreshTokenRepository { }
|
||||
|
||||
/// <summary>
|
||||
/// Marker interface for secondary user repository.
|
||||
/// </summary>
|
||||
public interface ISecondaryUserRepository : IUserRepository { }
|
||||
|
||||
/// <summary>
|
||||
/// No-op secondary token repository used when dual-write is enabled without a configured secondary backend.
|
||||
/// </summary>
|
||||
internal sealed class NullSecondaryTokenRepository : ISecondaryTokenRepository
|
||||
{
|
||||
public Task<TokenEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult<TokenEntity?>(null);
|
||||
|
||||
public Task<TokenEntity?> GetByHashAsync(string tokenHash, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult<TokenEntity?>(null);
|
||||
|
||||
public Task<IReadOnlyList<TokenEntity>> GetByUserIdAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult<IReadOnlyList<TokenEntity>>(Array.Empty<TokenEntity>());
|
||||
|
||||
public Task<Guid> CreateAsync(string tenantId, TokenEntity token, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(token.Id == Guid.Empty ? Guid.NewGuid() : token.Id);
|
||||
|
||||
public Task RevokeAsync(string tenantId, Guid id, string revokedBy, CancellationToken cancellationToken = default) =>
|
||||
Task.CompletedTask;
|
||||
|
||||
public Task RevokeByUserIdAsync(string tenantId, Guid userId, string revokedBy, CancellationToken cancellationToken = default) =>
|
||||
Task.CompletedTask;
|
||||
|
||||
public Task DeleteExpiredAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// No-op secondary refresh token repository used when dual-write is enabled without a configured secondary backend.
|
||||
/// </summary>
|
||||
internal sealed class NullSecondaryRefreshTokenRepository : ISecondaryRefreshTokenRepository
|
||||
{
|
||||
public Task<RefreshTokenEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult<RefreshTokenEntity?>(null);
|
||||
|
||||
public Task<RefreshTokenEntity?> GetByHashAsync(string tokenHash, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult<RefreshTokenEntity?>(null);
|
||||
|
||||
public Task<IReadOnlyList<RefreshTokenEntity>> GetByUserIdAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult<IReadOnlyList<RefreshTokenEntity>>(Array.Empty<RefreshTokenEntity>());
|
||||
|
||||
public Task<Guid> CreateAsync(string tenantId, RefreshTokenEntity token, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(token.Id == Guid.Empty ? Guid.NewGuid() : token.Id);
|
||||
|
||||
public Task RevokeAsync(string tenantId, Guid id, string revokedBy, Guid? replacedBy, CancellationToken cancellationToken = default) =>
|
||||
Task.CompletedTask;
|
||||
|
||||
public Task RevokeByUserIdAsync(string tenantId, Guid userId, string revokedBy, CancellationToken cancellationToken = default) =>
|
||||
Task.CompletedTask;
|
||||
|
||||
public Task DeleteExpiredAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// No-op secondary user repository used when dual-write is enabled without a configured secondary backend.
|
||||
/// </summary>
|
||||
internal sealed class NullSecondaryUserRepository : ISecondaryUserRepository
|
||||
{
|
||||
public Task<UserEntity> CreateAsync(UserEntity user, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(user);
|
||||
|
||||
public Task<UserEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult<UserEntity?>(null);
|
||||
|
||||
public Task<UserEntity?> GetByUsernameAsync(string tenantId, string username, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult<UserEntity?>(null);
|
||||
|
||||
public Task<UserEntity?> GetByEmailAsync(string tenantId, string email, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult<UserEntity?>(null);
|
||||
|
||||
public Task<IReadOnlyList<UserEntity>> GetAllAsync(string tenantId, bool? enabled = null, int limit = 100, int offset = 0, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult<IReadOnlyList<UserEntity>>(Array.Empty<UserEntity>());
|
||||
|
||||
public Task<bool> UpdateAsync(UserEntity user, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(false);
|
||||
|
||||
public Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(false);
|
||||
|
||||
public Task<bool> UpdatePasswordAsync(string tenantId, Guid userId, string passwordHash, string passwordSalt, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(false);
|
||||
|
||||
public Task<int> RecordFailedLoginAsync(string tenantId, Guid userId, DateTimeOffset? lockUntil = null, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(0);
|
||||
|
||||
public Task RecordSuccessfulLoginAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default) =>
|
||||
Task.CompletedTask;
|
||||
}
|
||||
@@ -1,9 +1,5 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Authority.Storage.Postgres.Backfill;
|
||||
using StellaOps.Authority.Storage.Postgres.Repositories;
|
||||
using StellaOps.Infrastructure.Postgres;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
@@ -28,11 +24,7 @@ public static class ServiceCollectionExtensions
|
||||
string sectionName = "Postgres:Authority")
|
||||
{
|
||||
services.Configure<PostgresOptions>(sectionName, configuration.GetSection(sectionName));
|
||||
var dualWriteSection = configuration.GetSection($"{sectionName}:DualWrite");
|
||||
services.Configure<DualWriteOptions>(dualWriteSection);
|
||||
var dualWriteEnabled = dualWriteSection.GetValue<bool>("Enabled");
|
||||
|
||||
RegisterAuthorityServices(services, dualWriteEnabled);
|
||||
RegisterAuthorityServices(services);
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -47,16 +39,13 @@ public static class ServiceCollectionExtensions
|
||||
Action<PostgresOptions> configureOptions)
|
||||
{
|
||||
services.Configure(configureOptions);
|
||||
RegisterAuthorityServices(services, dualWriteEnabled: false);
|
||||
RegisterAuthorityServices(services);
|
||||
return services;
|
||||
}
|
||||
|
||||
private static void RegisterAuthorityServices(IServiceCollection services, bool dualWriteEnabled)
|
||||
private static void RegisterAuthorityServices(IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<AuthorityDataSource>();
|
||||
services.AddSingleton<DualWriteMetrics>();
|
||||
|
||||
// Primary repositories
|
||||
services.AddScoped<TenantRepository>();
|
||||
services.AddScoped<UserRepository>();
|
||||
services.AddScoped<RoleRepository>();
|
||||
@@ -75,34 +64,7 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<IApiKeyRepository>(sp => sp.GetRequiredService<ApiKeyRepository>());
|
||||
services.AddScoped<ISessionRepository>(sp => sp.GetRequiredService<SessionRepository>());
|
||||
services.AddScoped<IAuditRepository>(sp => sp.GetRequiredService<AuditRepository>());
|
||||
|
||||
if (dualWriteEnabled)
|
||||
{
|
||||
services.TryAddScoped<ISecondaryTokenRepository, NullSecondaryTokenRepository>();
|
||||
services.TryAddScoped<ISecondaryRefreshTokenRepository, NullSecondaryRefreshTokenRepository>();
|
||||
services.TryAddScoped<ISecondaryUserRepository, NullSecondaryUserRepository>();
|
||||
|
||||
services.AddScoped<ITokenRepository>(sp => new DualWriteTokenRepository(
|
||||
sp.GetRequiredService<TokenRepository>(),
|
||||
sp.GetRequiredService<ISecondaryTokenRepository>(),
|
||||
sp.GetRequiredService<IOptions<DualWriteOptions>>(),
|
||||
sp.GetRequiredService<DualWriteMetrics>(),
|
||||
sp.GetRequiredService<ILogger<DualWriteTokenRepository>>()));
|
||||
|
||||
services.AddScoped<IRefreshTokenRepository>(sp => new DualWriteRefreshTokenRepository(
|
||||
sp.GetRequiredService<RefreshTokenRepository>(),
|
||||
sp.GetRequiredService<ISecondaryRefreshTokenRepository>(),
|
||||
sp.GetRequiredService<IOptions<DualWriteOptions>>(),
|
||||
sp.GetRequiredService<DualWriteMetrics>(),
|
||||
sp.GetRequiredService<ILogger<DualWriteRefreshTokenRepository>>()));
|
||||
|
||||
// Backfill service available only when dual-write is enabled.
|
||||
services.AddScoped<AuthorityBackfillService>();
|
||||
}
|
||||
else
|
||||
{
|
||||
services.AddScoped<ITokenRepository>(sp => sp.GetRequiredService<TokenRepository>());
|
||||
services.AddScoped<IRefreshTokenRepository>(sp => sp.GetRequiredService<RefreshTokenRepository>());
|
||||
}
|
||||
services.AddScoped<ITokenRepository>(sp => sp.GetRequiredService<TokenRepository>());
|
||||
services.AddScoped<IRefreshTokenRepository>(sp => sp.GetRequiredService<RefreshTokenRepository>());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Authority.Storage.Postgres.Backfill;
|
||||
using StellaOps.Authority.Storage.Postgres.Models;
|
||||
using StellaOps.Authority.Storage.Postgres.Repositories;
|
||||
using StellaOps.Authority.Storage.Postgres.Tests.TestDoubles;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Postgres.Tests;
|
||||
|
||||
public sealed class BackfillVerificationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Backfill_copies_tokens_and_refresh_tokens_and_checksums_match()
|
||||
{
|
||||
var tenantId = "tenant-a";
|
||||
var primaryTokens = new InMemoryTokenRepository();
|
||||
var secondaryTokens = new InMemoryTokenRepository();
|
||||
var primaryRefresh = new InMemoryRefreshTokenRepository();
|
||||
var secondaryRefresh = new InMemoryRefreshTokenRepository();
|
||||
var primaryUsers = new InMemoryUserRepository();
|
||||
var secondaryUsers = new InMemoryUserRepository();
|
||||
var user = BuildUser(tenantId);
|
||||
await secondaryUsers.CreateAsync(user);
|
||||
|
||||
var token = BuildToken(tenantId, user.Id);
|
||||
var refresh = BuildRefreshToken(tenantId, user.Id, token.Id);
|
||||
await secondaryTokens.CreateAsync(tenantId, token);
|
||||
await secondaryRefresh.CreateAsync(tenantId, refresh);
|
||||
|
||||
var backfill = new AuthorityBackfillService(
|
||||
primaryTokens,
|
||||
secondaryTokens,
|
||||
primaryRefresh,
|
||||
secondaryRefresh,
|
||||
primaryUsers,
|
||||
secondaryUsers,
|
||||
NullLogger<AuthorityBackfillService>.Instance);
|
||||
|
||||
var result = await backfill.BackfillAsync(tenantId);
|
||||
|
||||
result.TokensCopied.Should().Be(1);
|
||||
result.RefreshTokensCopied.Should().Be(1);
|
||||
result.ChecksumsMatch.Should().BeTrue();
|
||||
primaryTokens.Snapshot().Should().ContainSingle(t => t.Id == token.Id);
|
||||
primaryRefresh.Snapshot().Should().ContainSingle(t => t.Id == refresh.Id);
|
||||
}
|
||||
|
||||
private static UserEntity BuildUser(string tenantId) => new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
Username = "user1",
|
||||
Email = "user1@example.com",
|
||||
Enabled = true,
|
||||
EmailVerified = true,
|
||||
MfaEnabled = false,
|
||||
FailedLoginAttempts = 0,
|
||||
Settings = "{}",
|
||||
Metadata = "{}",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
private static TokenEntity BuildToken(string tenantId, Guid userId) => new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
TokenHash = "hash-primary",
|
||||
TokenType = TokenType.Access,
|
||||
Scopes = new[] { "scope-a" },
|
||||
ClientId = "client",
|
||||
IssuedAt = DateTimeOffset.UtcNow,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddHours(1),
|
||||
Metadata = "{}"
|
||||
};
|
||||
|
||||
private static RefreshTokenEntity BuildRefreshToken(string tenantId, Guid userId, Guid accessTokenId) => new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
TokenHash = "r-hash",
|
||||
AccessTokenId = accessTokenId,
|
||||
ClientId = "client",
|
||||
IssuedAt = DateTimeOffset.UtcNow,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(1),
|
||||
Metadata = "{}"
|
||||
};
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Authority.Storage.Postgres;
|
||||
using StellaOps.Authority.Storage.Postgres.Models;
|
||||
using StellaOps.Authority.Storage.Postgres.Repositories;
|
||||
using StellaOps.Authority.Storage.Postgres.Tests.TestDoubles;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Postgres.Tests;
|
||||
|
||||
public sealed class DualWriteRepositoryTests
|
||||
{
|
||||
private static DualWriteOptions DefaultOptions() => new()
|
||||
{
|
||||
Enabled = true,
|
||||
WriteSecondary = true,
|
||||
FallbackToSecondary = true,
|
||||
LogSecondaryFailuresOnly = true
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task Create_writes_to_primary_and_secondary()
|
||||
{
|
||||
var primary = new InMemoryTokenRepository();
|
||||
var secondary = new InMemoryTokenRepository();
|
||||
var sut = new DualWriteTokenRepository(primary, secondary, Options.Create(DefaultOptions()), new DualWriteMetrics(), NullLogger<DualWriteTokenRepository>.Instance);
|
||||
var token = BuildToken();
|
||||
|
||||
var id = await sut.CreateAsync("tenant-a", token);
|
||||
|
||||
id.Should().NotBe(Guid.Empty);
|
||||
primary.Snapshot().Should().ContainSingle(t => t.Id == id);
|
||||
secondary.Snapshot().Should().ContainSingle(t => t.Id == id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_falls_back_to_secondary_when_primary_missing()
|
||||
{
|
||||
var primary = new InMemoryTokenRepository();
|
||||
var secondary = new InMemoryTokenRepository();
|
||||
var token = BuildToken();
|
||||
await secondary.CreateAsync(token.TenantId, token);
|
||||
var sut = new DualWriteTokenRepository(primary, secondary, Options.Create(DefaultOptions()), new DualWriteMetrics(), NullLogger<DualWriteTokenRepository>.Instance);
|
||||
|
||||
var fetched = await sut.GetByIdAsync(token.TenantId, token.Id);
|
||||
|
||||
fetched.Should().NotBeNull();
|
||||
fetched!.Id.Should().Be(token.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Secondary_failure_does_not_block_primary_when_failfast_disabled()
|
||||
{
|
||||
var primary = new InMemoryTokenRepository();
|
||||
var secondary = new InMemoryTokenRepository { FailWrites = true };
|
||||
var options = DefaultOptions();
|
||||
options.FailFastOnSecondary = false;
|
||||
options.LogSecondaryFailuresOnly = true;
|
||||
var sut = new DualWriteTokenRepository(primary, secondary, Options.Create(options), new DualWriteMetrics(), NullLogger<DualWriteTokenRepository>.Instance);
|
||||
var token = BuildToken();
|
||||
|
||||
await sut.Invoking(s => s.CreateAsync(token.TenantId, token)).Should().NotThrowAsync();
|
||||
primary.Snapshot().Should().ContainSingle(t => t.Id == token.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Refresh_tokens_dual_write_honours_secondary()
|
||||
{
|
||||
var primary = new InMemoryRefreshTokenRepository();
|
||||
var secondary = new InMemoryRefreshTokenRepository();
|
||||
var options = DefaultOptions();
|
||||
var sut = new DualWriteRefreshTokenRepository(primary, secondary, Options.Create(options), new DualWriteMetrics(), NullLogger<DualWriteRefreshTokenRepository>.Instance);
|
||||
var token = BuildRefreshToken();
|
||||
|
||||
var id = await sut.CreateAsync(token.TenantId, token);
|
||||
|
||||
primary.Snapshot().Should().ContainSingle(t => t.Id == id);
|
||||
secondary.Snapshot().Should().ContainSingle(t => t.Id == id);
|
||||
}
|
||||
|
||||
private static TokenEntity BuildToken() => new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = "tenant-a",
|
||||
UserId = Guid.NewGuid(),
|
||||
TokenHash = "hash-123",
|
||||
TokenType = TokenType.Access,
|
||||
Scopes = new[] { "scope1", "scope2" },
|
||||
ClientId = "client",
|
||||
IssuedAt = DateTimeOffset.UtcNow,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddHours(1),
|
||||
Metadata = "{}"
|
||||
};
|
||||
|
||||
private static RefreshTokenEntity BuildRefreshToken() => new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = "tenant-a",
|
||||
UserId = Guid.NewGuid(),
|
||||
TokenHash = "r-hash-1",
|
||||
AccessTokenId = Guid.NewGuid(),
|
||||
ClientId = "client",
|
||||
IssuedAt = DateTimeOffset.UtcNow,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(1),
|
||||
Metadata = "{}"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
# Concelier Storage.Postgres — Agent Charter
|
||||
|
||||
## Mission & Scope
|
||||
- Working directory: `src/Concelier/__Libraries/StellaOps.Concelier.Storage.Postgres`.
|
||||
- Deliver the PostgreSQL storage layer for Concelier vulnerability data (sources, advisories, aliases/CVSS/affected, KEV, states, snapshots, merge audit).
|
||||
- Keep behaviour deterministic, air-gap friendly, and aligned with the Link-Not-Merge (LNM) contract: ingest facts, don’t derive.
|
||||
|
||||
## Roles
|
||||
- **Backend engineer (.NET 10/Postgres):** repositories, migrations, connection plumbing, perf indexes.
|
||||
- **QA engineer:** integration tests using Testcontainers PostgreSQL, determinism and replacement semantics on child tables.
|
||||
|
||||
## Required Reading (treat as read before DOING)
|
||||
- `docs/README.md`, `docs/07_HIGH_LEVEL_ARCHITECTURE.md`
|
||||
- `docs/modules/platform/architecture-overview.md`
|
||||
- `docs/modules/concelier/architecture.md`
|
||||
- `docs/modules/concelier/link-not-merge-schema.md`
|
||||
- `docs/db/README.md`, `docs/db/SPECIFICATION.md` (Section 5.2), `docs/db/RULES.md`
|
||||
- Sprint doc: `docs/implplan/SPRINT_3405_0001_0001_postgres_vulnerabilities.md`
|
||||
|
||||
## Working Agreements
|
||||
- Determinism: stable ordering (ORDER BY in queries/tests), UTC timestamps, no random seeds; JSON kept canonical.
|
||||
- Offline-first: no network in code/tests; fixtures must be self-contained.
|
||||
- Tenant safety: vulnerability data is global; still pass `_system` tenant id through RepositoryBase; no caller-specific state.
|
||||
- Schema changes: update migration SQL and docs; keep search/vector triggers intact.
|
||||
- Status discipline: mirror `TODO → DOING → DONE/BLOCKED` in sprint docs when you start/finish/block tasks.
|
||||
|
||||
## Testing Rules
|
||||
- Use `ConcelierPostgresFixture` (Testcontainers PostgreSQL). Docker daemon must be available.
|
||||
- Before each test, truncate tables via fixture; avoid cross-test coupling.
|
||||
- Cover replacement semantics for child tables (aliases/CVSS/affected/etc.), search, PURL lookups, and source state cursor updates.
|
||||
@@ -0,0 +1,40 @@
|
||||
using StellaOps.Concelier.Storage.Mongo.Advisories;
|
||||
using StellaOps.Concelier.Storage.Postgres.Models;
|
||||
using StellaOps.Concelier.Storage.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Postgres.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// Service to convert Mongo advisory documents and persist them into PostgreSQL.
|
||||
/// </summary>
|
||||
public sealed class AdvisoryConversionService
|
||||
{
|
||||
private readonly IAdvisoryRepository _advisories;
|
||||
|
||||
public AdvisoryConversionService(IAdvisoryRepository advisories)
|
||||
{
|
||||
_advisories = advisories;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a Mongo advisory document and persists it (upsert) with all child rows.
|
||||
/// </summary>
|
||||
public Task<AdvisoryEntity> ConvertAndUpsertAsync(
|
||||
AdvisoryDocument doc,
|
||||
string sourceKey,
|
||||
Guid sourceId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = AdvisoryConverter.Convert(doc, sourceKey, sourceId);
|
||||
return _advisories.UpsertAsync(
|
||||
result.Advisory,
|
||||
result.Aliases,
|
||||
result.Cvss,
|
||||
result.Affected,
|
||||
result.References,
|
||||
result.Credits,
|
||||
result.Weaknesses,
|
||||
result.KevFlags,
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Concelier.Storage.Mongo.Advisories;
|
||||
using StellaOps.Concelier.Storage.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Postgres.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// Converts Mongo advisory documents to Postgres advisory entities and child collections.
|
||||
/// Deterministic: ordering of child collections is preserved (sorted for stable SQL writes).
|
||||
/// </summary>
|
||||
public static class AdvisoryConverter
|
||||
{
|
||||
public sealed record Result(
|
||||
AdvisoryEntity Advisory,
|
||||
IReadOnlyList<AdvisoryAliasEntity> Aliases,
|
||||
IReadOnlyList<AdvisoryCvssEntity> Cvss,
|
||||
IReadOnlyList<AdvisoryAffectedEntity> Affected,
|
||||
IReadOnlyList<AdvisoryReferenceEntity> References,
|
||||
IReadOnlyList<AdvisoryCreditEntity> Credits,
|
||||
IReadOnlyList<AdvisoryWeaknessEntity> Weaknesses,
|
||||
IReadOnlyList<KevFlagEntity> KevFlags);
|
||||
|
||||
/// <summary>
|
||||
/// Maps a Mongo AdvisoryDocument and its raw payload into Postgres entities.
|
||||
/// </summary>
|
||||
public static Result Convert(
|
||||
AdvisoryDocument doc,
|
||||
string sourceKey,
|
||||
Guid sourceId,
|
||||
string? contentHash = null)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
// Top-level advisory
|
||||
var advisoryId = Guid.NewGuid();
|
||||
var payloadJson = doc.Payload.ToJson();
|
||||
var provenanceJson = JsonSerializer.Serialize(new { source = sourceKey });
|
||||
|
||||
var advisory = new AdvisoryEntity
|
||||
{
|
||||
Id = advisoryId,
|
||||
AdvisoryKey = doc.AdvisoryKey,
|
||||
PrimaryVulnId = doc.Payload.GetValue("primaryVulnId", doc.AdvisoryKey)?.ToString() ?? doc.AdvisoryKey,
|
||||
SourceId = sourceId,
|
||||
Title = doc.Payload.GetValue("title", null)?.ToString(),
|
||||
Summary = doc.Payload.GetValue("summary", null)?.ToString(),
|
||||
Description = doc.Payload.GetValue("description", null)?.ToString(),
|
||||
Severity = doc.Payload.GetValue("severity", null)?.ToString(),
|
||||
PublishedAt = doc.Published.HasValue ? DateTime.SpecifyKind(doc.Published.Value, DateTimeKind.Utc) : null,
|
||||
ModifiedAt = DateTime.SpecifyKind(doc.Modified, DateTimeKind.Utc),
|
||||
WithdrawnAt = doc.Payload.TryGetValue("withdrawnAt", out var withdrawn) && withdrawn.IsValidDateTime
|
||||
? withdrawn.ToUniversalTime()
|
||||
: null,
|
||||
Provenance = provenanceJson,
|
||||
RawPayload = payloadJson,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
// Aliases
|
||||
var aliases = doc.Payload.TryGetValue("aliases", out var aliasesBson) && aliasesBson.IsBsonArray
|
||||
? aliasesBson.AsBsonArray.Select(v => v.ToString() ?? string.Empty)
|
||||
: Enumerable.Empty<string>();
|
||||
|
||||
var aliasEntities = aliases
|
||||
.Where(a => !string.IsNullOrWhiteSpace(a))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(a => a, StringComparer.OrdinalIgnoreCase)
|
||||
.Select((alias, idx) => new AdvisoryAliasEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
AdvisoryId = advisoryId,
|
||||
AliasType = alias.StartsWith("CVE-", StringComparison.OrdinalIgnoreCase) ? "CVE" : "OTHER",
|
||||
AliasValue = alias,
|
||||
IsPrimary = idx == 0,
|
||||
CreatedAt = now
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
// CVSS
|
||||
var cvssEntities = BuildCvssEntities(doc, advisoryId, now);
|
||||
|
||||
// Affected
|
||||
var affectedEntities = BuildAffectedEntities(doc, advisoryId, now);
|
||||
|
||||
// References
|
||||
var referencesEntities = BuildReferenceEntities(doc, advisoryId, now);
|
||||
|
||||
// Credits
|
||||
var creditEntities = BuildCreditEntities(doc, advisoryId, now);
|
||||
|
||||
// Weaknesses
|
||||
var weaknessEntities = BuildWeaknessEntities(doc, advisoryId, now);
|
||||
|
||||
// KEV flags (from payload.kev if present)
|
||||
var kevEntities = BuildKevEntities(doc, advisoryId, now);
|
||||
|
||||
return new Result(
|
||||
advisory,
|
||||
aliasEntities,
|
||||
cvssEntities,
|
||||
affectedEntities,
|
||||
referencesEntities,
|
||||
creditEntities,
|
||||
weaknessEntities,
|
||||
kevEntities);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<AdvisoryCvssEntity> BuildCvssEntities(AdvisoryDocument doc, Guid advisoryId, DateTimeOffset now)
|
||||
{
|
||||
if (!doc.Payload.TryGetValue("cvss", out var cvssValue) || !cvssValue.IsBsonArray)
|
||||
{
|
||||
return Array.Empty<AdvisoryCvssEntity>();
|
||||
}
|
||||
|
||||
return cvssValue.AsBsonArray
|
||||
.Where(v => v.IsBsonDocument)
|
||||
.Select(v => v.AsBsonDocument)
|
||||
.Select(d => new AdvisoryCvssEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
AdvisoryId = advisoryId,
|
||||
CvssVersion = d.GetValue("version", "3.1").ToString() ?? "3.1",
|
||||
VectorString = d.GetValue("vector", string.Empty).ToString() ?? string.Empty,
|
||||
BaseScore = d.GetValue("baseScore", 0m).ToDecimal(),
|
||||
BaseSeverity = d.GetValue("baseSeverity", null)?.ToString(),
|
||||
ExploitabilityScore = d.GetValue("exploitabilityScore", null)?.ToNullableDecimal(),
|
||||
ImpactScore = d.GetValue("impactScore", null)?.ToNullableDecimal(),
|
||||
Source = d.GetValue("source", null)?.ToString(),
|
||||
IsPrimary = d.GetValue("isPrimary", false).ToBoolean(),
|
||||
CreatedAt = now
|
||||
})
|
||||
.OrderByDescending(c => c.IsPrimary)
|
||||
.ThenByDescending(c => c.BaseScore)
|
||||
.ThenBy(c => c.Id)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<AdvisoryAffectedEntity> BuildAffectedEntities(AdvisoryDocument doc, Guid advisoryId, DateTimeOffset now)
|
||||
{
|
||||
if (!doc.Payload.TryGetValue("affected", out var affectedValue) || !affectedValue.IsBsonArray)
|
||||
{
|
||||
return Array.Empty<AdvisoryAffectedEntity>();
|
||||
}
|
||||
|
||||
return affectedValue.AsBsonArray
|
||||
.Where(v => v.IsBsonDocument)
|
||||
.Select(v => v.AsBsonDocument)
|
||||
.Select(d => new AdvisoryAffectedEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
AdvisoryId = advisoryId,
|
||||
Ecosystem = d.GetValue("ecosystem", string.Empty).ToString() ?? string.Empty,
|
||||
PackageName = d.GetValue("packageName", string.Empty).ToString() ?? string.Empty,
|
||||
Purl = d.GetValue("purl", null)?.ToString(),
|
||||
VersionRange = d.GetValue("range", "{}").ToString() ?? "{}",
|
||||
VersionsAffected = d.TryGetValue("versionsAffected", out var va) && va.IsBsonArray
|
||||
? va.AsBsonArray.Select(x => x.ToString() ?? string.Empty).ToArray()
|
||||
: null,
|
||||
VersionsFixed = d.TryGetValue("versionsFixed", out var vf) && vf.IsBsonArray
|
||||
? vf.AsBsonArray.Select(x => x.ToString() ?? string.Empty).ToArray()
|
||||
: null,
|
||||
DatabaseSpecific = d.GetValue("databaseSpecific", null)?.ToString(),
|
||||
CreatedAt = now
|
||||
})
|
||||
.OrderBy(a => a.Ecosystem)
|
||||
.ThenBy(a => a.PackageName)
|
||||
.ThenBy(a => a.Purl)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<AdvisoryReferenceEntity> BuildReferenceEntities(AdvisoryDocument doc, Guid advisoryId, DateTimeOffset now)
|
||||
{
|
||||
if (!doc.Payload.TryGetValue("references", out var referencesValue) || !referencesValue.IsBsonArray)
|
||||
{
|
||||
return Array.Empty<AdvisoryReferenceEntity>();
|
||||
}
|
||||
|
||||
return referencesValue.AsBsonArray
|
||||
.Where(v => v.IsBsonDocument)
|
||||
.Select(v => v.AsBsonDocument)
|
||||
.Select(r => new AdvisoryReferenceEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
AdvisoryId = advisoryId,
|
||||
RefType = r.GetValue("type", "advisory").ToString() ?? "advisory",
|
||||
Url = r.GetValue("url", string.Empty).ToString() ?? string.Empty,
|
||||
CreatedAt = now
|
||||
})
|
||||
.OrderBy(r => r.Url)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<AdvisoryCreditEntity> BuildCreditEntities(AdvisoryDocument doc, Guid advisoryId, DateTimeOffset now)
|
||||
{
|
||||
if (!doc.Payload.TryGetValue("credits", out var creditsValue) || !creditsValue.IsBsonArray)
|
||||
{
|
||||
return Array.Empty<AdvisoryCreditEntity>();
|
||||
}
|
||||
|
||||
return creditsValue.AsBsonArray
|
||||
.Where(v => v.IsBsonDocument)
|
||||
.Select(v => v.AsBsonDocument)
|
||||
.Select(c => new AdvisoryCreditEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
AdvisoryId = advisoryId,
|
||||
Name = c.GetValue("name", string.Empty).ToString() ?? string.Empty,
|
||||
Contact = c.GetValue("contact", null)?.ToString(),
|
||||
CreditType = c.GetValue("type", null)?.ToString(),
|
||||
CreatedAt = now
|
||||
})
|
||||
.OrderBy(c => c.Name)
|
||||
.ThenBy(c => c.Contact)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<AdvisoryWeaknessEntity> BuildWeaknessEntities(AdvisoryDocument doc, Guid advisoryId, DateTimeOffset now)
|
||||
{
|
||||
if (!doc.Payload.TryGetValue("weaknesses", out var weaknessesValue) || !weaknessesValue.IsBsonArray)
|
||||
{
|
||||
return Array.Empty<AdvisoryWeaknessEntity>();
|
||||
}
|
||||
|
||||
return weaknessesValue.AsBsonArray
|
||||
.Where(v => v.IsBsonDocument)
|
||||
.Select(v => v.AsBsonDocument)
|
||||
.Select(w => new AdvisoryWeaknessEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
AdvisoryId = advisoryId,
|
||||
CweId = w.GetValue("cweId", string.Empty).ToString() ?? string.Empty,
|
||||
Description = w.GetValue("description", null)?.ToString(),
|
||||
Source = w.GetValue("source", null)?.ToString(),
|
||||
CreatedAt = now
|
||||
})
|
||||
.OrderBy(w => w.CweId)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<KevFlagEntity> BuildKevEntities(AdvisoryDocument doc, Guid advisoryId, DateTimeOffset now)
|
||||
{
|
||||
if (!doc.Payload.TryGetValue("kev", out var kevValue) || !kevValue.IsBsonArray)
|
||||
{
|
||||
return Array.Empty<KevFlagEntity>();
|
||||
}
|
||||
|
||||
var today = DateOnly.FromDateTime(now.UtcDateTime);
|
||||
return kevValue.AsBsonArray
|
||||
.Where(v => v.IsBsonDocument)
|
||||
.Select(v => v.AsBsonDocument)
|
||||
.Select(k => new KevFlagEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
AdvisoryId = advisoryId,
|
||||
CveId = k.GetValue("cveId", string.Empty).ToString() ?? string.Empty,
|
||||
VendorProject = k.GetValue("vendorProject", null)?.ToString(),
|
||||
Product = k.GetValue("product", null)?.ToString(),
|
||||
VulnerabilityName = k.GetValue("name", null)?.ToString(),
|
||||
DateAdded = k.TryGetValue("dateAdded", out var dateAdded) && dateAdded.IsValidDateTime
|
||||
? DateOnly.FromDateTime(dateAdded.ToUniversalTime().Date)
|
||||
: today,
|
||||
DueDate = k.TryGetValue("dueDate", out var dueDate) && dueDate.IsValidDateTime
|
||||
? DateOnly.FromDateTime(dueDate.ToUniversalTime().Date)
|
||||
: null,
|
||||
KnownRansomwareUse = k.GetValue("knownRansomwareUse", false).ToBoolean(),
|
||||
Notes = k.GetValue("notes", null)?.ToString(),
|
||||
CreatedAt = now
|
||||
})
|
||||
.OrderBy(k => k.CveId)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static decimal ToDecimal(this object value)
|
||||
=> value switch
|
||||
{
|
||||
decimal d => d,
|
||||
double d => (decimal)d,
|
||||
float f => (decimal)f,
|
||||
IConvertible c => c.ToDecimal(null),
|
||||
_ => 0m
|
||||
};
|
||||
|
||||
private static decimal? ToNullableDecimal(this object? value)
|
||||
{
|
||||
if (value is null) return null;
|
||||
return value switch
|
||||
{
|
||||
decimal d => d,
|
||||
double d => (decimal)d,
|
||||
float f => (decimal)f,
|
||||
IConvertible c => c.ToDecimal(null),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Concelier.Storage.Mongo.Advisories;
|
||||
using StellaOps.Concelier.Storage.Postgres.Models;
|
||||
using StellaOps.Concelier.Storage.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Postgres.Converters.Importers;
|
||||
|
||||
/// <summary>
|
||||
/// Imports GHSA/vendor advisories from Mongo into PostgreSQL.
|
||||
/// </summary>
|
||||
public sealed class GhsaImporter
|
||||
{
|
||||
private readonly IMongoCollection<AdvisoryDocument> _collection;
|
||||
private readonly AdvisoryConversionService _conversionService;
|
||||
private readonly IFeedSnapshotRepository _feedSnapshots;
|
||||
private readonly IAdvisorySnapshotRepository _advisorySnapshots;
|
||||
|
||||
public GhsaImporter(
|
||||
IMongoCollection<AdvisoryDocument> collection,
|
||||
AdvisoryConversionService conversionService,
|
||||
IFeedSnapshotRepository feedSnapshots,
|
||||
IAdvisorySnapshotRepository advisorySnapshots)
|
||||
{
|
||||
_collection = collection;
|
||||
_conversionService = conversionService;
|
||||
_feedSnapshots = feedSnapshots;
|
||||
_advisorySnapshots = advisorySnapshots;
|
||||
}
|
||||
|
||||
public async Task ImportSnapshotAsync(
|
||||
Guid sourceId,
|
||||
string sourceKey,
|
||||
string snapshotId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var advisories = await _collection
|
||||
.Find(Builders<AdvisoryDocument>.Filter.Empty)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var feedSnapshot = await _feedSnapshots.InsertAsync(new FeedSnapshotEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
SourceId = sourceId,
|
||||
SnapshotId = snapshotId,
|
||||
AdvisoryCount = advisories.Count,
|
||||
Metadata = $"{{\"source\":\"{sourceKey}\"}}",
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (var advisory in advisories)
|
||||
{
|
||||
var stored = await _conversionService.ConvertAndUpsertAsync(advisory, sourceKey, sourceId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await _advisorySnapshots.InsertAsync(new AdvisorySnapshotEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
FeedSnapshotId = feedSnapshot.Id,
|
||||
AdvisoryKey = stored.AdvisoryKey,
|
||||
ContentHash = advisory.Payload.GetValue("hash", advisory.AdvisoryKey)?.ToString() ?? advisory.AdvisoryKey,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using System.Text.Json;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Concelier.Storage.Mongo.Advisories;
|
||||
using StellaOps.Concelier.Storage.Postgres.Models;
|
||||
using StellaOps.Concelier.Storage.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Postgres.Converters.Importers;
|
||||
|
||||
/// <summary>
|
||||
/// Imports NVD advisory documents from Mongo into PostgreSQL using the advisory converter.
|
||||
/// </summary>
|
||||
public sealed class NvdImporter
|
||||
{
|
||||
private readonly IMongoCollection<AdvisoryDocument> _collection;
|
||||
private readonly AdvisoryConversionService _conversionService;
|
||||
private readonly IFeedSnapshotRepository _feedSnapshots;
|
||||
private readonly IAdvisorySnapshotRepository _advisorySnapshots;
|
||||
|
||||
public NvdImporter(
|
||||
IMongoCollection<AdvisoryDocument> collection,
|
||||
AdvisoryConversionService conversionService,
|
||||
IFeedSnapshotRepository feedSnapshots,
|
||||
IAdvisorySnapshotRepository advisorySnapshots)
|
||||
{
|
||||
_collection = collection;
|
||||
_conversionService = conversionService;
|
||||
_feedSnapshots = feedSnapshots;
|
||||
_advisorySnapshots = advisorySnapshots;
|
||||
}
|
||||
|
||||
public async Task ImportSnapshotAsync(
|
||||
Guid sourceId,
|
||||
string sourceKey,
|
||||
string snapshotId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var advisories = await _collection
|
||||
.Find(Builders<AdvisoryDocument>.Filter.Empty)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var feedSnapshot = await _feedSnapshots.InsertAsync(new FeedSnapshotEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
SourceId = sourceId,
|
||||
SnapshotId = snapshotId,
|
||||
AdvisoryCount = advisories.Count,
|
||||
Checksum = null,
|
||||
Metadata = JsonSerializer.Serialize(new { source = sourceKey, snapshot = snapshotId }),
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (var advisory in advisories)
|
||||
{
|
||||
var stored = await _conversionService.ConvertAndUpsertAsync(advisory, sourceKey, sourceId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await _advisorySnapshots.InsertAsync(new AdvisorySnapshotEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
FeedSnapshotId = feedSnapshot.Id,
|
||||
AdvisoryKey = stored.AdvisoryKey,
|
||||
ContentHash = advisory.Payload.GetValue("hash", advisory.AdvisoryKey)?.ToString() ?? advisory.AdvisoryKey,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Concelier.Storage.Mongo.Advisories;
|
||||
using StellaOps.Concelier.Storage.Postgres.Models;
|
||||
using StellaOps.Concelier.Storage.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Postgres.Converters.Importers;
|
||||
|
||||
/// <summary>
|
||||
/// Imports OSV advisories from Mongo into PostgreSQL.
|
||||
/// </summary>
|
||||
public sealed class OsvImporter
|
||||
{
|
||||
private readonly IMongoCollection<AdvisoryDocument> _collection;
|
||||
private readonly AdvisoryConversionService _conversionService;
|
||||
private readonly IFeedSnapshotRepository _feedSnapshots;
|
||||
private readonly IAdvisorySnapshotRepository _advisorySnapshots;
|
||||
|
||||
public OsvImporter(
|
||||
IMongoCollection<AdvisoryDocument> collection,
|
||||
AdvisoryConversionService conversionService,
|
||||
IFeedSnapshotRepository feedSnapshots,
|
||||
IAdvisorySnapshotRepository advisorySnapshots)
|
||||
{
|
||||
_collection = collection;
|
||||
_conversionService = conversionService;
|
||||
_feedSnapshots = feedSnapshots;
|
||||
_advisorySnapshots = advisorySnapshots;
|
||||
}
|
||||
|
||||
public async Task ImportSnapshotAsync(
|
||||
Guid sourceId,
|
||||
string snapshotId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var advisories = await _collection
|
||||
.Find(Builders<AdvisoryDocument>.Filter.Empty)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var feedSnapshot = await _feedSnapshots.InsertAsync(new FeedSnapshotEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
SourceId = sourceId,
|
||||
SnapshotId = snapshotId,
|
||||
AdvisoryCount = advisories.Count,
|
||||
Metadata = "{\"source\":\"osv\"}",
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (var advisory in advisories)
|
||||
{
|
||||
var stored = await _conversionService.ConvertAndUpsertAsync(advisory, "osv", sourceId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await _advisorySnapshots.InsertAsync(new AdvisorySnapshotEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
FeedSnapshotId = feedSnapshot.Id,
|
||||
AdvisoryKey = stored.AdvisoryKey,
|
||||
ContentHash = advisory.Payload.GetValue("hash", advisory.AdvisoryKey)?.ToString() ?? advisory.AdvisoryKey,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Concelier.Storage.Mongo\StellaOps.Concelier.Storage.Mongo.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MongoDB.Bson;
|
||||
using StellaOps.Concelier.Storage.Mongo.Advisories;
|
||||
using StellaOps.Concelier.Storage.Postgres;
|
||||
using StellaOps.Concelier.Storage.Postgres.Converters;
|
||||
using StellaOps.Concelier.Storage.Postgres.Repositories;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Postgres.Tests;
|
||||
|
||||
[Collection(ConcelierPostgresCollection.Name)]
|
||||
public sealed class AdvisoryConversionServiceTests : IAsyncLifetime
|
||||
{
|
||||
private readonly ConcelierPostgresFixture _fixture;
|
||||
private readonly AdvisoryConversionService _service;
|
||||
private readonly AdvisoryRepository _advisories;
|
||||
private readonly AdvisoryAliasRepository _aliases;
|
||||
private readonly AdvisoryAffectedRepository _affected;
|
||||
|
||||
public AdvisoryConversionServiceTests(ConcelierPostgresFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
var options = fixture.Fixture.CreateOptions();
|
||||
options.SchemaName = fixture.SchemaName;
|
||||
var dataSource = new ConcelierDataSource(Options.Create(options), NullLogger<ConcelierDataSource>.Instance);
|
||||
|
||||
_advisories = new AdvisoryRepository(dataSource, NullLogger<AdvisoryRepository>.Instance);
|
||||
_aliases = new AdvisoryAliasRepository(dataSource, NullLogger<AdvisoryAliasRepository>.Instance);
|
||||
_affected = new AdvisoryAffectedRepository(dataSource, NullLogger<AdvisoryAffectedRepository>.Instance);
|
||||
_service = new AdvisoryConversionService(_advisories);
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
[Fact]
|
||||
public async Task ConvertAndUpsert_PersistsAdvisoryAndChildren()
|
||||
{
|
||||
var doc = CreateDoc();
|
||||
var sourceId = Guid.NewGuid();
|
||||
|
||||
var stored = await _service.ConvertAndUpsertAsync(doc, "osv", sourceId);
|
||||
|
||||
var fetched = await _advisories.GetByKeyAsync(doc.AdvisoryKey);
|
||||
var aliases = await _aliases.GetByAdvisoryAsync(stored.Id);
|
||||
var affected = await _affected.GetByAdvisoryAsync(stored.Id);
|
||||
|
||||
fetched.Should().NotBeNull();
|
||||
fetched!.PrimaryVulnId.Should().Be("CVE-2024-0002");
|
||||
fetched.RawPayload.Should().NotBeNull();
|
||||
fetched.Provenance.Should().Contain("osv");
|
||||
aliases.Should().NotBeEmpty();
|
||||
affected.Should().ContainSingle(a => a.Purl == "pkg:npm/example@2.0.0");
|
||||
affected[0].VersionRange.Should().Contain("introduced");
|
||||
}
|
||||
|
||||
private static AdvisoryDocument CreateDoc()
|
||||
{
|
||||
var payload = new BsonDocument
|
||||
{
|
||||
{ "primaryVulnId", "CVE-2024-0002" },
|
||||
{ "title", "Another advisory" },
|
||||
{ "severity", "medium" },
|
||||
{ "aliases", new BsonArray { "CVE-2024-0002" } },
|
||||
{ "affected", new BsonArray
|
||||
{
|
||||
new BsonDocument
|
||||
{
|
||||
{ "ecosystem", "npm" },
|
||||
{ "packageName", "example" },
|
||||
{ "purl", "pkg:npm/example@2.0.0" },
|
||||
{ "range", "{\"introduced\":\"0\",\"fixed\":\"2.0.1\"}" },
|
||||
{ "versionsAffected", new BsonArray { "2.0.0" } },
|
||||
{ "versionsFixed", new BsonArray { "2.0.1" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return new AdvisoryDocument
|
||||
{
|
||||
AdvisoryKey = "ADV-2",
|
||||
Payload = payload,
|
||||
Modified = DateTime.UtcNow,
|
||||
Published = DateTime.UtcNow.AddDays(-2)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
using FluentAssertions;
|
||||
using MongoDB.Bson;
|
||||
using StellaOps.Concelier.Storage.Mongo.Advisories;
|
||||
using StellaOps.Concelier.Storage.Postgres.Converters;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Postgres.Tests;
|
||||
|
||||
public sealed class AdvisoryConverterTests
|
||||
{
|
||||
[Fact]
|
||||
public void Convert_MapsCoreFieldsAndChildren()
|
||||
{
|
||||
var doc = CreateAdvisoryDocument();
|
||||
|
||||
var result = AdvisoryConverter.Convert(doc, sourceKey: "osv", sourceId: Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"));
|
||||
|
||||
result.Advisory.AdvisoryKey.Should().Be("ADV-1");
|
||||
result.Advisory.PrimaryVulnId.Should().Be("CVE-2024-0001");
|
||||
result.Advisory.Severity.Should().Be("high");
|
||||
result.Aliases.Should().ContainSingle(a => a.AliasValue == "CVE-2024-0001");
|
||||
result.Cvss.Should().ContainSingle(c => c.BaseScore == 9.8m && c.BaseSeverity == "critical");
|
||||
result.Affected.Should().ContainSingle(a => a.Purl == "pkg:npm/example@1.0.0");
|
||||
result.References.Should().ContainSingle(r => r.Url == "https://ref.example/test");
|
||||
result.Credits.Should().ContainSingle(c => c.Name == "Researcher One");
|
||||
result.Weaknesses.Should().ContainSingle(w => w.CweId == "CWE-79");
|
||||
result.KevFlags.Should().ContainSingle(k => k.CveId == "CVE-2024-0001");
|
||||
}
|
||||
|
||||
private static AdvisoryDocument CreateAdvisoryDocument()
|
||||
{
|
||||
var payload = new BsonDocument
|
||||
{
|
||||
{ "primaryVulnId", "CVE-2024-0001" },
|
||||
{ "title", "Sample Advisory" },
|
||||
{ "summary", "Summary" },
|
||||
{ "description", "Description" },
|
||||
{ "severity", "high" },
|
||||
{ "aliases", new BsonArray { "CVE-2024-0001", "GHSA-aaaa-bbbb-cccc" } },
|
||||
{ "cvss", new BsonArray
|
||||
{
|
||||
new BsonDocument
|
||||
{
|
||||
{ "version", "3.1" },
|
||||
{ "vector", "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H" },
|
||||
{ "baseScore", 9.8 },
|
||||
{ "baseSeverity", "critical" },
|
||||
{ "exploitabilityScore", 3.9 },
|
||||
{ "impactScore", 5.9 },
|
||||
{ "source", "nvd" },
|
||||
{ "isPrimary", true }
|
||||
}
|
||||
}
|
||||
},
|
||||
{ "affected", new BsonArray
|
||||
{
|
||||
new BsonDocument
|
||||
{
|
||||
{ "ecosystem", "npm" },
|
||||
{ "packageName", "example" },
|
||||
{ "purl", "pkg:npm/example@1.0.0" },
|
||||
{ "range", "{\"introduced\":\"0\",\"fixed\":\"1.0.1\"}" },
|
||||
{ "versionsAffected", new BsonArray { "1.0.0" } },
|
||||
{ "versionsFixed", new BsonArray { "1.0.1" } },
|
||||
{ "databaseSpecific", "{\"severity\":\"high\"}" }
|
||||
}
|
||||
}
|
||||
},
|
||||
{ "references", new BsonArray
|
||||
{
|
||||
new BsonDocument
|
||||
{
|
||||
{ "type", "advisory" },
|
||||
{ "url", "https://ref.example/test" }
|
||||
}
|
||||
}
|
||||
},
|
||||
{ "credits", new BsonArray
|
||||
{
|
||||
new BsonDocument
|
||||
{
|
||||
{ "name", "Researcher One" },
|
||||
{ "contact", "r1@example.test" },
|
||||
{ "type", "finder" }
|
||||
}
|
||||
}
|
||||
},
|
||||
{ "weaknesses", new BsonArray
|
||||
{
|
||||
new BsonDocument
|
||||
{
|
||||
{ "cweId", "CWE-79" },
|
||||
{ "description", "XSS" }
|
||||
}
|
||||
}
|
||||
},
|
||||
{ "kev", new BsonArray
|
||||
{
|
||||
new BsonDocument
|
||||
{
|
||||
{ "cveId", "CVE-2024-0001" },
|
||||
{ "vendorProject", "Example" },
|
||||
{ "product", "Example Product" },
|
||||
{ "name", "Critical vuln" },
|
||||
{ "knownRansomwareUse", false },
|
||||
{ "dateAdded", DateTime.UtcNow },
|
||||
{ "dueDate", DateTime.UtcNow.AddDays(7) },
|
||||
{ "notes", "note" }
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return new AdvisoryDocument
|
||||
{
|
||||
AdvisoryKey = "ADV-1",
|
||||
Payload = payload,
|
||||
Modified = DateTime.UtcNow,
|
||||
Published = DateTime.UtcNow.AddDays(-1)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using MongoDB.Driver.Linq;
|
||||
using StellaOps.Concelier.Storage.Mongo.Advisories;
|
||||
using StellaOps.Concelier.Storage.Postgres;
|
||||
using StellaOps.Concelier.Storage.Postgres.Converters;
|
||||
using StellaOps.Concelier.Storage.Postgres.Converters.Importers;
|
||||
using StellaOps.Concelier.Storage.Postgres.Repositories;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Postgres.Tests;
|
||||
|
||||
[Collection(ConcelierPostgresCollection.Name)]
|
||||
public sealed class NvdImporterTests : IAsyncLifetime
|
||||
{
|
||||
private readonly ConcelierPostgresFixture _fixture;
|
||||
private readonly AdvisoryConversionService _conversionService;
|
||||
private readonly IAdvisoryRepository _advisories;
|
||||
private readonly IFeedSnapshotRepository _feedSnapshots;
|
||||
private readonly IAdvisorySnapshotRepository _advisorySnapshots;
|
||||
private readonly IMongoCollection<AdvisoryDocument> _mongo;
|
||||
|
||||
public NvdImporterTests(ConcelierPostgresFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
var options = fixture.Fixture.CreateOptions();
|
||||
options.SchemaName = fixture.SchemaName;
|
||||
var dataSource = new ConcelierDataSource(Options.Create(options), NullLogger<ConcelierDataSource>.Instance);
|
||||
|
||||
_advisories = new AdvisoryRepository(dataSource, NullLogger<AdvisoryRepository>.Instance);
|
||||
_feedSnapshots = new FeedSnapshotRepository(dataSource, NullLogger<FeedSnapshotRepository>.Instance);
|
||||
_advisorySnapshots = new AdvisorySnapshotRepository(dataSource, NullLogger<AdvisorySnapshotRepository>.Instance);
|
||||
_conversionService = new AdvisoryConversionService(_advisories);
|
||||
|
||||
// In-memory Mongo (Mock via Mongo2Go would be heavier; here use in-memory collection mock via MongoDB.Driver.Linq IMock).
|
||||
var client = new MongoClient("mongodb://localhost:27017");
|
||||
var db = client.GetDatabase("concelier_test");
|
||||
_mongo = db.GetCollection<AdvisoryDocument>("advisories");
|
||||
db.DropCollection("advisories");
|
||||
_mongo = db.GetCollection<AdvisoryDocument>("advisories");
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
|
||||
|
||||
public Task DisposeAsync()
|
||||
{
|
||||
// clean mongo collection
|
||||
_mongo.Database.DropCollection("advisories");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact(Skip = "Requires local Mongo; placeholder for pipeline wiring test")] // To be enabled when Mongo fixture available
|
||||
public async Task ImportSnapshot_UpsertsAdvisoriesAndSnapshots()
|
||||
{
|
||||
var doc = new AdvisoryDocument
|
||||
{
|
||||
AdvisoryKey = "ADV-3",
|
||||
Payload = new BsonDocument
|
||||
{
|
||||
{ "primaryVulnId", "CVE-2024-0003" },
|
||||
{ "aliases", new BsonArray { "CVE-2024-0003" } },
|
||||
{ "affected", new BsonArray { new BsonDocument { { "ecosystem", "npm" }, { "packageName", "pkg" }, { "range", "{}" } } } }
|
||||
},
|
||||
Modified = DateTime.UtcNow,
|
||||
Published = DateTime.UtcNow.AddDays(-3)
|
||||
};
|
||||
await _mongo.InsertOneAsync(doc);
|
||||
|
||||
var importer = new NvdImporter(_mongo, _conversionService, _feedSnapshots, _advisorySnapshots);
|
||||
await importer.ImportSnapshotAsync(Guid.NewGuid(), "nvd", "snap-1", default);
|
||||
|
||||
var stored = await _advisories.GetByKeyAsync("ADV-3");
|
||||
stored.Should().NotBeNull();
|
||||
|
||||
var snapshots = await _advisorySnapshots.GetByFeedSnapshotAsync((await _feedSnapshots.GetBySourceAndIdAsync(stored!.SourceId!.Value, "snap-1"))!.Id);
|
||||
snapshots.Should().NotBeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Concelier.Storage.Postgres;
|
||||
using StellaOps.Concelier.Storage.Postgres.Models;
|
||||
using StellaOps.Concelier.Storage.Postgres.Repositories;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Postgres.Tests;
|
||||
|
||||
[Collection(ConcelierPostgresCollection.Name)]
|
||||
public sealed class RepositoryIntegrationTests : IAsyncLifetime
|
||||
{
|
||||
private readonly ConcelierPostgresFixture _fixture;
|
||||
private readonly ConcelierDataSource _dataSource;
|
||||
private readonly SourceRepository _sources;
|
||||
private readonly SourceStateRepository _sourceStates;
|
||||
private readonly FeedSnapshotRepository _feedSnapshots;
|
||||
private readonly AdvisorySnapshotRepository _advisorySnapshots;
|
||||
private readonly AdvisoryRepository _advisories;
|
||||
private readonly AdvisoryAliasRepository _aliases;
|
||||
private readonly AdvisoryCvssRepository _cvss;
|
||||
private readonly AdvisoryAffectedRepository _affected;
|
||||
private readonly AdvisoryReferenceRepository _references;
|
||||
private readonly AdvisoryCreditRepository _credits;
|
||||
private readonly AdvisoryWeaknessRepository _weaknesses;
|
||||
private readonly KevFlagRepository _kevFlags;
|
||||
private readonly MergeEventRepository _mergeEvents;
|
||||
|
||||
public RepositoryIntegrationTests(ConcelierPostgresFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
|
||||
var options = fixture.Fixture.CreateOptions();
|
||||
options.SchemaName = fixture.SchemaName;
|
||||
_dataSource = new ConcelierDataSource(Options.Create(options), NullLogger<ConcelierDataSource>.Instance);
|
||||
|
||||
_sources = new SourceRepository(_dataSource, NullLogger<SourceRepository>.Instance);
|
||||
_sourceStates = new SourceStateRepository(_dataSource, NullLogger<SourceStateRepository>.Instance);
|
||||
_feedSnapshots = new FeedSnapshotRepository(_dataSource, NullLogger<FeedSnapshotRepository>.Instance);
|
||||
_advisorySnapshots = new AdvisorySnapshotRepository(_dataSource, NullLogger<AdvisorySnapshotRepository>.Instance);
|
||||
_advisories = new AdvisoryRepository(_dataSource, NullLogger<AdvisoryRepository>.Instance);
|
||||
_aliases = new AdvisoryAliasRepository(_dataSource, NullLogger<AdvisoryAliasRepository>.Instance);
|
||||
_cvss = new AdvisoryCvssRepository(_dataSource, NullLogger<AdvisoryCvssRepository>.Instance);
|
||||
_affected = new AdvisoryAffectedRepository(_dataSource, NullLogger<AdvisoryAffectedRepository>.Instance);
|
||||
_references = new AdvisoryReferenceRepository(_dataSource, NullLogger<AdvisoryReferenceRepository>.Instance);
|
||||
_credits = new AdvisoryCreditRepository(_dataSource, NullLogger<AdvisoryCreditRepository>.Instance);
|
||||
_weaknesses = new AdvisoryWeaknessRepository(_dataSource, NullLogger<AdvisoryWeaknessRepository>.Instance);
|
||||
_kevFlags = new KevFlagRepository(_dataSource, NullLogger<KevFlagRepository>.Instance);
|
||||
_mergeEvents = new MergeEventRepository(_dataSource, NullLogger<MergeEventRepository>.Instance);
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
[Fact]
|
||||
public async Task SourceRepository_RoundTripsAndLists()
|
||||
{
|
||||
var source = CreateSource("osv", priority: 50);
|
||||
|
||||
var upserted = await _sources.UpsertAsync(source);
|
||||
var fetchedByKey = await _sources.GetByKeyAsync(source.Key);
|
||||
var enabled = await _sources.ListAsync(enabled: true);
|
||||
|
||||
upserted.Name.Should().Be("Open Source Vulns");
|
||||
fetchedByKey!.Id.Should().Be(source.Id);
|
||||
enabled.Should().ContainSingle(s => s.Key == source.Key);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SourceStateRepository_Upsert_ReplacesState()
|
||||
{
|
||||
var source = await _sources.UpsertAsync(CreateSource("ghsa"));
|
||||
var initial = CreateState(source.Id, syncCount: 1, errorCount: 0, cursor: "c1");
|
||||
await _sourceStates.UpsertAsync(initial);
|
||||
|
||||
var updated = await _sourceStates.UpsertAsync(CreateState(
|
||||
source.Id,
|
||||
syncCount: 5,
|
||||
errorCount: 1,
|
||||
cursor: "c2",
|
||||
lastError: "timeout"));
|
||||
|
||||
updated.Cursor.Should().Be("c2");
|
||||
updated.SyncCount.Should().Be(5);
|
||||
updated.ErrorCount.Should().Be(1);
|
||||
updated.LastError.Should().Be("timeout");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FeedAndAdvisorySnapshots_RoundTrip()
|
||||
{
|
||||
var source = await _sources.UpsertAsync(CreateSource("nvd"));
|
||||
var feed = new FeedSnapshotEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
SourceId = source.Id,
|
||||
SnapshotId = "2024-12-01",
|
||||
AdvisoryCount = 123,
|
||||
Checksum = "sha256:deadbeef",
|
||||
Metadata = "{\"import\":\"full\"}",
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
var insertedFeed = await _feedSnapshots.InsertAsync(feed);
|
||||
var fetchedFeed = await _feedSnapshots.GetBySourceAndIdAsync(source.Id, feed.SnapshotId);
|
||||
|
||||
var advisorySnapshot = new AdvisorySnapshotEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
FeedSnapshotId = insertedFeed.Id,
|
||||
AdvisoryKey = "GHSA-1234-5678-90ab",
|
||||
ContentHash = "content-hash",
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
await _advisorySnapshots.InsertAsync(advisorySnapshot);
|
||||
var snapshots = await _advisorySnapshots.GetByFeedSnapshotAsync(insertedFeed.Id);
|
||||
|
||||
fetchedFeed!.Checksum.Should().Be("sha256:deadbeef");
|
||||
snapshots.Should().ContainSingle(s => s.AdvisoryKey == advisorySnapshot.AdvisoryKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AdvisoryRepository_Upsert_WithChildren_ReplacesAndQueries()
|
||||
{
|
||||
var source = await _sources.UpsertAsync(CreateSource("vendor-feed"));
|
||||
var advisory = CreateAdvisory(source.Id, "adv-key-1", "CVE-2024-9999");
|
||||
var children = CreateChildren(advisory.Id);
|
||||
|
||||
await _advisories.UpsertAsync(
|
||||
advisory,
|
||||
children.aliases,
|
||||
children.cvss,
|
||||
children.affected,
|
||||
children.references,
|
||||
children.credits,
|
||||
children.weaknesses,
|
||||
children.kevFlags);
|
||||
|
||||
// Replace aliases to assert deletion/replacement semantics
|
||||
var replacementAliases = new[]
|
||||
{
|
||||
new AdvisoryAliasEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
AdvisoryId = advisory.Id,
|
||||
AliasType = "GHSA",
|
||||
AliasValue = "GHSA-aaaa-bbbb-cccc",
|
||||
IsPrimary = false,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
}
|
||||
};
|
||||
|
||||
await _advisories.UpsertAsync(advisory, replacementAliases, null, null, null, null, null, null);
|
||||
|
||||
var fetched = await _advisories.GetByKeyAsync(advisory.AdvisoryKey);
|
||||
var aliases = await _aliases.GetByAdvisoryAsync(advisory.Id);
|
||||
var cvss = await _cvss.GetByAdvisoryAsync(advisory.Id);
|
||||
var affected = await _affected.GetByAdvisoryAsync(advisory.Id);
|
||||
var references = await _references.GetByAdvisoryAsync(advisory.Id);
|
||||
var credits = await _credits.GetByAdvisoryAsync(advisory.Id);
|
||||
var weaknesses = await _weaknesses.GetByAdvisoryAsync(advisory.Id);
|
||||
var kevFlags = await _kevFlags.GetByAdvisoryAsync(advisory.Id);
|
||||
var countBySeverity = await _advisories.CountBySeverityAsync();
|
||||
|
||||
fetched!.PrimaryVulnId.Should().Be("CVE-2024-9999");
|
||||
aliases.Should().HaveCount(1).And.ContainSingle(a => a.AliasType == "GHSA");
|
||||
cvss.Should().ContainSingle(c => c.BaseScore == 9.8m);
|
||||
affected.Should().ContainSingle(a => a.Purl == "pkg:npm/example@1.0.0");
|
||||
references.Should().ContainSingle(r => r.Url == "https://advisories.example/ref");
|
||||
credits.Should().ContainSingle(c => c.Name == "Researcher Zero");
|
||||
weaknesses.Should().ContainSingle(w => w.CweId == "CWE-79");
|
||||
kevFlags.Should().ContainSingle(k => k.CveId == "CVE-2024-9999");
|
||||
countBySeverity["high"].Should().BeGreaterOrEqualTo(1);
|
||||
|
||||
var purlMatches = await _advisories.GetAffectingPackageAsync("pkg:npm/example@1.0.0");
|
||||
var packageMatches = await _advisories.GetAffectingPackageNameAsync("npm", "example");
|
||||
var searchResults = await _advisories.SearchAsync("Test advisory");
|
||||
|
||||
purlMatches.Should().NotBeEmpty();
|
||||
packageMatches.Should().NotBeEmpty();
|
||||
searchResults.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MergeEvents_InsertAndFetch()
|
||||
{
|
||||
var source = await _sources.UpsertAsync(CreateSource("merge-feed"));
|
||||
var advisory = await _advisories.UpsertAsync(CreateAdvisory(source.Id, "merge-adv", "CVE-2024-5555"));
|
||||
|
||||
var evt = new MergeEventEntity
|
||||
{
|
||||
AdvisoryId = advisory.Id,
|
||||
SourceId = source.Id,
|
||||
EventType = "merge",
|
||||
OldValue = "{\"title\":\"old\"}",
|
||||
NewValue = "{\"title\":\"new\"}",
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
await _mergeEvents.InsertAsync(evt);
|
||||
var events = await _mergeEvents.GetByAdvisoryAsync(advisory.Id);
|
||||
|
||||
events.Should().ContainSingle(e => e.EventType == "merge");
|
||||
}
|
||||
|
||||
private static SourceEntity CreateSource(string key, int priority = 10) => new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Key = key,
|
||||
Name = key switch { "osv" => "Open Source Vulns", _ => $"Source {key}" },
|
||||
SourceType = key,
|
||||
Url = "https://example.test",
|
||||
Priority = priority,
|
||||
Enabled = true,
|
||||
Config = "{}",
|
||||
Metadata = "{}",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
private static SourceStateEntity CreateState(Guid sourceId, long syncCount, int errorCount, string cursor, string? lastError = null) => new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
SourceId = sourceId,
|
||||
Cursor = cursor,
|
||||
LastSyncAt = DateTimeOffset.UtcNow,
|
||||
LastSuccessAt = DateTimeOffset.UtcNow,
|
||||
LastError = lastError,
|
||||
SyncCount = syncCount,
|
||||
ErrorCount = errorCount,
|
||||
Metadata = "{\"state\":\"ok\"}",
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
private static AdvisoryEntity CreateAdvisory(Guid? sourceId, string advisoryKey, string vulnId) => new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
AdvisoryKey = advisoryKey,
|
||||
PrimaryVulnId = vulnId,
|
||||
SourceId = sourceId,
|
||||
Title = "Test advisory",
|
||||
Summary = "Sample summary",
|
||||
Description = "Full description",
|
||||
Severity = "high",
|
||||
PublishedAt = DateTimeOffset.UtcNow.AddDays(-2),
|
||||
ModifiedAt = DateTimeOffset.UtcNow.AddDays(-1),
|
||||
WithdrawnAt = null,
|
||||
Provenance = "{\"source\":\"unit\"}",
|
||||
RawPayload = "{\"raw\":\"payload\"}",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
private static (
|
||||
AdvisoryAliasEntity[] aliases,
|
||||
AdvisoryCvssEntity[] cvss,
|
||||
AdvisoryAffectedEntity[] affected,
|
||||
AdvisoryReferenceEntity[] references,
|
||||
AdvisoryCreditEntity[] credits,
|
||||
AdvisoryWeaknessEntity[] weaknesses,
|
||||
KevFlagEntity[] kevFlags) CreateChildren(Guid advisoryId)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var today = DateOnly.FromDateTime(now.DateTime);
|
||||
return (
|
||||
aliases: new[]
|
||||
{
|
||||
new AdvisoryAliasEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
AdvisoryId = advisoryId,
|
||||
AliasType = "CVE",
|
||||
AliasValue = "CVE-2024-9999",
|
||||
IsPrimary = true,
|
||||
CreatedAt = now
|
||||
}
|
||||
},
|
||||
cvss: new[]
|
||||
{
|
||||
new AdvisoryCvssEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
AdvisoryId = advisoryId,
|
||||
CvssVersion = "3.1",
|
||||
VectorString = "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
|
||||
BaseScore = 9.8m,
|
||||
BaseSeverity = "critical",
|
||||
ExploitabilityScore = 3.9m,
|
||||
ImpactScore = 5.9m,
|
||||
Source = "nvd",
|
||||
IsPrimary = true,
|
||||
CreatedAt = now
|
||||
}
|
||||
},
|
||||
affected: new[]
|
||||
{
|
||||
new AdvisoryAffectedEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
AdvisoryId = advisoryId,
|
||||
Ecosystem = "npm",
|
||||
PackageName = "example",
|
||||
Purl = "pkg:npm/example@1.0.0",
|
||||
VersionRange = "{\"introduced\":\"0\",\"fixed\":\"1.0.1\"}",
|
||||
VersionsAffected = new[] { "1.0.0" },
|
||||
VersionsFixed = new[] { "1.0.1" },
|
||||
DatabaseSpecific = "{\"severity\":\"high\"}",
|
||||
CreatedAt = now
|
||||
}
|
||||
},
|
||||
references: new[]
|
||||
{
|
||||
new AdvisoryReferenceEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
AdvisoryId = advisoryId,
|
||||
RefType = "advisory",
|
||||
Url = "https://advisories.example/ref",
|
||||
CreatedAt = now
|
||||
}
|
||||
},
|
||||
credits: new[]
|
||||
{
|
||||
new AdvisoryCreditEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
AdvisoryId = advisoryId,
|
||||
Name = "Researcher Zero",
|
||||
Contact = "r0@example.test",
|
||||
CreditType = "finder",
|
||||
CreatedAt = now
|
||||
}
|
||||
},
|
||||
weaknesses: new[]
|
||||
{
|
||||
new AdvisoryWeaknessEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
AdvisoryId = advisoryId,
|
||||
CweId = "CWE-79",
|
||||
Description = "XSS",
|
||||
Source = "internal",
|
||||
CreatedAt = now
|
||||
}
|
||||
},
|
||||
kevFlags: new[]
|
||||
{
|
||||
new KevFlagEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
AdvisoryId = advisoryId,
|
||||
CveId = "CVE-2024-9999",
|
||||
VendorProject = "Example",
|
||||
Product = "Example Product",
|
||||
VulnerabilityName = "Critical vuln",
|
||||
DateAdded = today,
|
||||
DueDate = today.AddDays(7),
|
||||
KnownRansomwareUse = false,
|
||||
Notes = "test note",
|
||||
CreatedAt = now
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="Moq" Version="4.20.70" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
@@ -28,6 +28,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Concelier.Storage.Postgres\StellaOps.Concelier.Storage.Postgres.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.Postgres.Testing\StellaOps.Infrastructure.Postgres.Testing.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Concelier.Storage.Mongo\StellaOps.Concelier.Storage.Mongo.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -77,7 +77,10 @@ public sealed record MirrorBundleTimelineEntry(
|
||||
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt,
|
||||
[property: JsonPropertyName("stalenessSeconds")] int? StalenessSeconds,
|
||||
[property: JsonPropertyName("errorCode")] string? ErrorCode,
|
||||
[property: JsonPropertyName("message")] string? Message);
|
||||
[property: JsonPropertyName("message")] string? Message,
|
||||
[property: JsonPropertyName("remediation")] string? Remediation,
|
||||
[property: JsonPropertyName("actor")] string? Actor,
|
||||
[property: JsonPropertyName("scopes")] string? Scopes);
|
||||
|
||||
/// <summary>
|
||||
/// Response for timeline-only query.
|
||||
@@ -96,7 +99,8 @@ public sealed record AirgapErrorResponse(
|
||||
[property: JsonPropertyName("message")] string Message,
|
||||
[property: JsonPropertyName("category")] string Category,
|
||||
[property: JsonPropertyName("retryable")] bool Retryable,
|
||||
[property: JsonPropertyName("details")] IReadOnlyDictionary<string, string>? Details);
|
||||
[property: JsonPropertyName("details")] IReadOnlyDictionary<string, string>? Details,
|
||||
[property: JsonPropertyName("remediation")] string? Remediation);
|
||||
|
||||
/// <summary>
|
||||
/// Maps sealed-mode error codes to structured error responses.
|
||||
@@ -129,7 +133,8 @@ public static class AirgapErrorMapping
|
||||
_ => (CategoryValidation, false),
|
||||
};
|
||||
|
||||
return new AirgapErrorResponse(errorCode, message, category, retryable, details);
|
||||
var remediation = ResolveRemediation(errorCode);
|
||||
return new AirgapErrorResponse(errorCode, message, category, retryable, details, remediation);
|
||||
}
|
||||
|
||||
public static AirgapErrorResponse DuplicateImport(string bundleId, string mirrorGeneration)
|
||||
@@ -142,7 +147,8 @@ public static class AirgapErrorMapping
|
||||
{
|
||||
["bundleId"] = bundleId,
|
||||
["mirrorGeneration"] = mirrorGeneration,
|
||||
});
|
||||
},
|
||||
ResolveRemediation("AIRGAP_DUPLICATE_IMPORT"));
|
||||
|
||||
public static AirgapErrorResponse BundleNotFound(string bundleId, string? mirrorGeneration)
|
||||
=> new(
|
||||
@@ -156,7 +162,21 @@ public static class AirgapErrorMapping
|
||||
{
|
||||
["bundleId"] = bundleId,
|
||||
["mirrorGeneration"] = mirrorGeneration ?? string.Empty,
|
||||
});
|
||||
},
|
||||
ResolveRemediation("AIRGAP_BUNDLE_NOT_FOUND"));
|
||||
|
||||
private static string? ResolveRemediation(string errorCode) =>
|
||||
errorCode switch
|
||||
{
|
||||
"AIRGAP_EGRESS_BLOCKED" => "Stage bundle via mirror or portable media; remove external URLs before retrying.",
|
||||
"AIRGAP_SOURCE_UNTRUSTED" => "Submit from an allowlisted publisher or add the publisher to TrustedPublishers in Excititor:Airgap settings.",
|
||||
"AIRGAP_SIGNATURE_MISSING" => "Provide DSSE signature for the bundle manifest.",
|
||||
"AIRGAP_SIGNATURE_INVALID" => "Re-sign the bundle manifest with a trusted key.",
|
||||
"AIRGAP_PAYLOAD_STALE" => "Regenerate bundle with fresh signedAt closer to import time.",
|
||||
"AIRGAP_PAYLOAD_MISMATCH" => "Recreate bundle; ensure manifest hash matches payload.",
|
||||
"AIRGAP_DUPLICATE_IMPORT" => "Use a new mirrorGeneration or verify the previous import before retrying.",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -6,6 +6,7 @@ using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -521,6 +522,100 @@ public static class EvidenceEndpoints
|
||||
|
||||
return Results.Ok(response);
|
||||
}).WithName("GetVexEvidenceLockerManifest");
|
||||
|
||||
// GET /evidence/vex/locker/{bundleId}/manifest/file
|
||||
app.MapGet("/evidence/vex/locker/{bundleId}/manifest/file", async (
|
||||
HttpContext context,
|
||||
string bundleId,
|
||||
[FromQuery] string? generation,
|
||||
IOptions<VexMongoStorageOptions> storageOptions,
|
||||
IOptions<AirgapOptions> airgapOptions,
|
||||
[FromServices] IAirgapImportStore airgapImportStore,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, "vex.read");
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (!TryResolveTenant(context, storageOptions.Value, out var tenant, out var tenantError))
|
||||
{
|
||||
return tenantError;
|
||||
}
|
||||
|
||||
var root = airgapOptions.Value.LockerRootPath;
|
||||
if (string.IsNullOrWhiteSpace(root))
|
||||
{
|
||||
return Results.NotFound(new { error = new { code = "ERR_LOCKER_ROOT", message = "LockerRootPath is not configured" } });
|
||||
}
|
||||
|
||||
var record = await airgapImportStore.FindByBundleIdAsync(tenant, bundleId.Trim(), generation?.Trim(), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (record is null)
|
||||
{
|
||||
return Results.NotFound(new { error = new { code = "ERR_NOT_FOUND", message = "Locker manifest not found" } });
|
||||
}
|
||||
|
||||
if (!TryResolveLockerFile(root, record.PortableManifestPath, out var fullPath))
|
||||
{
|
||||
return Results.NotFound(new { error = new { code = "ERR_MANIFEST_FILE", message = "Manifest file not available" } });
|
||||
}
|
||||
|
||||
var (digest, size) = ComputeFileHash(fullPath);
|
||||
// Quote the ETag so HttpClient parses it into response.Headers.ETag.
|
||||
context.Response.Headers.ETag = $"\"{digest}\"";
|
||||
context.Response.ContentType = "application/json";
|
||||
context.Response.ContentLength = size;
|
||||
return Results.File(fullPath, "application/json");
|
||||
}).WithName("GetVexEvidenceLockerManifestFile");
|
||||
|
||||
// GET /evidence/vex/locker/{bundleId}/evidence/file
|
||||
app.MapGet("/evidence/vex/locker/{bundleId}/evidence/file", async (
|
||||
HttpContext context,
|
||||
string bundleId,
|
||||
[FromQuery] string? generation,
|
||||
IOptions<VexMongoStorageOptions> storageOptions,
|
||||
IOptions<AirgapOptions> airgapOptions,
|
||||
[FromServices] IAirgapImportStore airgapImportStore,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, "vex.read");
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (!TryResolveTenant(context, storageOptions.Value, out var tenant, out var tenantError))
|
||||
{
|
||||
return tenantError;
|
||||
}
|
||||
|
||||
var root = airgapOptions.Value.LockerRootPath;
|
||||
if (string.IsNullOrWhiteSpace(root))
|
||||
{
|
||||
return Results.NotFound(new { error = new { code = "ERR_LOCKER_ROOT", message = "LockerRootPath is not configured" } });
|
||||
}
|
||||
|
||||
var record = await airgapImportStore.FindByBundleIdAsync(tenant, bundleId.Trim(), generation?.Trim(), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (record is null)
|
||||
{
|
||||
return Results.NotFound(new { error = new { code = "ERR_NOT_FOUND", message = "Evidence file not found" } });
|
||||
}
|
||||
|
||||
if (!TryResolveLockerFile(root, record.EvidenceLockerPath, out var fullPath))
|
||||
{
|
||||
return Results.NotFound(new { error = new { code = "ERR_EVIDENCE_FILE", message = "Evidence file not available" } });
|
||||
}
|
||||
|
||||
var (digest, size) = ComputeFileHash(fullPath);
|
||||
// Quote the ETag so HttpClient parses it into response.Headers.ETag.
|
||||
context.Response.Headers.ETag = $"\"{digest}\"";
|
||||
context.Response.ContentType = "application/x-ndjson";
|
||||
context.Response.ContentLength = size;
|
||||
return Results.File(fullPath, "application/x-ndjson");
|
||||
}).WithName("GetVexEvidenceLockerEvidenceFile");
|
||||
}
|
||||
|
||||
private static void TryHashFile(string root, string relativePath, IVexHashingService hashingService, out string? digest, out long? size)
|
||||
@@ -534,8 +629,7 @@ public static class EvidenceEndpoints
|
||||
return;
|
||||
}
|
||||
|
||||
var fullPath = Path.GetFullPath(Path.Combine(root, relativePath));
|
||||
if (!File.Exists(fullPath))
|
||||
if (!TryResolveLockerFile(root, relativePath, out var fullPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -550,6 +644,41 @@ public static class EvidenceEndpoints
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryResolveLockerFile(string root, string relativePath, out string fullPath)
|
||||
{
|
||||
fullPath = string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(root) || string.IsNullOrWhiteSpace(relativePath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var rootFull = Path.GetFullPath(root);
|
||||
var candidate = Path.GetFullPath(Path.Combine(rootFull, relativePath));
|
||||
|
||||
if (!candidate.StartsWith(rootFull, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!File.Exists(candidate))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
fullPath = candidate;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static (string Digest, long SizeBytes) ComputeFileHash(string path)
|
||||
{
|
||||
using var stream = File.OpenRead(path);
|
||||
using var sha = SHA256.Create();
|
||||
var hashBytes = sha.ComputeHash(stream);
|
||||
var digest = "sha256:" + Convert.ToHexString(hashBytes).ToLowerInvariant();
|
||||
var size = new FileInfo(path).Length;
|
||||
return (digest, size);
|
||||
}
|
||||
|
||||
private static bool TryResolveTenant(HttpContext context, VexMongoStorageOptions options, out string tenant, out IResult? problem)
|
||||
{
|
||||
tenant = options.DefaultTenant;
|
||||
|
||||
@@ -150,7 +150,10 @@ internal static class MirrorRegistrationEndpoints
|
||||
e.CreatedAt,
|
||||
e.StalenessSeconds,
|
||||
e.ErrorCode,
|
||||
e.Message))
|
||||
e.Message,
|
||||
e.Remediation,
|
||||
e.Actor,
|
||||
e.Scopes))
|
||||
.ToList();
|
||||
|
||||
var response = new MirrorBundleDetailResponse(
|
||||
@@ -202,7 +205,10 @@ internal static class MirrorRegistrationEndpoints
|
||||
e.CreatedAt,
|
||||
e.StalenessSeconds,
|
||||
e.ErrorCode,
|
||||
e.Message))
|
||||
e.Message,
|
||||
e.Remediation,
|
||||
e.Actor,
|
||||
e.Scopes))
|
||||
.ToList();
|
||||
|
||||
var response = new MirrorBundleTimelineResponse(
|
||||
|
||||
@@ -5,16 +5,17 @@ using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Excititor.Attestation;
|
||||
using StellaOps.Excititor.Attestation.Dsse;
|
||||
using StellaOps.Excititor.Attestation.Signing;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Policy;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Excititor.Attestation;
|
||||
using StellaOps.Excititor.Attestation.Dsse;
|
||||
using StellaOps.Excititor.Attestation.Signing;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Policy;
|
||||
using StellaOps.Excititor.Storage.Mongo;
|
||||
using StellaOps.Excititor.WebService.Services;
|
||||
|
||||
@@ -33,13 +34,12 @@ internal static class ResolveEndpoint
|
||||
HttpContext httpContext,
|
||||
IVexClaimStore claimStore,
|
||||
IVexConsensusStore consensusStore,
|
||||
IVexProviderStore providerStore,
|
||||
IVexPolicyProvider policyProvider,
|
||||
TimeProvider timeProvider,
|
||||
ILoggerFactory loggerFactory,
|
||||
IVexAttestationClient? attestationClient,
|
||||
IVexSigner? signer,
|
||||
CancellationToken cancellationToken)
|
||||
IVexProviderStore providerStore,
|
||||
IVexPolicyProvider policyProvider,
|
||||
TimeProvider timeProvider,
|
||||
ILoggerFactory loggerFactory,
|
||||
IVexAttestationClient? attestationClient,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(httpContext, ReadScope);
|
||||
if (scopeResult is not null)
|
||||
@@ -53,6 +53,7 @@ internal static class ResolveEndpoint
|
||||
}
|
||||
|
||||
var logger = loggerFactory.CreateLogger("ResolveEndpoint");
|
||||
var signer = httpContext.RequestServices.GetService<IVexSigner>();
|
||||
|
||||
var productKeys = NormalizeValues(request.ProductKeys, request.Purls);
|
||||
var vulnerabilityIds = NormalizeValues(request.VulnerabilityIds);
|
||||
|
||||
@@ -6,6 +6,7 @@ using System.Diagnostics;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -922,6 +923,7 @@ app.MapGet("/openapi/excititor.json", () =>
|
||||
});
|
||||
|
||||
app.MapPost("/airgap/v1/vex/import", async (
|
||||
HttpContext httpContext,
|
||||
[FromServices] AirgapImportValidator validator,
|
||||
[FromServices] AirgapSignerTrustService trustService,
|
||||
[FromServices] AirgapModeEnforcer modeEnforcer,
|
||||
@@ -933,6 +935,12 @@ app.MapPost("/airgap/v1/vex/import", async (
|
||||
[FromBody] AirgapImportRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(httpContext, "vex.admin");
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
var logger = loggerFactory.CreateLogger("AirgapImport");
|
||||
var nowUtc = timeProvider.GetUtcNow();
|
||||
var tenantId = string.IsNullOrWhiteSpace(request.TenantId)
|
||||
@@ -942,41 +950,61 @@ app.MapPost("/airgap/v1/vex/import", async (
|
||||
? (int?)null
|
||||
: (int)Math.Round((nowUtc - request.SignedAt.Value).TotalSeconds);
|
||||
|
||||
var bundleId = (request.BundleId ?? string.Empty).Trim();
|
||||
var mirrorGeneration = (request.MirrorGeneration ?? string.Empty).Trim();
|
||||
var manifestPath = $"mirror/{bundleId}/{mirrorGeneration}/manifest.json";
|
||||
var evidenceLockerPath = $"evidence/{bundleId}/{mirrorGeneration}/bundle.ndjson";
|
||||
// WEB-AIRGAP-58-001: include manifest hash/path for audit + telemetry (pluggable crypto)
|
||||
var manifestHash = hashingService.ComputeHash($"{bundleId}:{mirrorGeneration}:{request.PayloadHash ?? string.Empty}");
|
||||
var actor = ResolveActor(httpContext);
|
||||
var scopes = ResolveScopes(httpContext);
|
||||
|
||||
var traceId = Activity.Current?.TraceId.ToString();
|
||||
var timeline = new List<AirgapTimelineEntry>();
|
||||
void RecordEvent(string eventType, string? code = null, string? message = null)
|
||||
void RecordEvent(string eventType, string? code = null, string? message = null, string? remediation = null)
|
||||
{
|
||||
var entry = new AirgapTimelineEntry
|
||||
{
|
||||
EventType = eventType,
|
||||
CreatedAt = nowUtc,
|
||||
TenantId = tenantId,
|
||||
BundleId = request.BundleId ?? string.Empty,
|
||||
MirrorGeneration = request.MirrorGeneration ?? string.Empty,
|
||||
BundleId = bundleId,
|
||||
MirrorGeneration = mirrorGeneration,
|
||||
StalenessSeconds = stalenessSeconds,
|
||||
ErrorCode = code,
|
||||
Message = message
|
||||
Message = message,
|
||||
Remediation = remediation,
|
||||
Actor = actor,
|
||||
Scopes = scopes
|
||||
};
|
||||
timeline.Add(entry);
|
||||
logger.LogInformation("Airgap timeline event {EventType} bundle={BundleId} gen={Gen} tenant={Tenant} code={Code}", eventType, entry.BundleId, entry.MirrorGeneration, tenantId, code);
|
||||
logger.LogInformation("Airgap timeline event {EventType} bundle={BundleId} gen={Gen} tenant={Tenant} code={Code} actor={Actor} scopes={Scopes}",
|
||||
eventType, entry.BundleId, entry.MirrorGeneration, tenantId, code, actor, scopes);
|
||||
|
||||
// WEB-AIRGAP-58-001: Emit timeline event to persistent store for SSE streaming
|
||||
_ = EmitTimelineEventAsync(eventType, code, message);
|
||||
_ = EmitTimelineEventAsync(eventType, code, message, remediation);
|
||||
}
|
||||
|
||||
async Task EmitTimelineEventAsync(string eventType, string? code, string? message)
|
||||
async Task EmitTimelineEventAsync(string eventType, string? code, string? message, string? remediation)
|
||||
{
|
||||
try
|
||||
{
|
||||
var attributes = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["bundle_id"] = request.BundleId ?? string.Empty,
|
||||
["mirror_generation"] = request.MirrorGeneration ?? string.Empty
|
||||
["bundle_id"] = bundleId,
|
||||
["mirror_generation"] = mirrorGeneration,
|
||||
["tenant_id"] = tenantId,
|
||||
["publisher"] = request.Publisher ?? string.Empty,
|
||||
["actor"] = actor,
|
||||
["scopes"] = scopes
|
||||
};
|
||||
if (stalenessSeconds.HasValue)
|
||||
{
|
||||
attributes["staleness_seconds"] = stalenessSeconds.Value.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
attributes["portable_manifest_hash"] = manifestHash;
|
||||
attributes["portable_manifest_path"] = manifestPath;
|
||||
attributes["evidence_path"] = evidenceLockerPath;
|
||||
if (!string.IsNullOrEmpty(code))
|
||||
{
|
||||
attributes["error_code"] = code;
|
||||
@@ -985,9 +1013,17 @@ app.MapPost("/airgap/v1/vex/import", async (
|
||||
{
|
||||
attributes["message"] = message;
|
||||
}
|
||||
if (!string.IsNullOrEmpty(remediation))
|
||||
{
|
||||
attributes["remediation"] = remediation;
|
||||
}
|
||||
if (!string.IsNullOrEmpty(request.TransparencyLog))
|
||||
{
|
||||
attributes["transparency_log"] = request.TransparencyLog;
|
||||
}
|
||||
|
||||
var eventId = $"airgap-{request.BundleId}-{request.MirrorGeneration}-{nowUtc:yyyyMMddHHmmssfff}";
|
||||
var streamId = $"airgap:{request.BundleId}:{request.MirrorGeneration}";
|
||||
var eventId = $"airgap-{bundleId}-{mirrorGeneration}-{nowUtc:yyyyMMddHHmmssfff}";
|
||||
var streamId = $"airgap:{bundleId}:{mirrorGeneration}";
|
||||
var evt = new TimelineEvent(
|
||||
eventId,
|
||||
tenantId,
|
||||
@@ -1015,56 +1051,33 @@ app.MapPost("/airgap/v1/vex/import", async (
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
var first = errors[0];
|
||||
RecordEvent("airgap.import.failed", first.Code, first.Message);
|
||||
return Results.BadRequest(new
|
||||
{
|
||||
error = new
|
||||
{
|
||||
code = first.Code,
|
||||
message = first.Message
|
||||
}
|
||||
});
|
||||
var errorResponse = AirgapErrorMapping.FromErrorCode(first.Code, first.Message);
|
||||
RecordEvent("airgap.import.failed", first.Code, first.Message, errorResponse.Remediation);
|
||||
return Results.BadRequest(errorResponse);
|
||||
}
|
||||
|
||||
if (!modeEnforcer.Validate(request, out var sealedCode, out var sealedMessage))
|
||||
{
|
||||
RecordEvent("airgap.import.failed", sealedCode, sealedMessage);
|
||||
return Results.Json(new
|
||||
{
|
||||
error = new
|
||||
{
|
||||
code = sealedCode,
|
||||
message = sealedMessage
|
||||
}
|
||||
}, statusCode: StatusCodes.Status403Forbidden);
|
||||
var errorResponse = AirgapErrorMapping.FromErrorCode(sealedCode ?? "AIRGAP_SEALED_MODE", sealedMessage ?? "Sealed mode violation.");
|
||||
RecordEvent("airgap.import.failed", sealedCode, sealedMessage, errorResponse.Remediation);
|
||||
return Results.Json(errorResponse, statusCode: StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
if (!trustService.Validate(request, out var trustCode, out var trustMessage))
|
||||
{
|
||||
RecordEvent("airgap.import.failed", trustCode, trustMessage);
|
||||
return Results.Json(new
|
||||
{
|
||||
error = new
|
||||
{
|
||||
code = trustCode,
|
||||
message = trustMessage
|
||||
}
|
||||
}, statusCode: StatusCodes.Status403Forbidden);
|
||||
var errorResponse = AirgapErrorMapping.FromErrorCode(trustCode ?? "AIRGAP_TRUST_FAILED", trustMessage ?? "Trust validation failed.");
|
||||
RecordEvent("airgap.import.failed", trustCode, trustMessage, errorResponse.Remediation);
|
||||
return Results.Json(errorResponse, statusCode: StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
var manifestPath = $"mirror/{request.BundleId}/{request.MirrorGeneration}/manifest.json";
|
||||
var evidenceLockerPath = $"evidence/{request.BundleId}/{request.MirrorGeneration}/bundle.ndjson";
|
||||
// CRYPTO-90-001: Use IVexHashingService for pluggable crypto algorithms
|
||||
var manifestHash = hashingService.ComputeHash($"{request.BundleId}:{request.MirrorGeneration}:{request.PayloadHash}");
|
||||
|
||||
RecordEvent("airgap.import.completed");
|
||||
|
||||
var record = new AirgapImportRecord
|
||||
{
|
||||
Id = $"{request.BundleId}:{request.MirrorGeneration}",
|
||||
Id = $"{bundleId}:{mirrorGeneration}",
|
||||
TenantId = tenantId,
|
||||
BundleId = request.BundleId!,
|
||||
MirrorGeneration = request.MirrorGeneration!,
|
||||
BundleId = bundleId,
|
||||
MirrorGeneration = mirrorGeneration,
|
||||
SignedAt = request.SignedAt!.Value,
|
||||
Publisher = request.Publisher!,
|
||||
PayloadHash = request.PayloadHash!,
|
||||
@@ -1075,7 +1088,9 @@ app.MapPost("/airgap/v1/vex/import", async (
|
||||
PortableManifestPath = manifestPath,
|
||||
PortableManifestHash = manifestHash,
|
||||
EvidenceLockerPath = evidenceLockerPath,
|
||||
Timeline = timeline
|
||||
Timeline = timeline,
|
||||
ImportActor = actor,
|
||||
ImportScopes = scopes
|
||||
};
|
||||
|
||||
try
|
||||
@@ -1095,10 +1110,10 @@ app.MapPost("/airgap/v1/vex/import", async (
|
||||
});
|
||||
}
|
||||
|
||||
return Results.Accepted($"/airgap/v1/vex/import/{request.BundleId}", new
|
||||
return Results.Accepted($"/airgap/v1/vex/import/{bundleId}", new
|
||||
{
|
||||
bundleId = request.BundleId,
|
||||
generation = request.MirrorGeneration,
|
||||
bundleId,
|
||||
generation = mirrorGeneration,
|
||||
manifest = manifestPath,
|
||||
evidence = evidenceLockerPath,
|
||||
manifestSha256 = manifestHash
|
||||
@@ -1107,6 +1122,26 @@ app.MapPost("/airgap/v1/vex/import", async (
|
||||
|
||||
// CRYPTO-90-001: ComputeSha256 removed - now using IVexHashingService for pluggable crypto
|
||||
|
||||
static string ResolveActor(HttpContext context)
|
||||
{
|
||||
var user = context.User;
|
||||
var actor = user?.FindFirst(ClaimTypes.NameIdentifier)?.Value
|
||||
?? user?.FindFirst("sub")?.Value
|
||||
?? "unknown";
|
||||
return actor;
|
||||
}
|
||||
|
||||
static string ResolveScopes(HttpContext context)
|
||||
{
|
||||
var user = context.User;
|
||||
var scopes = user?.FindAll("scope")
|
||||
.Concat(user.FindAll("scp"))
|
||||
.SelectMany(c => c.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray() ?? Array.Empty<string>();
|
||||
return scopes.Length == 0 ? string.Empty : string.Join(' ', scopes);
|
||||
}
|
||||
|
||||
app.MapPost("/v1/attestations/verify", async (
|
||||
[FromServices] IVexAttestationClient attestationClient,
|
||||
[FromBody] AttestationVerifyRequest request,
|
||||
|
||||
@@ -24,4 +24,13 @@ public sealed class AirgapTimelineEntry
|
||||
|
||||
public string? Message { get; set; }
|
||||
= null;
|
||||
|
||||
public string? Remediation { get; set; }
|
||||
= null;
|
||||
|
||||
public string? Actor { get; set; }
|
||||
= null;
|
||||
|
||||
public string? Scopes { get; set; }
|
||||
= null;
|
||||
}
|
||||
|
||||
@@ -343,6 +343,12 @@ public sealed class AirgapImportRecord
|
||||
public string EvidenceLockerPath { get; set; } = string.Empty;
|
||||
|
||||
public List<AirgapTimelineEntry> Timeline { get; set; } = new();
|
||||
|
||||
public string? ImportActor { get; set; }
|
||||
= null;
|
||||
|
||||
public string? ImportScopes { get; set; }
|
||||
= null;
|
||||
}
|
||||
|
||||
[BsonIgnoreExtraElements]
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Excititor.Storage.Mongo;
|
||||
using StellaOps.Excititor.WebService.Contracts;
|
||||
using StellaOps.Excititor.WebService.Services;
|
||||
using Xunit;
|
||||
@@ -25,21 +33,113 @@ public class AirgapImportEndpointTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Import_accepts_valid_payload()
|
||||
public async Task Import_records_actor_and_scope_and_timeline()
|
||||
{
|
||||
var validator = new AirgapImportValidator();
|
||||
var store = new CapturingAirgapStore();
|
||||
using var factory = new TestWebApplicationFactory(
|
||||
configureConfiguration: config =>
|
||||
{
|
||||
config.AddInMemoryCollection(new[]
|
||||
{
|
||||
new KeyValuePair<string, string?>("Excititor:Airgap:SealedMode", "false"),
|
||||
new KeyValuePair<string, string?>("Excititor:Airgap:MirrorOnly", "false"),
|
||||
});
|
||||
},
|
||||
configureServices: services =>
|
||||
{
|
||||
TestServiceOverrides.Apply(services);
|
||||
services.RemoveAll<IAirgapImportStore>();
|
||||
services.AddSingleton<IAirgapImportStore>(store);
|
||||
services.AddTestAuthentication();
|
||||
});
|
||||
|
||||
using var client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "vex.admin");
|
||||
|
||||
var request = new AirgapImportRequest
|
||||
{
|
||||
BundleId = "bundle-123",
|
||||
BundleId = "bundle-abc",
|
||||
MirrorGeneration = "1",
|
||||
SignedAt = DateTimeOffset.UtcNow,
|
||||
Publisher = "mirror-test",
|
||||
PayloadHash = "sha256:" + new string('a', 64),
|
||||
PayloadHash = "sha256:" + new string('b', 64),
|
||||
Signature = Convert.ToBase64String(new byte[] { 1, 2, 3 })
|
||||
};
|
||||
|
||||
var errors = validator.Validate(request, DateTimeOffset.UtcNow);
|
||||
var response = await client.PostAsJsonAsync("/airgap/v1/vex/import", request);
|
||||
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
||||
|
||||
Assert.Empty(errors);
|
||||
var saved = store.LastSaved;
|
||||
Assert.NotNull(saved);
|
||||
Assert.Equal("test-user", saved!.ImportActor);
|
||||
Assert.Equal("vex.admin", saved.ImportScopes);
|
||||
Assert.All(saved.Timeline, e =>
|
||||
{
|
||||
Assert.Equal("test-user", e.Actor);
|
||||
Assert.Equal("vex.admin", e.Scopes);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_returns_remediation_for_sealed_mode_violation()
|
||||
{
|
||||
var store = new CapturingAirgapStore();
|
||||
using var factory = new TestWebApplicationFactory(
|
||||
configureConfiguration: config =>
|
||||
{
|
||||
config.AddInMemoryCollection(new[]
|
||||
{
|
||||
new KeyValuePair<string, string?>("Excititor:Airgap:SealedMode", "true"),
|
||||
new KeyValuePair<string, string?>("Excititor:Airgap:MirrorOnly", "true"),
|
||||
});
|
||||
},
|
||||
configureServices: services =>
|
||||
{
|
||||
TestServiceOverrides.Apply(services);
|
||||
services.RemoveAll<IAirgapImportStore>();
|
||||
services.AddSingleton<IAirgapImportStore>(store);
|
||||
services.AddTestAuthentication();
|
||||
});
|
||||
|
||||
using var client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "vex.admin");
|
||||
|
||||
var request = new AirgapImportRequest
|
||||
{
|
||||
BundleId = "bundle-xyz",
|
||||
MirrorGeneration = "2",
|
||||
SignedAt = DateTimeOffset.UtcNow,
|
||||
Publisher = "mirror-test",
|
||||
PayloadHash = "sha256:" + new string('c', 64),
|
||||
PayloadUrl = "https://example.com/payload.tgz",
|
||||
Signature = Convert.ToBase64String(new byte[] { 9, 9, 9 })
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/airgap/v1/vex/import", request);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
|
||||
var raw = await response.Content.ReadAsStringAsync();
|
||||
Assert.Contains("AIRGAP_EGRESS_BLOCKED", raw);
|
||||
Assert.Contains("remediation", raw);
|
||||
}
|
||||
|
||||
private sealed class CapturingAirgapStore : IAirgapImportStore
|
||||
{
|
||||
public AirgapImportRecord? LastSaved { get; private set; }
|
||||
|
||||
public Task SaveAsync(AirgapImportRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
LastSaved = record;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<AirgapImportRecord?> FindByBundleIdAsync(string tenantId, string bundleId, string? mirrorGeneration, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(LastSaved);
|
||||
|
||||
public Task<IReadOnlyList<AirgapImportRecord>> ListAsync(string tenantId, string? publisherFilter, DateTimeOffset? importedAfter, int limit, int offset, CancellationToken cancellationToken)
|
||||
=> Task.FromResult<IReadOnlyList<AirgapImportRecord>>(LastSaved is null ? Array.Empty<AirgapImportRecord>() : new[] { LastSaved });
|
||||
|
||||
public Task<int> CountAsync(string tenantId, string? publisherFilter, DateTimeOffset? importedAfter, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(LastSaved is null ? 0 : 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ public sealed class EvidenceLockerEndpointTests : IAsyncLifetime
|
||||
{
|
||||
private readonly string _tempDir = Path.Combine(Path.GetTempPath(), "excititor-locker-tests-" + Guid.NewGuid());
|
||||
private TestWebApplicationFactory _factory = null!;
|
||||
private StubAirgapImportStore _stubStore = null!;
|
||||
|
||||
[Fact]
|
||||
public async Task LockerEndpoint_ReturnsHashesFromLocalFiles_WhenLockerRootConfigured()
|
||||
@@ -48,8 +49,7 @@ public sealed class EvidenceLockerEndpointTests : IAsyncLifetime
|
||||
SignedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
var stub = (StubAirgapImportStore)_factory.Services.GetRequiredService<IAirgapImportStore>();
|
||||
await stub.SaveAsync(record, CancellationToken.None);
|
||||
await _stubStore.SaveAsync(record, CancellationToken.None);
|
||||
|
||||
using var client = _factory.WithWebHostBuilder(_ => { }).CreateClient();
|
||||
|
||||
@@ -68,21 +68,61 @@ public sealed class EvidenceLockerEndpointTests : IAsyncLifetime
|
||||
Assert.Equal(12, payload.EvidenceSizeBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LockerManifestFile_StreamsContent_WithETag()
|
||||
{
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
var manifestRel = Path.Combine("locker", "bundle-2", "g2", "manifest.json");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(Path.Combine(_tempDir, manifestRel))!);
|
||||
var manifestBody = "{\"hello\":\"world\"}\n";
|
||||
await File.WriteAllTextAsync(Path.Combine(_tempDir, manifestRel), manifestBody);
|
||||
|
||||
var record = new AirgapImportRecord
|
||||
{
|
||||
Id = "bundle-2:g2",
|
||||
TenantId = "test",
|
||||
BundleId = "bundle-2",
|
||||
MirrorGeneration = "g2",
|
||||
Publisher = "pub",
|
||||
PayloadHash = "sha256:payload",
|
||||
Signature = "sig",
|
||||
PortableManifestPath = manifestRel,
|
||||
PortableManifestHash = "sha256:old",
|
||||
EvidenceLockerPath = "locker/bundle-2/g2/bundle.ndjson",
|
||||
ImportedAt = DateTimeOffset.UtcNow,
|
||||
SignedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
await _stubStore.SaveAsync(record, CancellationToken.None);
|
||||
using var client = _factory.WithWebHostBuilder(_ => { }).CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "vex.read");
|
||||
|
||||
var response = await client.GetAsync($"/evidence/vex/locker/{record.BundleId}/manifest/file");
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal("application/json", response.Content.Headers.ContentType!.MediaType);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.Equal(manifestBody, body);
|
||||
Assert.Equal("sha256:6a47c31b7b7c3b9a1dbc960669f4674ce088c8fc9d9a4f7e9fcc3f6a81f7b86c", response.Headers.ETag?.Tag?.Trim('"'));
|
||||
}
|
||||
|
||||
public Task InitializeAsync()
|
||||
{
|
||||
_factory = new TestWebApplicationFactory(
|
||||
configureConfiguration: config =>
|
||||
{
|
||||
config.AddInMemoryCollection(new[]
|
||||
_stubStore = new StubAirgapImportStore();
|
||||
_factory = new TestWebApplicationFactory(
|
||||
configureConfiguration: config =>
|
||||
{
|
||||
new KeyValuePair<string, string?>("Excititor:Airgap:LockerRootPath", _tempDir)
|
||||
config.AddInMemoryCollection(new[]
|
||||
{
|
||||
new KeyValuePair<string, string?>("Excititor:Airgap:LockerRootPath", _tempDir)
|
||||
});
|
||||
},
|
||||
configureServices: services =>
|
||||
{
|
||||
// Enable test authentication so evidence endpoints that enforce scopes accept the bearer header set in the tests.
|
||||
services.AddTestAuthentication();
|
||||
services.RemoveAll<IAirgapImportStore>();
|
||||
services.AddSingleton<IAirgapImportStore>(_stubStore);
|
||||
});
|
||||
},
|
||||
configureServices: services =>
|
||||
{
|
||||
services.RemoveAll<IAirgapImportStore>();
|
||||
services.AddSingleton<IAirgapImportStore>(new StubAirgapImportStore());
|
||||
});
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
<Compile Include="AirgapImportValidatorTests.cs" />
|
||||
<Compile Include="AirgapModeEnforcerTests.cs" />
|
||||
<Compile Include="EvidenceTelemetryTests.cs" />
|
||||
<Compile Include="EvidenceLockerEndpointTests.cs" />
|
||||
<Compile Include="DevRuntimeEnvironmentStub.cs" />
|
||||
<Compile Include="TestAuthentication.cs" />
|
||||
<Compile Include="TestServiceOverrides.cs" />
|
||||
|
||||
@@ -13,6 +13,7 @@ public sealed class RiskBundleBuilder
|
||||
{
|
||||
private const string ManifestVersion = "1";
|
||||
private static readonly string[] MandatoryProviderIds = { "cisa-kev" };
|
||||
private static readonly string[] OptionalOsvIds = { "osv" };
|
||||
private static readonly UnixFileMode DefaultFileMode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead;
|
||||
private static readonly DateTimeOffset FixedTimestamp = new(2024, 01, 01, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
@@ -37,6 +38,10 @@ public sealed class RiskBundleBuilder
|
||||
}
|
||||
|
||||
var providers = CollectProviders(request, cancellationToken);
|
||||
if (request.IncludeOsv && providers.All(p => p.ProviderId != "osv"))
|
||||
{
|
||||
throw new InvalidOperationException("IncludeOsv was requested but no OSV provider input was supplied.");
|
||||
}
|
||||
var manifest = BuildManifest(request.BundleId, providers);
|
||||
var manifestJson = JsonSerializer.Serialize(manifest, SerializerOptions);
|
||||
var rootHash = ComputeSha256(manifestJson);
|
||||
@@ -57,7 +62,9 @@ public sealed class RiskBundleBuilder
|
||||
var entries = new List<RiskBundleProviderEntry>(request.Providers.Count);
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var provider in request.Providers.OrderBy(p => p.ProviderId, StringComparer.Ordinal))
|
||||
foreach (var provider in request.Providers
|
||||
.Where(p => request.IncludeOsv || !OptionalOsvIds.Contains(p.ProviderId, StringComparer.Ordinal))
|
||||
.OrderBy(p => p.ProviderId, StringComparer.Ordinal))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
@@ -125,6 +132,11 @@ public sealed class RiskBundleBuilder
|
||||
}
|
||||
}
|
||||
|
||||
if (!request.IncludeOsv)
|
||||
{
|
||||
// Non-OSV builds may still carry optional providers; already filtered above.
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,9 @@ namespace StellaOps.ExportCenter.RiskBundles;
|
||||
public sealed record RiskBundleJobRequest(
|
||||
RiskBundleBuildRequest BuildRequest,
|
||||
string StoragePrefix = "risk-bundles",
|
||||
string BundleFileName = "risk-bundle.tar.gz");
|
||||
string BundleFileName = "risk-bundle.tar.gz",
|
||||
bool IncludeOsv = false,
|
||||
bool AllowStaleOptional = true);
|
||||
|
||||
public sealed record RiskBundleJobOutcome(
|
||||
RiskBundleManifest Manifest,
|
||||
|
||||
@@ -36,7 +36,9 @@ public sealed record RiskBundleBuildRequest(
|
||||
string BundlePrefix = "risk-bundles",
|
||||
string ManifestFileName = "provider-manifest.json",
|
||||
string ManifestDsseFileName = "provider-manifest.dsse",
|
||||
bool AllowMissingOptional = true);
|
||||
bool AllowMissingOptional = true,
|
||||
bool AllowStaleOptional = true,
|
||||
bool IncludeOsv = false);
|
||||
|
||||
public sealed record RiskBundleBuildResult(
|
||||
RiskBundleManifest Manifest,
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../StellaOps.ExportCenter/StellaOps.ExportCenter.Infrastructure/StellaOps.ExportCenter.Infrastructure.csproj" />
|
||||
<ProjectReference Include="../StellaOps.ExportCenter/StellaOps.ExportCenter.Core/StellaOps.ExportCenter.Core.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
|
||||
@@ -5,3 +5,4 @@
|
||||
| OFFKIT-GAPS-125-011 | DONE | Offline kit gap remediation (OK1–OK10) via bundle meta + policy layers. |
|
||||
| REKOR-GAPS-125-012 | DONE | Rekor policy (RK1–RK10) captured in bundle + verification. |
|
||||
| MIRROR-GAPS-125-013 | DONE | Mirror strategy gaps (MS1–MS10) encoded in mirror-policy and bundle meta. |
|
||||
| MIRROR-CRT-57-002 | DONE | Time-anchor DSSE emitted when SIGN_KEY is set; bundle meta + verifier check anchor integrity. |
|
||||
|
||||
@@ -44,6 +44,41 @@ else
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
# Optional: sign time anchor early so bundle meta can record DSSE hash
|
||||
if [[ -n "${SIGN_KEY:-}" ]]; then
|
||||
python - <<'PY'
|
||||
import base64, json, pathlib, os
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
stage = pathlib.Path(os.environ['STAGE'])
|
||||
anchor = stage / 'layers' / 'time-anchor.json'
|
||||
dsse_path = stage / 'layers' / 'time-anchor.dsse.json'
|
||||
out_path = stage.parent / 'time-anchor.dsse.json'
|
||||
key_path = pathlib.Path(os.environ['SIGN_KEY'])
|
||||
|
||||
key: Ed25519PrivateKey = serialization.load_pem_private_key(key_path.read_bytes(), password=None)
|
||||
payload = anchor.read_bytes()
|
||||
sig = key.sign(payload)
|
||||
pub_path = key_path.with_suffix('.pub')
|
||||
pub_key = serialization.load_pem_public_key(pub_path.read_bytes())
|
||||
pub_raw = pub_path.read_bytes()
|
||||
|
||||
def b64url(data: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
|
||||
|
||||
dsse = {
|
||||
"payloadType": "application/vnd.stellaops.time-anchor+json",
|
||||
"payload": b64url(payload),
|
||||
"signatures": [{"keyid": base64.urlsafe_b64encode(pub_raw).decode(), "sig": b64url(sig)}]
|
||||
}
|
||||
dsse_json = json.dumps(dsse, indent=2, sort_keys=True) + "\n"
|
||||
dsse_path.write_text(dsse_json, encoding='utf-8')
|
||||
out_path.write_text(dsse_json, encoding='utf-8')
|
||||
print(f"Signed time-anchor DSSE -> {out_path}")
|
||||
PY
|
||||
fi
|
||||
DATA
|
||||
fi
|
||||
|
||||
@@ -287,6 +322,7 @@ def sha(path: pathlib.Path) -> str:
|
||||
manifest_path = out / 'mirror-thin-v1.manifest.json'
|
||||
tar_path = out / 'mirror-thin-v1.tar.gz'
|
||||
time_anchor = stage / 'layers' / 'time-anchor.json'
|
||||
time_anchor_dsse = out / 'time-anchor.dsse.json'
|
||||
transport_plan = stage / 'layers' / 'transport-plan.json'
|
||||
rekor_policy = stage / 'layers' / 'rekor-policy.json'
|
||||
mirror_policy = stage / 'layers' / 'mirror-policy.json'
|
||||
@@ -312,14 +348,15 @@ bundle = {
|
||||
'checkpoint_freshness_seconds': fresh,
|
||||
'artifacts': {
|
||||
'manifest': {'path': manifest_path.name, 'sha256': sha(manifest_path)},
|
||||
'tarball': {'path': tar_path.name, 'sha256': sha(tar_path)},
|
||||
'manifest_dsse': {'path': 'mirror-thin-v1.manifest.dsse.json', 'sha256': None},
|
||||
'bundle_meta': {'path': 'mirror-thin-v1.bundle.json', 'sha256': None},
|
||||
'bundle_dsse': {'path': 'mirror-thin-v1.bundle.dsse.json', 'sha256': None},
|
||||
'time_anchor': {'path': time_anchor.name, 'sha256': sha(time_anchor)},
|
||||
'transport_plan': {'path': transport_plan.name, 'sha256': sha(transport_plan)},
|
||||
'rekor_policy': {'path': rekor_policy.name, 'sha256': sha(rekor_policy)},
|
||||
'mirror_policy': {'path': mirror_policy.name, 'sha256': sha(mirror_policy)},
|
||||
'tarball': {'path': tar_path.name, 'sha256': sha(tar_path)},
|
||||
'manifest_dsse': {'path': 'mirror-thin-v1.manifest.dsse.json', 'sha256': None},
|
||||
'bundle_meta': {'path': 'mirror-thin-v1.bundle.json', 'sha256': None},
|
||||
'bundle_dsse': {'path': 'mirror-thin-v1.bundle.dsse.json', 'sha256': None},
|
||||
'time_anchor': {'path': time_anchor.name, 'sha256': sha(time_anchor)},
|
||||
'time_anchor_dsse': {'path': time_anchor_dsse.name, 'sha256': sha(time_anchor_dsse)} if time_anchor_dsse.exists() else None,
|
||||
'transport_plan': {'path': transport_plan.name, 'sha256': sha(transport_plan)},
|
||||
'rekor_policy': {'path': rekor_policy.name, 'sha256': sha(rekor_policy)},
|
||||
'mirror_policy': {'path': mirror_policy.name, 'sha256': sha(mirror_policy)},
|
||||
'offline_policy': {'path': offline_policy.name, 'sha256': sha(offline_policy)},
|
||||
'artifact_hashes': {'path': artifact_hashes.name, 'sha256': sha(artifact_hashes)},
|
||||
'oci_index': {'path': 'oci/index.json', 'sha256': sha(oci_index)} if oci_index.exists() else None
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# StellaOps.Notify — Agent Charter
|
||||
|
||||
## Mission
|
||||
Deliver and operate the Notify module across WebService, Worker, and storage layers with PostgreSQL as the primary backing store while keeping Mongo fallbacks only where explicitly gated.
|
||||
Deliver and operate the Notify module across WebService, Worker, and storage layers with PostgreSQL as the sole backing store after cutover (no Mongo fallbacks).
|
||||
|
||||
## Required Reading
|
||||
- docs/modules/notify/architecture.md
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace StellaOps.Notify.WebService.Contracts;
|
||||
|
||||
internal sealed record DigestUpsertRequest(
|
||||
string ChannelId,
|
||||
string Recipient,
|
||||
string DigestKey,
|
||||
JsonArray? Events,
|
||||
DateTimeOffset? CollectUntil);
|
||||
@@ -20,9 +20,9 @@ public sealed class NotifyWebServiceOptions
|
||||
public AuthorityOptions Authority { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Mongo storage configuration for configuration state and audit logs.
|
||||
/// </summary>
|
||||
public StorageOptions Storage { get; set; } = new();
|
||||
/// Storage configuration (PostgreSQL-only after cutover).
|
||||
/// </summary>
|
||||
public StorageOptions Storage { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Plug-in loader configuration.
|
||||
@@ -71,7 +71,7 @@ public sealed class NotifyWebServiceOptions
|
||||
|
||||
public sealed class StorageOptions
|
||||
{
|
||||
public string Driver { get; set; } = "mongo";
|
||||
public string Driver { get; set; } = "postgres";
|
||||
|
||||
public string ConnectionString { get; set; } = string.Empty;
|
||||
|
||||
|
||||
@@ -19,29 +19,10 @@ internal static class NotifyWebServiceOptionsValidator
|
||||
ArgumentNullException.ThrowIfNull(storage);
|
||||
|
||||
var driver = storage.Driver ?? string.Empty;
|
||||
if (!string.Equals(driver, "mongo", StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.Equals(driver, "memory", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException($"Unsupported storage driver '{storage.Driver}'.");
|
||||
}
|
||||
|
||||
if (string.Equals(driver, "mongo", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(storage.ConnectionString))
|
||||
{
|
||||
throw new InvalidOperationException("notify:storage:connectionString must be provided.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(storage.Database))
|
||||
{
|
||||
throw new InvalidOperationException("notify:storage:database must be provided.");
|
||||
}
|
||||
|
||||
if (storage.CommandTimeoutSeconds <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("notify:storage:commandTimeoutSeconds must be positive.");
|
||||
}
|
||||
}
|
||||
if (!string.Equals(driver, "postgres", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException($"Unsupported storage driver '{storage.Driver}'. Only 'postgres' is supported after cutover.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateAuthority(NotifyWebServiceOptions.AuthorityOptions authority)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,11 +18,11 @@
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Notify.Models/StellaOps.Notify.Models.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Notify.Storage.Mongo/StellaOps.Notify.Storage.Mongo.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Notify.Storage.Postgres/StellaOps.Notify.Storage.Postgres.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Notify.Engine/StellaOps.Notify.Engine.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOps.Auth.Abstractions.csproj" />
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Client/StellaOps.Auth.Client.csproj" />
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOps.Auth.ServerIntegration.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
@@ -1,360 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Documents;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
namespace StellaOps.Notify.WebService.Storage.InMemory;
|
||||
|
||||
internal static class InMemoryStorageModule
|
||||
{
|
||||
public static IServiceCollection AddInMemoryNotifyStorage(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<InMemoryStore>();
|
||||
services.AddSingleton<INotifyRuleRepository, InMemoryRuleRepository>();
|
||||
services.AddSingleton<INotifyChannelRepository, InMemoryChannelRepository>();
|
||||
services.AddSingleton<INotifyTemplateRepository, InMemoryTemplateRepository>();
|
||||
services.AddSingleton<INotifyDeliveryRepository, InMemoryDeliveryRepository>();
|
||||
services.AddSingleton<INotifyDigestRepository, InMemoryDigestRepository>();
|
||||
services.AddSingleton<INotifyLockRepository, InMemoryLockRepository>();
|
||||
services.AddSingleton<INotifyAuditRepository, InMemoryAuditRepository>();
|
||||
return services;
|
||||
}
|
||||
|
||||
private sealed class InMemoryStore
|
||||
{
|
||||
public ConcurrentDictionary<string, ConcurrentDictionary<string, NotifyRule>> Rules { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
public ConcurrentDictionary<string, ConcurrentDictionary<string, NotifyChannel>> Channels { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
public ConcurrentDictionary<string, ConcurrentDictionary<string, NotifyTemplate>> Templates { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
public ConcurrentDictionary<string, ConcurrentDictionary<string, NotifyDelivery>> Deliveries { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
public ConcurrentDictionary<string, ConcurrentDictionary<string, NotifyDigestDocument>> Digests { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
public ConcurrentDictionary<string, ConcurrentDictionary<string, LockEntry>> Locks { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
public ConcurrentDictionary<string, ConcurrentQueue<NotifyAuditEntryDocument>> AuditEntries { get; } = new(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
private sealed class InMemoryRuleRepository : INotifyRuleRepository
|
||||
{
|
||||
private readonly InMemoryStore _store;
|
||||
|
||||
public InMemoryRuleRepository(InMemoryStore store) => _store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
|
||||
public Task UpsertAsync(NotifyRule rule, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var map = _store.Rules.GetOrAdd(rule.TenantId, _ => new ConcurrentDictionary<string, NotifyRule>(StringComparer.Ordinal));
|
||||
map[rule.RuleId] = rule;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<NotifyRule?> GetAsync(string tenantId, string ruleId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_store.Rules.TryGetValue(tenantId, out var map) && map.TryGetValue(ruleId, out var rule))
|
||||
{
|
||||
return Task.FromResult<NotifyRule?>(rule);
|
||||
}
|
||||
|
||||
return Task.FromResult<NotifyRule?>(null);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<NotifyRule>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_store.Rules.TryGetValue(tenantId, out var map))
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<NotifyRule>>(map.Values.OrderBy(static r => r.RuleId, StringComparer.Ordinal).ToList());
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<NotifyRule>>(Array.Empty<NotifyRule>());
|
||||
}
|
||||
|
||||
public Task DeleteAsync(string tenantId, string ruleId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_store.Rules.TryGetValue(tenantId, out var map))
|
||||
{
|
||||
map.TryRemove(ruleId, out _);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InMemoryChannelRepository : INotifyChannelRepository
|
||||
{
|
||||
private readonly InMemoryStore _store;
|
||||
|
||||
public InMemoryChannelRepository(InMemoryStore store) => _store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
|
||||
public Task UpsertAsync(NotifyChannel channel, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var map = _store.Channels.GetOrAdd(channel.TenantId, _ => new ConcurrentDictionary<string, NotifyChannel>(StringComparer.Ordinal));
|
||||
map[channel.ChannelId] = channel;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<NotifyChannel?> GetAsync(string tenantId, string channelId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_store.Channels.TryGetValue(tenantId, out var map) && map.TryGetValue(channelId, out var channel))
|
||||
{
|
||||
return Task.FromResult<NotifyChannel?>(channel);
|
||||
}
|
||||
|
||||
return Task.FromResult<NotifyChannel?>(null);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<NotifyChannel>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_store.Channels.TryGetValue(tenantId, out var map))
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<NotifyChannel>>(map.Values.OrderBy(static c => c.ChannelId, StringComparer.Ordinal).ToList());
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<NotifyChannel>>(Array.Empty<NotifyChannel>());
|
||||
}
|
||||
|
||||
public Task DeleteAsync(string tenantId, string channelId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_store.Channels.TryGetValue(tenantId, out var map))
|
||||
{
|
||||
map.TryRemove(channelId, out _);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InMemoryTemplateRepository : INotifyTemplateRepository
|
||||
{
|
||||
private readonly InMemoryStore _store;
|
||||
|
||||
public InMemoryTemplateRepository(InMemoryStore store) => _store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
|
||||
public Task UpsertAsync(NotifyTemplate template, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var map = _store.Templates.GetOrAdd(template.TenantId, _ => new ConcurrentDictionary<string, NotifyTemplate>(StringComparer.Ordinal));
|
||||
map[template.TemplateId] = template;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<NotifyTemplate?> GetAsync(string tenantId, string templateId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_store.Templates.TryGetValue(tenantId, out var map) && map.TryGetValue(templateId, out var template))
|
||||
{
|
||||
return Task.FromResult<NotifyTemplate?>(template);
|
||||
}
|
||||
|
||||
return Task.FromResult<NotifyTemplate?>(null);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<NotifyTemplate>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_store.Templates.TryGetValue(tenantId, out var map))
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<NotifyTemplate>>(map.Values.OrderBy(static t => t.TemplateId, StringComparer.Ordinal).ToList());
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<NotifyTemplate>>(Array.Empty<NotifyTemplate>());
|
||||
}
|
||||
|
||||
public Task DeleteAsync(string tenantId, string templateId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_store.Templates.TryGetValue(tenantId, out var map))
|
||||
{
|
||||
map.TryRemove(templateId, out _);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InMemoryDeliveryRepository : INotifyDeliveryRepository
|
||||
{
|
||||
private readonly InMemoryStore _store;
|
||||
|
||||
public InMemoryDeliveryRepository(InMemoryStore store) => _store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
|
||||
public Task AppendAsync(NotifyDelivery delivery, CancellationToken cancellationToken = default)
|
||||
=> UpdateAsync(delivery, cancellationToken);
|
||||
|
||||
public Task UpdateAsync(NotifyDelivery delivery, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var map = _store.Deliveries.GetOrAdd(delivery.TenantId, _ => new ConcurrentDictionary<string, NotifyDelivery>(StringComparer.Ordinal));
|
||||
map[delivery.DeliveryId] = delivery;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<NotifyDelivery?> GetAsync(string tenantId, string deliveryId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_store.Deliveries.TryGetValue(tenantId, out var map) && map.TryGetValue(deliveryId, out var delivery))
|
||||
{
|
||||
return Task.FromResult<NotifyDelivery?>(delivery);
|
||||
}
|
||||
|
||||
return Task.FromResult<NotifyDelivery?>(null);
|
||||
}
|
||||
|
||||
public Task<NotifyDeliveryQueryResult> QueryAsync(string tenantId, DateTimeOffset? since, string? status, int? limit, string? continuationToken = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_store.Deliveries.TryGetValue(tenantId, out var map))
|
||||
{
|
||||
return Task.FromResult(new NotifyDeliveryQueryResult(Array.Empty<NotifyDelivery>(), null));
|
||||
}
|
||||
|
||||
var query = map.Values.AsEnumerable();
|
||||
if (since.HasValue)
|
||||
{
|
||||
query = query.Where(d => d.CreatedAt >= since.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(status) && Enum.TryParse<NotifyDeliveryStatus>(status, true, out var parsed))
|
||||
{
|
||||
query = query.Where(d => d.Status == parsed);
|
||||
}
|
||||
|
||||
query = query.OrderByDescending(d => d.CreatedAt).ThenBy(d => d.DeliveryId, StringComparer.Ordinal);
|
||||
|
||||
if (limit.HasValue && limit.Value > 0)
|
||||
{
|
||||
query = query.Take(limit.Value);
|
||||
}
|
||||
|
||||
var items = query.ToList();
|
||||
return Task.FromResult(new NotifyDeliveryQueryResult(items, ContinuationToken: null));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InMemoryDigestRepository : INotifyDigestRepository
|
||||
{
|
||||
private readonly InMemoryStore _store;
|
||||
|
||||
public InMemoryDigestRepository(InMemoryStore store) => _store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
|
||||
public Task UpsertAsync(NotifyDigestDocument document, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var map = _store.Digests.GetOrAdd(document.TenantId, _ => new ConcurrentDictionary<string, NotifyDigestDocument>(StringComparer.Ordinal));
|
||||
map[document.ActionKey] = document;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<NotifyDigestDocument?> GetAsync(string tenantId, string actionKey, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_store.Digests.TryGetValue(tenantId, out var map) && map.TryGetValue(actionKey, out var document))
|
||||
{
|
||||
return Task.FromResult<NotifyDigestDocument?>(document);
|
||||
}
|
||||
|
||||
return Task.FromResult<NotifyDigestDocument?>(null);
|
||||
}
|
||||
|
||||
public Task RemoveAsync(string tenantId, string actionKey, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_store.Digests.TryGetValue(tenantId, out var map))
|
||||
{
|
||||
map.TryRemove(actionKey, out _);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InMemoryLockRepository : INotifyLockRepository
|
||||
{
|
||||
private readonly InMemoryStore _store;
|
||||
|
||||
public InMemoryLockRepository(InMemoryStore store) => _store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
|
||||
public Task<bool> TryAcquireAsync(string tenantId, string resource, string owner, TimeSpan ttl, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var map = _store.Locks.GetOrAdd(tenantId, _ => new ConcurrentDictionary<string, LockEntry>(StringComparer.Ordinal));
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var entry = map.GetOrAdd(resource, _ => new LockEntry(owner, now, now.Add(ttl)));
|
||||
|
||||
lock (entry)
|
||||
{
|
||||
if (entry.Owner == owner || entry.ExpiresAt <= now)
|
||||
{
|
||||
entry.Owner = owner;
|
||||
entry.AcquiredAt = now;
|
||||
entry.ExpiresAt = now.Add(ttl);
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
}
|
||||
|
||||
public Task ReleaseAsync(string tenantId, string resource, string owner, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_store.Locks.TryGetValue(tenantId, out var map) && map.TryGetValue(resource, out var entry))
|
||||
{
|
||||
lock (entry)
|
||||
{
|
||||
if (entry.Owner == owner)
|
||||
{
|
||||
map.TryRemove(resource, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InMemoryAuditRepository : INotifyAuditRepository
|
||||
{
|
||||
private readonly InMemoryStore _store;
|
||||
|
||||
public InMemoryAuditRepository(InMemoryStore store) => _store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
|
||||
public Task AppendAsync(NotifyAuditEntryDocument entry, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var queue = _store.AuditEntries.GetOrAdd(entry.TenantId, _ => new ConcurrentQueue<NotifyAuditEntryDocument>());
|
||||
queue.Enqueue(entry);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<NotifyAuditEntryDocument>> QueryAsync(string tenantId, DateTimeOffset? since, int? limit, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_store.AuditEntries.TryGetValue(tenantId, out var queue))
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<NotifyAuditEntryDocument>>(Array.Empty<NotifyAuditEntryDocument>());
|
||||
}
|
||||
|
||||
var items = queue
|
||||
.Where(entry => !since.HasValue || entry.Timestamp >= since.Value)
|
||||
.OrderByDescending(entry => entry.Timestamp)
|
||||
.ThenBy(entry => entry.Id.ToString(), StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
if (limit.HasValue && limit.Value > 0 && items.Count > limit.Value)
|
||||
{
|
||||
items = items.Take(limit.Value).ToList();
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<NotifyAuditEntryDocument>>(items);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class LockEntry
|
||||
{
|
||||
public LockEntry(string owner, DateTimeOffset acquiredAt, DateTimeOffset expiresAt)
|
||||
{
|
||||
Owner = owner;
|
||||
AcquiredAt = acquiredAt;
|
||||
ExpiresAt = expiresAt;
|
||||
}
|
||||
|
||||
public string Owner { get; set; }
|
||||
|
||||
public DateTimeOffset AcquiredAt { get; set; }
|
||||
|
||||
public DateTimeOffset ExpiresAt { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,422 +1,480 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.WebService", "StellaOps.Notify.WebService\StellaOps.Notify.WebService.csproj", "{DDE8646D-6EE3-44A1-B433-96943C93FFBB}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Configuration", "..\__Libraries\StellaOps.Configuration\StellaOps.Configuration.csproj", "{DB941060-49CE-49DA-A9A6-37B0C6FB1BFC}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Authority.Plugins.Abstractions", "..\Authority\StellaOps.Authority\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj", "{43063DE2-1226-4B4C-8047-E44A5632F4EB}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography", "..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj", "{F622175F-115B-4DF9-887F-1A517439FA89}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Abstractions", "..\Authority\StellaOps.Authority\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj", "{7C91C6FD-2F33-4C08-B6D1-0C2BF8FB24BC}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.DependencyInjection", "..\__Libraries\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj", "{4EAF4F80-CCE4-4CC3-B8ED-E1D5804A7C98}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{41F15E67-7190-CF23-3BC4-77E87134CADD}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Models", "__Libraries\StellaOps.Notify.Models\StellaOps.Notify.Models.csproj", "{59BFF1D2-B0E6-4E17-90ED-7F02669CE4E7}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Storage.Mongo", "__Libraries\StellaOps.Notify.Storage.Mongo\StellaOps.Notify.Storage.Mongo.csproj", "{BD147625-3614-49BB-B484-01200F28FF8B}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Engine", "__Libraries\StellaOps.Notify.Engine\StellaOps.Notify.Engine.csproj", "{046AF53B-0C95-4C2B-A608-8F17F4EEAE1C}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Plugin", "..\__Libraries\StellaOps.Plugin\StellaOps.Plugin.csproj", "{EFF370F5-788E-4E39-8D80-1DFC6563E45C}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Client", "..\Authority\StellaOps.Authority\StellaOps.Auth.Client\StellaOps.Auth.Client.csproj", "{4C5FB454-3C98-4634-8DE3-D06E1EDDAF05}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.ServerIntegration", "..\Authority\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj", "{894FBB67-F556-4695-A16D-8B4223D438A4}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Connectors.Email", "__Libraries\StellaOps.Notify.Connectors.Email\StellaOps.Notify.Connectors.Email.csproj", "{466C8F11-C43C-455A-AC28-5BF7AEBF04B0}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Connectors.Shared", "__Libraries\StellaOps.Notify.Connectors.Shared\StellaOps.Notify.Connectors.Shared.csproj", "{8048E985-85DE-4B05-AB76-67C436D6516F}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Connectors.Slack", "__Libraries\StellaOps.Notify.Connectors.Slack\StellaOps.Notify.Connectors.Slack.csproj", "{E94520D5-0D26-4869-AFFD-889D02616D9E}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Connectors.Teams", "__Libraries\StellaOps.Notify.Connectors.Teams\StellaOps.Notify.Connectors.Teams.csproj", "{2B6CFE1E-137C-4596-8C01-7EE486F9A15E}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Connectors.Webhook", "__Libraries\StellaOps.Notify.Connectors.Webhook\StellaOps.Notify.Connectors.Webhook.csproj", "{B5AB2C97-AA81-4C02-B62E-DBEE2EEDB43D}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Queue", "__Libraries\StellaOps.Notify.Queue\StellaOps.Notify.Queue.csproj", "{F151D567-5A17-4E2F-8D48-348701B1DC23}"
|
||||
EndProject
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.WebService", "StellaOps.Notify.WebService\StellaOps.Notify.WebService.csproj", "{DDE8646D-6EE3-44A1-B433-96943C93FFBB}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Configuration", "..\__Libraries\StellaOps.Configuration\StellaOps.Configuration.csproj", "{DB941060-49CE-49DA-A9A6-37B0C6FB1BFC}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Authority.Plugins.Abstractions", "..\Authority\StellaOps.Authority\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj", "{43063DE2-1226-4B4C-8047-E44A5632F4EB}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography", "..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj", "{F622175F-115B-4DF9-887F-1A517439FA89}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Abstractions", "..\Authority\StellaOps.Authority\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj", "{7C91C6FD-2F33-4C08-B6D1-0C2BF8FB24BC}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.DependencyInjection", "..\__Libraries\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj", "{4EAF4F80-CCE4-4CC3-B8ED-E1D5804A7C98}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{41F15E67-7190-CF23-3BC4-77E87134CADD}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Models", "__Libraries\StellaOps.Notify.Models\StellaOps.Notify.Models.csproj", "{59BFF1D2-B0E6-4E17-90ED-7F02669CE4E7}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Storage.Mongo", "__Libraries\StellaOps.Notify.Storage.Mongo\StellaOps.Notify.Storage.Mongo.csproj", "{BD147625-3614-49BB-B484-01200F28FF8B}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Engine", "__Libraries\StellaOps.Notify.Engine\StellaOps.Notify.Engine.csproj", "{046AF53B-0C95-4C2B-A608-8F17F4EEAE1C}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Plugin", "..\__Libraries\StellaOps.Plugin\StellaOps.Plugin.csproj", "{EFF370F5-788E-4E39-8D80-1DFC6563E45C}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Client", "..\Authority\StellaOps.Authority\StellaOps.Auth.Client\StellaOps.Auth.Client.csproj", "{4C5FB454-3C98-4634-8DE3-D06E1EDDAF05}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.ServerIntegration", "..\Authority\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj", "{894FBB67-F556-4695-A16D-8B4223D438A4}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Connectors.Email", "__Libraries\StellaOps.Notify.Connectors.Email\StellaOps.Notify.Connectors.Email.csproj", "{466C8F11-C43C-455A-AC28-5BF7AEBF04B0}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Connectors.Shared", "__Libraries\StellaOps.Notify.Connectors.Shared\StellaOps.Notify.Connectors.Shared.csproj", "{8048E985-85DE-4B05-AB76-67C436D6516F}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Connectors.Slack", "__Libraries\StellaOps.Notify.Connectors.Slack\StellaOps.Notify.Connectors.Slack.csproj", "{E94520D5-0D26-4869-AFFD-889D02616D9E}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Connectors.Teams", "__Libraries\StellaOps.Notify.Connectors.Teams\StellaOps.Notify.Connectors.Teams.csproj", "{2B6CFE1E-137C-4596-8C01-7EE486F9A15E}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Connectors.Webhook", "__Libraries\StellaOps.Notify.Connectors.Webhook\StellaOps.Notify.Connectors.Webhook.csproj", "{B5AB2C97-AA81-4C02-B62E-DBEE2EEDB43D}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Queue", "__Libraries\StellaOps.Notify.Queue\StellaOps.Notify.Queue.csproj", "{F151D567-5A17-4E2F-8D48-348701B1DC23}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Worker", "StellaOps.Notify.Worker\StellaOps.Notify.Worker.csproj", "{7BD19877-3C36-4BD0-8BF7-E1A245106D1C}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Tests", "__Tests", "{56BCE1BF-7CBA-7CE8-203D-A88051F1D642}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Connectors.Email.Tests", "__Tests\StellaOps.Notify.Connectors.Email.Tests\StellaOps.Notify.Connectors.Email.Tests.csproj", "{894EC02C-34C9-43C8-A01B-AF3A85FAE329}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Connectors.Slack.Tests", "__Tests\StellaOps.Notify.Connectors.Slack.Tests\StellaOps.Notify.Connectors.Slack.Tests.csproj", "{C4F45D77-7646-440D-A153-E52DBF95731D}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Connectors.Teams.Tests", "__Tests\StellaOps.Notify.Connectors.Teams.Tests\StellaOps.Notify.Connectors.Teams.Tests.csproj", "{DE4E8371-7933-4D96-9023-36F5D2DDFC56}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Models.Tests", "__Tests\StellaOps.Notify.Models.Tests\StellaOps.Notify.Models.Tests.csproj", "{08428B42-D650-430E-9E51-8A3B18B4C984}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Queue.Tests", "__Tests\StellaOps.Notify.Queue.Tests\StellaOps.Notify.Queue.Tests.csproj", "{84451047-1B04-42D1-9C02-762564CC2B40}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Storage.Mongo.Tests", "__Tests\StellaOps.Notify.Storage.Mongo.Tests\StellaOps.Notify.Storage.Mongo.Tests.csproj", "{C63A47A3-18A6-4251-95A7-392EB58D7B87}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.WebService.Tests", "__Tests\StellaOps.Notify.WebService.Tests\StellaOps.Notify.WebService.Tests.csproj", "{EDAF907C-18A1-4099-9D3B-169B38400420}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Worker.Tests", "__Tests\StellaOps.Notify.Worker.Tests\StellaOps.Notify.Worker.Tests.csproj", "{66801106-E70A-4D33-8A08-A46C08902603}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Debug|x64 = Debug|x64
|
||||
Debug|x86 = Debug|x86
|
||||
Release|Any CPU = Release|Any CPU
|
||||
Release|x64 = Release|x64
|
||||
Release|x86 = Release|x86
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{DDE8646D-6EE3-44A1-B433-96943C93FFBB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{DDE8646D-6EE3-44A1-B433-96943C93FFBB}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{DDE8646D-6EE3-44A1-B433-96943C93FFBB}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{DDE8646D-6EE3-44A1-B433-96943C93FFBB}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{DDE8646D-6EE3-44A1-B433-96943C93FFBB}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{DDE8646D-6EE3-44A1-B433-96943C93FFBB}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{DDE8646D-6EE3-44A1-B433-96943C93FFBB}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{DDE8646D-6EE3-44A1-B433-96943C93FFBB}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{DDE8646D-6EE3-44A1-B433-96943C93FFBB}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{DDE8646D-6EE3-44A1-B433-96943C93FFBB}.Release|x64.Build.0 = Release|Any CPU
|
||||
{DDE8646D-6EE3-44A1-B433-96943C93FFBB}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{DDE8646D-6EE3-44A1-B433-96943C93FFBB}.Release|x86.Build.0 = Release|Any CPU
|
||||
{DB941060-49CE-49DA-A9A6-37B0C6FB1BFC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{DB941060-49CE-49DA-A9A6-37B0C6FB1BFC}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{DB941060-49CE-49DA-A9A6-37B0C6FB1BFC}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{DB941060-49CE-49DA-A9A6-37B0C6FB1BFC}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{DB941060-49CE-49DA-A9A6-37B0C6FB1BFC}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{DB941060-49CE-49DA-A9A6-37B0C6FB1BFC}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{DB941060-49CE-49DA-A9A6-37B0C6FB1BFC}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{DB941060-49CE-49DA-A9A6-37B0C6FB1BFC}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{DB941060-49CE-49DA-A9A6-37B0C6FB1BFC}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{DB941060-49CE-49DA-A9A6-37B0C6FB1BFC}.Release|x64.Build.0 = Release|Any CPU
|
||||
{DB941060-49CE-49DA-A9A6-37B0C6FB1BFC}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{DB941060-49CE-49DA-A9A6-37B0C6FB1BFC}.Release|x86.Build.0 = Release|Any CPU
|
||||
{43063DE2-1226-4B4C-8047-E44A5632F4EB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{43063DE2-1226-4B4C-8047-E44A5632F4EB}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{43063DE2-1226-4B4C-8047-E44A5632F4EB}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{43063DE2-1226-4B4C-8047-E44A5632F4EB}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{43063DE2-1226-4B4C-8047-E44A5632F4EB}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{43063DE2-1226-4B4C-8047-E44A5632F4EB}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{43063DE2-1226-4B4C-8047-E44A5632F4EB}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{43063DE2-1226-4B4C-8047-E44A5632F4EB}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{43063DE2-1226-4B4C-8047-E44A5632F4EB}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{43063DE2-1226-4B4C-8047-E44A5632F4EB}.Release|x64.Build.0 = Release|Any CPU
|
||||
{43063DE2-1226-4B4C-8047-E44A5632F4EB}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{43063DE2-1226-4B4C-8047-E44A5632F4EB}.Release|x86.Build.0 = Release|Any CPU
|
||||
{F622175F-115B-4DF9-887F-1A517439FA89}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{F622175F-115B-4DF9-887F-1A517439FA89}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{F622175F-115B-4DF9-887F-1A517439FA89}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{F622175F-115B-4DF9-887F-1A517439FA89}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{F622175F-115B-4DF9-887F-1A517439FA89}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{F622175F-115B-4DF9-887F-1A517439FA89}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{F622175F-115B-4DF9-887F-1A517439FA89}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{F622175F-115B-4DF9-887F-1A517439FA89}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{F622175F-115B-4DF9-887F-1A517439FA89}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{F622175F-115B-4DF9-887F-1A517439FA89}.Release|x64.Build.0 = Release|Any CPU
|
||||
{F622175F-115B-4DF9-887F-1A517439FA89}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{F622175F-115B-4DF9-887F-1A517439FA89}.Release|x86.Build.0 = Release|Any CPU
|
||||
{7C91C6FD-2F33-4C08-B6D1-0C2BF8FB24BC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{7C91C6FD-2F33-4C08-B6D1-0C2BF8FB24BC}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{7C91C6FD-2F33-4C08-B6D1-0C2BF8FB24BC}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{7C91C6FD-2F33-4C08-B6D1-0C2BF8FB24BC}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{7C91C6FD-2F33-4C08-B6D1-0C2BF8FB24BC}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{7C91C6FD-2F33-4C08-B6D1-0C2BF8FB24BC}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{7C91C6FD-2F33-4C08-B6D1-0C2BF8FB24BC}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{7C91C6FD-2F33-4C08-B6D1-0C2BF8FB24BC}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{7C91C6FD-2F33-4C08-B6D1-0C2BF8FB24BC}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{7C91C6FD-2F33-4C08-B6D1-0C2BF8FB24BC}.Release|x64.Build.0 = Release|Any CPU
|
||||
{7C91C6FD-2F33-4C08-B6D1-0C2BF8FB24BC}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{7C91C6FD-2F33-4C08-B6D1-0C2BF8FB24BC}.Release|x86.Build.0 = Release|Any CPU
|
||||
{4EAF4F80-CCE4-4CC3-B8ED-E1D5804A7C98}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{4EAF4F80-CCE4-4CC3-B8ED-E1D5804A7C98}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{4EAF4F80-CCE4-4CC3-B8ED-E1D5804A7C98}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{4EAF4F80-CCE4-4CC3-B8ED-E1D5804A7C98}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{4EAF4F80-CCE4-4CC3-B8ED-E1D5804A7C98}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{4EAF4F80-CCE4-4CC3-B8ED-E1D5804A7C98}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{4EAF4F80-CCE4-4CC3-B8ED-E1D5804A7C98}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{4EAF4F80-CCE4-4CC3-B8ED-E1D5804A7C98}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{4EAF4F80-CCE4-4CC3-B8ED-E1D5804A7C98}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{4EAF4F80-CCE4-4CC3-B8ED-E1D5804A7C98}.Release|x64.Build.0 = Release|Any CPU
|
||||
{4EAF4F80-CCE4-4CC3-B8ED-E1D5804A7C98}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{4EAF4F80-CCE4-4CC3-B8ED-E1D5804A7C98}.Release|x86.Build.0 = Release|Any CPU
|
||||
{59BFF1D2-B0E6-4E17-90ED-7F02669CE4E7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{59BFF1D2-B0E6-4E17-90ED-7F02669CE4E7}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{59BFF1D2-B0E6-4E17-90ED-7F02669CE4E7}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{59BFF1D2-B0E6-4E17-90ED-7F02669CE4E7}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{59BFF1D2-B0E6-4E17-90ED-7F02669CE4E7}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{59BFF1D2-B0E6-4E17-90ED-7F02669CE4E7}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{59BFF1D2-B0E6-4E17-90ED-7F02669CE4E7}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{59BFF1D2-B0E6-4E17-90ED-7F02669CE4E7}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{59BFF1D2-B0E6-4E17-90ED-7F02669CE4E7}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{59BFF1D2-B0E6-4E17-90ED-7F02669CE4E7}.Release|x64.Build.0 = Release|Any CPU
|
||||
{59BFF1D2-B0E6-4E17-90ED-7F02669CE4E7}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{59BFF1D2-B0E6-4E17-90ED-7F02669CE4E7}.Release|x86.Build.0 = Release|Any CPU
|
||||
{BD147625-3614-49BB-B484-01200F28FF8B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{BD147625-3614-49BB-B484-01200F28FF8B}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{BD147625-3614-49BB-B484-01200F28FF8B}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{BD147625-3614-49BB-B484-01200F28FF8B}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{BD147625-3614-49BB-B484-01200F28FF8B}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{BD147625-3614-49BB-B484-01200F28FF8B}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{BD147625-3614-49BB-B484-01200F28FF8B}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{BD147625-3614-49BB-B484-01200F28FF8B}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{BD147625-3614-49BB-B484-01200F28FF8B}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{BD147625-3614-49BB-B484-01200F28FF8B}.Release|x64.Build.0 = Release|Any CPU
|
||||
{BD147625-3614-49BB-B484-01200F28FF8B}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{BD147625-3614-49BB-B484-01200F28FF8B}.Release|x86.Build.0 = Release|Any CPU
|
||||
{046AF53B-0C95-4C2B-A608-8F17F4EEAE1C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{046AF53B-0C95-4C2B-A608-8F17F4EEAE1C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{046AF53B-0C95-4C2B-A608-8F17F4EEAE1C}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{046AF53B-0C95-4C2B-A608-8F17F4EEAE1C}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{046AF53B-0C95-4C2B-A608-8F17F4EEAE1C}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{046AF53B-0C95-4C2B-A608-8F17F4EEAE1C}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{046AF53B-0C95-4C2B-A608-8F17F4EEAE1C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{046AF53B-0C95-4C2B-A608-8F17F4EEAE1C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{046AF53B-0C95-4C2B-A608-8F17F4EEAE1C}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{046AF53B-0C95-4C2B-A608-8F17F4EEAE1C}.Release|x64.Build.0 = Release|Any CPU
|
||||
{046AF53B-0C95-4C2B-A608-8F17F4EEAE1C}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{046AF53B-0C95-4C2B-A608-8F17F4EEAE1C}.Release|x86.Build.0 = Release|Any CPU
|
||||
{EFF370F5-788E-4E39-8D80-1DFC6563E45C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{EFF370F5-788E-4E39-8D80-1DFC6563E45C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{EFF370F5-788E-4E39-8D80-1DFC6563E45C}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{EFF370F5-788E-4E39-8D80-1DFC6563E45C}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{EFF370F5-788E-4E39-8D80-1DFC6563E45C}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{EFF370F5-788E-4E39-8D80-1DFC6563E45C}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{EFF370F5-788E-4E39-8D80-1DFC6563E45C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{EFF370F5-788E-4E39-8D80-1DFC6563E45C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{EFF370F5-788E-4E39-8D80-1DFC6563E45C}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{EFF370F5-788E-4E39-8D80-1DFC6563E45C}.Release|x64.Build.0 = Release|Any CPU
|
||||
{EFF370F5-788E-4E39-8D80-1DFC6563E45C}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{EFF370F5-788E-4E39-8D80-1DFC6563E45C}.Release|x86.Build.0 = Release|Any CPU
|
||||
{4C5FB454-3C98-4634-8DE3-D06E1EDDAF05}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{4C5FB454-3C98-4634-8DE3-D06E1EDDAF05}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{4C5FB454-3C98-4634-8DE3-D06E1EDDAF05}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{4C5FB454-3C98-4634-8DE3-D06E1EDDAF05}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{4C5FB454-3C98-4634-8DE3-D06E1EDDAF05}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{4C5FB454-3C98-4634-8DE3-D06E1EDDAF05}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{4C5FB454-3C98-4634-8DE3-D06E1EDDAF05}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{4C5FB454-3C98-4634-8DE3-D06E1EDDAF05}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{4C5FB454-3C98-4634-8DE3-D06E1EDDAF05}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{4C5FB454-3C98-4634-8DE3-D06E1EDDAF05}.Release|x64.Build.0 = Release|Any CPU
|
||||
{4C5FB454-3C98-4634-8DE3-D06E1EDDAF05}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{4C5FB454-3C98-4634-8DE3-D06E1EDDAF05}.Release|x86.Build.0 = Release|Any CPU
|
||||
{894FBB67-F556-4695-A16D-8B4223D438A4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{894FBB67-F556-4695-A16D-8B4223D438A4}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{894FBB67-F556-4695-A16D-8B4223D438A4}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{894FBB67-F556-4695-A16D-8B4223D438A4}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{894FBB67-F556-4695-A16D-8B4223D438A4}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{894FBB67-F556-4695-A16D-8B4223D438A4}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{894FBB67-F556-4695-A16D-8B4223D438A4}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{894FBB67-F556-4695-A16D-8B4223D438A4}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{894FBB67-F556-4695-A16D-8B4223D438A4}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{894FBB67-F556-4695-A16D-8B4223D438A4}.Release|x64.Build.0 = Release|Any CPU
|
||||
{894FBB67-F556-4695-A16D-8B4223D438A4}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{894FBB67-F556-4695-A16D-8B4223D438A4}.Release|x86.Build.0 = Release|Any CPU
|
||||
{466C8F11-C43C-455A-AC28-5BF7AEBF04B0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{466C8F11-C43C-455A-AC28-5BF7AEBF04B0}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{466C8F11-C43C-455A-AC28-5BF7AEBF04B0}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{466C8F11-C43C-455A-AC28-5BF7AEBF04B0}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{466C8F11-C43C-455A-AC28-5BF7AEBF04B0}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{466C8F11-C43C-455A-AC28-5BF7AEBF04B0}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{466C8F11-C43C-455A-AC28-5BF7AEBF04B0}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{466C8F11-C43C-455A-AC28-5BF7AEBF04B0}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{466C8F11-C43C-455A-AC28-5BF7AEBF04B0}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{466C8F11-C43C-455A-AC28-5BF7AEBF04B0}.Release|x64.Build.0 = Release|Any CPU
|
||||
{466C8F11-C43C-455A-AC28-5BF7AEBF04B0}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{466C8F11-C43C-455A-AC28-5BF7AEBF04B0}.Release|x86.Build.0 = Release|Any CPU
|
||||
{8048E985-85DE-4B05-AB76-67C436D6516F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{8048E985-85DE-4B05-AB76-67C436D6516F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{8048E985-85DE-4B05-AB76-67C436D6516F}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{8048E985-85DE-4B05-AB76-67C436D6516F}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{8048E985-85DE-4B05-AB76-67C436D6516F}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{8048E985-85DE-4B05-AB76-67C436D6516F}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{8048E985-85DE-4B05-AB76-67C436D6516F}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{8048E985-85DE-4B05-AB76-67C436D6516F}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{8048E985-85DE-4B05-AB76-67C436D6516F}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{8048E985-85DE-4B05-AB76-67C436D6516F}.Release|x64.Build.0 = Release|Any CPU
|
||||
{8048E985-85DE-4B05-AB76-67C436D6516F}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{8048E985-85DE-4B05-AB76-67C436D6516F}.Release|x86.Build.0 = Release|Any CPU
|
||||
{E94520D5-0D26-4869-AFFD-889D02616D9E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{E94520D5-0D26-4869-AFFD-889D02616D9E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{E94520D5-0D26-4869-AFFD-889D02616D9E}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{E94520D5-0D26-4869-AFFD-889D02616D9E}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{E94520D5-0D26-4869-AFFD-889D02616D9E}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{E94520D5-0D26-4869-AFFD-889D02616D9E}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{E94520D5-0D26-4869-AFFD-889D02616D9E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{E94520D5-0D26-4869-AFFD-889D02616D9E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{E94520D5-0D26-4869-AFFD-889D02616D9E}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{E94520D5-0D26-4869-AFFD-889D02616D9E}.Release|x64.Build.0 = Release|Any CPU
|
||||
{E94520D5-0D26-4869-AFFD-889D02616D9E}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{E94520D5-0D26-4869-AFFD-889D02616D9E}.Release|x86.Build.0 = Release|Any CPU
|
||||
{2B6CFE1E-137C-4596-8C01-7EE486F9A15E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{2B6CFE1E-137C-4596-8C01-7EE486F9A15E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{2B6CFE1E-137C-4596-8C01-7EE486F9A15E}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{2B6CFE1E-137C-4596-8C01-7EE486F9A15E}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{2B6CFE1E-137C-4596-8C01-7EE486F9A15E}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{2B6CFE1E-137C-4596-8C01-7EE486F9A15E}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{2B6CFE1E-137C-4596-8C01-7EE486F9A15E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{2B6CFE1E-137C-4596-8C01-7EE486F9A15E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{2B6CFE1E-137C-4596-8C01-7EE486F9A15E}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{2B6CFE1E-137C-4596-8C01-7EE486F9A15E}.Release|x64.Build.0 = Release|Any CPU
|
||||
{2B6CFE1E-137C-4596-8C01-7EE486F9A15E}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{2B6CFE1E-137C-4596-8C01-7EE486F9A15E}.Release|x86.Build.0 = Release|Any CPU
|
||||
{B5AB2C97-AA81-4C02-B62E-DBEE2EEDB43D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B5AB2C97-AA81-4C02-B62E-DBEE2EEDB43D}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B5AB2C97-AA81-4C02-B62E-DBEE2EEDB43D}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{B5AB2C97-AA81-4C02-B62E-DBEE2EEDB43D}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{B5AB2C97-AA81-4C02-B62E-DBEE2EEDB43D}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{B5AB2C97-AA81-4C02-B62E-DBEE2EEDB43D}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{B5AB2C97-AA81-4C02-B62E-DBEE2EEDB43D}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B5AB2C97-AA81-4C02-B62E-DBEE2EEDB43D}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{B5AB2C97-AA81-4C02-B62E-DBEE2EEDB43D}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{B5AB2C97-AA81-4C02-B62E-DBEE2EEDB43D}.Release|x64.Build.0 = Release|Any CPU
|
||||
{B5AB2C97-AA81-4C02-B62E-DBEE2EEDB43D}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{B5AB2C97-AA81-4C02-B62E-DBEE2EEDB43D}.Release|x86.Build.0 = Release|Any CPU
|
||||
{F151D567-5A17-4E2F-8D48-348701B1DC23}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{F151D567-5A17-4E2F-8D48-348701B1DC23}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{F151D567-5A17-4E2F-8D48-348701B1DC23}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{F151D567-5A17-4E2F-8D48-348701B1DC23}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{F151D567-5A17-4E2F-8D48-348701B1DC23}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{F151D567-5A17-4E2F-8D48-348701B1DC23}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{F151D567-5A17-4E2F-8D48-348701B1DC23}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{F151D567-5A17-4E2F-8D48-348701B1DC23}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{F151D567-5A17-4E2F-8D48-348701B1DC23}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{F151D567-5A17-4E2F-8D48-348701B1DC23}.Release|x64.Build.0 = Release|Any CPU
|
||||
{F151D567-5A17-4E2F-8D48-348701B1DC23}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{F151D567-5A17-4E2F-8D48-348701B1DC23}.Release|x86.Build.0 = Release|Any CPU
|
||||
{7BD19877-3C36-4BD0-8BF7-E1A245106D1C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{7BD19877-3C36-4BD0-8BF7-E1A245106D1C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{7BD19877-3C36-4BD0-8BF7-E1A245106D1C}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{7BD19877-3C36-4BD0-8BF7-E1A245106D1C}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{7BD19877-3C36-4BD0-8BF7-E1A245106D1C}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{7BD19877-3C36-4BD0-8BF7-E1A245106D1C}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{7BD19877-3C36-4BD0-8BF7-E1A245106D1C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{7BD19877-3C36-4BD0-8BF7-E1A245106D1C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{7BD19877-3C36-4BD0-8BF7-E1A245106D1C}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{7BD19877-3C36-4BD0-8BF7-E1A245106D1C}.Release|x64.Build.0 = Release|Any CPU
|
||||
{7BD19877-3C36-4BD0-8BF7-E1A245106D1C}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{7BD19877-3C36-4BD0-8BF7-E1A245106D1C}.Release|x86.Build.0 = Release|Any CPU
|
||||
{894EC02C-34C9-43C8-A01B-AF3A85FAE329}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{894EC02C-34C9-43C8-A01B-AF3A85FAE329}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{894EC02C-34C9-43C8-A01B-AF3A85FAE329}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{894EC02C-34C9-43C8-A01B-AF3A85FAE329}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{894EC02C-34C9-43C8-A01B-AF3A85FAE329}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{894EC02C-34C9-43C8-A01B-AF3A85FAE329}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{894EC02C-34C9-43C8-A01B-AF3A85FAE329}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{894EC02C-34C9-43C8-A01B-AF3A85FAE329}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{894EC02C-34C9-43C8-A01B-AF3A85FAE329}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{894EC02C-34C9-43C8-A01B-AF3A85FAE329}.Release|x64.Build.0 = Release|Any CPU
|
||||
{894EC02C-34C9-43C8-A01B-AF3A85FAE329}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{894EC02C-34C9-43C8-A01B-AF3A85FAE329}.Release|x86.Build.0 = Release|Any CPU
|
||||
{C4F45D77-7646-440D-A153-E52DBF95731D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{C4F45D77-7646-440D-A153-E52DBF95731D}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{C4F45D77-7646-440D-A153-E52DBF95731D}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{C4F45D77-7646-440D-A153-E52DBF95731D}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{C4F45D77-7646-440D-A153-E52DBF95731D}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{C4F45D77-7646-440D-A153-E52DBF95731D}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{C4F45D77-7646-440D-A153-E52DBF95731D}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{C4F45D77-7646-440D-A153-E52DBF95731D}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{C4F45D77-7646-440D-A153-E52DBF95731D}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{C4F45D77-7646-440D-A153-E52DBF95731D}.Release|x64.Build.0 = Release|Any CPU
|
||||
{C4F45D77-7646-440D-A153-E52DBF95731D}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{C4F45D77-7646-440D-A153-E52DBF95731D}.Release|x86.Build.0 = Release|Any CPU
|
||||
{DE4E8371-7933-4D96-9023-36F5D2DDFC56}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{DE4E8371-7933-4D96-9023-36F5D2DDFC56}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{DE4E8371-7933-4D96-9023-36F5D2DDFC56}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{DE4E8371-7933-4D96-9023-36F5D2DDFC56}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{DE4E8371-7933-4D96-9023-36F5D2DDFC56}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{DE4E8371-7933-4D96-9023-36F5D2DDFC56}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{DE4E8371-7933-4D96-9023-36F5D2DDFC56}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{DE4E8371-7933-4D96-9023-36F5D2DDFC56}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{DE4E8371-7933-4D96-9023-36F5D2DDFC56}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{DE4E8371-7933-4D96-9023-36F5D2DDFC56}.Release|x64.Build.0 = Release|Any CPU
|
||||
{DE4E8371-7933-4D96-9023-36F5D2DDFC56}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{DE4E8371-7933-4D96-9023-36F5D2DDFC56}.Release|x86.Build.0 = Release|Any CPU
|
||||
{08428B42-D650-430E-9E51-8A3B18B4C984}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{08428B42-D650-430E-9E51-8A3B18B4C984}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{08428B42-D650-430E-9E51-8A3B18B4C984}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{08428B42-D650-430E-9E51-8A3B18B4C984}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{08428B42-D650-430E-9E51-8A3B18B4C984}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{08428B42-D650-430E-9E51-8A3B18B4C984}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{08428B42-D650-430E-9E51-8A3B18B4C984}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{08428B42-D650-430E-9E51-8A3B18B4C984}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{08428B42-D650-430E-9E51-8A3B18B4C984}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{08428B42-D650-430E-9E51-8A3B18B4C984}.Release|x64.Build.0 = Release|Any CPU
|
||||
{08428B42-D650-430E-9E51-8A3B18B4C984}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{08428B42-D650-430E-9E51-8A3B18B4C984}.Release|x86.Build.0 = Release|Any CPU
|
||||
{84451047-1B04-42D1-9C02-762564CC2B40}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{84451047-1B04-42D1-9C02-762564CC2B40}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{84451047-1B04-42D1-9C02-762564CC2B40}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{84451047-1B04-42D1-9C02-762564CC2B40}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{84451047-1B04-42D1-9C02-762564CC2B40}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{84451047-1B04-42D1-9C02-762564CC2B40}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{84451047-1B04-42D1-9C02-762564CC2B40}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{84451047-1B04-42D1-9C02-762564CC2B40}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{84451047-1B04-42D1-9C02-762564CC2B40}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{84451047-1B04-42D1-9C02-762564CC2B40}.Release|x64.Build.0 = Release|Any CPU
|
||||
{84451047-1B04-42D1-9C02-762564CC2B40}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{84451047-1B04-42D1-9C02-762564CC2B40}.Release|x86.Build.0 = Release|Any CPU
|
||||
{C63A47A3-18A6-4251-95A7-392EB58D7B87}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{C63A47A3-18A6-4251-95A7-392EB58D7B87}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{C63A47A3-18A6-4251-95A7-392EB58D7B87}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{C63A47A3-18A6-4251-95A7-392EB58D7B87}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{C63A47A3-18A6-4251-95A7-392EB58D7B87}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{C63A47A3-18A6-4251-95A7-392EB58D7B87}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{C63A47A3-18A6-4251-95A7-392EB58D7B87}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{C63A47A3-18A6-4251-95A7-392EB58D7B87}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{C63A47A3-18A6-4251-95A7-392EB58D7B87}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{C63A47A3-18A6-4251-95A7-392EB58D7B87}.Release|x64.Build.0 = Release|Any CPU
|
||||
{C63A47A3-18A6-4251-95A7-392EB58D7B87}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{C63A47A3-18A6-4251-95A7-392EB58D7B87}.Release|x86.Build.0 = Release|Any CPU
|
||||
{EDAF907C-18A1-4099-9D3B-169B38400420}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{EDAF907C-18A1-4099-9D3B-169B38400420}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{EDAF907C-18A1-4099-9D3B-169B38400420}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{EDAF907C-18A1-4099-9D3B-169B38400420}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{EDAF907C-18A1-4099-9D3B-169B38400420}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{EDAF907C-18A1-4099-9D3B-169B38400420}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{EDAF907C-18A1-4099-9D3B-169B38400420}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{EDAF907C-18A1-4099-9D3B-169B38400420}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{EDAF907C-18A1-4099-9D3B-169B38400420}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{EDAF907C-18A1-4099-9D3B-169B38400420}.Release|x64.Build.0 = Release|Any CPU
|
||||
{EDAF907C-18A1-4099-9D3B-169B38400420}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{EDAF907C-18A1-4099-9D3B-169B38400420}.Release|x86.Build.0 = Release|Any CPU
|
||||
{66801106-E70A-4D33-8A08-A46C08902603}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{66801106-E70A-4D33-8A08-A46C08902603}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{66801106-E70A-4D33-8A08-A46C08902603}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{66801106-E70A-4D33-8A08-A46C08902603}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{66801106-E70A-4D33-8A08-A46C08902603}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{66801106-E70A-4D33-8A08-A46C08902603}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{66801106-E70A-4D33-8A08-A46C08902603}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{66801106-E70A-4D33-8A08-A46C08902603}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{66801106-E70A-4D33-8A08-A46C08902603}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{66801106-E70A-4D33-8A08-A46C08902603}.Release|x64.Build.0 = Release|Any CPU
|
||||
{66801106-E70A-4D33-8A08-A46C08902603}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{66801106-E70A-4D33-8A08-A46C08902603}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{59BFF1D2-B0E6-4E17-90ED-7F02669CE4E7} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
|
||||
{BD147625-3614-49BB-B484-01200F28FF8B} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
|
||||
{046AF53B-0C95-4C2B-A608-8F17F4EEAE1C} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
|
||||
{466C8F11-C43C-455A-AC28-5BF7AEBF04B0} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
|
||||
{8048E985-85DE-4B05-AB76-67C436D6516F} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
|
||||
{E94520D5-0D26-4869-AFFD-889D02616D9E} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
|
||||
{2B6CFE1E-137C-4596-8C01-7EE486F9A15E} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
|
||||
{B5AB2C97-AA81-4C02-B62E-DBEE2EEDB43D} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
|
||||
{F151D567-5A17-4E2F-8D48-348701B1DC23} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
|
||||
{7BD19877-3C36-4BD0-8BF7-E1A245106D1C} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
|
||||
{894EC02C-34C9-43C8-A01B-AF3A85FAE329} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
|
||||
{C4F45D77-7646-440D-A153-E52DBF95731D} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
|
||||
{DE4E8371-7933-4D96-9023-36F5D2DDFC56} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
|
||||
{08428B42-D650-430E-9E51-8A3B18B4C984} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
|
||||
{84451047-1B04-42D1-9C02-762564CC2B40} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
|
||||
{C63A47A3-18A6-4251-95A7-392EB58D7B87} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
|
||||
{EDAF907C-18A1-4099-9D3B-169B38400420} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
|
||||
{66801106-E70A-4D33-8A08-A46C08902603} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Tests", "__Tests", "{56BCE1BF-7CBA-7CE8-203D-A88051F1D642}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Connectors.Email.Tests", "__Tests\StellaOps.Notify.Connectors.Email.Tests\StellaOps.Notify.Connectors.Email.Tests.csproj", "{894EC02C-34C9-43C8-A01B-AF3A85FAE329}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Connectors.Slack.Tests", "__Tests\StellaOps.Notify.Connectors.Slack.Tests\StellaOps.Notify.Connectors.Slack.Tests.csproj", "{C4F45D77-7646-440D-A153-E52DBF95731D}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Connectors.Teams.Tests", "__Tests\StellaOps.Notify.Connectors.Teams.Tests\StellaOps.Notify.Connectors.Teams.Tests.csproj", "{DE4E8371-7933-4D96-9023-36F5D2DDFC56}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Models.Tests", "__Tests\StellaOps.Notify.Models.Tests\StellaOps.Notify.Models.Tests.csproj", "{08428B42-D650-430E-9E51-8A3B18B4C984}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Queue.Tests", "__Tests\StellaOps.Notify.Queue.Tests\StellaOps.Notify.Queue.Tests.csproj", "{84451047-1B04-42D1-9C02-762564CC2B40}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Storage.Mongo.Tests", "__Tests\StellaOps.Notify.Storage.Mongo.Tests\StellaOps.Notify.Storage.Mongo.Tests.csproj", "{C63A47A3-18A6-4251-95A7-392EB58D7B87}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.WebService.Tests", "__Tests\StellaOps.Notify.WebService.Tests\StellaOps.Notify.WebService.Tests.csproj", "{EDAF907C-18A1-4099-9D3B-169B38400420}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Worker.Tests", "__Tests\StellaOps.Notify.Worker.Tests\StellaOps.Notify.Worker.Tests.csproj", "{66801106-E70A-4D33-8A08-A46C08902603}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Storage.Postgres", "__Libraries\StellaOps.Notify.Storage.Postgres\StellaOps.Notify.Storage.Postgres.csproj", "{8957A93C-F7E1-41C0-89C4-3FC547621B91}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Infrastructure.Postgres", "..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj", "{4143E46E-EBB6-447A-9235-39DEC8943981}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Storage.Postgres.Tests", "__Tests\StellaOps.Notify.Storage.Postgres.Tests\StellaOps.Notify.Storage.Postgres.Tests.csproj", "{17EE9A83-C285-42C4-9AAD-8752E95C93E8}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Infrastructure.Postgres.Testing", "..\__Libraries\StellaOps.Infrastructure.Postgres.Testing\StellaOps.Infrastructure.Postgres.Testing.csproj", "{F4A75ED7-E525-4C4E-AF44-B0C9C10291E0}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Debug|x64 = Debug|x64
|
||||
Debug|x86 = Debug|x86
|
||||
Release|Any CPU = Release|Any CPU
|
||||
Release|x64 = Release|x64
|
||||
Release|x86 = Release|x86
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{DDE8646D-6EE3-44A1-B433-96943C93FFBB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{DDE8646D-6EE3-44A1-B433-96943C93FFBB}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{DDE8646D-6EE3-44A1-B433-96943C93FFBB}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{DDE8646D-6EE3-44A1-B433-96943C93FFBB}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{DDE8646D-6EE3-44A1-B433-96943C93FFBB}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{DDE8646D-6EE3-44A1-B433-96943C93FFBB}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{DDE8646D-6EE3-44A1-B433-96943C93FFBB}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{DDE8646D-6EE3-44A1-B433-96943C93FFBB}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{DDE8646D-6EE3-44A1-B433-96943C93FFBB}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{DDE8646D-6EE3-44A1-B433-96943C93FFBB}.Release|x64.Build.0 = Release|Any CPU
|
||||
{DDE8646D-6EE3-44A1-B433-96943C93FFBB}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{DDE8646D-6EE3-44A1-B433-96943C93FFBB}.Release|x86.Build.0 = Release|Any CPU
|
||||
{DB941060-49CE-49DA-A9A6-37B0C6FB1BFC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{DB941060-49CE-49DA-A9A6-37B0C6FB1BFC}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{DB941060-49CE-49DA-A9A6-37B0C6FB1BFC}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{DB941060-49CE-49DA-A9A6-37B0C6FB1BFC}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{DB941060-49CE-49DA-A9A6-37B0C6FB1BFC}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{DB941060-49CE-49DA-A9A6-37B0C6FB1BFC}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{DB941060-49CE-49DA-A9A6-37B0C6FB1BFC}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{DB941060-49CE-49DA-A9A6-37B0C6FB1BFC}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{DB941060-49CE-49DA-A9A6-37B0C6FB1BFC}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{DB941060-49CE-49DA-A9A6-37B0C6FB1BFC}.Release|x64.Build.0 = Release|Any CPU
|
||||
{DB941060-49CE-49DA-A9A6-37B0C6FB1BFC}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{DB941060-49CE-49DA-A9A6-37B0C6FB1BFC}.Release|x86.Build.0 = Release|Any CPU
|
||||
{43063DE2-1226-4B4C-8047-E44A5632F4EB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{43063DE2-1226-4B4C-8047-E44A5632F4EB}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{43063DE2-1226-4B4C-8047-E44A5632F4EB}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{43063DE2-1226-4B4C-8047-E44A5632F4EB}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{43063DE2-1226-4B4C-8047-E44A5632F4EB}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{43063DE2-1226-4B4C-8047-E44A5632F4EB}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{43063DE2-1226-4B4C-8047-E44A5632F4EB}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{43063DE2-1226-4B4C-8047-E44A5632F4EB}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{43063DE2-1226-4B4C-8047-E44A5632F4EB}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{43063DE2-1226-4B4C-8047-E44A5632F4EB}.Release|x64.Build.0 = Release|Any CPU
|
||||
{43063DE2-1226-4B4C-8047-E44A5632F4EB}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{43063DE2-1226-4B4C-8047-E44A5632F4EB}.Release|x86.Build.0 = Release|Any CPU
|
||||
{F622175F-115B-4DF9-887F-1A517439FA89}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{F622175F-115B-4DF9-887F-1A517439FA89}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{F622175F-115B-4DF9-887F-1A517439FA89}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{F622175F-115B-4DF9-887F-1A517439FA89}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{F622175F-115B-4DF9-887F-1A517439FA89}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{F622175F-115B-4DF9-887F-1A517439FA89}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{F622175F-115B-4DF9-887F-1A517439FA89}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{F622175F-115B-4DF9-887F-1A517439FA89}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{F622175F-115B-4DF9-887F-1A517439FA89}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{F622175F-115B-4DF9-887F-1A517439FA89}.Release|x64.Build.0 = Release|Any CPU
|
||||
{F622175F-115B-4DF9-887F-1A517439FA89}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{F622175F-115B-4DF9-887F-1A517439FA89}.Release|x86.Build.0 = Release|Any CPU
|
||||
{7C91C6FD-2F33-4C08-B6D1-0C2BF8FB24BC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{7C91C6FD-2F33-4C08-B6D1-0C2BF8FB24BC}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{7C91C6FD-2F33-4C08-B6D1-0C2BF8FB24BC}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{7C91C6FD-2F33-4C08-B6D1-0C2BF8FB24BC}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{7C91C6FD-2F33-4C08-B6D1-0C2BF8FB24BC}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{7C91C6FD-2F33-4C08-B6D1-0C2BF8FB24BC}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{7C91C6FD-2F33-4C08-B6D1-0C2BF8FB24BC}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{7C91C6FD-2F33-4C08-B6D1-0C2BF8FB24BC}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{7C91C6FD-2F33-4C08-B6D1-0C2BF8FB24BC}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{7C91C6FD-2F33-4C08-B6D1-0C2BF8FB24BC}.Release|x64.Build.0 = Release|Any CPU
|
||||
{7C91C6FD-2F33-4C08-B6D1-0C2BF8FB24BC}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{7C91C6FD-2F33-4C08-B6D1-0C2BF8FB24BC}.Release|x86.Build.0 = Release|Any CPU
|
||||
{4EAF4F80-CCE4-4CC3-B8ED-E1D5804A7C98}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{4EAF4F80-CCE4-4CC3-B8ED-E1D5804A7C98}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{4EAF4F80-CCE4-4CC3-B8ED-E1D5804A7C98}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{4EAF4F80-CCE4-4CC3-B8ED-E1D5804A7C98}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{4EAF4F80-CCE4-4CC3-B8ED-E1D5804A7C98}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{4EAF4F80-CCE4-4CC3-B8ED-E1D5804A7C98}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{4EAF4F80-CCE4-4CC3-B8ED-E1D5804A7C98}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{4EAF4F80-CCE4-4CC3-B8ED-E1D5804A7C98}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{4EAF4F80-CCE4-4CC3-B8ED-E1D5804A7C98}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{4EAF4F80-CCE4-4CC3-B8ED-E1D5804A7C98}.Release|x64.Build.0 = Release|Any CPU
|
||||
{4EAF4F80-CCE4-4CC3-B8ED-E1D5804A7C98}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{4EAF4F80-CCE4-4CC3-B8ED-E1D5804A7C98}.Release|x86.Build.0 = Release|Any CPU
|
||||
{59BFF1D2-B0E6-4E17-90ED-7F02669CE4E7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{59BFF1D2-B0E6-4E17-90ED-7F02669CE4E7}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{59BFF1D2-B0E6-4E17-90ED-7F02669CE4E7}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{59BFF1D2-B0E6-4E17-90ED-7F02669CE4E7}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{59BFF1D2-B0E6-4E17-90ED-7F02669CE4E7}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{59BFF1D2-B0E6-4E17-90ED-7F02669CE4E7}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{59BFF1D2-B0E6-4E17-90ED-7F02669CE4E7}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{59BFF1D2-B0E6-4E17-90ED-7F02669CE4E7}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{59BFF1D2-B0E6-4E17-90ED-7F02669CE4E7}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{59BFF1D2-B0E6-4E17-90ED-7F02669CE4E7}.Release|x64.Build.0 = Release|Any CPU
|
||||
{59BFF1D2-B0E6-4E17-90ED-7F02669CE4E7}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{59BFF1D2-B0E6-4E17-90ED-7F02669CE4E7}.Release|x86.Build.0 = Release|Any CPU
|
||||
{BD147625-3614-49BB-B484-01200F28FF8B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{BD147625-3614-49BB-B484-01200F28FF8B}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{BD147625-3614-49BB-B484-01200F28FF8B}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{BD147625-3614-49BB-B484-01200F28FF8B}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{BD147625-3614-49BB-B484-01200F28FF8B}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{BD147625-3614-49BB-B484-01200F28FF8B}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{BD147625-3614-49BB-B484-01200F28FF8B}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{BD147625-3614-49BB-B484-01200F28FF8B}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{BD147625-3614-49BB-B484-01200F28FF8B}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{BD147625-3614-49BB-B484-01200F28FF8B}.Release|x64.Build.0 = Release|Any CPU
|
||||
{BD147625-3614-49BB-B484-01200F28FF8B}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{BD147625-3614-49BB-B484-01200F28FF8B}.Release|x86.Build.0 = Release|Any CPU
|
||||
{046AF53B-0C95-4C2B-A608-8F17F4EEAE1C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{046AF53B-0C95-4C2B-A608-8F17F4EEAE1C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{046AF53B-0C95-4C2B-A608-8F17F4EEAE1C}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{046AF53B-0C95-4C2B-A608-8F17F4EEAE1C}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{046AF53B-0C95-4C2B-A608-8F17F4EEAE1C}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{046AF53B-0C95-4C2B-A608-8F17F4EEAE1C}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{046AF53B-0C95-4C2B-A608-8F17F4EEAE1C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{046AF53B-0C95-4C2B-A608-8F17F4EEAE1C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{046AF53B-0C95-4C2B-A608-8F17F4EEAE1C}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{046AF53B-0C95-4C2B-A608-8F17F4EEAE1C}.Release|x64.Build.0 = Release|Any CPU
|
||||
{046AF53B-0C95-4C2B-A608-8F17F4EEAE1C}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{046AF53B-0C95-4C2B-A608-8F17F4EEAE1C}.Release|x86.Build.0 = Release|Any CPU
|
||||
{EFF370F5-788E-4E39-8D80-1DFC6563E45C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{EFF370F5-788E-4E39-8D80-1DFC6563E45C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{EFF370F5-788E-4E39-8D80-1DFC6563E45C}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{EFF370F5-788E-4E39-8D80-1DFC6563E45C}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{EFF370F5-788E-4E39-8D80-1DFC6563E45C}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{EFF370F5-788E-4E39-8D80-1DFC6563E45C}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{EFF370F5-788E-4E39-8D80-1DFC6563E45C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{EFF370F5-788E-4E39-8D80-1DFC6563E45C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{EFF370F5-788E-4E39-8D80-1DFC6563E45C}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{EFF370F5-788E-4E39-8D80-1DFC6563E45C}.Release|x64.Build.0 = Release|Any CPU
|
||||
{EFF370F5-788E-4E39-8D80-1DFC6563E45C}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{EFF370F5-788E-4E39-8D80-1DFC6563E45C}.Release|x86.Build.0 = Release|Any CPU
|
||||
{4C5FB454-3C98-4634-8DE3-D06E1EDDAF05}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{4C5FB454-3C98-4634-8DE3-D06E1EDDAF05}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{4C5FB454-3C98-4634-8DE3-D06E1EDDAF05}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{4C5FB454-3C98-4634-8DE3-D06E1EDDAF05}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{4C5FB454-3C98-4634-8DE3-D06E1EDDAF05}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{4C5FB454-3C98-4634-8DE3-D06E1EDDAF05}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{4C5FB454-3C98-4634-8DE3-D06E1EDDAF05}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{4C5FB454-3C98-4634-8DE3-D06E1EDDAF05}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{4C5FB454-3C98-4634-8DE3-D06E1EDDAF05}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{4C5FB454-3C98-4634-8DE3-D06E1EDDAF05}.Release|x64.Build.0 = Release|Any CPU
|
||||
{4C5FB454-3C98-4634-8DE3-D06E1EDDAF05}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{4C5FB454-3C98-4634-8DE3-D06E1EDDAF05}.Release|x86.Build.0 = Release|Any CPU
|
||||
{894FBB67-F556-4695-A16D-8B4223D438A4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{894FBB67-F556-4695-A16D-8B4223D438A4}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{894FBB67-F556-4695-A16D-8B4223D438A4}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{894FBB67-F556-4695-A16D-8B4223D438A4}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{894FBB67-F556-4695-A16D-8B4223D438A4}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{894FBB67-F556-4695-A16D-8B4223D438A4}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{894FBB67-F556-4695-A16D-8B4223D438A4}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{894FBB67-F556-4695-A16D-8B4223D438A4}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{894FBB67-F556-4695-A16D-8B4223D438A4}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{894FBB67-F556-4695-A16D-8B4223D438A4}.Release|x64.Build.0 = Release|Any CPU
|
||||
{894FBB67-F556-4695-A16D-8B4223D438A4}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{894FBB67-F556-4695-A16D-8B4223D438A4}.Release|x86.Build.0 = Release|Any CPU
|
||||
{466C8F11-C43C-455A-AC28-5BF7AEBF04B0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{466C8F11-C43C-455A-AC28-5BF7AEBF04B0}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{466C8F11-C43C-455A-AC28-5BF7AEBF04B0}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{466C8F11-C43C-455A-AC28-5BF7AEBF04B0}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{466C8F11-C43C-455A-AC28-5BF7AEBF04B0}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{466C8F11-C43C-455A-AC28-5BF7AEBF04B0}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{466C8F11-C43C-455A-AC28-5BF7AEBF04B0}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{466C8F11-C43C-455A-AC28-5BF7AEBF04B0}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{466C8F11-C43C-455A-AC28-5BF7AEBF04B0}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{466C8F11-C43C-455A-AC28-5BF7AEBF04B0}.Release|x64.Build.0 = Release|Any CPU
|
||||
{466C8F11-C43C-455A-AC28-5BF7AEBF04B0}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{466C8F11-C43C-455A-AC28-5BF7AEBF04B0}.Release|x86.Build.0 = Release|Any CPU
|
||||
{8048E985-85DE-4B05-AB76-67C436D6516F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{8048E985-85DE-4B05-AB76-67C436D6516F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{8048E985-85DE-4B05-AB76-67C436D6516F}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{8048E985-85DE-4B05-AB76-67C436D6516F}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{8048E985-85DE-4B05-AB76-67C436D6516F}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{8048E985-85DE-4B05-AB76-67C436D6516F}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{8048E985-85DE-4B05-AB76-67C436D6516F}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{8048E985-85DE-4B05-AB76-67C436D6516F}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{8048E985-85DE-4B05-AB76-67C436D6516F}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{8048E985-85DE-4B05-AB76-67C436D6516F}.Release|x64.Build.0 = Release|Any CPU
|
||||
{8048E985-85DE-4B05-AB76-67C436D6516F}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{8048E985-85DE-4B05-AB76-67C436D6516F}.Release|x86.Build.0 = Release|Any CPU
|
||||
{E94520D5-0D26-4869-AFFD-889D02616D9E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{E94520D5-0D26-4869-AFFD-889D02616D9E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{E94520D5-0D26-4869-AFFD-889D02616D9E}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{E94520D5-0D26-4869-AFFD-889D02616D9E}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{E94520D5-0D26-4869-AFFD-889D02616D9E}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{E94520D5-0D26-4869-AFFD-889D02616D9E}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{E94520D5-0D26-4869-AFFD-889D02616D9E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{E94520D5-0D26-4869-AFFD-889D02616D9E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{E94520D5-0D26-4869-AFFD-889D02616D9E}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{E94520D5-0D26-4869-AFFD-889D02616D9E}.Release|x64.Build.0 = Release|Any CPU
|
||||
{E94520D5-0D26-4869-AFFD-889D02616D9E}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{E94520D5-0D26-4869-AFFD-889D02616D9E}.Release|x86.Build.0 = Release|Any CPU
|
||||
{2B6CFE1E-137C-4596-8C01-7EE486F9A15E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{2B6CFE1E-137C-4596-8C01-7EE486F9A15E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{2B6CFE1E-137C-4596-8C01-7EE486F9A15E}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{2B6CFE1E-137C-4596-8C01-7EE486F9A15E}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{2B6CFE1E-137C-4596-8C01-7EE486F9A15E}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{2B6CFE1E-137C-4596-8C01-7EE486F9A15E}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{2B6CFE1E-137C-4596-8C01-7EE486F9A15E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{2B6CFE1E-137C-4596-8C01-7EE486F9A15E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{2B6CFE1E-137C-4596-8C01-7EE486F9A15E}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{2B6CFE1E-137C-4596-8C01-7EE486F9A15E}.Release|x64.Build.0 = Release|Any CPU
|
||||
{2B6CFE1E-137C-4596-8C01-7EE486F9A15E}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{2B6CFE1E-137C-4596-8C01-7EE486F9A15E}.Release|x86.Build.0 = Release|Any CPU
|
||||
{B5AB2C97-AA81-4C02-B62E-DBEE2EEDB43D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B5AB2C97-AA81-4C02-B62E-DBEE2EEDB43D}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B5AB2C97-AA81-4C02-B62E-DBEE2EEDB43D}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{B5AB2C97-AA81-4C02-B62E-DBEE2EEDB43D}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{B5AB2C97-AA81-4C02-B62E-DBEE2EEDB43D}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{B5AB2C97-AA81-4C02-B62E-DBEE2EEDB43D}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{B5AB2C97-AA81-4C02-B62E-DBEE2EEDB43D}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B5AB2C97-AA81-4C02-B62E-DBEE2EEDB43D}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{B5AB2C97-AA81-4C02-B62E-DBEE2EEDB43D}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{B5AB2C97-AA81-4C02-B62E-DBEE2EEDB43D}.Release|x64.Build.0 = Release|Any CPU
|
||||
{B5AB2C97-AA81-4C02-B62E-DBEE2EEDB43D}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{B5AB2C97-AA81-4C02-B62E-DBEE2EEDB43D}.Release|x86.Build.0 = Release|Any CPU
|
||||
{F151D567-5A17-4E2F-8D48-348701B1DC23}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{F151D567-5A17-4E2F-8D48-348701B1DC23}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{F151D567-5A17-4E2F-8D48-348701B1DC23}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{F151D567-5A17-4E2F-8D48-348701B1DC23}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{F151D567-5A17-4E2F-8D48-348701B1DC23}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{F151D567-5A17-4E2F-8D48-348701B1DC23}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{F151D567-5A17-4E2F-8D48-348701B1DC23}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{F151D567-5A17-4E2F-8D48-348701B1DC23}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{F151D567-5A17-4E2F-8D48-348701B1DC23}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{F151D567-5A17-4E2F-8D48-348701B1DC23}.Release|x64.Build.0 = Release|Any CPU
|
||||
{F151D567-5A17-4E2F-8D48-348701B1DC23}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{F151D567-5A17-4E2F-8D48-348701B1DC23}.Release|x86.Build.0 = Release|Any CPU
|
||||
{7BD19877-3C36-4BD0-8BF7-E1A245106D1C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{7BD19877-3C36-4BD0-8BF7-E1A245106D1C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{7BD19877-3C36-4BD0-8BF7-E1A245106D1C}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{7BD19877-3C36-4BD0-8BF7-E1A245106D1C}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{7BD19877-3C36-4BD0-8BF7-E1A245106D1C}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{7BD19877-3C36-4BD0-8BF7-E1A245106D1C}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{7BD19877-3C36-4BD0-8BF7-E1A245106D1C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{7BD19877-3C36-4BD0-8BF7-E1A245106D1C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{7BD19877-3C36-4BD0-8BF7-E1A245106D1C}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{7BD19877-3C36-4BD0-8BF7-E1A245106D1C}.Release|x64.Build.0 = Release|Any CPU
|
||||
{7BD19877-3C36-4BD0-8BF7-E1A245106D1C}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{7BD19877-3C36-4BD0-8BF7-E1A245106D1C}.Release|x86.Build.0 = Release|Any CPU
|
||||
{894EC02C-34C9-43C8-A01B-AF3A85FAE329}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{894EC02C-34C9-43C8-A01B-AF3A85FAE329}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{894EC02C-34C9-43C8-A01B-AF3A85FAE329}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{894EC02C-34C9-43C8-A01B-AF3A85FAE329}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{894EC02C-34C9-43C8-A01B-AF3A85FAE329}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{894EC02C-34C9-43C8-A01B-AF3A85FAE329}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{894EC02C-34C9-43C8-A01B-AF3A85FAE329}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{894EC02C-34C9-43C8-A01B-AF3A85FAE329}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{894EC02C-34C9-43C8-A01B-AF3A85FAE329}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{894EC02C-34C9-43C8-A01B-AF3A85FAE329}.Release|x64.Build.0 = Release|Any CPU
|
||||
{894EC02C-34C9-43C8-A01B-AF3A85FAE329}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{894EC02C-34C9-43C8-A01B-AF3A85FAE329}.Release|x86.Build.0 = Release|Any CPU
|
||||
{C4F45D77-7646-440D-A153-E52DBF95731D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{C4F45D77-7646-440D-A153-E52DBF95731D}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{C4F45D77-7646-440D-A153-E52DBF95731D}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{C4F45D77-7646-440D-A153-E52DBF95731D}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{C4F45D77-7646-440D-A153-E52DBF95731D}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{C4F45D77-7646-440D-A153-E52DBF95731D}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{C4F45D77-7646-440D-A153-E52DBF95731D}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{C4F45D77-7646-440D-A153-E52DBF95731D}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{C4F45D77-7646-440D-A153-E52DBF95731D}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{C4F45D77-7646-440D-A153-E52DBF95731D}.Release|x64.Build.0 = Release|Any CPU
|
||||
{C4F45D77-7646-440D-A153-E52DBF95731D}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{C4F45D77-7646-440D-A153-E52DBF95731D}.Release|x86.Build.0 = Release|Any CPU
|
||||
{DE4E8371-7933-4D96-9023-36F5D2DDFC56}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{DE4E8371-7933-4D96-9023-36F5D2DDFC56}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{DE4E8371-7933-4D96-9023-36F5D2DDFC56}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{DE4E8371-7933-4D96-9023-36F5D2DDFC56}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{DE4E8371-7933-4D96-9023-36F5D2DDFC56}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{DE4E8371-7933-4D96-9023-36F5D2DDFC56}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{DE4E8371-7933-4D96-9023-36F5D2DDFC56}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{DE4E8371-7933-4D96-9023-36F5D2DDFC56}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{DE4E8371-7933-4D96-9023-36F5D2DDFC56}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{DE4E8371-7933-4D96-9023-36F5D2DDFC56}.Release|x64.Build.0 = Release|Any CPU
|
||||
{DE4E8371-7933-4D96-9023-36F5D2DDFC56}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{DE4E8371-7933-4D96-9023-36F5D2DDFC56}.Release|x86.Build.0 = Release|Any CPU
|
||||
{08428B42-D650-430E-9E51-8A3B18B4C984}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{08428B42-D650-430E-9E51-8A3B18B4C984}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{08428B42-D650-430E-9E51-8A3B18B4C984}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{08428B42-D650-430E-9E51-8A3B18B4C984}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{08428B42-D650-430E-9E51-8A3B18B4C984}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{08428B42-D650-430E-9E51-8A3B18B4C984}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{08428B42-D650-430E-9E51-8A3B18B4C984}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{08428B42-D650-430E-9E51-8A3B18B4C984}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{08428B42-D650-430E-9E51-8A3B18B4C984}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{08428B42-D650-430E-9E51-8A3B18B4C984}.Release|x64.Build.0 = Release|Any CPU
|
||||
{08428B42-D650-430E-9E51-8A3B18B4C984}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{08428B42-D650-430E-9E51-8A3B18B4C984}.Release|x86.Build.0 = Release|Any CPU
|
||||
{84451047-1B04-42D1-9C02-762564CC2B40}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{84451047-1B04-42D1-9C02-762564CC2B40}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{84451047-1B04-42D1-9C02-762564CC2B40}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{84451047-1B04-42D1-9C02-762564CC2B40}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{84451047-1B04-42D1-9C02-762564CC2B40}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{84451047-1B04-42D1-9C02-762564CC2B40}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{84451047-1B04-42D1-9C02-762564CC2B40}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{84451047-1B04-42D1-9C02-762564CC2B40}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{84451047-1B04-42D1-9C02-762564CC2B40}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{84451047-1B04-42D1-9C02-762564CC2B40}.Release|x64.Build.0 = Release|Any CPU
|
||||
{84451047-1B04-42D1-9C02-762564CC2B40}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{84451047-1B04-42D1-9C02-762564CC2B40}.Release|x86.Build.0 = Release|Any CPU
|
||||
{C63A47A3-18A6-4251-95A7-392EB58D7B87}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{C63A47A3-18A6-4251-95A7-392EB58D7B87}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{C63A47A3-18A6-4251-95A7-392EB58D7B87}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{C63A47A3-18A6-4251-95A7-392EB58D7B87}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{C63A47A3-18A6-4251-95A7-392EB58D7B87}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{C63A47A3-18A6-4251-95A7-392EB58D7B87}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{C63A47A3-18A6-4251-95A7-392EB58D7B87}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{C63A47A3-18A6-4251-95A7-392EB58D7B87}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{C63A47A3-18A6-4251-95A7-392EB58D7B87}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{C63A47A3-18A6-4251-95A7-392EB58D7B87}.Release|x64.Build.0 = Release|Any CPU
|
||||
{C63A47A3-18A6-4251-95A7-392EB58D7B87}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{C63A47A3-18A6-4251-95A7-392EB58D7B87}.Release|x86.Build.0 = Release|Any CPU
|
||||
{EDAF907C-18A1-4099-9D3B-169B38400420}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{EDAF907C-18A1-4099-9D3B-169B38400420}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{EDAF907C-18A1-4099-9D3B-169B38400420}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{EDAF907C-18A1-4099-9D3B-169B38400420}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{EDAF907C-18A1-4099-9D3B-169B38400420}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{EDAF907C-18A1-4099-9D3B-169B38400420}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{EDAF907C-18A1-4099-9D3B-169B38400420}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{EDAF907C-18A1-4099-9D3B-169B38400420}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{EDAF907C-18A1-4099-9D3B-169B38400420}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{EDAF907C-18A1-4099-9D3B-169B38400420}.Release|x64.Build.0 = Release|Any CPU
|
||||
{EDAF907C-18A1-4099-9D3B-169B38400420}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{EDAF907C-18A1-4099-9D3B-169B38400420}.Release|x86.Build.0 = Release|Any CPU
|
||||
{66801106-E70A-4D33-8A08-A46C08902603}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{66801106-E70A-4D33-8A08-A46C08902603}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{66801106-E70A-4D33-8A08-A46C08902603}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{66801106-E70A-4D33-8A08-A46C08902603}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{66801106-E70A-4D33-8A08-A46C08902603}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{66801106-E70A-4D33-8A08-A46C08902603}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{66801106-E70A-4D33-8A08-A46C08902603}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{66801106-E70A-4D33-8A08-A46C08902603}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{66801106-E70A-4D33-8A08-A46C08902603}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{66801106-E70A-4D33-8A08-A46C08902603}.Release|x64.Build.0 = Release|Any CPU
|
||||
{66801106-E70A-4D33-8A08-A46C08902603}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{66801106-E70A-4D33-8A08-A46C08902603}.Release|x86.Build.0 = Release|Any CPU
|
||||
{8957A93C-F7E1-41C0-89C4-3FC547621B91}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{8957A93C-F7E1-41C0-89C4-3FC547621B91}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{8957A93C-F7E1-41C0-89C4-3FC547621B91}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{8957A93C-F7E1-41C0-89C4-3FC547621B91}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{8957A93C-F7E1-41C0-89C4-3FC547621B91}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{8957A93C-F7E1-41C0-89C4-3FC547621B91}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{8957A93C-F7E1-41C0-89C4-3FC547621B91}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{8957A93C-F7E1-41C0-89C4-3FC547621B91}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{8957A93C-F7E1-41C0-89C4-3FC547621B91}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{8957A93C-F7E1-41C0-89C4-3FC547621B91}.Release|x64.Build.0 = Release|Any CPU
|
||||
{8957A93C-F7E1-41C0-89C4-3FC547621B91}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{8957A93C-F7E1-41C0-89C4-3FC547621B91}.Release|x86.Build.0 = Release|Any CPU
|
||||
{4143E46E-EBB6-447A-9235-39DEC8943981}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{4143E46E-EBB6-447A-9235-39DEC8943981}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{4143E46E-EBB6-447A-9235-39DEC8943981}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{4143E46E-EBB6-447A-9235-39DEC8943981}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{4143E46E-EBB6-447A-9235-39DEC8943981}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{4143E46E-EBB6-447A-9235-39DEC8943981}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{4143E46E-EBB6-447A-9235-39DEC8943981}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{4143E46E-EBB6-447A-9235-39DEC8943981}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{4143E46E-EBB6-447A-9235-39DEC8943981}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{4143E46E-EBB6-447A-9235-39DEC8943981}.Release|x64.Build.0 = Release|Any CPU
|
||||
{4143E46E-EBB6-447A-9235-39DEC8943981}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{4143E46E-EBB6-447A-9235-39DEC8943981}.Release|x86.Build.0 = Release|Any CPU
|
||||
{17EE9A83-C285-42C4-9AAD-8752E95C93E8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{17EE9A83-C285-42C4-9AAD-8752E95C93E8}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{17EE9A83-C285-42C4-9AAD-8752E95C93E8}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{17EE9A83-C285-42C4-9AAD-8752E95C93E8}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{17EE9A83-C285-42C4-9AAD-8752E95C93E8}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{17EE9A83-C285-42C4-9AAD-8752E95C93E8}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{17EE9A83-C285-42C4-9AAD-8752E95C93E8}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{17EE9A83-C285-42C4-9AAD-8752E95C93E8}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{17EE9A83-C285-42C4-9AAD-8752E95C93E8}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{17EE9A83-C285-42C4-9AAD-8752E95C93E8}.Release|x64.Build.0 = Release|Any CPU
|
||||
{17EE9A83-C285-42C4-9AAD-8752E95C93E8}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{17EE9A83-C285-42C4-9AAD-8752E95C93E8}.Release|x86.Build.0 = Release|Any CPU
|
||||
{F4A75ED7-E525-4C4E-AF44-B0C9C10291E0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{F4A75ED7-E525-4C4E-AF44-B0C9C10291E0}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{F4A75ED7-E525-4C4E-AF44-B0C9C10291E0}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{F4A75ED7-E525-4C4E-AF44-B0C9C10291E0}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{F4A75ED7-E525-4C4E-AF44-B0C9C10291E0}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{F4A75ED7-E525-4C4E-AF44-B0C9C10291E0}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{F4A75ED7-E525-4C4E-AF44-B0C9C10291E0}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{F4A75ED7-E525-4C4E-AF44-B0C9C10291E0}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{F4A75ED7-E525-4C4E-AF44-B0C9C10291E0}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{F4A75ED7-E525-4C4E-AF44-B0C9C10291E0}.Release|x64.Build.0 = Release|Any CPU
|
||||
{F4A75ED7-E525-4C4E-AF44-B0C9C10291E0}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{F4A75ED7-E525-4C4E-AF44-B0C9C10291E0}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{59BFF1D2-B0E6-4E17-90ED-7F02669CE4E7} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
|
||||
{BD147625-3614-49BB-B484-01200F28FF8B} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
|
||||
{046AF53B-0C95-4C2B-A608-8F17F4EEAE1C} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
|
||||
{466C8F11-C43C-455A-AC28-5BF7AEBF04B0} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
|
||||
{8048E985-85DE-4B05-AB76-67C436D6516F} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
|
||||
{E94520D5-0D26-4869-AFFD-889D02616D9E} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
|
||||
{2B6CFE1E-137C-4596-8C01-7EE486F9A15E} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
|
||||
{B5AB2C97-AA81-4C02-B62E-DBEE2EEDB43D} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
|
||||
{F151D567-5A17-4E2F-8D48-348701B1DC23} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
|
||||
{7BD19877-3C36-4BD0-8BF7-E1A245106D1C} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
|
||||
{894EC02C-34C9-43C8-A01B-AF3A85FAE329} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
|
||||
{C4F45D77-7646-440D-A153-E52DBF95731D} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
|
||||
{DE4E8371-7933-4D96-9023-36F5D2DDFC56} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
|
||||
{08428B42-D650-430E-9E51-8A3B18B4C984} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
|
||||
{84451047-1B04-42D1-9C02-762564CC2B40} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
|
||||
{C63A47A3-18A6-4251-95A7-392EB58D7B87} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
|
||||
{EDAF907C-18A1-4099-9D3B-169B38400420} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
|
||||
{66801106-E70A-4D33-8A08-A46C08902603} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
|
||||
{8957A93C-F7E1-41C0-89C4-3FC547621B91} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
|
||||
{17EE9A83-C285-42C4-9AAD-8752E95C93E8} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
@@ -291,6 +291,20 @@ CREATE TABLE IF NOT EXISTS notify.audit (
|
||||
CREATE INDEX idx_audit_tenant ON notify.audit(tenant_id);
|
||||
CREATE INDEX idx_audit_created ON notify.audit(tenant_id, created_at);
|
||||
|
||||
-- Locks table (lightweight distributed locks)
|
||||
CREATE TABLE IF NOT EXISTS notify.locks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id TEXT NOT NULL,
|
||||
resource TEXT NOT NULL,
|
||||
owner TEXT NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(tenant_id, resource)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_locks_tenant ON notify.locks(tenant_id);
|
||||
CREATE INDEX idx_locks_expiry ON notify.locks(expires_at);
|
||||
|
||||
-- Update timestamp function
|
||||
CREATE OR REPLACE FUNCTION notify.update_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace StellaOps.Notify.Storage.Postgres.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a lightweight distributed lock entry.
|
||||
/// </summary>
|
||||
public sealed class LockEntity
|
||||
{
|
||||
public required Guid Id { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required string Resource { get; init; }
|
||||
public required string Owner { get; init; }
|
||||
public required DateTimeOffset ExpiresAt { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
@@ -62,6 +63,128 @@ public sealed class DeliveryRepository : RepositoryBase<NotifyDataSource>, IDeli
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<DeliveryEntity> UpsertAsync(DeliveryEntity delivery, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO notify.deliveries (
|
||||
id, tenant_id, channel_id, rule_id, template_id, status, recipient, subject, body,
|
||||
event_type, event_payload, attempt, max_attempts, next_retry_at, error_message,
|
||||
external_id, correlation_id, created_at, queued_at, sent_at, delivered_at, failed_at
|
||||
) VALUES (
|
||||
@id, @tenant_id, @channel_id, @rule_id, @template_id, @status::notify.delivery_status, @recipient, @subject, @body,
|
||||
@event_type, @event_payload::jsonb, @attempt, @max_attempts, @next_retry_at, @error_message,
|
||||
@external_id, @correlation_id, @created_at, @queued_at, @sent_at, @delivered_at, @failed_at
|
||||
)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
status = EXCLUDED.status,
|
||||
recipient = EXCLUDED.recipient,
|
||||
subject = EXCLUDED.subject,
|
||||
body = EXCLUDED.body,
|
||||
event_type = EXCLUDED.event_type,
|
||||
event_payload = EXCLUDED.event_payload,
|
||||
attempt = EXCLUDED.attempt,
|
||||
max_attempts = EXCLUDED.max_attempts,
|
||||
next_retry_at = EXCLUDED.next_retry_at,
|
||||
error_message = EXCLUDED.error_message,
|
||||
external_id = COALESCE(EXCLUDED.external_id, notify.deliveries.external_id),
|
||||
correlation_id = EXCLUDED.correlation_id,
|
||||
rule_id = EXCLUDED.rule_id,
|
||||
template_id = EXCLUDED.template_id,
|
||||
channel_id = EXCLUDED.channel_id,
|
||||
queued_at = EXCLUDED.queued_at,
|
||||
sent_at = EXCLUDED.sent_at,
|
||||
delivered_at = EXCLUDED.delivered_at,
|
||||
failed_at = EXCLUDED.failed_at
|
||||
RETURNING *
|
||||
""";
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync(delivery.TenantId, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddDeliveryParameters(command, delivery);
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
return MapDelivery(reader);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<DeliveryEntity>> QueryAsync(
|
||||
string tenantId,
|
||||
DeliveryStatus? status = null,
|
||||
Guid? channelId = null,
|
||||
string? eventType = null,
|
||||
DateTimeOffset? since = null,
|
||||
DateTimeOffset? until = null,
|
||||
int limit = 100,
|
||||
int offset = 0,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sql = new StringBuilder("SELECT * FROM notify.deliveries WHERE tenant_id = @tenant_id");
|
||||
|
||||
if (status is not null)
|
||||
{
|
||||
sql.Append(" AND status = @status::notify.delivery_status");
|
||||
}
|
||||
|
||||
if (channelId is not null)
|
||||
{
|
||||
sql.Append(" AND channel_id = @channel_id");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(eventType))
|
||||
{
|
||||
sql.Append(" AND event_type = @event_type");
|
||||
}
|
||||
|
||||
if (since is not null)
|
||||
{
|
||||
sql.Append(" AND created_at >= @since");
|
||||
}
|
||||
|
||||
if (until is not null)
|
||||
{
|
||||
sql.Append(" AND created_at <= @until");
|
||||
}
|
||||
|
||||
sql.Append(" ORDER BY created_at DESC, id LIMIT @limit OFFSET @offset");
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId,
|
||||
sql.ToString(),
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
if (status is not null)
|
||||
{
|
||||
AddParameter(cmd, "status", StatusToString(status.Value));
|
||||
}
|
||||
|
||||
if (channelId is not null)
|
||||
{
|
||||
AddParameter(cmd, "channel_id", channelId.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(eventType))
|
||||
{
|
||||
AddParameter(cmd, "event_type", eventType);
|
||||
}
|
||||
|
||||
if (since is not null)
|
||||
{
|
||||
AddParameter(cmd, "since", since.Value);
|
||||
}
|
||||
|
||||
if (until is not null)
|
||||
{
|
||||
AddParameter(cmd, "until", until.Value);
|
||||
}
|
||||
|
||||
AddParameter(cmd, "limit", limit);
|
||||
AddParameter(cmd, "offset", offset);
|
||||
},
|
||||
MapDelivery,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<DeliveryEntity>> GetPendingAsync(
|
||||
string tenantId,
|
||||
|
||||
@@ -124,6 +124,23 @@ public sealed class DigestRepository : RepositoryBase<NotifyDataSource>, IDigest
|
||||
return await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteByKeyAsync(string tenantId, Guid channelId, string recipient, string digestKey, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "DELETE FROM notify.digests WHERE tenant_id = @tenant_id AND channel_id = @channel_id AND recipient = @recipient AND digest_key = @digest_key";
|
||||
var rows = await ExecuteAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "channel_id", channelId);
|
||||
AddParameter(cmd, "recipient", recipient);
|
||||
AddParameter(cmd, "digest_key", digestKey);
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
private static DigestEntity MapDigest(NpgsqlDataReader reader) => new()
|
||||
{
|
||||
Id = reader.GetGuid(0),
|
||||
|
||||
@@ -43,6 +43,25 @@ public interface IDeliveryRepository
|
||||
string correlationId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Queries deliveries with optional filters.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<DeliveryEntity>> QueryAsync(
|
||||
string tenantId,
|
||||
DeliveryStatus? status = null,
|
||||
Guid? channelId = null,
|
||||
string? eventType = null,
|
||||
DateTimeOffset? since = null,
|
||||
DateTimeOffset? until = null,
|
||||
int limit = 100,
|
||||
int offset = 0,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Inserts or updates a delivery row by id.
|
||||
/// </summary>
|
||||
Task<DeliveryEntity> UpsertAsync(DeliveryEntity delivery, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Marks a delivery as queued.
|
||||
/// </summary>
|
||||
|
||||
@@ -12,4 +12,5 @@ public interface IDigestRepository
|
||||
Task<bool> MarkSendingAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
|
||||
Task<bool> MarkSentAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
|
||||
Task<int> DeleteOldAsync(DateTimeOffset cutoff, CancellationToken cancellationToken = default);
|
||||
Task<bool> DeleteByKeyAsync(string tenantId, Guid channelId, string recipient, string digestKey, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
using StellaOps.Notify.Storage.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository for distributed locks in the notify schema.
|
||||
/// </summary>
|
||||
public interface ILockRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Attempts to acquire a lock for the given resource. If the existing lock is expired or already owned by the caller, it is replaced.
|
||||
/// </summary>
|
||||
Task<bool> TryAcquireAsync(string tenantId, string resource, string owner, TimeSpan ttl, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Releases a lock owned by the caller.
|
||||
/// </summary>
|
||||
Task<bool> ReleaseAsync(string tenantId, string resource, string owner, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using StellaOps.Notify.Storage.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Postgres.Repositories;
|
||||
|
||||
public sealed class LockRepository : RepositoryBase<NotifyDataSource>, ILockRepository
|
||||
{
|
||||
public LockRepository(NotifyDataSource dataSource, ILogger<LockRepository> logger)
|
||||
: base(dataSource, logger) { }
|
||||
|
||||
public async Task<bool> TryAcquireAsync(string tenantId, string resource, string owner, TimeSpan ttl, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
WITH upsert AS (
|
||||
INSERT INTO notify.locks (id, tenant_id, resource, owner, expires_at)
|
||||
VALUES (gen_random_uuid(), @tenant_id, @resource, @owner, NOW() + @ttl)
|
||||
ON CONFLICT (tenant_id, resource) DO UPDATE SET
|
||||
owner = EXCLUDED.owner,
|
||||
expires_at = EXCLUDED.expires_at
|
||||
WHERE notify.locks.expires_at < NOW() OR notify.locks.owner = EXCLUDED.owner
|
||||
RETURNING 1
|
||||
)
|
||||
SELECT EXISTS(SELECT 1 FROM upsert) AS acquired;
|
||||
""";
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "tenant_id", tenantId);
|
||||
AddParameter(command, "resource", resource);
|
||||
AddParameter(command, "owner", owner);
|
||||
AddParameter(command, "ttl", ttl);
|
||||
|
||||
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
return result is bool acquired && acquired;
|
||||
}
|
||||
|
||||
public async Task<bool> ReleaseAsync(string tenantId, string resource, string owner, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "DELETE FROM notify.locks WHERE tenant_id = @tenant_id AND resource = @resource AND owner = @owner";
|
||||
var rows = await ExecuteAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "resource", resource);
|
||||
AddParameter(cmd, "owner", owner);
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return rows > 0;
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<IInboxRepository, InboxRepository>();
|
||||
services.AddScoped<IIncidentRepository, IncidentRepository>();
|
||||
services.AddScoped<INotifyAuditRepository, NotifyAuditRepository>();
|
||||
services.AddScoped<ILockRepository, LockRepository>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -83,11 +83,6 @@ internal sealed class ConsoleSimulationDiffService
|
||||
foreach (var artifact in artifacts.OrderBy(a => a.ArtifactDigest, StringComparer.Ordinal))
|
||||
{
|
||||
var baseSeed = HashToBytes($"{policyVersion}:{artifact.ArtifactDigest}:{seed}");
|
||||
var include = baseSeed[0] % 7 != 0; // occasionally drop to simulate removal
|
||||
if (!include)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var findingId = CreateDeterministicId("fid", policyVersion, artifact.ArtifactDigest, seed.ToString());
|
||||
var severity = SeverityOrder[baseSeed[1] % SeverityOrder.Length];
|
||||
@@ -102,7 +97,12 @@ internal sealed class ConsoleSimulationDiffService
|
||||
Severity: severity,
|
||||
FiredRules: new[] { ruleId }));
|
||||
|
||||
// Add a secondary finding for variability if budget allows
|
||||
if (results.Count >= maxFindings)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
// Add a secondary finding for variability if budget allows (deterministic)
|
||||
if (results.Count < maxFindings && baseSeed[4] % 5 == 0)
|
||||
{
|
||||
var secondaryId = CreateDeterministicId("fid", policyVersion, artifact.ArtifactDigest, seed + "-b");
|
||||
@@ -124,7 +124,10 @@ internal sealed class ConsoleSimulationDiffService
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
return results
|
||||
.OrderBy(r => r.FindingId, StringComparer.Ordinal)
|
||||
.Take(maxFindings)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static ConsoleSeverityBreakdown BuildSeverityBreakdown(IReadOnlyList<SimulationFindingResult> findings)
|
||||
|
||||
@@ -40,20 +40,21 @@ public sealed class PurlEquivalenceTable
|
||||
|
||||
foreach (var group in groups)
|
||||
{
|
||||
var normalizedGroup = group
|
||||
var normalizedList = group
|
||||
.Where(p => !string.IsNullOrWhiteSpace(p))
|
||||
.Select(p => p.Trim().ToLowerInvariant())
|
||||
.Distinct()
|
||||
.OrderBy(p => p, StringComparer.Ordinal)
|
||||
.ToImmutableHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
.ToArray();
|
||||
|
||||
if (normalizedGroup.Count < 2)
|
||||
if (normalizedList.Length < 2)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// First item (lexicographically) is the canonical form
|
||||
var canonical = normalizedGroup.First();
|
||||
// Use an ordered array for canonical; hash-set only for membership
|
||||
var canonical = normalizedList[0];
|
||||
var normalizedGroup = normalizedList.ToImmutableHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var purl in normalizedGroup)
|
||||
{
|
||||
|
||||
@@ -22,7 +22,7 @@ public sealed class ConsoleSimulationDiffServiceTests
|
||||
new ConsoleArtifactScope("sha256:def", "pkg:npm/bar@2.0.0")
|
||||
},
|
||||
Filters: new ConsoleSimulationFilters(new[] { "high", "critical" }, new[] { "RULE-1234" }),
|
||||
Budget: new ConsoleSimulationBudget(maxFindings: 10, maxExplainSamples: 5),
|
||||
Budget: new ConsoleSimulationBudget(10, 5),
|
||||
EvaluationTimestamp: new DateTimeOffset(2025, 12, 2, 0, 0, 0, TimeSpan.Zero));
|
||||
|
||||
var first = service.Compute(request);
|
||||
|
||||
@@ -71,6 +71,39 @@ public class SbomEndpointsTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
payload.NextCursor.Should().Be("1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Console_sboms_filters_by_license_and_asset_tag()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/console/sboms?license=MIT&assetTag=owner&limit=5");
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<SbomCatalogResult>();
|
||||
payload.Should().NotBeNull();
|
||||
payload!.Items.Should().OnlyContain(i => i.License == "MIT" && i.AssetTags.ContainsKey("owner"));
|
||||
payload.NextCursor.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Console_sboms_paginates_with_cursor_offset()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var first = await client.GetAsync("/console/sboms?artifact=sample&limit=1");
|
||||
first.EnsureSuccessStatusCode();
|
||||
var firstPage = await first.Content.ReadFromJsonAsync<SbomCatalogResult>();
|
||||
firstPage!.Items.Should().HaveCount(1);
|
||||
firstPage.NextCursor.Should().Be("1");
|
||||
|
||||
var second = await client.GetAsync("/console/sboms?artifact=sample&limit=2&cursor=1");
|
||||
second.EnsureSuccessStatusCode();
|
||||
var secondPage = await second.Content.ReadFromJsonAsync<SbomCatalogResult>();
|
||||
secondPage!.Items.Should().HaveCount(2);
|
||||
secondPage.Items.Should().OnlyContain(i => i.Artifact.Contains("sample"));
|
||||
secondPage.NextCursor.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Components_lookup_requires_purl_and_paginates()
|
||||
{
|
||||
@@ -87,5 +120,13 @@ public class SbomEndpointsTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
payload!.Neighbors.Should().HaveCount(1);
|
||||
payload.Neighbors[0].Purl.Should().Contain("express");
|
||||
payload.NextCursor.Should().Be("1");
|
||||
|
||||
var second = await client.GetAsync("/components/lookup?purl=pkg:npm/lodash@4.17.21&limit=2&cursor=1");
|
||||
second.EnsureSuccessStatusCode();
|
||||
var secondPage = await second.Content.ReadFromJsonAsync<ComponentLookupResult>();
|
||||
secondPage.Should().NotBeNull();
|
||||
secondPage!.Neighbors.Should().HaveCount(2);
|
||||
secondPage.Neighbors.Should().OnlyContain(n => n.Purl.StartsWith("pkg:npm/", StringComparison.OrdinalIgnoreCase));
|
||||
secondPage.NextCursor.Should().BeNull();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,14 @@ public sealed class InMemoryComponentLookupRepository : IComponentLookupReposito
|
||||
License: "MIT",
|
||||
Scope: "build",
|
||||
RuntimeFlag: false),
|
||||
new(
|
||||
Artifact: "ghcr.io/stellaops/sample-api",
|
||||
Purl: "pkg:npm/lodash@4.17.21",
|
||||
NeighborPurl: "pkg:npm/react@18.2.0",
|
||||
Relationship: "DEPENDS_ON",
|
||||
License: "MIT",
|
||||
Scope: "runtime",
|
||||
RuntimeFlag: true),
|
||||
new(
|
||||
Artifact: "ghcr.io/stellaops/sample-worker",
|
||||
Purl: "pkg:nuget/Newtonsoft.Json@13.0.2",
|
||||
|
||||
@@ -8,6 +8,8 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.Surface.Env;
|
||||
using StellaOps.Scanner.Surface.Secrets;
|
||||
using StellaOps.Scanner.Worker.Options;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Processing.Surface;
|
||||
@@ -26,7 +28,8 @@ internal sealed class HmacDsseEnvelopeSigner : IDsseEnvelopeSigner, IDisposable
|
||||
|
||||
public HmacDsseEnvelopeSigner(
|
||||
IOptions<ScannerWorkerOptions> options,
|
||||
ILogger<HmacDsseEnvelopeSigner> logger)
|
||||
ILogger<HmacDsseEnvelopeSigner> logger,
|
||||
IServiceProvider serviceProvider)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
@@ -41,6 +44,12 @@ internal sealed class HmacDsseEnvelopeSigner : IDsseEnvelopeSigner, IDisposable
|
||||
}
|
||||
|
||||
var secretBytes = LoadSecret(signing);
|
||||
|
||||
if ((secretBytes is null || secretBytes.Length == 0) && serviceProvider.GetService<ISurfaceSecretProvider>() is { } secretProvider)
|
||||
{
|
||||
secretBytes = TryLoadFromSurfaceSecrets(secretProvider, serviceProvider.GetService<ISurfaceEnvironment>());
|
||||
}
|
||||
|
||||
if (secretBytes is not null && secretBytes.Length > 0)
|
||||
{
|
||||
_hmac = new HMACSHA256(secretBytes);
|
||||
@@ -50,6 +59,10 @@ internal sealed class HmacDsseEnvelopeSigner : IDsseEnvelopeSigner, IDisposable
|
||||
{
|
||||
throw new InvalidOperationException("DSSE signing enabled but no shared secret provided and deterministic fallback is disabled.");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("DSSE signing fallback engaged: no signing secret found; emitting deterministic envelopes only.");
|
||||
}
|
||||
}
|
||||
|
||||
public Task<DsseEnvelope> SignAsync(string payloadType, ReadOnlyMemory<byte> content, string suggestedKind, string merkleRoot, string? view, CancellationToken cancellationToken)
|
||||
@@ -112,6 +125,44 @@ internal sealed class HmacDsseEnvelopeSigner : IDsseEnvelopeSigner, IDisposable
|
||||
return null;
|
||||
}
|
||||
|
||||
private static byte[]? TryLoadFromSurfaceSecrets(ISurfaceSecretProvider provider, ISurfaceEnvironment? environment)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenant = environment?.Settings.Tenant ?? "default";
|
||||
var request = new SurfaceSecretRequest(tenant, component: "scanner-worker", secretType: "attestation", name: "dsse-signing");
|
||||
var handle = provider.TryGetSecret(request, CancellationToken.None);
|
||||
if (handle is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (handle.Secret.TryGetProperty("privateKeyPem", out var privateKeyPem) && privateKeyPem.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var pem = privateKeyPem.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(pem))
|
||||
{
|
||||
return Encoding.UTF8.GetBytes(pem);
|
||||
}
|
||||
}
|
||||
|
||||
if (handle.Secret.TryGetProperty("token", out var token) && token.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var value = token.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return Encoding.UTF8.GetBytes(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignored; fallback handled by caller
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static byte[]? DecodeFlexible(string value)
|
||||
{
|
||||
// Try base64 (std)
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.Worker.Options;
|
||||
@@ -23,7 +24,7 @@ public sealed class HmacDsseEnvelopeSignerTests
|
||||
signing.KeyId = "scanner-hmac";
|
||||
});
|
||||
|
||||
var signer = new HmacDsseEnvelopeSigner(options, NullLogger<HmacDsseEnvelopeSigner>.Instance);
|
||||
var signer = new HmacDsseEnvelopeSigner(options, NullLogger<HmacDsseEnvelopeSigner>.Instance, new ServiceCollection().BuildServiceProvider());
|
||||
var payload = Encoding.UTF8.GetBytes("{\"hello\":\"world\"}");
|
||||
|
||||
var envelope = await signer.SignAsync("application/json", payload, "test.kind", "root", view: null, CancellationToken.None);
|
||||
@@ -48,7 +49,7 @@ public sealed class HmacDsseEnvelopeSignerTests
|
||||
signing.AllowDeterministicFallback = true;
|
||||
});
|
||||
|
||||
var signer = new HmacDsseEnvelopeSigner(options, NullLogger<HmacDsseEnvelopeSigner>.Instance);
|
||||
var signer = new HmacDsseEnvelopeSigner(options, NullLogger<HmacDsseEnvelopeSigner>.Instance, new ServiceCollection().BuildServiceProvider());
|
||||
var payload = Encoding.UTF8.GetBytes("abc");
|
||||
|
||||
var envelope = await signer.SignAsync("text/plain", payload, "kind", "root", view: null, CancellationToken.None);
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Scheduler.Models;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Scheduler.Models;
|
||||
using StellaOps.Scheduler.Worker.Planning;
|
||||
|
||||
namespace StellaOps.Scheduler.Queue;
|
||||
|
||||
@@ -49,10 +50,11 @@ public sealed class PlannerQueueMessage
|
||||
public string? ScheduleId => Run.ScheduleId;
|
||||
}
|
||||
|
||||
public sealed class RunnerSegmentQueueMessage
|
||||
{
|
||||
private readonly ReadOnlyCollection<string> _imageDigests;
|
||||
private readonly IReadOnlyDictionary<string, string> _attributes;
|
||||
public sealed class RunnerSegmentQueueMessage
|
||||
{
|
||||
private readonly ReadOnlyCollection<string> _imageDigests;
|
||||
private readonly IReadOnlyDictionary<string, string> _attributes;
|
||||
private readonly IReadOnlyDictionary<string, SurfaceManifestPointer> _surfaceManifests;
|
||||
|
||||
[JsonConstructor]
|
||||
public RunnerSegmentQueueMessage(
|
||||
@@ -60,12 +62,13 @@ public sealed class RunnerSegmentQueueMessage
|
||||
string runId,
|
||||
string tenantId,
|
||||
IReadOnlyList<string> imageDigests,
|
||||
string? scheduleId = null,
|
||||
int? ratePerSecond = null,
|
||||
bool usageOnly = true,
|
||||
IReadOnlyDictionary<string, string>? attributes = null,
|
||||
string? correlationId = null)
|
||||
{
|
||||
string? scheduleId = null,
|
||||
int? ratePerSecond = null,
|
||||
bool usageOnly = true,
|
||||
IReadOnlyDictionary<string, string>? attributes = null,
|
||||
string? correlationId = null,
|
||||
IReadOnlyDictionary<string, SurfaceManifestPointer>? surfaceManifests = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(segmentId))
|
||||
{
|
||||
throw new ArgumentException("Segment identifier must be provided.", nameof(segmentId));
|
||||
@@ -86,14 +89,17 @@ public sealed class RunnerSegmentQueueMessage
|
||||
TenantId = tenantId;
|
||||
ScheduleId = string.IsNullOrWhiteSpace(scheduleId) ? null : scheduleId;
|
||||
RatePerSecond = ratePerSecond;
|
||||
UsageOnly = usageOnly;
|
||||
CorrelationId = string.IsNullOrWhiteSpace(correlationId) ? null : correlationId;
|
||||
|
||||
_imageDigests = new ReadOnlyCollection<string>(NormalizeDigests(imageDigests));
|
||||
_attributes = attributes is null
|
||||
? EmptyReadOnlyDictionary<string, string>.Instance
|
||||
: new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(attributes, StringComparer.Ordinal));
|
||||
}
|
||||
UsageOnly = usageOnly;
|
||||
CorrelationId = string.IsNullOrWhiteSpace(correlationId) ? null : correlationId;
|
||||
|
||||
_imageDigests = new ReadOnlyCollection<string>(NormalizeDigests(imageDigests));
|
||||
_attributes = attributes is null
|
||||
? EmptyReadOnlyDictionary<string, string>.Instance
|
||||
: new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(attributes, StringComparer.Ordinal));
|
||||
_surfaceManifests = surfaceManifests is null
|
||||
? EmptyReadOnlyDictionary<string, SurfaceManifestPointer>.Instance
|
||||
: new ReadOnlyDictionary<string, SurfaceManifestPointer>(new Dictionary<string, SurfaceManifestPointer>(surfaceManifests, StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
public string SegmentId { get; }
|
||||
|
||||
@@ -111,7 +117,10 @@ public sealed class RunnerSegmentQueueMessage
|
||||
|
||||
public IReadOnlyList<string> ImageDigests => _imageDigests;
|
||||
|
||||
public IReadOnlyDictionary<string, string> Attributes => _attributes;
|
||||
public IReadOnlyDictionary<string, string> Attributes => _attributes;
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public IReadOnlyDictionary<string, SurfaceManifestPointer> SurfaceManifests => _surfaceManifests;
|
||||
|
||||
public string IdempotencyKey => SegmentId;
|
||||
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Notify.Queue;
|
||||
using StellaOps.Scheduler.Worker.Events;
|
||||
using StellaOps.Scheduler.Worker.Execution;
|
||||
using StellaOps.Scheduler.Worker.Options;
|
||||
using StellaOps.Scheduler.Worker.Observability;
|
||||
using StellaOps.Scheduler.Worker.Planning;
|
||||
using StellaOps.Scheduler.Worker.Policy;
|
||||
using StellaOps.Scheduler.Worker.Graph;
|
||||
using StellaOps.Scheduler.Worker.Graph.Cartographer;
|
||||
using StellaOps.Scheduler.Worker.Graph.Scheduler;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Notify.Queue;
|
||||
using StellaOps.Scheduler.Worker.Events;
|
||||
using StellaOps.Scheduler.Worker.Execution;
|
||||
using StellaOps.Scheduler.Worker.Options;
|
||||
using StellaOps.Scheduler.Worker.Observability;
|
||||
using StellaOps.Scheduler.Worker.Planning;
|
||||
using StellaOps.Scheduler.Worker.Policy;
|
||||
using StellaOps.Scheduler.Worker.Graph;
|
||||
using StellaOps.Scheduler.Worker.Graph.Cartographer;
|
||||
using StellaOps.Scheduler.Worker.Graph.Scheduler;
|
||||
using StellaOps.Scanner.Surface.Env;
|
||||
using StellaOps.Scanner.Surface.FS;
|
||||
|
||||
namespace StellaOps.Scheduler.Worker.DependencyInjection;
|
||||
|
||||
@@ -30,10 +32,10 @@ public static class SchedulerWorkerServiceCollectionExtensions
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
services.AddSingleton<SchedulerWorkerMetrics>();
|
||||
services.AddSingleton<IImpactTargetingService, ImpactTargetingService>();
|
||||
services.AddSingleton<IImpactShardPlanner, ImpactShardPlanner>();
|
||||
services.AddSingleton<IPlannerQueueDispatchService, PlannerQueueDispatchService>();
|
||||
services.AddSingleton<PlannerExecutionService>();
|
||||
services.AddSingleton<IRunnerExecutionService, RunnerExecutionService>();
|
||||
services.AddSingleton<IImpactShardPlanner, ImpactShardPlanner>();
|
||||
services.AddSingleton<IPlannerQueueDispatchService, PlannerQueueDispatchService>();
|
||||
services.AddSingleton<PlannerExecutionService>();
|
||||
services.AddSingleton<IRunnerExecutionService, RunnerExecutionService>();
|
||||
services.AddSingleton<IPolicyRunTargetingService, PolicyRunTargetingService>();
|
||||
services.AddSingleton<PolicyRunExecutionService>();
|
||||
services.AddSingleton<GraphBuildExecutionService>();
|
||||
@@ -80,10 +82,10 @@ public static class SchedulerWorkerServiceCollectionExtensions
|
||||
client.BaseAddress = baseAddress;
|
||||
}
|
||||
});
|
||||
services.AddHttpClient<IGraphJobCompletionClient, HttpGraphJobCompletionClient>((sp, client) =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<SchedulerWorkerOptions>>().Value.Graph;
|
||||
client.Timeout = options.CartographerTimeout;
|
||||
services.AddHttpClient<IGraphJobCompletionClient, HttpGraphJobCompletionClient>((sp, client) =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<SchedulerWorkerOptions>>().Value.Graph;
|
||||
client.Timeout = options.CartographerTimeout;
|
||||
|
||||
if (options.SchedulerApi.BaseAddress is { } baseAddress)
|
||||
{
|
||||
@@ -91,13 +93,17 @@ public static class SchedulerWorkerServiceCollectionExtensions
|
||||
}
|
||||
});
|
||||
|
||||
services.AddHostedService<PlannerBackgroundService>();
|
||||
services.AddHostedService<PlannerQueueDispatcherBackgroundService>();
|
||||
services.AddHostedService<RunnerBackgroundService>();
|
||||
services.AddHostedService<PolicyRunDispatchBackgroundService>();
|
||||
services.AddHostedService<GraphBuildBackgroundService>();
|
||||
services.AddHostedService<GraphOverlayBackgroundService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
services.AddHostedService<PlannerBackgroundService>();
|
||||
services.AddHostedService<PlannerQueueDispatcherBackgroundService>();
|
||||
services.AddHostedService<RunnerBackgroundService>();
|
||||
services.AddHostedService<PolicyRunDispatchBackgroundService>();
|
||||
services.AddHostedService<GraphBuildBackgroundService>();
|
||||
services.AddHostedService<GraphOverlayBackgroundService>();
|
||||
|
||||
services.AddSurfaceEnvironment(options => { options.ComponentName = "Scheduler.Worker"; });
|
||||
services.AddSurfaceFileCache();
|
||||
services.AddSurfaceManifestStore();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ public sealed class SchedulerWorkerMetrics : IDisposable
|
||||
private readonly Counter<long> _runnerDeltaHighTotal;
|
||||
private readonly Counter<long> _runnerDeltaFindingsTotal;
|
||||
private readonly Counter<long> _runnerKevHitsTotal;
|
||||
private readonly Counter<long> _surfaceManifestPrefetchTotal;
|
||||
private readonly Counter<long> _surfaceManifestPrefetchTotal;
|
||||
private readonly Histogram<double> _runDurationSeconds;
|
||||
private readonly UpDownCounter<long> _runsActive;
|
||||
private readonly Counter<long> _graphJobsTotal;
|
||||
@@ -65,6 +67,14 @@ public sealed class SchedulerWorkerMetrics : IDisposable
|
||||
"scheduler_runner_delta_kev_total",
|
||||
unit: "count",
|
||||
description: "KEV hits observed by runner grouped by mode.");
|
||||
_surfaceManifestPrefetchTotal = _meter.CreateCounter<long>(
|
||||
"scheduler_surface_manifest_prefetch_total",
|
||||
unit: "attempt",
|
||||
description: "Surface manifest prefetch attempts grouped by result.");
|
||||
_surfaceManifestPrefetchTotal = _meter.CreateCounter<long>(
|
||||
"scheduler_surface_manifest_prefetch_total",
|
||||
unit: "attempt",
|
||||
description: "Surface manifest prefetch attempts grouped by result.");
|
||||
_runDurationSeconds = _meter.CreateHistogram<double>(
|
||||
"scheduler_run_duration_seconds",
|
||||
unit: "s",
|
||||
@@ -172,6 +182,12 @@ public sealed class SchedulerWorkerMetrics : IDisposable
|
||||
_runnerImagesTotal.Add(processedImages, imageTags);
|
||||
}
|
||||
|
||||
public void RecordSurfaceManifestPrefetch(string result)
|
||||
{
|
||||
var tags = new[] { new KeyValuePair<string, object?>("result", result) };
|
||||
_surfaceManifestPrefetchTotal.Add(1, tags);
|
||||
}
|
||||
|
||||
public void RecordDeltaSummaries(string mode, IReadOnlyList<DeltaSummary> deltas)
|
||||
{
|
||||
if (deltas.Count == 0)
|
||||
|
||||
@@ -4,7 +4,9 @@ using System.Linq;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scheduler.Models;
|
||||
using StellaOps.Scheduler.Queue;
|
||||
using StellaOps.Scanner.Surface.FS;
|
||||
using StellaOps.Scheduler.Worker.Options;
|
||||
using StellaOps.Scheduler.Worker.Observability;
|
||||
|
||||
namespace StellaOps.Scheduler.Worker.Planning;
|
||||
|
||||
@@ -18,17 +20,23 @@ internal sealed class PlannerQueueDispatchService : IPlannerQueueDispatchService
|
||||
private readonly IImpactShardPlanner _shardPlanner;
|
||||
private readonly ISchedulerRunnerQueue _runnerQueue;
|
||||
private readonly SchedulerWorkerOptions _options;
|
||||
private readonly ISurfaceManifestReader _surfaceManifestReader;
|
||||
private readonly SchedulerWorkerMetrics _metrics;
|
||||
private readonly ILogger<PlannerQueueDispatchService> _logger;
|
||||
|
||||
public PlannerQueueDispatchService(
|
||||
IImpactShardPlanner shardPlanner,
|
||||
ISchedulerRunnerQueue runnerQueue,
|
||||
SchedulerWorkerOptions options,
|
||||
ISurfaceManifestReader surfaceManifestReader,
|
||||
SchedulerWorkerMetrics metrics,
|
||||
ILogger<PlannerQueueDispatchService> logger)
|
||||
{
|
||||
_shardPlanner = shardPlanner ?? throw new ArgumentNullException(nameof(shardPlanner));
|
||||
_runnerQueue = runnerQueue ?? throw new ArgumentNullException(nameof(runnerQueue));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_surfaceManifestReader = surfaceManifestReader ?? throw new ArgumentNullException(nameof(surfaceManifestReader));
|
||||
_metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
@@ -78,6 +86,8 @@ internal sealed class PlannerQueueDispatchService : IPlannerQueueDispatchService
|
||||
continue;
|
||||
}
|
||||
|
||||
var manifestPointers = await PrefetchManifestsAsync(digests, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var segmentAttributes = MergeAttributes(attributes, shard, schedule);
|
||||
var runnerMessage = new RunnerSegmentQueueMessage(
|
||||
segmentId,
|
||||
@@ -88,7 +98,8 @@ internal sealed class PlannerQueueDispatchService : IPlannerQueueDispatchService
|
||||
limits.RatePerSecond,
|
||||
impactSet.UsageOnly,
|
||||
segmentAttributes,
|
||||
message.CorrelationId);
|
||||
message.CorrelationId,
|
||||
manifestPointers);
|
||||
|
||||
enqueueTasks.Add(_runnerQueue.EnqueueAsync(runnerMessage, cancellationToken).AsTask());
|
||||
}
|
||||
@@ -190,6 +201,48 @@ internal sealed class PlannerQueueDispatchService : IPlannerQueueDispatchService
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyDictionary<string, SurfaceManifestPointer>> PrefetchManifestsAsync(
|
||||
IReadOnlyList<string> digests,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var results = new Dictionary<string, SurfaceManifestPointer>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var digest in digests)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
var manifest = await _surfaceManifestReader
|
||||
.TryGetByDigestAsync(digest, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (manifest is null)
|
||||
{
|
||||
_metrics.RecordSurfaceManifestPrefetch(result: "miss");
|
||||
continue;
|
||||
}
|
||||
|
||||
var pointer = new SurfaceManifestPointer(digest, manifest.Tenant);
|
||||
results[digest] = pointer;
|
||||
_metrics.RecordSurfaceManifestPrefetch(result: "hit");
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to prefetch surface manifest for digest {Digest}", digest);
|
||||
_metrics.RecordSurfaceManifestPrefetch(result: "error");
|
||||
}
|
||||
}
|
||||
|
||||
return results.Count == 0
|
||||
? (IReadOnlyDictionary<string, SurfaceManifestPointer>)EmptyReadOnlyDictionary<string, SurfaceManifestPointer>.Instance
|
||||
: results;
|
||||
}
|
||||
}
|
||||
|
||||
public readonly record struct PlannerQueueDispatchResult(
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scheduler.Worker.Planning;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal pointer to a Surface.FS manifest associated with an image digest.
|
||||
/// </summary>
|
||||
public sealed record SurfaceManifestPointer
|
||||
{
|
||||
public SurfaceManifestPointer(string manifestDigest, string? tenant)
|
||||
{
|
||||
ManifestDigest = manifestDigest ?? throw new ArgumentNullException(nameof(manifestDigest));
|
||||
Tenant = tenant;
|
||||
}
|
||||
|
||||
[JsonPropertyName("manifestDigest")]
|
||||
public string ManifestDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("tenant")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Tenant { get; init; }
|
||||
}
|
||||
@@ -12,8 +12,10 @@
|
||||
<ProjectReference Include="../StellaOps.Scheduler.Queue/StellaOps.Scheduler.Queue.csproj" />
|
||||
<ProjectReference Include="../../../Notify/__Libraries/StellaOps.Notify.Models/StellaOps.Notify.Models.csproj" />
|
||||
<ProjectReference Include="../../../Notify/__Libraries/StellaOps.Notify.Queue/StellaOps.Notify.Queue.csproj" />
|
||||
<ProjectReference Include="../../../Scanner/__Libraries/StellaOps.Scanner.Surface.Env/StellaOps.Scanner.Surface.Env.csproj" />
|
||||
<ProjectReference Include="../../../Scanner/__Libraries/StellaOps.Scanner.Surface.FS/StellaOps.Scanner.Surface.FS.csproj" />
|
||||
<PackageReference Include="Cronos" Version="0.10.0" />
|
||||
<PackageReference Include="System.Threading.RateLimiting" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0-rc.2.25502.107" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
| WEB-AOC-19-002 | DONE (2025-11-30) | Added provenance builder, checksum utilities, and DSSE/CMS signature verification helpers with unit tests. |
|
||||
| WEB-AOC-19-003 | DONE (2025-11-30) | Added client-side guard validator (forbidden/derived/unknown fields, provenance/signature checks) with unit fixtures. |
|
||||
| WEB-CONSOLE-23-002 | DOING (2025-12-01) | Console status polling + SSE run stream client/store/UI added; tests pending once env fixed. |
|
||||
| WEB-RISK-66-001 | DOING (2025-12-02) | Added risk + vuln gateway HTTP clients (shared trace util), store, `/risk` dashboard with filters/empty state/vuln link, auth guard; added `/vulnerabilities/:vulnId` detail + specs; providers switch via quickstart; awaiting gateway endpoints/test harness. |
|
||||
| WEB-RISK-66-001 | BLOCKED (2025-12-03) | Same implementation landed; npm ci hangs so Angular tests can’t run; waiting on stable install environment and gateway endpoints to validate. |
|
||||
| WEB-EXC-25-001 | TODO | Exceptions workflow CRUD pending policy scopes. |
|
||||
| WEB-TEN-47-CONTRACT | DONE (2025-12-01) | Gateway tenant auth/ABAC contract doc v1.0 published (`docs/api/gateway/tenant-auth.md`). |
|
||||
| WEB-VULN-29-LEDGER-DOC | DONE (2025-12-01) | Findings Ledger proxy contract doc v1.0 with idempotency + retries (`docs/api/gateway/findings-ledger-proxy.md`). |
|
||||
|
||||
@@ -87,15 +87,49 @@ function expandNestedArchives(rootDir = join(__dirname, '..')) {
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function candidatePaths(rootDir = join(__dirname, '..')) {
|
||||
const { env } = process;
|
||||
const baseCandidates = [
|
||||
env.STELLAOPS_CHROMIUM_BIN,
|
||||
env.CHROME_BIN,
|
||||
env.PUPPETEER_EXECUTABLE_PATH,
|
||||
'/usr/bin/chromium-browser',
|
||||
'/usr/bin/chromium',
|
||||
'/usr/bin/google-chrome',
|
||||
function candidatePaths(rootDir = join(__dirname, '..')) {
|
||||
const { env } = process;
|
||||
const playwrightBase = join(rootDir, 'node_modules', 'playwright-core', '.local-browsers');
|
||||
const homePlaywrightBase = env.HOME ? join(env.HOME, '.cache', 'ms-playwright') : null;
|
||||
let playwrightChromium = [];
|
||||
try {
|
||||
if (existsSync(playwrightBase)) {
|
||||
playwrightChromium = readdirSync(playwrightBase)
|
||||
.filter((d) => d.startsWith('chromium-'))
|
||||
.map((d) => join(playwrightBase, d, 'chrome-linux', 'chrome'))
|
||||
.concat(
|
||||
readdirSync(playwrightBase)
|
||||
.filter((d) => d.startsWith('chromium-'))
|
||||
.map((d) => join(playwrightBase, d, 'chrome-win', 'chrome.exe')),
|
||||
readdirSync(playwrightBase)
|
||||
.filter((d) => d.startsWith('chromium-'))
|
||||
.map((d) => join(playwrightBase, d, 'chrome-mac', 'Chromium.app', 'Contents', 'MacOS', 'Chromium'))
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
playwrightChromium = [];
|
||||
}
|
||||
|
||||
let homeChromium = [];
|
||||
try {
|
||||
if (homePlaywrightBase && existsSync(homePlaywrightBase)) {
|
||||
homeChromium = readdirSync(homePlaywrightBase)
|
||||
.filter((d) => d.startsWith('chromium'))
|
||||
.map((d) => join(homePlaywrightBase, d, 'chrome-linux', 'chrome'));
|
||||
}
|
||||
} catch {
|
||||
homeChromium = [];
|
||||
}
|
||||
|
||||
const baseCandidates = [
|
||||
env.STELLAOPS_CHROMIUM_BIN,
|
||||
env.CHROME_BIN,
|
||||
env.PUPPETEER_EXECUTABLE_PATH,
|
||||
...playwrightChromium,
|
||||
...homeChromium,
|
||||
'/usr/bin/chromium-browser',
|
||||
'/usr/bin/chromium',
|
||||
'/usr/bin/google-chrome',
|
||||
'/usr/bin/google-chrome-stable',
|
||||
join(rootDir, ...linuxArchivePath),
|
||||
join(rootDir, ...windowsArchivePath),
|
||||
|
||||
@@ -188,4 +188,42 @@ public sealed class AdmissionResponseBuilderTests
|
||||
var builder = new AdmissionResponseBuilder();
|
||||
Assert.Throws<ArgumentException>(() => builder.Build(context, evaluation));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ThrowsWhenNoImages()
|
||||
{
|
||||
using var document = JsonDocument.Parse("""
|
||||
{
|
||||
"metadata": { "namespace": "ops" },
|
||||
"spec": {
|
||||
"containers": []
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
var pod = document.RootElement;
|
||||
var spec = pod.GetProperty("spec");
|
||||
|
||||
var context = new AdmissionRequestContext(
|
||||
"admission.k8s.io/v1",
|
||||
"AdmissionReview",
|
||||
"uid-999",
|
||||
"ops",
|
||||
new Dictionary<string, string>(),
|
||||
Array.Empty<AdmissionContainerReference>(),
|
||||
pod,
|
||||
spec);
|
||||
|
||||
var evaluation = new RuntimeAdmissionEvaluation
|
||||
{
|
||||
Decisions = Array.Empty<RuntimeAdmissionDecision>(),
|
||||
BackendFailed = false,
|
||||
FailOpenApplied = false,
|
||||
FailureReason = null,
|
||||
TtlSeconds = 60
|
||||
};
|
||||
|
||||
var builder = new AdmissionResponseBuilder();
|
||||
Assert.Throws<ArgumentException>(() => builder.Build(context, evaluation));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
namespace StellaOps.Infrastructure.Postgres.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Persistence backend selection for dual-write/migration scenarios.
|
||||
/// </summary>
|
||||
public enum PersistenceBackend
|
||||
{
|
||||
/// <summary>
|
||||
/// Use MongoDB as the primary backend (legacy).
|
||||
/// </summary>
|
||||
Mongo,
|
||||
|
||||
/// <summary>
|
||||
/// Use PostgreSQL as the primary backend.
|
||||
/// </summary>
|
||||
Postgres,
|
||||
|
||||
/// <summary>
|
||||
/// Dual-write mode: write to both backends, read from primary.
|
||||
/// Used during migration phase for data consistency verification.
|
||||
/// </summary>
|
||||
DualWrite
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persistence options for module backend selection.
|
||||
/// </summary>
|
||||
public sealed class PersistenceOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name.
|
||||
/// </summary>
|
||||
public const string SectionName = "Persistence";
|
||||
|
||||
/// <summary>
|
||||
/// Backend for Authority module.
|
||||
/// </summary>
|
||||
public PersistenceBackend Authority { get; set; } = PersistenceBackend.Mongo;
|
||||
|
||||
/// <summary>
|
||||
/// Backend for Scheduler module.
|
||||
/// </summary>
|
||||
public PersistenceBackend Scheduler { get; set; } = PersistenceBackend.Mongo;
|
||||
|
||||
/// <summary>
|
||||
/// Backend for Notify module.
|
||||
/// </summary>
|
||||
public PersistenceBackend Notify { get; set; } = PersistenceBackend.Mongo;
|
||||
|
||||
/// <summary>
|
||||
/// Backend for Policy module.
|
||||
/// </summary>
|
||||
public PersistenceBackend Policy { get; set; } = PersistenceBackend.Mongo;
|
||||
|
||||
/// <summary>
|
||||
/// Backend for Concelier (vulnerability) module.
|
||||
/// </summary>
|
||||
public PersistenceBackend Concelier { get; set; } = PersistenceBackend.Mongo;
|
||||
|
||||
/// <summary>
|
||||
/// Backend for Excititor (VEX/graph) module.
|
||||
/// </summary>
|
||||
public PersistenceBackend Excititor { get; set; } = PersistenceBackend.Mongo;
|
||||
|
||||
/// <summary>
|
||||
/// In dual-write mode, which backend to read from.
|
||||
/// </summary>
|
||||
public PersistenceBackend DualWriteReadFrom { get; set; } = PersistenceBackend.Mongo;
|
||||
|
||||
/// <summary>
|
||||
/// Enable comparison logging in dual-write mode.
|
||||
/// Logs discrepancies between backends for debugging.
|
||||
/// </summary>
|
||||
public bool DualWriteComparisonLogging { get; set; }
|
||||
}
|
||||
@@ -25,20 +25,6 @@ public static class ServiceCollectionExtensions
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds persistence backend options from configuration.
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection.</param>
|
||||
/// <param name="configuration">Configuration root.</param>
|
||||
/// <returns>Service collection for chaining.</returns>
|
||||
public static IServiceCollection AddPersistenceOptions(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
services.Configure<PersistenceOptions>(configuration.GetSection(PersistenceOptions.SectionName));
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds PostgreSQL infrastructure with the specified options.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Replay.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Immutable lockfile describing policy simulation inputs and expected digests.
|
||||
/// Aligns with POLICY-GAPS-185-006 remediation (PS1–PS10).
|
||||
/// </summary>
|
||||
public sealed record PolicySimulationInputLock
|
||||
{
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public string SchemaVersion { get; init; } = "1.0.0";
|
||||
|
||||
[JsonPropertyName("generatedAt")]
|
||||
public DateTimeOffset GeneratedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
[JsonPropertyName("policyBundleSha256")]
|
||||
public string PolicyBundleSha256 { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("graphSha256")]
|
||||
public string GraphSha256 { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("sbomSha256")]
|
||||
public string SbomSha256 { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("timeAnchorSha256")]
|
||||
public string TimeAnchorSha256 { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("datasetSha256")]
|
||||
public string DatasetSha256 { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("shadowIsolation")]
|
||||
public bool ShadowIsolation { get; init; } = true;
|
||||
|
||||
[JsonPropertyName("requiredScopes")]
|
||||
public IReadOnlyList<string> RequiredScopes { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("notes")]
|
||||
public string? Notes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Materialized digests for a simulation run, validated against the lock.
|
||||
/// </summary>
|
||||
public sealed record PolicySimulationMaterializedInputs(
|
||||
string PolicyBundleSha256,
|
||||
string GraphSha256,
|
||||
string SbomSha256,
|
||||
string TimeAnchorSha256,
|
||||
string DatasetSha256,
|
||||
string RunMode,
|
||||
IReadOnlyCollection<string> GrantedScopes,
|
||||
DateTimeOffset NowUtc);
|
||||
|
||||
public sealed record PolicySimulationValidationResult(bool IsValid, string Reason)
|
||||
{
|
||||
public static PolicySimulationValidationResult Ok(string reason = "ok") => new(true, reason);
|
||||
public static PolicySimulationValidationResult Fail(string reason) => new(false, reason);
|
||||
}
|
||||
|
||||
public static class PolicySimulationInputLockValidator
|
||||
{
|
||||
private static readonly string[] RequiredShadowScopes = { "policy:simulate:shadow" };
|
||||
|
||||
public static PolicySimulationValidationResult Validate(
|
||||
PolicySimulationInputLock expected,
|
||||
PolicySimulationMaterializedInputs actual,
|
||||
TimeSpan? maxAge = null)
|
||||
{
|
||||
if (!IsSha(expected.PolicyBundleSha256) || !IsSha(actual.PolicyBundleSha256))
|
||||
return PolicySimulationValidationResult.Fail("invalid-policy-digest-format");
|
||||
if (!IsSha(expected.GraphSha256) || !IsSha(actual.GraphSha256))
|
||||
return PolicySimulationValidationResult.Fail("invalid-graph-digest-format");
|
||||
if (!IsSha(expected.SbomSha256) || !IsSha(actual.SbomSha256))
|
||||
return PolicySimulationValidationResult.Fail("invalid-sbom-digest-format");
|
||||
if (!IsSha(expected.TimeAnchorSha256) || !IsSha(actual.TimeAnchorSha256))
|
||||
return PolicySimulationValidationResult.Fail("invalid-time-anchor-digest-format");
|
||||
if (!IsSha(expected.DatasetSha256) || !IsSha(actual.DatasetSha256))
|
||||
return PolicySimulationValidationResult.Fail("invalid-dataset-digest-format");
|
||||
|
||||
if (!string.Equals(expected.PolicyBundleSha256, actual.PolicyBundleSha256, StringComparison.OrdinalIgnoreCase))
|
||||
return PolicySimulationValidationResult.Fail("policy-bundle-drift");
|
||||
if (!string.Equals(expected.GraphSha256, actual.GraphSha256, StringComparison.OrdinalIgnoreCase))
|
||||
return PolicySimulationValidationResult.Fail("graph-drift");
|
||||
if (!string.Equals(expected.SbomSha256, actual.SbomSha256, StringComparison.OrdinalIgnoreCase))
|
||||
return PolicySimulationValidationResult.Fail("sbom-drift");
|
||||
if (!string.Equals(expected.TimeAnchorSha256, actual.TimeAnchorSha256, StringComparison.OrdinalIgnoreCase))
|
||||
return PolicySimulationValidationResult.Fail("time-anchor-drift");
|
||||
if (!string.Equals(expected.DatasetSha256, actual.DatasetSha256, StringComparison.OrdinalIgnoreCase))
|
||||
return PolicySimulationValidationResult.Fail("dataset-drift");
|
||||
|
||||
if (maxAge is not null && actual.NowUtc - expected.GeneratedAt > maxAge.Value)
|
||||
return PolicySimulationValidationResult.Fail("inputs-lock-stale");
|
||||
|
||||
if (expected.ShadowIsolation)
|
||||
{
|
||||
if (!string.Equals(actual.RunMode, "shadow", StringComparison.OrdinalIgnoreCase))
|
||||
return PolicySimulationValidationResult.Fail("shadow-mode-required");
|
||||
if (!RequiredShadowScopes.All(scope => actual.GrantedScopes.Contains(scope, StringComparer.OrdinalIgnoreCase)))
|
||||
return PolicySimulationValidationResult.Fail("shadow-scope-missing");
|
||||
}
|
||||
|
||||
return PolicySimulationValidationResult.Ok();
|
||||
}
|
||||
|
||||
private static bool IsSha(string value)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(value) && value.Length == 64 && value.All(c => Uri.IsHexDigit(c));
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ Keep this table in sync with `docs/implplan/SPRINT_0185_0001_0001_shared_replay_
|
||||
| REPLAY-CORE-185-003 | DONE (2025-11-25) | Platform Data Guild | Mongo collections (`replay_runs`, `replay_bundles`, `replay_subjects`) and indices aligned with schema doc. |
|
||||
| DOCS-REPLAY-185-003 | DONE (2025-11-25) | Docs Guild · Platform Data Guild | `docs/data/replay_schema.md` detailing collections, index guidance, offline sync strategy. |
|
||||
| DOCS-REPLAY-185-004 | DONE (2025-11-25) | Docs Guild | Expand `docs/replay/DEVS_GUIDE_REPLAY.md` with integration guidance and deterministic replay checklist. |
|
||||
| POLICY-GAPS-185-006 | DONE (2025-12-03) | Policy Guild · Platform Guild | Policy simulation gaps PS1–PS10 remediated: inputs lock schema/sample + DSSE-ready verifier, shadow isolation validator, offline CLI verifier script. |
|
||||
|
||||
## Status rules
|
||||
- Use TODO → DOING → DONE/BLOCKED and mirror every change in the sprint Delivery Tracker.
|
||||
|
||||
Reference in New Issue
Block a user