Gaps fill up, fixes, ui restructuring
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
using StellaOps.Telemetry.Federation.Consent;
|
||||
|
||||
namespace StellaOps.Telemetry.Federation.Tests;
|
||||
|
||||
public sealed class ConsentManagerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Default_consent_state_is_not_granted()
|
||||
{
|
||||
var manager = new ConsentManager();
|
||||
|
||||
var state = await manager.GetConsentStateAsync("tenant-1");
|
||||
|
||||
Assert.False(state.Granted);
|
||||
Assert.Null(state.GrantedBy);
|
||||
Assert.Null(state.GrantedAt);
|
||||
Assert.Null(state.ExpiresAt);
|
||||
Assert.Null(state.DsseDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Grant_consent_sets_granted_state()
|
||||
{
|
||||
var manager = new ConsentManager();
|
||||
|
||||
var proof = await manager.GrantConsentAsync("tenant-1", "admin@example.com");
|
||||
|
||||
Assert.Equal("tenant-1", proof.TenantId);
|
||||
Assert.Equal("admin@example.com", proof.GrantedBy);
|
||||
Assert.NotNull(proof.DsseDigest);
|
||||
Assert.StartsWith("sha256:", proof.DsseDigest);
|
||||
Assert.NotEmpty(proof.Envelope);
|
||||
|
||||
var state = await manager.GetConsentStateAsync("tenant-1");
|
||||
Assert.True(state.Granted);
|
||||
Assert.Equal("admin@example.com", state.GrantedBy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Revoke_consent_clears_state()
|
||||
{
|
||||
var manager = new ConsentManager();
|
||||
|
||||
await manager.GrantConsentAsync("tenant-1", "admin@example.com");
|
||||
await manager.RevokeConsentAsync("tenant-1", "admin@example.com");
|
||||
|
||||
var state = await manager.GetConsentStateAsync("tenant-1");
|
||||
Assert.False(state.Granted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TTL_expiry_revokes_consent()
|
||||
{
|
||||
var fakeTime = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||
var manager = new ConsentManager(fakeTime);
|
||||
|
||||
await manager.GrantConsentAsync("tenant-1", "admin@example.com", ttl: TimeSpan.FromHours(1));
|
||||
|
||||
var stateBefore = await manager.GetConsentStateAsync("tenant-1");
|
||||
Assert.True(stateBefore.Granted);
|
||||
|
||||
// Advance time past TTL
|
||||
fakeTime.Advance(TimeSpan.FromHours(2));
|
||||
|
||||
var stateAfter = await manager.GetConsentStateAsync("tenant-1");
|
||||
Assert.False(stateAfter.Granted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Grant_without_TTL_has_no_expiry()
|
||||
{
|
||||
var manager = new ConsentManager();
|
||||
|
||||
var proof = await manager.GrantConsentAsync("tenant-1", "admin@example.com");
|
||||
|
||||
Assert.Null(proof.ExpiresAt);
|
||||
|
||||
var state = await manager.GetConsentStateAsync("tenant-1");
|
||||
Assert.True(state.Granted);
|
||||
Assert.Null(state.ExpiresAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Multiple_tenants_independent()
|
||||
{
|
||||
var manager = new ConsentManager();
|
||||
|
||||
await manager.GrantConsentAsync("tenant-1", "admin1@example.com");
|
||||
await manager.GrantConsentAsync("tenant-2", "admin2@example.com");
|
||||
|
||||
await manager.RevokeConsentAsync("tenant-1", "admin1@example.com");
|
||||
|
||||
var state1 = await manager.GetConsentStateAsync("tenant-1");
|
||||
var state2 = await manager.GetConsentStateAsync("tenant-2");
|
||||
|
||||
Assert.False(state1.Granted);
|
||||
Assert.True(state2.Granted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Grant_overwrites_previous_consent()
|
||||
{
|
||||
var manager = new ConsentManager();
|
||||
|
||||
var proof1 = await manager.GrantConsentAsync("tenant-1", "admin@example.com");
|
||||
var proof2 = await manager.GrantConsentAsync("tenant-1", "newadmin@example.com");
|
||||
|
||||
Assert.NotEqual(proof1.DsseDigest, proof2.DsseDigest);
|
||||
|
||||
var state = await manager.GetConsentStateAsync("tenant-1");
|
||||
Assert.Equal("newadmin@example.com", state.GrantedBy);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simple fake TimeProvider for testing time-dependent behavior.
|
||||
/// </summary>
|
||||
internal sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _utcNow;
|
||||
|
||||
public FakeTimeProvider(DateTimeOffset start)
|
||||
{
|
||||
_utcNow = start;
|
||||
}
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _utcNow;
|
||||
|
||||
public void Advance(TimeSpan duration) => _utcNow += duration;
|
||||
|
||||
public void SetUtcNow(DateTimeOffset value) => _utcNow = value;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Telemetry.Federation.Aggregation;
|
||||
using StellaOps.Telemetry.Federation.Bundles;
|
||||
using StellaOps.Telemetry.Federation.Consent;
|
||||
|
||||
namespace StellaOps.Telemetry.Federation.Tests;
|
||||
|
||||
public sealed class FederatedTelemetryBundleBuilderTests
|
||||
{
|
||||
private static FederatedTelemetryBundleBuilder CreateBuilder(string siteId = "test-site")
|
||||
{
|
||||
var options = Options.Create(new FederatedTelemetryOptions
|
||||
{
|
||||
SiteId = siteId,
|
||||
BundlePredicateType = "stella.ops/federatedTelemetry@v1"
|
||||
});
|
||||
|
||||
return new FederatedTelemetryBundleBuilder(options);
|
||||
}
|
||||
|
||||
private static AggregationResult CreateAggregation()
|
||||
{
|
||||
return new AggregationResult(
|
||||
Buckets:
|
||||
[
|
||||
new AggregationBucket("CVE-2024-0001", 10, 5, 10.3, false),
|
||||
new AggregationBucket("CVE-2024-0002", 3, 2, 0, true),
|
||||
],
|
||||
TotalFacts: 13,
|
||||
SuppressedBuckets: 1,
|
||||
EpsilonSpent: 0.5,
|
||||
AggregatedAt: DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
private static ConsentProof CreateConsentProof()
|
||||
{
|
||||
return new ConsentProof(
|
||||
TenantId: "tenant-1",
|
||||
GrantedBy: "admin@example.com",
|
||||
GrantedAt: DateTimeOffset.UtcNow,
|
||||
ExpiresAt: null,
|
||||
DsseDigest: "sha256:abc123",
|
||||
Envelope: new byte[] { 1, 2, 3 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Build_creates_bundle_with_correct_site_id()
|
||||
{
|
||||
var builder = CreateBuilder("my-site");
|
||||
var aggregation = CreateAggregation();
|
||||
var consent = CreateConsentProof();
|
||||
|
||||
var bundle = await builder.BuildAsync(aggregation, consent);
|
||||
|
||||
Assert.Equal("my-site", bundle.SourceSiteId);
|
||||
Assert.NotEqual(Guid.Empty, bundle.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Build_includes_consent_digest()
|
||||
{
|
||||
var builder = CreateBuilder();
|
||||
var consent = CreateConsentProof();
|
||||
|
||||
var bundle = await builder.BuildAsync(CreateAggregation(), consent);
|
||||
|
||||
Assert.Equal(consent.DsseDigest, bundle.ConsentDsseDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Build_produces_valid_dsse_digest()
|
||||
{
|
||||
var builder = CreateBuilder();
|
||||
|
||||
var bundle = await builder.BuildAsync(CreateAggregation(), CreateConsentProof());
|
||||
|
||||
Assert.StartsWith("sha256:", bundle.BundleDsseDigest);
|
||||
Assert.NotEmpty(bundle.Envelope);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Verify_succeeds_for_unmodified_bundle()
|
||||
{
|
||||
var builder = CreateBuilder();
|
||||
|
||||
var bundle = await builder.BuildAsync(CreateAggregation(), CreateConsentProof());
|
||||
var isValid = await builder.VerifyAsync(bundle);
|
||||
|
||||
Assert.True(isValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Verify_fails_for_tampered_bundle()
|
||||
{
|
||||
var builder = CreateBuilder();
|
||||
|
||||
var bundle = await builder.BuildAsync(CreateAggregation(), CreateConsentProof());
|
||||
|
||||
// Tamper with the envelope
|
||||
var tampered = bundle with
|
||||
{
|
||||
Envelope = new byte[] { 0xFF, 0xFE, 0xFD }
|
||||
};
|
||||
|
||||
var isValid = await builder.VerifyAsync(tampered);
|
||||
|
||||
Assert.False(isValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Build_includes_aggregation_data()
|
||||
{
|
||||
var builder = CreateBuilder();
|
||||
var aggregation = CreateAggregation();
|
||||
|
||||
var bundle = await builder.BuildAsync(aggregation, CreateConsentProof());
|
||||
|
||||
Assert.Same(aggregation, bundle.Aggregation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Build_sets_creation_timestamp()
|
||||
{
|
||||
var builder = CreateBuilder();
|
||||
|
||||
var before = DateTimeOffset.UtcNow;
|
||||
var bundle = await builder.BuildAsync(CreateAggregation(), CreateConsentProof());
|
||||
var after = DateTimeOffset.UtcNow;
|
||||
|
||||
Assert.InRange(bundle.CreatedAt, before, after);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Telemetry.Federation.Aggregation;
|
||||
using StellaOps.Telemetry.Federation.Bundles;
|
||||
using StellaOps.Telemetry.Federation.Consent;
|
||||
using StellaOps.Telemetry.Federation.Intelligence;
|
||||
using StellaOps.Telemetry.Federation.Privacy;
|
||||
using StellaOps.Telemetry.Federation.Sync;
|
||||
|
||||
namespace StellaOps.Telemetry.Federation.Tests;
|
||||
|
||||
public sealed class FederationSyncAndIntelligenceTests
|
||||
{
|
||||
[Fact]
|
||||
public void IntelligenceNormalizer_NormalizesCveSiteAndTimestamp()
|
||||
{
|
||||
var normalizer = new FederatedIntelligenceNormalizer();
|
||||
var localObservedAt = new DateTimeOffset(2026, 2, 20, 8, 30, 0, TimeSpan.FromHours(+3));
|
||||
|
||||
var normalized = normalizer.Normalize(new ExploitIntelligenceEntry(
|
||||
CveId: " cve-2026-12345 ",
|
||||
SourceSiteId: " Site-East ",
|
||||
ObservationCount: 7,
|
||||
NoisyCount: 7.2,
|
||||
ArtifactCount: 3,
|
||||
ObservedAt: localObservedAt));
|
||||
|
||||
Assert.Equal("CVE-2026-12345", normalized.CveId);
|
||||
Assert.Equal("site-east", normalized.SourceSiteId);
|
||||
Assert.Equal(localObservedAt.ToUniversalTime(), normalized.ObservedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExploitIntelligenceMerger_DeduplicatesByCveAndSite_KeepingLatestObservation()
|
||||
{
|
||||
var now = new DateTimeOffset(2026, 2, 20, 10, 0, 0, TimeSpan.Zero);
|
||||
var merger = new ExploitIntelligenceMerger(
|
||||
new FederatedIntelligenceNormalizer(),
|
||||
new DeterministicTimeProvider(now));
|
||||
|
||||
var older = new ExploitIntelligenceEntry(
|
||||
CveId: "CVE-2026-1000",
|
||||
SourceSiteId: "site-a",
|
||||
ObservationCount: 4,
|
||||
NoisyCount: 4.1,
|
||||
ArtifactCount: 2,
|
||||
ObservedAt: now.AddMinutes(-30));
|
||||
|
||||
var newer = older with
|
||||
{
|
||||
ObservationCount = 9,
|
||||
NoisyCount = 9.3,
|
||||
ArtifactCount = 5,
|
||||
ObservedAt = now
|
||||
};
|
||||
|
||||
var corpus = await merger.MergeAsync(new[] { older, newer });
|
||||
|
||||
Assert.Equal(1, corpus.TotalEntries);
|
||||
Assert.Equal(1, corpus.UniqueCves);
|
||||
Assert.Equal(1, corpus.ContributingSites);
|
||||
Assert.Equal(now, corpus.LastUpdated);
|
||||
Assert.Equal(9, corpus.Entries[0].ObservationCount);
|
||||
Assert.Equal(5, corpus.Entries[0].ArtifactCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EgressPolicyIntegration_AllowsByDefault()
|
||||
{
|
||||
var egress = new EgressPolicyIntegration(NullLogger<EgressPolicyIntegration>.Instance);
|
||||
|
||||
var result = await egress.CheckEgressAsync("mesh-eu", 512);
|
||||
|
||||
Assert.True(result.Allowed);
|
||||
Assert.Null(result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EgressPolicyIntegration_ThrowsWhenCancellationRequested()
|
||||
{
|
||||
var egress = new EgressPolicyIntegration(NullLogger<EgressPolicyIntegration>.Instance);
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
await Assert.ThrowsAsync<OperationCanceledException>(() => egress.CheckEgressAsync("mesh-eu", 128, cts.Token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SyncCycle_SkipsWhenBudgetExhausted()
|
||||
{
|
||||
var budget = new StubBudgetTracker(isBudgetExhausted: true);
|
||||
var aggregator = new RecordingAggregator();
|
||||
var consent = new StubConsentManager(granted: true);
|
||||
var bundles = new RecordingBundleBuilder();
|
||||
var egress = new StubEgressPolicy(allowed: true);
|
||||
var service = CreateSyncService(budget, aggregator, consent, bundles, egress);
|
||||
|
||||
service.EnqueueFact(CreateFact("CVE-2026-2001"));
|
||||
await service.RunSyncCycleAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, aggregator.CallCount);
|
||||
Assert.Equal(0, bundles.BuildCallCount);
|
||||
Assert.Equal(0, egress.CallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SyncCycle_SkipsWhenConsentMissing()
|
||||
{
|
||||
var budget = new StubBudgetTracker(isBudgetExhausted: false);
|
||||
var aggregator = new RecordingAggregator();
|
||||
var consent = new StubConsentManager(granted: false);
|
||||
var bundles = new RecordingBundleBuilder();
|
||||
var egress = new StubEgressPolicy(allowed: true);
|
||||
var service = CreateSyncService(budget, aggregator, consent, bundles, egress);
|
||||
|
||||
service.EnqueueFact(CreateFact("CVE-2026-2002"));
|
||||
await service.RunSyncCycleAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, aggregator.CallCount);
|
||||
Assert.Equal(0, bundles.BuildCallCount);
|
||||
Assert.Equal(0, egress.CallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SyncCycle_AggregatesBuildsAndChecksEgress_WhenEligible()
|
||||
{
|
||||
var budget = new StubBudgetTracker(isBudgetExhausted: false);
|
||||
var aggregator = new RecordingAggregator();
|
||||
var consent = new StubConsentManager(granted: true);
|
||||
var bundles = new RecordingBundleBuilder();
|
||||
var egress = new StubEgressPolicy(allowed: true);
|
||||
var service = CreateSyncService(budget, aggregator, consent, bundles, egress);
|
||||
|
||||
service.EnqueueFact(CreateFact("CVE-2026-2003"));
|
||||
await service.RunSyncCycleAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, aggregator.CallCount);
|
||||
Assert.Equal(1, bundles.BuildCallCount);
|
||||
Assert.Equal(1, egress.CallCount);
|
||||
Assert.Equal(1, consent.GrantCallCount);
|
||||
Assert.True(egress.LastPayloadSizeBytes > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SyncCycle_WhenEgressBlocked_StillRunsAggregationAndBuild()
|
||||
{
|
||||
var budget = new StubBudgetTracker(isBudgetExhausted: false);
|
||||
var aggregator = new RecordingAggregator();
|
||||
var consent = new StubConsentManager(granted: true);
|
||||
var bundles = new RecordingBundleBuilder();
|
||||
var egress = new StubEgressPolicy(allowed: false);
|
||||
var service = CreateSyncService(budget, aggregator, consent, bundles, egress);
|
||||
|
||||
service.EnqueueFact(CreateFact("CVE-2026-2004"));
|
||||
await service.RunSyncCycleAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, aggregator.CallCount);
|
||||
Assert.Equal(1, bundles.BuildCallCount);
|
||||
Assert.Equal(1, egress.CallCount);
|
||||
}
|
||||
|
||||
private static FederatedTelemetrySyncService CreateSyncService(
|
||||
IPrivacyBudgetTracker budget,
|
||||
ITelemetryAggregator aggregator,
|
||||
IConsentManager consent,
|
||||
IFederatedTelemetryBundleBuilder bundles,
|
||||
IEgressPolicyIntegration egress)
|
||||
{
|
||||
var options = Options.Create(new FederatedTelemetryOptions
|
||||
{
|
||||
SiteId = "site-test",
|
||||
SealedModeEnabled = false,
|
||||
AggregationInterval = TimeSpan.FromMilliseconds(25)
|
||||
});
|
||||
|
||||
return new FederatedTelemetrySyncService(
|
||||
options,
|
||||
budget,
|
||||
aggregator,
|
||||
consent,
|
||||
bundles,
|
||||
egress,
|
||||
NullLogger<FederatedTelemetrySyncService>.Instance);
|
||||
}
|
||||
|
||||
private static TelemetryFact CreateFact(string cveId)
|
||||
{
|
||||
return new TelemetryFact(
|
||||
ArtifactDigest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
CveId: cveId,
|
||||
SymbolPath: "/usr/lib/libcrypto.so",
|
||||
Exploited: true,
|
||||
ObservedAt: new DateTimeOffset(2026, 2, 20, 10, 0, 0, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
private sealed class DeterministicTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _now;
|
||||
|
||||
public DeterministicTimeProvider(DateTimeOffset now)
|
||||
{
|
||||
_now = now;
|
||||
}
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
}
|
||||
|
||||
private sealed class StubBudgetTracker : IPrivacyBudgetTracker
|
||||
{
|
||||
public StubBudgetTracker(bool isBudgetExhausted)
|
||||
{
|
||||
IsBudgetExhausted = isBudgetExhausted;
|
||||
}
|
||||
|
||||
public double RemainingEpsilon => IsBudgetExhausted ? 0 : 1;
|
||||
public double TotalBudget => 1;
|
||||
public bool IsBudgetExhausted { get; }
|
||||
public DateTimeOffset CurrentPeriodStart => new(2026, 2, 20, 0, 0, 0, TimeSpan.Zero);
|
||||
public DateTimeOffset NextReset => CurrentPeriodStart.AddHours(24);
|
||||
|
||||
public bool TrySpend(double epsilon) => !IsBudgetExhausted && epsilon > 0;
|
||||
public void Reset() { }
|
||||
|
||||
public PrivacyBudgetSnapshot GetSnapshot()
|
||||
{
|
||||
return new PrivacyBudgetSnapshot(
|
||||
Remaining: RemainingEpsilon,
|
||||
Total: TotalBudget,
|
||||
Exhausted: IsBudgetExhausted,
|
||||
PeriodStart: CurrentPeriodStart,
|
||||
NextReset: NextReset,
|
||||
QueriesThisPeriod: 0,
|
||||
SuppressedThisPeriod: IsBudgetExhausted ? 1 : 0);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingAggregator : ITelemetryAggregator
|
||||
{
|
||||
public int CallCount { get; private set; }
|
||||
|
||||
public Task<AggregationResult> AggregateAsync(IReadOnlyList<TelemetryFact> facts, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
CallCount++;
|
||||
|
||||
var bucket = new AggregationBucket(
|
||||
CveId: facts[0].CveId,
|
||||
ObservationCount: facts.Count,
|
||||
ArtifactCount: facts.Select(static fact => fact.ArtifactDigest).Distinct().Count(),
|
||||
NoisyCount: facts.Count,
|
||||
Suppressed: false);
|
||||
|
||||
return Task.FromResult(new AggregationResult(
|
||||
Buckets: new[] { bucket },
|
||||
TotalFacts: facts.Count,
|
||||
SuppressedBuckets: 0,
|
||||
EpsilonSpent: 0.1,
|
||||
AggregatedAt: new DateTimeOffset(2026, 2, 20, 10, 5, 0, TimeSpan.Zero)));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubConsentManager : IConsentManager
|
||||
{
|
||||
private bool _granted;
|
||||
|
||||
public StubConsentManager(bool granted)
|
||||
{
|
||||
_granted = granted;
|
||||
}
|
||||
|
||||
public int GrantCallCount { get; private set; }
|
||||
|
||||
public Task<ConsentState> GetConsentStateAsync(string tenantId, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
return Task.FromResult(new ConsentState(
|
||||
Granted: _granted,
|
||||
GrantedBy: _granted ? "tester" : null,
|
||||
GrantedAt: _granted ? new DateTimeOffset(2026, 2, 20, 9, 0, 0, TimeSpan.Zero) : null,
|
||||
ExpiresAt: null,
|
||||
DsseDigest: _granted ? "sha256:consent" : null));
|
||||
}
|
||||
|
||||
public Task<ConsentProof> GrantConsentAsync(string tenantId, string grantedBy, TimeSpan? ttl = null, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
GrantCallCount++;
|
||||
_granted = true;
|
||||
|
||||
return Task.FromResult(new ConsentProof(
|
||||
TenantId: tenantId,
|
||||
GrantedBy: grantedBy,
|
||||
GrantedAt: new DateTimeOffset(2026, 2, 20, 9, 0, 0, TimeSpan.Zero),
|
||||
ExpiresAt: ttl.HasValue ? new DateTimeOffset(2026, 2, 20, 9, 0, 0, TimeSpan.Zero).Add(ttl.Value) : null,
|
||||
DsseDigest: "sha256:consent-proof",
|
||||
Envelope: [0x10, 0x11, 0x12]));
|
||||
}
|
||||
|
||||
public Task RevokeConsentAsync(string tenantId, string revokedBy, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
_granted = false;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingBundleBuilder : IFederatedTelemetryBundleBuilder
|
||||
{
|
||||
public int BuildCallCount { get; private set; }
|
||||
|
||||
public Task<FederatedBundle> BuildAsync(AggregationResult aggregation, ConsentProof consent, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
BuildCallCount++;
|
||||
return Task.FromResult(new FederatedBundle(
|
||||
Id: Guid.Parse("2f4ad049-5b29-4f51-b022-ec0735630f38"),
|
||||
SourceSiteId: "site-test",
|
||||
Aggregation: aggregation,
|
||||
ConsentDsseDigest: consent.DsseDigest,
|
||||
BundleDsseDigest: "sha256:bundle-proof",
|
||||
Envelope: [0x01, 0x02, 0x03, 0x04],
|
||||
CreatedAt: new DateTimeOffset(2026, 2, 20, 10, 6, 0, TimeSpan.Zero)));
|
||||
}
|
||||
|
||||
public Task<bool> VerifyAsync(FederatedBundle bundle, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubEgressPolicy : IEgressPolicyIntegration
|
||||
{
|
||||
private readonly bool _allowed;
|
||||
|
||||
public StubEgressPolicy(bool allowed)
|
||||
{
|
||||
_allowed = allowed;
|
||||
}
|
||||
|
||||
public int CallCount { get; private set; }
|
||||
public int LastPayloadSizeBytes { get; private set; }
|
||||
|
||||
public Task<EgressCheckResult> CheckEgressAsync(
|
||||
string destinationSiteId,
|
||||
int payloadSizeBytes,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
CallCount++;
|
||||
LastPayloadSizeBytes = payloadSizeBytes;
|
||||
return Task.FromResult(new EgressCheckResult(
|
||||
Allowed: _allowed,
|
||||
Reason: _allowed ? null : "egress_policy_blocked"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Telemetry.Federation.Privacy;
|
||||
|
||||
namespace StellaOps.Telemetry.Federation.Tests;
|
||||
|
||||
public sealed class PrivacyBudgetTrackerTests
|
||||
{
|
||||
private static IOptions<FederatedTelemetryOptions> DefaultOptions(
|
||||
double epsilon = 1.0,
|
||||
TimeSpan? resetPeriod = null)
|
||||
{
|
||||
return Options.Create(new FederatedTelemetryOptions
|
||||
{
|
||||
EpsilonBudget = epsilon,
|
||||
BudgetResetPeriod = resetPeriod ?? TimeSpan.FromHours(24)
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Initial_budget_equals_total()
|
||||
{
|
||||
var tracker = new PrivacyBudgetTracker(DefaultOptions(epsilon: 2.0));
|
||||
|
||||
Assert.Equal(2.0, tracker.TotalBudget);
|
||||
Assert.Equal(2.0, tracker.RemainingEpsilon);
|
||||
Assert.False(tracker.IsBudgetExhausted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrySpend_reduces_remaining_epsilon()
|
||||
{
|
||||
var tracker = new PrivacyBudgetTracker(DefaultOptions(epsilon: 1.0));
|
||||
|
||||
Assert.True(tracker.TrySpend(0.3));
|
||||
Assert.Equal(0.7, tracker.RemainingEpsilon, precision: 10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrySpend_rejects_when_budget_exhausted()
|
||||
{
|
||||
var tracker = new PrivacyBudgetTracker(DefaultOptions(epsilon: 0.5));
|
||||
|
||||
Assert.True(tracker.TrySpend(0.5));
|
||||
Assert.False(tracker.TrySpend(0.1));
|
||||
Assert.True(tracker.IsBudgetExhausted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrySpend_rejects_negative_or_zero_epsilon()
|
||||
{
|
||||
var tracker = new PrivacyBudgetTracker(DefaultOptions());
|
||||
|
||||
Assert.False(tracker.TrySpend(0));
|
||||
Assert.False(tracker.TrySpend(-0.5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_restores_full_budget()
|
||||
{
|
||||
var tracker = new PrivacyBudgetTracker(DefaultOptions(epsilon: 1.0));
|
||||
|
||||
tracker.TrySpend(0.8);
|
||||
Assert.Equal(0.2, tracker.RemainingEpsilon, precision: 10);
|
||||
|
||||
tracker.Reset();
|
||||
Assert.Equal(1.0, tracker.RemainingEpsilon);
|
||||
Assert.False(tracker.IsBudgetExhausted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Snapshot_tracks_queries_and_suppressed_counts()
|
||||
{
|
||||
var tracker = new PrivacyBudgetTracker(DefaultOptions(epsilon: 0.5));
|
||||
|
||||
tracker.TrySpend(0.2); // success
|
||||
tracker.TrySpend(0.2); // success
|
||||
tracker.TrySpend(0.2); // fails — over budget
|
||||
|
||||
var snapshot = tracker.GetSnapshot();
|
||||
Assert.Equal(2, snapshot.QueriesThisPeriod);
|
||||
Assert.Equal(1, snapshot.SuppressedThisPeriod);
|
||||
Assert.True(snapshot.Exhausted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LaplacianNoise_produces_finite_values()
|
||||
{
|
||||
var rng = new Random(42);
|
||||
for (int i = 0; i < 1000; i++)
|
||||
{
|
||||
var noise = PrivacyBudgetTracker.LaplacianNoise(1.0, 0.5, rng);
|
||||
Assert.True(double.IsFinite(noise));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LaplacianNoise_is_deterministic_with_fixed_seed()
|
||||
{
|
||||
var rng1 = new Random(12345);
|
||||
var rng2 = new Random(12345);
|
||||
|
||||
var noise1 = PrivacyBudgetTracker.LaplacianNoise(1.0, 1.0, rng1);
|
||||
var noise2 = PrivacyBudgetTracker.LaplacianNoise(1.0, 1.0, rng2);
|
||||
|
||||
Assert.Equal(noise1, noise2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LaplacianNoise_scales_with_sensitivity_and_epsilon()
|
||||
{
|
||||
var rng = new Random(42);
|
||||
var samples = Enumerable.Range(0, 10000)
|
||||
.Select(_ => PrivacyBudgetTracker.LaplacianNoise(1.0, 1.0, rng))
|
||||
.ToList();
|
||||
|
||||
// Mean should be approximately 0 for large sample
|
||||
var mean = samples.Average();
|
||||
Assert.True(Math.Abs(mean) < 0.1, $"Mean {mean} too far from 0");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
<ConcelierTestingPath></ConcelierTestingPath>
|
||||
<ConcelierSharedTestsPath></ConcelierSharedTestsPath>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
<PackageReference Remove="Microsoft.AspNetCore.Mvc.Testing" />
|
||||
<PackageReference Remove="Microsoft.Extensions.TimeProvider.Testing" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Telemetry.Federation\StellaOps.Telemetry.Federation.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="StellaOps.TestKit" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,150 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Telemetry.Federation.Aggregation;
|
||||
using StellaOps.Telemetry.Federation.Privacy;
|
||||
|
||||
namespace StellaOps.Telemetry.Federation.Tests;
|
||||
|
||||
public sealed class TelemetryAggregatorTests
|
||||
{
|
||||
private static (TelemetryAggregator aggregator, PrivacyBudgetTracker budget) CreateAggregator(
|
||||
int kThreshold = 3,
|
||||
double epsilon = 10.0,
|
||||
int seed = 42)
|
||||
{
|
||||
var options = Options.Create(new FederatedTelemetryOptions
|
||||
{
|
||||
KAnonymityThreshold = kThreshold,
|
||||
EpsilonBudget = epsilon
|
||||
});
|
||||
|
||||
var budget = new PrivacyBudgetTracker(options);
|
||||
var rng = new Random(seed);
|
||||
var aggregator = new TelemetryAggregator(options, budget, rng: rng);
|
||||
return (aggregator, budget);
|
||||
}
|
||||
|
||||
private static List<TelemetryFact> CreateFacts(string cveId, int distinctArtifacts, int observationsPerArtifact = 1)
|
||||
{
|
||||
var facts = new List<TelemetryFact>();
|
||||
for (int a = 0; a < distinctArtifacts; a++)
|
||||
{
|
||||
for (int o = 0; o < observationsPerArtifact; o++)
|
||||
{
|
||||
facts.Add(new TelemetryFact(
|
||||
ArtifactDigest: $"sha256:artifact{a:D4}",
|
||||
CveId: cveId,
|
||||
SymbolPath: $"lib/module{a}.dll",
|
||||
Exploited: o % 2 == 0,
|
||||
ObservedAt: DateTimeOffset.UtcNow));
|
||||
}
|
||||
}
|
||||
return facts;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Suppresses_buckets_below_k_anonymity_threshold()
|
||||
{
|
||||
var (aggregator, _) = CreateAggregator(kThreshold: 5);
|
||||
|
||||
// CVE-2024-0001 has only 3 distinct artifacts (below k=5)
|
||||
var facts = CreateFacts("CVE-2024-0001", distinctArtifacts: 3, observationsPerArtifact: 2);
|
||||
|
||||
var result = await aggregator.AggregateAsync(facts);
|
||||
|
||||
Assert.Single(result.Buckets);
|
||||
Assert.True(result.Buckets[0].Suppressed);
|
||||
Assert.Equal(1, result.SuppressedBuckets);
|
||||
Assert.Equal(0, result.Buckets[0].NoisyCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Passes_buckets_meeting_k_anonymity_threshold()
|
||||
{
|
||||
var (aggregator, _) = CreateAggregator(kThreshold: 3);
|
||||
|
||||
// CVE-2024-0002 has 5 distinct artifacts (above k=3)
|
||||
var facts = CreateFacts("CVE-2024-0002", distinctArtifacts: 5);
|
||||
|
||||
var result = await aggregator.AggregateAsync(facts);
|
||||
|
||||
Assert.Single(result.Buckets);
|
||||
Assert.False(result.Buckets[0].Suppressed);
|
||||
Assert.True(result.Buckets[0].NoisyCount > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Noisy_count_differs_from_true_count()
|
||||
{
|
||||
var (aggregator, _) = CreateAggregator(kThreshold: 2, epsilon: 0.5);
|
||||
|
||||
var facts = CreateFacts("CVE-2024-0003", distinctArtifacts: 10, observationsPerArtifact: 3);
|
||||
|
||||
var result = await aggregator.AggregateAsync(facts);
|
||||
var bucket = result.Buckets.Single(b => !b.Suppressed);
|
||||
|
||||
// With low epsilon, noise is large; noisy count should differ from true count (30)
|
||||
Assert.NotEqual(30.0, bucket.NoisyCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deterministic_output_with_fixed_seed()
|
||||
{
|
||||
var (agg1, _) = CreateAggregator(seed: 99);
|
||||
var (agg2, _) = CreateAggregator(seed: 99);
|
||||
|
||||
var facts = CreateFacts("CVE-2024-0004", distinctArtifacts: 5);
|
||||
|
||||
var result1 = await agg1.AggregateAsync(facts);
|
||||
var result2 = await agg2.AggregateAsync(facts);
|
||||
|
||||
Assert.Equal(
|
||||
result1.Buckets[0].NoisyCount,
|
||||
result2.Buckets[0].NoisyCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Spends_epsilon_from_budget()
|
||||
{
|
||||
var (aggregator, budget) = CreateAggregator(kThreshold: 2, epsilon: 1.0);
|
||||
|
||||
var facts = CreateFacts("CVE-2024-0005", distinctArtifacts: 5);
|
||||
|
||||
var before = budget.RemainingEpsilon;
|
||||
await aggregator.AggregateAsync(facts);
|
||||
var after = budget.RemainingEpsilon;
|
||||
|
||||
Assert.True(after < before, "Budget should have been reduced after aggregation");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Budget_exhaustion_suppresses_remaining_buckets()
|
||||
{
|
||||
var (aggregator, budget) = CreateAggregator(kThreshold: 1, epsilon: 0.01);
|
||||
|
||||
// Create many CVE groups to exhaust the tiny budget
|
||||
var facts = new List<TelemetryFact>();
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
facts.AddRange(CreateFacts($"CVE-2024-{i:D4}", distinctArtifacts: 2));
|
||||
}
|
||||
|
||||
var result = await aggregator.AggregateAsync(facts);
|
||||
|
||||
Assert.True(result.SuppressedBuckets > 0, "Some buckets should be suppressed due to budget exhaustion");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Multiple_cve_groups_aggregated_separately()
|
||||
{
|
||||
var (aggregator, _) = CreateAggregator(kThreshold: 2);
|
||||
|
||||
var facts = new List<TelemetryFact>();
|
||||
facts.AddRange(CreateFacts("CVE-2024-1000", distinctArtifacts: 5));
|
||||
facts.AddRange(CreateFacts("CVE-2024-2000", distinctArtifacts: 5));
|
||||
|
||||
var result = await aggregator.AggregateAsync(facts);
|
||||
|
||||
Assert.Equal(2, result.Buckets.Count);
|
||||
Assert.Equal(10, result.TotalFacts);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace StellaOps.Telemetry.Federation.Aggregation;
|
||||
|
||||
public interface ITelemetryAggregator
|
||||
{
|
||||
Task<AggregationResult> AggregateAsync(
|
||||
IReadOnlyList<TelemetryFact> facts,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public sealed record TelemetryFact(
|
||||
string ArtifactDigest,
|
||||
string CveId,
|
||||
string SymbolPath,
|
||||
bool Exploited,
|
||||
DateTimeOffset ObservedAt);
|
||||
|
||||
public sealed record AggregationBucket(
|
||||
string CveId,
|
||||
int ObservationCount,
|
||||
int ArtifactCount,
|
||||
double NoisyCount,
|
||||
bool Suppressed);
|
||||
|
||||
public sealed record AggregationResult(
|
||||
IReadOnlyList<AggregationBucket> Buckets,
|
||||
int TotalFacts,
|
||||
int SuppressedBuckets,
|
||||
double EpsilonSpent,
|
||||
DateTimeOffset AggregatedAt);
|
||||
@@ -0,0 +1,101 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Telemetry.Federation.Privacy;
|
||||
|
||||
namespace StellaOps.Telemetry.Federation.Aggregation;
|
||||
|
||||
public sealed class TelemetryAggregator : ITelemetryAggregator
|
||||
{
|
||||
private readonly FederatedTelemetryOptions _options;
|
||||
private readonly IPrivacyBudgetTracker _budgetTracker;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly Random _rng;
|
||||
|
||||
public TelemetryAggregator(
|
||||
IOptions<FederatedTelemetryOptions> options,
|
||||
IPrivacyBudgetTracker budgetTracker,
|
||||
TimeProvider? timeProvider = null,
|
||||
Random? rng = null)
|
||||
{
|
||||
_options = options.Value;
|
||||
_budgetTracker = budgetTracker;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_rng = rng ?? Random.Shared;
|
||||
}
|
||||
|
||||
public Task<AggregationResult> AggregateAsync(
|
||||
IReadOnlyList<TelemetryFact> facts,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
// Group facts by CVE ID
|
||||
var groups = facts
|
||||
.GroupBy(f => f.CveId)
|
||||
.Select(g => new
|
||||
{
|
||||
CveId = g.Key,
|
||||
Observations = g.ToList(),
|
||||
DistinctArtifacts = g.Select(f => f.ArtifactDigest).Distinct().Count()
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var buckets = new List<AggregationBucket>();
|
||||
var suppressedCount = 0;
|
||||
var epsilonPerBucket = _options.EpsilonBudget / Math.Max(1, groups.Count);
|
||||
var totalEpsilonSpent = 0.0;
|
||||
|
||||
foreach (var group in groups)
|
||||
{
|
||||
// K-anonymity: suppress buckets with fewer than k distinct artifacts
|
||||
if (group.DistinctArtifacts < _options.KAnonymityThreshold)
|
||||
{
|
||||
buckets.Add(new AggregationBucket(
|
||||
CveId: group.CveId,
|
||||
ObservationCount: group.Observations.Count,
|
||||
ArtifactCount: group.DistinctArtifacts,
|
||||
NoisyCount: 0,
|
||||
Suppressed: true));
|
||||
suppressedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try to spend epsilon for this bucket
|
||||
if (!_budgetTracker.TrySpend(epsilonPerBucket))
|
||||
{
|
||||
buckets.Add(new AggregationBucket(
|
||||
CveId: group.CveId,
|
||||
ObservationCount: group.Observations.Count,
|
||||
ArtifactCount: group.DistinctArtifacts,
|
||||
NoisyCount: 0,
|
||||
Suppressed: true));
|
||||
suppressedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add Laplacian noise to the observation count
|
||||
var noise = PrivacyBudgetTracker.LaplacianNoise(
|
||||
sensitivity: 1.0,
|
||||
epsilon: epsilonPerBucket,
|
||||
rng: _rng);
|
||||
|
||||
var noisyCount = Math.Max(0, group.Observations.Count + noise);
|
||||
totalEpsilonSpent += epsilonPerBucket;
|
||||
|
||||
buckets.Add(new AggregationBucket(
|
||||
CveId: group.CveId,
|
||||
ObservationCount: group.Observations.Count,
|
||||
ArtifactCount: group.DistinctArtifacts,
|
||||
NoisyCount: noisyCount,
|
||||
Suppressed: false));
|
||||
}
|
||||
|
||||
var result = new AggregationResult(
|
||||
Buckets: buckets,
|
||||
TotalFacts: facts.Count,
|
||||
SuppressedBuckets: suppressedCount,
|
||||
EpsilonSpent: totalEpsilonSpent,
|
||||
AggregatedAt: _timeProvider.GetUtcNow());
|
||||
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Telemetry.Federation.Aggregation;
|
||||
using StellaOps.Telemetry.Federation.Consent;
|
||||
|
||||
namespace StellaOps.Telemetry.Federation.Bundles;
|
||||
|
||||
public sealed class FederatedTelemetryBundleBuilder : IFederatedTelemetryBundleBuilder
|
||||
{
|
||||
private readonly FederatedTelemetryOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public FederatedTelemetryBundleBuilder(
|
||||
IOptions<FederatedTelemetryOptions> options,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_options = options.Value;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public Task<FederatedBundle> BuildAsync(
|
||||
AggregationResult aggregation,
|
||||
ConsentProof consent,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var bundleId = Guid.NewGuid();
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
var payload = JsonSerializer.SerializeToUtf8Bytes(new
|
||||
{
|
||||
id = bundleId,
|
||||
siteId = _options.SiteId,
|
||||
predicateType = _options.BundlePredicateType,
|
||||
aggregatedAt = aggregation.AggregatedAt,
|
||||
totalFacts = aggregation.TotalFacts,
|
||||
suppressedBuckets = aggregation.SuppressedBuckets,
|
||||
epsilonSpent = aggregation.EpsilonSpent,
|
||||
buckets = aggregation.Buckets.Where(b => !b.Suppressed).Select(b => new
|
||||
{
|
||||
cveId = b.CveId,
|
||||
noisyCount = b.NoisyCount,
|
||||
artifactCount = b.ArtifactCount
|
||||
}),
|
||||
consentDigest = consent.DsseDigest,
|
||||
createdAt = now
|
||||
});
|
||||
|
||||
var digest = ComputeDigest(payload);
|
||||
var envelope = payload; // Placeholder: real DSSE envelope wraps with signature
|
||||
|
||||
var bundle = new FederatedBundle(
|
||||
Id: bundleId,
|
||||
SourceSiteId: _options.SiteId,
|
||||
Aggregation: aggregation,
|
||||
ConsentDsseDigest: consent.DsseDigest,
|
||||
BundleDsseDigest: digest,
|
||||
Envelope: envelope,
|
||||
CreatedAt: now);
|
||||
|
||||
return Task.FromResult(bundle);
|
||||
}
|
||||
|
||||
public Task<bool> VerifyAsync(FederatedBundle bundle, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
// Verify the bundle digest matches the envelope content
|
||||
var recomputedDigest = ComputeDigest(bundle.Envelope);
|
||||
var isValid = string.Equals(recomputedDigest, bundle.BundleDsseDigest, StringComparison.Ordinal);
|
||||
|
||||
return Task.FromResult(isValid);
|
||||
}
|
||||
|
||||
private static string ComputeDigest(byte[] payload)
|
||||
{
|
||||
var hash = SHA256.HashData(payload);
|
||||
return $"sha256:{Convert.ToHexStringLower(hash)}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using StellaOps.Telemetry.Federation.Aggregation;
|
||||
using StellaOps.Telemetry.Federation.Consent;
|
||||
|
||||
namespace StellaOps.Telemetry.Federation.Bundles;
|
||||
|
||||
public interface IFederatedTelemetryBundleBuilder
|
||||
{
|
||||
Task<FederatedBundle> BuildAsync(
|
||||
AggregationResult aggregation,
|
||||
ConsentProof consent,
|
||||
CancellationToken ct = default);
|
||||
|
||||
Task<bool> VerifyAsync(
|
||||
FederatedBundle bundle,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public sealed record FederatedBundle(
|
||||
Guid Id,
|
||||
string SourceSiteId,
|
||||
AggregationResult Aggregation,
|
||||
string ConsentDsseDigest,
|
||||
string BundleDsseDigest,
|
||||
byte[] Envelope,
|
||||
DateTimeOffset CreatedAt);
|
||||
@@ -0,0 +1,106 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Telemetry.Federation.Consent;
|
||||
|
||||
public sealed class ConsentManager : IConsentManager
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ConsentEntry> _consents = new();
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public ConsentManager(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public Task<ConsentState> GetConsentStateAsync(string tenantId, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
if (!_consents.TryGetValue(tenantId, out var entry))
|
||||
{
|
||||
return Task.FromResult(new ConsentState(
|
||||
Granted: false,
|
||||
GrantedBy: null,
|
||||
GrantedAt: null,
|
||||
ExpiresAt: null,
|
||||
DsseDigest: null));
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
if (entry.ExpiresAt.HasValue && now >= entry.ExpiresAt.Value)
|
||||
{
|
||||
_consents.TryRemove(tenantId, out _);
|
||||
return Task.FromResult(new ConsentState(
|
||||
Granted: false,
|
||||
GrantedBy: null,
|
||||
GrantedAt: null,
|
||||
ExpiresAt: null,
|
||||
DsseDigest: null));
|
||||
}
|
||||
|
||||
return Task.FromResult(new ConsentState(
|
||||
Granted: true,
|
||||
GrantedBy: entry.GrantedBy,
|
||||
GrantedAt: entry.GrantedAt,
|
||||
ExpiresAt: entry.ExpiresAt,
|
||||
DsseDigest: entry.DsseDigest));
|
||||
}
|
||||
|
||||
public Task<ConsentProof> GrantConsentAsync(
|
||||
string tenantId,
|
||||
string grantedBy,
|
||||
TimeSpan? ttl = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var expiresAt = ttl.HasValue ? now + ttl.Value : (DateTimeOffset?)null;
|
||||
|
||||
var payload = JsonSerializer.SerializeToUtf8Bytes(new
|
||||
{
|
||||
tenantId,
|
||||
grantedBy,
|
||||
grantedAt = now,
|
||||
expiresAt,
|
||||
type = "stella.ops/federatedConsent@v1"
|
||||
});
|
||||
|
||||
var digest = ComputeDigest(payload);
|
||||
var envelope = payload; // Placeholder: real DSSE envelope wraps with signature
|
||||
|
||||
var entry = new ConsentEntry(tenantId, grantedBy, now, expiresAt, digest);
|
||||
_consents[tenantId] = entry;
|
||||
|
||||
return Task.FromResult(new ConsentProof(
|
||||
TenantId: tenantId,
|
||||
GrantedBy: grantedBy,
|
||||
GrantedAt: now,
|
||||
ExpiresAt: expiresAt,
|
||||
DsseDigest: digest,
|
||||
Envelope: envelope));
|
||||
}
|
||||
|
||||
public Task RevokeConsentAsync(string tenantId, string revokedBy, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
_consents.TryRemove(tenantId, out _);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static string ComputeDigest(byte[] payload)
|
||||
{
|
||||
var hash = SHA256.HashData(payload);
|
||||
return $"sha256:{Convert.ToHexStringLower(hash)}";
|
||||
}
|
||||
|
||||
private sealed record ConsentEntry(
|
||||
string TenantId,
|
||||
string GrantedBy,
|
||||
DateTimeOffset GrantedAt,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
string DsseDigest);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace StellaOps.Telemetry.Federation.Consent;
|
||||
|
||||
public interface IConsentManager
|
||||
{
|
||||
Task<ConsentState> GetConsentStateAsync(string tenantId, CancellationToken ct = default);
|
||||
Task<ConsentProof> GrantConsentAsync(string tenantId, string grantedBy, TimeSpan? ttl = null, CancellationToken ct = default);
|
||||
Task RevokeConsentAsync(string tenantId, string revokedBy, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public sealed record ConsentState(
|
||||
bool Granted,
|
||||
string? GrantedBy,
|
||||
DateTimeOffset? GrantedAt,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
string? DsseDigest);
|
||||
|
||||
public sealed record ConsentProof(
|
||||
string TenantId,
|
||||
string GrantedBy,
|
||||
DateTimeOffset GrantedAt,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
string DsseDigest,
|
||||
byte[] Envelope);
|
||||
@@ -0,0 +1,46 @@
|
||||
namespace StellaOps.Telemetry.Federation;
|
||||
|
||||
public sealed class FederatedTelemetryOptions
|
||||
{
|
||||
public const string SectionName = "FederatedTelemetry";
|
||||
|
||||
/// <summary>
|
||||
/// Minimum number of distinct artifacts per CVE bucket to avoid suppression.
|
||||
/// </summary>
|
||||
public int KAnonymityThreshold { get; set; } = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Total differential privacy epsilon budget per reset period.
|
||||
/// </summary>
|
||||
public double EpsilonBudget { get; set; } = 1.0;
|
||||
|
||||
/// <summary>
|
||||
/// How often the privacy budget resets.
|
||||
/// </summary>
|
||||
public TimeSpan BudgetResetPeriod { get; set; } = TimeSpan.FromHours(24);
|
||||
|
||||
/// <summary>
|
||||
/// Interval between automated aggregation cycles.
|
||||
/// </summary>
|
||||
public TimeSpan AggregationInterval { get; set; } = TimeSpan.FromMinutes(15);
|
||||
|
||||
/// <summary>
|
||||
/// When true, federation operates in sealed mode (no outbound traffic).
|
||||
/// </summary>
|
||||
public bool SealedModeEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// DSSE predicate type for consent attestation.
|
||||
/// </summary>
|
||||
public string ConsentPredicateType { get; set; } = "stella.ops/federatedConsent@v1";
|
||||
|
||||
/// <summary>
|
||||
/// DSSE predicate type for telemetry bundle attestation.
|
||||
/// </summary>
|
||||
public string BundlePredicateType { get; set; } = "stella.ops/federatedTelemetry@v1";
|
||||
|
||||
/// <summary>
|
||||
/// Identifier for this site in the federation mesh.
|
||||
/// </summary>
|
||||
public string SiteId { get; set; } = "default";
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Telemetry.Federation.Aggregation;
|
||||
using StellaOps.Telemetry.Federation.Bundles;
|
||||
using StellaOps.Telemetry.Federation.Consent;
|
||||
using StellaOps.Telemetry.Federation.Intelligence;
|
||||
using StellaOps.Telemetry.Federation.Privacy;
|
||||
using StellaOps.Telemetry.Federation.Sync;
|
||||
|
||||
namespace StellaOps.Telemetry.Federation;
|
||||
|
||||
public static class FederationServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddFederatedTelemetry(
|
||||
this IServiceCollection services,
|
||||
Action<FederatedTelemetryOptions>? configureOptions = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
services.AddOptions<FederatedTelemetryOptions>()
|
||||
.Configure(options => configureOptions?.Invoke(options));
|
||||
|
||||
services.TryAddSingleton<IPrivacyBudgetTracker, PrivacyBudgetTracker>();
|
||||
services.TryAddSingleton<ITelemetryAggregator, TelemetryAggregator>();
|
||||
services.TryAddSingleton<IConsentManager, ConsentManager>();
|
||||
services.TryAddSingleton<IFederatedTelemetryBundleBuilder, FederatedTelemetryBundleBuilder>();
|
||||
services.TryAddSingleton<IExploitIntelligenceMerger, ExploitIntelligenceMerger>();
|
||||
services.TryAddSingleton<FederatedIntelligenceNormalizer>();
|
||||
services.TryAddSingleton<IEgressPolicyIntegration, EgressPolicyIntegration>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddFederatedTelemetrySync(
|
||||
this IServiceCollection services)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
services.AddHostedService<FederatedTelemetrySyncService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace StellaOps.Telemetry.Federation.Intelligence;
|
||||
|
||||
public sealed class ExploitIntelligenceMerger : IExploitIntelligenceMerger
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ExploitIntelligenceEntry> _corpus = new();
|
||||
private readonly FederatedIntelligenceNormalizer _normalizer;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public ExploitIntelligenceMerger(
|
||||
FederatedIntelligenceNormalizer normalizer,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_normalizer = normalizer;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public Task<ExploitCorpus> MergeAsync(
|
||||
IReadOnlyList<ExploitIntelligenceEntry> incoming,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
foreach (var entry in incoming)
|
||||
{
|
||||
var normalized = _normalizer.Normalize(entry);
|
||||
var key = $"{normalized.CveId}:{normalized.SourceSiteId}";
|
||||
|
||||
_corpus.AddOrUpdate(
|
||||
key,
|
||||
normalized,
|
||||
(_, existing) => normalized.ObservedAt > existing.ObservedAt ? normalized : existing);
|
||||
}
|
||||
|
||||
return GetCorpusAsync(ct);
|
||||
}
|
||||
|
||||
public Task<ExploitCorpus> GetCorpusAsync(CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var entries = _corpus.Values.ToList();
|
||||
var corpus = new ExploitCorpus(
|
||||
Entries: entries,
|
||||
TotalEntries: entries.Count,
|
||||
UniqueCves: entries.Select(e => e.CveId).Distinct().Count(),
|
||||
ContributingSites: entries.Select(e => e.SourceSiteId).Distinct().Count(),
|
||||
LastUpdated: _timeProvider.GetUtcNow());
|
||||
|
||||
return Task.FromResult(corpus);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Telemetry.Federation.Intelligence;
|
||||
|
||||
public sealed partial class FederatedIntelligenceNormalizer
|
||||
{
|
||||
[GeneratedRegex(@"^CVE-\d{4}-\d{4,}$", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
|
||||
private static partial Regex CvePattern();
|
||||
|
||||
public ExploitIntelligenceEntry Normalize(ExploitIntelligenceEntry entry)
|
||||
{
|
||||
return entry with
|
||||
{
|
||||
CveId = NormalizeCveId(entry.CveId),
|
||||
SourceSiteId = entry.SourceSiteId.Trim().ToLowerInvariant(),
|
||||
ObservedAt = entry.ObservedAt.ToUniversalTime()
|
||||
};
|
||||
}
|
||||
|
||||
private static string NormalizeCveId(string cveId)
|
||||
{
|
||||
var trimmed = cveId.Trim();
|
||||
|
||||
// Ensure uppercase CVE prefix
|
||||
if (CvePattern().IsMatch(trimmed))
|
||||
{
|
||||
return trimmed.ToUpperInvariant();
|
||||
}
|
||||
|
||||
// If it doesn't match the pattern, return as-is (may be a non-CVE identifier)
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace StellaOps.Telemetry.Federation.Intelligence;
|
||||
|
||||
public interface IExploitIntelligenceMerger
|
||||
{
|
||||
Task<ExploitCorpus> MergeAsync(
|
||||
IReadOnlyList<ExploitIntelligenceEntry> incoming,
|
||||
CancellationToken ct = default);
|
||||
|
||||
Task<ExploitCorpus> GetCorpusAsync(CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public sealed record ExploitIntelligenceEntry(
|
||||
string CveId,
|
||||
string SourceSiteId,
|
||||
int ObservationCount,
|
||||
double NoisyCount,
|
||||
int ArtifactCount,
|
||||
DateTimeOffset ObservedAt);
|
||||
|
||||
public sealed record ExploitCorpus(
|
||||
IReadOnlyList<ExploitIntelligenceEntry> Entries,
|
||||
int TotalEntries,
|
||||
int UniqueCves,
|
||||
int ContributingSites,
|
||||
DateTimeOffset LastUpdated);
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace StellaOps.Telemetry.Federation.Privacy;
|
||||
|
||||
public interface IPrivacyBudgetTracker
|
||||
{
|
||||
double RemainingEpsilon { get; }
|
||||
double TotalBudget { get; }
|
||||
bool IsBudgetExhausted { get; }
|
||||
DateTimeOffset CurrentPeriodStart { get; }
|
||||
DateTimeOffset NextReset { get; }
|
||||
bool TrySpend(double epsilon);
|
||||
void Reset();
|
||||
PrivacyBudgetSnapshot GetSnapshot();
|
||||
}
|
||||
|
||||
public sealed record PrivacyBudgetSnapshot(
|
||||
double Remaining,
|
||||
double Total,
|
||||
bool Exhausted,
|
||||
DateTimeOffset PeriodStart,
|
||||
DateTimeOffset NextReset,
|
||||
int QueriesThisPeriod,
|
||||
int SuppressedThisPeriod);
|
||||
@@ -0,0 +1,137 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Telemetry.Federation.Privacy;
|
||||
|
||||
public sealed class PrivacyBudgetTracker : IPrivacyBudgetTracker
|
||||
{
|
||||
private readonly FederatedTelemetryOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly object _lock = new();
|
||||
|
||||
private double _spent;
|
||||
private int _queriesThisPeriod;
|
||||
private int _suppressedThisPeriod;
|
||||
private DateTimeOffset _periodStart;
|
||||
|
||||
public PrivacyBudgetTracker(IOptions<FederatedTelemetryOptions> options, TimeProvider? timeProvider = null)
|
||||
{
|
||||
_options = options.Value;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_periodStart = _timeProvider.GetUtcNow();
|
||||
}
|
||||
|
||||
public double RemainingEpsilon
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
MaybeResetPeriod();
|
||||
return Math.Max(0, _options.EpsilonBudget - _spent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public double TotalBudget => _options.EpsilonBudget;
|
||||
|
||||
public bool IsBudgetExhausted
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
MaybeResetPeriod();
|
||||
return _spent >= _options.EpsilonBudget;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public DateTimeOffset CurrentPeriodStart
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
MaybeResetPeriod();
|
||||
return _periodStart;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public DateTimeOffset NextReset
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
MaybeResetPeriod();
|
||||
return _periodStart + _options.BudgetResetPeriod;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool TrySpend(double epsilon)
|
||||
{
|
||||
if (epsilon <= 0) return false;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
MaybeResetPeriod();
|
||||
|
||||
if (_spent + epsilon > _options.EpsilonBudget)
|
||||
{
|
||||
_suppressedThisPeriod++;
|
||||
return false;
|
||||
}
|
||||
|
||||
_spent += epsilon;
|
||||
_queriesThisPeriod++;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_spent = 0;
|
||||
_queriesThisPeriod = 0;
|
||||
_suppressedThisPeriod = 0;
|
||||
_periodStart = _timeProvider.GetUtcNow();
|
||||
}
|
||||
}
|
||||
|
||||
public PrivacyBudgetSnapshot GetSnapshot()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
MaybeResetPeriod();
|
||||
return new PrivacyBudgetSnapshot(
|
||||
Remaining: Math.Max(0, _options.EpsilonBudget - _spent),
|
||||
Total: _options.EpsilonBudget,
|
||||
Exhausted: _spent >= _options.EpsilonBudget,
|
||||
PeriodStart: _periodStart,
|
||||
NextReset: _periodStart + _options.BudgetResetPeriod,
|
||||
QueriesThisPeriod: _queriesThisPeriod,
|
||||
SuppressedThisPeriod: _suppressedThisPeriod);
|
||||
}
|
||||
}
|
||||
|
||||
private void MaybeResetPeriod()
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
if (now >= _periodStart + _options.BudgetResetPeriod)
|
||||
{
|
||||
_spent = 0;
|
||||
_queriesThisPeriod = 0;
|
||||
_suppressedThisPeriod = 0;
|
||||
_periodStart = now;
|
||||
}
|
||||
}
|
||||
|
||||
internal static double LaplacianNoise(double sensitivity, double epsilon, Random rng)
|
||||
{
|
||||
double u = rng.NextDouble() - 0.5;
|
||||
return -(sensitivity / epsilon) * Math.Sign(u) * Math.Log(1 - 2 * Math.Abs(u));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,44 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Telemetry.Federation.Sync;
|
||||
|
||||
public interface IEgressPolicyIntegration
|
||||
{
|
||||
Task<EgressCheckResult> CheckEgressAsync(
|
||||
string destinationSiteId,
|
||||
int payloadSizeBytes,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public sealed class EgressPolicyIntegration : IEgressPolicyIntegration
|
||||
{
|
||||
private readonly ILogger<EgressPolicyIntegration> _logger;
|
||||
|
||||
public EgressPolicyIntegration(ILogger<EgressPolicyIntegration> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether outbound federation traffic is permitted by the platform egress policy.
|
||||
/// </summary>
|
||||
public Task<EgressCheckResult> CheckEgressAsync(
|
||||
string destinationSiteId,
|
||||
int payloadSizeBytes,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
// Placeholder: integrate with IEgressPolicy from AirGap module when available.
|
||||
// For now, all egress is allowed unless sealed mode is active.
|
||||
_logger.LogDebug(
|
||||
"Egress check for destination {DestinationSiteId}, payload {PayloadSize} bytes",
|
||||
destinationSiteId, payloadSizeBytes);
|
||||
|
||||
return Task.FromResult(new EgressCheckResult(
|
||||
Allowed: true,
|
||||
Reason: null));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record EgressCheckResult(bool Allowed, string? Reason);
|
||||
@@ -0,0 +1,135 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Telemetry.Federation.Aggregation;
|
||||
using StellaOps.Telemetry.Federation.Bundles;
|
||||
using StellaOps.Telemetry.Federation.Consent;
|
||||
using StellaOps.Telemetry.Federation.Privacy;
|
||||
|
||||
namespace StellaOps.Telemetry.Federation.Sync;
|
||||
|
||||
public sealed class FederatedTelemetrySyncService : BackgroundService
|
||||
{
|
||||
private readonly FederatedTelemetryOptions _options;
|
||||
private readonly IPrivacyBudgetTracker _budgetTracker;
|
||||
private readonly ITelemetryAggregator _aggregator;
|
||||
private readonly IConsentManager _consentManager;
|
||||
private readonly IFederatedTelemetryBundleBuilder _bundleBuilder;
|
||||
private readonly IEgressPolicyIntegration _egressPolicy;
|
||||
private readonly ILogger<FederatedTelemetrySyncService> _logger;
|
||||
|
||||
// In-memory fact buffer; production implementation would read from persistent store
|
||||
private readonly List<TelemetryFact> _factBuffer = new();
|
||||
private readonly object _bufferLock = new();
|
||||
|
||||
public FederatedTelemetrySyncService(
|
||||
IOptions<FederatedTelemetryOptions> options,
|
||||
IPrivacyBudgetTracker budgetTracker,
|
||||
ITelemetryAggregator aggregator,
|
||||
IConsentManager consentManager,
|
||||
IFederatedTelemetryBundleBuilder bundleBuilder,
|
||||
IEgressPolicyIntegration egressPolicy,
|
||||
ILogger<FederatedTelemetrySyncService> logger)
|
||||
{
|
||||
_options = options.Value;
|
||||
_budgetTracker = budgetTracker;
|
||||
_aggregator = aggregator;
|
||||
_consentManager = consentManager;
|
||||
_bundleBuilder = bundleBuilder;
|
||||
_egressPolicy = egressPolicy;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Federation sync service started. Interval: {Interval}, SealedMode: {SealedMode}",
|
||||
_options.AggregationInterval, _options.SealedModeEnabled);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(_options.AggregationInterval, stoppingToken).ConfigureAwait(false);
|
||||
await RunSyncCycleAsync(stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Federation sync cycle failed");
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Federation sync service stopped");
|
||||
}
|
||||
|
||||
public async Task RunSyncCycleAsync(CancellationToken ct)
|
||||
{
|
||||
if (_options.SealedModeEnabled)
|
||||
{
|
||||
_logger.LogDebug("Sealed mode active; skipping federation sync cycle");
|
||||
return;
|
||||
}
|
||||
|
||||
if (_budgetTracker.IsBudgetExhausted)
|
||||
{
|
||||
_logger.LogDebug("Privacy budget exhausted; skipping federation sync cycle");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check consent for the default tenant (placeholder: real implementation iterates tenants)
|
||||
var consent = await _consentManager.GetConsentStateAsync("default", ct).ConfigureAwait(false);
|
||||
if (!consent.Granted)
|
||||
{
|
||||
_logger.LogDebug("No consent granted; skipping federation sync cycle");
|
||||
return;
|
||||
}
|
||||
|
||||
// Drain fact buffer
|
||||
List<TelemetryFact> facts;
|
||||
lock (_bufferLock)
|
||||
{
|
||||
facts = new List<TelemetryFact>(_factBuffer);
|
||||
_factBuffer.Clear();
|
||||
}
|
||||
|
||||
if (facts.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("No telemetry facts to aggregate");
|
||||
return;
|
||||
}
|
||||
|
||||
// Aggregate
|
||||
var aggregation = await _aggregator.AggregateAsync(facts, ct).ConfigureAwait(false);
|
||||
|
||||
// Build bundle
|
||||
var consentProof = await _consentManager.GrantConsentAsync("default", "sync-service", null, ct).ConfigureAwait(false);
|
||||
var bundle = await _bundleBuilder.BuildAsync(aggregation, consentProof, ct).ConfigureAwait(false);
|
||||
|
||||
// Check egress policy
|
||||
var egressCheck = await _egressPolicy.CheckEgressAsync("federation-mesh", bundle.Envelope.Length, ct).ConfigureAwait(false);
|
||||
if (!egressCheck.Allowed)
|
||||
{
|
||||
_logger.LogWarning("Egress blocked: {Reason}", egressCheck.Reason);
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Federation sync cycle complete. Bundle {BundleId}: {BucketCount} buckets, {Suppressed} suppressed, epsilon spent: {EpsilonSpent:F4}",
|
||||
bundle.Id, aggregation.Buckets.Count, aggregation.SuppressedBuckets, aggregation.EpsilonSpent);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enqueue a telemetry fact for the next aggregation cycle.
|
||||
/// </summary>
|
||||
public void EnqueueFact(TelemetryFact fact)
|
||||
{
|
||||
lock (_bufferLock)
|
||||
{
|
||||
_factBuffer.Add(fact);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user