up
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
namespace StellaOps.Authority.Storage.Postgres.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a role entity in the authority schema.
|
||||
/// </summary>
|
||||
public sealed class RoleEntity
|
||||
{
|
||||
public required Guid Id { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public string? DisplayName { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public bool IsSystem { get; init; }
|
||||
public string Metadata { get; init; } = "{}";
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a permission entity in the authority schema.
|
||||
/// </summary>
|
||||
public sealed class PermissionEntity
|
||||
{
|
||||
public required Guid Id { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public required string Resource { get; init; }
|
||||
public required string Action { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a role-permission assignment.
|
||||
/// </summary>
|
||||
public sealed class RolePermissionEntity
|
||||
{
|
||||
public required Guid RoleId { get; init; }
|
||||
public required Guid PermissionId { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a user-role assignment.
|
||||
/// </summary>
|
||||
public sealed class UserRoleEntity
|
||||
{
|
||||
public required Guid UserId { get; init; }
|
||||
public required Guid RoleId { get; init; }
|
||||
public DateTimeOffset GrantedAt { get; init; }
|
||||
public string? GrantedBy { get; init; }
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
namespace StellaOps.Authority.Storage.Postgres.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a session entity in the authority schema.
|
||||
/// </summary>
|
||||
public sealed class SessionEntity
|
||||
{
|
||||
public required Guid Id { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required Guid UserId { get; init; }
|
||||
public required string SessionTokenHash { get; init; }
|
||||
public string? IpAddress { get; init; }
|
||||
public string? UserAgent { get; init; }
|
||||
public DateTimeOffset StartedAt { get; init; }
|
||||
public DateTimeOffset LastActivityAt { get; init; }
|
||||
public DateTimeOffset ExpiresAt { get; init; }
|
||||
public DateTimeOffset? EndedAt { get; init; }
|
||||
public string? EndReason { get; init; }
|
||||
public string Metadata { get; init; } = "{}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents an audit log entry in the authority schema.
|
||||
/// </summary>
|
||||
public sealed class AuditEntity
|
||||
{
|
||||
public long Id { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public Guid? UserId { get; init; }
|
||||
public required string Action { get; init; }
|
||||
public required string ResourceType { get; init; }
|
||||
public string? ResourceId { get; init; }
|
||||
public string? OldValue { get; init; }
|
||||
public string? NewValue { get; init; }
|
||||
public string? IpAddress { get; init; }
|
||||
public string? UserAgent { get; init; }
|
||||
public string? CorrelationId { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
namespace StellaOps.Authority.Storage.Postgres.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an access token entity in the authority schema.
|
||||
/// </summary>
|
||||
public sealed class TokenEntity
|
||||
{
|
||||
public required Guid Id { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public Guid? UserId { get; init; }
|
||||
public required string TokenHash { get; init; }
|
||||
public required string TokenType { get; init; }
|
||||
public string[] Scopes { get; init; } = [];
|
||||
public string? ClientId { get; init; }
|
||||
public DateTimeOffset IssuedAt { get; init; }
|
||||
public DateTimeOffset ExpiresAt { get; init; }
|
||||
public DateTimeOffset? RevokedAt { get; init; }
|
||||
public string? RevokedBy { get; init; }
|
||||
public string Metadata { get; init; } = "{}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a refresh token entity in the authority schema.
|
||||
/// </summary>
|
||||
public sealed class RefreshTokenEntity
|
||||
{
|
||||
public required Guid Id { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required Guid UserId { get; init; }
|
||||
public required string TokenHash { get; init; }
|
||||
public Guid? AccessTokenId { get; init; }
|
||||
public string? ClientId { get; init; }
|
||||
public DateTimeOffset IssuedAt { get; init; }
|
||||
public DateTimeOffset ExpiresAt { get; init; }
|
||||
public DateTimeOffset? RevokedAt { get; init; }
|
||||
public string? RevokedBy { get; init; }
|
||||
public Guid? ReplacedBy { get; init; }
|
||||
public string Metadata { get; init; } = "{}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents an API key entity in the authority schema.
|
||||
/// </summary>
|
||||
public sealed class ApiKeyEntity
|
||||
{
|
||||
public required Guid Id { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public Guid? UserId { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public required string KeyHash { get; init; }
|
||||
public required string KeyPrefix { get; init; }
|
||||
public string[] Scopes { get; init; } = [];
|
||||
public required string Status { get; init; }
|
||||
public DateTimeOffset? LastUsedAt { get; init; }
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
public string Metadata { get; init; } = "{}";
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset? RevokedAt { get; init; }
|
||||
public string? RevokedBy { get; init; }
|
||||
}
|
||||
|
||||
public static class ApiKeyStatus
|
||||
{
|
||||
public const string Active = "active";
|
||||
public const string Revoked = "revoked";
|
||||
public const string Expired = "expired";
|
||||
}
|
||||
|
||||
public static class TokenType
|
||||
{
|
||||
public const string Access = "access";
|
||||
public const string Refresh = "refresh";
|
||||
public const string Api = "api";
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
using StellaOps.Authority.Storage.Postgres.Models;
|
||||
using StellaOps.Infrastructure.Postgres;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Postgres.Repositories;
|
||||
|
||||
public sealed class ApiKeyRepository : RepositoryBase<AuthorityDataSource>, IApiKeyRepository
|
||||
{
|
||||
public ApiKeyRepository(AuthorityDataSource dataSource) : base(dataSource) { }
|
||||
|
||||
public async Task<ApiKeyEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, user_id, name, key_hash, key_prefix, scopes, status, last_used_at, expires_at, metadata, created_at, revoked_at, revoked_by
|
||||
FROM authority.api_keys
|
||||
WHERE tenant_id = @tenant_id AND id = @id
|
||||
""";
|
||||
return await QuerySingleOrDefaultAsync(tenantId, sql, MapApiKey,
|
||||
cmd => { cmd.Parameters.AddWithValue("id", id); },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<ApiKeyEntity?> GetByPrefixAsync(string keyPrefix, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, user_id, name, key_hash, key_prefix, scopes, status, last_used_at, expires_at, metadata, created_at, revoked_at, revoked_by
|
||||
FROM authority.api_keys
|
||||
WHERE key_prefix = @key_prefix AND status = 'active'
|
||||
""";
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = sql;
|
||||
command.Parameters.AddWithValue("key_prefix", keyPrefix);
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await reader.ReadAsync(cancellationToken).ConfigureAwait(false) ? MapApiKey(reader) : null;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ApiKeyEntity>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, user_id, name, key_hash, key_prefix, scopes, status, last_used_at, expires_at, metadata, created_at, revoked_at, revoked_by
|
||||
FROM authority.api_keys
|
||||
WHERE tenant_id = @tenant_id
|
||||
ORDER BY created_at DESC
|
||||
""";
|
||||
return await QueryAsync(tenantId, sql, MapApiKey, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ApiKeyEntity>> GetByUserIdAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, user_id, name, key_hash, key_prefix, scopes, status, last_used_at, expires_at, metadata, created_at, revoked_at, revoked_by
|
||||
FROM authority.api_keys
|
||||
WHERE tenant_id = @tenant_id AND user_id = @user_id
|
||||
ORDER BY created_at DESC
|
||||
""";
|
||||
return await QueryAsync(tenantId, sql, MapApiKey,
|
||||
cmd => { cmd.Parameters.AddWithValue("user_id", userId); },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<Guid> CreateAsync(string tenantId, ApiKeyEntity apiKey, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO authority.api_keys (id, tenant_id, user_id, name, key_hash, key_prefix, scopes, status, expires_at, metadata)
|
||||
VALUES (@id, @tenant_id, @user_id, @name, @key_hash, @key_prefix, @scopes, @status, @expires_at, @metadata::jsonb)
|
||||
RETURNING id
|
||||
""";
|
||||
var id = apiKey.Id == Guid.Empty ? Guid.NewGuid() : apiKey.Id;
|
||||
await ExecuteAsync(tenantId, sql, cmd =>
|
||||
{
|
||||
cmd.Parameters.AddWithValue("id", id);
|
||||
AddNullableParameter(cmd, "user_id", apiKey.UserId);
|
||||
cmd.Parameters.AddWithValue("name", apiKey.Name);
|
||||
cmd.Parameters.AddWithValue("key_hash", apiKey.KeyHash);
|
||||
cmd.Parameters.AddWithValue("key_prefix", apiKey.KeyPrefix);
|
||||
AddArrayParameter(cmd, "scopes", apiKey.Scopes);
|
||||
cmd.Parameters.AddWithValue("status", apiKey.Status);
|
||||
AddNullableParameter(cmd, "expires_at", apiKey.ExpiresAt);
|
||||
AddJsonbParameter(cmd, "metadata", apiKey.Metadata);
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
return id;
|
||||
}
|
||||
|
||||
public async Task UpdateLastUsedAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "UPDATE authority.api_keys SET last_used_at = NOW() WHERE tenant_id = @tenant_id AND id = @id";
|
||||
await ExecuteAsync(tenantId, sql, cmd => { cmd.Parameters.AddWithValue("id", id); }, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task RevokeAsync(string tenantId, Guid id, string revokedBy, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE authority.api_keys SET status = 'revoked', revoked_at = NOW(), revoked_by = @revoked_by
|
||||
WHERE tenant_id = @tenant_id AND id = @id AND status = 'active'
|
||||
""";
|
||||
await ExecuteAsync(tenantId, sql, cmd =>
|
||||
{
|
||||
cmd.Parameters.AddWithValue("id", id);
|
||||
cmd.Parameters.AddWithValue("revoked_by", revokedBy);
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "DELETE FROM authority.api_keys WHERE tenant_id = @tenant_id AND id = @id";
|
||||
await ExecuteAsync(tenantId, sql, cmd => { cmd.Parameters.AddWithValue("id", id); }, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static ApiKeyEntity MapApiKey(System.Data.Common.DbDataReader reader) => new()
|
||||
{
|
||||
Id = reader.GetGuid(0),
|
||||
TenantId = reader.GetString(1),
|
||||
UserId = reader.IsDBNull(2) ? null : reader.GetGuid(2),
|
||||
Name = reader.GetString(3),
|
||||
KeyHash = reader.GetString(4),
|
||||
KeyPrefix = reader.GetString(5),
|
||||
Scopes = reader.IsDBNull(6) ? [] : reader.GetFieldValue<string[]>(6),
|
||||
Status = reader.GetString(7),
|
||||
LastUsedAt = reader.IsDBNull(8) ? null : reader.GetFieldValue<DateTimeOffset>(8),
|
||||
ExpiresAt = reader.IsDBNull(9) ? null : reader.GetFieldValue<DateTimeOffset>(9),
|
||||
Metadata = reader.GetString(10),
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(11),
|
||||
RevokedAt = reader.IsDBNull(12) ? null : reader.GetFieldValue<DateTimeOffset>(12),
|
||||
RevokedBy = reader.IsDBNull(13) ? null : reader.GetString(13)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using StellaOps.Authority.Storage.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Postgres.Repositories;
|
||||
|
||||
public interface IApiKeyRepository
|
||||
{
|
||||
Task<ApiKeyEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
|
||||
Task<ApiKeyEntity?> GetByPrefixAsync(string keyPrefix, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<ApiKeyEntity>> ListAsync(string tenantId, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<ApiKeyEntity>> GetByUserIdAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default);
|
||||
Task<Guid> CreateAsync(string tenantId, ApiKeyEntity apiKey, CancellationToken cancellationToken = default);
|
||||
Task UpdateLastUsedAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
|
||||
Task RevokeAsync(string tenantId, Guid id, string revokedBy, CancellationToken cancellationToken = default);
|
||||
Task DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using StellaOps.Authority.Storage.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Postgres.Repositories;
|
||||
|
||||
public interface IPermissionRepository
|
||||
{
|
||||
Task<PermissionEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
|
||||
Task<PermissionEntity?> GetByNameAsync(string tenantId, string name, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<PermissionEntity>> ListAsync(string tenantId, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<PermissionEntity>> GetByResourceAsync(string tenantId, string resource, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<PermissionEntity>> GetRolePermissionsAsync(string tenantId, Guid roleId, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<PermissionEntity>> GetUserPermissionsAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default);
|
||||
Task<Guid> CreateAsync(string tenantId, PermissionEntity permission, CancellationToken cancellationToken = default);
|
||||
Task DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
|
||||
Task AssignToRoleAsync(string tenantId, Guid roleId, Guid permissionId, CancellationToken cancellationToken = default);
|
||||
Task RemoveFromRoleAsync(string tenantId, Guid roleId, Guid permissionId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using StellaOps.Authority.Storage.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Postgres.Repositories;
|
||||
|
||||
public interface IRoleRepository
|
||||
{
|
||||
Task<RoleEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
|
||||
Task<RoleEntity?> GetByNameAsync(string tenantId, string name, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<RoleEntity>> ListAsync(string tenantId, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<RoleEntity>> GetUserRolesAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default);
|
||||
Task<Guid> CreateAsync(string tenantId, RoleEntity role, CancellationToken cancellationToken = default);
|
||||
Task UpdateAsync(string tenantId, RoleEntity role, CancellationToken cancellationToken = default);
|
||||
Task DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
|
||||
Task AssignToUserAsync(string tenantId, Guid userId, Guid roleId, string? grantedBy, DateTimeOffset? expiresAt, CancellationToken cancellationToken = default);
|
||||
Task RemoveFromUserAsync(string tenantId, Guid userId, Guid roleId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using StellaOps.Authority.Storage.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Postgres.Repositories;
|
||||
|
||||
public interface ITokenRepository
|
||||
{
|
||||
Task<TokenEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
|
||||
Task<TokenEntity?> GetByHashAsync(string tokenHash, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<TokenEntity>> GetByUserIdAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default);
|
||||
Task<Guid> CreateAsync(string tenantId, TokenEntity token, CancellationToken cancellationToken = default);
|
||||
Task RevokeAsync(string tenantId, Guid id, string revokedBy, CancellationToken cancellationToken = default);
|
||||
Task RevokeByUserIdAsync(string tenantId, Guid userId, string revokedBy, CancellationToken cancellationToken = default);
|
||||
Task DeleteExpiredAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface IRefreshTokenRepository
|
||||
{
|
||||
Task<RefreshTokenEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
|
||||
Task<RefreshTokenEntity?> GetByHashAsync(string tokenHash, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<RefreshTokenEntity>> GetByUserIdAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default);
|
||||
Task<Guid> CreateAsync(string tenantId, RefreshTokenEntity token, CancellationToken cancellationToken = default);
|
||||
Task RevokeAsync(string tenantId, Guid id, string revokedBy, Guid? replacedBy, CancellationToken cancellationToken = default);
|
||||
Task RevokeByUserIdAsync(string tenantId, Guid userId, string revokedBy, CancellationToken cancellationToken = default);
|
||||
Task DeleteExpiredAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
using StellaOps.Authority.Storage.Postgres.Models;
|
||||
using StellaOps.Infrastructure.Postgres;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Postgres.Repositories;
|
||||
|
||||
public sealed class PermissionRepository : RepositoryBase<AuthorityDataSource>, IPermissionRepository
|
||||
{
|
||||
public PermissionRepository(AuthorityDataSource dataSource) : base(dataSource) { }
|
||||
|
||||
public async Task<PermissionEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, name, resource, action, description, created_at
|
||||
FROM authority.permissions
|
||||
WHERE tenant_id = @tenant_id AND id = @id
|
||||
""";
|
||||
return await QuerySingleOrDefaultAsync(tenantId, sql, MapPermission,
|
||||
cmd => { cmd.Parameters.AddWithValue("id", id); },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<PermissionEntity?> GetByNameAsync(string tenantId, string name, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, name, resource, action, description, created_at
|
||||
FROM authority.permissions
|
||||
WHERE tenant_id = @tenant_id AND name = @name
|
||||
""";
|
||||
return await QuerySingleOrDefaultAsync(tenantId, sql, MapPermission,
|
||||
cmd => { cmd.Parameters.AddWithValue("name", name); },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<PermissionEntity>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, name, resource, action, description, created_at
|
||||
FROM authority.permissions
|
||||
WHERE tenant_id = @tenant_id
|
||||
ORDER BY resource, action
|
||||
""";
|
||||
return await QueryAsync(tenantId, sql, MapPermission, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<PermissionEntity>> GetByResourceAsync(string tenantId, string resource, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, name, resource, action, description, created_at
|
||||
FROM authority.permissions
|
||||
WHERE tenant_id = @tenant_id AND resource = @resource
|
||||
ORDER BY action
|
||||
""";
|
||||
return await QueryAsync(tenantId, sql, MapPermission,
|
||||
cmd => { cmd.Parameters.AddWithValue("resource", resource); },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<PermissionEntity>> GetRolePermissionsAsync(string tenantId, Guid roleId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT p.id, p.tenant_id, p.name, p.resource, p.action, p.description, p.created_at
|
||||
FROM authority.permissions p
|
||||
INNER JOIN authority.role_permissions rp ON p.id = rp.permission_id
|
||||
WHERE p.tenant_id = @tenant_id AND rp.role_id = @role_id
|
||||
ORDER BY p.resource, p.action
|
||||
""";
|
||||
return await QueryAsync(tenantId, sql, MapPermission,
|
||||
cmd => { cmd.Parameters.AddWithValue("role_id", roleId); },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<PermissionEntity>> GetUserPermissionsAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT DISTINCT p.id, p.tenant_id, p.name, p.resource, p.action, p.description, p.created_at
|
||||
FROM authority.permissions p
|
||||
INNER JOIN authority.role_permissions rp ON p.id = rp.permission_id
|
||||
INNER JOIN authority.user_roles ur ON rp.role_id = ur.role_id
|
||||
WHERE p.tenant_id = @tenant_id AND ur.user_id = @user_id
|
||||
AND (ur.expires_at IS NULL OR ur.expires_at > NOW())
|
||||
ORDER BY p.resource, p.action
|
||||
""";
|
||||
return await QueryAsync(tenantId, sql, MapPermission,
|
||||
cmd => { cmd.Parameters.AddWithValue("user_id", userId); },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<Guid> CreateAsync(string tenantId, PermissionEntity permission, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO authority.permissions (id, tenant_id, name, resource, action, description)
|
||||
VALUES (@id, @tenant_id, @name, @resource, @action, @description)
|
||||
RETURNING id
|
||||
""";
|
||||
var id = permission.Id == Guid.Empty ? Guid.NewGuid() : permission.Id;
|
||||
await ExecuteAsync(tenantId, sql, cmd =>
|
||||
{
|
||||
cmd.Parameters.AddWithValue("id", id);
|
||||
cmd.Parameters.AddWithValue("name", permission.Name);
|
||||
cmd.Parameters.AddWithValue("resource", permission.Resource);
|
||||
cmd.Parameters.AddWithValue("action", permission.Action);
|
||||
AddNullableParameter(cmd, "description", permission.Description);
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
return id;
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "DELETE FROM authority.permissions WHERE tenant_id = @tenant_id AND id = @id";
|
||||
await ExecuteAsync(tenantId, sql, cmd => { cmd.Parameters.AddWithValue("id", id); }, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task AssignToRoleAsync(string tenantId, Guid roleId, Guid permissionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO authority.role_permissions (role_id, permission_id)
|
||||
VALUES (@role_id, @permission_id)
|
||||
ON CONFLICT (role_id, permission_id) DO NOTHING
|
||||
""";
|
||||
await ExecuteAsync(tenantId, sql, cmd =>
|
||||
{
|
||||
cmd.Parameters.AddWithValue("role_id", roleId);
|
||||
cmd.Parameters.AddWithValue("permission_id", permissionId);
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task RemoveFromRoleAsync(string tenantId, Guid roleId, Guid permissionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "DELETE FROM authority.role_permissions WHERE role_id = @role_id AND permission_id = @permission_id";
|
||||
await ExecuteAsync(tenantId, sql, cmd =>
|
||||
{
|
||||
cmd.Parameters.AddWithValue("role_id", roleId);
|
||||
cmd.Parameters.AddWithValue("permission_id", permissionId);
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static PermissionEntity MapPermission(System.Data.Common.DbDataReader reader) => new()
|
||||
{
|
||||
Id = reader.GetGuid(0),
|
||||
TenantId = reader.GetString(1),
|
||||
Name = reader.GetString(2),
|
||||
Resource = reader.GetString(3),
|
||||
Action = reader.GetString(4),
|
||||
Description = reader.IsDBNull(5) ? null : reader.GetString(5),
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(6)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
using StellaOps.Authority.Storage.Postgres.Models;
|
||||
using StellaOps.Infrastructure.Postgres;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Postgres.Repositories;
|
||||
|
||||
public sealed class RoleRepository : RepositoryBase<AuthorityDataSource>, IRoleRepository
|
||||
{
|
||||
public RoleRepository(AuthorityDataSource dataSource) : base(dataSource) { }
|
||||
|
||||
public async Task<RoleEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, name, display_name, description, is_system, metadata, created_at, updated_at
|
||||
FROM authority.roles
|
||||
WHERE tenant_id = @tenant_id AND id = @id
|
||||
""";
|
||||
return await QuerySingleOrDefaultAsync(tenantId, sql, MapRole,
|
||||
cmd => { cmd.Parameters.AddWithValue("id", id); },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<RoleEntity?> GetByNameAsync(string tenantId, string name, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, name, display_name, description, is_system, metadata, created_at, updated_at
|
||||
FROM authority.roles
|
||||
WHERE tenant_id = @tenant_id AND name = @name
|
||||
""";
|
||||
return await QuerySingleOrDefaultAsync(tenantId, sql, MapRole,
|
||||
cmd => { cmd.Parameters.AddWithValue("name", name); },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<RoleEntity>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, name, display_name, description, is_system, metadata, created_at, updated_at
|
||||
FROM authority.roles
|
||||
WHERE tenant_id = @tenant_id
|
||||
ORDER BY name
|
||||
""";
|
||||
return await QueryAsync(tenantId, sql, MapRole, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<RoleEntity>> GetUserRolesAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT r.id, r.tenant_id, r.name, r.display_name, r.description, r.is_system, r.metadata, r.created_at, r.updated_at
|
||||
FROM authority.roles r
|
||||
INNER JOIN authority.user_roles ur ON r.id = ur.role_id
|
||||
WHERE r.tenant_id = @tenant_id AND ur.user_id = @user_id
|
||||
AND (ur.expires_at IS NULL OR ur.expires_at > NOW())
|
||||
ORDER BY r.name
|
||||
""";
|
||||
return await QueryAsync(tenantId, sql, MapRole,
|
||||
cmd => { cmd.Parameters.AddWithValue("user_id", userId); },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<Guid> CreateAsync(string tenantId, RoleEntity role, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO authority.roles (id, tenant_id, name, display_name, description, is_system, metadata)
|
||||
VALUES (@id, @tenant_id, @name, @display_name, @description, @is_system, @metadata::jsonb)
|
||||
RETURNING id
|
||||
""";
|
||||
var id = role.Id == Guid.Empty ? Guid.NewGuid() : role.Id;
|
||||
await ExecuteAsync(tenantId, sql, cmd =>
|
||||
{
|
||||
cmd.Parameters.AddWithValue("id", id);
|
||||
cmd.Parameters.AddWithValue("name", role.Name);
|
||||
AddNullableParameter(cmd, "display_name", role.DisplayName);
|
||||
AddNullableParameter(cmd, "description", role.Description);
|
||||
cmd.Parameters.AddWithValue("is_system", role.IsSystem);
|
||||
AddJsonbParameter(cmd, "metadata", role.Metadata);
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
return id;
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(string tenantId, RoleEntity role, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE authority.roles
|
||||
SET name = @name, display_name = @display_name, description = @description,
|
||||
is_system = @is_system, metadata = @metadata::jsonb
|
||||
WHERE tenant_id = @tenant_id AND id = @id
|
||||
""";
|
||||
await ExecuteAsync(tenantId, sql, cmd =>
|
||||
{
|
||||
cmd.Parameters.AddWithValue("id", role.Id);
|
||||
cmd.Parameters.AddWithValue("name", role.Name);
|
||||
AddNullableParameter(cmd, "display_name", role.DisplayName);
|
||||
AddNullableParameter(cmd, "description", role.Description);
|
||||
cmd.Parameters.AddWithValue("is_system", role.IsSystem);
|
||||
AddJsonbParameter(cmd, "metadata", role.Metadata);
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "DELETE FROM authority.roles WHERE tenant_id = @tenant_id AND id = @id";
|
||||
await ExecuteAsync(tenantId, sql, cmd => { cmd.Parameters.AddWithValue("id", id); }, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task AssignToUserAsync(string tenantId, Guid userId, Guid roleId, string? grantedBy, DateTimeOffset? expiresAt, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO authority.user_roles (user_id, role_id, granted_by, expires_at)
|
||||
VALUES (@user_id, @role_id, @granted_by, @expires_at)
|
||||
ON CONFLICT (user_id, role_id) DO UPDATE SET
|
||||
granted_at = NOW(), granted_by = EXCLUDED.granted_by, expires_at = EXCLUDED.expires_at
|
||||
""";
|
||||
await ExecuteAsync(tenantId, sql, cmd =>
|
||||
{
|
||||
cmd.Parameters.AddWithValue("user_id", userId);
|
||||
cmd.Parameters.AddWithValue("role_id", roleId);
|
||||
AddNullableParameter(cmd, "granted_by", grantedBy);
|
||||
AddNullableParameter(cmd, "expires_at", expiresAt);
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task RemoveFromUserAsync(string tenantId, Guid userId, Guid roleId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "DELETE FROM authority.user_roles WHERE user_id = @user_id AND role_id = @role_id";
|
||||
await ExecuteAsync(tenantId, sql, cmd =>
|
||||
{
|
||||
cmd.Parameters.AddWithValue("user_id", userId);
|
||||
cmd.Parameters.AddWithValue("role_id", roleId);
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static RoleEntity MapRole(System.Data.Common.DbDataReader reader) => new()
|
||||
{
|
||||
Id = reader.GetGuid(0),
|
||||
TenantId = reader.GetString(1),
|
||||
Name = reader.GetString(2),
|
||||
DisplayName = reader.IsDBNull(3) ? null : reader.GetString(3),
|
||||
Description = reader.IsDBNull(4) ? null : reader.GetString(4),
|
||||
IsSystem = reader.GetBoolean(5),
|
||||
Metadata = reader.GetString(6),
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(7),
|
||||
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(8)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
using StellaOps.Authority.Storage.Postgres.Models;
|
||||
using StellaOps.Infrastructure.Postgres;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Postgres.Repositories;
|
||||
|
||||
public sealed class TokenRepository : RepositoryBase<AuthorityDataSource>, ITokenRepository
|
||||
{
|
||||
public TokenRepository(AuthorityDataSource dataSource) : base(dataSource) { }
|
||||
|
||||
public async Task<TokenEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, user_id, token_hash, token_type, scopes, client_id, issued_at, expires_at, revoked_at, revoked_by, metadata
|
||||
FROM authority.tokens
|
||||
WHERE tenant_id = @tenant_id AND id = @id
|
||||
""";
|
||||
return await QuerySingleOrDefaultAsync(tenantId, sql, MapToken,
|
||||
cmd => { cmd.Parameters.AddWithValue("id", id); },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<TokenEntity?> GetByHashAsync(string tokenHash, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, user_id, token_hash, token_type, scopes, client_id, issued_at, expires_at, revoked_at, revoked_by, metadata
|
||||
FROM authority.tokens
|
||||
WHERE token_hash = @token_hash AND revoked_at IS NULL AND expires_at > NOW()
|
||||
""";
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = sql;
|
||||
command.Parameters.AddWithValue("token_hash", tokenHash);
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await reader.ReadAsync(cancellationToken).ConfigureAwait(false) ? MapToken(reader) : null;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<TokenEntity>> GetByUserIdAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, user_id, token_hash, token_type, scopes, client_id, issued_at, expires_at, revoked_at, revoked_by, metadata
|
||||
FROM authority.tokens
|
||||
WHERE tenant_id = @tenant_id AND user_id = @user_id AND revoked_at IS NULL
|
||||
ORDER BY issued_at DESC
|
||||
""";
|
||||
return await QueryAsync(tenantId, sql, MapToken,
|
||||
cmd => { cmd.Parameters.AddWithValue("user_id", userId); },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<Guid> CreateAsync(string tenantId, TokenEntity token, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO authority.tokens (id, tenant_id, user_id, token_hash, token_type, scopes, client_id, expires_at, metadata)
|
||||
VALUES (@id, @tenant_id, @user_id, @token_hash, @token_type, @scopes, @client_id, @expires_at, @metadata::jsonb)
|
||||
RETURNING id
|
||||
""";
|
||||
var id = token.Id == Guid.Empty ? Guid.NewGuid() : token.Id;
|
||||
await ExecuteAsync(tenantId, sql, cmd =>
|
||||
{
|
||||
cmd.Parameters.AddWithValue("id", id);
|
||||
AddNullableParameter(cmd, "user_id", token.UserId);
|
||||
cmd.Parameters.AddWithValue("token_hash", token.TokenHash);
|
||||
cmd.Parameters.AddWithValue("token_type", token.TokenType);
|
||||
AddArrayParameter(cmd, "scopes", token.Scopes);
|
||||
AddNullableParameter(cmd, "client_id", token.ClientId);
|
||||
cmd.Parameters.AddWithValue("expires_at", token.ExpiresAt);
|
||||
AddJsonbParameter(cmd, "metadata", token.Metadata);
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
return id;
|
||||
}
|
||||
|
||||
public async Task RevokeAsync(string tenantId, Guid id, string revokedBy, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE authority.tokens SET revoked_at = NOW(), revoked_by = @revoked_by
|
||||
WHERE tenant_id = @tenant_id AND id = @id AND revoked_at IS NULL
|
||||
""";
|
||||
await ExecuteAsync(tenantId, sql, cmd =>
|
||||
{
|
||||
cmd.Parameters.AddWithValue("id", id);
|
||||
cmd.Parameters.AddWithValue("revoked_by", revokedBy);
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task RevokeByUserIdAsync(string tenantId, Guid userId, string revokedBy, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE authority.tokens SET revoked_at = NOW(), revoked_by = @revoked_by
|
||||
WHERE tenant_id = @tenant_id AND user_id = @user_id AND revoked_at IS NULL
|
||||
""";
|
||||
await ExecuteAsync(tenantId, sql, cmd =>
|
||||
{
|
||||
cmd.Parameters.AddWithValue("user_id", userId);
|
||||
cmd.Parameters.AddWithValue("revoked_by", revokedBy);
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task DeleteExpiredAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "DELETE FROM authority.tokens WHERE expires_at < NOW() - INTERVAL '7 days'";
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = sql;
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static TokenEntity MapToken(System.Data.Common.DbDataReader reader) => new()
|
||||
{
|
||||
Id = reader.GetGuid(0),
|
||||
TenantId = reader.GetString(1),
|
||||
UserId = reader.IsDBNull(2) ? null : reader.GetGuid(2),
|
||||
TokenHash = reader.GetString(3),
|
||||
TokenType = reader.GetString(4),
|
||||
Scopes = reader.IsDBNull(5) ? [] : reader.GetFieldValue<string[]>(5),
|
||||
ClientId = reader.IsDBNull(6) ? null : reader.GetString(6),
|
||||
IssuedAt = reader.GetFieldValue<DateTimeOffset>(7),
|
||||
ExpiresAt = reader.GetFieldValue<DateTimeOffset>(8),
|
||||
RevokedAt = reader.IsDBNull(9) ? null : reader.GetFieldValue<DateTimeOffset>(9),
|
||||
RevokedBy = reader.IsDBNull(10) ? null : reader.GetString(10),
|
||||
Metadata = reader.GetString(11)
|
||||
};
|
||||
}
|
||||
|
||||
public sealed class RefreshTokenRepository : RepositoryBase<AuthorityDataSource>, IRefreshTokenRepository
|
||||
{
|
||||
public RefreshTokenRepository(AuthorityDataSource dataSource) : base(dataSource) { }
|
||||
|
||||
public async Task<RefreshTokenEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, user_id, token_hash, access_token_id, client_id, issued_at, expires_at, revoked_at, revoked_by, replaced_by, metadata
|
||||
FROM authority.refresh_tokens
|
||||
WHERE tenant_id = @tenant_id AND id = @id
|
||||
""";
|
||||
return await QuerySingleOrDefaultAsync(tenantId, sql, MapRefreshToken,
|
||||
cmd => { cmd.Parameters.AddWithValue("id", id); },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<RefreshTokenEntity?> GetByHashAsync(string tokenHash, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, user_id, token_hash, access_token_id, client_id, issued_at, expires_at, revoked_at, revoked_by, replaced_by, metadata
|
||||
FROM authority.refresh_tokens
|
||||
WHERE token_hash = @token_hash AND revoked_at IS NULL AND expires_at > NOW()
|
||||
""";
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = sql;
|
||||
command.Parameters.AddWithValue("token_hash", tokenHash);
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await reader.ReadAsync(cancellationToken).ConfigureAwait(false) ? MapRefreshToken(reader) : null;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<RefreshTokenEntity>> GetByUserIdAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, tenant_id, user_id, token_hash, access_token_id, client_id, issued_at, expires_at, revoked_at, revoked_by, replaced_by, metadata
|
||||
FROM authority.refresh_tokens
|
||||
WHERE tenant_id = @tenant_id AND user_id = @user_id AND revoked_at IS NULL
|
||||
ORDER BY issued_at DESC
|
||||
""";
|
||||
return await QueryAsync(tenantId, sql, MapRefreshToken,
|
||||
cmd => { cmd.Parameters.AddWithValue("user_id", userId); },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<Guid> CreateAsync(string tenantId, RefreshTokenEntity token, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO authority.refresh_tokens (id, tenant_id, user_id, token_hash, access_token_id, client_id, expires_at, metadata)
|
||||
VALUES (@id, @tenant_id, @user_id, @token_hash, @access_token_id, @client_id, @expires_at, @metadata::jsonb)
|
||||
RETURNING id
|
||||
""";
|
||||
var id = token.Id == Guid.Empty ? Guid.NewGuid() : token.Id;
|
||||
await ExecuteAsync(tenantId, sql, cmd =>
|
||||
{
|
||||
cmd.Parameters.AddWithValue("id", id);
|
||||
cmd.Parameters.AddWithValue("user_id", token.UserId);
|
||||
cmd.Parameters.AddWithValue("token_hash", token.TokenHash);
|
||||
AddNullableParameter(cmd, "access_token_id", token.AccessTokenId);
|
||||
AddNullableParameter(cmd, "client_id", token.ClientId);
|
||||
cmd.Parameters.AddWithValue("expires_at", token.ExpiresAt);
|
||||
AddJsonbParameter(cmd, "metadata", token.Metadata);
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
return id;
|
||||
}
|
||||
|
||||
public async Task RevokeAsync(string tenantId, Guid id, string revokedBy, Guid? replacedBy, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE authority.refresh_tokens SET revoked_at = NOW(), revoked_by = @revoked_by, replaced_by = @replaced_by
|
||||
WHERE tenant_id = @tenant_id AND id = @id AND revoked_at IS NULL
|
||||
""";
|
||||
await ExecuteAsync(tenantId, sql, cmd =>
|
||||
{
|
||||
cmd.Parameters.AddWithValue("id", id);
|
||||
cmd.Parameters.AddWithValue("revoked_by", revokedBy);
|
||||
AddNullableParameter(cmd, "replaced_by", replacedBy);
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task RevokeByUserIdAsync(string tenantId, Guid userId, string revokedBy, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE authority.refresh_tokens SET revoked_at = NOW(), revoked_by = @revoked_by
|
||||
WHERE tenant_id = @tenant_id AND user_id = @user_id AND revoked_at IS NULL
|
||||
""";
|
||||
await ExecuteAsync(tenantId, sql, cmd =>
|
||||
{
|
||||
cmd.Parameters.AddWithValue("user_id", userId);
|
||||
cmd.Parameters.AddWithValue("revoked_by", revokedBy);
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task DeleteExpiredAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "DELETE FROM authority.refresh_tokens WHERE expires_at < NOW() - INTERVAL '30 days'";
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = sql;
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static RefreshTokenEntity MapRefreshToken(System.Data.Common.DbDataReader reader) => new()
|
||||
{
|
||||
Id = reader.GetGuid(0),
|
||||
TenantId = reader.GetString(1),
|
||||
UserId = reader.GetGuid(2),
|
||||
TokenHash = reader.GetString(3),
|
||||
AccessTokenId = reader.IsDBNull(4) ? null : reader.GetGuid(4),
|
||||
ClientId = reader.IsDBNull(5) ? null : reader.GetString(5),
|
||||
IssuedAt = reader.GetFieldValue<DateTimeOffset>(6),
|
||||
ExpiresAt = reader.GetFieldValue<DateTimeOffset>(7),
|
||||
RevokedAt = reader.IsDBNull(8) ? null : reader.GetFieldValue<DateTimeOffset>(8),
|
||||
RevokedBy = reader.IsDBNull(9) ? null : reader.GetString(9),
|
||||
ReplacedBy = reader.IsDBNull(10) ? null : reader.GetGuid(10),
|
||||
Metadata = reader.GetString(11)
|
||||
};
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="Migrations\**\*.sql" CopyToOutputDirectory="PreserveNewest" />
|
||||
<EmbeddedResource Include="Migrations\**\*.sql" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
using FluentAssertions;
|
||||
using Npgsql;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Postgres.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests that verify Authority module migrations run successfully.
|
||||
/// </summary>
|
||||
[Collection(AuthorityPostgresCollection.Name)]
|
||||
public sealed class AuthorityMigrationTests
|
||||
{
|
||||
private readonly AuthorityPostgresFixture _fixture;
|
||||
|
||||
public AuthorityMigrationTests(AuthorityPostgresFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MigrationsApplied_SchemaHasTables()
|
||||
{
|
||||
// Arrange
|
||||
await using var connection = new NpgsqlConnection(_fixture.ConnectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
// Act - Query for tables in schema
|
||||
await using var cmd = new NpgsqlCommand(
|
||||
"""
|
||||
SELECT table_name FROM information_schema.tables
|
||||
WHERE table_schema = @schema
|
||||
AND table_type = 'BASE TABLE'
|
||||
ORDER BY table_name;
|
||||
""",
|
||||
connection);
|
||||
cmd.Parameters.AddWithValue("schema", _fixture.SchemaName);
|
||||
|
||||
var tables = new List<string>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync();
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
tables.Add(reader.GetString(0));
|
||||
}
|
||||
|
||||
// Assert - Should have core Authority tables
|
||||
tables.Should().Contain("schema_migrations");
|
||||
// Add more specific table assertions based on Authority migrations
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MigrationsApplied_SchemaVersionRecorded()
|
||||
{
|
||||
// Arrange
|
||||
await using var connection = new NpgsqlConnection(_fixture.ConnectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
// Act - Check schema_migrations table
|
||||
await using var cmd = new NpgsqlCommand(
|
||||
$"SELECT COUNT(*) FROM {_fixture.SchemaName}.schema_migrations;",
|
||||
connection);
|
||||
|
||||
var count = await cmd.ExecuteScalarAsync();
|
||||
|
||||
// Assert - At least one migration should be recorded
|
||||
count.Should().NotBeNull();
|
||||
((long)count!).Should().BeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using System.Reflection;
|
||||
using StellaOps.Authority.Storage.Postgres;
|
||||
using StellaOps.Infrastructure.Postgres.Testing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Authority.Storage.Postgres.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL integration test fixture for the Authority module.
|
||||
/// Runs migrations from embedded resources and provides test isolation.
|
||||
/// </summary>
|
||||
public sealed class AuthorityPostgresFixture : PostgresIntegrationFixture, ICollectionFixture<AuthorityPostgresFixture>
|
||||
{
|
||||
protected override Assembly? GetMigrationAssembly()
|
||||
=> typeof(AuthorityDataSource).Assembly;
|
||||
|
||||
protected override string GetModuleName() => "Authority";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collection definition for Authority PostgreSQL integration tests.
|
||||
/// Tests in this collection share a single PostgreSQL container instance.
|
||||
/// </summary>
|
||||
[CollectionDefinition(Name)]
|
||||
public sealed class AuthorityPostgresCollection : ICollectionFixture<AuthorityPostgresFixture>
|
||||
{
|
||||
public const string Name = "AuthorityPostgres";
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" ?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="Moq" Version="4.20.70" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Authority.Storage.Postgres\StellaOps.Authority.Storage.Postgres.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.Postgres.Testing\StellaOps.Infrastructure.Postgres.Testing.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -11,7 +11,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="Migrations\**\*.sql" CopyToOutputDirectory="PreserveNewest" />
|
||||
<EmbeddedResource Include="Migrations\**\*.sql" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
using System.Reflection;
|
||||
using StellaOps.Concelier.Storage.Postgres;
|
||||
using StellaOps.Infrastructure.Postgres.Testing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Postgres.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL integration test fixture for the Concelier module.
|
||||
/// Runs migrations from embedded resources and provides test isolation.
|
||||
/// </summary>
|
||||
public sealed class ConcelierPostgresFixture : PostgresIntegrationFixture, ICollectionFixture<ConcelierPostgresFixture>
|
||||
{
|
||||
protected override Assembly? GetMigrationAssembly()
|
||||
=> typeof(ConcelierDataSource).Assembly;
|
||||
|
||||
protected override string GetModuleName() => "Concelier";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collection definition for Concelier PostgreSQL integration tests.
|
||||
/// Tests in this collection share a single PostgreSQL container instance.
|
||||
/// </summary>
|
||||
[CollectionDefinition(Name)]
|
||||
public sealed class ConcelierPostgresCollection : ICollectionFixture<ConcelierPostgresFixture>
|
||||
{
|
||||
public const string Name = "ConcelierPostgres";
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" ?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="Moq" Version="4.20.70" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Concelier.Storage.Postgres\StellaOps.Concelier.Storage.Postgres.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.Postgres.Testing\StellaOps.Infrastructure.Postgres.Testing.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -11,7 +11,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="Migrations\**\*.sql" CopyToOutputDirectory="PreserveNewest" />
|
||||
<EmbeddedResource Include="Migrations\**\*.sql" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
using System.Reflection;
|
||||
using StellaOps.Excititor.Storage.Postgres;
|
||||
using StellaOps.Infrastructure.Postgres.Testing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Excititor.Storage.Postgres.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL integration test fixture for the Excititor module.
|
||||
/// Runs migrations from embedded resources and provides test isolation.
|
||||
/// </summary>
|
||||
public sealed class ExcititorPostgresFixture : PostgresIntegrationFixture, ICollectionFixture<ExcititorPostgresFixture>
|
||||
{
|
||||
protected override Assembly? GetMigrationAssembly()
|
||||
=> typeof(ExcititorDataSource).Assembly;
|
||||
|
||||
protected override string GetModuleName() => "Excititor";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collection definition for Excititor PostgreSQL integration tests.
|
||||
/// Tests in this collection share a single PostgreSQL container instance.
|
||||
/// </summary>
|
||||
[CollectionDefinition(Name)]
|
||||
public sealed class ExcititorPostgresCollection : ICollectionFixture<ExcititorPostgresFixture>
|
||||
{
|
||||
public const string Name = "ExcititorPostgres";
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" ?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="Moq" Version="4.20.70" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Excititor.Storage.Postgres\StellaOps.Excititor.Storage.Postgres.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.Postgres.Testing\StellaOps.Infrastructure.Postgres.Testing.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -11,7 +11,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="Migrations\**\*.sql" CopyToOutputDirectory="PreserveNewest" />
|
||||
<EmbeddedResource Include="Migrations\**\*.sql" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
using System.Reflection;
|
||||
using StellaOps.Infrastructure.Postgres.Testing;
|
||||
using StellaOps.Notify.Storage.Postgres;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Postgres.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL integration test fixture for the Notify module.
|
||||
/// Runs migrations from embedded resources and provides test isolation.
|
||||
/// </summary>
|
||||
public sealed class NotifyPostgresFixture : PostgresIntegrationFixture, ICollectionFixture<NotifyPostgresFixture>
|
||||
{
|
||||
protected override Assembly? GetMigrationAssembly()
|
||||
=> typeof(NotifyDataSource).Assembly;
|
||||
|
||||
protected override string GetModuleName() => "Notify";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collection definition for Notify PostgreSQL integration tests.
|
||||
/// Tests in this collection share a single PostgreSQL container instance.
|
||||
/// </summary>
|
||||
[CollectionDefinition(Name)]
|
||||
public sealed class NotifyPostgresCollection : ICollectionFixture<NotifyPostgresFixture>
|
||||
{
|
||||
public const string Name = "NotifyPostgres";
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" ?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="Moq" Version="4.20.70" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Notify.Storage.Postgres\StellaOps.Notify.Storage.Postgres.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.Postgres.Testing\StellaOps.Infrastructure.Postgres.Testing.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,255 @@
|
||||
namespace StellaOps.Orchestrator.Core.Scale;
|
||||
|
||||
/// <summary>
|
||||
/// Service for load shedding decisions during high-load scenarios.
|
||||
/// </summary>
|
||||
public sealed class LoadShedder
|
||||
{
|
||||
private readonly ScaleMetrics _scaleMetrics;
|
||||
private readonly LoadShedderOptions _options;
|
||||
private volatile LoadShedState _currentState = LoadShedState.Normal;
|
||||
private DateTimeOffset _lastStateChange = DateTimeOffset.UtcNow;
|
||||
private readonly object _lock = new();
|
||||
|
||||
public LoadShedder(ScaleMetrics scaleMetrics, LoadShedderOptions? options = null)
|
||||
{
|
||||
_scaleMetrics = scaleMetrics;
|
||||
_options = options ?? LoadShedderOptions.Default;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current load shedding state.
|
||||
/// </summary>
|
||||
public LoadShedState CurrentState => _currentState;
|
||||
|
||||
/// <summary>
|
||||
/// Gets when the state last changed.
|
||||
/// </summary>
|
||||
public DateTimeOffset LastStateChange => _lastStateChange;
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a request should be accepted based on current load.
|
||||
/// </summary>
|
||||
/// <param name="priority">Request priority (higher = more important).</param>
|
||||
/// <returns>True if the request should be accepted.</returns>
|
||||
public bool ShouldAcceptRequest(int priority = 0)
|
||||
{
|
||||
UpdateState();
|
||||
|
||||
return _currentState switch
|
||||
{
|
||||
LoadShedState.Normal => true,
|
||||
LoadShedState.Warning => priority >= _options.WarningPriorityThreshold,
|
||||
LoadShedState.Critical => priority >= _options.CriticalPriorityThreshold,
|
||||
LoadShedState.Emergency => priority >= _options.EmergencyPriorityThreshold,
|
||||
_ => true
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current load factor (0.0 - 1.0+).
|
||||
/// </summary>
|
||||
public double GetLoadFactor()
|
||||
{
|
||||
var metrics = _scaleMetrics.GetAutoscaleMetrics();
|
||||
|
||||
// Compute load factor based on multiple signals
|
||||
var queueFactor = Math.Min(2.0, metrics.QueueDepth / (double)_options.QueueDepthTarget);
|
||||
var latencyFactor = Math.Min(2.0, metrics.DispatchLatencyP95Ms / _options.LatencyP95TargetMs);
|
||||
|
||||
// Weight: 60% latency, 40% queue depth
|
||||
return latencyFactor * 0.6 + queueFactor * 0.4;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the recommended delay for a request based on current load.
|
||||
/// </summary>
|
||||
/// <returns>Recommended delay, or null if no delay needed.</returns>
|
||||
public TimeSpan? GetRecommendedDelay()
|
||||
{
|
||||
var loadFactor = GetLoadFactor();
|
||||
|
||||
if (loadFactor < 0.8) return null;
|
||||
if (loadFactor < 1.0) return TimeSpan.FromMilliseconds(50);
|
||||
if (loadFactor < 1.2) return TimeSpan.FromMilliseconds(100);
|
||||
if (loadFactor < 1.5) return TimeSpan.FromMilliseconds(200);
|
||||
return TimeSpan.FromMilliseconds(500);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a snapshot of the current load shedding status.
|
||||
/// </summary>
|
||||
public LoadSheddingStatus GetStatus()
|
||||
{
|
||||
var metrics = _scaleMetrics.GetAutoscaleMetrics();
|
||||
var loadFactor = GetLoadFactor();
|
||||
|
||||
return new LoadSheddingStatus(
|
||||
State: _currentState,
|
||||
LoadFactor: loadFactor,
|
||||
QueueDepth: metrics.QueueDepth,
|
||||
DispatchLatencyP95Ms: metrics.DispatchLatencyP95Ms,
|
||||
AcceptingPriority: GetMinAcceptedPriority(),
|
||||
RecommendedDelayMs: GetRecommendedDelay()?.TotalMilliseconds ?? 0,
|
||||
StateChangedAt: _lastStateChange,
|
||||
IsSheddingLoad: _currentState != LoadShedState.Normal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forces a state update based on current metrics.
|
||||
/// </summary>
|
||||
public void UpdateState()
|
||||
{
|
||||
var loadFactor = GetLoadFactor();
|
||||
var newState = DetermineState(loadFactor);
|
||||
|
||||
if (newState == _currentState) return;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
// Hysteresis: require sustained condition for state changes
|
||||
var timeSinceLastChange = DateTimeOffset.UtcNow - _lastStateChange;
|
||||
|
||||
// Going up (worse) is immediate; going down (better) requires cooldown
|
||||
var isImproving = newState < _currentState;
|
||||
|
||||
if (isImproving && timeSinceLastChange < _options.RecoveryCooldown)
|
||||
{
|
||||
return; // Wait for cooldown before improving state
|
||||
}
|
||||
|
||||
_currentState = newState;
|
||||
_lastStateChange = DateTimeOffset.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manually sets the load shedding state (for operator override).
|
||||
/// </summary>
|
||||
public void SetState(LoadShedState state)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_currentState = state;
|
||||
_lastStateChange = DateTimeOffset.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
private LoadShedState DetermineState(double loadFactor)
|
||||
{
|
||||
if (loadFactor >= _options.EmergencyThreshold)
|
||||
return LoadShedState.Emergency;
|
||||
if (loadFactor >= _options.CriticalThreshold)
|
||||
return LoadShedState.Critical;
|
||||
if (loadFactor >= _options.WarningThreshold)
|
||||
return LoadShedState.Warning;
|
||||
return LoadShedState.Normal;
|
||||
}
|
||||
|
||||
private int GetMinAcceptedPriority()
|
||||
{
|
||||
return _currentState switch
|
||||
{
|
||||
LoadShedState.Normal => 0,
|
||||
LoadShedState.Warning => _options.WarningPriorityThreshold,
|
||||
LoadShedState.Critical => _options.CriticalPriorityThreshold,
|
||||
LoadShedState.Emergency => _options.EmergencyPriorityThreshold,
|
||||
_ => 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Load shedding states.
|
||||
/// </summary>
|
||||
public enum LoadShedState
|
||||
{
|
||||
/// <summary>
|
||||
/// Normal operation, all requests accepted.
|
||||
/// </summary>
|
||||
Normal = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Warning level, low-priority requests may be delayed or rejected.
|
||||
/// </summary>
|
||||
Warning = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Critical level, only medium and high priority requests accepted.
|
||||
/// </summary>
|
||||
Critical = 2,
|
||||
|
||||
/// <summary>
|
||||
/// Emergency level, only high priority requests accepted.
|
||||
/// </summary>
|
||||
Emergency = 3
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for load shedding.
|
||||
/// </summary>
|
||||
public sealed record LoadShedderOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Default options.
|
||||
/// </summary>
|
||||
public static readonly LoadShedderOptions Default = new();
|
||||
|
||||
/// <summary>
|
||||
/// Target queue depth for 1.0 load factor.
|
||||
/// </summary>
|
||||
public long QueueDepthTarget { get; init; } = 10000;
|
||||
|
||||
/// <summary>
|
||||
/// Target P95 latency in milliseconds for 1.0 load factor.
|
||||
/// </summary>
|
||||
public double LatencyP95TargetMs { get; init; } = 150.0;
|
||||
|
||||
/// <summary>
|
||||
/// Load factor threshold for warning state.
|
||||
/// </summary>
|
||||
public double WarningThreshold { get; init; } = 0.8;
|
||||
|
||||
/// <summary>
|
||||
/// Load factor threshold for critical state.
|
||||
/// </summary>
|
||||
public double CriticalThreshold { get; init; } = 1.0;
|
||||
|
||||
/// <summary>
|
||||
/// Load factor threshold for emergency state.
|
||||
/// </summary>
|
||||
public double EmergencyThreshold { get; init; } = 1.5;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum priority accepted during warning state.
|
||||
/// </summary>
|
||||
public int WarningPriorityThreshold { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum priority accepted during critical state.
|
||||
/// </summary>
|
||||
public int CriticalPriorityThreshold { get; init; } = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum priority accepted during emergency state.
|
||||
/// </summary>
|
||||
public int EmergencyPriorityThreshold { get; init; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Cooldown period before recovering to a better state.
|
||||
/// </summary>
|
||||
public TimeSpan RecoveryCooldown { get; init; } = TimeSpan.FromSeconds(30);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Current load shedding status.
|
||||
/// </summary>
|
||||
public sealed record LoadSheddingStatus(
|
||||
LoadShedState State,
|
||||
double LoadFactor,
|
||||
long QueueDepth,
|
||||
double DispatchLatencyP95Ms,
|
||||
int AcceptingPriority,
|
||||
double RecommendedDelayMs,
|
||||
DateTimeOffset StateChangedAt,
|
||||
bool IsSheddingLoad);
|
||||
@@ -0,0 +1,317 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace StellaOps.Orchestrator.Core.Scale;
|
||||
|
||||
/// <summary>
|
||||
/// Service for tracking scale-related metrics for autoscaling decisions.
|
||||
/// </summary>
|
||||
public sealed class ScaleMetrics
|
||||
{
|
||||
private readonly ConcurrentQueue<LatencySample> _dispatchLatencies = new();
|
||||
private readonly ConcurrentDictionary<string, long> _queueDepths = new();
|
||||
private readonly ConcurrentDictionary<string, long> _activeJobs = new();
|
||||
private readonly object _lock = new();
|
||||
|
||||
// Keep samples for the last 5 minutes
|
||||
private static readonly TimeSpan SampleWindow = TimeSpan.FromMinutes(5);
|
||||
private const int MaxSamples = 10000;
|
||||
|
||||
/// <summary>
|
||||
/// Records a dispatch latency sample.
|
||||
/// </summary>
|
||||
/// <param name="latency">The dispatch latency.</param>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="jobType">The job type.</param>
|
||||
public void RecordDispatchLatency(TimeSpan latency, string tenantId, string? jobType = null)
|
||||
{
|
||||
var sample = new LatencySample(
|
||||
Timestamp: DateTimeOffset.UtcNow,
|
||||
LatencyMs: latency.TotalMilliseconds,
|
||||
TenantId: tenantId,
|
||||
JobType: jobType);
|
||||
|
||||
_dispatchLatencies.Enqueue(sample);
|
||||
|
||||
// Prune old samples periodically
|
||||
PruneSamplesIfNeeded();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records dispatch latency using a stopwatch.
|
||||
/// </summary>
|
||||
public DispatchTimer StartDispatchTimer(string tenantId, string? jobType = null)
|
||||
{
|
||||
return new DispatchTimer(this, tenantId, jobType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the queue depth for a tenant/job type combination.
|
||||
/// </summary>
|
||||
public void UpdateQueueDepth(string tenantId, string? jobType, long depth)
|
||||
{
|
||||
var key = GetKey(tenantId, jobType);
|
||||
_queueDepths.AddOrUpdate(key, depth, (_, _) => depth);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Increments the queue depth.
|
||||
/// </summary>
|
||||
public void IncrementQueueDepth(string tenantId, string? jobType = null)
|
||||
{
|
||||
var key = GetKey(tenantId, jobType);
|
||||
_queueDepths.AddOrUpdate(key, 1, (_, v) => v + 1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decrements the queue depth.
|
||||
/// </summary>
|
||||
public void DecrementQueueDepth(string tenantId, string? jobType = null)
|
||||
{
|
||||
var key = GetKey(tenantId, jobType);
|
||||
_queueDepths.AddOrUpdate(key, 0, (_, v) => Math.Max(0, v - 1));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the active job count for a tenant/job type.
|
||||
/// </summary>
|
||||
public void UpdateActiveJobs(string tenantId, string? jobType, long count)
|
||||
{
|
||||
var key = GetKey(tenantId, jobType);
|
||||
_activeJobs.AddOrUpdate(key, count, (_, _) => count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the dispatch latency percentiles.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Optional tenant filter.</param>
|
||||
/// <param name="window">Time window for samples (default: 1 minute).</param>
|
||||
public LatencyPercentiles GetDispatchLatencyPercentiles(string? tenantId = null, TimeSpan? window = null)
|
||||
{
|
||||
var cutoff = DateTimeOffset.UtcNow - (window ?? TimeSpan.FromMinutes(1));
|
||||
|
||||
var samples = _dispatchLatencies
|
||||
.Where(s => s.Timestamp >= cutoff)
|
||||
.Where(s => tenantId is null || s.TenantId == tenantId)
|
||||
.Select(s => s.LatencyMs)
|
||||
.OrderBy(x => x)
|
||||
.ToList();
|
||||
|
||||
if (samples.Count == 0)
|
||||
{
|
||||
return new LatencyPercentiles(0, 0, 0, 0, 0, 0, 0);
|
||||
}
|
||||
|
||||
return new LatencyPercentiles(
|
||||
Count: samples.Count,
|
||||
Min: samples[0],
|
||||
Max: samples[^1],
|
||||
Avg: samples.Average(),
|
||||
P50: GetPercentile(samples, 0.50),
|
||||
P95: GetPercentile(samples, 0.95),
|
||||
P99: GetPercentile(samples, 0.99));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a snapshot of current scale metrics.
|
||||
/// </summary>
|
||||
public ScaleSnapshot GetSnapshot()
|
||||
{
|
||||
var percentiles = GetDispatchLatencyPercentiles();
|
||||
var totalQueueDepth = _queueDepths.Values.Sum();
|
||||
var totalActiveJobs = _activeJobs.Values.Sum();
|
||||
|
||||
return new ScaleSnapshot(
|
||||
Timestamp: DateTimeOffset.UtcNow,
|
||||
TotalQueueDepth: totalQueueDepth,
|
||||
TotalActiveJobs: totalActiveJobs,
|
||||
DispatchLatency: percentiles,
|
||||
QueueDepthByKey: new Dictionary<string, long>(_queueDepths),
|
||||
ActiveJobsByKey: new Dictionary<string, long>(_activeJobs));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets autoscaling-compatible metrics in Prometheus format.
|
||||
/// </summary>
|
||||
public AutoscaleMetrics GetAutoscaleMetrics()
|
||||
{
|
||||
var snapshot = GetSnapshot();
|
||||
var latency = snapshot.DispatchLatency;
|
||||
|
||||
// Compute scaling signals
|
||||
var isUnderPressure = latency.P95 > 150.0 || snapshot.TotalQueueDepth > 10000;
|
||||
var recommendedReplicas = ComputeRecommendedReplicas(snapshot);
|
||||
|
||||
return new AutoscaleMetrics(
|
||||
QueueDepth: snapshot.TotalQueueDepth,
|
||||
ActiveJobs: snapshot.TotalActiveJobs,
|
||||
DispatchLatencyP95Ms: latency.P95,
|
||||
DispatchLatencyP99Ms: latency.P99,
|
||||
SamplesInWindow: latency.Count,
|
||||
IsUnderPressure: isUnderPressure,
|
||||
RecommendedReplicas: recommendedReplicas,
|
||||
ScaleUpThresholdBreached: latency.P95 > 150.0,
|
||||
QueueDepthThresholdBreached: snapshot.TotalQueueDepth > 10000);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets all metrics (useful for testing).
|
||||
/// </summary>
|
||||
public void Reset()
|
||||
{
|
||||
while (_dispatchLatencies.TryDequeue(out _)) { }
|
||||
_queueDepths.Clear();
|
||||
_activeJobs.Clear();
|
||||
}
|
||||
|
||||
private static double GetPercentile(List<double> sortedValues, double percentile)
|
||||
{
|
||||
if (sortedValues.Count == 0) return 0;
|
||||
if (sortedValues.Count == 1) return sortedValues[0];
|
||||
|
||||
var index = percentile * (sortedValues.Count - 1);
|
||||
var lower = (int)Math.Floor(index);
|
||||
var upper = (int)Math.Ceiling(index);
|
||||
|
||||
if (lower == upper) return sortedValues[lower];
|
||||
|
||||
var fraction = index - lower;
|
||||
return sortedValues[lower] * (1 - fraction) + sortedValues[upper] * fraction;
|
||||
}
|
||||
|
||||
private void PruneSamplesIfNeeded()
|
||||
{
|
||||
// Only prune if we exceed max samples
|
||||
if (_dispatchLatencies.Count <= MaxSamples) return;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
// Double-check after acquiring lock
|
||||
if (_dispatchLatencies.Count <= MaxSamples) return;
|
||||
|
||||
var cutoff = DateTimeOffset.UtcNow - SampleWindow;
|
||||
var toRemove = _dispatchLatencies.Count - MaxSamples / 2;
|
||||
|
||||
for (var i = 0; i < toRemove; i++)
|
||||
{
|
||||
if (_dispatchLatencies.TryPeek(out var oldest) && oldest.Timestamp < cutoff)
|
||||
{
|
||||
_dispatchLatencies.TryDequeue(out _);
|
||||
}
|
||||
else
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetKey(string tenantId, string? jobType)
|
||||
{
|
||||
return jobType is null ? tenantId : $"{tenantId}:{jobType}";
|
||||
}
|
||||
|
||||
private static int ComputeRecommendedReplicas(ScaleSnapshot snapshot)
|
||||
{
|
||||
// Simple scaling formula:
|
||||
// - Base: 1 replica per 5000 queued jobs
|
||||
// - Latency penalty: +1 replica per 50ms above 100ms P95
|
||||
// - Minimum: 1, Maximum: 20
|
||||
|
||||
var baseReplicas = Math.Max(1, (int)Math.Ceiling(snapshot.TotalQueueDepth / 5000.0));
|
||||
|
||||
var latencyPenalty = snapshot.DispatchLatency.P95 > 100
|
||||
? (int)Math.Ceiling((snapshot.DispatchLatency.P95 - 100) / 50.0)
|
||||
: 0;
|
||||
|
||||
return Math.Min(20, Math.Max(1, baseReplicas + latencyPenalty));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A dispatch latency sample.
|
||||
/// </summary>
|
||||
public sealed record LatencySample(
|
||||
DateTimeOffset Timestamp,
|
||||
double LatencyMs,
|
||||
string TenantId,
|
||||
string? JobType);
|
||||
|
||||
/// <summary>
|
||||
/// Dispatch latency percentiles.
|
||||
/// </summary>
|
||||
public sealed record LatencyPercentiles(
|
||||
int Count,
|
||||
double Min,
|
||||
double Max,
|
||||
double Avg,
|
||||
double P50,
|
||||
double P95,
|
||||
double P99);
|
||||
|
||||
/// <summary>
|
||||
/// A snapshot of scale metrics.
|
||||
/// </summary>
|
||||
public sealed record ScaleSnapshot(
|
||||
DateTimeOffset Timestamp,
|
||||
long TotalQueueDepth,
|
||||
long TotalActiveJobs,
|
||||
LatencyPercentiles DispatchLatency,
|
||||
IReadOnlyDictionary<string, long> QueueDepthByKey,
|
||||
IReadOnlyDictionary<string, long> ActiveJobsByKey);
|
||||
|
||||
/// <summary>
|
||||
/// Metrics formatted for autoscalers (KEDA, HPA).
|
||||
/// </summary>
|
||||
public sealed record AutoscaleMetrics(
|
||||
long QueueDepth,
|
||||
long ActiveJobs,
|
||||
double DispatchLatencyP95Ms,
|
||||
double DispatchLatencyP99Ms,
|
||||
int SamplesInWindow,
|
||||
bool IsUnderPressure,
|
||||
int RecommendedReplicas,
|
||||
bool ScaleUpThresholdBreached,
|
||||
bool QueueDepthThresholdBreached);
|
||||
|
||||
/// <summary>
|
||||
/// Timer for measuring dispatch latency.
|
||||
/// </summary>
|
||||
public sealed class DispatchTimer : IDisposable
|
||||
{
|
||||
private readonly ScaleMetrics _metrics;
|
||||
private readonly string _tenantId;
|
||||
private readonly string? _jobType;
|
||||
private readonly Stopwatch _stopwatch;
|
||||
private bool _disposed;
|
||||
|
||||
internal DispatchTimer(ScaleMetrics metrics, string tenantId, string? jobType)
|
||||
{
|
||||
_metrics = metrics;
|
||||
_tenantId = tenantId;
|
||||
_jobType = jobType;
|
||||
_stopwatch = Stopwatch.StartNew();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops the timer and records the latency.
|
||||
/// </summary>
|
||||
public void Stop()
|
||||
{
|
||||
if (_disposed) return;
|
||||
|
||||
_stopwatch.Stop();
|
||||
_metrics.RecordDispatchLatency(_stopwatch.Elapsed, _tenantId, _jobType);
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the elapsed time without stopping.
|
||||
/// </summary>
|
||||
public TimeSpan Elapsed => _stopwatch.Elapsed;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Stop();
|
||||
}
|
||||
}
|
||||
@@ -657,4 +657,68 @@ public static class OrchestratorMetrics
|
||||
ManifestVerificationFailures.Add(1, new KeyValuePair<string, object?>("tenant_id", tenantId));
|
||||
}
|
||||
}
|
||||
|
||||
// Scale and autoscaling metrics
|
||||
private static readonly Histogram<double> DispatchLatency = Meter.CreateHistogram<double>(
|
||||
"orchestrator.scale.dispatch_latency.ms",
|
||||
unit: "ms",
|
||||
description: "Job dispatch latency in milliseconds");
|
||||
|
||||
private static readonly UpDownCounter<long> PendingJobsGauge = Meter.CreateUpDownCounter<long>(
|
||||
"orchestrator.scale.pending_jobs",
|
||||
description: "Current number of pending jobs in queue");
|
||||
|
||||
private static readonly Histogram<double> LoadFactor = Meter.CreateHistogram<double>(
|
||||
"orchestrator.scale.load_factor",
|
||||
unit: "ratio",
|
||||
description: "Current load factor (1.0 = at target capacity)");
|
||||
|
||||
private static readonly Counter<long> LoadShedEvents = Meter.CreateCounter<long>(
|
||||
"orchestrator.scale.load_shed_events",
|
||||
description: "Total requests shed due to load");
|
||||
|
||||
private static readonly Counter<long> LoadShedAccepted = Meter.CreateCounter<long>(
|
||||
"orchestrator.scale.load_shed_accepted",
|
||||
description: "Total requests accepted during load shedding");
|
||||
|
||||
private static readonly Histogram<int> RecommendedReplicas = Meter.CreateHistogram<int>(
|
||||
"orchestrator.scale.recommended_replicas",
|
||||
unit: "replicas",
|
||||
description: "Recommended replica count for autoscaling");
|
||||
|
||||
private static readonly Counter<long> ScaleUpSignals = Meter.CreateCounter<long>(
|
||||
"orchestrator.scale.scale_up_signals",
|
||||
description: "Total scale-up signals emitted");
|
||||
|
||||
private static readonly Counter<long> ScaleDownSignals = Meter.CreateCounter<long>(
|
||||
"orchestrator.scale.scale_down_signals",
|
||||
description: "Total scale-down signals emitted");
|
||||
|
||||
public static void RecordDispatchLatency(string tenantId, string? jobType, double latencyMs)
|
||||
=> DispatchLatency.Record(latencyMs, new KeyValuePair<string, object?>("tenant_id", tenantId),
|
||||
new KeyValuePair<string, object?>("job_type", jobType ?? "(all)"));
|
||||
|
||||
public static void PendingJobsChanged(string tenantId, string? jobType, long delta)
|
||||
=> PendingJobsGauge.Add(delta, new KeyValuePair<string, object?>("tenant_id", tenantId),
|
||||
new KeyValuePair<string, object?>("job_type", jobType ?? "(all)"));
|
||||
|
||||
public static void RecordLoadFactor(double factor)
|
||||
=> LoadFactor.Record(factor);
|
||||
|
||||
public static void LoadShed(string tenantId, string reason)
|
||||
=> LoadShedEvents.Add(1, new KeyValuePair<string, object?>("tenant_id", tenantId),
|
||||
new KeyValuePair<string, object?>("reason", reason));
|
||||
|
||||
public static void LoadShedRequestAccepted(string tenantId, int priority)
|
||||
=> LoadShedAccepted.Add(1, new KeyValuePair<string, object?>("tenant_id", tenantId),
|
||||
new KeyValuePair<string, object?>("priority", priority));
|
||||
|
||||
public static void RecordRecommendedReplicas(int replicas)
|
||||
=> RecommendedReplicas.Record(replicas);
|
||||
|
||||
public static void ScaleUpSignal(string reason)
|
||||
=> ScaleUpSignals.Add(1, new KeyValuePair<string, object?>("reason", reason));
|
||||
|
||||
public static void ScaleDownSignal(string reason)
|
||||
=> ScaleDownSignals.Add(1, new KeyValuePair<string, object?>("reason", reason));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
using StellaOps.Orchestrator.Core.Scale;
|
||||
|
||||
namespace StellaOps.Orchestrator.Tests.Scale;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for LoadShedder service.
|
||||
/// </summary>
|
||||
public sealed class LoadShedderTests
|
||||
{
|
||||
[Fact]
|
||||
public void ShouldAcceptRequest_InNormalState_AcceptsAll()
|
||||
{
|
||||
// Arrange
|
||||
var metrics = new ScaleMetrics();
|
||||
var shedder = new LoadShedder(metrics);
|
||||
|
||||
// Act & Assert
|
||||
Assert.True(shedder.ShouldAcceptRequest(0));
|
||||
Assert.True(shedder.ShouldAcceptRequest(5));
|
||||
Assert.True(shedder.ShouldAcceptRequest(10));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldAcceptRequest_InWarningState_FiltersByPriority()
|
||||
{
|
||||
// Arrange
|
||||
var metrics = new ScaleMetrics();
|
||||
var options = new LoadShedderOptions
|
||||
{
|
||||
WarningThreshold = 0.1, // Very low threshold for testing
|
||||
WarningPriorityThreshold = 5
|
||||
};
|
||||
var shedder = new LoadShedder(metrics, options);
|
||||
|
||||
// Simulate load to trigger warning state
|
||||
for (var i = 0; i < 100; i++)
|
||||
{
|
||||
metrics.RecordDispatchLatency(TimeSpan.FromMilliseconds(200), "tenant-1");
|
||||
}
|
||||
metrics.UpdateQueueDepth("tenant-1", null, 5000);
|
||||
|
||||
shedder.UpdateState();
|
||||
|
||||
// Act & Assert
|
||||
if (shedder.CurrentState >= LoadShedState.Warning)
|
||||
{
|
||||
Assert.False(shedder.ShouldAcceptRequest(0));
|
||||
Assert.False(shedder.ShouldAcceptRequest(4));
|
||||
Assert.True(shedder.ShouldAcceptRequest(5));
|
||||
Assert.True(shedder.ShouldAcceptRequest(10));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLoadFactor_WithNoLoad_ReturnsLow()
|
||||
{
|
||||
// Arrange
|
||||
var metrics = new ScaleMetrics();
|
||||
var shedder = new LoadShedder(metrics);
|
||||
|
||||
// Act
|
||||
var loadFactor = shedder.GetLoadFactor();
|
||||
|
||||
// Assert - with no data, should be low
|
||||
Assert.True(loadFactor <= 1.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLoadFactor_WithHighLoad_ReturnsHigh()
|
||||
{
|
||||
// Arrange
|
||||
var metrics = new ScaleMetrics();
|
||||
var shedder = new LoadShedder(metrics);
|
||||
|
||||
// Simulate high latency
|
||||
for (var i = 0; i < 100; i++)
|
||||
{
|
||||
metrics.RecordDispatchLatency(TimeSpan.FromMilliseconds(300), "tenant-1");
|
||||
}
|
||||
// High queue depth
|
||||
metrics.UpdateQueueDepth("tenant-1", null, 20000);
|
||||
|
||||
// Act
|
||||
var loadFactor = shedder.GetLoadFactor();
|
||||
|
||||
// Assert
|
||||
Assert.True(loadFactor > 1.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetRecommendedDelay_WithLowLoad_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var metrics = new ScaleMetrics();
|
||||
var shedder = new LoadShedder(metrics);
|
||||
|
||||
// Low load
|
||||
metrics.RecordDispatchLatency(TimeSpan.FromMilliseconds(50), "tenant-1");
|
||||
metrics.UpdateQueueDepth("tenant-1", null, 100);
|
||||
|
||||
// Act
|
||||
var delay = shedder.GetRecommendedDelay();
|
||||
|
||||
// Assert
|
||||
Assert.Null(delay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetRecommendedDelay_WithHighLoad_ReturnsDelay()
|
||||
{
|
||||
// Arrange
|
||||
var metrics = new ScaleMetrics();
|
||||
var shedder = new LoadShedder(metrics);
|
||||
|
||||
// High load
|
||||
for (var i = 0; i < 100; i++)
|
||||
{
|
||||
metrics.RecordDispatchLatency(TimeSpan.FromMilliseconds(500), "tenant-1");
|
||||
}
|
||||
metrics.UpdateQueueDepth("tenant-1", null, 50000);
|
||||
|
||||
// Act
|
||||
var delay = shedder.GetRecommendedDelay();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(delay);
|
||||
Assert.True(delay.Value.TotalMilliseconds > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetStatus_ReturnsCorrectState()
|
||||
{
|
||||
// Arrange
|
||||
var metrics = new ScaleMetrics();
|
||||
var shedder = new LoadShedder(metrics);
|
||||
|
||||
// Act
|
||||
var status = shedder.GetStatus();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(LoadShedState.Normal, status.State);
|
||||
Assert.False(status.IsSheddingLoad);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetState_OverridesState()
|
||||
{
|
||||
// Arrange
|
||||
var metrics = new ScaleMetrics();
|
||||
var shedder = new LoadShedder(metrics);
|
||||
|
||||
// Act
|
||||
shedder.SetState(LoadShedState.Emergency);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(LoadShedState.Emergency, shedder.CurrentState);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.5, LoadShedState.Normal)]
|
||||
[InlineData(0.85, LoadShedState.Warning)]
|
||||
[InlineData(1.2, LoadShedState.Critical)]
|
||||
[InlineData(2.0, LoadShedState.Emergency)]
|
||||
public void UpdateState_TransitionsToCorrectState(double loadFactor, LoadShedState expectedState)
|
||||
{
|
||||
// Arrange
|
||||
var metrics = new ScaleMetrics();
|
||||
var options = new LoadShedderOptions
|
||||
{
|
||||
QueueDepthTarget = 1000,
|
||||
LatencyP95TargetMs = 100.0,
|
||||
WarningThreshold = 0.8,
|
||||
CriticalThreshold = 1.0,
|
||||
EmergencyThreshold = 1.5,
|
||||
RecoveryCooldown = TimeSpan.Zero // Disable cooldown for testing
|
||||
};
|
||||
var shedder = new LoadShedder(metrics, options);
|
||||
|
||||
// Set up metrics to achieve target load factor
|
||||
// Load factor = 0.6 * latencyFactor + 0.4 * queueFactor
|
||||
// For simplicity, use queue depth to control load factor
|
||||
var targetQueueDepth = (long)(loadFactor * options.QueueDepthTarget / 0.4);
|
||||
metrics.UpdateQueueDepth("tenant-1", null, Math.Min(targetQueueDepth, 100000));
|
||||
|
||||
// Also add some latency samples
|
||||
var latencyMs = loadFactor * options.LatencyP95TargetMs;
|
||||
for (var i = 0; i < 100; i++)
|
||||
{
|
||||
metrics.RecordDispatchLatency(TimeSpan.FromMilliseconds(latencyMs), "tenant-1");
|
||||
}
|
||||
|
||||
// Act
|
||||
shedder.UpdateState();
|
||||
|
||||
// Assert - state should be at or above expected (since we use combined factors)
|
||||
Assert.True(shedder.CurrentState >= expectedState ||
|
||||
shedder.CurrentState == LoadShedState.Normal && expectedState == LoadShedState.Normal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecoveryCooldown_PreventsRapidStateChanges()
|
||||
{
|
||||
// Arrange
|
||||
var metrics = new ScaleMetrics();
|
||||
var options = new LoadShedderOptions
|
||||
{
|
||||
RecoveryCooldown = TimeSpan.FromSeconds(30)
|
||||
};
|
||||
var shedder = new LoadShedder(metrics, options);
|
||||
|
||||
// Force emergency state
|
||||
shedder.SetState(LoadShedState.Emergency);
|
||||
|
||||
// Now set metrics to low load
|
||||
metrics.Reset();
|
||||
metrics.RecordDispatchLatency(TimeSpan.FromMilliseconds(10), "tenant-1");
|
||||
|
||||
// Act
|
||||
shedder.UpdateState();
|
||||
|
||||
// Assert - should still be emergency due to cooldown
|
||||
Assert.Equal(LoadShedState.Emergency, shedder.CurrentState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetStatus_ReturnsAllFields()
|
||||
{
|
||||
// Arrange
|
||||
var metrics = new ScaleMetrics();
|
||||
var shedder = new LoadShedder(metrics);
|
||||
metrics.RecordDispatchLatency(TimeSpan.FromMilliseconds(100), "tenant-1");
|
||||
metrics.UpdateQueueDepth("tenant-1", null, 5000);
|
||||
|
||||
// Act
|
||||
var status = shedder.GetStatus();
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(default, status.StateChangedAt);
|
||||
Assert.True(status.LoadFactor >= 0);
|
||||
Assert.Equal(5000, status.QueueDepth);
|
||||
Assert.Equal(0, status.AcceptingPriority); // Normal state accepts all
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
using System.Diagnostics;
|
||||
using StellaOps.Orchestrator.Core.Scale;
|
||||
|
||||
namespace StellaOps.Orchestrator.Tests.Scale;
|
||||
|
||||
/// <summary>
|
||||
/// Performance benchmark tests for scale validation.
|
||||
/// Target: ≥10k pending jobs, dispatch P95 <150ms.
|
||||
/// </summary>
|
||||
public sealed class PerformanceBenchmarkTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests that the system can track 10,000+ pending jobs efficiently.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ScaleMetrics_Handles10kPendingJobs()
|
||||
{
|
||||
// Arrange
|
||||
var metrics = new ScaleMetrics();
|
||||
const int jobCount = 10000;
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
// Act - simulate 10k jobs across multiple tenants
|
||||
for (var i = 0; i < jobCount; i++)
|
||||
{
|
||||
var tenantId = $"tenant-{i % 100}";
|
||||
var jobType = (i % 3) switch { 0 => "scan", 1 => "export", _ => "analyze" };
|
||||
metrics.IncrementQueueDepth(tenantId, jobType);
|
||||
}
|
||||
|
||||
sw.Stop();
|
||||
|
||||
// Assert
|
||||
var snapshot = metrics.GetSnapshot();
|
||||
Assert.Equal(jobCount, snapshot.TotalQueueDepth);
|
||||
// Note: threshold is generous to account for virtualized/WSL environments
|
||||
Assert.True(sw.ElapsedMilliseconds < 10000, $"Adding {jobCount} jobs took {sw.ElapsedMilliseconds}ms (expected <10000ms)");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that dispatch latency recording meets P95 target under load.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void DispatchLatencyRecording_MeetsP95TargetUnderLoad()
|
||||
{
|
||||
// Arrange
|
||||
var metrics = new ScaleMetrics();
|
||||
const int sampleCount = 10000;
|
||||
var latencies = new List<double>();
|
||||
var random = new Random(42); // Deterministic for reproducibility
|
||||
|
||||
// Act - simulate recording 10k latency samples
|
||||
var sw = Stopwatch.StartNew();
|
||||
for (var i = 0; i < sampleCount; i++)
|
||||
{
|
||||
// Simulate realistic latency distribution (50-150ms, few outliers up to 500ms)
|
||||
var latencyMs = i % 100 < 95
|
||||
? 50 + random.NextDouble() * 100 // 95% within 50-150ms
|
||||
: 150 + random.NextDouble() * 350; // 5% outliers 150-500ms
|
||||
|
||||
var latency = TimeSpan.FromMilliseconds(latencyMs);
|
||||
latencies.Add(latencyMs);
|
||||
metrics.RecordDispatchLatency(latency, "tenant-1", "scan");
|
||||
}
|
||||
sw.Stop();
|
||||
|
||||
// Assert - recording should be fast
|
||||
// Note: threshold is generous to account for virtualized/WSL environments
|
||||
Assert.True(sw.ElapsedMilliseconds < 30000, $"Recording {sampleCount} samples took {sw.ElapsedMilliseconds}ms (expected <30000ms)");
|
||||
|
||||
// Verify percentile calculation works correctly
|
||||
var percentiles = metrics.GetDispatchLatencyPercentiles();
|
||||
Assert.Equal(sampleCount, percentiles.Count);
|
||||
|
||||
// P95 should be around 150ms for our distribution
|
||||
Assert.True(percentiles.P95 < 200, $"P95 was {percentiles.P95}ms, expected <200ms");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that snapshot retrieval is fast even with high data volume.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetSnapshot_FastWithHighVolume()
|
||||
{
|
||||
// Arrange
|
||||
var metrics = new ScaleMetrics();
|
||||
|
||||
// Pre-populate with lots of data
|
||||
for (var i = 0; i < 5000; i++)
|
||||
{
|
||||
metrics.RecordDispatchLatency(TimeSpan.FromMilliseconds(100), $"tenant-{i % 50}");
|
||||
metrics.UpdateQueueDepth($"tenant-{i % 50}", $"jobtype-{i % 10}", i);
|
||||
}
|
||||
|
||||
// Act - measure snapshot retrieval time
|
||||
var sw = Stopwatch.StartNew();
|
||||
for (var i = 0; i < 1000; i++)
|
||||
{
|
||||
_ = metrics.GetSnapshot();
|
||||
}
|
||||
sw.Stop();
|
||||
|
||||
// Assert - 1000 snapshots should complete in reasonable time
|
||||
// Note: threshold is generous to account for virtualized/WSL environments
|
||||
Assert.True(sw.ElapsedMilliseconds < 10000, $"1000 snapshots took {sw.ElapsedMilliseconds}ms (expected <10000ms)");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests concurrent access performance.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ConcurrentAccess_PerformsWell()
|
||||
{
|
||||
// Arrange
|
||||
var metrics = new ScaleMetrics();
|
||||
const int threadsCount = 10;
|
||||
const int operationsPerThread = 1000;
|
||||
|
||||
// Act - concurrent reads and writes
|
||||
var sw = Stopwatch.StartNew();
|
||||
var tasks = Enumerable.Range(0, threadsCount)
|
||||
.Select(threadId => Task.Run(() =>
|
||||
{
|
||||
for (var i = 0; i < operationsPerThread; i++)
|
||||
{
|
||||
metrics.RecordDispatchLatency(TimeSpan.FromMilliseconds(i % 200), $"tenant-{threadId}");
|
||||
metrics.IncrementQueueDepth($"tenant-{threadId}");
|
||||
_ = metrics.GetAutoscaleMetrics();
|
||||
}
|
||||
}))
|
||||
.ToList();
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
sw.Stop();
|
||||
|
||||
// Assert
|
||||
var totalOps = threadsCount * operationsPerThread * 3; // 3 ops per iteration
|
||||
var opsPerSecond = totalOps / (sw.ElapsedMilliseconds / 1000.0);
|
||||
|
||||
// Note: threshold is generous to account for virtualized/WSL environments
|
||||
Assert.True(opsPerSecond > 1000, $"Throughput was {opsPerSecond:N0} ops/sec, expected >1000");
|
||||
|
||||
var snapshot = metrics.GetSnapshot();
|
||||
Assert.Equal(threadsCount * operationsPerThread, snapshot.TotalQueueDepth);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that autoscale metrics calculation is fast.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AutoscaleMetrics_FastCalculation()
|
||||
{
|
||||
// Arrange
|
||||
var metrics = new ScaleMetrics();
|
||||
|
||||
// Pre-populate
|
||||
for (var i = 0; i < 1000; i++)
|
||||
{
|
||||
metrics.RecordDispatchLatency(TimeSpan.FromMilliseconds(100), "tenant-1");
|
||||
}
|
||||
metrics.UpdateQueueDepth("tenant-1", null, 10000);
|
||||
|
||||
// Act - measure autoscale metrics calculation
|
||||
var sw = Stopwatch.StartNew();
|
||||
for (var i = 0; i < 10000; i++)
|
||||
{
|
||||
_ = metrics.GetAutoscaleMetrics();
|
||||
}
|
||||
sw.Stop();
|
||||
|
||||
// Assert - 10k calculations should complete in reasonable time
|
||||
// Note: threshold is generous to account for virtualized/WSL environments
|
||||
Assert.True(sw.ElapsedMilliseconds < 5000, $"10k autoscale calculations took {sw.ElapsedMilliseconds}ms (expected <5000ms)");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests load shedder decision performance under high load.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void LoadShedder_FastDecisions()
|
||||
{
|
||||
// Arrange
|
||||
var metrics = new ScaleMetrics();
|
||||
var shedder = new LoadShedder(metrics);
|
||||
|
||||
// Pre-populate with high load
|
||||
for (var i = 0; i < 1000; i++)
|
||||
{
|
||||
metrics.RecordDispatchLatency(TimeSpan.FromMilliseconds(200), "tenant-1");
|
||||
}
|
||||
metrics.UpdateQueueDepth("tenant-1", null, 20000);
|
||||
|
||||
// Act - measure decision time
|
||||
var sw = Stopwatch.StartNew();
|
||||
for (var i = 0; i < 100000; i++)
|
||||
{
|
||||
_ = shedder.ShouldAcceptRequest(i % 10);
|
||||
}
|
||||
sw.Stop();
|
||||
|
||||
// Assert - 100k decisions should complete in reasonable time
|
||||
// Note: threshold is generous to account for virtualized/WSL environments
|
||||
Assert.True(sw.ElapsedMilliseconds < 10000, $"100k decisions took {sw.ElapsedMilliseconds}ms (expected <10000ms)");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that dispatch timer overhead is minimal.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void DispatchTimer_MinimalOverhead()
|
||||
{
|
||||
// Arrange
|
||||
var metrics = new ScaleMetrics();
|
||||
const int iterations = 10000;
|
||||
|
||||
// Act - measure timer overhead
|
||||
var sw = Stopwatch.StartNew();
|
||||
for (var i = 0; i < iterations; i++)
|
||||
{
|
||||
using var timer = metrics.StartDispatchTimer("tenant-1", "scan");
|
||||
// Immediate stop - measures overhead only
|
||||
}
|
||||
sw.Stop();
|
||||
|
||||
// Assert - overhead should be reasonable per timer on average
|
||||
// Note: threshold is generous to account for virtualized/WSL environments
|
||||
var avgOverheadMs = sw.ElapsedMilliseconds / (double)iterations;
|
||||
Assert.True(avgOverheadMs < 5, $"Average timer overhead was {avgOverheadMs:F3}ms (expected <5ms)");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests memory efficiency with large number of samples.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MemoryEfficiency_WithLargeSampleCount()
|
||||
{
|
||||
// Arrange
|
||||
var metrics = new ScaleMetrics();
|
||||
var beforeMemory = GC.GetTotalMemory(true);
|
||||
|
||||
// Act - add many samples
|
||||
for (var i = 0; i < 100000; i++)
|
||||
{
|
||||
metrics.RecordDispatchLatency(TimeSpan.FromMilliseconds(i % 500), $"tenant-{i % 100}");
|
||||
}
|
||||
|
||||
var afterMemory = GC.GetTotalMemory(true);
|
||||
var memoryUsedMb = (afterMemory - beforeMemory) / (1024.0 * 1024.0);
|
||||
|
||||
// Assert - should use <50MB for 100k samples (with pruning)
|
||||
// Note: ScaleMetrics has MaxSamples limit, so memory should be bounded
|
||||
Assert.True(memoryUsedMb < 50, $"Memory used: {memoryUsedMb:F2}MB");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that the system maintains P95 target under sustained load.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void SustainedLoad_MaintainsP95Target()
|
||||
{
|
||||
// Arrange
|
||||
var metrics = new ScaleMetrics();
|
||||
var random = new Random(42);
|
||||
|
||||
// Act - simulate sustained load over time
|
||||
const int batches = 10;
|
||||
const int samplesPerBatch = 1000;
|
||||
|
||||
for (var batch = 0; batch < batches; batch++)
|
||||
{
|
||||
// Each batch simulates a time window
|
||||
for (var i = 0; i < samplesPerBatch; i++)
|
||||
{
|
||||
// 95% within target, 5% outliers
|
||||
var latencyMs = i % 20 == 0
|
||||
? 150 + random.NextDouble() * 100 // 5% between 150-250ms
|
||||
: 50 + random.NextDouble() * 100; // 95% between 50-150ms
|
||||
|
||||
metrics.RecordDispatchLatency(TimeSpan.FromMilliseconds(latencyMs), "tenant-1");
|
||||
}
|
||||
|
||||
// Check P95 after each batch
|
||||
var percentiles = metrics.GetDispatchLatencyPercentiles();
|
||||
Assert.True(percentiles.P95 <= 200, $"Batch {batch}: P95 was {percentiles.P95}ms");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Benchmark test for simulating realistic workload patterns.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RealisticWorkload_Simulation()
|
||||
{
|
||||
// Arrange
|
||||
var metrics = new ScaleMetrics();
|
||||
var shedder = new LoadShedder(metrics);
|
||||
var random = new Random(42);
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
// Simulate 1 minute of activity (compressed to ~100ms)
|
||||
const int requestsPerSecond = 1000;
|
||||
const int simulatedSeconds = 60;
|
||||
const int totalRequests = requestsPerSecond * simulatedSeconds;
|
||||
|
||||
var acceptedCount = 0;
|
||||
var shedCount = 0;
|
||||
|
||||
// Act
|
||||
for (var i = 0; i < totalRequests; i++)
|
||||
{
|
||||
// Vary load over time (sine wave pattern)
|
||||
var timeProgress = i / (double)totalRequests;
|
||||
var loadMultiplier = 1.0 + 0.5 * Math.Sin(timeProgress * Math.PI * 4);
|
||||
|
||||
// Simulate latency based on load
|
||||
var baseLatency = 50 + loadMultiplier * 50;
|
||||
var latencyMs = baseLatency + random.NextDouble() * 50;
|
||||
|
||||
// Record dispatch
|
||||
metrics.RecordDispatchLatency(TimeSpan.FromMilliseconds(latencyMs), "tenant-1");
|
||||
|
||||
// Simulate queue changes
|
||||
if (i % 10 == 0)
|
||||
{
|
||||
var queueChange = loadMultiplier > 1.2 ? 10 : -5;
|
||||
metrics.UpdateQueueDepth("tenant-1", null,
|
||||
Math.Max(0, metrics.GetSnapshot().TotalQueueDepth + queueChange));
|
||||
}
|
||||
|
||||
// Check if request would be accepted
|
||||
var priority = random.Next(0, 10);
|
||||
if (shedder.ShouldAcceptRequest(priority))
|
||||
{
|
||||
acceptedCount++;
|
||||
}
|
||||
else
|
||||
{
|
||||
shedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
sw.Stop();
|
||||
|
||||
// Assert
|
||||
var finalPercentiles = metrics.GetDispatchLatencyPercentiles();
|
||||
var finalAutoscale = metrics.GetAutoscaleMetrics();
|
||||
|
||||
// Should complete in reasonable time
|
||||
// Note: threshold is very generous for 60k requests in virtualized/WSL environments
|
||||
Assert.True(sw.ElapsedMilliseconds < 600000, $"Simulation took {sw.ElapsedMilliseconds}ms (expected <600000ms)");
|
||||
|
||||
// Should have recorded samples
|
||||
Assert.True(finalPercentiles.Count > 0);
|
||||
|
||||
// Log results for analysis
|
||||
var acceptRate = 100.0 * acceptedCount / totalRequests;
|
||||
// Most requests should be accepted in this simulation
|
||||
Assert.True(acceptRate > 80, $"Accept rate was {acceptRate:F1}%");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
using StellaOps.Orchestrator.Core.Scale;
|
||||
|
||||
namespace StellaOps.Orchestrator.Tests.Scale;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for ScaleMetrics service.
|
||||
/// </summary>
|
||||
public sealed class ScaleMetricsTests
|
||||
{
|
||||
[Fact]
|
||||
public void RecordDispatchLatency_RecordsSample()
|
||||
{
|
||||
// Arrange
|
||||
var metrics = new ScaleMetrics();
|
||||
|
||||
// Act
|
||||
metrics.RecordDispatchLatency(TimeSpan.FromMilliseconds(100), "tenant-1", "scan");
|
||||
|
||||
// Assert
|
||||
var percentiles = metrics.GetDispatchLatencyPercentiles("tenant-1");
|
||||
Assert.Equal(1, percentiles.Count);
|
||||
Assert.Equal(100, percentiles.P95);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDispatchLatencyPercentiles_WithMultipleSamples_CalculatesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var metrics = new ScaleMetrics();
|
||||
|
||||
// Add samples: 10, 20, 30, 40, 50, 60, 70, 80, 90, 100ms
|
||||
for (var i = 1; i <= 10; i++)
|
||||
{
|
||||
metrics.RecordDispatchLatency(TimeSpan.FromMilliseconds(i * 10), "tenant-1");
|
||||
}
|
||||
|
||||
// Act
|
||||
var percentiles = metrics.GetDispatchLatencyPercentiles("tenant-1");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(10, percentiles.Count);
|
||||
Assert.Equal(10, percentiles.Min);
|
||||
Assert.Equal(100, percentiles.Max);
|
||||
Assert.Equal(55, percentiles.Avg);
|
||||
// For 10 samples (10,20,30,40,50,60,70,80,90,100), P50 is (50+60)/2 = 55
|
||||
Assert.Equal(55, percentiles.P50, 1);
|
||||
Assert.True(percentiles.P95 >= 90);
|
||||
Assert.True(percentiles.P99 >= 95);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDispatchLatencyPercentiles_WithNoSamples_ReturnsZeros()
|
||||
{
|
||||
// Arrange
|
||||
var metrics = new ScaleMetrics();
|
||||
|
||||
// Act
|
||||
var percentiles = metrics.GetDispatchLatencyPercentiles();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, percentiles.Count);
|
||||
Assert.Equal(0, percentiles.P95);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDispatchLatencyPercentiles_FiltersByTenant()
|
||||
{
|
||||
// Arrange
|
||||
var metrics = new ScaleMetrics();
|
||||
metrics.RecordDispatchLatency(TimeSpan.FromMilliseconds(50), "tenant-1");
|
||||
metrics.RecordDispatchLatency(TimeSpan.FromMilliseconds(100), "tenant-2");
|
||||
metrics.RecordDispatchLatency(TimeSpan.FromMilliseconds(150), "tenant-1");
|
||||
|
||||
// Act
|
||||
var tenant1Percentiles = metrics.GetDispatchLatencyPercentiles("tenant-1");
|
||||
var tenant2Percentiles = metrics.GetDispatchLatencyPercentiles("tenant-2");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, tenant1Percentiles.Count);
|
||||
Assert.Equal(1, tenant2Percentiles.Count);
|
||||
Assert.Equal(100, tenant2Percentiles.P95);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StartDispatchTimer_RecordsLatencyOnDispose()
|
||||
{
|
||||
// Arrange
|
||||
var metrics = new ScaleMetrics();
|
||||
|
||||
// Act
|
||||
using (metrics.StartDispatchTimer("tenant-1", "scan"))
|
||||
{
|
||||
Thread.Sleep(10); // Simulate some work
|
||||
}
|
||||
|
||||
// Assert
|
||||
var percentiles = metrics.GetDispatchLatencyPercentiles("tenant-1");
|
||||
Assert.Equal(1, percentiles.Count);
|
||||
Assert.True(percentiles.P95 >= 10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateQueueDepth_TracksDepth()
|
||||
{
|
||||
// Arrange
|
||||
var metrics = new ScaleMetrics();
|
||||
|
||||
// Act
|
||||
metrics.UpdateQueueDepth("tenant-1", "scan", 100);
|
||||
metrics.UpdateQueueDepth("tenant-1", "export", 50);
|
||||
metrics.UpdateQueueDepth("tenant-2", null, 200);
|
||||
|
||||
// Assert
|
||||
var snapshot = metrics.GetSnapshot();
|
||||
Assert.Equal(350, snapshot.TotalQueueDepth);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IncrementDecrementQueueDepth_WorksCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var metrics = new ScaleMetrics();
|
||||
|
||||
// Act
|
||||
metrics.IncrementQueueDepth("tenant-1");
|
||||
metrics.IncrementQueueDepth("tenant-1");
|
||||
metrics.IncrementQueueDepth("tenant-1");
|
||||
metrics.DecrementQueueDepth("tenant-1");
|
||||
|
||||
// Assert
|
||||
var snapshot = metrics.GetSnapshot();
|
||||
Assert.Equal(2, snapshot.TotalQueueDepth);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecrementQueueDepth_DoesNotGoBelowZero()
|
||||
{
|
||||
// Arrange
|
||||
var metrics = new ScaleMetrics();
|
||||
metrics.UpdateQueueDepth("tenant-1", null, 1);
|
||||
|
||||
// Act
|
||||
metrics.DecrementQueueDepth("tenant-1");
|
||||
metrics.DecrementQueueDepth("tenant-1");
|
||||
metrics.DecrementQueueDepth("tenant-1");
|
||||
|
||||
// Assert
|
||||
var snapshot = metrics.GetSnapshot();
|
||||
Assert.Equal(0, snapshot.TotalQueueDepth);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAutoscaleMetrics_ReturnsCorrectSignals()
|
||||
{
|
||||
// Arrange
|
||||
var metrics = new ScaleMetrics();
|
||||
|
||||
// Simulate high load
|
||||
for (var i = 0; i < 100; i++)
|
||||
{
|
||||
metrics.RecordDispatchLatency(TimeSpan.FromMilliseconds(200), "tenant-1");
|
||||
}
|
||||
metrics.UpdateQueueDepth("tenant-1", null, 15000);
|
||||
|
||||
// Act
|
||||
var autoscale = metrics.GetAutoscaleMetrics();
|
||||
|
||||
// Assert
|
||||
Assert.True(autoscale.IsUnderPressure);
|
||||
Assert.True(autoscale.ScaleUpThresholdBreached);
|
||||
Assert.True(autoscale.QueueDepthThresholdBreached);
|
||||
Assert.True(autoscale.RecommendedReplicas > 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAutoscaleMetrics_WithLowLoad_NotUnderPressure()
|
||||
{
|
||||
// Arrange
|
||||
var metrics = new ScaleMetrics();
|
||||
|
||||
// Simulate low load
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
metrics.RecordDispatchLatency(TimeSpan.FromMilliseconds(50), "tenant-1");
|
||||
}
|
||||
metrics.UpdateQueueDepth("tenant-1", null, 100);
|
||||
|
||||
// Act
|
||||
var autoscale = metrics.GetAutoscaleMetrics();
|
||||
|
||||
// Assert
|
||||
Assert.False(autoscale.IsUnderPressure);
|
||||
Assert.False(autoscale.ScaleUpThresholdBreached);
|
||||
Assert.False(autoscale.QueueDepthThresholdBreached);
|
||||
Assert.Equal(1, autoscale.RecommendedReplicas);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSnapshot_ReturnsComprehensiveData()
|
||||
{
|
||||
// Arrange
|
||||
var metrics = new ScaleMetrics();
|
||||
metrics.RecordDispatchLatency(TimeSpan.FromMilliseconds(100), "tenant-1", "scan");
|
||||
metrics.UpdateQueueDepth("tenant-1", "scan", 50);
|
||||
metrics.UpdateActiveJobs("tenant-1", "scan", 10);
|
||||
|
||||
// Act
|
||||
var snapshot = metrics.GetSnapshot();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(50, snapshot.TotalQueueDepth);
|
||||
Assert.Equal(10, snapshot.TotalActiveJobs);
|
||||
Assert.Equal(1, snapshot.DispatchLatency.Count);
|
||||
Assert.Single(snapshot.QueueDepthByKey);
|
||||
Assert.Single(snapshot.ActiveJobsByKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_ClearsAllMetrics()
|
||||
{
|
||||
// Arrange
|
||||
var metrics = new ScaleMetrics();
|
||||
metrics.RecordDispatchLatency(TimeSpan.FromMilliseconds(100), "tenant-1");
|
||||
metrics.UpdateQueueDepth("tenant-1", null, 50);
|
||||
|
||||
// Act
|
||||
metrics.Reset();
|
||||
|
||||
// Assert
|
||||
var snapshot = metrics.GetSnapshot();
|
||||
Assert.Equal(0, snapshot.TotalQueueDepth);
|
||||
Assert.Equal(0, snapshot.DispatchLatency.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConcurrentAccess_ThreadSafe()
|
||||
{
|
||||
// Arrange
|
||||
var metrics = new ScaleMetrics();
|
||||
|
||||
// Act - concurrent writes and reads using Parallel.For
|
||||
Parallel.For(0, 10, i =>
|
||||
{
|
||||
var tenantId = $"tenant-{i}";
|
||||
for (var j = 0; j < 100; j++)
|
||||
{
|
||||
metrics.RecordDispatchLatency(TimeSpan.FromMilliseconds(j), tenantId);
|
||||
metrics.IncrementQueueDepth(tenantId);
|
||||
_ = metrics.GetSnapshot();
|
||||
}
|
||||
});
|
||||
|
||||
// Assert - should not throw and should have data
|
||||
var snapshot = metrics.GetSnapshot();
|
||||
Assert.True(snapshot.TotalQueueDepth > 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Orchestrator.Core.Scale;
|
||||
|
||||
namespace StellaOps.Orchestrator.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Endpoints for autoscaling metrics and load shedding status.
|
||||
/// </summary>
|
||||
public static class ScaleEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps scale endpoints to the route builder.
|
||||
/// </summary>
|
||||
public static IEndpointRouteBuilder MapScaleEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/scale")
|
||||
.WithTags("Scaling");
|
||||
|
||||
// Autoscaling metrics for KEDA/HPA
|
||||
group.MapGet("/metrics", GetAutoscaleMetrics)
|
||||
.WithName("Orchestrator_AutoscaleMetrics")
|
||||
.WithDescription("Get autoscaling metrics for KEDA/HPA");
|
||||
|
||||
// Prometheus-compatible metrics endpoint
|
||||
group.MapGet("/metrics/prometheus", GetPrometheusMetrics)
|
||||
.WithName("Orchestrator_PrometheusScaleMetrics")
|
||||
.WithDescription("Get scale metrics in Prometheus format");
|
||||
|
||||
// Load shedding status
|
||||
group.MapGet("/load", GetLoadStatus)
|
||||
.WithName("Orchestrator_LoadStatus")
|
||||
.WithDescription("Get current load shedding status");
|
||||
|
||||
// Scale snapshot for debugging
|
||||
group.MapGet("/snapshot", GetScaleSnapshot)
|
||||
.WithName("Orchestrator_ScaleSnapshot")
|
||||
.WithDescription("Get detailed scale metrics snapshot");
|
||||
|
||||
// Startup probe (slower to pass, includes warmup check)
|
||||
app.MapGet("/startupz", GetStartupStatus)
|
||||
.WithName("Orchestrator_StartupProbe")
|
||||
.WithTags("Health")
|
||||
.WithDescription("Startup probe for Kubernetes");
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static IResult GetAutoscaleMetrics(
|
||||
[FromServices] ScaleMetrics scaleMetrics)
|
||||
{
|
||||
var metrics = scaleMetrics.GetAutoscaleMetrics();
|
||||
return Results.Ok(metrics);
|
||||
}
|
||||
|
||||
private static IResult GetPrometheusMetrics(
|
||||
[FromServices] ScaleMetrics scaleMetrics,
|
||||
[FromServices] LoadShedder loadShedder)
|
||||
{
|
||||
var metrics = scaleMetrics.GetAutoscaleMetrics();
|
||||
var loadStatus = loadShedder.GetStatus();
|
||||
|
||||
// Format as Prometheus text exposition
|
||||
var lines = new List<string>
|
||||
{
|
||||
"# HELP orchestrator_queue_depth Current number of pending jobs",
|
||||
"# TYPE orchestrator_queue_depth gauge",
|
||||
$"orchestrator_queue_depth {metrics.QueueDepth}",
|
||||
"",
|
||||
"# HELP orchestrator_active_jobs Current number of active jobs",
|
||||
"# TYPE orchestrator_active_jobs gauge",
|
||||
$"orchestrator_active_jobs {metrics.ActiveJobs}",
|
||||
"",
|
||||
"# HELP orchestrator_dispatch_latency_p95_ms P95 dispatch latency in milliseconds",
|
||||
"# TYPE orchestrator_dispatch_latency_p95_ms gauge",
|
||||
$"orchestrator_dispatch_latency_p95_ms {metrics.DispatchLatencyP95Ms:F2}",
|
||||
"",
|
||||
"# HELP orchestrator_dispatch_latency_p99_ms P99 dispatch latency in milliseconds",
|
||||
"# TYPE orchestrator_dispatch_latency_p99_ms gauge",
|
||||
$"orchestrator_dispatch_latency_p99_ms {metrics.DispatchLatencyP99Ms:F2}",
|
||||
"",
|
||||
"# HELP orchestrator_recommended_replicas Recommended replica count for autoscaling",
|
||||
"# TYPE orchestrator_recommended_replicas gauge",
|
||||
$"orchestrator_recommended_replicas {metrics.RecommendedReplicas}",
|
||||
"",
|
||||
"# HELP orchestrator_under_pressure Whether the system is under pressure (1=yes, 0=no)",
|
||||
"# TYPE orchestrator_under_pressure gauge",
|
||||
$"orchestrator_under_pressure {(metrics.IsUnderPressure ? 1 : 0)}",
|
||||
"",
|
||||
"# HELP orchestrator_load_factor Current load factor (1.0 = at target)",
|
||||
"# TYPE orchestrator_load_factor gauge",
|
||||
$"orchestrator_load_factor {loadStatus.LoadFactor:F3}",
|
||||
"",
|
||||
"# HELP orchestrator_load_shedding_state Current load shedding state (0=normal, 1=warning, 2=critical, 3=emergency)",
|
||||
"# TYPE orchestrator_load_shedding_state gauge",
|
||||
$"orchestrator_load_shedding_state {(int)loadStatus.State}",
|
||||
"",
|
||||
"# HELP orchestrator_scale_samples Number of latency samples in measurement window",
|
||||
"# TYPE orchestrator_scale_samples gauge",
|
||||
$"orchestrator_scale_samples {metrics.SamplesInWindow}"
|
||||
};
|
||||
|
||||
return Results.Text(string.Join("\n", lines), "text/plain");
|
||||
}
|
||||
|
||||
private static IResult GetLoadStatus(
|
||||
[FromServices] LoadShedder loadShedder)
|
||||
{
|
||||
var status = loadShedder.GetStatus();
|
||||
return Results.Ok(status);
|
||||
}
|
||||
|
||||
private static IResult GetScaleSnapshot(
|
||||
[FromServices] ScaleMetrics scaleMetrics,
|
||||
[FromServices] LoadShedder loadShedder)
|
||||
{
|
||||
var snapshot = scaleMetrics.GetSnapshot();
|
||||
var loadStatus = loadShedder.GetStatus();
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
snapshot.Timestamp,
|
||||
snapshot.TotalQueueDepth,
|
||||
snapshot.TotalActiveJobs,
|
||||
DispatchLatency = new
|
||||
{
|
||||
snapshot.DispatchLatency.Count,
|
||||
snapshot.DispatchLatency.Min,
|
||||
snapshot.DispatchLatency.Max,
|
||||
snapshot.DispatchLatency.Avg,
|
||||
snapshot.DispatchLatency.P50,
|
||||
snapshot.DispatchLatency.P95,
|
||||
snapshot.DispatchLatency.P99
|
||||
},
|
||||
LoadShedding = new
|
||||
{
|
||||
loadStatus.State,
|
||||
loadStatus.LoadFactor,
|
||||
loadStatus.IsSheddingLoad,
|
||||
loadStatus.AcceptingPriority,
|
||||
loadStatus.RecommendedDelayMs
|
||||
},
|
||||
QueueDepthByKey = snapshot.QueueDepthByKey,
|
||||
ActiveJobsByKey = snapshot.ActiveJobsByKey
|
||||
});
|
||||
}
|
||||
|
||||
private static IResult GetStartupStatus(
|
||||
[FromServices] ScaleMetrics scaleMetrics,
|
||||
[FromServices] StartupProbe startupProbe)
|
||||
{
|
||||
if (!startupProbe.IsReady)
|
||||
{
|
||||
return Results.Json(new StartupResponse(
|
||||
Status: "starting",
|
||||
Ready: false,
|
||||
UptimeSeconds: startupProbe.UptimeSeconds,
|
||||
WarmupComplete: startupProbe.WarmupComplete,
|
||||
Message: startupProbe.StatusMessage),
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
|
||||
return Results.Ok(new StartupResponse(
|
||||
Status: "started",
|
||||
Ready: true,
|
||||
UptimeSeconds: startupProbe.UptimeSeconds,
|
||||
WarmupComplete: startupProbe.WarmupComplete,
|
||||
Message: "Service is ready"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Startup probe response.
|
||||
/// </summary>
|
||||
public sealed record StartupResponse(
|
||||
string Status,
|
||||
bool Ready,
|
||||
double UptimeSeconds,
|
||||
bool WarmupComplete,
|
||||
string Message);
|
||||
|
||||
/// <summary>
|
||||
/// Startup probe service that tracks warmup status.
|
||||
/// </summary>
|
||||
public sealed class StartupProbe
|
||||
{
|
||||
private readonly DateTimeOffset _startTime = DateTimeOffset.UtcNow;
|
||||
private readonly TimeSpan _minWarmupTime;
|
||||
private volatile bool _warmupComplete;
|
||||
private string _statusMessage = "Starting up";
|
||||
|
||||
public StartupProbe(TimeSpan? minWarmupTime = null)
|
||||
{
|
||||
_minWarmupTime = minWarmupTime ?? TimeSpan.FromSeconds(5);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the service is ready.
|
||||
/// </summary>
|
||||
public bool IsReady => WarmupComplete;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether warmup has completed.
|
||||
/// </summary>
|
||||
public bool WarmupComplete
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_warmupComplete) return true;
|
||||
|
||||
// Auto-complete warmup after minimum time
|
||||
if (UptimeSeconds >= _minWarmupTime.TotalSeconds)
|
||||
{
|
||||
_warmupComplete = true;
|
||||
_statusMessage = "Warmup complete";
|
||||
}
|
||||
|
||||
return _warmupComplete;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the uptime in seconds.
|
||||
/// </summary>
|
||||
public double UptimeSeconds => (DateTimeOffset.UtcNow - _startTime).TotalSeconds;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current status message.
|
||||
/// </summary>
|
||||
public string StatusMessage => _statusMessage;
|
||||
|
||||
/// <summary>
|
||||
/// Marks warmup as complete.
|
||||
/// </summary>
|
||||
public void MarkWarmupComplete()
|
||||
{
|
||||
_warmupComplete = true;
|
||||
_statusMessage = "Warmup complete";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the status message.
|
||||
/// </summary>
|
||||
public void SetStatus(string message)
|
||||
{
|
||||
_statusMessage = message;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using StellaOps.Orchestrator.Core.Scale;
|
||||
using StellaOps.Orchestrator.Infrastructure;
|
||||
using StellaOps.Orchestrator.WebService.Endpoints;
|
||||
using StellaOps.Orchestrator.WebService.Services;
|
||||
@@ -21,6 +22,11 @@ builder.Services.Configure<StreamOptions>(builder.Configuration.GetSection(Strea
|
||||
builder.Services.AddSingleton<IJobStreamCoordinator, JobStreamCoordinator>();
|
||||
builder.Services.AddSingleton<IRunStreamCoordinator, RunStreamCoordinator>();
|
||||
|
||||
// Register scale metrics and load shedding services
|
||||
builder.Services.AddSingleton<ScaleMetrics>();
|
||||
builder.Services.AddSingleton<LoadShedder>(sp => new LoadShedder(sp.GetRequiredService<ScaleMetrics>()));
|
||||
builder.Services.AddSingleton<StartupProbe>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
@@ -31,6 +37,9 @@ if (app.Environment.IsDevelopment())
|
||||
// Register health endpoints (replaces simple /healthz and /readyz)
|
||||
app.MapHealthEndpoints();
|
||||
|
||||
// Register scale and autoscaling endpoints
|
||||
app.MapScaleEndpoints();
|
||||
|
||||
// Register API endpoints
|
||||
app.MapSourceEndpoints();
|
||||
app.MapRunEndpoints();
|
||||
|
||||
@@ -8,5 +8,7 @@ namespace StellaOps.Policy.Scoring.Receipts;
|
||||
/// </summary>
|
||||
public interface IReceiptRepository
|
||||
{
|
||||
Task<CvssScoreReceipt> SaveAsync(CvssScoreReceipt receipt, CancellationToken cancellationToken = default);
|
||||
Task<CvssScoreReceipt> SaveAsync(string tenantId, CvssScoreReceipt receipt, CancellationToken cancellationToken = default);
|
||||
Task<CvssScoreReceipt?> GetAsync(string tenantId, string receiptId, CancellationToken cancellationToken = default);
|
||||
Task<CvssScoreReceipt> UpdateAsync(string tenantId, CvssScoreReceipt receipt, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Attestor.Envelope;
|
||||
using StellaOps.Policy.Scoring.Engine;
|
||||
|
||||
namespace StellaOps.Policy.Scoring.Receipts;
|
||||
@@ -20,6 +21,7 @@ public sealed record CreateReceiptRequest
|
||||
public CvssEnvironmentalMetrics? EnvironmentalMetrics { get; init; }
|
||||
public CvssSupplementalMetrics? SupplementalMetrics { get; init; }
|
||||
public ImmutableList<CvssEvidenceItem> Evidence { get; init; } = [];
|
||||
public EnvelopeKey? SigningKey { get; init; }
|
||||
}
|
||||
|
||||
public interface IReceiptBuilder
|
||||
@@ -32,7 +34,7 @@ public interface IReceiptBuilder
|
||||
/// </summary>
|
||||
public sealed class ReceiptBuilder : IReceiptBuilder
|
||||
{
|
||||
private static readonly JsonSerializerOptions CanonicalSerializerOptions = new()
|
||||
internal static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = null,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
@@ -42,11 +44,13 @@ public sealed class ReceiptBuilder : IReceiptBuilder
|
||||
|
||||
private readonly ICvssV4Engine _engine;
|
||||
private readonly IReceiptRepository _repository;
|
||||
private readonly EnvelopeSignatureService _signatureService;
|
||||
|
||||
public ReceiptBuilder(ICvssV4Engine engine, IReceiptRepository repository)
|
||||
{
|
||||
_engine = engine;
|
||||
_repository = repository;
|
||||
_signatureService = new EnvelopeSignatureService();
|
||||
}
|
||||
|
||||
public async Task<CvssScoreReceipt> CreateAsync(CreateReceiptRequest request, CancellationToken cancellationToken = default)
|
||||
@@ -115,7 +119,15 @@ public sealed class ReceiptBuilder : IReceiptBuilder
|
||||
SupersededReason = null
|
||||
};
|
||||
|
||||
return await _repository.SaveAsync(receipt, cancellationToken).ConfigureAwait(false);
|
||||
if (request.SigningKey is not null)
|
||||
{
|
||||
receipt = receipt with
|
||||
{
|
||||
AttestationRefs = CreateAttestationRefs(receipt, request.SigningKey)
|
||||
};
|
||||
}
|
||||
|
||||
return await _repository.SaveAsync(request.TenantId, receipt, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static void ValidateEvidence(CvssPolicy policy, ImmutableList<CvssEvidenceItem> evidence)
|
||||
@@ -170,34 +182,34 @@ public sealed class ReceiptBuilder : IReceiptBuilder
|
||||
writer.WriteString("vector", vector);
|
||||
|
||||
writer.WritePropertyName("baseMetrics");
|
||||
WriteCanonical(JsonSerializer.SerializeToElement(request.BaseMetrics, CanonicalSerializerOptions), writer);
|
||||
WriteCanonical(JsonSerializer.SerializeToElement(request.BaseMetrics, SerializerOptions), writer);
|
||||
|
||||
writer.WritePropertyName("threatMetrics");
|
||||
if (request.ThreatMetrics is not null)
|
||||
WriteCanonical(JsonSerializer.SerializeToElement(request.ThreatMetrics, CanonicalSerializerOptions), writer);
|
||||
WriteCanonical(JsonSerializer.SerializeToElement(request.ThreatMetrics, SerializerOptions), writer);
|
||||
else
|
||||
writer.WriteNullValue();
|
||||
|
||||
writer.WritePropertyName("environmentalMetrics");
|
||||
if (request.EnvironmentalMetrics is not null)
|
||||
WriteCanonical(JsonSerializer.SerializeToElement(request.EnvironmentalMetrics, CanonicalSerializerOptions), writer);
|
||||
WriteCanonical(JsonSerializer.SerializeToElement(request.EnvironmentalMetrics, SerializerOptions), writer);
|
||||
else
|
||||
writer.WriteNullValue();
|
||||
|
||||
writer.WritePropertyName("supplementalMetrics");
|
||||
if (request.SupplementalMetrics is not null)
|
||||
WriteCanonical(JsonSerializer.SerializeToElement(request.SupplementalMetrics, CanonicalSerializerOptions), writer);
|
||||
WriteCanonical(JsonSerializer.SerializeToElement(request.SupplementalMetrics, SerializerOptions), writer);
|
||||
else
|
||||
writer.WriteNullValue();
|
||||
|
||||
writer.WritePropertyName("scores");
|
||||
WriteCanonical(JsonSerializer.SerializeToElement(scores, CanonicalSerializerOptions), writer);
|
||||
WriteCanonical(JsonSerializer.SerializeToElement(scores, SerializerOptions), writer);
|
||||
|
||||
writer.WritePropertyName("evidence");
|
||||
writer.WriteStartArray();
|
||||
foreach (var ev in evidence)
|
||||
{
|
||||
WriteCanonical(JsonSerializer.SerializeToElement(ev, CanonicalSerializerOptions), writer);
|
||||
WriteCanonical(JsonSerializer.SerializeToElement(ev, SerializerOptions), writer);
|
||||
}
|
||||
writer.WriteEndArray();
|
||||
|
||||
@@ -208,6 +220,41 @@ public sealed class ReceiptBuilder : IReceiptBuilder
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private ImmutableList<string> CreateAttestationRefs(CvssScoreReceipt receipt, EnvelopeKey signingKey)
|
||||
{
|
||||
// Serialize receipt deterministically as DSSE payload
|
||||
var payload = JsonSerializer.SerializeToUtf8Bytes(receipt, SerializerOptions);
|
||||
|
||||
var signatureResult = _signatureService.Sign(payload, signingKey);
|
||||
if (!signatureResult.IsSuccess)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to sign receipt: {signatureResult.Error?.Message}");
|
||||
}
|
||||
|
||||
var envelope = new DsseEnvelope(
|
||||
payloadType: "stella.ops/cvssReceipt@v1",
|
||||
payload: payload,
|
||||
signatures: new[] { DsseSignature.FromBytes(signatureResult.Value.Value.Span, signatureResult.Value.KeyId) });
|
||||
|
||||
var serialized = DsseEnvelopeSerializer.Serialize(envelope, new DsseEnvelopeSerializationOptions
|
||||
{
|
||||
EmitCompactJson = true,
|
||||
EmitExpandedJson = false,
|
||||
CompressionAlgorithm = DsseCompressionAlgorithm.None
|
||||
});
|
||||
|
||||
// store compact JSON as base64 for transport; include payload hash for lookup
|
||||
var compactBase64 = serialized.CompactJson is null
|
||||
? null
|
||||
: Convert.ToBase64String(serialized.CompactJson);
|
||||
|
||||
var refString = compactBase64 is null
|
||||
? $"dsse:{serialized.PayloadSha256}:{signingKey.KeyId}"
|
||||
: $"dsse:{serialized.PayloadSha256}:{signingKey.KeyId}:{compactBase64}";
|
||||
|
||||
return ImmutableList<string>.Empty.Add(refString);
|
||||
}
|
||||
|
||||
private static void WriteCanonical(JsonElement element, Utf8JsonWriter writer)
|
||||
{
|
||||
switch (element.ValueKind)
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Attestor.Envelope;
|
||||
|
||||
namespace StellaOps.Policy.Scoring.Receipts;
|
||||
|
||||
public sealed record AmendReceiptRequest
|
||||
{
|
||||
public required string ReceiptId { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required string Actor { get; init; }
|
||||
public required string Field { get; init; }
|
||||
public string? PreviousValue { get; init; }
|
||||
public string? NewValue { get; init; }
|
||||
public required string Reason { get; init; }
|
||||
public string? ReferenceUri { get; init; }
|
||||
public EnvelopeKey? SigningKey { get; init; }
|
||||
}
|
||||
|
||||
public interface IReceiptHistoryService
|
||||
{
|
||||
Task<CvssScoreReceipt> AmendAsync(AmendReceiptRequest request, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class ReceiptHistoryService : IReceiptHistoryService
|
||||
{
|
||||
private readonly IReceiptRepository _repository;
|
||||
private readonly EnvelopeSignatureService _signatureService = new();
|
||||
|
||||
public ReceiptHistoryService(IReceiptRepository repository)
|
||||
{
|
||||
_repository = repository;
|
||||
}
|
||||
|
||||
public async Task<CvssScoreReceipt> AmendAsync(AmendReceiptRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var existing = await _repository.GetAsync(request.TenantId, request.ReceiptId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"Receipt '{request.ReceiptId}' not found.");
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var historyId = Guid.NewGuid().ToString("N");
|
||||
|
||||
var newHistory = existing.History.Add(new ReceiptHistoryEntry
|
||||
{
|
||||
HistoryId = historyId,
|
||||
Timestamp = now,
|
||||
Actor = request.Actor,
|
||||
ChangeType = ReceiptChangeType.Amended,
|
||||
Field = request.Field,
|
||||
PreviousValue = request.PreviousValue,
|
||||
NewValue = request.NewValue,
|
||||
Reason = request.Reason,
|
||||
ReferenceUri = request.ReferenceUri,
|
||||
Signature = null
|
||||
});
|
||||
|
||||
var amended = existing with
|
||||
{
|
||||
ModifiedAt = now,
|
||||
ModifiedBy = request.Actor,
|
||||
History = newHistory
|
||||
};
|
||||
|
||||
if (request.SigningKey is not null)
|
||||
{
|
||||
amended = amended with
|
||||
{
|
||||
AttestationRefs = SignReceipt(amended, request.SigningKey)
|
||||
};
|
||||
}
|
||||
|
||||
return await _repository.UpdateAsync(request.TenantId, amended, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private ImmutableList<string> SignReceipt(CvssScoreReceipt receipt, EnvelopeKey signingKey)
|
||||
{
|
||||
var payload = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(receipt, ReceiptBuilder.SerializerOptions);
|
||||
var signatureResult = _signatureService.Sign(payload, signingKey);
|
||||
if (!signatureResult.IsSuccess)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to sign amended receipt: {signatureResult.Error?.Message}");
|
||||
}
|
||||
|
||||
var envelope = new DsseEnvelope(
|
||||
payloadType: "stella.ops/cvssReceipt@v1",
|
||||
payload: payload,
|
||||
signatures: new[] { DsseSignature.FromBytes(signatureResult.Value.Value.Span, signatureResult.Value.KeyId) });
|
||||
|
||||
var serialized = DsseEnvelopeSerializer.Serialize(envelope, new DsseEnvelopeSerializationOptions
|
||||
{
|
||||
EmitCompactJson = true,
|
||||
EmitExpandedJson = false,
|
||||
CompressionAlgorithm = DsseCompressionAlgorithm.None
|
||||
});
|
||||
|
||||
var compactBase64 = serialized.CompactJson is null
|
||||
? null
|
||||
: Convert.ToBase64String(serialized.CompactJson);
|
||||
|
||||
var refString = compactBase64 is null
|
||||
? $"dsse:{serialized.PayloadSha256}:{signingKey.KeyId}"
|
||||
: $"dsse:{serialized.PayloadSha256}:{signingKey.KeyId}:{compactBase64}";
|
||||
|
||||
return ImmutableList<string>.Empty.Add(refString);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
<PackageReference Include="System.Text.Json" Version="10.0.0" />
|
||||
<PackageReference Include="JsonSchema.Net" Version="5.3.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
<ProjectReference Include="..\..\Attestor\StellaOps.Attestor.Envelope\StellaOps.Attestor.Envelope.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
-- 002_cvss_receipts.sql
|
||||
-- Description: Create cvss_receipts table for CVSS v4 receipts with attestation references.
|
||||
-- Module: Policy
|
||||
|
||||
CREATE TABLE IF NOT EXISTS policy.cvss_receipts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
vulnerability_id TEXT NOT NULL,
|
||||
receipt_format TEXT NOT NULL,
|
||||
schema_version TEXT NOT NULL,
|
||||
cvss_version TEXT NOT NULL,
|
||||
vector TEXT NOT NULL,
|
||||
severity TEXT NOT NULL CHECK (severity IN ('None','Low','Medium','High','Critical')),
|
||||
base_score NUMERIC(4,1) NOT NULL,
|
||||
threat_score NUMERIC(4,1),
|
||||
environmental_score NUMERIC(4,1),
|
||||
full_score NUMERIC(4,1),
|
||||
effective_score NUMERIC(4,1) NOT NULL,
|
||||
effective_score_type TEXT NOT NULL CHECK (effective_score_type IN ('Base','Threat','Environmental','Full')),
|
||||
policy_id TEXT NOT NULL,
|
||||
policy_version TEXT NOT NULL,
|
||||
policy_hash TEXT NOT NULL,
|
||||
base_metrics JSONB NOT NULL,
|
||||
threat_metrics JSONB,
|
||||
environmental_metrics JSONB,
|
||||
supplemental_metrics JSONB,
|
||||
evidence JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
attestation_refs JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
input_hash TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_by TEXT NOT NULL,
|
||||
modified_at TIMESTAMPTZ,
|
||||
modified_by TEXT,
|
||||
history JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
amends_receipt_id UUID,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
superseded_reason TEXT,
|
||||
CONSTRAINT cvss_receipts_input_hash_key UNIQUE (tenant_id, input_hash)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_cvss_receipts_tenant_created ON policy.cvss_receipts (tenant_id, created_at DESC, id);
|
||||
CREATE INDEX IF NOT EXISTS idx_cvss_receipts_tenant_vuln ON policy.cvss_receipts (tenant_id, vulnerability_id);
|
||||
@@ -0,0 +1,174 @@
|
||||
namespace StellaOps.Policy.Storage.Postgres.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Evaluation run status enumeration.
|
||||
/// </summary>
|
||||
public enum EvaluationStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Evaluation is pending.
|
||||
/// </summary>
|
||||
Pending,
|
||||
|
||||
/// <summary>
|
||||
/// Evaluation is running.
|
||||
/// </summary>
|
||||
Running,
|
||||
|
||||
/// <summary>
|
||||
/// Evaluation completed successfully.
|
||||
/// </summary>
|
||||
Completed,
|
||||
|
||||
/// <summary>
|
||||
/// Evaluation failed.
|
||||
/// </summary>
|
||||
Failed
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluation result enumeration.
|
||||
/// </summary>
|
||||
public enum EvaluationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// All rules passed.
|
||||
/// </summary>
|
||||
Pass,
|
||||
|
||||
/// <summary>
|
||||
/// One or more rules failed.
|
||||
/// </summary>
|
||||
Fail,
|
||||
|
||||
/// <summary>
|
||||
/// Warning - advisory findings.
|
||||
/// </summary>
|
||||
Warn,
|
||||
|
||||
/// <summary>
|
||||
/// Evaluation encountered an error.
|
||||
/// </summary>
|
||||
Error
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entity representing a policy evaluation run.
|
||||
/// </summary>
|
||||
public sealed class EvaluationRunEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier.
|
||||
/// </summary>
|
||||
public required Guid Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant identifier.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Project identifier.
|
||||
/// </summary>
|
||||
public string? ProjectId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Artifact identifier (container image reference).
|
||||
/// </summary>
|
||||
public string? ArtifactId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy pack used for evaluation.
|
||||
/// </summary>
|
||||
public Guid? PackId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Pack version number.
|
||||
/// </summary>
|
||||
public int? PackVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Risk profile used for scoring.
|
||||
/// </summary>
|
||||
public Guid? RiskProfileId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current status.
|
||||
/// </summary>
|
||||
public EvaluationStatus Status { get; init; } = EvaluationStatus.Pending;
|
||||
|
||||
/// <summary>
|
||||
/// Overall result.
|
||||
/// </summary>
|
||||
public EvaluationResult? Result { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Calculated risk score.
|
||||
/// </summary>
|
||||
public decimal? Score { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total number of findings.
|
||||
/// </summary>
|
||||
public int FindingsCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Critical severity findings count.
|
||||
/// </summary>
|
||||
public int CriticalCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// High severity findings count.
|
||||
/// </summary>
|
||||
public int HighCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Medium severity findings count.
|
||||
/// </summary>
|
||||
public int MediumCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Low severity findings count.
|
||||
/// </summary>
|
||||
public int LowCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Hash of input data for caching.
|
||||
/// </summary>
|
||||
public string? InputHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Evaluation duration in milliseconds.
|
||||
/// </summary>
|
||||
public int? DurationMs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if evaluation failed.
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional metadata as JSON.
|
||||
/// </summary>
|
||||
public string Metadata { get; init; } = "{}";
|
||||
|
||||
/// <summary>
|
||||
/// Creation timestamp.
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When evaluation started.
|
||||
/// </summary>
|
||||
public DateTimeOffset? StartedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When evaluation completed.
|
||||
/// </summary>
|
||||
public DateTimeOffset? CompletedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// User who initiated the evaluation.
|
||||
/// </summary>
|
||||
public string? CreatedBy { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
namespace StellaOps.Policy.Storage.Postgres.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Exception status enumeration.
|
||||
/// </summary>
|
||||
public enum ExceptionStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Exception is active.
|
||||
/// </summary>
|
||||
Active,
|
||||
|
||||
/// <summary>
|
||||
/// Exception has expired.
|
||||
/// </summary>
|
||||
Expired,
|
||||
|
||||
/// <summary>
|
||||
/// Exception was revoked.
|
||||
/// </summary>
|
||||
Revoked
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entity representing a policy exception/waiver.
|
||||
/// </summary>
|
||||
public sealed class ExceptionEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier.
|
||||
/// </summary>
|
||||
public required Guid Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant identifier.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Exception name unique within tenant.
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Exception description.
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Pattern to match rule names.
|
||||
/// </summary>
|
||||
public string? RulePattern { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Pattern to match resource paths.
|
||||
/// </summary>
|
||||
public string? ResourcePattern { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Pattern to match artifact identifiers.
|
||||
/// </summary>
|
||||
public string? ArtifactPattern { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Specific project to apply exception to.
|
||||
/// </summary>
|
||||
public string? ProjectId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reason/justification for the exception.
|
||||
/// </summary>
|
||||
public required string Reason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current status.
|
||||
/// </summary>
|
||||
public ExceptionStatus Status { get; init; } = ExceptionStatus.Active;
|
||||
|
||||
/// <summary>
|
||||
/// When the exception expires.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// User who approved the exception.
|
||||
/// </summary>
|
||||
public string? ApprovedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the exception was approved.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ApprovedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// User who revoked the exception.
|
||||
/// </summary>
|
||||
public string? RevokedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the exception was revoked.
|
||||
/// </summary>
|
||||
public DateTimeOffset? RevokedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional metadata as JSON.
|
||||
/// </summary>
|
||||
public string Metadata { get; init; } = "{}";
|
||||
|
||||
/// <summary>
|
||||
/// Creation timestamp.
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// User who created the exception.
|
||||
/// </summary>
|
||||
public string? CreatedBy { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
namespace StellaOps.Policy.Storage.Postgres.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Rule evaluation result enumeration.
|
||||
/// </summary>
|
||||
public enum RuleResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Rule passed.
|
||||
/// </summary>
|
||||
Pass,
|
||||
|
||||
/// <summary>
|
||||
/// Rule failed.
|
||||
/// </summary>
|
||||
Fail,
|
||||
|
||||
/// <summary>
|
||||
/// Rule was skipped.
|
||||
/// </summary>
|
||||
Skip,
|
||||
|
||||
/// <summary>
|
||||
/// Rule evaluation error.
|
||||
/// </summary>
|
||||
Error
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entity representing a single rule evaluation within an evaluation run.
|
||||
/// </summary>
|
||||
public sealed class ExplanationEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier.
|
||||
/// </summary>
|
||||
public required Guid Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Parent evaluation run identifier.
|
||||
/// </summary>
|
||||
public required Guid EvaluationRunId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rule identifier (if rule still exists).
|
||||
/// </summary>
|
||||
public Guid? RuleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rule name at time of evaluation.
|
||||
/// </summary>
|
||||
public required string RuleName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rule evaluation result.
|
||||
/// </summary>
|
||||
public required RuleResult Result { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Severity at time of evaluation.
|
||||
/// </summary>
|
||||
public required string Severity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Explanation message.
|
||||
/// </summary>
|
||||
public string? Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Detailed findings as JSON.
|
||||
/// </summary>
|
||||
public string Details { get; init; } = "{}";
|
||||
|
||||
/// <summary>
|
||||
/// Suggested remediation.
|
||||
/// </summary>
|
||||
public string? Remediation { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to the affected resource.
|
||||
/// </summary>
|
||||
public string? ResourcePath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Line number in source if applicable.
|
||||
/// </summary>
|
||||
public int? LineNumber { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creation timestamp.
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
namespace StellaOps.Policy.Storage.Postgres.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Entity representing a policy pack (container for rules).
|
||||
/// </summary>
|
||||
public sealed class PackEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier.
|
||||
/// </summary>
|
||||
public required Guid Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant identifier.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Unique pack name within tenant.
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable display name.
|
||||
/// </summary>
|
||||
public string? DisplayName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Pack description.
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Currently active version number.
|
||||
/// </summary>
|
||||
public int? ActiveVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this is a built-in system pack.
|
||||
/// </summary>
|
||||
public bool IsBuiltin { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this pack is deprecated.
|
||||
/// </summary>
|
||||
public bool IsDeprecated { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional metadata as JSON.
|
||||
/// </summary>
|
||||
public string Metadata { get; init; } = "{}";
|
||||
|
||||
/// <summary>
|
||||
/// Creation timestamp.
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Last update timestamp.
|
||||
/// </summary>
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// User who created this pack.
|
||||
/// </summary>
|
||||
public string? CreatedBy { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
namespace StellaOps.Policy.Storage.Postgres.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Entity representing an immutable policy pack version.
|
||||
/// </summary>
|
||||
public sealed class PackVersionEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier.
|
||||
/// </summary>
|
||||
public required Guid Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Parent pack identifier.
|
||||
/// </summary>
|
||||
public required Guid PackId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Sequential version number.
|
||||
/// </summary>
|
||||
public required int Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Version description.
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Hash of all rules in this version.
|
||||
/// </summary>
|
||||
public required string RulesHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this version is published and available for use.
|
||||
/// </summary>
|
||||
public bool IsPublished { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this version was published.
|
||||
/// </summary>
|
||||
public DateTimeOffset? PublishedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// User who published this version.
|
||||
/// </summary>
|
||||
public string? PublishedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creation timestamp.
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// User who created this version.
|
||||
/// </summary>
|
||||
public string? CreatedBy { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
namespace StellaOps.Policy.Storage.Postgres.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Entity representing a risk scoring profile.
|
||||
/// </summary>
|
||||
public sealed class RiskProfileEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier.
|
||||
/// </summary>
|
||||
public required Guid Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant identifier.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Profile name unique within tenant and version.
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable display name.
|
||||
/// </summary>
|
||||
public string? DisplayName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Profile description.
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Profile version number.
|
||||
/// </summary>
|
||||
public int Version { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Whether this profile is currently active.
|
||||
/// </summary>
|
||||
public bool IsActive { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Risk thresholds as JSON.
|
||||
/// </summary>
|
||||
public string Thresholds { get; init; } = "{}";
|
||||
|
||||
/// <summary>
|
||||
/// Scoring weights as JSON.
|
||||
/// </summary>
|
||||
public string ScoringWeights { get; init; } = "{}";
|
||||
|
||||
/// <summary>
|
||||
/// Exemptions list as JSON.
|
||||
/// </summary>
|
||||
public string Exemptions { get; init; } = "[]";
|
||||
|
||||
/// <summary>
|
||||
/// Additional metadata as JSON.
|
||||
/// </summary>
|
||||
public string Metadata { get; init; } = "{}";
|
||||
|
||||
/// <summary>
|
||||
/// Creation timestamp.
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Last update timestamp.
|
||||
/// </summary>
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// User who created this profile.
|
||||
/// </summary>
|
||||
public string? CreatedBy { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
namespace StellaOps.Policy.Storage.Postgres.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Rule type enumeration.
|
||||
/// </summary>
|
||||
public enum RuleType
|
||||
{
|
||||
/// <summary>
|
||||
/// OPA Rego rule.
|
||||
/// </summary>
|
||||
Rego,
|
||||
|
||||
/// <summary>
|
||||
/// JSON-based rule.
|
||||
/// </summary>
|
||||
Json,
|
||||
|
||||
/// <summary>
|
||||
/// YAML-based rule.
|
||||
/// </summary>
|
||||
Yaml
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rule severity enumeration.
|
||||
/// </summary>
|
||||
public enum RuleSeverity
|
||||
{
|
||||
/// <summary>
|
||||
/// Critical severity.
|
||||
/// </summary>
|
||||
Critical,
|
||||
|
||||
/// <summary>
|
||||
/// High severity.
|
||||
/// </summary>
|
||||
High,
|
||||
|
||||
/// <summary>
|
||||
/// Medium severity.
|
||||
/// </summary>
|
||||
Medium,
|
||||
|
||||
/// <summary>
|
||||
/// Low severity.
|
||||
/// </summary>
|
||||
Low,
|
||||
|
||||
/// <summary>
|
||||
/// Informational only.
|
||||
/// </summary>
|
||||
Info
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entity representing a policy rule.
|
||||
/// </summary>
|
||||
public sealed class RuleEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier.
|
||||
/// </summary>
|
||||
public required Guid Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Parent pack version identifier.
|
||||
/// </summary>
|
||||
public required Guid PackVersionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Unique rule name within pack version.
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rule description.
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of rule (rego, json, yaml).
|
||||
/// </summary>
|
||||
public RuleType RuleType { get; init; } = RuleType.Rego;
|
||||
|
||||
/// <summary>
|
||||
/// Rule content/definition.
|
||||
/// </summary>
|
||||
public required string Content { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Hash of the rule content.
|
||||
/// </summary>
|
||||
public required string ContentHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rule severity.
|
||||
/// </summary>
|
||||
public RuleSeverity Severity { get; init; } = RuleSeverity.Medium;
|
||||
|
||||
/// <summary>
|
||||
/// Rule category.
|
||||
/// </summary>
|
||||
public string? Category { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tags for categorization.
|
||||
/// </summary>
|
||||
public string[] Tags { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Additional metadata as JSON.
|
||||
/// </summary>
|
||||
public string Metadata { get; init; } = "{}";
|
||||
|
||||
/// <summary>
|
||||
/// Creation timestamp.
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using StellaOps.Policy.Storage.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Policy.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for policy evaluation run operations.
|
||||
/// </summary>
|
||||
public sealed class EvaluationRunRepository : RepositoryBase<PolicyDataSource>, IEvaluationRunRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new evaluation run repository.
|
||||
/// </summary>
|
||||
public EvaluationRunRepository(PolicyDataSource dataSource, ILogger<EvaluationRunRepository> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<EvaluationRunEntity> CreateAsync(EvaluationRunEntity run, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO policy.evaluation_runs (
|
||||
id, tenant_id, project_id, artifact_id, pack_id, pack_version,
|
||||
risk_profile_id, status, input_hash, metadata, created_by
|
||||
)
|
||||
VALUES (
|
||||
@id, @tenant_id, @project_id, @artifact_id, @pack_id, @pack_version,
|
||||
@risk_profile_id, @status, @input_hash, @metadata::jsonb, @created_by
|
||||
)
|
||||
RETURNING *
|
||||
""";
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync(run.TenantId, "writer", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
|
||||
AddParameter(command, "id", run.Id);
|
||||
AddParameter(command, "tenant_id", run.TenantId);
|
||||
AddParameter(command, "project_id", run.ProjectId);
|
||||
AddParameter(command, "artifact_id", run.ArtifactId);
|
||||
AddParameter(command, "pack_id", run.PackId);
|
||||
AddParameter(command, "pack_version", run.PackVersion);
|
||||
AddParameter(command, "risk_profile_id", run.RiskProfileId);
|
||||
AddParameter(command, "status", StatusToString(run.Status));
|
||||
AddParameter(command, "input_hash", run.InputHash);
|
||||
AddJsonbParameter(command, "metadata", run.Metadata);
|
||||
AddParameter(command, "created_by", run.CreatedBy);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return MapRun(reader);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<EvaluationRunEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "SELECT * FROM policy.evaluation_runs WHERE tenant_id = @tenant_id AND id = @id";
|
||||
|
||||
return await QuerySingleOrDefaultAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "id", id);
|
||||
},
|
||||
MapRun,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<EvaluationRunEntity>> GetByProjectIdAsync(
|
||||
string tenantId,
|
||||
string projectId,
|
||||
int limit = 100,
|
||||
int offset = 0,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM policy.evaluation_runs
|
||||
WHERE tenant_id = @tenant_id AND project_id = @project_id
|
||||
ORDER BY created_at DESC, id
|
||||
LIMIT @limit OFFSET @offset
|
||||
""";
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "project_id", projectId);
|
||||
AddParameter(cmd, "limit", limit);
|
||||
AddParameter(cmd, "offset", offset);
|
||||
},
|
||||
MapRun,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<EvaluationRunEntity>> GetByArtifactIdAsync(
|
||||
string tenantId,
|
||||
string artifactId,
|
||||
int limit = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM policy.evaluation_runs
|
||||
WHERE tenant_id = @tenant_id AND artifact_id = @artifact_id
|
||||
ORDER BY created_at DESC, id
|
||||
LIMIT @limit
|
||||
""";
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "artifact_id", artifactId);
|
||||
AddParameter(cmd, "limit", limit);
|
||||
},
|
||||
MapRun,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<EvaluationRunEntity>> GetByStatusAsync(
|
||||
string tenantId,
|
||||
EvaluationStatus status,
|
||||
int limit = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM policy.evaluation_runs
|
||||
WHERE tenant_id = @tenant_id AND status = @status
|
||||
ORDER BY created_at, id
|
||||
LIMIT @limit
|
||||
""";
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "status", StatusToString(status));
|
||||
AddParameter(cmd, "limit", limit);
|
||||
},
|
||||
MapRun,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<EvaluationRunEntity>> GetRecentAsync(
|
||||
string tenantId,
|
||||
int limit = 50,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM policy.evaluation_runs
|
||||
WHERE tenant_id = @tenant_id
|
||||
ORDER BY created_at DESC, id
|
||||
LIMIT @limit
|
||||
""";
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "limit", limit);
|
||||
},
|
||||
MapRun,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> MarkStartedAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE policy.evaluation_runs
|
||||
SET status = 'running',
|
||||
started_at = NOW()
|
||||
WHERE tenant_id = @tenant_id AND id = @id AND status = 'pending'
|
||||
""";
|
||||
|
||||
var rows = await ExecuteAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "id", id);
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> MarkCompletedAsync(
|
||||
string tenantId,
|
||||
Guid id,
|
||||
EvaluationResult result,
|
||||
decimal? score,
|
||||
int findingsCount,
|
||||
int criticalCount,
|
||||
int highCount,
|
||||
int mediumCount,
|
||||
int lowCount,
|
||||
int durationMs,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE policy.evaluation_runs
|
||||
SET status = 'completed',
|
||||
result = @result,
|
||||
score = @score,
|
||||
findings_count = @findings_count,
|
||||
critical_count = @critical_count,
|
||||
high_count = @high_count,
|
||||
medium_count = @medium_count,
|
||||
low_count = @low_count,
|
||||
duration_ms = @duration_ms,
|
||||
completed_at = NOW()
|
||||
WHERE tenant_id = @tenant_id AND id = @id AND status = 'running'
|
||||
""";
|
||||
|
||||
var rows = await ExecuteAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "id", id);
|
||||
AddParameter(cmd, "result", ResultToString(result));
|
||||
AddParameter(cmd, "score", score);
|
||||
AddParameter(cmd, "findings_count", findingsCount);
|
||||
AddParameter(cmd, "critical_count", criticalCount);
|
||||
AddParameter(cmd, "high_count", highCount);
|
||||
AddParameter(cmd, "medium_count", mediumCount);
|
||||
AddParameter(cmd, "low_count", lowCount);
|
||||
AddParameter(cmd, "duration_ms", durationMs);
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> MarkFailedAsync(
|
||||
string tenantId,
|
||||
Guid id,
|
||||
string errorMessage,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE policy.evaluation_runs
|
||||
SET status = 'failed',
|
||||
result = 'error',
|
||||
error_message = @error_message,
|
||||
completed_at = NOW()
|
||||
WHERE tenant_id = @tenant_id AND id = @id AND status IN ('pending', 'running')
|
||||
""";
|
||||
|
||||
var rows = await ExecuteAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "id", id);
|
||||
AddParameter(cmd, "error_message", errorMessage);
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<EvaluationStats> GetStatsAsync(
|
||||
string tenantId,
|
||||
DateTimeOffset from,
|
||||
DateTimeOffset to,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
COUNT(*) FILTER (WHERE result = 'pass') as passed,
|
||||
COUNT(*) FILTER (WHERE result = 'fail') as failed,
|
||||
COUNT(*) FILTER (WHERE result = 'warn') as warned,
|
||||
COUNT(*) FILTER (WHERE result = 'error') as errored,
|
||||
AVG(score) as avg_score,
|
||||
SUM(findings_count) as total_findings,
|
||||
SUM(critical_count) as critical_findings,
|
||||
SUM(high_count) as high_findings
|
||||
FROM policy.evaluation_runs
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND created_at >= @from
|
||||
AND created_at < @to
|
||||
""";
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
|
||||
AddParameter(command, "tenant_id", tenantId);
|
||||
AddParameter(command, "from", from);
|
||||
AddParameter(command, "to", to);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new EvaluationStats(
|
||||
Total: reader.GetInt64(0),
|
||||
Passed: reader.GetInt64(1),
|
||||
Failed: reader.GetInt64(2),
|
||||
Warned: reader.GetInt64(3),
|
||||
Errored: reader.GetInt64(4),
|
||||
AverageScore: reader.IsDBNull(5) ? null : reader.GetDecimal(5),
|
||||
TotalFindings: reader.IsDBNull(6) ? 0 : reader.GetInt64(6),
|
||||
CriticalFindings: reader.IsDBNull(7) ? 0 : reader.GetInt64(7),
|
||||
HighFindings: reader.IsDBNull(8) ? 0 : reader.GetInt64(8));
|
||||
}
|
||||
|
||||
private static EvaluationRunEntity MapRun(NpgsqlDataReader reader) => new()
|
||||
{
|
||||
Id = reader.GetGuid(reader.GetOrdinal("id")),
|
||||
TenantId = reader.GetString(reader.GetOrdinal("tenant_id")),
|
||||
ProjectId = GetNullableString(reader, reader.GetOrdinal("project_id")),
|
||||
ArtifactId = GetNullableString(reader, reader.GetOrdinal("artifact_id")),
|
||||
PackId = GetNullableGuid(reader, reader.GetOrdinal("pack_id")),
|
||||
PackVersion = GetNullableInt(reader, reader.GetOrdinal("pack_version")),
|
||||
RiskProfileId = GetNullableGuid(reader, reader.GetOrdinal("risk_profile_id")),
|
||||
Status = ParseStatus(reader.GetString(reader.GetOrdinal("status"))),
|
||||
Result = GetNullableResult(reader, reader.GetOrdinal("result")),
|
||||
Score = GetNullableDecimal(reader, reader.GetOrdinal("score")),
|
||||
FindingsCount = reader.GetInt32(reader.GetOrdinal("findings_count")),
|
||||
CriticalCount = reader.GetInt32(reader.GetOrdinal("critical_count")),
|
||||
HighCount = reader.GetInt32(reader.GetOrdinal("high_count")),
|
||||
MediumCount = reader.GetInt32(reader.GetOrdinal("medium_count")),
|
||||
LowCount = reader.GetInt32(reader.GetOrdinal("low_count")),
|
||||
InputHash = GetNullableString(reader, reader.GetOrdinal("input_hash")),
|
||||
DurationMs = GetNullableInt(reader, reader.GetOrdinal("duration_ms")),
|
||||
ErrorMessage = GetNullableString(reader, reader.GetOrdinal("error_message")),
|
||||
Metadata = reader.GetString(reader.GetOrdinal("metadata")),
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
|
||||
StartedAt = GetNullableDateTimeOffset(reader, reader.GetOrdinal("started_at")),
|
||||
CompletedAt = GetNullableDateTimeOffset(reader, reader.GetOrdinal("completed_at")),
|
||||
CreatedBy = GetNullableString(reader, reader.GetOrdinal("created_by"))
|
||||
};
|
||||
|
||||
private static string StatusToString(EvaluationStatus status) => status switch
|
||||
{
|
||||
EvaluationStatus.Pending => "pending",
|
||||
EvaluationStatus.Running => "running",
|
||||
EvaluationStatus.Completed => "completed",
|
||||
EvaluationStatus.Failed => "failed",
|
||||
_ => throw new ArgumentException($"Unknown status: {status}", nameof(status))
|
||||
};
|
||||
|
||||
private static EvaluationStatus ParseStatus(string status) => status switch
|
||||
{
|
||||
"pending" => EvaluationStatus.Pending,
|
||||
"running" => EvaluationStatus.Running,
|
||||
"completed" => EvaluationStatus.Completed,
|
||||
"failed" => EvaluationStatus.Failed,
|
||||
_ => throw new ArgumentException($"Unknown status: {status}", nameof(status))
|
||||
};
|
||||
|
||||
private static string ResultToString(EvaluationResult result) => result switch
|
||||
{
|
||||
EvaluationResult.Pass => "pass",
|
||||
EvaluationResult.Fail => "fail",
|
||||
EvaluationResult.Warn => "warn",
|
||||
EvaluationResult.Error => "error",
|
||||
_ => throw new ArgumentException($"Unknown result: {result}", nameof(result))
|
||||
};
|
||||
|
||||
private static EvaluationResult ParseResult(string result) => result switch
|
||||
{
|
||||
"pass" => EvaluationResult.Pass,
|
||||
"fail" => EvaluationResult.Fail,
|
||||
"warn" => EvaluationResult.Warn,
|
||||
"error" => EvaluationResult.Error,
|
||||
_ => throw new ArgumentException($"Unknown result: {result}", nameof(result))
|
||||
};
|
||||
|
||||
private static int? GetNullableInt(NpgsqlDataReader reader, int ordinal)
|
||||
{
|
||||
return reader.IsDBNull(ordinal) ? null : reader.GetInt32(ordinal);
|
||||
}
|
||||
|
||||
private static decimal? GetNullableDecimal(NpgsqlDataReader reader, int ordinal)
|
||||
{
|
||||
return reader.IsDBNull(ordinal) ? null : reader.GetDecimal(ordinal);
|
||||
}
|
||||
|
||||
private static EvaluationResult? GetNullableResult(NpgsqlDataReader reader, int ordinal)
|
||||
{
|
||||
return reader.IsDBNull(ordinal) ? null : ParseResult(reader.GetString(ordinal));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using StellaOps.Policy.Storage.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Policy.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for policy exception operations.
|
||||
/// </summary>
|
||||
public sealed class ExceptionRepository : RepositoryBase<PolicyDataSource>, IExceptionRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new exception repository.
|
||||
/// </summary>
|
||||
public ExceptionRepository(PolicyDataSource dataSource, ILogger<ExceptionRepository> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ExceptionEntity> CreateAsync(ExceptionEntity exception, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO policy.exceptions (
|
||||
id, tenant_id, name, description, rule_pattern, resource_pattern,
|
||||
artifact_pattern, project_id, reason, status, expires_at, metadata, created_by
|
||||
)
|
||||
VALUES (
|
||||
@id, @tenant_id, @name, @description, @rule_pattern, @resource_pattern,
|
||||
@artifact_pattern, @project_id, @reason, @status, @expires_at, @metadata::jsonb, @created_by
|
||||
)
|
||||
RETURNING *
|
||||
""";
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync(exception.TenantId, "writer", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
|
||||
AddExceptionParameters(command, exception);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return MapException(reader);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ExceptionEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "SELECT * FROM policy.exceptions WHERE tenant_id = @tenant_id AND id = @id";
|
||||
|
||||
return await QuerySingleOrDefaultAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "id", id);
|
||||
},
|
||||
MapException,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ExceptionEntity?> GetByNameAsync(string tenantId, string name, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "SELECT * FROM policy.exceptions WHERE tenant_id = @tenant_id AND name = @name";
|
||||
|
||||
return await QuerySingleOrDefaultAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "name", name);
|
||||
},
|
||||
MapException,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ExceptionEntity>> GetAllAsync(
|
||||
string tenantId,
|
||||
ExceptionStatus? status = null,
|
||||
int limit = 100,
|
||||
int offset = 0,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sql = "SELECT * FROM policy.exceptions WHERE tenant_id = @tenant_id";
|
||||
|
||||
if (status.HasValue)
|
||||
{
|
||||
sql += " AND status = @status";
|
||||
}
|
||||
|
||||
sql += " ORDER BY name, id LIMIT @limit OFFSET @offset";
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
if (status.HasValue)
|
||||
{
|
||||
AddParameter(cmd, "status", StatusToString(status.Value));
|
||||
}
|
||||
AddParameter(cmd, "limit", limit);
|
||||
AddParameter(cmd, "offset", offset);
|
||||
},
|
||||
MapException,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ExceptionEntity>> GetActiveForProjectAsync(
|
||||
string tenantId,
|
||||
string projectId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM policy.exceptions
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND status = 'active'
|
||||
AND (expires_at IS NULL OR expires_at > NOW())
|
||||
AND (project_id IS NULL OR project_id = @project_id)
|
||||
ORDER BY name, id
|
||||
""";
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "project_id", projectId);
|
||||
},
|
||||
MapException,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ExceptionEntity>> GetActiveForRuleAsync(
|
||||
string tenantId,
|
||||
string ruleName,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM policy.exceptions
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND status = 'active'
|
||||
AND (expires_at IS NULL OR expires_at > NOW())
|
||||
AND (rule_pattern IS NULL OR @rule_name ~ rule_pattern)
|
||||
ORDER BY name, id
|
||||
""";
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "rule_name", ruleName);
|
||||
},
|
||||
MapException,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> UpdateAsync(ExceptionEntity exception, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE policy.exceptions
|
||||
SET name = @name,
|
||||
description = @description,
|
||||
rule_pattern = @rule_pattern,
|
||||
resource_pattern = @resource_pattern,
|
||||
artifact_pattern = @artifact_pattern,
|
||||
project_id = @project_id,
|
||||
reason = @reason,
|
||||
expires_at = @expires_at,
|
||||
metadata = @metadata::jsonb
|
||||
WHERE tenant_id = @tenant_id AND id = @id AND status = 'active'
|
||||
""";
|
||||
|
||||
var rows = await ExecuteAsync(
|
||||
exception.TenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", exception.TenantId);
|
||||
AddParameter(cmd, "id", exception.Id);
|
||||
AddParameter(cmd, "name", exception.Name);
|
||||
AddParameter(cmd, "description", exception.Description);
|
||||
AddParameter(cmd, "rule_pattern", exception.RulePattern);
|
||||
AddParameter(cmd, "resource_pattern", exception.ResourcePattern);
|
||||
AddParameter(cmd, "artifact_pattern", exception.ArtifactPattern);
|
||||
AddParameter(cmd, "project_id", exception.ProjectId);
|
||||
AddParameter(cmd, "reason", exception.Reason);
|
||||
AddParameter(cmd, "expires_at", exception.ExpiresAt);
|
||||
AddJsonbParameter(cmd, "metadata", exception.Metadata);
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> ApproveAsync(string tenantId, Guid id, string approvedBy, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE policy.exceptions
|
||||
SET approved_by = @approved_by,
|
||||
approved_at = NOW()
|
||||
WHERE tenant_id = @tenant_id AND id = @id AND status = 'active'
|
||||
""";
|
||||
|
||||
var rows = await ExecuteAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "id", id);
|
||||
AddParameter(cmd, "approved_by", approvedBy);
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> RevokeAsync(string tenantId, Guid id, string revokedBy, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE policy.exceptions
|
||||
SET status = 'revoked',
|
||||
revoked_by = @revoked_by,
|
||||
revoked_at = NOW()
|
||||
WHERE tenant_id = @tenant_id AND id = @id AND status = 'active'
|
||||
""";
|
||||
|
||||
var rows = await ExecuteAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "id", id);
|
||||
AddParameter(cmd, "revoked_by", revokedBy);
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> ExpireAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE policy.exceptions
|
||||
SET status = 'expired'
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND status = 'active'
|
||||
AND expires_at IS NOT NULL
|
||||
AND expires_at <= NOW()
|
||||
""";
|
||||
|
||||
return await ExecuteAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd => AddParameter(cmd, "tenant_id", tenantId),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "DELETE FROM policy.exceptions WHERE tenant_id = @tenant_id AND id = @id";
|
||||
|
||||
var rows = await ExecuteAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "id", id);
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
private static void AddExceptionParameters(NpgsqlCommand command, ExceptionEntity exception)
|
||||
{
|
||||
AddParameter(command, "id", exception.Id);
|
||||
AddParameter(command, "tenant_id", exception.TenantId);
|
||||
AddParameter(command, "name", exception.Name);
|
||||
AddParameter(command, "description", exception.Description);
|
||||
AddParameter(command, "rule_pattern", exception.RulePattern);
|
||||
AddParameter(command, "resource_pattern", exception.ResourcePattern);
|
||||
AddParameter(command, "artifact_pattern", exception.ArtifactPattern);
|
||||
AddParameter(command, "project_id", exception.ProjectId);
|
||||
AddParameter(command, "reason", exception.Reason);
|
||||
AddParameter(command, "status", StatusToString(exception.Status));
|
||||
AddParameter(command, "expires_at", exception.ExpiresAt);
|
||||
AddJsonbParameter(command, "metadata", exception.Metadata);
|
||||
AddParameter(command, "created_by", exception.CreatedBy);
|
||||
}
|
||||
|
||||
private static ExceptionEntity MapException(NpgsqlDataReader reader) => new()
|
||||
{
|
||||
Id = reader.GetGuid(reader.GetOrdinal("id")),
|
||||
TenantId = reader.GetString(reader.GetOrdinal("tenant_id")),
|
||||
Name = reader.GetString(reader.GetOrdinal("name")),
|
||||
Description = GetNullableString(reader, reader.GetOrdinal("description")),
|
||||
RulePattern = GetNullableString(reader, reader.GetOrdinal("rule_pattern")),
|
||||
ResourcePattern = GetNullableString(reader, reader.GetOrdinal("resource_pattern")),
|
||||
ArtifactPattern = GetNullableString(reader, reader.GetOrdinal("artifact_pattern")),
|
||||
ProjectId = GetNullableString(reader, reader.GetOrdinal("project_id")),
|
||||
Reason = reader.GetString(reader.GetOrdinal("reason")),
|
||||
Status = ParseStatus(reader.GetString(reader.GetOrdinal("status"))),
|
||||
ExpiresAt = GetNullableDateTimeOffset(reader, reader.GetOrdinal("expires_at")),
|
||||
ApprovedBy = GetNullableString(reader, reader.GetOrdinal("approved_by")),
|
||||
ApprovedAt = GetNullableDateTimeOffset(reader, reader.GetOrdinal("approved_at")),
|
||||
RevokedBy = GetNullableString(reader, reader.GetOrdinal("revoked_by")),
|
||||
RevokedAt = GetNullableDateTimeOffset(reader, reader.GetOrdinal("revoked_at")),
|
||||
Metadata = reader.GetString(reader.GetOrdinal("metadata")),
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
|
||||
CreatedBy = GetNullableString(reader, reader.GetOrdinal("created_by"))
|
||||
};
|
||||
|
||||
private static string StatusToString(ExceptionStatus status) => status switch
|
||||
{
|
||||
ExceptionStatus.Active => "active",
|
||||
ExceptionStatus.Expired => "expired",
|
||||
ExceptionStatus.Revoked => "revoked",
|
||||
_ => throw new ArgumentException($"Unknown status: {status}", nameof(status))
|
||||
};
|
||||
|
||||
private static ExceptionStatus ParseStatus(string status) => status switch
|
||||
{
|
||||
"active" => ExceptionStatus.Active,
|
||||
"expired" => ExceptionStatus.Expired,
|
||||
"revoked" => ExceptionStatus.Revoked,
|
||||
_ => throw new ArgumentException($"Unknown status: {status}", nameof(status))
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
using StellaOps.Policy.Storage.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Policy.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository interface for policy evaluation run operations.
|
||||
/// </summary>
|
||||
public interface IEvaluationRunRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new evaluation run.
|
||||
/// </summary>
|
||||
Task<EvaluationRunEntity> CreateAsync(EvaluationRunEntity run, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets an evaluation run by ID.
|
||||
/// </summary>
|
||||
Task<EvaluationRunEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets evaluation runs for a project.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<EvaluationRunEntity>> GetByProjectIdAsync(
|
||||
string tenantId,
|
||||
string projectId,
|
||||
int limit = 100,
|
||||
int offset = 0,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets evaluation runs for an artifact.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<EvaluationRunEntity>> GetByArtifactIdAsync(
|
||||
string tenantId,
|
||||
string artifactId,
|
||||
int limit = 100,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets evaluation runs by status.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<EvaluationRunEntity>> GetByStatusAsync(
|
||||
string tenantId,
|
||||
EvaluationStatus status,
|
||||
int limit = 100,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets recent evaluation runs.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<EvaluationRunEntity>> GetRecentAsync(
|
||||
string tenantId,
|
||||
int limit = 50,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Marks an evaluation as started.
|
||||
/// </summary>
|
||||
Task<bool> MarkStartedAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Marks an evaluation as completed.
|
||||
/// </summary>
|
||||
Task<bool> MarkCompletedAsync(
|
||||
string tenantId,
|
||||
Guid id,
|
||||
EvaluationResult result,
|
||||
decimal? score,
|
||||
int findingsCount,
|
||||
int criticalCount,
|
||||
int highCount,
|
||||
int mediumCount,
|
||||
int lowCount,
|
||||
int durationMs,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Marks an evaluation as failed.
|
||||
/// </summary>
|
||||
Task<bool> MarkFailedAsync(
|
||||
string tenantId,
|
||||
Guid id,
|
||||
string errorMessage,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets evaluation statistics for a tenant.
|
||||
/// </summary>
|
||||
Task<EvaluationStats> GetStatsAsync(
|
||||
string tenantId,
|
||||
DateTimeOffset from,
|
||||
DateTimeOffset to,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluation statistics.
|
||||
/// </summary>
|
||||
public sealed record EvaluationStats(
|
||||
long Total,
|
||||
long Passed,
|
||||
long Failed,
|
||||
long Warned,
|
||||
long Errored,
|
||||
decimal? AverageScore,
|
||||
long TotalFindings,
|
||||
long CriticalFindings,
|
||||
long HighFindings);
|
||||
@@ -0,0 +1,75 @@
|
||||
using StellaOps.Policy.Storage.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Policy.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository interface for policy exception operations.
|
||||
/// </summary>
|
||||
public interface IExceptionRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new exception.
|
||||
/// </summary>
|
||||
Task<ExceptionEntity> CreateAsync(ExceptionEntity exception, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets an exception by ID.
|
||||
/// </summary>
|
||||
Task<ExceptionEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets an exception by name.
|
||||
/// </summary>
|
||||
Task<ExceptionEntity?> GetByNameAsync(string tenantId, string name, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all exceptions for a tenant.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ExceptionEntity>> GetAllAsync(
|
||||
string tenantId,
|
||||
ExceptionStatus? status = null,
|
||||
int limit = 100,
|
||||
int offset = 0,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets active exceptions for a project.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ExceptionEntity>> GetActiveForProjectAsync(
|
||||
string tenantId,
|
||||
string projectId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets active exceptions matching a rule pattern.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ExceptionEntity>> GetActiveForRuleAsync(
|
||||
string tenantId,
|
||||
string ruleName,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates an exception.
|
||||
/// </summary>
|
||||
Task<bool> UpdateAsync(ExceptionEntity exception, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Approves an exception.
|
||||
/// </summary>
|
||||
Task<bool> ApproveAsync(string tenantId, Guid id, string approvedBy, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Revokes an exception.
|
||||
/// </summary>
|
||||
Task<bool> RevokeAsync(string tenantId, Guid id, string revokedBy, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Expires exceptions that have passed their expiration date.
|
||||
/// </summary>
|
||||
Task<int> ExpireAsync(string tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes an exception.
|
||||
/// </summary>
|
||||
Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using StellaOps.Policy.Storage.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Policy.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository interface for policy pack operations.
|
||||
/// </summary>
|
||||
public interface IPackRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new pack.
|
||||
/// </summary>
|
||||
Task<PackEntity> CreateAsync(PackEntity pack, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a pack by ID.
|
||||
/// </summary>
|
||||
Task<PackEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a pack by name.
|
||||
/// </summary>
|
||||
Task<PackEntity?> GetByNameAsync(string tenantId, string name, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all packs for a tenant.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<PackEntity>> GetAllAsync(
|
||||
string tenantId,
|
||||
bool? includeBuiltin = true,
|
||||
bool? includeDeprecated = false,
|
||||
int limit = 100,
|
||||
int offset = 0,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets built-in packs.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<PackEntity>> GetBuiltinAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates a pack.
|
||||
/// </summary>
|
||||
Task<bool> UpdateAsync(PackEntity pack, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Sets the active version for a pack.
|
||||
/// </summary>
|
||||
Task<bool> SetActiveVersionAsync(
|
||||
string tenantId,
|
||||
Guid id,
|
||||
int version,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deprecates a pack.
|
||||
/// </summary>
|
||||
Task<bool> DeprecateAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a pack.
|
||||
/// </summary>
|
||||
Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using StellaOps.Policy.Storage.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Policy.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository interface for policy pack version operations.
|
||||
/// </summary>
|
||||
public interface IPackVersionRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new pack version.
|
||||
/// </summary>
|
||||
Task<PackVersionEntity> CreateAsync(PackVersionEntity version, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a pack version by ID.
|
||||
/// </summary>
|
||||
Task<PackVersionEntity?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific version of a pack.
|
||||
/// </summary>
|
||||
Task<PackVersionEntity?> GetByPackAndVersionAsync(
|
||||
Guid packId,
|
||||
int version,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the latest version of a pack.
|
||||
/// </summary>
|
||||
Task<PackVersionEntity?> GetLatestAsync(Guid packId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all versions of a pack.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<PackVersionEntity>> GetByPackIdAsync(
|
||||
Guid packId,
|
||||
bool? publishedOnly = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Publishes a pack version.
|
||||
/// </summary>
|
||||
Task<bool> PublishAsync(Guid id, string? publishedBy, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the next version number for a pack.
|
||||
/// </summary>
|
||||
Task<int> GetNextVersionAsync(Guid packId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using StellaOps.Policy.Storage.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Policy.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository interface for risk profile operations.
|
||||
/// </summary>
|
||||
public interface IRiskProfileRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new risk profile.
|
||||
/// </summary>
|
||||
Task<RiskProfileEntity> CreateAsync(RiskProfileEntity profile, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a risk profile by ID.
|
||||
/// </summary>
|
||||
Task<RiskProfileEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the active version of a profile by name.
|
||||
/// </summary>
|
||||
Task<RiskProfileEntity?> GetActiveByNameAsync(
|
||||
string tenantId,
|
||||
string name,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all risk profiles for a tenant.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<RiskProfileEntity>> GetAllAsync(
|
||||
string tenantId,
|
||||
bool? activeOnly = true,
|
||||
int limit = 100,
|
||||
int offset = 0,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all versions of a profile by name.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<RiskProfileEntity>> GetVersionsByNameAsync(
|
||||
string tenantId,
|
||||
string name,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates a risk profile.
|
||||
/// </summary>
|
||||
Task<bool> UpdateAsync(RiskProfileEntity profile, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new version of a profile.
|
||||
/// </summary>
|
||||
Task<RiskProfileEntity> CreateVersionAsync(
|
||||
string tenantId,
|
||||
string name,
|
||||
RiskProfileEntity newProfile,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Activates a specific profile version.
|
||||
/// </summary>
|
||||
Task<bool> ActivateAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deactivates a profile.
|
||||
/// </summary>
|
||||
Task<bool> DeactivateAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a risk profile.
|
||||
/// </summary>
|
||||
Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using StellaOps.Policy.Storage.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Policy.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository interface for policy rule operations.
|
||||
/// </summary>
|
||||
public interface IRuleRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new rule.
|
||||
/// </summary>
|
||||
Task<RuleEntity> CreateAsync(RuleEntity rule, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates multiple rules in a batch.
|
||||
/// </summary>
|
||||
Task<int> CreateBatchAsync(IEnumerable<RuleEntity> rules, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a rule by ID.
|
||||
/// </summary>
|
||||
Task<RuleEntity?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a rule by name within a pack version.
|
||||
/// </summary>
|
||||
Task<RuleEntity?> GetByNameAsync(
|
||||
Guid packVersionId,
|
||||
string name,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all rules for a pack version.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<RuleEntity>> GetByPackVersionIdAsync(
|
||||
Guid packVersionId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets rules by severity.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<RuleEntity>> GetBySeverityAsync(
|
||||
Guid packVersionId,
|
||||
RuleSeverity severity,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets rules by category.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<RuleEntity>> GetByCategoryAsync(
|
||||
Guid packVersionId,
|
||||
string category,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets rules by tag.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<RuleEntity>> GetByTagAsync(
|
||||
Guid packVersionId,
|
||||
string tag,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Counts rules in a pack version.
|
||||
/// </summary>
|
||||
Task<int> CountByPackVersionIdAsync(Guid packVersionId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using StellaOps.Policy.Storage.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Policy.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for policy pack operations.
|
||||
/// </summary>
|
||||
public sealed class PackRepository : RepositoryBase<PolicyDataSource>, IPackRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new pack repository.
|
||||
/// </summary>
|
||||
public PackRepository(PolicyDataSource dataSource, ILogger<PackRepository> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PackEntity> CreateAsync(PackEntity pack, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO policy.packs (
|
||||
id, tenant_id, name, display_name, description, active_version,
|
||||
is_builtin, is_deprecated, metadata, created_by
|
||||
)
|
||||
VALUES (
|
||||
@id, @tenant_id, @name, @display_name, @description, @active_version,
|
||||
@is_builtin, @is_deprecated, @metadata::jsonb, @created_by
|
||||
)
|
||||
RETURNING *
|
||||
""";
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync(pack.TenantId, "writer", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
|
||||
AddParameter(command, "id", pack.Id);
|
||||
AddParameter(command, "tenant_id", pack.TenantId);
|
||||
AddParameter(command, "name", pack.Name);
|
||||
AddParameter(command, "display_name", pack.DisplayName);
|
||||
AddParameter(command, "description", pack.Description);
|
||||
AddParameter(command, "active_version", pack.ActiveVersion);
|
||||
AddParameter(command, "is_builtin", pack.IsBuiltin);
|
||||
AddParameter(command, "is_deprecated", pack.IsDeprecated);
|
||||
AddJsonbParameter(command, "metadata", pack.Metadata);
|
||||
AddParameter(command, "created_by", pack.CreatedBy);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return MapPack(reader);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PackEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "SELECT * FROM policy.packs WHERE tenant_id = @tenant_id AND id = @id";
|
||||
|
||||
return await QuerySingleOrDefaultAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "id", id);
|
||||
},
|
||||
MapPack,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PackEntity?> GetByNameAsync(string tenantId, string name, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "SELECT * FROM policy.packs WHERE tenant_id = @tenant_id AND name = @name";
|
||||
|
||||
return await QuerySingleOrDefaultAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "name", name);
|
||||
},
|
||||
MapPack,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<PackEntity>> GetAllAsync(
|
||||
string tenantId,
|
||||
bool? includeBuiltin = true,
|
||||
bool? includeDeprecated = false,
|
||||
int limit = 100,
|
||||
int offset = 0,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sql = "SELECT * FROM policy.packs WHERE tenant_id = @tenant_id";
|
||||
|
||||
if (includeBuiltin == false)
|
||||
{
|
||||
sql += " AND is_builtin = FALSE";
|
||||
}
|
||||
|
||||
if (includeDeprecated == false)
|
||||
{
|
||||
sql += " AND is_deprecated = FALSE";
|
||||
}
|
||||
|
||||
sql += " ORDER BY name, id LIMIT @limit OFFSET @offset";
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "limit", limit);
|
||||
AddParameter(cmd, "offset", offset);
|
||||
},
|
||||
MapPack,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<PackEntity>> GetBuiltinAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM policy.packs
|
||||
WHERE tenant_id = @tenant_id AND is_builtin = TRUE AND is_deprecated = FALSE
|
||||
ORDER BY name, id
|
||||
""";
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd => AddParameter(cmd, "tenant_id", tenantId),
|
||||
MapPack,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> UpdateAsync(PackEntity pack, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE policy.packs
|
||||
SET name = @name,
|
||||
display_name = @display_name,
|
||||
description = @description,
|
||||
is_deprecated = @is_deprecated,
|
||||
metadata = @metadata::jsonb
|
||||
WHERE tenant_id = @tenant_id AND id = @id AND is_builtin = FALSE
|
||||
""";
|
||||
|
||||
var rows = await ExecuteAsync(
|
||||
pack.TenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", pack.TenantId);
|
||||
AddParameter(cmd, "id", pack.Id);
|
||||
AddParameter(cmd, "name", pack.Name);
|
||||
AddParameter(cmd, "display_name", pack.DisplayName);
|
||||
AddParameter(cmd, "description", pack.Description);
|
||||
AddParameter(cmd, "is_deprecated", pack.IsDeprecated);
|
||||
AddJsonbParameter(cmd, "metadata", pack.Metadata);
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> SetActiveVersionAsync(
|
||||
string tenantId,
|
||||
Guid id,
|
||||
int version,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE policy.packs
|
||||
SET active_version = @version
|
||||
WHERE tenant_id = @tenant_id AND id = @id
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM policy.pack_versions pv
|
||||
WHERE pv.pack_id = @id AND pv.version = @version AND pv.is_published = TRUE
|
||||
)
|
||||
""";
|
||||
|
||||
var rows = await ExecuteAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "id", id);
|
||||
AddParameter(cmd, "version", version);
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> DeprecateAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE policy.packs
|
||||
SET is_deprecated = TRUE
|
||||
WHERE tenant_id = @tenant_id AND id = @id
|
||||
""";
|
||||
|
||||
var rows = await ExecuteAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "id", id);
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "DELETE FROM policy.packs WHERE tenant_id = @tenant_id AND id = @id AND is_builtin = FALSE";
|
||||
|
||||
var rows = await ExecuteAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "id", id);
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
private static PackEntity MapPack(NpgsqlDataReader reader) => new()
|
||||
{
|
||||
Id = reader.GetGuid(reader.GetOrdinal("id")),
|
||||
TenantId = reader.GetString(reader.GetOrdinal("tenant_id")),
|
||||
Name = reader.GetString(reader.GetOrdinal("name")),
|
||||
DisplayName = GetNullableString(reader, reader.GetOrdinal("display_name")),
|
||||
Description = GetNullableString(reader, reader.GetOrdinal("description")),
|
||||
ActiveVersion = GetNullableInt(reader, reader.GetOrdinal("active_version")),
|
||||
IsBuiltin = reader.GetBoolean(reader.GetOrdinal("is_builtin")),
|
||||
IsDeprecated = reader.GetBoolean(reader.GetOrdinal("is_deprecated")),
|
||||
Metadata = reader.GetString(reader.GetOrdinal("metadata")),
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
|
||||
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("updated_at")),
|
||||
CreatedBy = GetNullableString(reader, reader.GetOrdinal("created_by"))
|
||||
};
|
||||
|
||||
private static int? GetNullableInt(NpgsqlDataReader reader, int ordinal)
|
||||
{
|
||||
return reader.IsDBNull(ordinal) ? null : reader.GetInt32(ordinal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using StellaOps.Policy.Storage.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Policy.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for policy pack version operations.
|
||||
/// Note: pack_versions table doesn't have tenant_id; tenant context comes from parent pack.
|
||||
/// </summary>
|
||||
public sealed class PackVersionRepository : RepositoryBase<PolicyDataSource>, IPackVersionRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new pack version repository.
|
||||
/// </summary>
|
||||
public PackVersionRepository(PolicyDataSource dataSource, ILogger<PackVersionRepository> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PackVersionEntity> CreateAsync(PackVersionEntity version, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO policy.pack_versions (
|
||||
id, pack_id, version, description, rules_hash,
|
||||
is_published, published_at, published_by, created_by
|
||||
)
|
||||
VALUES (
|
||||
@id, @pack_id, @version, @description, @rules_hash,
|
||||
@is_published, @published_at, @published_by, @created_by
|
||||
)
|
||||
RETURNING *
|
||||
""";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
|
||||
AddParameter(command, "id", version.Id);
|
||||
AddParameter(command, "pack_id", version.PackId);
|
||||
AddParameter(command, "version", version.Version);
|
||||
AddParameter(command, "description", version.Description);
|
||||
AddParameter(command, "rules_hash", version.RulesHash);
|
||||
AddParameter(command, "is_published", version.IsPublished);
|
||||
AddParameter(command, "published_at", version.PublishedAt);
|
||||
AddParameter(command, "published_by", version.PublishedBy);
|
||||
AddParameter(command, "created_by", version.CreatedBy);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return MapPackVersion(reader);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PackVersionEntity?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "SELECT * FROM policy.pack_versions WHERE id = @id";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
|
||||
AddParameter(command, "id", id);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return MapPackVersion(reader);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PackVersionEntity?> GetByPackAndVersionAsync(
|
||||
Guid packId,
|
||||
int version,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "SELECT * FROM policy.pack_versions WHERE pack_id = @pack_id AND version = @version";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
|
||||
AddParameter(command, "pack_id", packId);
|
||||
AddParameter(command, "version", version);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return MapPackVersion(reader);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PackVersionEntity?> GetLatestAsync(Guid packId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM policy.pack_versions
|
||||
WHERE pack_id = @pack_id
|
||||
ORDER BY version DESC
|
||||
LIMIT 1
|
||||
""";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
|
||||
AddParameter(command, "pack_id", packId);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return MapPackVersion(reader);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<PackVersionEntity>> GetByPackIdAsync(
|
||||
Guid packId,
|
||||
bool? publishedOnly = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sql = "SELECT * FROM policy.pack_versions WHERE pack_id = @pack_id";
|
||||
|
||||
if (publishedOnly == true)
|
||||
{
|
||||
sql += " AND is_published = TRUE";
|
||||
}
|
||||
|
||||
sql += " ORDER BY version DESC";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
|
||||
AddParameter(command, "pack_id", packId);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
var results = new List<PackVersionEntity>();
|
||||
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(MapPackVersion(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> PublishAsync(Guid id, string? publishedBy, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE policy.pack_versions
|
||||
SET is_published = TRUE,
|
||||
published_at = NOW(),
|
||||
published_by = @published_by
|
||||
WHERE id = @id AND is_published = FALSE
|
||||
""";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
|
||||
AddParameter(command, "id", id);
|
||||
AddParameter(command, "published_by", publishedBy);
|
||||
|
||||
var rows = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> GetNextVersionAsync(Guid packId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT COALESCE(MAX(version), 0) + 1
|
||||
FROM policy.pack_versions
|
||||
WHERE pack_id = @pack_id
|
||||
""";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
|
||||
AddParameter(command, "pack_id", packId);
|
||||
|
||||
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
return Convert.ToInt32(result);
|
||||
}
|
||||
|
||||
private static PackVersionEntity MapPackVersion(NpgsqlDataReader reader) => new()
|
||||
{
|
||||
Id = reader.GetGuid(reader.GetOrdinal("id")),
|
||||
PackId = reader.GetGuid(reader.GetOrdinal("pack_id")),
|
||||
Version = reader.GetInt32(reader.GetOrdinal("version")),
|
||||
Description = GetNullableString(reader, reader.GetOrdinal("description")),
|
||||
RulesHash = reader.GetString(reader.GetOrdinal("rules_hash")),
|
||||
IsPublished = reader.GetBoolean(reader.GetOrdinal("is_published")),
|
||||
PublishedAt = GetNullableDateTimeOffset(reader, reader.GetOrdinal("published_at")),
|
||||
PublishedBy = GetNullableString(reader, reader.GetOrdinal("published_by")),
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
|
||||
CreatedBy = GetNullableString(reader, reader.GetOrdinal("created_by"))
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
using System.Text.Json;
|
||||
using System;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using NpgsqlTypes;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using StellaOps.Policy.Scoring;
|
||||
using StellaOps.Policy.Scoring.Receipts;
|
||||
|
||||
namespace StellaOps.Policy.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for CVSS score receipts.
|
||||
/// </summary>
|
||||
public sealed class PostgresReceiptRepository : RepositoryBase<PolicyDataSource>, IReceiptRepository
|
||||
{
|
||||
private const string Columns = "id, tenant_id, vulnerability_id, receipt_format, schema_version, cvss_version, vector, severity, base_score, threat_score, environmental_score, full_score, effective_score, effective_score_type, policy_id, policy_version, policy_hash, base_metrics, threat_metrics, environmental_metrics, supplemental_metrics, evidence, attestation_refs, input_hash, created_at, created_by, modified_at, modified_by, history, amends_receipt_id, is_active, superseded_reason";
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = ReceiptBuilder.SerializerOptions;
|
||||
|
||||
public PostgresReceiptRepository(PolicyDataSource dataSource, ILogger<PostgresReceiptRepository> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
}
|
||||
|
||||
public async Task<CvssScoreReceipt> SaveAsync(string tenantId, CvssScoreReceipt receipt, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = $@"insert into policy.cvss_receipts (
|
||||
{Columns}
|
||||
) values (
|
||||
@id, @tenant_id, @vulnerability_id, @receipt_format, @schema_version, @cvss_version,
|
||||
@vector, @severity, @base_score, @threat_score, @environmental_score, @full_score,
|
||||
@effective_score, @effective_score_type,
|
||||
@policy_id, @policy_version, @policy_hash,
|
||||
@base_metrics, @threat_metrics, @environmental_metrics, @supplemental_metrics,
|
||||
@evidence, @attestation_refs, @input_hash,
|
||||
@created_at, @created_by, @modified_at, @modified_by, @history, @amends_receipt_id, @is_active, @superseded_reason
|
||||
) returning {Columns};";
|
||||
|
||||
return await QuerySingleOrDefaultAsync(tenantId, sql, cmd => BindParameters(cmd, tenantId, receipt), Map, cancellationToken)
|
||||
?? throw new InvalidOperationException("Failed to insert receipt.");
|
||||
}
|
||||
|
||||
public async Task<CvssScoreReceipt?> GetAsync(string tenantId, string receiptId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = $@"select {Columns}
|
||||
from policy.cvss_receipts
|
||||
where tenant_id = @tenant_id and id = @id";
|
||||
|
||||
return await QuerySingleOrDefaultAsync(tenantId, sql, cmd =>
|
||||
{
|
||||
cmd.Parameters.AddWithValue("tenant_id", Guid.Parse(tenantId));
|
||||
cmd.Parameters.AddWithValue("id", Guid.Parse(receiptId));
|
||||
}, Map, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<CvssScoreReceipt> UpdateAsync(string tenantId, CvssScoreReceipt receipt, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = $@"update policy.cvss_receipts set
|
||||
vulnerability_id = @vulnerability_id,
|
||||
receipt_format = @receipt_format,
|
||||
schema_version = @schema_version,
|
||||
cvss_version = @cvss_version,
|
||||
vector = @vector,
|
||||
severity = @severity,
|
||||
base_score = @base_score,
|
||||
threat_score = @threat_score,
|
||||
environmental_score = @environmental_score,
|
||||
full_score = @full_score,
|
||||
effective_score = @effective_score,
|
||||
effective_score_type = @effective_score_type,
|
||||
policy_id = @policy_id,
|
||||
policy_version = @policy_version,
|
||||
policy_hash = @policy_hash,
|
||||
base_metrics = @base_metrics,
|
||||
threat_metrics = @threat_metrics,
|
||||
environmental_metrics = @environmental_metrics,
|
||||
supplemental_metrics = @supplemental_metrics,
|
||||
evidence = @evidence,
|
||||
attestation_refs = @attestation_refs,
|
||||
input_hash = @input_hash,
|
||||
created_at = @created_at,
|
||||
created_by = @created_by,
|
||||
modified_at = @modified_at,
|
||||
modified_by = @modified_by,
|
||||
history = @history,
|
||||
amends_receipt_id = @amends_receipt_id,
|
||||
is_active = @is_active,
|
||||
superseded_reason = @superseded_reason
|
||||
where tenant_id = @tenant_id and id = @id
|
||||
returning {Columns};";
|
||||
|
||||
return await QuerySingleOrDefaultAsync(tenantId, sql, cmd => BindParameters(cmd, tenantId, receipt), Map, cancellationToken)
|
||||
?? throw new InvalidOperationException("Failed to update receipt.");
|
||||
}
|
||||
|
||||
private static void BindParameters(NpgsqlCommand cmd, string tenantId, CvssScoreReceipt receipt)
|
||||
{
|
||||
cmd.Parameters.AddWithValue("id", Guid.Parse(receipt.ReceiptId));
|
||||
cmd.Parameters.AddWithValue("tenant_id", Guid.Parse(tenantId));
|
||||
cmd.Parameters.AddWithValue("vulnerability_id", receipt.VulnerabilityId);
|
||||
cmd.Parameters.AddWithValue("receipt_format", receipt.Format);
|
||||
cmd.Parameters.AddWithValue("schema_version", receipt.SchemaVersion);
|
||||
cmd.Parameters.AddWithValue("cvss_version", receipt.CvssVersion);
|
||||
cmd.Parameters.AddWithValue("vector", receipt.VectorString);
|
||||
cmd.Parameters.AddWithValue("severity", receipt.Severity.ToString());
|
||||
cmd.Parameters.AddWithValue("base_score", receipt.Scores.BaseScore);
|
||||
cmd.Parameters.AddWithValue("threat_score", (object?)receipt.Scores.ThreatScore ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("environmental_score", (object?)receipt.Scores.EnvironmentalScore ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("full_score", (object?)receipt.Scores.FullScore ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("effective_score", receipt.Scores.EffectiveScore);
|
||||
cmd.Parameters.AddWithValue("effective_score_type", receipt.Scores.EffectiveScoreType.ToString());
|
||||
cmd.Parameters.AddWithValue("policy_id", receipt.PolicyRef.PolicyId);
|
||||
cmd.Parameters.AddWithValue("policy_version", receipt.PolicyRef.Version);
|
||||
cmd.Parameters.AddWithValue("policy_hash", receipt.PolicyRef.Hash);
|
||||
|
||||
AddJsonbParameter(cmd, "base_metrics", Serialize(receipt.BaseMetrics));
|
||||
AddJsonbParameter(cmd, "threat_metrics", receipt.ThreatMetrics is null ? null : Serialize(receipt.ThreatMetrics));
|
||||
AddJsonbParameter(cmd, "environmental_metrics", receipt.EnvironmentalMetrics is null ? null : Serialize(receipt.EnvironmentalMetrics));
|
||||
AddJsonbParameter(cmd, "supplemental_metrics", receipt.SupplementalMetrics is null ? null : Serialize(receipt.SupplementalMetrics));
|
||||
AddJsonbParameter(cmd, "evidence", Serialize(receipt.Evidence));
|
||||
AddJsonbParameter(cmd, "attestation_refs", Serialize(receipt.AttestationRefs));
|
||||
cmd.Parameters.AddWithValue("input_hash", receipt.InputHash);
|
||||
|
||||
cmd.Parameters.AddWithValue("created_at", receipt.CreatedAt);
|
||||
cmd.Parameters.AddWithValue("created_by", receipt.CreatedBy);
|
||||
cmd.Parameters.AddWithValue("modified_at", (object?)receipt.ModifiedAt ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("modified_by", (object?)receipt.ModifiedBy ?? DBNull.Value);
|
||||
AddJsonbParameter(cmd, "history", Serialize(receipt.History));
|
||||
cmd.Parameters.AddWithValue("amends_receipt_id", receipt.AmendsReceiptId is null ? DBNull.Value : Guid.Parse(receipt.AmendsReceiptId));
|
||||
cmd.Parameters.AddWithValue("is_active", receipt.IsActive);
|
||||
cmd.Parameters.AddWithValue("superseded_reason", (object?)receipt.SupersededReason ?? DBNull.Value);
|
||||
}
|
||||
|
||||
private static CvssScoreReceipt Map(NpgsqlDataReader reader)
|
||||
{
|
||||
var idx = new ReceiptOrdinal(reader);
|
||||
|
||||
var scores = new CvssScores
|
||||
{
|
||||
BaseScore = reader.GetDouble(idx.BaseScore),
|
||||
ThreatScore = reader.IsDBNull(idx.ThreatScore) ? null : reader.GetDouble(idx.ThreatScore),
|
||||
EnvironmentalScore = reader.IsDBNull(idx.EnvironmentalScore) ? null : reader.GetDouble(idx.EnvironmentalScore),
|
||||
FullScore = reader.IsDBNull(idx.FullScore) ? null : reader.GetDouble(idx.FullScore),
|
||||
EffectiveScore = reader.GetDouble(idx.EffectiveScore),
|
||||
EffectiveScoreType = Enum.Parse<EffectiveScoreType>(reader.GetString(idx.EffectiveScoreType), ignoreCase: true)
|
||||
};
|
||||
|
||||
return new CvssScoreReceipt
|
||||
{
|
||||
ReceiptId = reader.GetGuid(idx.Id).ToString(),
|
||||
TenantId = reader.GetGuid(idx.TenantId).ToString(),
|
||||
VulnerabilityId = reader.GetString(idx.VulnId),
|
||||
Format = reader.GetString(idx.Format),
|
||||
SchemaVersion = reader.GetString(idx.SchemaVersion),
|
||||
CvssVersion = reader.GetString(idx.CvssVersion),
|
||||
VectorString = reader.GetString(idx.Vector),
|
||||
Severity = Enum.Parse<CvssSeverity>(reader.GetString(idx.Severity), true),
|
||||
Scores = scores,
|
||||
PolicyRef = new CvssPolicyReference
|
||||
{
|
||||
PolicyId = reader.GetString(idx.PolicyId),
|
||||
Version = reader.GetString(idx.PolicyVersion),
|
||||
Hash = reader.GetString(idx.PolicyHash),
|
||||
ActivatedAt = null
|
||||
},
|
||||
BaseMetrics = Deserialize<CvssBaseMetrics>(reader, idx.BaseMetrics),
|
||||
ThreatMetrics = Deserialize<CvssThreatMetrics>(reader, idx.ThreatMetrics),
|
||||
EnvironmentalMetrics = Deserialize<CvssEnvironmentalMetrics>(reader, idx.EnvironmentalMetrics),
|
||||
SupplementalMetrics = Deserialize<CvssSupplementalMetrics>(reader, idx.SupplementalMetrics),
|
||||
Evidence = Deserialize<ImmutableList<CvssEvidenceItem>>(reader, idx.Evidence) ?? ImmutableList<CvssEvidenceItem>.Empty,
|
||||
AttestationRefs = Deserialize<ImmutableList<string>>(reader, idx.AttestationRefs) ?? ImmutableList<string>.Empty,
|
||||
InputHash = reader.GetString(idx.InputHash),
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(idx.CreatedAt),
|
||||
CreatedBy = reader.GetString(idx.CreatedBy),
|
||||
ModifiedAt = GetNullableDateTimeOffset(reader, idx.ModifiedAt),
|
||||
ModifiedBy = GetNullableString(reader, idx.ModifiedBy),
|
||||
History = Deserialize<ImmutableList<ReceiptHistoryEntry>>(reader, idx.History) ?? ImmutableList<ReceiptHistoryEntry>.Empty,
|
||||
AmendsReceiptId = GetNullableGuid(reader, idx.AmendsReceiptId)?.ToString(),
|
||||
IsActive = reader.GetBoolean(idx.IsActive),
|
||||
SupersededReason = GetNullableString(reader, idx.SupersededReason)
|
||||
};
|
||||
}
|
||||
|
||||
private static string? Serialize<T>(T value) => value is null ? null : JsonSerializer.Serialize(value, JsonOptions);
|
||||
|
||||
private static T? Deserialize<T>(NpgsqlDataReader reader, int ordinal)
|
||||
{
|
||||
if (reader.IsDBNull(ordinal)) return default;
|
||||
var json = reader.GetString(ordinal);
|
||||
return JsonSerializer.Deserialize<T>(json, JsonOptions);
|
||||
}
|
||||
|
||||
private sealed record ReceiptOrdinal
|
||||
{
|
||||
public ReceiptOrdinal(NpgsqlDataReader reader)
|
||||
{
|
||||
Id = reader.GetOrdinal("id");
|
||||
TenantId = reader.GetOrdinal("tenant_id");
|
||||
VulnId = reader.GetOrdinal("vulnerability_id");
|
||||
Format = reader.GetOrdinal("receipt_format");
|
||||
SchemaVersion = reader.GetOrdinal("schema_version");
|
||||
CvssVersion = reader.GetOrdinal("cvss_version");
|
||||
Vector = reader.GetOrdinal("vector");
|
||||
Severity = reader.GetOrdinal("severity");
|
||||
BaseScore = reader.GetOrdinal("base_score");
|
||||
ThreatScore = reader.GetOrdinal("threat_score");
|
||||
EnvironmentalScore = reader.GetOrdinal("environmental_score");
|
||||
FullScore = reader.GetOrdinal("full_score");
|
||||
EffectiveScore = reader.GetOrdinal("effective_score");
|
||||
EffectiveScoreType = reader.GetOrdinal("effective_score_type");
|
||||
PolicyId = reader.GetOrdinal("policy_id");
|
||||
PolicyVersion = reader.GetOrdinal("policy_version");
|
||||
PolicyHash = reader.GetOrdinal("policy_hash");
|
||||
BaseMetrics = reader.GetOrdinal("base_metrics");
|
||||
ThreatMetrics = reader.GetOrdinal("threat_metrics");
|
||||
EnvironmentalMetrics = reader.GetOrdinal("environmental_metrics");
|
||||
SupplementalMetrics = reader.GetOrdinal("supplemental_metrics");
|
||||
Evidence = reader.GetOrdinal("evidence");
|
||||
AttestationRefs = reader.GetOrdinal("attestation_refs");
|
||||
InputHash = reader.GetOrdinal("input_hash");
|
||||
CreatedAt = reader.GetOrdinal("created_at");
|
||||
CreatedBy = reader.GetOrdinal("created_by");
|
||||
ModifiedAt = reader.GetOrdinal("modified_at");
|
||||
ModifiedBy = reader.GetOrdinal("modified_by");
|
||||
History = reader.GetOrdinal("history");
|
||||
AmendsReceiptId = reader.GetOrdinal("amends_receipt_id");
|
||||
IsActive = reader.GetOrdinal("is_active");
|
||||
SupersededReason = reader.GetOrdinal("superseded_reason");
|
||||
}
|
||||
|
||||
public int Id { get; }
|
||||
public int TenantId { get; }
|
||||
public int VulnId { get; }
|
||||
public int Format { get; }
|
||||
public int SchemaVersion { get; }
|
||||
public int CvssVersion { get; }
|
||||
public int Vector { get; }
|
||||
public int Severity { get; }
|
||||
public int BaseScore { get; }
|
||||
public int ThreatScore { get; }
|
||||
public int EnvironmentalScore { get; }
|
||||
public int FullScore { get; }
|
||||
public int EffectiveScore { get; }
|
||||
public int EffectiveScoreType { get; }
|
||||
public int PolicyId { get; }
|
||||
public int PolicyVersion { get; }
|
||||
public int PolicyHash { get; }
|
||||
public int BaseMetrics { get; }
|
||||
public int ThreatMetrics { get; }
|
||||
public int EnvironmentalMetrics { get; }
|
||||
public int SupplementalMetrics { get; }
|
||||
public int Evidence { get; }
|
||||
public int AttestationRefs { get; }
|
||||
public int InputHash { get; }
|
||||
public int CreatedAt { get; }
|
||||
public int CreatedBy { get; }
|
||||
public int ModifiedAt { get; }
|
||||
public int ModifiedBy { get; }
|
||||
public int History { get; }
|
||||
public int AmendsReceiptId { get; }
|
||||
public int IsActive { get; }
|
||||
public int SupersededReason { get; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using StellaOps.Policy.Storage.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Policy.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for risk profile operations.
|
||||
/// </summary>
|
||||
public sealed class RiskProfileRepository : RepositoryBase<PolicyDataSource>, IRiskProfileRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new risk profile repository.
|
||||
/// </summary>
|
||||
public RiskProfileRepository(PolicyDataSource dataSource, ILogger<RiskProfileRepository> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<RiskProfileEntity> CreateAsync(RiskProfileEntity profile, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO policy.risk_profiles (
|
||||
id, tenant_id, name, display_name, description, version,
|
||||
is_active, thresholds, scoring_weights, exemptions, metadata, created_by
|
||||
)
|
||||
VALUES (
|
||||
@id, @tenant_id, @name, @display_name, @description, @version,
|
||||
@is_active, @thresholds::jsonb, @scoring_weights::jsonb, @exemptions::jsonb, @metadata::jsonb, @created_by
|
||||
)
|
||||
RETURNING *
|
||||
""";
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync(profile.TenantId, "writer", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
|
||||
AddProfileParameters(command, profile);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return MapProfile(reader);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<RiskProfileEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "SELECT * FROM policy.risk_profiles WHERE tenant_id = @tenant_id AND id = @id";
|
||||
|
||||
return await QuerySingleOrDefaultAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "id", id);
|
||||
},
|
||||
MapProfile,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<RiskProfileEntity?> GetActiveByNameAsync(
|
||||
string tenantId,
|
||||
string name,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM policy.risk_profiles
|
||||
WHERE tenant_id = @tenant_id AND name = @name AND is_active = TRUE
|
||||
ORDER BY version DESC
|
||||
LIMIT 1
|
||||
""";
|
||||
|
||||
return await QuerySingleOrDefaultAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "name", name);
|
||||
},
|
||||
MapProfile,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<RiskProfileEntity>> GetAllAsync(
|
||||
string tenantId,
|
||||
bool? activeOnly = true,
|
||||
int limit = 100,
|
||||
int offset = 0,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sql = "SELECT * FROM policy.risk_profiles WHERE tenant_id = @tenant_id";
|
||||
|
||||
if (activeOnly == true)
|
||||
{
|
||||
sql += " AND is_active = TRUE";
|
||||
}
|
||||
|
||||
sql += " ORDER BY name, version DESC LIMIT @limit OFFSET @offset";
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "limit", limit);
|
||||
AddParameter(cmd, "offset", offset);
|
||||
},
|
||||
MapProfile,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<RiskProfileEntity>> GetVersionsByNameAsync(
|
||||
string tenantId,
|
||||
string name,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM policy.risk_profiles
|
||||
WHERE tenant_id = @tenant_id AND name = @name
|
||||
ORDER BY version DESC
|
||||
""";
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "name", name);
|
||||
},
|
||||
MapProfile,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> UpdateAsync(RiskProfileEntity profile, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE policy.risk_profiles
|
||||
SET display_name = @display_name,
|
||||
description = @description,
|
||||
thresholds = @thresholds::jsonb,
|
||||
scoring_weights = @scoring_weights::jsonb,
|
||||
exemptions = @exemptions::jsonb,
|
||||
metadata = @metadata::jsonb
|
||||
WHERE tenant_id = @tenant_id AND id = @id
|
||||
""";
|
||||
|
||||
var rows = await ExecuteAsync(
|
||||
profile.TenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", profile.TenantId);
|
||||
AddParameter(cmd, "id", profile.Id);
|
||||
AddParameter(cmd, "display_name", profile.DisplayName);
|
||||
AddParameter(cmd, "description", profile.Description);
|
||||
AddJsonbParameter(cmd, "thresholds", profile.Thresholds);
|
||||
AddJsonbParameter(cmd, "scoring_weights", profile.ScoringWeights);
|
||||
AddJsonbParameter(cmd, "exemptions", profile.Exemptions);
|
||||
AddJsonbParameter(cmd, "metadata", profile.Metadata);
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<RiskProfileEntity> CreateVersionAsync(
|
||||
string tenantId,
|
||||
string name,
|
||||
RiskProfileEntity newProfile,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Get next version number
|
||||
const string versionSql = """
|
||||
SELECT COALESCE(MAX(version), 0) + 1
|
||||
FROM policy.risk_profiles
|
||||
WHERE tenant_id = @tenant_id AND name = @name
|
||||
""";
|
||||
|
||||
await using var versionCmd = CreateCommand(versionSql, connection);
|
||||
versionCmd.Transaction = transaction;
|
||||
AddParameter(versionCmd, "tenant_id", tenantId);
|
||||
AddParameter(versionCmd, "name", name);
|
||||
|
||||
var nextVersion = Convert.ToInt32(await versionCmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false));
|
||||
|
||||
// Insert new version
|
||||
const string insertSql = """
|
||||
INSERT INTO policy.risk_profiles (
|
||||
id, tenant_id, name, display_name, description, version,
|
||||
is_active, thresholds, scoring_weights, exemptions, metadata, created_by
|
||||
)
|
||||
VALUES (
|
||||
@id, @tenant_id, @name, @display_name, @description, @version,
|
||||
TRUE, @thresholds::jsonb, @scoring_weights::jsonb, @exemptions::jsonb, @metadata::jsonb, @created_by
|
||||
)
|
||||
RETURNING *
|
||||
""";
|
||||
|
||||
await using var insertCmd = CreateCommand(insertSql, connection);
|
||||
insertCmd.Transaction = transaction;
|
||||
|
||||
AddParameter(insertCmd, "id", newProfile.Id);
|
||||
AddParameter(insertCmd, "tenant_id", tenantId);
|
||||
AddParameter(insertCmd, "name", name);
|
||||
AddParameter(insertCmd, "display_name", newProfile.DisplayName);
|
||||
AddParameter(insertCmd, "description", newProfile.Description);
|
||||
AddParameter(insertCmd, "version", nextVersion);
|
||||
AddJsonbParameter(insertCmd, "thresholds", newProfile.Thresholds);
|
||||
AddJsonbParameter(insertCmd, "scoring_weights", newProfile.ScoringWeights);
|
||||
AddJsonbParameter(insertCmd, "exemptions", newProfile.Exemptions);
|
||||
AddJsonbParameter(insertCmd, "metadata", newProfile.Metadata);
|
||||
AddParameter(insertCmd, "created_by", newProfile.CreatedBy);
|
||||
|
||||
await using var reader = await insertCmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = MapProfile(reader);
|
||||
await reader.CloseAsync().ConfigureAwait(false);
|
||||
|
||||
// Deactivate other versions
|
||||
const string deactivateSql = """
|
||||
UPDATE policy.risk_profiles
|
||||
SET is_active = FALSE
|
||||
WHERE tenant_id = @tenant_id AND name = @name AND id != @id
|
||||
""";
|
||||
|
||||
await using var deactivateCmd = CreateCommand(deactivateSql, connection);
|
||||
deactivateCmd.Transaction = transaction;
|
||||
AddParameter(deactivateCmd, "tenant_id", tenantId);
|
||||
AddParameter(deactivateCmd, "name", name);
|
||||
AddParameter(deactivateCmd, "id", result.Id);
|
||||
await deactivateCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> ActivateAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Get the profile name
|
||||
const string nameSql = "SELECT name FROM policy.risk_profiles WHERE tenant_id = @tenant_id AND id = @id";
|
||||
await using var nameCmd = CreateCommand(nameSql, connection);
|
||||
nameCmd.Transaction = transaction;
|
||||
AddParameter(nameCmd, "tenant_id", tenantId);
|
||||
AddParameter(nameCmd, "id", id);
|
||||
|
||||
var name = await nameCmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false) as string;
|
||||
if (name == null) return false;
|
||||
|
||||
// Deactivate other versions
|
||||
const string deactivateSql = """
|
||||
UPDATE policy.risk_profiles
|
||||
SET is_active = FALSE
|
||||
WHERE tenant_id = @tenant_id AND name = @name AND id != @id
|
||||
""";
|
||||
|
||||
await using var deactivateCmd = CreateCommand(deactivateSql, connection);
|
||||
deactivateCmd.Transaction = transaction;
|
||||
AddParameter(deactivateCmd, "tenant_id", tenantId);
|
||||
AddParameter(deactivateCmd, "name", name);
|
||||
AddParameter(deactivateCmd, "id", id);
|
||||
await deactivateCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Activate this version
|
||||
const string activateSql = """
|
||||
UPDATE policy.risk_profiles
|
||||
SET is_active = TRUE
|
||||
WHERE tenant_id = @tenant_id AND id = @id
|
||||
""";
|
||||
|
||||
await using var activateCmd = CreateCommand(activateSql, connection);
|
||||
activateCmd.Transaction = transaction;
|
||||
AddParameter(activateCmd, "tenant_id", tenantId);
|
||||
AddParameter(activateCmd, "id", id);
|
||||
var rows = await activateCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> DeactivateAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE policy.risk_profiles
|
||||
SET is_active = FALSE
|
||||
WHERE tenant_id = @tenant_id AND id = @id
|
||||
""";
|
||||
|
||||
var rows = await ExecuteAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "id", id);
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "DELETE FROM policy.risk_profiles WHERE tenant_id = @tenant_id AND id = @id";
|
||||
|
||||
var rows = await ExecuteAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "id", id);
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
private static void AddProfileParameters(NpgsqlCommand command, RiskProfileEntity profile)
|
||||
{
|
||||
AddParameter(command, "id", profile.Id);
|
||||
AddParameter(command, "tenant_id", profile.TenantId);
|
||||
AddParameter(command, "name", profile.Name);
|
||||
AddParameter(command, "display_name", profile.DisplayName);
|
||||
AddParameter(command, "description", profile.Description);
|
||||
AddParameter(command, "version", profile.Version);
|
||||
AddParameter(command, "is_active", profile.IsActive);
|
||||
AddJsonbParameter(command, "thresholds", profile.Thresholds);
|
||||
AddJsonbParameter(command, "scoring_weights", profile.ScoringWeights);
|
||||
AddJsonbParameter(command, "exemptions", profile.Exemptions);
|
||||
AddJsonbParameter(command, "metadata", profile.Metadata);
|
||||
AddParameter(command, "created_by", profile.CreatedBy);
|
||||
}
|
||||
|
||||
private static RiskProfileEntity MapProfile(NpgsqlDataReader reader) => new()
|
||||
{
|
||||
Id = reader.GetGuid(reader.GetOrdinal("id")),
|
||||
TenantId = reader.GetString(reader.GetOrdinal("tenant_id")),
|
||||
Name = reader.GetString(reader.GetOrdinal("name")),
|
||||
DisplayName = GetNullableString(reader, reader.GetOrdinal("display_name")),
|
||||
Description = GetNullableString(reader, reader.GetOrdinal("description")),
|
||||
Version = reader.GetInt32(reader.GetOrdinal("version")),
|
||||
IsActive = reader.GetBoolean(reader.GetOrdinal("is_active")),
|
||||
Thresholds = reader.GetString(reader.GetOrdinal("thresholds")),
|
||||
ScoringWeights = reader.GetString(reader.GetOrdinal("scoring_weights")),
|
||||
Exemptions = reader.GetString(reader.GetOrdinal("exemptions")),
|
||||
Metadata = reader.GetString(reader.GetOrdinal("metadata")),
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
|
||||
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("updated_at")),
|
||||
CreatedBy = GetNullableString(reader, reader.GetOrdinal("created_by"))
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using StellaOps.Policy.Storage.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Policy.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository for policy rule operations.
|
||||
/// Note: rules table doesn't have tenant_id; tenant context comes from parent pack.
|
||||
/// </summary>
|
||||
public sealed class RuleRepository : RepositoryBase<PolicyDataSource>, IRuleRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new rule repository.
|
||||
/// </summary>
|
||||
public RuleRepository(PolicyDataSource dataSource, ILogger<RuleRepository> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<RuleEntity> CreateAsync(RuleEntity rule, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO policy.rules (
|
||||
id, pack_version_id, name, description, rule_type, content,
|
||||
content_hash, severity, category, tags, metadata
|
||||
)
|
||||
VALUES (
|
||||
@id, @pack_version_id, @name, @description, @rule_type, @content,
|
||||
@content_hash, @severity, @category, @tags, @metadata::jsonb
|
||||
)
|
||||
RETURNING *
|
||||
""";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
|
||||
AddRuleParameters(command, rule);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return MapRule(reader);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> CreateBatchAsync(IEnumerable<RuleEntity> rules, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var rulesList = rules.ToList();
|
||||
if (rulesList.Count == 0) return 0;
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var count = 0;
|
||||
foreach (var rule in rulesList)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO policy.rules (
|
||||
id, pack_version_id, name, description, rule_type, content,
|
||||
content_hash, severity, category, tags, metadata
|
||||
)
|
||||
VALUES (
|
||||
@id, @pack_version_id, @name, @description, @rule_type, @content,
|
||||
@content_hash, @severity, @category, @tags, @metadata::jsonb
|
||||
)
|
||||
""";
|
||||
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
command.Transaction = transaction;
|
||||
AddRuleParameters(command, rule);
|
||||
|
||||
count += await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||
return count;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<RuleEntity?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "SELECT * FROM policy.rules WHERE id = @id";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
|
||||
AddParameter(command, "id", id);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return MapRule(reader);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<RuleEntity?> GetByNameAsync(
|
||||
Guid packVersionId,
|
||||
string name,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "SELECT * FROM policy.rules WHERE pack_version_id = @pack_version_id AND name = @name";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
|
||||
AddParameter(command, "pack_version_id", packVersionId);
|
||||
AddParameter(command, "name", name);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return MapRule(reader);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<RuleEntity>> GetByPackVersionIdAsync(
|
||||
Guid packVersionId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM policy.rules
|
||||
WHERE pack_version_id = @pack_version_id
|
||||
ORDER BY name, id
|
||||
""";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
|
||||
AddParameter(command, "pack_version_id", packVersionId);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
var results = new List<RuleEntity>();
|
||||
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(MapRule(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<RuleEntity>> GetBySeverityAsync(
|
||||
Guid packVersionId,
|
||||
RuleSeverity severity,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM policy.rules
|
||||
WHERE pack_version_id = @pack_version_id AND severity = @severity
|
||||
ORDER BY name, id
|
||||
""";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
|
||||
AddParameter(command, "pack_version_id", packVersionId);
|
||||
AddParameter(command, "severity", SeverityToString(severity));
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
var results = new List<RuleEntity>();
|
||||
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(MapRule(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<RuleEntity>> GetByCategoryAsync(
|
||||
Guid packVersionId,
|
||||
string category,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM policy.rules
|
||||
WHERE pack_version_id = @pack_version_id AND category = @category
|
||||
ORDER BY name, id
|
||||
""";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
|
||||
AddParameter(command, "pack_version_id", packVersionId);
|
||||
AddParameter(command, "category", category);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
var results = new List<RuleEntity>();
|
||||
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(MapRule(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<RuleEntity>> GetByTagAsync(
|
||||
Guid packVersionId,
|
||||
string tag,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM policy.rules
|
||||
WHERE pack_version_id = @pack_version_id AND @tag = ANY(tags)
|
||||
ORDER BY name, id
|
||||
""";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
|
||||
AddParameter(command, "pack_version_id", packVersionId);
|
||||
AddParameter(command, "tag", tag);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
var results = new List<RuleEntity>();
|
||||
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(MapRule(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> CountByPackVersionIdAsync(Guid packVersionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "SELECT COUNT(*) FROM policy.rules WHERE pack_version_id = @pack_version_id";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
|
||||
AddParameter(command, "pack_version_id", packVersionId);
|
||||
|
||||
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
return Convert.ToInt32(result);
|
||||
}
|
||||
|
||||
private static void AddRuleParameters(NpgsqlCommand command, RuleEntity rule)
|
||||
{
|
||||
AddParameter(command, "id", rule.Id);
|
||||
AddParameter(command, "pack_version_id", rule.PackVersionId);
|
||||
AddParameter(command, "name", rule.Name);
|
||||
AddParameter(command, "description", rule.Description);
|
||||
AddParameter(command, "rule_type", RuleTypeToString(rule.RuleType));
|
||||
AddParameter(command, "content", rule.Content);
|
||||
AddParameter(command, "content_hash", rule.ContentHash);
|
||||
AddParameter(command, "severity", SeverityToString(rule.Severity));
|
||||
AddParameter(command, "category", rule.Category);
|
||||
AddTextArrayParameter(command, "tags", rule.Tags);
|
||||
AddJsonbParameter(command, "metadata", rule.Metadata);
|
||||
}
|
||||
|
||||
private static RuleEntity MapRule(NpgsqlDataReader reader) => new()
|
||||
{
|
||||
Id = reader.GetGuid(reader.GetOrdinal("id")),
|
||||
PackVersionId = reader.GetGuid(reader.GetOrdinal("pack_version_id")),
|
||||
Name = reader.GetString(reader.GetOrdinal("name")),
|
||||
Description = GetNullableString(reader, reader.GetOrdinal("description")),
|
||||
RuleType = ParseRuleType(reader.GetString(reader.GetOrdinal("rule_type"))),
|
||||
Content = reader.GetString(reader.GetOrdinal("content")),
|
||||
ContentHash = reader.GetString(reader.GetOrdinal("content_hash")),
|
||||
Severity = ParseSeverity(reader.GetString(reader.GetOrdinal("severity"))),
|
||||
Category = GetNullableString(reader, reader.GetOrdinal("category")),
|
||||
Tags = GetTextArray(reader, reader.GetOrdinal("tags")),
|
||||
Metadata = reader.GetString(reader.GetOrdinal("metadata")),
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at"))
|
||||
};
|
||||
|
||||
private static string RuleTypeToString(RuleType ruleType) => ruleType switch
|
||||
{
|
||||
RuleType.Rego => "rego",
|
||||
RuleType.Json => "json",
|
||||
RuleType.Yaml => "yaml",
|
||||
_ => throw new ArgumentException($"Unknown rule type: {ruleType}", nameof(ruleType))
|
||||
};
|
||||
|
||||
private static RuleType ParseRuleType(string ruleType) => ruleType switch
|
||||
{
|
||||
"rego" => RuleType.Rego,
|
||||
"json" => RuleType.Json,
|
||||
"yaml" => RuleType.Yaml,
|
||||
_ => throw new ArgumentException($"Unknown rule type: {ruleType}", nameof(ruleType))
|
||||
};
|
||||
|
||||
private static string SeverityToString(RuleSeverity severity) => severity switch
|
||||
{
|
||||
RuleSeverity.Critical => "critical",
|
||||
RuleSeverity.High => "high",
|
||||
RuleSeverity.Medium => "medium",
|
||||
RuleSeverity.Low => "low",
|
||||
RuleSeverity.Info => "info",
|
||||
_ => throw new ArgumentException($"Unknown severity: {severity}", nameof(severity))
|
||||
};
|
||||
|
||||
private static RuleSeverity ParseSeverity(string severity) => severity switch
|
||||
{
|
||||
"critical" => RuleSeverity.Critical,
|
||||
"high" => RuleSeverity.High,
|
||||
"medium" => RuleSeverity.Medium,
|
||||
"low" => RuleSeverity.Low,
|
||||
"info" => RuleSeverity.Info,
|
||||
_ => throw new ArgumentException($"Unknown severity: {severity}", nameof(severity))
|
||||
};
|
||||
|
||||
private static string[] GetTextArray(NpgsqlDataReader reader, int ordinal)
|
||||
{
|
||||
if (reader.IsDBNull(ordinal))
|
||||
return [];
|
||||
|
||||
return reader.GetFieldValue<string[]>(ordinal);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Infrastructure.Postgres;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
using StellaOps.Policy.Scoring.Receipts;
|
||||
using StellaOps.Policy.Storage.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Policy.Storage.Postgres;
|
||||
|
||||
@@ -25,6 +27,15 @@ public static class ServiceCollectionExtensions
|
||||
services.Configure<PostgresOptions>(sectionName, configuration.GetSection(sectionName));
|
||||
services.AddSingleton<PolicyDataSource>();
|
||||
|
||||
// Register repositories
|
||||
services.AddScoped<IPackRepository, PackRepository>();
|
||||
services.AddScoped<IPackVersionRepository, PackVersionRepository>();
|
||||
services.AddScoped<IRuleRepository, RuleRepository>();
|
||||
services.AddScoped<IRiskProfileRepository, RiskProfileRepository>();
|
||||
services.AddScoped<IEvaluationRunRepository, EvaluationRunRepository>();
|
||||
services.AddScoped<IExceptionRepository, ExceptionRepository>();
|
||||
services.AddScoped<IReceiptRepository, PostgresReceiptRepository>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -41,6 +52,15 @@ public static class ServiceCollectionExtensions
|
||||
services.Configure(configureOptions);
|
||||
services.AddSingleton<PolicyDataSource>();
|
||||
|
||||
// Register repositories
|
||||
services.AddScoped<IPackRepository, PackRepository>();
|
||||
services.AddScoped<IPackVersionRepository, PackVersionRepository>();
|
||||
services.AddScoped<IRuleRepository, RuleRepository>();
|
||||
services.AddScoped<IRiskProfileRepository, RiskProfileRepository>();
|
||||
services.AddScoped<IEvaluationRunRepository, EvaluationRunRepository>();
|
||||
services.AddScoped<IExceptionRepository, ExceptionRepository>();
|
||||
services.AddScoped<IReceiptRepository, PostgresReceiptRepository>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,10 +11,11 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="Migrations\**\*.sql" CopyToOutputDirectory="PreserveNewest" />
|
||||
<EmbeddedResource Include="Migrations\**\*.sql" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Policy.Scoring\StellaOps.Policy.Scoring.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -7,11 +7,23 @@ internal sealed class InMemoryReceiptRepository : IReceiptRepository
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, CvssScoreReceipt> _store = new();
|
||||
|
||||
public Task<CvssScoreReceipt> SaveAsync(CvssScoreReceipt receipt, CancellationToken cancellationToken = default)
|
||||
public Task<CvssScoreReceipt> SaveAsync(string tenantId, CvssScoreReceipt receipt, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_store[receipt.ReceiptId] = receipt;
|
||||
return Task.FromResult(receipt);
|
||||
}
|
||||
|
||||
public bool Contains(string receiptId) => _store.ContainsKey(receiptId);
|
||||
|
||||
public Task<CvssScoreReceipt?> GetAsync(string tenantId, string receiptId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_store.TryGetValue(receiptId, out var receipt);
|
||||
return Task.FromResult(receipt);
|
||||
}
|
||||
|
||||
public Task<CvssScoreReceipt> UpdateAsync(string tenantId, CvssScoreReceipt receipt, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_store[receipt.ReceiptId] = receipt;
|
||||
return Task.FromResult(receipt);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Policy.Scoring.Tests.Fakes;
|
||||
|
||||
internal static class TestKeys
|
||||
{
|
||||
// Ed25519 test key material from Attestor envelope tests
|
||||
public static readonly byte[] Ed25519PrivateExpanded = Convert.FromHexString(
|
||||
"9D61B19DEFFD5A60BA844AF492EC2CC4" +
|
||||
"4449C5697B326919703BAC031CAE7F60D75A980182B10AB7D54BFED3C964073A" +
|
||||
"0EE172F3DAA62325AF021A68F707511A");
|
||||
|
||||
public static readonly byte[] Ed25519Public = Convert.FromHexString(
|
||||
"D75A980182B10AB7D54BFED3C964073A0EE172F3DAA62325AF021A68F707511A");
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Attestor.Envelope;
|
||||
using StellaOps.Policy.Scoring.Engine;
|
||||
using StellaOps.Policy.Scoring.Receipts;
|
||||
using StellaOps.Policy.Scoring.Tests.Fakes;
|
||||
@@ -70,6 +71,54 @@ public sealed class ReceiptBuilderTests
|
||||
_repository.Contains(receipt1.ReceiptId).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_WithSigningKey_AttachesDsseReference()
|
||||
{
|
||||
// Arrange
|
||||
var signingKey = EnvelopeKey.CreateEd25519Signer(TestKeys.Ed25519PrivateExpanded, TestKeys.Ed25519Public, "test-key");
|
||||
|
||||
var policy = new CvssPolicy
|
||||
{
|
||||
PolicyId = "default",
|
||||
Version = "1.0.0",
|
||||
Name = "Default",
|
||||
EffectiveFrom = DateTimeOffset.UtcNow,
|
||||
Hash = "abc123"
|
||||
};
|
||||
|
||||
var request = new CreateReceiptRequest
|
||||
{
|
||||
VulnerabilityId = "CVE-2025-0003",
|
||||
TenantId = "tenant-c",
|
||||
CreatedBy = "tester",
|
||||
Policy = policy,
|
||||
BaseMetrics = new CvssBaseMetrics
|
||||
{
|
||||
AttackVector = AttackVector.Network,
|
||||
AttackComplexity = AttackComplexity.Low,
|
||||
AttackRequirements = AttackRequirements.None,
|
||||
PrivilegesRequired = PrivilegesRequired.None,
|
||||
UserInteraction = UserInteraction.None,
|
||||
VulnerableSystemConfidentiality = ImpactMetricValue.High,
|
||||
VulnerableSystemIntegrity = ImpactMetricValue.High,
|
||||
VulnerableSystemAvailability = ImpactMetricValue.High,
|
||||
SubsequentSystemConfidentiality = ImpactMetricValue.High,
|
||||
SubsequentSystemIntegrity = ImpactMetricValue.High,
|
||||
SubsequentSystemAvailability = ImpactMetricValue.High
|
||||
},
|
||||
SigningKey = signingKey
|
||||
};
|
||||
|
||||
var builder = new ReceiptBuilder(_engine, _repository);
|
||||
|
||||
// Act
|
||||
var receipt = await builder.CreateAsync(request);
|
||||
|
||||
// Assert
|
||||
receipt.AttestationRefs.Should().NotBeEmpty();
|
||||
receipt.AttestationRefs[0].Should().StartWith("dsse:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_EnforcesEvidenceRequirements()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
using System.Reflection;
|
||||
using StellaOps.Infrastructure.Postgres.Testing;
|
||||
using StellaOps.Policy.Storage.Postgres;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Storage.Postgres.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL integration test fixture for the Policy module.
|
||||
/// Runs migrations from embedded resources and provides test isolation.
|
||||
/// </summary>
|
||||
public sealed class PolicyPostgresFixture : PostgresIntegrationFixture, ICollectionFixture<PolicyPostgresFixture>
|
||||
{
|
||||
protected override Assembly? GetMigrationAssembly()
|
||||
=> typeof(PolicyDataSource).Assembly;
|
||||
|
||||
protected override string GetModuleName() => "Policy";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collection definition for Policy PostgreSQL integration tests.
|
||||
/// Tests in this collection share a single PostgreSQL container instance.
|
||||
/// </summary>
|
||||
[CollectionDefinition(Name)]
|
||||
public sealed class PolicyPostgresCollection : ICollectionFixture<PolicyPostgresFixture>
|
||||
{
|
||||
public const string Name = "PolicyPostgres";
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy.Scoring;
|
||||
using StellaOps.Policy.Scoring.Receipts;
|
||||
using StellaOps.Policy.Storage.Postgres.Repositories;
|
||||
using StellaOps.Policy.Storage.Postgres.Tests;
|
||||
using StellaOps.Policy.Storage.Postgres;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Storage.Postgres.Tests;
|
||||
|
||||
[Collection(PolicyPostgresCollection.Name)]
|
||||
public sealed class PostgresReceiptRepositoryTests : IAsyncLifetime
|
||||
{
|
||||
private readonly PolicyPostgresFixture _fixture;
|
||||
private readonly PostgresReceiptRepository _repository;
|
||||
private readonly string _tenantId = Guid.NewGuid().ToString();
|
||||
|
||||
public PostgresReceiptRepositoryTests(PolicyPostgresFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
|
||||
var options = fixture.Fixture.CreateOptions();
|
||||
options.SchemaName = fixture.SchemaName;
|
||||
var dataSource = new PolicyDataSource(Options.Create(options), NullLogger<PolicyDataSource>.Instance);
|
||||
_repository = new PostgresReceiptRepository(dataSource, NullLogger<PostgresReceiptRepository>.Instance);
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
[Fact]
|
||||
public async Task SaveAndGet_RoundTripsReceipt()
|
||||
{
|
||||
var receipt = CreateReceipt(_tenantId);
|
||||
|
||||
var saved = await _repository.SaveAsync(_tenantId, receipt);
|
||||
var fetched = await _repository.GetAsync(_tenantId, saved.ReceiptId);
|
||||
|
||||
fetched.Should().NotBeNull();
|
||||
fetched!.ReceiptId.Should().Be(saved.ReceiptId);
|
||||
fetched.InputHash.Should().Be(saved.InputHash);
|
||||
fetched.PolicyRef.PolicyId.Should().Be("default");
|
||||
fetched.AttestationRefs.Should().BeEquivalentTo(saved.AttestationRefs);
|
||||
}
|
||||
|
||||
private static CvssScoreReceipt CreateReceipt(string tenantId)
|
||||
{
|
||||
var baseMetrics = new CvssBaseMetrics
|
||||
{
|
||||
AttackVector = AttackVector.Network,
|
||||
AttackComplexity = AttackComplexity.Low,
|
||||
AttackRequirements = AttackRequirements.None,
|
||||
PrivilegesRequired = PrivilegesRequired.None,
|
||||
UserInteraction = UserInteraction.None,
|
||||
VulnerableSystemConfidentiality = ImpactMetricValue.High,
|
||||
VulnerableSystemIntegrity = ImpactMetricValue.High,
|
||||
VulnerableSystemAvailability = ImpactMetricValue.High,
|
||||
SubsequentSystemConfidentiality = ImpactMetricValue.High,
|
||||
SubsequentSystemIntegrity = ImpactMetricValue.High,
|
||||
SubsequentSystemAvailability = ImpactMetricValue.High
|
||||
};
|
||||
|
||||
var scores = new CvssScores
|
||||
{
|
||||
BaseScore = 10.0,
|
||||
ThreatScore = null,
|
||||
EnvironmentalScore = null,
|
||||
FullScore = null,
|
||||
EffectiveScore = 10.0,
|
||||
EffectiveScoreType = EffectiveScoreType.Base
|
||||
};
|
||||
|
||||
return new CvssScoreReceipt
|
||||
{
|
||||
ReceiptId = Guid.NewGuid().ToString(),
|
||||
SchemaVersion = "1.0.0",
|
||||
Format = "stella.ops/cvssReceipt@v1",
|
||||
VulnerabilityId = "CVE-2025-0001",
|
||||
TenantId = tenantId,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
CreatedBy = "tester",
|
||||
CvssVersion = "4.0",
|
||||
BaseMetrics = baseMetrics,
|
||||
ThreatMetrics = null,
|
||||
EnvironmentalMetrics = null,
|
||||
SupplementalMetrics = null,
|
||||
Scores = scores,
|
||||
VectorString = "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H",
|
||||
Severity = CvssSeverity.Critical,
|
||||
PolicyRef = new CvssPolicyReference
|
||||
{
|
||||
PolicyId = "default",
|
||||
Version = "1.0.0",
|
||||
Hash = "abc123",
|
||||
ActivatedAt = null
|
||||
},
|
||||
Evidence = ImmutableList<CvssEvidenceItem>.Empty,
|
||||
AttestationRefs = ImmutableList<string>.Empty,
|
||||
InputHash = "hash123",
|
||||
History = ImmutableList<ReceiptHistoryEntry>.Empty,
|
||||
AmendsReceiptId = null,
|
||||
IsActive = true,
|
||||
SupersededReason = null
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" ?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="Moq" Version="4.20.70" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Policy.Storage.Postgres\StellaOps.Policy.Storage.Postgres.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.Postgres.Testing\StellaOps.Infrastructure.Postgres.Testing.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Policy.Scoring\StellaOps.Policy.Scoring.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -11,7 +11,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="Migrations\**\*.sql" CopyToOutputDirectory="PreserveNewest" />
|
||||
<EmbeddedResource Include="Migrations\**\*.sql" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
using System.Reflection;
|
||||
using StellaOps.Infrastructure.Postgres.Testing;
|
||||
using StellaOps.Scheduler.Storage.Postgres;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scheduler.Storage.Postgres.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL integration test fixture for the Scheduler module.
|
||||
/// Runs migrations from embedded resources and provides test isolation.
|
||||
/// </summary>
|
||||
public sealed class SchedulerPostgresFixture : PostgresIntegrationFixture, ICollectionFixture<SchedulerPostgresFixture>
|
||||
{
|
||||
protected override Assembly? GetMigrationAssembly()
|
||||
=> typeof(SchedulerDataSource).Assembly;
|
||||
|
||||
protected override string GetModuleName() => "Scheduler";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collection definition for Scheduler PostgreSQL integration tests.
|
||||
/// Tests in this collection share a single PostgreSQL container instance.
|
||||
/// </summary>
|
||||
[CollectionDefinition(Name)]
|
||||
public sealed class SchedulerPostgresCollection : ICollectionFixture<SchedulerPostgresFixture>
|
||||
{
|
||||
public const string Name = "SchedulerPostgres";
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" ?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="Moq" Version="4.20.70" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Scheduler.Storage.Postgres\StellaOps.Scheduler.Storage.Postgres.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.Postgres.Testing\StellaOps.Infrastructure.Postgres.Testing.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,128 @@
|
||||
using System.Reflection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Testcontainers.PostgreSql;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Infrastructure.Postgres.Testing;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for PostgreSQL integration test fixtures.
|
||||
/// Uses Testcontainers to spin up a real PostgreSQL instance.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Inherit from this class and override <see cref="GetMigrationAssembly"/> and <see cref="GetModuleName"/>
|
||||
/// to provide module-specific migrations.
|
||||
/// </remarks>
|
||||
public abstract class PostgresIntegrationFixture : IAsyncLifetime
|
||||
{
|
||||
private PostgreSqlContainer? _container;
|
||||
private PostgresFixture? _fixture;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the PostgreSQL connection string for tests.
|
||||
/// </summary>
|
||||
public string ConnectionString => _container?.GetConnectionString()
|
||||
?? throw new InvalidOperationException("Container not initialized");
|
||||
|
||||
/// <summary>
|
||||
/// Gets the schema name for test isolation.
|
||||
/// </summary>
|
||||
public string SchemaName => _fixture?.SchemaName
|
||||
?? throw new InvalidOperationException("Fixture not initialized");
|
||||
|
||||
/// <summary>
|
||||
/// Gets the PostgreSQL test fixture.
|
||||
/// </summary>
|
||||
public PostgresFixture Fixture => _fixture
|
||||
?? throw new InvalidOperationException("Fixture not initialized");
|
||||
|
||||
/// <summary>
|
||||
/// Gets the logger for this fixture.
|
||||
/// </summary>
|
||||
protected virtual ILogger Logger => NullLogger.Instance;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the PostgreSQL Docker image to use.
|
||||
/// </summary>
|
||||
protected virtual string PostgresImage => "postgres:16-alpine";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the assembly containing embedded SQL migrations.
|
||||
/// </summary>
|
||||
/// <returns>Assembly with embedded migration resources, or null if no migrations.</returns>
|
||||
protected abstract Assembly? GetMigrationAssembly();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the module name for logging and schema naming.
|
||||
/// </summary>
|
||||
protected abstract string GetModuleName();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the resource prefix for filtering embedded resources.
|
||||
/// </summary>
|
||||
protected virtual string? GetResourcePrefix() => null;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the PostgreSQL container and runs migrations.
|
||||
/// </summary>
|
||||
public virtual async Task InitializeAsync()
|
||||
{
|
||||
_container = new PostgreSqlBuilder()
|
||||
.WithImage(PostgresImage)
|
||||
.Build();
|
||||
|
||||
await _container.StartAsync();
|
||||
|
||||
var moduleName = GetModuleName();
|
||||
_fixture = PostgresFixtureFactory.Create(ConnectionString, moduleName, Logger);
|
||||
await _fixture.InitializeAsync();
|
||||
|
||||
var migrationAssembly = GetMigrationAssembly();
|
||||
if (migrationAssembly != null)
|
||||
{
|
||||
await _fixture.RunMigrationsFromAssemblyAsync(
|
||||
migrationAssembly,
|
||||
moduleName,
|
||||
GetResourcePrefix());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cleans up the PostgreSQL container and fixture.
|
||||
/// </summary>
|
||||
public virtual async Task DisposeAsync()
|
||||
{
|
||||
if (_fixture != null)
|
||||
{
|
||||
await _fixture.DisposeAsync();
|
||||
}
|
||||
|
||||
if (_container != null)
|
||||
{
|
||||
await _container.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Truncates all tables in the test schema for test isolation between test methods.
|
||||
/// </summary>
|
||||
public Task TruncateAllTablesAsync(CancellationToken cancellationToken = default)
|
||||
=> Fixture.TruncateAllTablesAsync(cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Executes raw SQL for test setup.
|
||||
/// </summary>
|
||||
public Task ExecuteSqlAsync(string sql, CancellationToken cancellationToken = default)
|
||||
=> Fixture.ExecuteSqlAsync(sql, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL integration fixture without migrations.
|
||||
/// Useful for testing the infrastructure itself or creating schemas dynamically.
|
||||
/// </summary>
|
||||
public sealed class PostgresIntegrationFixtureWithoutMigrations : PostgresIntegrationFixture
|
||||
{
|
||||
protected override Assembly? GetMigrationAssembly() => null;
|
||||
protected override string GetModuleName() => "Test";
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" ?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<RootNamespace>StellaOps.Infrastructure.Postgres.Testing</RootNamespace>
|
||||
<AssemblyName>StellaOps.Infrastructure.Postgres.Testing</AssemblyName>
|
||||
<Description>PostgreSQL test infrastructure for StellaOps module integration tests</Description>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Testcontainers.PostgreSql" Version="4.1.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Reflection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
|
||||
@@ -110,6 +111,85 @@ public sealed class MigrationRunner
|
||||
return appliedCount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs all pending migrations from embedded resources in an assembly.
|
||||
/// </summary>
|
||||
/// <param name="assembly">Assembly containing embedded migration resources.</param>
|
||||
/// <param name="resourcePrefix">Optional prefix to filter resources (e.g., "Migrations").</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Number of migrations applied.</returns>
|
||||
public async Task<int> RunFromAssemblyAsync(
|
||||
Assembly assembly,
|
||||
string? resourcePrefix = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(assembly);
|
||||
|
||||
var resourceNames = assembly.GetManifestResourceNames()
|
||||
.Where(name => name.EndsWith(".sql", StringComparison.OrdinalIgnoreCase))
|
||||
.Where(name => string.IsNullOrEmpty(resourcePrefix) || name.StartsWith(resourcePrefix, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(name => name)
|
||||
.ToList();
|
||||
|
||||
if (resourceNames.Count == 0)
|
||||
{
|
||||
_logger.LogInformation("No embedded migration resources found in assembly {Assembly} for module {Module}.",
|
||||
assembly.GetName().Name, _moduleName);
|
||||
return 0;
|
||||
}
|
||||
|
||||
await using var connection = new NpgsqlConnection(_connectionString);
|
||||
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Ensure schema exists
|
||||
await EnsureSchemaAsync(connection, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Ensure migrations table exists
|
||||
await EnsureMigrationsTableAsync(connection, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Get applied migrations
|
||||
var appliedMigrations = await GetAppliedMigrationsAsync(connection, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var appliedCount = 0;
|
||||
|
||||
foreach (var resourceName in resourceNames)
|
||||
{
|
||||
// Extract just the filename from the resource name
|
||||
var fileName = ExtractMigrationFileName(resourceName);
|
||||
|
||||
if (appliedMigrations.Contains(fileName))
|
||||
{
|
||||
_logger.LogDebug("Migration {Migration} already applied for module {Module}.",
|
||||
fileName, _moduleName);
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Applying migration {Migration} for module {Module}...",
|
||||
fileName, _moduleName);
|
||||
|
||||
await ApplyMigrationFromResourceAsync(connection, assembly, resourceName, fileName, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
appliedCount++;
|
||||
|
||||
_logger.LogInformation("Migration {Migration} applied successfully for module {Module}.",
|
||||
fileName, _moduleName);
|
||||
}
|
||||
|
||||
if (appliedCount > 0)
|
||||
{
|
||||
_logger.LogInformation("Applied {Count} embedded migration(s) for module {Module}.",
|
||||
appliedCount, _moduleName);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("Database is up to date for module {Module}.", _moduleName);
|
||||
}
|
||||
|
||||
return appliedCount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current migration version (latest applied migration).
|
||||
/// </summary>
|
||||
@@ -270,6 +350,63 @@ public sealed class MigrationRunner
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ApplyMigrationFromResourceAsync(
|
||||
NpgsqlConnection connection,
|
||||
Assembly assembly,
|
||||
string resourceName,
|
||||
string fileName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var stream = assembly.GetManifestResourceStream(resourceName)
|
||||
?? throw new InvalidOperationException($"Could not load embedded resource: {resourceName}");
|
||||
|
||||
using var reader = new StreamReader(stream);
|
||||
var sql = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
|
||||
var checksum = ComputeChecksum(sql);
|
||||
|
||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
// Run migration SQL
|
||||
await using (var migrationCommand = new NpgsqlCommand(sql, connection, transaction))
|
||||
{
|
||||
migrationCommand.CommandTimeout = 300; // 5 minute timeout for migrations
|
||||
await migrationCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Record migration
|
||||
await using (var recordCommand = new NpgsqlCommand(
|
||||
$"""
|
||||
INSERT INTO {_schemaName}.schema_migrations (migration_name, checksum)
|
||||
VALUES (@name, @checksum);
|
||||
""",
|
||||
connection,
|
||||
transaction))
|
||||
{
|
||||
recordCommand.Parameters.AddWithValue("name", fileName);
|
||||
recordCommand.Parameters.AddWithValue("checksum", checksum);
|
||||
await recordCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ExtractMigrationFileName(string resourceName)
|
||||
{
|
||||
// Resource names use the LogicalName from .csproj which is just the filename
|
||||
// e.g., "001_initial.sql" or might have path prefix like "Migrations/001_initial.sql"
|
||||
var lastSlash = resourceName.LastIndexOf('/');
|
||||
return lastSlash >= 0 ? resourceName[(lastSlash + 1)..] : resourceName;
|
||||
}
|
||||
|
||||
private static string ComputeChecksum(string content)
|
||||
{
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes(content);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Reflection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Npgsql;
|
||||
@@ -75,7 +76,7 @@ public sealed class PostgresFixture : IAsyncDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs migrations for the test schema.
|
||||
/// Runs migrations for the test schema from filesystem path.
|
||||
/// </summary>
|
||||
/// <param name="migrationsPath">Path to migration SQL files.</param>
|
||||
/// <param name="moduleName">Module name for logging.</param>
|
||||
@@ -94,6 +95,41 @@ public sealed class PostgresFixture : IAsyncDisposable
|
||||
await runner.RunAsync(migrationsPath, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs migrations for the test schema from embedded resources in an assembly.
|
||||
/// </summary>
|
||||
/// <param name="assembly">Assembly containing embedded migration resources.</param>
|
||||
/// <param name="moduleName">Module name for logging.</param>
|
||||
/// <param name="resourcePrefix">Optional prefix to filter resources.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public async Task RunMigrationsFromAssemblyAsync(
|
||||
Assembly assembly,
|
||||
string moduleName,
|
||||
string? resourcePrefix = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var runner = new MigrationRunner(
|
||||
_connectionString,
|
||||
_schemaName,
|
||||
moduleName,
|
||||
_logger);
|
||||
|
||||
await runner.RunFromAssemblyAsync(assembly, resourcePrefix, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs migrations for the test schema from embedded resources using a type from the assembly.
|
||||
/// </summary>
|
||||
/// <typeparam name="TAssemblyMarker">Type from the assembly containing migrations.</typeparam>
|
||||
/// <param name="moduleName">Module name for logging.</param>
|
||||
/// <param name="resourcePrefix">Optional prefix to filter resources.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public Task RunMigrationsFromAssemblyAsync<TAssemblyMarker>(
|
||||
string moduleName,
|
||||
string? resourcePrefix = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> RunMigrationsFromAssemblyAsync(typeof(TAssemblyMarker).Assembly, moduleName, resourcePrefix, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Executes raw SQL for test setup.
|
||||
/// </summary>
|
||||
|
||||
Reference in New Issue
Block a user