diff --git a/README.md b/README.md index 7d1b529..20bb0bc 100644 --- a/README.md +++ b/README.md @@ -293,6 +293,7 @@ Comprehensive documentation is available in the [`docs/`](docs/) directory: - **[Dashboard Charts & Splash Screen](docs/DASHBOARD_CHARTS_SPLASH_IMPLEMENTATION.md)** - Analytics features - **[Device List Separation](docs/DEVICE_LIST_SEPARATION.md)** - UI reorganization details - **[Certificate Enumeration](docs/CERTIFICATE_ENUMERATION.md)** - UEFI certificate tracking +- **[Certificate Compliance Policies](docs/CERTIFICATE_COMPLIANCE_POLICIES.md)** - Policy enforcement and compliance - **[Logo & Banner Implementation](docs/LOGO_BANNER_IMPLEMENTATION.md)** - Branding assets ### Operations & Troubleshooting @@ -486,7 +487,7 @@ For questions, issues, or support: ### v1.2 (Q2 2025) - [ ] Multi-tenant support with RBAC -- [ ] Certificate compliance policies +- [x] Certificate compliance policies - [ ] Automated remediation workflows - [ ] Enhanced analytics (30/60/90 day trends) diff --git a/SecureBootDashboard.Api.Tests/PolicyEvaluationServiceTests.cs b/SecureBootDashboard.Api.Tests/PolicyEvaluationServiceTests.cs new file mode 100644 index 0000000..ee436bb --- /dev/null +++ b/SecureBootDashboard.Api.Tests/PolicyEvaluationServiceTests.cs @@ -0,0 +1,352 @@ +using System; +using System.Collections.Generic; +using SecureBootDashboard.Api.Services; +using SecureBootWatcher.Shared.Models; +using Xunit; + +namespace SecureBootDashboard.Api.Tests +{ + public class PolicyEvaluationServiceTests + { + [Fact] + public void EvaluateCompliance_NoCertificates_ReturnsUnknown() + { + // Arrange + var policies = new List(); + var service = new PolicyEvaluationService(policies); + var deviceId = Guid.NewGuid(); + + // Act + var result = service.EvaluateCompliance(deviceId, null, null, null); + + // Assert + Assert.Equal(ComplianceStatus.Unknown, result.Status); + Assert.Equal(deviceId, result.DeviceId); + Assert.Empty(result.Violations); + } + + [Fact] + public void EvaluateCompliance_NoPolicies_ReturnsCompliant() + { + // Arrange + var policies = new List(); + var service = new PolicyEvaluationService(policies); + var deviceId = Guid.NewGuid(); + var certificates = CreateSampleCertificates(); + + // Act + var result = service.EvaluateCompliance(deviceId, null, certificates, null); + + // Assert + Assert.Equal(ComplianceStatus.Compliant, result.Status); + Assert.Empty(result.Violations); + } + + [Fact] + public void EvaluateCompliance_MinimumKeySize_DetectsViolation() + { + // Arrange + var policy = new CertificateCompliancePolicy + { + Id = Guid.NewGuid(), + Name = "Minimum Key Size Policy", + IsEnabled = true, + Priority = 1, + Rules = new List + { + new PolicyRule + { + RuleType = PolicyRuleType.MinimumKeySize, + Severity = PolicySeverity.Critical, + Value = "2048" + } + } + }; + + var policies = new List { policy }; + var service = new PolicyEvaluationService(policies); + var deviceId = Guid.NewGuid(); + + var certificates = new SecureBootCertificateCollection + { + SignatureDatabase = new List + { + new SecureBootCertificate + { + Database = "db", + Thumbprint = "ABC123", + Subject = "CN=Test Cert", + KeySize = 1024, // Below minimum + IsExpired = false + } + } + }; + + // Act + var result = service.EvaluateCompliance(deviceId, null, certificates, null); + + // Assert + Assert.Equal(ComplianceStatus.NonCompliant, result.Status); + Assert.Single(result.Violations); + Assert.Equal(policy.Id, result.Violations[0].PolicyId); + Assert.Contains("1024", result.Violations[0].Message); + Assert.Contains("2048", result.Violations[0].Message); + } + + [Fact] + public void EvaluateCompliance_ExpiredCertificate_DetectsViolation() + { + // Arrange + var policy = new CertificateCompliancePolicy + { + Id = Guid.NewGuid(), + Name = "No Expired Certs Policy", + IsEnabled = true, + Priority = 1, + Rules = new List + { + new PolicyRule + { + RuleType = PolicyRuleType.DisallowExpiredCertificates, + Severity = PolicySeverity.Critical + } + } + }; + + var policies = new List { policy }; + var service = new PolicyEvaluationService(policies); + var deviceId = Guid.NewGuid(); + + var certificates = new SecureBootCertificateCollection + { + SignatureDatabase = new List + { + new SecureBootCertificate + { + Database = "db", + Thumbprint = "DEF456", + Subject = "CN=Expired Cert", + IsExpired = true, + NotAfter = DateTimeOffset.UtcNow.AddDays(-10) + } + } + }; + + // Act + var result = service.EvaluateCompliance(deviceId, null, certificates, null); + + // Assert + Assert.Equal(ComplianceStatus.NonCompliant, result.Status); + Assert.Single(result.Violations); + Assert.Contains("expired", result.Violations[0].Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void EvaluateCompliance_RequireMicrosoftCert_DetectsViolation() + { + // Arrange + var policy = new CertificateCompliancePolicy + { + Id = Guid.NewGuid(), + Name = "Microsoft Only Policy", + IsEnabled = true, + Priority = 1, + Rules = new List + { + new PolicyRule + { + RuleType = PolicyRuleType.RequireMicrosoftCertificate, + Severity = PolicySeverity.Warning + } + } + }; + + var policies = new List { policy }; + var service = new PolicyEvaluationService(policies); + var deviceId = Guid.NewGuid(); + + var certificates = new SecureBootCertificateCollection + { + SignatureDatabase = new List + { + new SecureBootCertificate + { + Database = "db", + Thumbprint = "GHI789", + Subject = "CN=Third Party Cert", + IsMicrosoftCertificate = false + } + } + }; + + // Act + var result = service.EvaluateCompliance(deviceId, null, certificates, null); + + // Assert + Assert.Equal(ComplianceStatus.Warning, result.Status); + Assert.Single(result.Violations); + Assert.Contains("Microsoft", result.Violations[0].Message); + } + + [Fact] + public void EvaluateCompliance_MinimumDaysUntilExpiration_DetectsViolation() + { + // Arrange + var policy = new CertificateCompliancePolicy + { + Id = Guid.NewGuid(), + Name = "Expiration Warning Policy", + IsEnabled = true, + Priority = 1, + Rules = new List + { + new PolicyRule + { + RuleType = PolicyRuleType.MinimumDaysUntilExpiration, + Severity = PolicySeverity.Warning, + Value = "90" + } + } + }; + + var policies = new List { policy }; + var service = new PolicyEvaluationService(policies); + var deviceId = Guid.NewGuid(); + + var certificates = new SecureBootCertificateCollection + { + SignatureDatabase = new List + { + new SecureBootCertificate + { + Database = "db", + Thumbprint = "JKL012", + Subject = "CN=Expiring Soon", + DaysUntilExpiration = 30, // Below 90 day threshold + IsExpired = false + } + } + }; + + // Act + var result = service.EvaluateCompliance(deviceId, null, certificates, null); + + // Assert + Assert.Equal(ComplianceStatus.Warning, result.Status); + Assert.Single(result.Violations); + Assert.Contains("30", result.Violations[0].Message); + Assert.Contains("90", result.Violations[0].Message); + } + + [Fact] + public void EvaluateCompliance_DisabledPolicy_IsIgnored() + { + // Arrange + var policy = new CertificateCompliancePolicy + { + Id = Guid.NewGuid(), + Name = "Disabled Policy", + IsEnabled = false, // Disabled + Priority = 1, + Rules = new List + { + new PolicyRule + { + RuleType = PolicyRuleType.DisallowExpiredCertificates, + Severity = PolicySeverity.Critical + } + } + }; + + var policies = new List { policy }; + var service = new PolicyEvaluationService(policies); + var deviceId = Guid.NewGuid(); + + var certificates = new SecureBootCertificateCollection + { + SignatureDatabase = new List + { + new SecureBootCertificate + { + Database = "db", + Thumbprint = "MNO345", + IsExpired = true // Would violate if policy was enabled + } + } + }; + + // Act + var result = service.EvaluateCompliance(deviceId, null, certificates, null); + + // Assert + Assert.Equal(ComplianceStatus.Compliant, result.Status); + Assert.Empty(result.Violations); + } + + [Fact] + public void EvaluateCompliance_FleetScoped_OnlyAppliesToMatchingFleet() + { + // Arrange + var policy = new CertificateCompliancePolicy + { + Id = Guid.NewGuid(), + Name = "Fleet-Specific Policy", + IsEnabled = true, + Priority = 1, + FleetId = "fleet-01", + Rules = new List + { + new PolicyRule + { + RuleType = PolicyRuleType.DisallowExpiredCertificates, + Severity = PolicySeverity.Critical + } + } + }; + + var policies = new List { policy }; + var service = new PolicyEvaluationService(policies); + var deviceId = Guid.NewGuid(); + + var certificates = new SecureBootCertificateCollection + { + SignatureDatabase = new List + { + new SecureBootCertificate + { + Database = "db", + Thumbprint = "PQR678", + IsExpired = true + } + } + }; + + // Act - device in different fleet + var result = service.EvaluateCompliance(deviceId, null, certificates, "fleet-02"); + + // Assert + Assert.Equal(ComplianceStatus.Compliant, result.Status); + Assert.Empty(result.Violations); + } + + private SecureBootCertificateCollection CreateSampleCertificates() + { + return new SecureBootCertificateCollection + { + SignatureDatabase = new List + { + new SecureBootCertificate + { + Database = "db", + Thumbprint = "123ABC", + Subject = "CN=Microsoft Corporation", + KeySize = 2048, + IsExpired = false, + IsMicrosoftCertificate = true, + DaysUntilExpiration = 365 + } + } + }; + } + } +} diff --git a/SecureBootDashboard.Api/Controllers/ComplianceController.cs b/SecureBootDashboard.Api/Controllers/ComplianceController.cs new file mode 100644 index 0000000..46f9c7d --- /dev/null +++ b/SecureBootDashboard.Api/Controllers/ComplianceController.cs @@ -0,0 +1,277 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using SecureBootDashboard.Api.Data; +using SecureBootDashboard.Api.Services; +using SecureBootWatcher.Shared.Models; + +namespace SecureBootDashboard.Api.Controllers +{ + /// + /// API endpoints for device compliance evaluation. + /// + [ApiController] + [Route("api/[controller]")] + public class ComplianceController : ControllerBase + { + private readonly SecureBootDbContext _dbContext; + private readonly ILogger _logger; + + public ComplianceController( + SecureBootDbContext dbContext, + ILogger logger) + { + _dbContext = dbContext; + _logger = logger; + } + + /// + /// Evaluate compliance for a specific device using its latest report. + /// + [HttpGet("devices/{deviceId}")] + public async Task> EvaluateDevice(Guid deviceId) + { + try + { + var device = await _dbContext.Devices + .Include(d => d.Reports.OrderByDescending(r => r.CreatedAtUtc).Take(1)) + .FirstOrDefaultAsync(d => d.Id == deviceId); + + if (device == null) + { + return NotFound($"Device {deviceId} not found"); + } + + var latestReport = device.Reports.FirstOrDefault(); + if (latestReport == null) + { + return NotFound($"No reports found for device {deviceId}"); + } + + // Deserialize certificates + SecureBootCertificateCollection? certificates = null; + if (!string.IsNullOrEmpty(latestReport.CertificatesJson)) + { + try + { + certificates = JsonSerializer.Deserialize(latestReport.CertificatesJson); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to deserialize certificates for device {DeviceId}", deviceId); + } + } + + // Load policies + var policies = await LoadPoliciesAsync(); + + // Evaluate compliance + var evaluationService = new PolicyEvaluationService(policies); + var result = evaluationService.EvaluateCompliance( + deviceId, + latestReport.Id, + certificates, + device.FleetId); + + return Ok(result); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error evaluating compliance for device {DeviceId}", deviceId); + return StatusCode(500, "Internal server error"); + } + } + + /// + /// Evaluate compliance for all devices. + /// + [HttpGet("devices")] + public async Task>> EvaluateAllDevices() + { + try + { + var devices = await _dbContext.Devices + .Include(d => d.Reports.OrderByDescending(r => r.CreatedAtUtc).Take(1)) + .ToListAsync(); + + // Load policies once + var policies = await LoadPoliciesAsync(); + var evaluationService = new PolicyEvaluationService(policies); + + var results = new List(); + + foreach (var device in devices) + { + var latestReport = device.Reports.FirstOrDefault(); + if (latestReport == null) + { + continue; + } + + // Deserialize certificates + SecureBootCertificateCollection? certificates = null; + if (!string.IsNullOrEmpty(latestReport.CertificatesJson)) + { + try + { + certificates = JsonSerializer.Deserialize(latestReport.CertificatesJson); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to deserialize certificates for device {DeviceId}", device.Id); + } + } + + var result = evaluationService.EvaluateCompliance( + device.Id, + latestReport.Id, + certificates, + device.FleetId); + + results.Add(result); + } + + return Ok(results); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error evaluating compliance for all devices"); + return StatusCode(500, "Internal server error"); + } + } + + /// + /// Get compliance summary statistics. + /// + [HttpGet("summary")] + public async Task> GetComplianceSummary() + { + try + { + var devices = await _dbContext.Devices + .Include(d => d.Reports.OrderByDescending(r => r.CreatedAtUtc).Take(1)) + .ToListAsync(); + + var policies = await LoadPoliciesAsync(); + var evaluationService = new PolicyEvaluationService(policies); + + var summary = new ComplianceSummary(); + + foreach (var device in devices) + { + var latestReport = device.Reports.FirstOrDefault(); + if (latestReport == null) + { + summary.UnknownCount++; + continue; + } + + SecureBootCertificateCollection? certificates = null; + if (!string.IsNullOrEmpty(latestReport.CertificatesJson)) + { + try + { + certificates = JsonSerializer.Deserialize(latestReport.CertificatesJson); + } + catch (JsonException ex) + { + _logger.LogWarning(ex, "Failed to deserialize certificates for device {DeviceId}, report {ReportId}", + device.Id, latestReport.Id); + } + } + + var result = evaluationService.EvaluateCompliance( + device.Id, + latestReport.Id, + certificates, + device.FleetId); + + switch (result.Status) + { + case ComplianceStatus.Compliant: + summary.CompliantCount++; + break; + case ComplianceStatus.Warning: + summary.WarningCount++; + break; + case ComplianceStatus.NonCompliant: + summary.NonCompliantCount++; + break; + default: + summary.UnknownCount++; + break; + } + } + + summary.TotalDevices = devices.Count; + summary.EvaluatedAtUtc = DateTimeOffset.UtcNow; + + return Ok(summary); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error generating compliance summary"); + return StatusCode(500, "Internal server error"); + } + } + + private async Task> LoadPoliciesAsync() + { + var policyEntities = await _dbContext.Policies + .Where(p => p.IsEnabled) + .OrderBy(p => p.Priority) + .ToListAsync(); + + var policies = new List(); + + foreach (var entity in policyEntities) + { + var rules = new List(); + if (!string.IsNullOrEmpty(entity.RulesJson)) + { + try + { + rules = JsonSerializer.Deserialize>(entity.RulesJson) ?? new List(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to deserialize rules for policy {PolicyId}", entity.Id); + } + } + + policies.Add(new CertificateCompliancePolicy + { + Id = entity.Id, + Name = entity.Name, + Description = entity.Description, + IsEnabled = entity.IsEnabled, + Priority = entity.Priority, + FleetId = entity.FleetId, + Rules = rules, + CreatedAtUtc = entity.CreatedAtUtc, + ModifiedAtUtc = entity.ModifiedAtUtc + }); + } + + return policies; + } + } + + /// + /// Summary of compliance status across all devices. + /// + public sealed class ComplianceSummary + { + public int TotalDevices { get; set; } + public int CompliantCount { get; set; } + public int WarningCount { get; set; } + public int NonCompliantCount { get; set; } + public int UnknownCount { get; set; } + public DateTimeOffset EvaluatedAtUtc { get; set; } + } +} diff --git a/SecureBootDashboard.Api/Controllers/PoliciesController.cs b/SecureBootDashboard.Api/Controllers/PoliciesController.cs new file mode 100644 index 0000000..434223d --- /dev/null +++ b/SecureBootDashboard.Api/Controllers/PoliciesController.cs @@ -0,0 +1,222 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using SecureBootDashboard.Api.Data; +using SecureBootWatcher.Shared.Models; + +namespace SecureBootDashboard.Api.Controllers +{ + /// + /// API endpoints for managing certificate compliance policies. + /// + [ApiController] + [Route("api/[controller]")] + public class PoliciesController : ControllerBase + { + private readonly SecureBootDbContext _dbContext; + private readonly ILogger _logger; + + public PoliciesController( + SecureBootDbContext dbContext, + ILogger logger) + { + _dbContext = dbContext; + _logger = logger; + } + + /// + /// Get all compliance policies. + /// + [HttpGet] + public async Task>> GetAllPolicies() + { + try + { + var entities = await _dbContext.Policies + .OrderBy(p => p.Priority) + .ToListAsync(); + + var policies = entities.Select(MapToModel).ToList(); + return Ok(policies); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving policies"); + return StatusCode(500, "Internal server error"); + } + } + + /// + /// Get a specific policy by ID. + /// + [HttpGet("{id}")] + public async Task> GetPolicy(Guid id) + { + try + { + var entity = await _dbContext.Policies.FindAsync(id); + if (entity == null) + { + return NotFound(); + } + + var policy = MapToModel(entity); + return Ok(policy); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving policy {PolicyId}", id); + return StatusCode(500, "Internal server error"); + } + } + + /// + /// Create a new compliance policy. + /// + [HttpPost] + public async Task> CreatePolicy( + [FromBody] CertificateCompliancePolicy policy) + { + try + { + if (string.IsNullOrWhiteSpace(policy.Name)) + { + return BadRequest("Policy name is required"); + } + + policy.Id = Guid.NewGuid(); + policy.CreatedAtUtc = DateTimeOffset.UtcNow; + policy.ModifiedAtUtc = null; + + var entity = MapToEntity(policy); + _dbContext.Policies.Add(entity); + await _dbContext.SaveChangesAsync(); + + _logger.LogInformation("Created policy {PolicyId}", policy.Id); + return CreatedAtAction(nameof(GetPolicy), new { id = policy.Id }, policy); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating policy"); + return StatusCode(500, "Internal server error"); + } + } + + /// + /// Update an existing compliance policy. + /// + [HttpPut("{id}")] + public async Task> UpdatePolicy( + Guid id, + [FromBody] CertificateCompliancePolicy policy) + { + try + { + if (id != policy.Id) + { + return BadRequest("Policy ID mismatch"); + } + + var entity = await _dbContext.Policies.FindAsync(id); + if (entity == null) + { + return NotFound(); + } + + policy.ModifiedAtUtc = DateTimeOffset.UtcNow; + policy.CreatedAtUtc = entity.CreatedAtUtc; // Preserve original creation time + + var updatedEntity = MapToEntity(policy); + _dbContext.Entry(entity).CurrentValues.SetValues(updatedEntity); + await _dbContext.SaveChangesAsync(); + + _logger.LogInformation("Updated policy {PolicyId}", policy.Id); + return Ok(policy); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error updating policy {PolicyId}", id); + return StatusCode(500, "Internal server error"); + } + } + + /// + /// Delete a compliance policy. + /// + [HttpDelete("{id}")] + public async Task DeletePolicy(Guid id) + { + try + { + var entity = await _dbContext.Policies.FindAsync(id); + if (entity == null) + { + return NotFound(); + } + + _dbContext.Policies.Remove(entity); + await _dbContext.SaveChangesAsync(); + + _logger.LogInformation("Deleted policy {PolicyId}", id); + return NoContent(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting policy {PolicyId}", id); + return StatusCode(500, "Internal server error"); + } + } + + private CertificateCompliancePolicy MapToModel(PolicyEntity entity) + { + var rules = new List(); + if (!string.IsNullOrEmpty(entity.RulesJson)) + { + try + { + rules = JsonSerializer.Deserialize>(entity.RulesJson) ?? new List(); + } + catch (JsonException ex) + { + // Log deserialization failure but continue with empty rules + _logger.LogWarning(ex, "Failed to deserialize rules for policy '{PolicyName}' (ID: {PolicyId})", + entity.Name, entity.Id); + } + } + + return new CertificateCompliancePolicy + { + Id = entity.Id, + Name = entity.Name, + Description = entity.Description, + IsEnabled = entity.IsEnabled, + Priority = entity.Priority, + FleetId = entity.FleetId, + Rules = rules, + CreatedAtUtc = entity.CreatedAtUtc, + ModifiedAtUtc = entity.ModifiedAtUtc + }; + } + + private static PolicyEntity MapToEntity(CertificateCompliancePolicy policy) + { + return new PolicyEntity + { + Id = policy.Id, + Name = policy.Name, + Description = policy.Description, + IsEnabled = policy.IsEnabled, + Priority = policy.Priority, + FleetId = policy.FleetId, + RulesJson = JsonSerializer.Serialize(policy.Rules), + CreatedAtUtc = policy.CreatedAtUtc, + ModifiedAtUtc = policy.ModifiedAtUtc + }; + } + } +} diff --git a/SecureBootDashboard.Api/Data/Migrations/20251107060842_AddCertificateCompliancePolicies.Designer.cs b/SecureBootDashboard.Api/Data/Migrations/20251107060842_AddCertificateCompliancePolicies.Designer.cs new file mode 100644 index 0000000..90de072 --- /dev/null +++ b/SecureBootDashboard.Api/Data/Migrations/20251107060842_AddCertificateCompliancePolicies.Designer.cs @@ -0,0 +1,239 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using SecureBootDashboard.Api.Data; + +#nullable disable + +namespace SecureBootDashboard.Api.Data.Migrations +{ + [DbContext(typeof(SecureBootDbContext))] + [Migration("20251107060842_AddCertificateCompliancePolicies")] + partial class AddCertificateCompliancePolicies + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("SecureBootDashboard.Api.Data.DeviceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("DomainName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("FirmwareVersion") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("FleetId") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("LastSeenUtc") + .HasColumnType("datetimeoffset"); + + b.Property("MachineName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Manufacturer") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Model") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("TagsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("UserPrincipalName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("MachineName"); + + b.ToTable("Devices", (string)null); + }); + + modelBuilder.Entity("SecureBootDashboard.Api.Data.PolicyEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("FleetId") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("IsEnabled") + .HasColumnType("bit"); + + b.Property("ModifiedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Priority") + .HasColumnType("int"); + + b.Property("RulesJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("FleetId"); + + b.HasIndex("IsEnabled"); + + b.ToTable("Policies", (string)null); + }); + + modelBuilder.Entity("SecureBootDashboard.Api.Data.SecureBootEventEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("EventId") + .HasColumnType("int"); + + b.Property("Level") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Message") + .HasColumnType("nvarchar(max)"); + + b.Property("ProviderName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("RawXml") + .HasColumnType("nvarchar(max)"); + + b.Property("ReportId") + .HasColumnType("uniqueidentifier"); + + b.Property("TimestampUtc") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("ReportId"); + + b.HasIndex("TimestampUtc"); + + b.ToTable("SecureBootEvents", (string)null); + }); + + modelBuilder.Entity("SecureBootDashboard.Api.Data.SecureBootReportEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AlertsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("CertificatesJson") + .HasColumnType("nvarchar(max)"); + + b.Property("ClientVersion") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CorrelationId") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("DeploymentState") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DeviceId") + .HasColumnType("uniqueidentifier"); + + b.Property("RegistryStateJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAtUtc"); + + b.HasIndex("DeviceId"); + + b.ToTable("SecureBootReports", (string)null); + }); + + modelBuilder.Entity("SecureBootDashboard.Api.Data.SecureBootEventEntity", b => + { + b.HasOne("SecureBootDashboard.Api.Data.SecureBootReportEntity", "Report") + .WithMany("Events") + .HasForeignKey("ReportId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Report"); + }); + + modelBuilder.Entity("SecureBootDashboard.Api.Data.SecureBootReportEntity", b => + { + b.HasOne("SecureBootDashboard.Api.Data.DeviceEntity", "Device") + .WithMany("Reports") + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Device"); + }); + + modelBuilder.Entity("SecureBootDashboard.Api.Data.DeviceEntity", b => + { + b.Navigation("Reports"); + }); + + modelBuilder.Entity("SecureBootDashboard.Api.Data.SecureBootReportEntity", b => + { + b.Navigation("Events"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SecureBootDashboard.Api/Data/Migrations/20251107060842_AddCertificateCompliancePolicies.cs b/SecureBootDashboard.Api/Data/Migrations/20251107060842_AddCertificateCompliancePolicies.cs new file mode 100644 index 0000000..a27892f --- /dev/null +++ b/SecureBootDashboard.Api/Data/Migrations/20251107060842_AddCertificateCompliancePolicies.cs @@ -0,0 +1,51 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SecureBootDashboard.Api.Data.Migrations +{ + /// + public partial class AddCertificateCompliancePolicies : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Policies", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + Name = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: false), + Description = table.Column(type: "nvarchar(2000)", maxLength: 2000, nullable: true), + IsEnabled = table.Column(type: "bit", nullable: false), + Priority = table.Column(type: "int", nullable: false), + FleetId = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: true), + RulesJson = table.Column(type: "nvarchar(max)", nullable: false), + CreatedAtUtc = table.Column(type: "datetimeoffset", nullable: false), + ModifiedAtUtc = table.Column(type: "datetimeoffset", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Policies", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_Policies_FleetId", + table: "Policies", + column: "FleetId"); + + migrationBuilder.CreateIndex( + name: "IX_Policies_IsEnabled", + table: "Policies", + column: "IsEnabled"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Policies"); + } + } +} diff --git a/SecureBootDashboard.Api/Data/Migrations/SecureBootDbContextModelSnapshot.cs b/SecureBootDashboard.Api/Data/Migrations/SecureBootDbContextModelSnapshot.cs index eceb1e0..cd3d226 100644 --- a/SecureBootDashboard.Api/Data/Migrations/SecureBootDbContextModelSnapshot.cs +++ b/SecureBootDashboard.Api/Data/Migrations/SecureBootDbContextModelSnapshot.cs @@ -73,6 +73,50 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Devices", (string)null); }); + modelBuilder.Entity("SecureBootDashboard.Api.Data.PolicyEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("FleetId") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("IsEnabled") + .HasColumnType("bit"); + + b.Property("ModifiedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Priority") + .HasColumnType("int"); + + b.Property("RulesJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("FleetId"); + + b.HasIndex("IsEnabled"); + + b.ToTable("Policies", (string)null); + }); + modelBuilder.Entity("SecureBootDashboard.Api.Data.SecureBootEventEntity", b => { b.Property("Id") diff --git a/SecureBootDashboard.Api/Data/PolicyEntity.cs b/SecureBootDashboard.Api/Data/PolicyEntity.cs new file mode 100644 index 0000000..22d9659 --- /dev/null +++ b/SecureBootDashboard.Api/Data/PolicyEntity.cs @@ -0,0 +1,31 @@ +using System; + +namespace SecureBootDashboard.Api.Data +{ + /// + /// Entity for storing certificate compliance policies in the database. + /// + public sealed class PolicyEntity + { + public Guid Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public string? Description { get; set; } + + public bool IsEnabled { get; set; } + + public int Priority { get; set; } + + public string? FleetId { get; set; } + + /// + /// JSON-serialized policy rules. + /// + public string RulesJson { get; set; } = string.Empty; + + public DateTimeOffset CreatedAtUtc { get; set; } + + public DateTimeOffset? ModifiedAtUtc { get; set; } + } +} diff --git a/SecureBootDashboard.Api/Data/SecureBootDbContext.cs b/SecureBootDashboard.Api/Data/SecureBootDbContext.cs index 5348994..bdad4d4 100644 --- a/SecureBootDashboard.Api/Data/SecureBootDbContext.cs +++ b/SecureBootDashboard.Api/Data/SecureBootDbContext.cs @@ -15,6 +15,8 @@ public SecureBootDbContext(DbContextOptions options) public DbSet Events => Set(); + public DbSet Policies => Set(); + protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); @@ -65,6 +67,18 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasForeignKey(e => e.ReportId) .OnDelete(DeleteBehavior.Cascade); }); + + modelBuilder.Entity(entity => + { + entity.ToTable("Policies"); + entity.HasKey(e => e.Id); + entity.Property(e => e.Name).HasMaxLength(256).IsRequired(); + entity.Property(e => e.Description).HasMaxLength(2000); + entity.Property(e => e.FleetId).HasMaxLength(128); + entity.Property(e => e.RulesJson).HasColumnType("nvarchar(max)").IsRequired(); + entity.HasIndex(e => e.IsEnabled); + entity.HasIndex(e => e.FleetId); + }); } } } diff --git a/SecureBootDashboard.Api/Services/PolicyEvaluationService.cs b/SecureBootDashboard.Api/Services/PolicyEvaluationService.cs new file mode 100644 index 0000000..cf2c5fd --- /dev/null +++ b/SecureBootDashboard.Api/Services/PolicyEvaluationService.cs @@ -0,0 +1,242 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using SecureBootWatcher.Shared.Models; + +namespace SecureBootDashboard.Api.Services +{ + /// + /// Service for evaluating device compliance against certificate policies. + /// + public interface IPolicyEvaluationService + { + /// + /// Evaluates a device's certificates against active policies. + /// + ComplianceResult EvaluateCompliance( + Guid deviceId, + Guid? reportId, + SecureBootCertificateCollection? certificates, + string? fleetId); + } + + public sealed class PolicyEvaluationService : IPolicyEvaluationService + { + private readonly IEnumerable _policies; + + public PolicyEvaluationService(IEnumerable policies) + { + _policies = policies ?? throw new ArgumentNullException(nameof(policies)); + } + + public ComplianceResult EvaluateCompliance( + Guid deviceId, + Guid? reportId, + SecureBootCertificateCollection? certificates, + string? fleetId) + { + var result = new ComplianceResult + { + DeviceId = deviceId, + ReportId = reportId, + EvaluatedAtUtc = DateTimeOffset.UtcNow, + Status = ComplianceStatus.Compliant + }; + + if (certificates == null || certificates.TotalCertificateCount == 0) + { + result.Status = ComplianceStatus.Unknown; + return result; + } + + // Get applicable policies + var applicablePolicies = _policies + .Where(p => p.IsEnabled) + .Where(p => string.IsNullOrEmpty(p.FleetId) || p.FleetId == fleetId) + .OrderBy(p => p.Priority) + .ToList(); + + foreach (var policy in applicablePolicies) + { + EvaluatePolicy(policy, certificates, result); + } + + // Determine overall status based on violations + if (result.Violations.Any(v => v.Rule.Severity == PolicySeverity.Critical)) + { + result.Status = ComplianceStatus.NonCompliant; + } + else if (result.Violations.Any(v => v.Rule.Severity == PolicySeverity.Warning)) + { + result.Status = ComplianceStatus.Warning; + } + + return result; + } + + private void EvaluatePolicy( + CertificateCompliancePolicy policy, + SecureBootCertificateCollection certificates, + ComplianceResult result) + { + foreach (var rule in policy.Rules) + { + EvaluateRule(policy, rule, certificates, result); + } + } + + private void EvaluateRule( + CertificateCompliancePolicy policy, + PolicyRule rule, + SecureBootCertificateCollection certificates, + ComplianceResult result) + { + // Gather all certificates from all databases + var allCertificates = new List(); + allCertificates.AddRange(certificates.SignatureDatabase ?? new List()); + allCertificates.AddRange(certificates.ForbiddenDatabase ?? new List()); + allCertificates.AddRange(certificates.KeyExchangeKeys ?? new List()); + allCertificates.AddRange(certificates.PlatformKeys ?? new List()); + + // Apply database filter if specified + var certsToCheck = allCertificates; + if (!string.IsNullOrEmpty(rule.DatabaseFilter)) + { + certsToCheck = certsToCheck + .Where(c => c.Database?.Equals(rule.DatabaseFilter, StringComparison.OrdinalIgnoreCase) == true) + .ToList(); + } + + foreach (var cert in certsToCheck) + { + var violation = EvaluateCertificateAgainstRule(policy, rule, cert); + if (violation != null) + { + result.Violations.Add(violation); + } + } + } + + private PolicyViolation? EvaluateCertificateAgainstRule( + CertificateCompliancePolicy policy, + PolicyRule rule, + SecureBootCertificate cert) + { + switch (rule.RuleType) + { + case PolicyRuleType.MinimumKeySize: + if (int.TryParse(rule.Value, out int minKeySize)) + { + if (cert.KeySize.HasValue && cert.KeySize.Value < minKeySize) + { + return CreateViolation(policy, rule, cert, + $"Certificate key size ({cert.KeySize} bits) is below minimum ({minKeySize} bits)"); + } + } + break; + + case PolicyRuleType.AllowedSignatureAlgorithms: + if (!string.IsNullOrEmpty(rule.Value) && !string.IsNullOrEmpty(cert.SignatureAlgorithm)) + { + var allowed = rule.Value.Split(',').Select(a => a.Trim()).ToList(); + if (!allowed.Any(a => cert.SignatureAlgorithm.Contains(a, StringComparison.OrdinalIgnoreCase))) + { + return CreateViolation(policy, rule, cert, + $"Certificate signature algorithm '{cert.SignatureAlgorithm}' is not in allowed list: {rule.Value}"); + } + } + break; + + case PolicyRuleType.DisallowedSignatureAlgorithms: + if (!string.IsNullOrEmpty(rule.Value) && !string.IsNullOrEmpty(cert.SignatureAlgorithm)) + { + var disallowed = rule.Value.Split(',').Select(a => a.Trim()).ToList(); + if (disallowed.Any(a => cert.SignatureAlgorithm.Contains(a, StringComparison.OrdinalIgnoreCase))) + { + return CreateViolation(policy, rule, cert, + $"Certificate signature algorithm '{cert.SignatureAlgorithm}' is disallowed"); + } + } + break; + + case PolicyRuleType.MaximumCertificateAge: + if (int.TryParse(rule.Value, out int maxAgeDays) && cert.NotBefore.HasValue) + { + var age = (DateTimeOffset.UtcNow - cert.NotBefore.Value).Days; + if (age > maxAgeDays) + { + return CreateViolation(policy, rule, cert, + $"Certificate age ({age} days) exceeds maximum ({maxAgeDays} days)"); + } + } + break; + + case PolicyRuleType.MinimumDaysUntilExpiration: + if (int.TryParse(rule.Value, out int minDays) && cert.DaysUntilExpiration.HasValue) + { + if (cert.DaysUntilExpiration.Value < minDays) + { + return CreateViolation(policy, rule, cert, + $"Certificate expires in {cert.DaysUntilExpiration} days, which is below threshold ({minDays} days)"); + } + } + break; + + case PolicyRuleType.RequireMicrosoftCertificate: + if (!cert.IsMicrosoftCertificate) + { + return CreateViolation(policy, rule, cert, + "Certificate is not issued by Microsoft"); + } + break; + + case PolicyRuleType.DisallowExpiredCertificates: + if (cert.IsExpired) + { + return CreateViolation(policy, rule, cert, + $"Certificate is expired (expired on {cert.NotAfter?.ToString("yyyy-MM-dd")})"); + } + break; + + case PolicyRuleType.RequireSubjectPattern: + if (!string.IsNullOrEmpty(rule.Value) && !string.IsNullOrEmpty(cert.Subject)) + { + try + { + if (!Regex.IsMatch(cert.Subject, rule.Value)) + { + return CreateViolation(policy, rule, cert, + $"Certificate subject '{cert.Subject}' does not match required pattern: {rule.Value}"); + } + } + catch (ArgumentException) + { + // Invalid regex pattern - skip this rule + // Note: Policy validation should catch this at creation time + } + } + break; + } + + return null; + } + + private PolicyViolation CreateViolation( + CertificateCompliancePolicy policy, + PolicyRule rule, + SecureBootCertificate cert, + string message) + { + return new PolicyViolation + { + PolicyId = policy.Id, + PolicyName = policy.Name, + Rule = rule, + Message = message, + CertificateThumbprint = cert.Thumbprint, + Database = cert.Database + }; + } + } +} diff --git a/SecureBootDashboard.Web/Pages/Devices/Details.cshtml b/SecureBootDashboard.Web/Pages/Devices/Details.cshtml index da245a2..d33af0d 100644 --- a/SecureBootDashboard.Web/Pages/Devices/Details.cshtml +++ b/SecureBootDashboard.Web/Pages/Devices/Details.cshtml @@ -104,6 +104,118 @@ + @* Compliance Status Card *@ + @if (Model.ComplianceStatus != null) + { +
+
+
Certificate Compliance
+
+
+
+
+
+ @switch (Model.ComplianceStatus.Status) + { + case SecureBootWatcher.Shared.Models.ComplianceStatus.Compliant: + +
Compliant
+

All policies passed

+ break; + case SecureBootWatcher.Shared.Models.ComplianceStatus.Warning: + +
Warning
+

@Model.ComplianceStatus.Violations.Count warning(s)

+ break; + case SecureBootWatcher.Shared.Models.ComplianceStatus.NonCompliant: + +
Non-Compliant
+

@Model.ComplianceStatus.Violations.Count violation(s)

+ break; + default: + +
Unknown
+

Cannot determine status

+ break; + } +
+
+
+ @if (Model.ComplianceStatus.Violations.Any()) + { +
Policy Violations
+
+ + + + + + + + + + + @foreach (var violation in Model.ComplianceStatus.Violations) + { + + + + + + + } + +
SeverityPolicyMessageCertificate
+ @switch (violation.Rule.Severity) + { + case SecureBootWatcher.Shared.Models.PolicySeverity.Critical: + Critical + break; + case SecureBootWatcher.Shared.Models.PolicySeverity.Warning: + Warning + break; + default: + Info + break; + } + @violation.PolicyName@violation.Message + @if (!string.IsNullOrEmpty(violation.CertificateThumbprint)) + { + + @violation.CertificateThumbprint.Substring(0, Math.Min(12, violation.CertificateThumbprint.Length))... + @if (!string.IsNullOrEmpty(violation.Database)) + { + @violation.Database + } + + } + else + { + - + } +
+
+ } + else + { +
+ This device meets all certificate compliance requirements. +
+ } +
+ + Evaluated: @Model.ComplianceStatus.EvaluatedAtUtc.ToString("yyyy-MM-dd HH:mm:ss") UTC + + + Manage Policies + +
+
+
+
+
+ } +
diff --git a/SecureBootDashboard.Web/Pages/Devices/Details.cshtml.cs b/SecureBootDashboard.Web/Pages/Devices/Details.cshtml.cs index cb085df..48f88ae 100644 --- a/SecureBootDashboard.Web/Pages/Devices/Details.cshtml.cs +++ b/SecureBootDashboard.Web/Pages/Devices/Details.cshtml.cs @@ -1,7 +1,9 @@ +using System.Text.Json; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using SecureBootDashboard.Web.Services; +using SecureBootWatcher.Shared.Models; namespace SecureBootDashboard.Web.Pages.Devices; @@ -9,15 +11,21 @@ namespace SecureBootDashboard.Web.Pages.Devices; public class DetailsModel : PageModel { private readonly ISecureBootApiClient _apiClient; + private readonly IHttpClientFactory _httpClientFactory; private readonly ILogger _logger; - public DetailsModel(ISecureBootApiClient apiClient, ILogger logger) + public DetailsModel( + ISecureBootApiClient apiClient, + IHttpClientFactory httpClientFactory, + ILogger logger) { _apiClient = apiClient; + _httpClientFactory = httpClientFactory; _logger = logger; } public DeviceDetail? Device { get; private set; } + public ComplianceResult? ComplianceStatus { get; private set; } public string? ErrorMessage { get; private set; } public async Task OnGetAsync(Guid id) @@ -32,6 +40,27 @@ public async Task OnGetAsync(Guid id) return Page(); } + // Try to load compliance status + try + { + var client = _httpClientFactory.CreateClient("SecureBootApi"); + var response = await client.GetAsync($"/api/Compliance/devices/{id}"); + + if (response.IsSuccessStatusCode) + { + var json = await response.Content.ReadAsStringAsync(); + ComplianceStatus = JsonSerializer.Deserialize(json, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to load compliance status for device {DeviceId}", id); + // Don't fail the whole page if compliance check fails + } + return Page(); } catch (Exception ex) diff --git a/SecureBootDashboard.Web/Pages/Policies/Create.cshtml b/SecureBootDashboard.Web/Pages/Policies/Create.cshtml new file mode 100644 index 0000000..7265426 --- /dev/null +++ b/SecureBootDashboard.Web/Pages/Policies/Create.cshtml @@ -0,0 +1,252 @@ +@page +@model SecureBootDashboard.Web.Pages.Policies.CreateModel +@{ + ViewData["Title"] = "Create Policy"; +} + +
+
+

Create Compliance Policy

+ +
+
+ +@if (!string.IsNullOrEmpty(Model.ErrorMessage)) +{ + +} + +
+
+
+
Policy Details
+
+
+
+
+ + + +
+
+ + + Lower values = higher priority + +
+
+ +
+ + + +
+ +
+
+ + + Policy will apply only to this fleet if specified + +
+
+
+ + +
+
+
+
+
+ +
+
+
Policy Rules
+ +
+
+
+
+ No rules added yet. Click "Add Rule" to define compliance requirements. +
+
+
+
+ + + +
+ + + Cancel + +
+
+ +@section Scripts { + + +} diff --git a/SecureBootDashboard.Web/Pages/Policies/Create.cshtml.cs b/SecureBootDashboard.Web/Pages/Policies/Create.cshtml.cs new file mode 100644 index 0000000..93cc229 --- /dev/null +++ b/SecureBootDashboard.Web/Pages/Policies/Create.cshtml.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.Extensions.Logging; +using SecureBootWatcher.Shared.Models; + +namespace SecureBootDashboard.Web.Pages.Policies +{ + public class CreateModel : PageModel + { + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; + + public CreateModel(IHttpClientFactory httpClientFactory, ILogger logger) + { + _httpClientFactory = httpClientFactory; + _logger = logger; + } + + [BindProperty] + [Required] + public string Name { get; set; } = string.Empty; + + [BindProperty] + public string? Description { get; set; } + + [BindProperty] + public bool IsEnabled { get; set; } = true; + + [BindProperty] + [Required] + public int Priority { get; set; } = 100; + + [BindProperty] + public string? FleetId { get; set; } + + [BindProperty] + public string? RulesJson { get; set; } + + public string? ErrorMessage { get; set; } + + public IActionResult OnGet() + { + return Page(); + } + + public async Task OnPostAsync() + { + if (!ModelState.IsValid) + { + return Page(); + } + + try + { + var rules = new List(); + if (!string.IsNullOrEmpty(RulesJson)) + { + try + { + rules = JsonSerializer.Deserialize>(RulesJson, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }) ?? new List(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to deserialize rules JSON"); + ErrorMessage = "Invalid rules data. Please check your rule configuration."; + return Page(); + } + } + + var policy = new CertificateCompliancePolicy + { + Name = Name, + Description = Description, + IsEnabled = IsEnabled, + Priority = Priority, + FleetId = FleetId, + Rules = rules + }; + + var client = _httpClientFactory.CreateClient("SecureBootApi"); + var json = JsonSerializer.Serialize(policy); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + + var response = await client.PostAsync("/api/Policies", content); + + if (response.IsSuccessStatusCode) + { + return RedirectToPage("./List"); + } + else + { + ErrorMessage = $"Failed to create policy: {response.StatusCode}"; + _logger.LogWarning("Failed to create policy. Status: {StatusCode}", response.StatusCode); + return Page(); + } + } + catch (Exception ex) + { + ErrorMessage = "An error occurred while creating the policy."; + _logger.LogError(ex, "Error creating policy"); + return Page(); + } + } + } +} diff --git a/SecureBootDashboard.Web/Pages/Policies/List.cshtml b/SecureBootDashboard.Web/Pages/Policies/List.cshtml new file mode 100644 index 0000000..a4babcb --- /dev/null +++ b/SecureBootDashboard.Web/Pages/Policies/List.cshtml @@ -0,0 +1,148 @@ +@page +@model SecureBootDashboard.Web.Pages.Policies.ListModel +@{ + ViewData["Title"] = "Compliance Policies"; +} + +
+
+

Certificate Compliance Policies

+

Define and manage policies to enforce certificate compliance across your device fleet.

+
+ +
+ +@if (Model.ErrorMessage != null) +{ + +} + +@if (Model.Policies.Count == 0) +{ +
+ No compliance policies have been created yet. + Create your first policy to start enforcing certificate compliance. +
+} +else +{ +
+
+
+ + + + + + + + + + + + + + @foreach (var policy in Model.Policies) + { + + + + + + + + + + } + +
NameStatusPriorityFleetRulesCreatedActions
+ @policy.Name + @if (!string.IsNullOrEmpty(policy.Description)) + { +
+ @policy.Description + } +
+ @if (policy.IsEnabled) + { + + Enabled + + } + else + { + + Disabled + + } + @policy.Priority + @if (string.IsNullOrEmpty(policy.FleetId)) + { + All Fleets + } + else + { + @policy.FleetId + } + + @policy.Rules.Count rule(s) + + @policy.CreatedAtUtc.ToString("yyyy-MM-dd HH:mm") + +
+ + + + +
+
+
+
+
+} + +@* Delete confirmation modal *@ + + +@section Scripts { + +} diff --git a/SecureBootDashboard.Web/Pages/Policies/List.cshtml.cs b/SecureBootDashboard.Web/Pages/Policies/List.cshtml.cs new file mode 100644 index 0000000..4f7d6c3 --- /dev/null +++ b/SecureBootDashboard.Web/Pages/Policies/List.cshtml.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.Extensions.Logging; +using SecureBootWatcher.Shared.Models; + +namespace SecureBootDashboard.Web.Pages.Policies +{ + public class ListModel : PageModel + { + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; + + public ListModel(IHttpClientFactory httpClientFactory, ILogger logger) + { + _httpClientFactory = httpClientFactory; + _logger = logger; + } + + public List Policies { get; set; } = new List(); + public string? ErrorMessage { get; set; } + + public async Task OnGetAsync() + { + try + { + var client = _httpClientFactory.CreateClient("SecureBootApi"); + var response = await client.GetAsync("/api/Policies"); + + if (response.IsSuccessStatusCode) + { + var json = await response.Content.ReadAsStringAsync(); + Policies = JsonSerializer.Deserialize>(json, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }) ?? new List(); + } + else + { + ErrorMessage = $"Failed to load policies: {response.StatusCode}"; + _logger.LogWarning("Failed to load policies. Status: {StatusCode}", response.StatusCode); + } + } + catch (Exception ex) + { + ErrorMessage = "An error occurred while loading policies."; + _logger.LogError(ex, "Error loading policies"); + } + + return Page(); + } + + public async Task OnPostDeleteAsync(Guid id) + { + try + { + var client = _httpClientFactory.CreateClient("SecureBootApi"); + var response = await client.DeleteAsync($"/api/Policies/{id}"); + + if (!response.IsSuccessStatusCode) + { + ErrorMessage = $"Failed to delete policy: {response.StatusCode}"; + _logger.LogWarning("Failed to delete policy {PolicyId}. Status: {StatusCode}", id, response.StatusCode); + } + } + catch (Exception ex) + { + ErrorMessage = "An error occurred while deleting the policy."; + _logger.LogError(ex, "Error deleting policy {PolicyId}", id); + } + + return RedirectToPage(); + } + } +} diff --git a/SecureBootDashboard.Web/Pages/Shared/_Layout.cshtml b/SecureBootDashboard.Web/Pages/Shared/_Layout.cshtml index c9675bc..ba59ea0 100644 --- a/SecureBootDashboard.Web/Pages/Shared/_Layout.cshtml +++ b/SecureBootDashboard.Web/Pages/Shared/_Layout.cshtml @@ -76,6 +76,11 @@ Dispositivi +