Implement incident mode management service and models
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
- Added IPackRunIncidentModeService interface for managing incident mode activation, deactivation, and status retrieval. - Created PackRunIncidentModeService class implementing the service interface with methods for activating, deactivating, and escalating incident modes. - Introduced incident mode status model (PackRunIncidentModeStatus) and related enums for escalation levels and activation sources. - Developed retention policy, telemetry settings, and debug capture settings models to manage incident mode configurations. - Implemented SLO breach notification handling to activate incident mode based on severity. - Added in-memory store (InMemoryPackRunIncidentModeStore) for testing purposes. - Created comprehensive unit tests for incident mode service, covering activation, deactivation, status retrieval, and SLO breach handling.
This commit is contained in:
232
src/Policy/StellaOps.Policy.Registry/Storage/Entities.cs
Normal file
232
src/Policy/StellaOps.Policy.Registry/Storage/Entities.cs
Normal file
@@ -0,0 +1,232 @@
|
||||
using StellaOps.Policy.Registry.Contracts;
|
||||
|
||||
namespace StellaOps.Policy.Registry.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Storage entity for policy pack.
|
||||
/// </summary>
|
||||
public sealed record PolicyPackEntity
|
||||
{
|
||||
public required Guid TenantId { get; init; }
|
||||
public required Guid PackId { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public required string Version { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public required PolicyPackStatus Status { get; init; }
|
||||
public IReadOnlyList<PolicyRule>? Rules { get; init; }
|
||||
public IReadOnlyDictionary<string, object>? Metadata { get; init; }
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
public required DateTimeOffset UpdatedAt { get; init; }
|
||||
public DateTimeOffset? PublishedAt { get; init; }
|
||||
public string? Digest { get; init; }
|
||||
public string? CreatedBy { get; init; }
|
||||
public string? UpdatedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Converts to API contract.
|
||||
/// </summary>
|
||||
public PolicyPack ToContract() => new()
|
||||
{
|
||||
PackId = PackId,
|
||||
Name = Name,
|
||||
Version = Version,
|
||||
Description = Description,
|
||||
Status = Status,
|
||||
Rules = Rules,
|
||||
Metadata = Metadata,
|
||||
CreatedAt = CreatedAt,
|
||||
UpdatedAt = UpdatedAt,
|
||||
PublishedAt = PublishedAt,
|
||||
Digest = Digest
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Storage entity for verification policy.
|
||||
/// </summary>
|
||||
public sealed record VerificationPolicyEntity
|
||||
{
|
||||
public required Guid TenantId { get; init; }
|
||||
public required string PolicyId { get; init; }
|
||||
public required string Version { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public required string TenantScope { get; init; }
|
||||
public required IReadOnlyList<string> PredicateTypes { get; init; }
|
||||
public required SignerRequirements SignerRequirements { get; init; }
|
||||
public ValidityWindow? ValidityWindow { get; init; }
|
||||
public IReadOnlyDictionary<string, object>? Metadata { get; init; }
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
public required DateTimeOffset UpdatedAt { get; init; }
|
||||
public string? CreatedBy { get; init; }
|
||||
public string? UpdatedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Converts to API contract.
|
||||
/// </summary>
|
||||
public VerificationPolicy ToContract() => new()
|
||||
{
|
||||
PolicyId = PolicyId,
|
||||
Version = Version,
|
||||
Description = Description,
|
||||
TenantScope = TenantScope,
|
||||
PredicateTypes = PredicateTypes,
|
||||
SignerRequirements = SignerRequirements,
|
||||
ValidityWindow = ValidityWindow,
|
||||
Metadata = Metadata,
|
||||
CreatedAt = CreatedAt,
|
||||
UpdatedAt = UpdatedAt
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Storage entity for snapshot.
|
||||
/// </summary>
|
||||
public sealed record SnapshotEntity
|
||||
{
|
||||
public required Guid TenantId { get; init; }
|
||||
public required Guid SnapshotId { get; init; }
|
||||
public required string Digest { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public IReadOnlyList<Guid>? PackIds { get; init; }
|
||||
public IReadOnlyDictionary<string, object>? Metadata { get; init; }
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
public string? CreatedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Converts to API contract.
|
||||
/// </summary>
|
||||
public Snapshot ToContract() => new()
|
||||
{
|
||||
SnapshotId = SnapshotId,
|
||||
Digest = Digest,
|
||||
Description = Description,
|
||||
PackIds = PackIds,
|
||||
Metadata = Metadata,
|
||||
CreatedAt = CreatedAt,
|
||||
CreatedBy = CreatedBy
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Storage entity for violation.
|
||||
/// </summary>
|
||||
public sealed record ViolationEntity
|
||||
{
|
||||
public required Guid TenantId { get; init; }
|
||||
public required Guid ViolationId { get; init; }
|
||||
public string? PolicyId { get; init; }
|
||||
public required string RuleId { get; init; }
|
||||
public required Severity Severity { get; init; }
|
||||
public required string Message { get; init; }
|
||||
public string? Purl { get; init; }
|
||||
public string? CveId { get; init; }
|
||||
public IReadOnlyDictionary<string, object>? Context { get; init; }
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Converts to API contract.
|
||||
/// </summary>
|
||||
public Violation ToContract() => new()
|
||||
{
|
||||
ViolationId = ViolationId,
|
||||
PolicyId = PolicyId,
|
||||
RuleId = RuleId,
|
||||
Severity = Severity,
|
||||
Message = Message,
|
||||
Purl = Purl,
|
||||
CveId = CveId,
|
||||
Context = Context,
|
||||
CreatedAt = CreatedAt
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Storage entity for override.
|
||||
/// </summary>
|
||||
public sealed record OverrideEntity
|
||||
{
|
||||
public required Guid TenantId { get; init; }
|
||||
public required Guid OverrideId { get; init; }
|
||||
public Guid? ProfileId { get; init; }
|
||||
public required string RuleId { get; init; }
|
||||
public required OverrideStatus Status { get; init; }
|
||||
public string? Reason { get; init; }
|
||||
public OverrideScope? Scope { get; init; }
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
public string? ApprovedBy { get; init; }
|
||||
public DateTimeOffset? ApprovedAt { get; init; }
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
public string? CreatedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Converts to API contract.
|
||||
/// </summary>
|
||||
public Override ToContract() => new()
|
||||
{
|
||||
OverrideId = OverrideId,
|
||||
ProfileId = ProfileId,
|
||||
RuleId = RuleId,
|
||||
Status = Status,
|
||||
Reason = Reason,
|
||||
Scope = Scope,
|
||||
ExpiresAt = ExpiresAt,
|
||||
ApprovedBy = ApprovedBy,
|
||||
ApprovedAt = ApprovedAt,
|
||||
CreatedAt = CreatedAt,
|
||||
CreatedBy = CreatedBy
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// History entry for policy pack changes.
|
||||
/// </summary>
|
||||
public sealed record PolicyPackHistoryEntry
|
||||
{
|
||||
public required Guid PackId { get; init; }
|
||||
public required string Action { get; init; }
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
public string? PerformedBy { get; init; }
|
||||
public PolicyPackStatus? PreviousStatus { get; init; }
|
||||
public PolicyPackStatus? NewStatus { get; init; }
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result for paginated policy pack list.
|
||||
/// </summary>
|
||||
public sealed record PolicyPackListResult
|
||||
{
|
||||
public required IReadOnlyList<PolicyPackEntity> Items { get; init; }
|
||||
public string? NextPageToken { get; init; }
|
||||
public int TotalCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result for paginated verification policy list.
|
||||
/// </summary>
|
||||
public sealed record VerificationPolicyListResult
|
||||
{
|
||||
public required IReadOnlyList<VerificationPolicyEntity> Items { get; init; }
|
||||
public string? NextPageToken { get; init; }
|
||||
public int TotalCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result for paginated snapshot list.
|
||||
/// </summary>
|
||||
public sealed record SnapshotListResult
|
||||
{
|
||||
public required IReadOnlyList<SnapshotEntity> Items { get; init; }
|
||||
public string? NextPageToken { get; init; }
|
||||
public int TotalCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result for paginated violation list.
|
||||
/// </summary>
|
||||
public sealed record ViolationListResult
|
||||
{
|
||||
public required IReadOnlyList<ViolationEntity> Items { get; init; }
|
||||
public string? NextPageToken { get; init; }
|
||||
public int TotalCount { get; init; }
|
||||
}
|
||||
212
src/Policy/StellaOps.Policy.Registry/Storage/IPolicyPackStore.cs
Normal file
212
src/Policy/StellaOps.Policy.Registry/Storage/IPolicyPackStore.cs
Normal file
@@ -0,0 +1,212 @@
|
||||
using StellaOps.Policy.Registry.Contracts;
|
||||
|
||||
namespace StellaOps.Policy.Registry.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Store interface for policy pack workspace operations with history tracking.
|
||||
/// Implements REGISTRY-API-27-002: Workspace storage with CRUD + history.
|
||||
/// </summary>
|
||||
public interface IPolicyPackStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new policy pack.
|
||||
/// </summary>
|
||||
Task<PolicyPackEntity> CreateAsync(
|
||||
Guid tenantId,
|
||||
CreatePolicyPackRequest request,
|
||||
string? createdBy = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a policy pack by ID.
|
||||
/// </summary>
|
||||
Task<PolicyPackEntity?> GetByIdAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a policy pack by name.
|
||||
/// </summary>
|
||||
Task<PolicyPackEntity?> GetByNameAsync(
|
||||
Guid tenantId,
|
||||
string name,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists policy packs with optional filtering.
|
||||
/// </summary>
|
||||
Task<PolicyPackListResult> ListAsync(
|
||||
Guid tenantId,
|
||||
PolicyPackStatus? status = null,
|
||||
int pageSize = 20,
|
||||
string? pageToken = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates a policy pack.
|
||||
/// </summary>
|
||||
Task<PolicyPackEntity?> UpdateAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
UpdatePolicyPackRequest request,
|
||||
string? updatedBy = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a policy pack (only drafts can be deleted).
|
||||
/// </summary>
|
||||
Task<bool> DeleteAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates the status of a policy pack.
|
||||
/// </summary>
|
||||
Task<PolicyPackEntity?> UpdateStatusAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
PolicyPackStatus newStatus,
|
||||
string? updatedBy = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the history of changes for a policy pack.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<PolicyPackHistoryEntry>> GetHistoryAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
int limit = 50,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Store interface for verification policy operations.
|
||||
/// </summary>
|
||||
public interface IVerificationPolicyStore
|
||||
{
|
||||
Task<VerificationPolicyEntity> CreateAsync(
|
||||
Guid tenantId,
|
||||
CreateVerificationPolicyRequest request,
|
||||
string? createdBy = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<VerificationPolicyEntity?> GetByIdAsync(
|
||||
Guid tenantId,
|
||||
string policyId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<VerificationPolicyListResult> ListAsync(
|
||||
Guid tenantId,
|
||||
int pageSize = 20,
|
||||
string? pageToken = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<VerificationPolicyEntity?> UpdateAsync(
|
||||
Guid tenantId,
|
||||
string policyId,
|
||||
UpdateVerificationPolicyRequest request,
|
||||
string? updatedBy = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> DeleteAsync(
|
||||
Guid tenantId,
|
||||
string policyId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Store interface for policy snapshot operations.
|
||||
/// </summary>
|
||||
public interface ISnapshotStore
|
||||
{
|
||||
Task<SnapshotEntity> CreateAsync(
|
||||
Guid tenantId,
|
||||
CreateSnapshotRequest request,
|
||||
string? createdBy = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<SnapshotEntity?> GetByIdAsync(
|
||||
Guid tenantId,
|
||||
Guid snapshotId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<SnapshotEntity?> GetByDigestAsync(
|
||||
Guid tenantId,
|
||||
string digest,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<SnapshotListResult> ListAsync(
|
||||
Guid tenantId,
|
||||
int pageSize = 20,
|
||||
string? pageToken = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> DeleteAsync(
|
||||
Guid tenantId,
|
||||
Guid snapshotId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Store interface for violation operations.
|
||||
/// </summary>
|
||||
public interface IViolationStore
|
||||
{
|
||||
Task<ViolationEntity> AppendAsync(
|
||||
Guid tenantId,
|
||||
CreateViolationRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ViolationBatchResult> AppendBatchAsync(
|
||||
Guid tenantId,
|
||||
IReadOnlyList<CreateViolationRequest> requests,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ViolationEntity?> GetByIdAsync(
|
||||
Guid tenantId,
|
||||
Guid violationId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ViolationListResult> ListAsync(
|
||||
Guid tenantId,
|
||||
Severity? severity = null,
|
||||
int pageSize = 20,
|
||||
string? pageToken = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Store interface for override operations.
|
||||
/// </summary>
|
||||
public interface IOverrideStore
|
||||
{
|
||||
Task<OverrideEntity> CreateAsync(
|
||||
Guid tenantId,
|
||||
CreateOverrideRequest request,
|
||||
string? createdBy = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<OverrideEntity?> GetByIdAsync(
|
||||
Guid tenantId,
|
||||
Guid overrideId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> DeleteAsync(
|
||||
Guid tenantId,
|
||||
Guid overrideId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<OverrideEntity?> ApproveAsync(
|
||||
Guid tenantId,
|
||||
Guid overrideId,
|
||||
string? approvedBy = null,
|
||||
string? comment = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<OverrideEntity?> DisableAsync(
|
||||
Guid tenantId,
|
||||
Guid overrideId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
using System.Collections.Concurrent;
|
||||
using StellaOps.Policy.Registry.Contracts;
|
||||
|
||||
namespace StellaOps.Policy.Registry.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of IOverrideStore for testing and development.
|
||||
/// </summary>
|
||||
public sealed class InMemoryOverrideStore : IOverrideStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<(Guid TenantId, Guid OverrideId), OverrideEntity> _overrides = new();
|
||||
|
||||
public Task<OverrideEntity> CreateAsync(
|
||||
Guid tenantId,
|
||||
CreateOverrideRequest request,
|
||||
string? createdBy = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var overrideId = Guid.NewGuid();
|
||||
|
||||
var entity = new OverrideEntity
|
||||
{
|
||||
TenantId = tenantId,
|
||||
OverrideId = overrideId,
|
||||
ProfileId = request.ProfileId,
|
||||
RuleId = request.RuleId,
|
||||
Status = OverrideStatus.Pending,
|
||||
Reason = request.Reason,
|
||||
Scope = request.Scope,
|
||||
ExpiresAt = request.ExpiresAt,
|
||||
CreatedAt = now,
|
||||
CreatedBy = createdBy
|
||||
};
|
||||
|
||||
_overrides[(tenantId, overrideId)] = entity;
|
||||
|
||||
return Task.FromResult(entity);
|
||||
}
|
||||
|
||||
public Task<OverrideEntity?> GetByIdAsync(
|
||||
Guid tenantId,
|
||||
Guid overrideId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_overrides.TryGetValue((tenantId, overrideId), out var entity);
|
||||
return Task.FromResult(entity);
|
||||
}
|
||||
|
||||
public Task<bool> DeleteAsync(
|
||||
Guid tenantId,
|
||||
Guid overrideId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(_overrides.TryRemove((tenantId, overrideId), out _));
|
||||
}
|
||||
|
||||
public Task<OverrideEntity?> ApproveAsync(
|
||||
Guid tenantId,
|
||||
Guid overrideId,
|
||||
string? approvedBy = null,
|
||||
string? comment = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_overrides.TryGetValue((tenantId, overrideId), out var existing))
|
||||
{
|
||||
return Task.FromResult<OverrideEntity?>(null);
|
||||
}
|
||||
|
||||
// Only pending overrides can be approved
|
||||
if (existing.Status != OverrideStatus.Pending)
|
||||
{
|
||||
return Task.FromResult<OverrideEntity?>(null);
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var updated = existing with
|
||||
{
|
||||
Status = OverrideStatus.Approved,
|
||||
ApprovedBy = approvedBy,
|
||||
ApprovedAt = now
|
||||
};
|
||||
|
||||
_overrides[(tenantId, overrideId)] = updated;
|
||||
|
||||
return Task.FromResult<OverrideEntity?>(updated);
|
||||
}
|
||||
|
||||
public Task<OverrideEntity?> DisableAsync(
|
||||
Guid tenantId,
|
||||
Guid overrideId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_overrides.TryGetValue((tenantId, overrideId), out var existing))
|
||||
{
|
||||
return Task.FromResult<OverrideEntity?>(null);
|
||||
}
|
||||
|
||||
var updated = existing with
|
||||
{
|
||||
Status = OverrideStatus.Disabled
|
||||
};
|
||||
|
||||
_overrides[(tenantId, overrideId)] = updated;
|
||||
|
||||
return Task.FromResult<OverrideEntity?>(updated);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Policy.Registry.Contracts;
|
||||
|
||||
namespace StellaOps.Policy.Registry.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of IPolicyPackStore for testing and development.
|
||||
/// </summary>
|
||||
public sealed class InMemoryPolicyPackStore : IPolicyPackStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<(Guid TenantId, Guid PackId), PolicyPackEntity> _packs = new();
|
||||
private readonly ConcurrentDictionary<(Guid TenantId, Guid PackId), List<PolicyPackHistoryEntry>> _history = new();
|
||||
|
||||
public Task<PolicyPackEntity> CreateAsync(
|
||||
Guid tenantId,
|
||||
CreatePolicyPackRequest request,
|
||||
string? createdBy = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var packId = Guid.NewGuid();
|
||||
|
||||
var entity = new PolicyPackEntity
|
||||
{
|
||||
TenantId = tenantId,
|
||||
PackId = packId,
|
||||
Name = request.Name,
|
||||
Version = request.Version,
|
||||
Description = request.Description,
|
||||
Status = PolicyPackStatus.Draft,
|
||||
Rules = request.Rules,
|
||||
Metadata = request.Metadata,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
CreatedBy = createdBy
|
||||
};
|
||||
|
||||
_packs[(tenantId, packId)] = entity;
|
||||
|
||||
// Add history entry
|
||||
AddHistoryEntry(tenantId, packId, "created", createdBy, null, PolicyPackStatus.Draft, "Policy pack created");
|
||||
|
||||
return Task.FromResult(entity);
|
||||
}
|
||||
|
||||
public Task<PolicyPackEntity?> GetByIdAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_packs.TryGetValue((tenantId, packId), out var entity);
|
||||
return Task.FromResult(entity);
|
||||
}
|
||||
|
||||
public Task<PolicyPackEntity?> GetByNameAsync(
|
||||
Guid tenantId,
|
||||
string name,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = _packs.Values
|
||||
.Where(p => p.TenantId == tenantId && p.Name == name)
|
||||
.OrderByDescending(p => p.UpdatedAt)
|
||||
.FirstOrDefault();
|
||||
|
||||
return Task.FromResult(entity);
|
||||
}
|
||||
|
||||
public Task<PolicyPackListResult> ListAsync(
|
||||
Guid tenantId,
|
||||
PolicyPackStatus? status = null,
|
||||
int pageSize = 20,
|
||||
string? pageToken = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = _packs.Values
|
||||
.Where(p => p.TenantId == tenantId);
|
||||
|
||||
if (status.HasValue)
|
||||
{
|
||||
query = query.Where(p => p.Status == status.Value);
|
||||
}
|
||||
|
||||
var items = query
|
||||
.OrderByDescending(p => p.UpdatedAt)
|
||||
.ToList();
|
||||
|
||||
int skip = 0;
|
||||
if (!string.IsNullOrEmpty(pageToken) && int.TryParse(pageToken, out var offset))
|
||||
{
|
||||
skip = offset;
|
||||
}
|
||||
|
||||
var pagedItems = items.Skip(skip).Take(pageSize).ToList();
|
||||
string? nextToken = skip + pagedItems.Count < items.Count
|
||||
? (skip + pagedItems.Count).ToString()
|
||||
: null;
|
||||
|
||||
return Task.FromResult(new PolicyPackListResult
|
||||
{
|
||||
Items = pagedItems,
|
||||
NextPageToken = nextToken,
|
||||
TotalCount = items.Count
|
||||
});
|
||||
}
|
||||
|
||||
public Task<PolicyPackEntity?> UpdateAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
UpdatePolicyPackRequest request,
|
||||
string? updatedBy = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_packs.TryGetValue((tenantId, packId), out var existing))
|
||||
{
|
||||
return Task.FromResult<PolicyPackEntity?>(null);
|
||||
}
|
||||
|
||||
// Only allow updates to drafts
|
||||
if (existing.Status != PolicyPackStatus.Draft)
|
||||
{
|
||||
return Task.FromResult<PolicyPackEntity?>(null);
|
||||
}
|
||||
|
||||
var updated = existing with
|
||||
{
|
||||
Name = request.Name ?? existing.Name,
|
||||
Description = request.Description ?? existing.Description,
|
||||
Rules = request.Rules ?? existing.Rules,
|
||||
Metadata = request.Metadata ?? existing.Metadata,
|
||||
UpdatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedBy = updatedBy
|
||||
};
|
||||
|
||||
_packs[(tenantId, packId)] = updated;
|
||||
|
||||
AddHistoryEntry(tenantId, packId, "updated", updatedBy, existing.Status, updated.Status, "Policy pack updated");
|
||||
|
||||
return Task.FromResult<PolicyPackEntity?>(updated);
|
||||
}
|
||||
|
||||
public Task<bool> DeleteAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_packs.TryGetValue((tenantId, packId), out var existing))
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
// Only allow deletion of drafts
|
||||
if (existing.Status != PolicyPackStatus.Draft)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
var removed = _packs.TryRemove((tenantId, packId), out _);
|
||||
if (removed)
|
||||
{
|
||||
_history.TryRemove((tenantId, packId), out _);
|
||||
}
|
||||
|
||||
return Task.FromResult(removed);
|
||||
}
|
||||
|
||||
public Task<PolicyPackEntity?> UpdateStatusAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
PolicyPackStatus newStatus,
|
||||
string? updatedBy = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_packs.TryGetValue((tenantId, packId), out var existing))
|
||||
{
|
||||
return Task.FromResult<PolicyPackEntity?>(null);
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var updated = existing with
|
||||
{
|
||||
Status = newStatus,
|
||||
UpdatedAt = now,
|
||||
UpdatedBy = updatedBy,
|
||||
PublishedAt = newStatus == PolicyPackStatus.Published ? now : existing.PublishedAt,
|
||||
Digest = newStatus == PolicyPackStatus.Published ? ComputeDigest(existing) : existing.Digest
|
||||
};
|
||||
|
||||
_packs[(tenantId, packId)] = updated;
|
||||
|
||||
AddHistoryEntry(tenantId, packId, $"status_changed_to_{newStatus}", updatedBy, existing.Status, newStatus,
|
||||
$"Status changed from {existing.Status} to {newStatus}");
|
||||
|
||||
return Task.FromResult<PolicyPackEntity?>(updated);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<PolicyPackHistoryEntry>> GetHistoryAsync(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
int limit = 50,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_history.TryGetValue((tenantId, packId), out var history))
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<PolicyPackHistoryEntry>>(Array.Empty<PolicyPackHistoryEntry>());
|
||||
}
|
||||
|
||||
var entries = history
|
||||
.OrderByDescending(h => h.Timestamp)
|
||||
.Take(limit)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<PolicyPackHistoryEntry>>(entries);
|
||||
}
|
||||
|
||||
private void AddHistoryEntry(
|
||||
Guid tenantId,
|
||||
Guid packId,
|
||||
string action,
|
||||
string? performedBy,
|
||||
PolicyPackStatus? previousStatus,
|
||||
PolicyPackStatus? newStatus,
|
||||
string? description)
|
||||
{
|
||||
var entry = new PolicyPackHistoryEntry
|
||||
{
|
||||
PackId = packId,
|
||||
Action = action,
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
PerformedBy = performedBy,
|
||||
PreviousStatus = previousStatus,
|
||||
NewStatus = newStatus,
|
||||
Description = description
|
||||
};
|
||||
|
||||
_history.AddOrUpdate(
|
||||
(tenantId, packId),
|
||||
_ => [entry],
|
||||
(_, list) =>
|
||||
{
|
||||
list.Add(entry);
|
||||
return list;
|
||||
});
|
||||
}
|
||||
|
||||
private static string ComputeDigest(PolicyPackEntity pack)
|
||||
{
|
||||
var content = JsonSerializer.Serialize(new
|
||||
{
|
||||
pack.Name,
|
||||
pack.Version,
|
||||
pack.Rules
|
||||
});
|
||||
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Policy.Registry.Contracts;
|
||||
|
||||
namespace StellaOps.Policy.Registry.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of ISnapshotStore for testing and development.
|
||||
/// </summary>
|
||||
public sealed class InMemorySnapshotStore : ISnapshotStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<(Guid TenantId, Guid SnapshotId), SnapshotEntity> _snapshots = new();
|
||||
|
||||
public Task<SnapshotEntity> CreateAsync(
|
||||
Guid tenantId,
|
||||
CreateSnapshotRequest request,
|
||||
string? createdBy = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var snapshotId = Guid.NewGuid();
|
||||
|
||||
// Compute digest from pack IDs and timestamp for uniqueness
|
||||
var digest = ComputeDigest(request.PackIds, now);
|
||||
|
||||
var entity = new SnapshotEntity
|
||||
{
|
||||
TenantId = tenantId,
|
||||
SnapshotId = snapshotId,
|
||||
Digest = digest,
|
||||
Description = request.Description,
|
||||
PackIds = request.PackIds,
|
||||
Metadata = request.Metadata,
|
||||
CreatedAt = now,
|
||||
CreatedBy = createdBy
|
||||
};
|
||||
|
||||
_snapshots[(tenantId, snapshotId)] = entity;
|
||||
|
||||
return Task.FromResult(entity);
|
||||
}
|
||||
|
||||
public Task<SnapshotEntity?> GetByIdAsync(
|
||||
Guid tenantId,
|
||||
Guid snapshotId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_snapshots.TryGetValue((tenantId, snapshotId), out var entity);
|
||||
return Task.FromResult(entity);
|
||||
}
|
||||
|
||||
public Task<SnapshotEntity?> GetByDigestAsync(
|
||||
Guid tenantId,
|
||||
string digest,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = _snapshots.Values
|
||||
.Where(s => s.TenantId == tenantId && s.Digest == digest)
|
||||
.FirstOrDefault();
|
||||
|
||||
return Task.FromResult(entity);
|
||||
}
|
||||
|
||||
public Task<SnapshotListResult> ListAsync(
|
||||
Guid tenantId,
|
||||
int pageSize = 20,
|
||||
string? pageToken = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var items = _snapshots.Values
|
||||
.Where(s => s.TenantId == tenantId)
|
||||
.OrderByDescending(s => s.CreatedAt)
|
||||
.ToList();
|
||||
|
||||
int skip = 0;
|
||||
if (!string.IsNullOrEmpty(pageToken) && int.TryParse(pageToken, out var offset))
|
||||
{
|
||||
skip = offset;
|
||||
}
|
||||
|
||||
var pagedItems = items.Skip(skip).Take(pageSize).ToList();
|
||||
string? nextToken = skip + pagedItems.Count < items.Count
|
||||
? (skip + pagedItems.Count).ToString()
|
||||
: null;
|
||||
|
||||
return Task.FromResult(new SnapshotListResult
|
||||
{
|
||||
Items = pagedItems,
|
||||
NextPageToken = nextToken,
|
||||
TotalCount = items.Count
|
||||
});
|
||||
}
|
||||
|
||||
public Task<bool> DeleteAsync(
|
||||
Guid tenantId,
|
||||
Guid snapshotId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(_snapshots.TryRemove((tenantId, snapshotId), out _));
|
||||
}
|
||||
|
||||
private static string ComputeDigest(IReadOnlyList<Guid> packIds, DateTimeOffset timestamp)
|
||||
{
|
||||
var content = JsonSerializer.Serialize(new
|
||||
{
|
||||
PackIds = packIds.OrderBy(id => id).ToList(),
|
||||
Timestamp = timestamp.ToUnixTimeMilliseconds()
|
||||
});
|
||||
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
using System.Collections.Concurrent;
|
||||
using StellaOps.Policy.Registry.Contracts;
|
||||
|
||||
namespace StellaOps.Policy.Registry.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of IVerificationPolicyStore for testing and development.
|
||||
/// </summary>
|
||||
public sealed class InMemoryVerificationPolicyStore : IVerificationPolicyStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<(Guid TenantId, string PolicyId), VerificationPolicyEntity> _policies = new();
|
||||
|
||||
public Task<VerificationPolicyEntity> CreateAsync(
|
||||
Guid tenantId,
|
||||
CreateVerificationPolicyRequest request,
|
||||
string? createdBy = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
var entity = new VerificationPolicyEntity
|
||||
{
|
||||
TenantId = tenantId,
|
||||
PolicyId = request.PolicyId,
|
||||
Version = request.Version,
|
||||
Description = request.Description,
|
||||
TenantScope = request.TenantScope ?? tenantId.ToString(),
|
||||
PredicateTypes = request.PredicateTypes,
|
||||
SignerRequirements = request.SignerRequirements ?? new SignerRequirements
|
||||
{
|
||||
MinimumSignatures = 1,
|
||||
TrustedKeyFingerprints = Array.Empty<string>()
|
||||
},
|
||||
ValidityWindow = request.ValidityWindow,
|
||||
Metadata = request.Metadata,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
CreatedBy = createdBy
|
||||
};
|
||||
|
||||
_policies[(tenantId, request.PolicyId)] = entity;
|
||||
|
||||
return Task.FromResult(entity);
|
||||
}
|
||||
|
||||
public Task<VerificationPolicyEntity?> GetByIdAsync(
|
||||
Guid tenantId,
|
||||
string policyId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_policies.TryGetValue((tenantId, policyId), out var entity);
|
||||
return Task.FromResult(entity);
|
||||
}
|
||||
|
||||
public Task<VerificationPolicyListResult> ListAsync(
|
||||
Guid tenantId,
|
||||
int pageSize = 20,
|
||||
string? pageToken = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var items = _policies.Values
|
||||
.Where(p => p.TenantId == tenantId)
|
||||
.OrderByDescending(p => p.UpdatedAt)
|
||||
.ToList();
|
||||
|
||||
int skip = 0;
|
||||
if (!string.IsNullOrEmpty(pageToken) && int.TryParse(pageToken, out var offset))
|
||||
{
|
||||
skip = offset;
|
||||
}
|
||||
|
||||
var pagedItems = items.Skip(skip).Take(pageSize).ToList();
|
||||
string? nextToken = skip + pagedItems.Count < items.Count
|
||||
? (skip + pagedItems.Count).ToString()
|
||||
: null;
|
||||
|
||||
return Task.FromResult(new VerificationPolicyListResult
|
||||
{
|
||||
Items = pagedItems,
|
||||
NextPageToken = nextToken,
|
||||
TotalCount = items.Count
|
||||
});
|
||||
}
|
||||
|
||||
public Task<VerificationPolicyEntity?> UpdateAsync(
|
||||
Guid tenantId,
|
||||
string policyId,
|
||||
UpdateVerificationPolicyRequest request,
|
||||
string? updatedBy = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_policies.TryGetValue((tenantId, policyId), out var existing))
|
||||
{
|
||||
return Task.FromResult<VerificationPolicyEntity?>(null);
|
||||
}
|
||||
|
||||
var updated = existing with
|
||||
{
|
||||
Version = request.Version ?? existing.Version,
|
||||
Description = request.Description ?? existing.Description,
|
||||
PredicateTypes = request.PredicateTypes ?? existing.PredicateTypes,
|
||||
SignerRequirements = request.SignerRequirements ?? existing.SignerRequirements,
|
||||
ValidityWindow = request.ValidityWindow ?? existing.ValidityWindow,
|
||||
Metadata = request.Metadata ?? existing.Metadata,
|
||||
UpdatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedBy = updatedBy
|
||||
};
|
||||
|
||||
_policies[(tenantId, policyId)] = updated;
|
||||
|
||||
return Task.FromResult<VerificationPolicyEntity?>(updated);
|
||||
}
|
||||
|
||||
public Task<bool> DeleteAsync(
|
||||
Guid tenantId,
|
||||
string policyId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(_policies.TryRemove((tenantId, policyId), out _));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
using System.Collections.Concurrent;
|
||||
using StellaOps.Policy.Registry.Contracts;
|
||||
|
||||
namespace StellaOps.Policy.Registry.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of IViolationStore for testing and development.
|
||||
/// </summary>
|
||||
public sealed class InMemoryViolationStore : IViolationStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<(Guid TenantId, Guid ViolationId), ViolationEntity> _violations = new();
|
||||
|
||||
public Task<ViolationEntity> AppendAsync(
|
||||
Guid tenantId,
|
||||
CreateViolationRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var violationId = Guid.NewGuid();
|
||||
|
||||
var entity = new ViolationEntity
|
||||
{
|
||||
TenantId = tenantId,
|
||||
ViolationId = violationId,
|
||||
PolicyId = request.PolicyId,
|
||||
RuleId = request.RuleId,
|
||||
Severity = request.Severity,
|
||||
Message = request.Message,
|
||||
Purl = request.Purl,
|
||||
CveId = request.CveId,
|
||||
Context = request.Context,
|
||||
CreatedAt = now
|
||||
};
|
||||
|
||||
_violations[(tenantId, violationId)] = entity;
|
||||
|
||||
return Task.FromResult(entity);
|
||||
}
|
||||
|
||||
public Task<ViolationBatchResult> AppendBatchAsync(
|
||||
Guid tenantId,
|
||||
IReadOnlyList<CreateViolationRequest> requests,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
int created = 0;
|
||||
int failed = 0;
|
||||
var errors = new List<BatchError>();
|
||||
|
||||
for (int i = 0; i < requests.Count; i++)
|
||||
{
|
||||
try
|
||||
{
|
||||
var request = requests[i];
|
||||
var violationId = Guid.NewGuid();
|
||||
|
||||
var entity = new ViolationEntity
|
||||
{
|
||||
TenantId = tenantId,
|
||||
ViolationId = violationId,
|
||||
PolicyId = request.PolicyId,
|
||||
RuleId = request.RuleId,
|
||||
Severity = request.Severity,
|
||||
Message = request.Message,
|
||||
Purl = request.Purl,
|
||||
CveId = request.CveId,
|
||||
Context = request.Context,
|
||||
CreatedAt = now
|
||||
};
|
||||
|
||||
_violations[(tenantId, violationId)] = entity;
|
||||
created++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failed++;
|
||||
errors.Add(new BatchError
|
||||
{
|
||||
Index = i,
|
||||
Error = ex.Message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult(new ViolationBatchResult
|
||||
{
|
||||
Created = created,
|
||||
Failed = failed,
|
||||
Errors = errors.Count > 0 ? errors : null
|
||||
});
|
||||
}
|
||||
|
||||
public Task<ViolationEntity?> GetByIdAsync(
|
||||
Guid tenantId,
|
||||
Guid violationId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_violations.TryGetValue((tenantId, violationId), out var entity);
|
||||
return Task.FromResult(entity);
|
||||
}
|
||||
|
||||
public Task<ViolationListResult> ListAsync(
|
||||
Guid tenantId,
|
||||
Severity? severity = null,
|
||||
int pageSize = 20,
|
||||
string? pageToken = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = _violations.Values
|
||||
.Where(v => v.TenantId == tenantId);
|
||||
|
||||
if (severity.HasValue)
|
||||
{
|
||||
query = query.Where(v => v.Severity == severity.Value);
|
||||
}
|
||||
|
||||
var items = query
|
||||
.OrderByDescending(v => v.CreatedAt)
|
||||
.ToList();
|
||||
|
||||
int skip = 0;
|
||||
if (!string.IsNullOrEmpty(pageToken) && int.TryParse(pageToken, out var offset))
|
||||
{
|
||||
skip = offset;
|
||||
}
|
||||
|
||||
var pagedItems = items.Skip(skip).Take(pageSize).ToList();
|
||||
string? nextToken = skip + pagedItems.Count < items.Count
|
||||
? (skip + pagedItems.Count).ToString()
|
||||
: null;
|
||||
|
||||
return Task.FromResult(new ViolationListResult
|
||||
{
|
||||
Items = pagedItems,
|
||||
NextPageToken = nextToken,
|
||||
TotalCount = items.Count
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user