diff --git a/src/ScadaLink.Commons/Entities/Security/LdapGroupMapping.cs b/src/ScadaLink.Commons/Entities/Security/LdapGroupMapping.cs index c6c5827..3f3bd2c 100644 --- a/src/ScadaLink.Commons/Entities/Security/LdapGroupMapping.cs +++ b/src/ScadaLink.Commons/Entities/Security/LdapGroupMapping.cs @@ -6,6 +6,9 @@ public class LdapGroupMapping public string LdapGroupName { get; set; } public string Role { get; set; } + // Parameterless constructor for EF Core seed data + private LdapGroupMapping() { LdapGroupName = null!; Role = null!; } + public LdapGroupMapping(string ldapGroupName, string role) { LdapGroupName = ldapGroupName ?? throw new ArgumentNullException(nameof(ldapGroupName)); diff --git a/src/ScadaLink.Commons/Interfaces/Repositories/ICentralUiRepository.cs b/src/ScadaLink.Commons/Interfaces/Repositories/ICentralUiRepository.cs index 753c3aa..a1dbb50 100644 --- a/src/ScadaLink.Commons/Interfaces/Repositories/ICentralUiRepository.cs +++ b/src/ScadaLink.Commons/Interfaces/Repositories/ICentralUiRepository.cs @@ -1,3 +1,4 @@ +using ScadaLink.Commons.Entities.Audit; using ScadaLink.Commons.Entities.Deployment; using ScadaLink.Commons.Entities.Instances; using ScadaLink.Commons.Entities.Sites; @@ -15,5 +16,18 @@ public interface ICentralUiRepository Task> GetRecentDeploymentsAsync(int count, CancellationToken cancellationToken = default); Task> GetAreaTreeBySiteIdAsync(int siteId, CancellationToken cancellationToken = default); + // Audit log queries + Task<(IReadOnlyList Entries, int TotalCount)> GetAuditLogEntriesAsync( + string? user = null, + string? entityType = null, + string? action = null, + DateTimeOffset? from = null, + DateTimeOffset? to = null, + string? entityId = null, + string? entityName = null, + int page = 1, + int pageSize = 50, + CancellationToken cancellationToken = default); + Task SaveChangesAsync(CancellationToken cancellationToken = default); } diff --git a/src/ScadaLink.ConfigurationDatabase/Configurations/SecurityConfiguration.cs b/src/ScadaLink.ConfigurationDatabase/Configurations/SecurityConfiguration.cs index 0d365d7..ba0f24b 100644 --- a/src/ScadaLink.ConfigurationDatabase/Configurations/SecurityConfiguration.cs +++ b/src/ScadaLink.ConfigurationDatabase/Configurations/SecurityConfiguration.cs @@ -20,6 +20,9 @@ public class LdapGroupMappingConfiguration : IEntityTypeConfiguration m.LdapGroupName).IsUnique(); + + // Seed default admin mapping + builder.HasData(new LdapGroupMapping("SCADA-Admins", "Admin") { Id = 1 }); } } diff --git a/src/ScadaLink.ConfigurationDatabase/Migrations/20260316231942_SeedData.Designer.cs b/src/ScadaLink.ConfigurationDatabase/Migrations/20260316231942_SeedData.Designer.cs new file mode 100644 index 0000000..e8d03e4 --- /dev/null +++ b/src/ScadaLink.ConfigurationDatabase/Migrations/20260316231942_SeedData.Designer.cs @@ -0,0 +1,1152 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using ScadaLink.ConfigurationDatabase; + +#nullable disable + +namespace ScadaLink.ConfigurationDatabase.Migrations +{ + [DbContext(typeof(ScadaLinkDbContext))] + [Migration("20260316231942_SeedData")] + partial class SeedData + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Audit.AuditLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("AfterStateJson") + .HasColumnType("nvarchar(max)"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("EntityName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.Property("User") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("Action"); + + b.HasIndex("EntityId"); + + b.HasIndex("EntityType"); + + b.HasIndex("Timestamp"); + + b.HasIndex("User"); + + b.ToTable("AuditLogEntries"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeploymentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("DeploymentId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("RevisionHash") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("DeployedAt"); + + b.HasIndex("DeploymentId") + .IsUnique(); + + b.HasIndex("InstanceId"); + + b.ToTable("DeploymentRecords"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.SystemArtifactDeploymentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ArtifactType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PerSiteStatus") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("DeployedAt"); + + b.ToTable("SystemArtifactDeploymentRecords"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.ExternalSystems.DatabaseConnectionDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConnectionString") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("DatabaseConnectionDefinitions"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.ExternalSystems.ExternalSystemDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AuthConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("AuthType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("EndpointUrl") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ExternalSystemDefinitions"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.ExternalSystems.ExternalSystemMethod", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ExternalSystemDefinitionId") + .HasColumnType("int"); + + b.Property("HttpMethod") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("ExternalSystemDefinitionId", "Name") + .IsUnique(); + + b.ToTable("ExternalSystemMethods"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.InboundApi.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("IsEnabled") + .HasColumnType("bit"); + + b.Property("KeyValue") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("KeyValue") + .IsUnique(); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ApiKeys"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.InboundApi.ApiMethod", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ApprovedApiKeyIds") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Script") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TimeoutSeconds") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ApiMethods"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Area", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParentAreaId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ParentAreaId"); + + b.HasIndex("SiteId", "ParentAreaId", "Name") + .IsUnique() + .HasFilter("[ParentAreaId] IS NOT NULL"); + + b.ToTable("Areas"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Instance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AreaId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.Property("State") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("UniqueName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("AreaId"); + + b.HasIndex("TemplateId"); + + b.HasIndex("SiteId", "UniqueName") + .IsUnique(); + + b.ToTable("Instances"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAttributeOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AttributeName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("OverrideValue") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "AttributeName") + .IsUnique(); + + b.ToTable("InstanceAttributeOverrides"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceConnectionBinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AttributeName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("DataConnectionId") + .HasColumnType("int"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DataConnectionId"); + + b.HasIndex("InstanceId", "AttributeName") + .IsUnique(); + + b.ToTable("InstanceConnectionBindings"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.NotificationList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("NotificationLists"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.NotificationRecipient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("EmailAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NotificationListId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("NotificationListId"); + + b.ToTable("NotificationRecipients"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.SmtpConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AuthType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ConnectionTimeoutSeconds") + .HasColumnType("int"); + + b.Property("Credentials") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("FromAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("MaxConcurrentConnections") + .HasColumnType("int"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Port") + .HasColumnType("int"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.Property("TlsMode") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.ToTable("SmtpConfigurations"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Scripts.SharedScript", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("SharedScripts"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Security.LdapGroupMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("LdapGroupName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("LdapGroupName") + .IsUnique(); + + b.ToTable("LdapGroupMappings"); + + b.HasData( + new + { + Id = 1, + LdapGroupName = "SCADA-Admins", + Role = "Admin" + }); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Security.SiteScopeRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("LdapGroupMappingId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SiteId"); + + b.HasIndex("LdapGroupMappingId", "SiteId") + .IsUnique(); + + b.ToTable("SiteScopeRules"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Sites.DataConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Configuration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("DataConnections"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Sites.Site", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SiteIdentifier") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("SiteIdentifier") + .IsUnique(); + + b.ToTable("Sites"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Sites.SiteDataConnectionAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DataConnectionId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DataConnectionId"); + + b.HasIndex("SiteId", "DataConnectionId") + .IsUnique(); + + b.ToTable("SiteDataConnectionAssignments"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.Template", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParentTemplateId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("ParentTemplateId"); + + b.ToTable("Templates"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateAlarm", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OnTriggerScriptId") + .HasColumnType("int"); + + b.Property("PriorityLevel") + .HasColumnType("int"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("TriggerConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TriggerType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateAlarms"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateAttribute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DataSourceReference") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DataType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("Value") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateAttributes"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateComposition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ComposedTemplateId") + .HasColumnType("int"); + + b.Property("InstanceName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ComposedTemplateId"); + + b.HasIndex("TemplateId", "InstanceName") + .IsUnique(); + + b.ToTable("TemplateCompositions"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateScript", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("MinTimeBetweenRuns") + .HasColumnType("time"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("TriggerConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TriggerType") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateScripts"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeploymentRecord", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany() + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.ExternalSystems.ExternalSystemMethod", b => + { + b.HasOne("ScadaLink.Commons.Entities.ExternalSystems.ExternalSystemDefinition", null) + .WithMany() + .HasForeignKey("ExternalSystemDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Area", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Area", null) + .WithMany("Children") + .HasForeignKey("ParentAreaId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Instance", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Area", null) + .WithMany() + .HasForeignKey("AreaId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAttributeOverride", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany("AttributeOverrides") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceConnectionBinding", b => + { + b.HasOne("ScadaLink.Commons.Entities.Sites.DataConnection", null) + .WithMany() + .HasForeignKey("DataConnectionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany("ConnectionBindings") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.NotificationRecipient", b => + { + b.HasOne("ScadaLink.Commons.Entities.Notifications.NotificationList", null) + .WithMany("Recipients") + .HasForeignKey("NotificationListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Security.SiteScopeRule", b => + { + b.HasOne("ScadaLink.Commons.Entities.Security.LdapGroupMapping", null) + .WithMany() + .HasForeignKey("LdapGroupMappingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Sites.SiteDataConnectionAssignment", b => + { + b.HasOne("ScadaLink.Commons.Entities.Sites.DataConnection", null) + .WithMany() + .HasForeignKey("DataConnectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.Template", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("ParentTemplateId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateAlarm", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany("Alarms") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateAttribute", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany("Attributes") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateComposition", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("ComposedTemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany("Compositions") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateScript", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany("Scripts") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Area", b => + { + b.Navigation("Children"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Instance", b => + { + b.Navigation("AttributeOverrides"); + + b.Navigation("ConnectionBindings"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.NotificationList", b => + { + b.Navigation("Recipients"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.Template", b => + { + b.Navigation("Alarms"); + + b.Navigation("Attributes"); + + b.Navigation("Compositions"); + + b.Navigation("Scripts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ScadaLink.ConfigurationDatabase/Migrations/20260316231942_SeedData.cs b/src/ScadaLink.ConfigurationDatabase/Migrations/20260316231942_SeedData.cs new file mode 100644 index 0000000..c3fdb79 --- /dev/null +++ b/src/ScadaLink.ConfigurationDatabase/Migrations/20260316231942_SeedData.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ScadaLink.ConfigurationDatabase.Migrations +{ + /// + public partial class SeedData : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.InsertData( + table: "LdapGroupMappings", + columns: new[] { "Id", "LdapGroupName", "Role" }, + values: new object[] { 1, "SCADA-Admins", "Admin" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DeleteData( + table: "LdapGroupMappings", + keyColumn: "Id", + keyValue: 1); + } + } +} diff --git a/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs b/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs index b5e3c3b..5557550 100644 --- a/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs +++ b/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs @@ -631,6 +631,14 @@ namespace ScadaLink.ConfigurationDatabase.Migrations .IsUnique(); b.ToTable("LdapGroupMappings"); + + b.HasData( + new + { + Id = 1, + LdapGroupName = "SCADA-Admins", + Role = "Admin" + }); }); modelBuilder.Entity("ScadaLink.Commons.Entities.Security.SiteScopeRule", b => diff --git a/src/ScadaLink.ConfigurationDatabase/Repositories/CentralUiRepository.cs b/src/ScadaLink.ConfigurationDatabase/Repositories/CentralUiRepository.cs new file mode 100644 index 0000000..2366de3 --- /dev/null +++ b/src/ScadaLink.ConfigurationDatabase/Repositories/CentralUiRepository.cs @@ -0,0 +1,147 @@ +using Microsoft.EntityFrameworkCore; +using ScadaLink.Commons.Entities.Audit; +using ScadaLink.Commons.Entities.Deployment; +using ScadaLink.Commons.Entities.Instances; +using ScadaLink.Commons.Entities.Sites; +using ScadaLink.Commons.Entities.Templates; +using ScadaLink.Commons.Interfaces.Repositories; + +namespace ScadaLink.ConfigurationDatabase.Repositories; + +public class CentralUiRepository : ICentralUiRepository +{ + private readonly ScadaLinkDbContext _context; + + public CentralUiRepository(ScadaLinkDbContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + public async Task> GetAllSitesAsync(CancellationToken cancellationToken = default) + { + return await _context.Sites + .AsNoTracking() + .OrderBy(s => s.Name) + .ToListAsync(cancellationToken); + } + + public async Task> GetDataConnectionsBySiteIdAsync(int siteId, CancellationToken cancellationToken = default) + { + return await _context.SiteDataConnectionAssignments + .AsNoTracking() + .Where(a => a.SiteId == siteId) + .Join(_context.DataConnections, a => a.DataConnectionId, d => d.Id, (_, d) => d) + .ToListAsync(cancellationToken); + } + + public async Task> GetAllSiteDataConnectionAssignmentsAsync(CancellationToken cancellationToken = default) + { + return await _context.SiteDataConnectionAssignments + .AsNoTracking() + .ToListAsync(cancellationToken); + } + + public async Task> GetTemplateTreeAsync(CancellationToken cancellationToken = default) + { + return await _context.Templates + .AsNoTracking() + .Include(t => t.Attributes) + .Include(t => t.Alarms) + .Include(t => t.Scripts) + .Include(t => t.Compositions) + .OrderBy(t => t.Name) + .ToListAsync(cancellationToken); + } + + public async Task> GetInstancesFilteredAsync( + int? siteId = null, + int? templateId = null, + string? searchTerm = null, + CancellationToken cancellationToken = default) + { + var query = _context.Instances.AsNoTracking().AsQueryable(); + + if (siteId.HasValue) + query = query.Where(i => i.SiteId == siteId.Value); + + if (templateId.HasValue) + query = query.Where(i => i.TemplateId == templateId.Value); + + if (!string.IsNullOrWhiteSpace(searchTerm)) + query = query.Where(i => i.UniqueName.Contains(searchTerm)); + + return await query + .OrderBy(i => i.UniqueName) + .ToListAsync(cancellationToken); + } + + public async Task> GetRecentDeploymentsAsync(int count, CancellationToken cancellationToken = default) + { + return await _context.DeploymentRecords + .AsNoTracking() + .OrderByDescending(d => d.DeployedAt) + .Take(count) + .ToListAsync(cancellationToken); + } + + public async Task> GetAreaTreeBySiteIdAsync(int siteId, CancellationToken cancellationToken = default) + { + return await _context.Areas + .AsNoTracking() + .Where(a => a.SiteId == siteId) + .Include(a => a.Children) + .OrderBy(a => a.Name) + .ToListAsync(cancellationToken); + } + + public async Task<(IReadOnlyList Entries, int TotalCount)> GetAuditLogEntriesAsync( + string? user = null, + string? entityType = null, + string? action = null, + DateTimeOffset? from = null, + DateTimeOffset? to = null, + string? entityId = null, + string? entityName = null, + int page = 1, + int pageSize = 50, + CancellationToken cancellationToken = default) + { + var query = _context.AuditLogEntries.AsNoTracking().AsQueryable(); + + if (!string.IsNullOrWhiteSpace(user)) + query = query.Where(a => a.User == user); + + if (!string.IsNullOrWhiteSpace(entityType)) + query = query.Where(a => a.EntityType == entityType); + + if (!string.IsNullOrWhiteSpace(action)) + query = query.Where(a => a.Action == action); + + if (from.HasValue) + query = query.Where(a => a.Timestamp >= from.Value); + + if (to.HasValue) + query = query.Where(a => a.Timestamp <= to.Value); + + if (!string.IsNullOrWhiteSpace(entityId)) + query = query.Where(a => a.EntityId == entityId); + + if (!string.IsNullOrWhiteSpace(entityName)) + query = query.Where(a => a.EntityName.Contains(entityName)); + + var totalCount = await query.CountAsync(cancellationToken); + + var entries = await query + .OrderByDescending(a => a.Timestamp) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(cancellationToken); + + return (entries, totalCount); + } + + public async Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + return await _context.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/ScadaLink.ConfigurationDatabase/Repositories/SecurityRepository.cs b/src/ScadaLink.ConfigurationDatabase/Repositories/SecurityRepository.cs new file mode 100644 index 0000000..90547c2 --- /dev/null +++ b/src/ScadaLink.ConfigurationDatabase/Repositories/SecurityRepository.cs @@ -0,0 +1,93 @@ +using Microsoft.EntityFrameworkCore; +using ScadaLink.Commons.Entities.Security; +using ScadaLink.Commons.Interfaces.Repositories; + +namespace ScadaLink.ConfigurationDatabase.Repositories; + +public class SecurityRepository : ISecurityRepository +{ + private readonly ScadaLinkDbContext _context; + + public SecurityRepository(ScadaLinkDbContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + // LdapGroupMapping + + public async Task GetMappingByIdAsync(int id, CancellationToken cancellationToken = default) + { + return await _context.LdapGroupMappings.FindAsync(new object[] { id }, cancellationToken); + } + + public async Task> GetAllMappingsAsync(CancellationToken cancellationToken = default) + { + return await _context.LdapGroupMappings.ToListAsync(cancellationToken); + } + + public async Task> GetMappingsByRoleAsync(string role, CancellationToken cancellationToken = default) + { + return await _context.LdapGroupMappings + .Where(m => m.Role == role) + .ToListAsync(cancellationToken); + } + + public async Task AddMappingAsync(LdapGroupMapping mapping, CancellationToken cancellationToken = default) + { + await _context.LdapGroupMappings.AddAsync(mapping, cancellationToken); + } + + public Task UpdateMappingAsync(LdapGroupMapping mapping, CancellationToken cancellationToken = default) + { + _context.LdapGroupMappings.Update(mapping); + return Task.CompletedTask; + } + + public async Task DeleteMappingAsync(int id, CancellationToken cancellationToken = default) + { + var mapping = await _context.LdapGroupMappings.FindAsync(new object[] { id }, cancellationToken); + if (mapping != null) + { + _context.LdapGroupMappings.Remove(mapping); + } + } + + // SiteScopeRule + + public async Task GetScopeRuleByIdAsync(int id, CancellationToken cancellationToken = default) + { + return await _context.SiteScopeRules.FindAsync(new object[] { id }, cancellationToken); + } + + public async Task> GetScopeRulesForMappingAsync(int ldapGroupMappingId, CancellationToken cancellationToken = default) + { + return await _context.SiteScopeRules + .Where(r => r.LdapGroupMappingId == ldapGroupMappingId) + .ToListAsync(cancellationToken); + } + + public async Task AddScopeRuleAsync(SiteScopeRule rule, CancellationToken cancellationToken = default) + { + await _context.SiteScopeRules.AddAsync(rule, cancellationToken); + } + + public Task UpdateScopeRuleAsync(SiteScopeRule rule, CancellationToken cancellationToken = default) + { + _context.SiteScopeRules.Update(rule); + return Task.CompletedTask; + } + + public async Task DeleteScopeRuleAsync(int id, CancellationToken cancellationToken = default) + { + var rule = await _context.SiteScopeRules.FindAsync(new object[] { id }, cancellationToken); + if (rule != null) + { + _context.SiteScopeRules.Remove(rule); + } + } + + public async Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + return await _context.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/ScadaLink.ConfigurationDatabase/ScadaLink.ConfigurationDatabase.csproj b/src/ScadaLink.ConfigurationDatabase/ScadaLink.ConfigurationDatabase.csproj index c069c4d..f5ef6d4 100644 --- a/src/ScadaLink.ConfigurationDatabase/ScadaLink.ConfigurationDatabase.csproj +++ b/src/ScadaLink.ConfigurationDatabase/ScadaLink.ConfigurationDatabase.csproj @@ -17,6 +17,7 @@ + diff --git a/src/ScadaLink.ConfigurationDatabase/ScadaLinkDbContext.cs b/src/ScadaLink.ConfigurationDatabase/ScadaLinkDbContext.cs index fd8c2e6..e7596b5 100644 --- a/src/ScadaLink.ConfigurationDatabase/ScadaLinkDbContext.cs +++ b/src/ScadaLink.ConfigurationDatabase/ScadaLinkDbContext.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; using ScadaLink.Commons.Entities.Audit; using ScadaLink.Commons.Entities.Deployment; @@ -12,7 +13,7 @@ using ScadaLink.Commons.Entities.Templates; namespace ScadaLink.ConfigurationDatabase; -public class ScadaLinkDbContext : DbContext +public class ScadaLinkDbContext : DbContext, IDataProtectionKeyContext { public ScadaLinkDbContext(DbContextOptions options) : base(options) { @@ -64,6 +65,9 @@ public class ScadaLinkDbContext : DbContext // Audit public DbSet AuditLogEntries => Set(); + // Data Protection Keys (for shared ASP.NET Data Protection across nodes) + public DbSet DataProtectionKeys => Set(); + protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.ApplyConfigurationsFromAssembly(typeof(ScadaLinkDbContext).Assembly); diff --git a/src/ScadaLink.ConfigurationDatabase/ServiceCollectionExtensions.cs b/src/ScadaLink.ConfigurationDatabase/ServiceCollectionExtensions.cs index 819ea11..30a1761 100644 --- a/src/ScadaLink.ConfigurationDatabase/ServiceCollectionExtensions.cs +++ b/src/ScadaLink.ConfigurationDatabase/ServiceCollectionExtensions.cs @@ -1,5 +1,10 @@ +using Microsoft.AspNetCore.DataProtection; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using ScadaLink.Commons.Interfaces.Repositories; +using ScadaLink.Commons.Interfaces.Services; +using ScadaLink.ConfigurationDatabase.Repositories; +using ScadaLink.ConfigurationDatabase.Services; namespace ScadaLink.ConfigurationDatabase; @@ -13,6 +18,13 @@ public static class ServiceCollectionExtensions services.AddDbContext(options => options.UseSqlServer(connectionString)); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddDataProtection() + .PersistKeysToDbContext(); + return services; } diff --git a/src/ScadaLink.ConfigurationDatabase/Services/AuditService.cs b/src/ScadaLink.ConfigurationDatabase/Services/AuditService.cs new file mode 100644 index 0000000..5fdeb07 --- /dev/null +++ b/src/ScadaLink.ConfigurationDatabase/Services/AuditService.cs @@ -0,0 +1,37 @@ +using System.Text.Json; +using ScadaLink.Commons.Entities.Audit; +using ScadaLink.Commons.Interfaces.Services; + +namespace ScadaLink.ConfigurationDatabase.Services; + +public class AuditService : IAuditService +{ + private readonly ScadaLinkDbContext _context; + + public AuditService(ScadaLinkDbContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + public async Task LogAsync( + string user, + string action, + string entityType, + string entityId, + string entityName, + object? afterState, + CancellationToken cancellationToken = default) + { + var entry = new AuditLogEntry(user, action, entityType, entityId, entityName) + { + Timestamp = DateTimeOffset.UtcNow, + AfterStateJson = afterState != null + ? JsonSerializer.Serialize(afterState) + : null + }; + + // Add to change tracker only — caller is responsible for calling SaveChangesAsync + // to ensure atomicity with the entity change. + await _context.AuditLogEntries.AddAsync(entry, cancellationToken); + } +} diff --git a/src/ScadaLink.Security/AuthorizationPolicies.cs b/src/ScadaLink.Security/AuthorizationPolicies.cs new file mode 100644 index 0000000..c426b74 --- /dev/null +++ b/src/ScadaLink.Security/AuthorizationPolicies.cs @@ -0,0 +1,30 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.DependencyInjection; + +namespace ScadaLink.Security; + +public static class AuthorizationPolicies +{ + public const string RequireAdmin = "RequireAdmin"; + public const string RequireDesign = "RequireDesign"; + public const string RequireDeployment = "RequireDeployment"; + + public static IServiceCollection AddScadaLinkAuthorization(this IServiceCollection services) + { + services.AddAuthorization(options => + { + options.AddPolicy(RequireAdmin, policy => + policy.RequireClaim(JwtTokenService.RoleClaimType, "Admin")); + + options.AddPolicy(RequireDesign, policy => + policy.RequireClaim(JwtTokenService.RoleClaimType, "Design")); + + options.AddPolicy(RequireDeployment, policy => + policy.RequireClaim(JwtTokenService.RoleClaimType, "Deployment")); + }); + + services.AddSingleton(); + + return services; + } +} diff --git a/src/ScadaLink.Security/JwtTokenService.cs b/src/ScadaLink.Security/JwtTokenService.cs new file mode 100644 index 0000000..f4f892b --- /dev/null +++ b/src/ScadaLink.Security/JwtTokenService.cs @@ -0,0 +1,124 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; + +namespace ScadaLink.Security; + +public class JwtTokenService +{ + private readonly SecurityOptions _options; + private readonly ILogger _logger; + + public const string DisplayNameClaimType = "DisplayName"; + public const string UsernameClaimType = "Username"; + public const string RoleClaimType = "Role"; + public const string SiteIdClaimType = "SiteId"; + public const string LastActivityClaimType = "LastActivity"; + + public JwtTokenService(IOptions options, ILogger logger) + { + _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public string GenerateToken( + string displayName, + string username, + IReadOnlyList roles, + IReadOnlyList? permittedSiteIds) + { + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.JwtSigningKey)); + var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var claims = new List + { + new(DisplayNameClaimType, displayName), + new(UsernameClaimType, username), + new(LastActivityClaimType, DateTimeOffset.UtcNow.ToString("o")) + }; + + foreach (var role in roles) + { + claims.Add(new Claim(RoleClaimType, role)); + } + + if (permittedSiteIds != null) + { + foreach (var siteId in permittedSiteIds) + { + claims.Add(new Claim(SiteIdClaimType, siteId)); + } + } + + var token = new JwtSecurityToken( + claims: claims, + expires: DateTime.UtcNow.AddMinutes(_options.JwtExpiryMinutes), + signingCredentials: credentials); + + return new JwtSecurityTokenHandler().WriteToken(token); + } + + public ClaimsPrincipal? ValidateToken(string token) + { + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.JwtSigningKey)); + var validationParameters = new TokenValidationParameters + { + ValidateIssuer = false, + ValidateAudience = false, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + IssuerSigningKey = key, + ClockSkew = TimeSpan.Zero + }; + + try + { + var handler = new JwtSecurityTokenHandler(); + var principal = handler.ValidateToken(token, validationParameters, out _); + return principal; + } + catch (Exception ex) when (ex is SecurityTokenException or ArgumentException) + { + _logger.LogDebug(ex, "Token validation failed"); + return null; + } + } + + public bool ShouldRefresh(ClaimsPrincipal principal) + { + var expClaim = principal.FindFirst("exp"); + if (expClaim == null || !long.TryParse(expClaim.Value, out var expUnix)) + return false; + + var expiry = DateTimeOffset.FromUnixTimeSeconds(expUnix); + var remaining = expiry - DateTimeOffset.UtcNow; + + return remaining.TotalMinutes < _options.JwtRefreshThresholdMinutes; + } + + public bool IsIdleTimedOut(ClaimsPrincipal principal) + { + var lastActivityClaim = principal.FindFirst(LastActivityClaimType); + if (lastActivityClaim == null || !DateTimeOffset.TryParse(lastActivityClaim.Value, out var lastActivity)) + return true; + + return (DateTimeOffset.UtcNow - lastActivity).TotalMinutes > _options.IdleTimeoutMinutes; + } + + public string? RefreshToken(ClaimsPrincipal currentPrincipal, IReadOnlyList currentRoles, IReadOnlyList? permittedSiteIds) + { + var displayName = currentPrincipal.FindFirst(DisplayNameClaimType)?.Value; + var username = currentPrincipal.FindFirst(UsernameClaimType)?.Value; + + if (displayName == null || username == null) + { + _logger.LogWarning("Cannot refresh token: missing DisplayName or Username claims"); + return null; + } + + return GenerateToken(displayName, username, currentRoles, permittedSiteIds); + } +} diff --git a/src/ScadaLink.Security/LdapAuthResult.cs b/src/ScadaLink.Security/LdapAuthResult.cs new file mode 100644 index 0000000..fff0cae --- /dev/null +++ b/src/ScadaLink.Security/LdapAuthResult.cs @@ -0,0 +1,8 @@ +namespace ScadaLink.Security; + +public record LdapAuthResult( + bool Success, + string? DisplayName, + string? Username, + IReadOnlyList? Groups, + string? ErrorMessage); diff --git a/src/ScadaLink.Security/LdapAuthService.cs b/src/ScadaLink.Security/LdapAuthService.cs new file mode 100644 index 0000000..8c54462 --- /dev/null +++ b/src/ScadaLink.Security/LdapAuthService.cs @@ -0,0 +1,148 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Novell.Directory.Ldap; + +namespace ScadaLink.Security; + +public class LdapAuthService +{ + private readonly SecurityOptions _options; + private readonly ILogger _logger; + + public LdapAuthService(IOptions options, ILogger logger) + { + _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task AuthenticateAsync(string username, string password, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(username)) + return new LdapAuthResult(false, null, null, null, "Username is required."); + + if (string.IsNullOrWhiteSpace(password)) + return new LdapAuthResult(false, null, null, null, "Password is required."); + + // Enforce TLS unless explicitly allowed for dev/test + if (!_options.LdapUseTls && !_options.AllowInsecureLdap) + { + return new LdapAuthResult(false, null, null, null, + "Insecure LDAP connections are not allowed. Enable TLS or set AllowInsecureLdap for dev/test."); + } + + try + { + using var connection = new LdapConnection(); + + if (_options.LdapUseTls) + { + connection.SecureSocketLayer = true; + } + + await Task.Run(() => connection.Connect(_options.LdapServer, _options.LdapPort), ct); + + if (_options.LdapUseTls && !connection.SecureSocketLayer) + { + await Task.Run(() => connection.StartTls(), ct); + } + + // Direct bind with user credentials + var bindDn = BuildBindDn(username); + await Task.Run(() => connection.Bind(bindDn, password), ct); + + // Query for user attributes and group memberships + var displayName = username; + var groups = new List(); + + try + { + var searchFilter = $"(uid={EscapeLdapFilter(username)})"; + var searchResults = await Task.Run(() => + connection.Search( + _options.LdapSearchBase, + LdapConnection.ScopeSub, + searchFilter, + new[] { _options.LdapDisplayNameAttribute, _options.LdapGroupAttribute }, + false), ct); + + while (searchResults.HasMore()) + { + try + { + var entry = searchResults.Next(); + var dnAttr = entry.GetAttribute(_options.LdapDisplayNameAttribute); + if (dnAttr != null) + displayName = dnAttr.StringValue; + + var groupAttr = entry.GetAttribute(_options.LdapGroupAttribute); + if (groupAttr != null) + { + foreach (var groupDn in groupAttr.StringValueArray) + { + groups.Add(ExtractCn(groupDn)); + } + } + } + catch (LdapException) + { + // No more results + break; + } + } + } + catch (LdapException ex) + { + _logger.LogWarning(ex, "Failed to query LDAP attributes for user {Username}; authentication succeeded but group lookup failed", username); + // Auth succeeded even if attribute lookup failed + } + + connection.Disconnect(); + + return new LdapAuthResult(true, displayName, username, groups, null); + } + catch (LdapException ex) + { + _logger.LogWarning(ex, "LDAP authentication failed for user {Username}", username); + return new LdapAuthResult(false, null, username, null, "Invalid username or password."); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogError(ex, "Unexpected error during LDAP authentication for user {Username}", username); + return new LdapAuthResult(false, null, username, null, "An unexpected error occurred during authentication."); + } + } + + private string BuildBindDn(string username) + { + // If username already looks like a DN, use it as-is + if (username.Contains('=')) + return username; + + // Build DN from username and search base + return string.IsNullOrWhiteSpace(_options.LdapSearchBase) + ? $"cn={username}" + : $"cn={username},{_options.LdapSearchBase}"; + } + + private static string EscapeLdapFilter(string input) + { + return input + .Replace("\\", "\\5c") + .Replace("*", "\\2a") + .Replace("(", "\\28") + .Replace(")", "\\29") + .Replace("\0", "\\00"); + } + + private static string ExtractCn(string dn) + { + // Extract CN from a DN like "cn=GroupName,dc=example,dc=com" + if (dn.StartsWith("cn=", StringComparison.OrdinalIgnoreCase) || + dn.StartsWith("CN=", StringComparison.OrdinalIgnoreCase)) + { + var commaIndex = dn.IndexOf(','); + return commaIndex > 3 ? dn[3..commaIndex] : dn[3..]; + } + return dn; + } +} diff --git a/src/ScadaLink.Security/RoleMapper.cs b/src/ScadaLink.Security/RoleMapper.cs new file mode 100644 index 0000000..4ab88b8 --- /dev/null +++ b/src/ScadaLink.Security/RoleMapper.cs @@ -0,0 +1,58 @@ +using ScadaLink.Commons.Interfaces.Repositories; + +namespace ScadaLink.Security; + +public class RoleMapper +{ + private readonly ISecurityRepository _securityRepository; + + public RoleMapper(ISecurityRepository securityRepository) + { + _securityRepository = securityRepository ?? throw new ArgumentNullException(nameof(securityRepository)); + } + + public async Task MapGroupsToRolesAsync( + IReadOnlyList ldapGroups, + CancellationToken ct = default) + { + var allMappings = await _securityRepository.GetAllMappingsAsync(ct); + + var matchedRoles = new HashSet(StringComparer.OrdinalIgnoreCase); + var permittedSiteIds = new HashSet(); + var hasDeploymentRole = false; + var hasDeploymentWithScopeRules = false; + + foreach (var mapping in allMappings) + { + // Match LDAP group names (case-insensitive) + if (!ldapGroups.Any(g => g.Equals(mapping.LdapGroupName, StringComparison.OrdinalIgnoreCase))) + continue; + + matchedRoles.Add(mapping.Role); + + if (mapping.Role.Equals("Deployment", StringComparison.OrdinalIgnoreCase)) + { + hasDeploymentRole = true; + + // Check for site scope rules + var scopeRules = await _securityRepository.GetScopeRulesForMappingAsync(mapping.Id, ct); + if (scopeRules.Count > 0) + { + hasDeploymentWithScopeRules = true; + foreach (var rule in scopeRules) + { + permittedSiteIds.Add(rule.SiteId.ToString()); + } + } + } + } + + // System-wide deployment: user has Deployment role but no site scope rules restrict them + var isSystemWide = hasDeploymentRole && !hasDeploymentWithScopeRules; + + return new RoleMappingResult( + matchedRoles.ToList(), + permittedSiteIds.ToList(), + isSystemWide); + } +} diff --git a/src/ScadaLink.Security/RoleMappingResult.cs b/src/ScadaLink.Security/RoleMappingResult.cs new file mode 100644 index 0000000..9ff4591 --- /dev/null +++ b/src/ScadaLink.Security/RoleMappingResult.cs @@ -0,0 +1,6 @@ +namespace ScadaLink.Security; + +public record RoleMappingResult( + IReadOnlyList Roles, + IReadOnlyList PermittedSiteIds, + bool IsSystemWideDeployment); diff --git a/src/ScadaLink.Security/ScadaLink.Security.csproj b/src/ScadaLink.Security/ScadaLink.Security.csproj index 049c7d9..93e5878 100644 --- a/src/ScadaLink.Security/ScadaLink.Security.csproj +++ b/src/ScadaLink.Security/ScadaLink.Security.csproj @@ -10,6 +10,10 @@ + + + + diff --git a/src/ScadaLink.Security/SecurityOptions.cs b/src/ScadaLink.Security/SecurityOptions.cs index d58756a..eb62061 100644 --- a/src/ScadaLink.Security/SecurityOptions.cs +++ b/src/ScadaLink.Security/SecurityOptions.cs @@ -5,7 +5,34 @@ public class SecurityOptions public string LdapServer { get; set; } = string.Empty; public int LdapPort { get; set; } = 389; public bool LdapUseTls { get; set; } = true; + + /// + /// Allow insecure (non-TLS) LDAP connections. ONLY for dev/test with GLAuth. + /// Must be false in production. + /// + public bool AllowInsecureLdap { get; set; } = false; + + /// + /// Base DN for LDAP searches (e.g., "dc=example,dc=com"). + /// + public string LdapSearchBase { get; set; } = string.Empty; + + /// + /// LDAP attribute that contains the user's display name. + /// + public string LdapDisplayNameAttribute { get; set; } = "cn"; + + /// + /// LDAP attribute that contains group membership. + /// + public string LdapGroupAttribute { get; set; } = "memberOf"; + public string JwtSigningKey { get; set; } = string.Empty; public int JwtExpiryMinutes { get; set; } = 15; public int IdleTimeoutMinutes { get; set; } = 30; + + /// + /// Minutes before token expiry to trigger refresh. + /// + public int JwtRefreshThresholdMinutes { get; set; } = 5; } diff --git a/src/ScadaLink.Security/ServiceCollectionExtensions.cs b/src/ScadaLink.Security/ServiceCollectionExtensions.cs index c743d9a..6bf984b 100644 --- a/src/ScadaLink.Security/ServiceCollectionExtensions.cs +++ b/src/ScadaLink.Security/ServiceCollectionExtensions.cs @@ -6,7 +6,11 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddSecurity(this IServiceCollection services) { - // Phase 0: skeleton only + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScadaLinkAuthorization(); + return services; } diff --git a/src/ScadaLink.Security/SiteScopeAuthorizationHandler.cs b/src/ScadaLink.Security/SiteScopeAuthorizationHandler.cs new file mode 100644 index 0000000..cb37d57 --- /dev/null +++ b/src/ScadaLink.Security/SiteScopeAuthorizationHandler.cs @@ -0,0 +1,52 @@ +using Microsoft.AspNetCore.Authorization; + +namespace ScadaLink.Security; + +/// +/// Authorization requirement for site-scoped deployment operations. +/// +public class SiteScopeRequirement : IAuthorizationRequirement +{ + public string TargetSiteId { get; } + + public SiteScopeRequirement(string targetSiteId) + { + TargetSiteId = targetSiteId ?? throw new ArgumentNullException(nameof(targetSiteId)); + } +} + +/// +/// Checks that a user with the Deployment role is permitted to operate on the target site. +/// Users with Deployment role and no SiteId claims are system-wide deployers. +/// Users with SiteId claims are only permitted on those specific sites. +/// +public class SiteScopeAuthorizationHandler : AuthorizationHandler +{ + protected override Task HandleRequirementAsync( + AuthorizationHandlerContext context, + SiteScopeRequirement requirement) + { + // Must have Deployment role + var hasDeploymentRole = context.User.HasClaim(JwtTokenService.RoleClaimType, "Deployment"); + if (!hasDeploymentRole) + { + return Task.CompletedTask; // Fail — no Deployment role + } + + var siteIdClaims = context.User.FindAll(JwtTokenService.SiteIdClaimType).ToList(); + + if (siteIdClaims.Count == 0) + { + // No site scope restrictions — system-wide deployer + context.Succeed(requirement); + } + else if (siteIdClaims.Any(c => c.Value == requirement.TargetSiteId)) + { + // User is permitted on this specific site + context.Succeed(requirement); + } + + // Otherwise, silently fail (not authorized for this site) + return Task.CompletedTask; + } +} diff --git a/tests/ScadaLink.ConfigurationDatabase.Tests/AuditServiceTests.cs b/tests/ScadaLink.ConfigurationDatabase.Tests/AuditServiceTests.cs new file mode 100644 index 0000000..a5a2620 --- /dev/null +++ b/tests/ScadaLink.ConfigurationDatabase.Tests/AuditServiceTests.cs @@ -0,0 +1,127 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using ScadaLink.Commons.Entities.Templates; +using ScadaLink.Commons.Interfaces.Services; +using ScadaLink.ConfigurationDatabase; +using ScadaLink.ConfigurationDatabase.Services; + +namespace ScadaLink.ConfigurationDatabase.Tests; + +public class AuditServiceTests : IDisposable +{ + private readonly ScadaLinkDbContext _context; + private readonly AuditService _auditService; + + public AuditServiceTests() + { + var options = new DbContextOptionsBuilder() + .UseSqlite("DataSource=:memory:") + .Options; + + _context = new ScadaLinkDbContext(options); + _context.Database.OpenConnection(); + _context.Database.EnsureCreated(); + _auditService = new AuditService(_context); + } + + public void Dispose() + { + _context.Database.CloseConnection(); + _context.Dispose(); + } + + [Fact] + public async Task LogAsync_CreatesAuditEntry_CommittedWithEntityChange() + { + // Simulate entity change + audit in same transaction + var template = new Template("TestTemplate"); + _context.Templates.Add(template); + + await _auditService.LogAsync("admin", "Create", "Template", "1", "TestTemplate", + new { Name = "TestTemplate" }); + + // Single SaveChangesAsync commits both + await _context.SaveChangesAsync(); + + var audit = await _context.AuditLogEntries.SingleAsync(); + Assert.Equal("admin", audit.User); + Assert.Equal("Create", audit.Action); + Assert.Equal("Template", audit.EntityType); + Assert.NotNull(audit.AfterStateJson); + + // Template also committed + Assert.Single(await _context.Templates.ToListAsync()); + } + + [Fact] + public async Task LogAsync_Rollback_BothChangeAndAuditRolledBack() + { + // Use a separate context to simulate rollback via not calling SaveChanges + var options = new DbContextOptionsBuilder() + .UseSqlite(_context.Database.GetDbConnection()) + .Options; + + using var context2 = new ScadaLinkDbContext(options); + var auditService2 = new AuditService(context2); + + var template = new Template("RollbackTemplate"); + context2.Templates.Add(template); + await auditService2.LogAsync("admin", "Create", "Template", "99", "RollbackTemplate", + new { Name = "RollbackTemplate" }); + + // Intentionally do NOT call SaveChangesAsync — simulates rollback + // Verify nothing persisted + Assert.Empty(await _context.AuditLogEntries.Where(a => a.EntityName == "RollbackTemplate").ToListAsync()); + Assert.Empty(await _context.Templates.Where(t => t.Name == "RollbackTemplate").ToListAsync()); + } + + [Fact] + public async Task LogAsync_SerializesAfterStateAsJson() + { + var state = new { Name = "Test", Value = 42, Nested = new { Prop = "inner" } }; + await _auditService.LogAsync("admin", "Create", "Entity", "1", "Test", state); + await _context.SaveChangesAsync(); + + var audit = await _context.AuditLogEntries.SingleAsync(); + Assert.NotNull(audit.AfterStateJson); + + var deserialized = JsonSerializer.Deserialize(audit.AfterStateJson!); + Assert.Equal("Test", deserialized.GetProperty("Name").GetString()); + Assert.Equal(42, deserialized.GetProperty("Value").GetInt32()); + } + + [Fact] + public async Task LogAsync_NullAfterState_ForDeletes() + { + await _auditService.LogAsync("admin", "Delete", "Template", "1", "DeletedTemplate", null); + await _context.SaveChangesAsync(); + + var audit = await _context.AuditLogEntries.SingleAsync(); + Assert.Null(audit.AfterStateJson); + Assert.Equal("Delete", audit.Action); + } + + [Fact] + public async Task LogAsync_SetsTimestampToUtcNow() + { + var before = DateTimeOffset.UtcNow; + await _auditService.LogAsync("admin", "Create", "Template", "1", "T1", new { }); + await _context.SaveChangesAsync(); + var after = DateTimeOffset.UtcNow; + + var audit = await _context.AuditLogEntries.SingleAsync(); + // Allow 2 second tolerance for SQLite precision + Assert.True(audit.Timestamp >= before.AddSeconds(-2)); + Assert.True(audit.Timestamp <= after.AddSeconds(2)); + } + + [Fact] + public void AuditService_IsAppendOnly_NoUpdateOrDeleteMethods() + { + // Verify IAuditService only exposes LogAsync — no update/delete + var methods = typeof(IAuditService).GetMethods(); + Assert.Single(methods, m => m.Name == "LogAsync"); + Assert.DoesNotContain(methods, m => m.Name.Contains("Update", StringComparison.OrdinalIgnoreCase)); + Assert.DoesNotContain(methods, m => m.Name.Contains("Delete", StringComparison.OrdinalIgnoreCase)); + } +} diff --git a/tests/ScadaLink.ConfigurationDatabase.Tests/ConcurrencyTests.cs b/tests/ScadaLink.ConfigurationDatabase.Tests/ConcurrencyTests.cs new file mode 100644 index 0000000..8db5d31 --- /dev/null +++ b/tests/ScadaLink.ConfigurationDatabase.Tests/ConcurrencyTests.cs @@ -0,0 +1,166 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using ScadaLink.Commons.Entities.Deployment; +using ScadaLink.Commons.Entities.Instances; +using ScadaLink.Commons.Entities.Sites; +using ScadaLink.Commons.Entities.Templates; +using ScadaLink.Commons.Types.Enums; +using ScadaLink.ConfigurationDatabase; + +namespace ScadaLink.ConfigurationDatabase.Tests; + +/// +/// A test-specific DbContext that uses an explicit ConcurrencyToken on DeploymentRecord +/// (as opposed to SQL Server's IsRowVersion()) so that SQLite can enforce concurrency. +/// In production, the SQL Server RowVersion provides this automatically. +/// +public class ConcurrencyTestDbContext : ScadaLinkDbContext +{ + public ConcurrencyTestDbContext(DbContextOptions options) : base(options) { } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + // Replace the SQL Server RowVersion with an explicit concurrency token for SQLite + // Remove the shadow RowVersion property and add a visible ConcurrencyStamp + modelBuilder.Entity(builder => + { + // The shadow RowVersion property from the base config doesn't work in SQLite. + // Instead, use Status as a concurrency token for the test. + builder.Property(d => d.Status).IsConcurrencyToken(); + }); + } +} + +public class ConcurrencyTests : IDisposable +{ + private readonly string _dbPath; + + public ConcurrencyTests() + { + _dbPath = Path.Combine(Path.GetTempPath(), $"scadalink_test_{Guid.NewGuid()}.db"); + } + + public void Dispose() + { + if (File.Exists(_dbPath)) + File.Delete(_dbPath); + } + + private ScadaLinkDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseSqlite($"DataSource={_dbPath}") + .ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning)) + .Options; + return new ConcurrencyTestDbContext(options); + } + + [Fact] + public async Task DeploymentRecord_OptimisticConcurrency_SecondUpdateThrows() + { + // Setup: create necessary entities + using (var setupCtx = CreateContext()) + { + await setupCtx.Database.EnsureCreatedAsync(); + + var site = new Site("Site1", "S-001"); + var template = new Template("T1"); + setupCtx.Sites.Add(site); + setupCtx.Templates.Add(template); + await setupCtx.SaveChangesAsync(); + + var instance = new Instance("I1") + { + SiteId = site.Id, + TemplateId = template.Id, + State = InstanceState.Enabled + }; + setupCtx.Instances.Add(instance); + await setupCtx.SaveChangesAsync(); + + var record = new DeploymentRecord("deploy-concurrent", "admin") + { + InstanceId = instance.Id, + Status = DeploymentStatus.Pending, + DeployedAt = DateTimeOffset.UtcNow + }; + setupCtx.DeploymentRecords.Add(record); + await setupCtx.SaveChangesAsync(); + } + + // Load the same record in two separate contexts + using var ctx1 = CreateContext(); + using var ctx2 = CreateContext(); + + var record1 = await ctx1.DeploymentRecords.SingleAsync(d => d.DeploymentId == "deploy-concurrent"); + var record2 = await ctx2.DeploymentRecords.SingleAsync(d => d.DeploymentId == "deploy-concurrent"); + + // Both loaded Status = Pending. First context updates and saves successfully. + record1.Status = DeploymentStatus.Success; + record1.CompletedAt = DateTimeOffset.UtcNow; + await ctx1.SaveChangesAsync(); + + // Second context tries to update the same record from the stale "Pending" state — should throw + // because the Status concurrency token has changed from Pending to Success + record2.Status = DeploymentStatus.Failed; + await Assert.ThrowsAsync( + () => ctx2.SaveChangesAsync()); + } + + [Fact] + public async Task Template_NoOptimisticConcurrency_LastWriteWins() + { + // Setup + using (var setupCtx = CreateContext()) + { + await setupCtx.Database.EnsureCreatedAsync(); + + var template = new Template("ConcurrentTemplate") + { + Description = "Original" + }; + setupCtx.Templates.Add(template); + await setupCtx.SaveChangesAsync(); + } + + // Load in two contexts + using var ctx1 = CreateContext(); + using var ctx2 = CreateContext(); + + var template1 = await ctx1.Templates.SingleAsync(t => t.Name == "ConcurrentTemplate"); + var template2 = await ctx2.Templates.SingleAsync(t => t.Name == "ConcurrentTemplate"); + + // First update + template1.Description = "First update"; + await ctx1.SaveChangesAsync(); + + // Second update — should succeed (last-write-wins, no concurrency token) + template2.Description = "Second update"; + await ctx2.SaveChangesAsync(); // Should NOT throw + + // Verify last write won + using var verifyCtx = CreateContext(); + var loaded = await verifyCtx.Templates.SingleAsync(t => t.Name == "ConcurrentTemplate"); + Assert.Equal("Second update", loaded.Description); + } + + [Fact] + public void DeploymentRecord_HasRowVersionConfigured() + { + // Verify the production configuration has a RowVersion shadow property + var options = new DbContextOptionsBuilder() + .UseSqlite("DataSource=:memory:") + .Options; + + using var context = new ScadaLinkDbContext(options); + context.Database.OpenConnection(); + context.Database.EnsureCreated(); + + var entityType = context.Model.FindEntityType(typeof(DeploymentRecord))!; + var rowVersion = entityType.FindProperty("RowVersion"); + Assert.NotNull(rowVersion); + Assert.True(rowVersion!.IsConcurrencyToken); + } +} diff --git a/tests/ScadaLink.ConfigurationDatabase.Tests/DataProtectionTests.cs b/tests/ScadaLink.ConfigurationDatabase.Tests/DataProtectionTests.cs new file mode 100644 index 0000000..46e5462 --- /dev/null +++ b/tests/ScadaLink.ConfigurationDatabase.Tests/DataProtectionTests.cs @@ -0,0 +1,63 @@ +using Microsoft.AspNetCore.DataProtection; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using ScadaLink.ConfigurationDatabase; + +namespace ScadaLink.ConfigurationDatabase.Tests; + +public class DataProtectionTests : IDisposable +{ + private readonly string _dbPath; + + public DataProtectionTests() + { + _dbPath = Path.Combine(Path.GetTempPath(), $"scadalink_dp_test_{Guid.NewGuid()}.db"); + } + + public void Dispose() + { + if (File.Exists(_dbPath)) + File.Delete(_dbPath); + } + + [Fact] + public void SharedDataProtection_ProtectAndUnprotect_AcrossContainers() + { + var connectionString = $"DataSource={_dbPath}"; + + // Create the database schema + var setupOptions = new DbContextOptionsBuilder() + .UseSqlite(connectionString) + .Options; + using (var setupCtx = new ScadaLinkDbContext(setupOptions)) + { + setupCtx.Database.EnsureCreated(); + } + + // Container 1: protect some data + var services1 = new ServiceCollection(); + services1.AddDbContext(opt => opt.UseSqlite(connectionString)); + services1.AddDataProtection() + .SetApplicationName("ScadaLink") + .PersistKeysToDbContext(); + + using var provider1 = services1.BuildServiceProvider(); + var protector1 = provider1.GetRequiredService() + .CreateProtector("test-purpose"); + var protectedPayload = protector1.Protect("secret-data"); + + // Container 2: unprotect using the same DB (shared keys) + var services2 = new ServiceCollection(); + services2.AddDbContext(opt => opt.UseSqlite(connectionString)); + services2.AddDataProtection() + .SetApplicationName("ScadaLink") + .PersistKeysToDbContext(); + + using var provider2 = services2.BuildServiceProvider(); + var protector2 = provider2.GetRequiredService() + .CreateProtector("test-purpose"); + var unprotected = protector2.Unprotect(protectedPayload); + + Assert.Equal("secret-data", unprotected); + } +} diff --git a/tests/ScadaLink.ConfigurationDatabase.Tests/RepositoryTests.cs b/tests/ScadaLink.ConfigurationDatabase.Tests/RepositoryTests.cs new file mode 100644 index 0000000..e6175b8 --- /dev/null +++ b/tests/ScadaLink.ConfigurationDatabase.Tests/RepositoryTests.cs @@ -0,0 +1,410 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using ScadaLink.Commons.Entities.Audit; +using ScadaLink.Commons.Entities.Deployment; +using ScadaLink.Commons.Entities.Instances; +using ScadaLink.Commons.Entities.Security; +using ScadaLink.Commons.Entities.Sites; +using ScadaLink.Commons.Entities.Templates; +using ScadaLink.Commons.Types.Enums; +using ScadaLink.ConfigurationDatabase; +using ScadaLink.ConfigurationDatabase.Repositories; + +namespace ScadaLink.ConfigurationDatabase.Tests; + +public class SecurityRepositoryTests : IDisposable +{ + private readonly ScadaLinkDbContext _context; + private readonly SecurityRepository _repository; + + public SecurityRepositoryTests() + { + var options = new DbContextOptionsBuilder() + .UseSqlite("DataSource=:memory:") + .Options; + + _context = new ScadaLinkDbContext(options); + _context.Database.OpenConnection(); + _context.Database.EnsureCreated(); + _repository = new SecurityRepository(_context); + } + + public void Dispose() + { + _context.Database.CloseConnection(); + _context.Dispose(); + } + + [Fact] + public async Task AddMapping_AndGetById_ReturnsMapping() + { + var mapping = new LdapGroupMapping("CN=Admins,DC=test", "Admin"); + await _repository.AddMappingAsync(mapping); + await _repository.SaveChangesAsync(); + + var loaded = await _repository.GetMappingByIdAsync(mapping.Id); + Assert.NotNull(loaded); + Assert.Equal("CN=Admins,DC=test", loaded.LdapGroupName); + Assert.Equal("Admin", loaded.Role); + } + + [Fact] + public async Task GetAllMappings_ReturnsAll() + { + await _repository.AddMappingAsync(new LdapGroupMapping("Group1", "Admin")); + await _repository.AddMappingAsync(new LdapGroupMapping("Group2", "Design")); + await _repository.SaveChangesAsync(); + + // +1 for seed data + var all = await _repository.GetAllMappingsAsync(); + Assert.True(all.Count >= 2); + } + + [Fact] + public async Task GetMappingsByRole_FiltersCorrectly() + { + await _repository.AddMappingAsync(new LdapGroupMapping("Designers", "Design")); + await _repository.AddMappingAsync(new LdapGroupMapping("Deployers", "Deployment")); + await _repository.SaveChangesAsync(); + + var designMappings = await _repository.GetMappingsByRoleAsync("Design"); + Assert.Single(designMappings); + Assert.Equal("Designers", designMappings[0].LdapGroupName); + } + + [Fact] + public async Task UpdateMapping_PersistsChange() + { + var mapping = new LdapGroupMapping("OldGroup", "Admin"); + await _repository.AddMappingAsync(mapping); + await _repository.SaveChangesAsync(); + + mapping.Role = "Design"; + await _repository.UpdateMappingAsync(mapping); + await _repository.SaveChangesAsync(); + + _context.ChangeTracker.Clear(); + var loaded = await _repository.GetMappingByIdAsync(mapping.Id); + Assert.Equal("Design", loaded!.Role); + } + + [Fact] + public async Task DeleteMapping_RemovesEntity() + { + var mapping = new LdapGroupMapping("ToDelete", "Admin"); + await _repository.AddMappingAsync(mapping); + await _repository.SaveChangesAsync(); + + await _repository.DeleteMappingAsync(mapping.Id); + await _repository.SaveChangesAsync(); + + var loaded = await _repository.GetMappingByIdAsync(mapping.Id); + Assert.Null(loaded); + } + + [Fact] + public async Task AddScopeRule_AndGetForMapping() + { + var site = new Site("Site1", "SITE-001"); + _context.Sites.Add(site); + var mapping = new LdapGroupMapping("Deployers", "Deployment"); + await _repository.AddMappingAsync(mapping); + await _repository.SaveChangesAsync(); + + var rule = new SiteScopeRule { LdapGroupMappingId = mapping.Id, SiteId = site.Id }; + await _repository.AddScopeRuleAsync(rule); + await _repository.SaveChangesAsync(); + + var rules = await _repository.GetScopeRulesForMappingAsync(mapping.Id); + Assert.Single(rules); + Assert.Equal(site.Id, rules[0].SiteId); + } + + [Fact] + public async Task GetScopeRuleById_ReturnsRule() + { + var site = new Site("Site1", "SITE-001"); + _context.Sites.Add(site); + var mapping = new LdapGroupMapping("Group", "Deployment"); + await _repository.AddMappingAsync(mapping); + await _repository.SaveChangesAsync(); + + var rule = new SiteScopeRule { LdapGroupMappingId = mapping.Id, SiteId = site.Id }; + await _repository.AddScopeRuleAsync(rule); + await _repository.SaveChangesAsync(); + + var loaded = await _repository.GetScopeRuleByIdAsync(rule.Id); + Assert.NotNull(loaded); + Assert.Equal(mapping.Id, loaded.LdapGroupMappingId); + } + + [Fact] + public async Task UpdateScopeRule_PersistsChange() + { + var site1 = new Site("Site1", "SITE-001"); + var site2 = new Site("Site2", "SITE-002"); + _context.Sites.AddRange(site1, site2); + var mapping = new LdapGroupMapping("Group", "Deployment"); + await _repository.AddMappingAsync(mapping); + await _repository.SaveChangesAsync(); + + var rule = new SiteScopeRule { LdapGroupMappingId = mapping.Id, SiteId = site1.Id }; + await _repository.AddScopeRuleAsync(rule); + await _repository.SaveChangesAsync(); + + rule.SiteId = site2.Id; + await _repository.UpdateScopeRuleAsync(rule); + await _repository.SaveChangesAsync(); + + _context.ChangeTracker.Clear(); + var loaded = await _repository.GetScopeRuleByIdAsync(rule.Id); + Assert.Equal(site2.Id, loaded!.SiteId); + } + + [Fact] + public async Task DeleteScopeRule_RemovesEntity() + { + var site = new Site("Site1", "SITE-001"); + _context.Sites.Add(site); + var mapping = new LdapGroupMapping("Group", "Deployment"); + await _repository.AddMappingAsync(mapping); + await _repository.SaveChangesAsync(); + + var rule = new SiteScopeRule { LdapGroupMappingId = mapping.Id, SiteId = site.Id }; + await _repository.AddScopeRuleAsync(rule); + await _repository.SaveChangesAsync(); + + await _repository.DeleteScopeRuleAsync(rule.Id); + await _repository.SaveChangesAsync(); + + var loaded = await _repository.GetScopeRuleByIdAsync(rule.Id); + Assert.Null(loaded); + } +} + +public class CentralUiRepositoryTests : IDisposable +{ + private readonly ScadaLinkDbContext _context; + private readonly CentralUiRepository _repository; + + public CentralUiRepositoryTests() + { + _context = SqliteTestHelper.CreateInMemoryContext(); + _repository = new CentralUiRepository(_context); + } + + public void Dispose() + { + _context.Database.CloseConnection(); + _context.Dispose(); + } + + [Fact] + public async Task GetAllSites_ReturnsOrderedByName() + { + _context.Sites.AddRange( + new Site("Zulu", "Z-001"), + new Site("Alpha", "A-001")); + await _context.SaveChangesAsync(); + + var sites = await _repository.GetAllSitesAsync(); + Assert.Equal(2, sites.Count); + Assert.Equal("Alpha", sites[0].Name); + Assert.Equal("Zulu", sites[1].Name); + } + + [Fact] + public async Task GetInstancesFiltered_BySiteId() + { + var site1 = new Site("Site1", "S-001"); + var site2 = new Site("Site2", "S-002"); + var template = new Template("T1"); + _context.Sites.AddRange(site1, site2); + _context.Templates.Add(template); + await _context.SaveChangesAsync(); + + _context.Instances.AddRange( + new Instance("Inst1") { SiteId = site1.Id, TemplateId = template.Id }, + new Instance("Inst2") { SiteId = site2.Id, TemplateId = template.Id }); + await _context.SaveChangesAsync(); + + var instances = await _repository.GetInstancesFilteredAsync(siteId: site1.Id); + Assert.Single(instances); + Assert.Equal("Inst1", instances[0].UniqueName); + } + + [Fact] + public async Task GetInstancesFiltered_BySearchTerm() + { + var site = new Site("Site1", "S-001"); + var template = new Template("T1"); + _context.Sites.Add(site); + _context.Templates.Add(template); + await _context.SaveChangesAsync(); + + _context.Instances.AddRange( + new Instance("PumpStation1") { SiteId = site.Id, TemplateId = template.Id }, + new Instance("TankLevel1") { SiteId = site.Id, TemplateId = template.Id }); + await _context.SaveChangesAsync(); + + var instances = await _repository.GetInstancesFilteredAsync(searchTerm: "Pump"); + Assert.Single(instances); + } + + [Fact] + public async Task GetRecentDeployments_ReturnsInReverseChronological() + { + var site = new Site("Site1", "S-001"); + var template = new Template("T1"); + _context.Sites.Add(site); + _context.Templates.Add(template); + await _context.SaveChangesAsync(); + + var instance = new Instance("I1") { SiteId = site.Id, TemplateId = template.Id }; + _context.Instances.Add(instance); + await _context.SaveChangesAsync(); + + _context.DeploymentRecords.AddRange( + new DeploymentRecord("d-001", "admin") { InstanceId = instance.Id, DeployedAt = DateTimeOffset.UtcNow.AddHours(-2) }, + new DeploymentRecord("d-002", "admin") { InstanceId = instance.Id, DeployedAt = DateTimeOffset.UtcNow.AddHours(-1) }, + new DeploymentRecord("d-003", "admin") { InstanceId = instance.Id, DeployedAt = DateTimeOffset.UtcNow }); + await _context.SaveChangesAsync(); + + var recent = await _repository.GetRecentDeploymentsAsync(2); + Assert.Equal(2, recent.Count); + Assert.Equal("d-003", recent[0].DeploymentId); + Assert.Equal("d-002", recent[1].DeploymentId); + } + + [Fact] + public async Task GetAuditLogEntries_FiltersByUser() + { + _context.AuditLogEntries.AddRange( + new AuditLogEntry("admin", "Create", "Template", "1", "T1") { Timestamp = DateTimeOffset.UtcNow }, + new AuditLogEntry("user1", "Update", "Instance", "2", "I1") { Timestamp = DateTimeOffset.UtcNow }); + await _context.SaveChangesAsync(); + + var (entries, total) = await _repository.GetAuditLogEntriesAsync(user: "admin"); + Assert.Single(entries); + Assert.Equal(1, total); + Assert.Equal("admin", entries[0].User); + } + + [Fact] + public async Task GetAuditLogEntries_FiltersByEntityType() + { + _context.AuditLogEntries.AddRange( + new AuditLogEntry("admin", "Create", "Template", "1", "T1") { Timestamp = DateTimeOffset.UtcNow }, + new AuditLogEntry("admin", "Create", "Instance", "2", "I1") { Timestamp = DateTimeOffset.UtcNow }); + await _context.SaveChangesAsync(); + + var (entries, total) = await _repository.GetAuditLogEntriesAsync(entityType: "Template"); + Assert.Single(entries); + Assert.Equal(1, total); + } + + [Fact] + public async Task GetAuditLogEntries_FiltersByActionType() + { + _context.AuditLogEntries.AddRange( + new AuditLogEntry("admin", "Create", "Template", "1", "T1") { Timestamp = DateTimeOffset.UtcNow }, + new AuditLogEntry("admin", "Delete", "Template", "2", "T2") { Timestamp = DateTimeOffset.UtcNow }); + await _context.SaveChangesAsync(); + + var (entries, total) = await _repository.GetAuditLogEntriesAsync(action: "Delete"); + Assert.Single(entries); + } + + [Fact] + public async Task GetAuditLogEntries_FiltersByTimeRange() + { + var now = DateTimeOffset.UtcNow; + _context.AuditLogEntries.AddRange( + new AuditLogEntry("admin", "Create", "Template", "1", "T1") { Timestamp = now.AddHours(-5) }, + new AuditLogEntry("admin", "Update", "Template", "2", "T2") { Timestamp = now.AddHours(-1) }); + await _context.SaveChangesAsync(); + + var (entries, total) = await _repository.GetAuditLogEntriesAsync(from: now.AddHours(-2)); + Assert.Single(entries); + } + + [Fact] + public async Task GetAuditLogEntries_FiltersByEntityId() + { + _context.AuditLogEntries.AddRange( + new AuditLogEntry("admin", "Create", "Template", "1", "T1") { Timestamp = DateTimeOffset.UtcNow }, + new AuditLogEntry("admin", "Create", "Template", "2", "T2") { Timestamp = DateTimeOffset.UtcNow }); + await _context.SaveChangesAsync(); + + var (entries, total) = await _repository.GetAuditLogEntriesAsync(entityId: "1"); + Assert.Single(entries); + } + + [Fact] + public async Task GetAuditLogEntries_FiltersByEntityName() + { + _context.AuditLogEntries.AddRange( + new AuditLogEntry("admin", "Create", "Template", "1", "PumpStation") { Timestamp = DateTimeOffset.UtcNow }, + new AuditLogEntry("admin", "Create", "Template", "2", "TankLevel") { Timestamp = DateTimeOffset.UtcNow }); + await _context.SaveChangesAsync(); + + var (entries, total) = await _repository.GetAuditLogEntriesAsync(entityName: "Pump"); + Assert.Single(entries); + } + + [Fact] + public async Task GetAuditLogEntries_ReverseChronologicalWithPagination() + { + var now = DateTimeOffset.UtcNow; + for (int i = 0; i < 10; i++) + { + _context.AuditLogEntries.Add(new AuditLogEntry("admin", "Create", "Template", i.ToString(), $"T{i}") + { + Timestamp = now.AddMinutes(i) + }); + } + await _context.SaveChangesAsync(); + + var (page1, total) = await _repository.GetAuditLogEntriesAsync(page: 1, pageSize: 3); + Assert.Equal(10, total); + Assert.Equal(3, page1.Count); + Assert.Equal("T9", page1[0].EntityName); // Most recent first + + var (page2, _) = await _repository.GetAuditLogEntriesAsync(page: 2, pageSize: 3); + Assert.Equal(3, page2.Count); + Assert.Equal("T6", page2[0].EntityName); + } + + [Fact] + public async Task GetTemplateTree_IncludesChildren() + { + var template = new Template("TestTemplate"); + template.Attributes.Add(new TemplateAttribute("Attr1") { DataType = DataType.Int32 }); + _context.Templates.Add(template); + await _context.SaveChangesAsync(); + + var tree = await _repository.GetTemplateTreeAsync(); + Assert.NotEmpty(tree); + var loaded = tree.First(t => t.Name == "TestTemplate"); + Assert.Single(loaded.Attributes); + } + + [Fact] + public async Task GetAreaTree_ReturnsHierarchy() + { + var site = new Site("Site1", "S-001"); + _context.Sites.Add(site); + await _context.SaveChangesAsync(); + + var parent = new Area("Building A") { SiteId = site.Id }; + _context.Areas.Add(parent); + await _context.SaveChangesAsync(); + + var child = new Area("Floor 1") { SiteId = site.Id, ParentAreaId = parent.Id }; + _context.Areas.Add(child); + await _context.SaveChangesAsync(); + + var areas = await _repository.GetAreaTreeBySiteIdAsync(site.Id); + Assert.Equal(2, areas.Count); + } +} diff --git a/tests/ScadaLink.ConfigurationDatabase.Tests/ScadaLink.ConfigurationDatabase.Tests.csproj b/tests/ScadaLink.ConfigurationDatabase.Tests/ScadaLink.ConfigurationDatabase.Tests.csproj index 7d743f9..50a3aaf 100644 --- a/tests/ScadaLink.ConfigurationDatabase.Tests/ScadaLink.ConfigurationDatabase.Tests.csproj +++ b/tests/ScadaLink.ConfigurationDatabase.Tests/ScadaLink.ConfigurationDatabase.Tests.csproj @@ -11,6 +11,8 @@ + + diff --git a/tests/ScadaLink.ConfigurationDatabase.Tests/SeedDataTests.cs b/tests/ScadaLink.ConfigurationDatabase.Tests/SeedDataTests.cs new file mode 100644 index 0000000..b1f6aa8 --- /dev/null +++ b/tests/ScadaLink.ConfigurationDatabase.Tests/SeedDataTests.cs @@ -0,0 +1,37 @@ +using Microsoft.EntityFrameworkCore; +using ScadaLink.ConfigurationDatabase; + +namespace ScadaLink.ConfigurationDatabase.Tests; + +public class SeedDataTests : IDisposable +{ + private readonly ScadaLinkDbContext _context; + + public SeedDataTests() + { + var options = new DbContextOptionsBuilder() + .UseSqlite("DataSource=:memory:") + .Options; + + _context = new ScadaLinkDbContext(options); + _context.Database.OpenConnection(); + _context.Database.EnsureCreated(); + } + + public void Dispose() + { + _context.Database.CloseConnection(); + _context.Dispose(); + } + + [Fact] + public async Task SeedData_AdminMappingExists() + { + var adminMapping = await _context.LdapGroupMappings + .SingleOrDefaultAsync(m => m.LdapGroupName == "SCADA-Admins"); + + Assert.NotNull(adminMapping); + Assert.Equal("Admin", adminMapping.Role); + Assert.Equal(1, adminMapping.Id); + } +} diff --git a/tests/ScadaLink.ConfigurationDatabase.Tests/SqliteTestHelper.cs b/tests/ScadaLink.ConfigurationDatabase.Tests/SqliteTestHelper.cs new file mode 100644 index 0000000..d5551e7 --- /dev/null +++ b/tests/ScadaLink.ConfigurationDatabase.Tests/SqliteTestHelper.cs @@ -0,0 +1,75 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using ScadaLink.ConfigurationDatabase; + +namespace ScadaLink.ConfigurationDatabase.Tests; + +/// +/// Test DbContext that maps DateTimeOffset to a sortable string format for SQLite. +/// EF Core 10 SQLite provider does not support ORDER BY on DateTimeOffset columns. +/// +public class SqliteTestDbContext : ScadaLinkDbContext +{ + public SqliteTestDbContext(DbContextOptions options) : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + // Convert DateTimeOffset to ISO 8601 string for SQLite so ORDER BY works + var converter = new ValueConverter( + v => v.UtcDateTime.ToString("o"), + v => DateTimeOffset.Parse(v)); + + var nullableConverter = new ValueConverter( + v => v.HasValue ? v.Value.UtcDateTime.ToString("o") : null, + v => v != null ? DateTimeOffset.Parse(v) : null); + + foreach (var entityType in modelBuilder.Model.GetEntityTypes()) + { + foreach (var property in entityType.GetProperties()) + { + if (property.ClrType == typeof(DateTimeOffset)) + { + property.SetValueConverter(converter); + property.SetColumnType("TEXT"); + } + else if (property.ClrType == typeof(DateTimeOffset?)) + { + property.SetValueConverter(nullableConverter); + property.SetColumnType("TEXT"); + } + } + } + } +} + +public static class SqliteTestHelper +{ + public static ScadaLinkDbContext CreateInMemoryContext() + { + var options = new DbContextOptionsBuilder() + .UseSqlite("DataSource=:memory:") + .ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning)) + .Options; + + var context = new SqliteTestDbContext(options); + context.Database.OpenConnection(); + context.Database.EnsureCreated(); + return context; + } + + public static ScadaLinkDbContext CreateFileContext(string dbPath) + { + var options = new DbContextOptionsBuilder() + .UseSqlite($"DataSource={dbPath}") + .ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning)) + .Options; + + var context = new SqliteTestDbContext(options); + return context; + } +} diff --git a/tests/ScadaLink.Security.Tests/ScadaLink.Security.Tests.csproj b/tests/ScadaLink.Security.Tests/ScadaLink.Security.Tests.csproj index 0fb8ee3..bc96ad8 100644 --- a/tests/ScadaLink.Security.Tests/ScadaLink.Security.Tests.csproj +++ b/tests/ScadaLink.Security.Tests/ScadaLink.Security.Tests.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -10,6 +10,12 @@ + + + + + + @@ -21,6 +27,8 @@ + + - \ No newline at end of file + diff --git a/tests/ScadaLink.Security.Tests/UnitTest1.cs b/tests/ScadaLink.Security.Tests/UnitTest1.cs index 343b048..1ccdd1a 100644 --- a/tests/ScadaLink.Security.Tests/UnitTest1.cs +++ b/tests/ScadaLink.Security.Tests/UnitTest1.cs @@ -1,10 +1,507 @@ -namespace ScadaLink.Security.Tests; +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using ScadaLink.Commons.Entities.Security; +using ScadaLink.Commons.Entities.Sites; +using ScadaLink.ConfigurationDatabase; +using ScadaLink.ConfigurationDatabase.Repositories; +using ScadaLink.Security; -public class UnitTest1 +namespace ScadaLink.Security.Tests; + +#region WP-6: LdapAuthService Tests + +public class LdapAuthServiceTests { - [Fact] - public void Test1() + private static SecurityOptions CreateOptions(bool useTls = true, bool allowInsecure = false) => new() { + LdapServer = "ldap.example.com", + LdapPort = 636, + LdapUseTls = useTls, + AllowInsecureLdap = allowInsecure, + LdapSearchBase = "dc=example,dc=com", + JwtSigningKey = "test-key-that-is-long-enough-for-hmac-sha256-minimum" + }; + [Fact] + public async Task AuthenticateAsync_EmptyUsername_ReturnsFailed() + { + var service = new LdapAuthService( + Options.Create(CreateOptions()), + NullLogger.Instance); + + var result = await service.AuthenticateAsync("", "password"); + Assert.False(result.Success); + Assert.Contains("Username is required", result.ErrorMessage); + } + + [Fact] + public async Task AuthenticateAsync_EmptyPassword_ReturnsFailed() + { + var service = new LdapAuthService( + Options.Create(CreateOptions()), + NullLogger.Instance); + + var result = await service.AuthenticateAsync("user", ""); + Assert.False(result.Success); + Assert.Contains("Password is required", result.ErrorMessage); + } + + [Fact] + public async Task AuthenticateAsync_InsecureLdapNotAllowed_ReturnsFailed() + { + var service = new LdapAuthService( + Options.Create(CreateOptions(useTls: false, allowInsecure: false)), + NullLogger.Instance); + + var result = await service.AuthenticateAsync("user", "password"); + Assert.False(result.Success); + Assert.Contains("Insecure LDAP", result.ErrorMessage); + } + + [Fact] + public async Task AuthenticateAsync_ConnectionFailure_ReturnsFailed() + { + // Point to a non-existent server — connection should fail + var options = CreateOptions(); + options.LdapServer = "nonexistent.invalid"; + options.LdapPort = 9999; + + var service = new LdapAuthService( + Options.Create(options), + NullLogger.Instance); + + var result = await service.AuthenticateAsync("user", "password"); + Assert.False(result.Success); } } + +#endregion + +#region WP-7: JwtTokenService Tests + +public class JwtTokenServiceTests +{ + private static SecurityOptions CreateOptions() => new() + { + JwtSigningKey = "this-is-a-test-signing-key-for-hmac-sha256-must-be-long-enough", + JwtExpiryMinutes = 15, + IdleTimeoutMinutes = 30, + JwtRefreshThresholdMinutes = 5 + }; + + private static JwtTokenService CreateService(SecurityOptions? options = null) + { + return new JwtTokenService( + Options.Create(options ?? CreateOptions()), + NullLogger.Instance); + } + + [Fact] + public void GenerateToken_ContainsCorrectClaims() + { + var service = CreateService(); + var token = service.GenerateToken( + "John Doe", "johnd", + new[] { "Admin", "Design" }, + new[] { "1", "2" }); + + var principal = service.ValidateToken(token); + Assert.NotNull(principal); + + Assert.Equal("John Doe", principal!.FindFirst(JwtTokenService.DisplayNameClaimType)?.Value); + Assert.Equal("johnd", principal.FindFirst(JwtTokenService.UsernameClaimType)?.Value); + + var roles = principal.FindAll(JwtTokenService.RoleClaimType).Select(c => c.Value).ToList(); + Assert.Contains("Admin", roles); + Assert.Contains("Design", roles); + + var siteIds = principal.FindAll(JwtTokenService.SiteIdClaimType).Select(c => c.Value).ToList(); + Assert.Contains("1", siteIds); + Assert.Contains("2", siteIds); + + Assert.NotNull(principal.FindFirst(JwtTokenService.LastActivityClaimType)); + } + + [Fact] + public void GenerateToken_NullSiteIds_NoSiteIdClaims() + { + var service = CreateService(); + var token = service.GenerateToken("User", "user", new[] { "Admin" }, null); + var principal = service.ValidateToken(token); + + Assert.NotNull(principal); + Assert.Empty(principal!.FindAll(JwtTokenService.SiteIdClaimType)); + } + + [Fact] + public void ValidateToken_InvalidToken_ReturnsNull() + { + var service = CreateService(); + var result = service.ValidateToken("invalid.token.here"); + Assert.Null(result); + } + + [Fact] + public void ValidateToken_WrongKey_ReturnsNull() + { + var service1 = CreateService(); + var token = service1.GenerateToken("User", "user", new[] { "Admin" }, null); + + var service2 = CreateService(new SecurityOptions + { + JwtSigningKey = "a-completely-different-signing-key-for-hmac-sha256-validation", + JwtExpiryMinutes = 15, + IdleTimeoutMinutes = 30, + JwtRefreshThresholdMinutes = 5 + }); + var result = service2.ValidateToken(token); + Assert.Null(result); + } + + [Fact] + public void ValidateToken_UsesHmacSha256() + { + var service = CreateService(); + var token = service.GenerateToken("User", "user", new[] { "Admin" }, null); + + // Decode header to verify algorithm + var parts = token.Split('.'); + var headerJson = System.Text.Encoding.UTF8.GetString( + Convert.FromBase64String(parts[0].PadRight((parts[0].Length + 3) & ~3, '='))); + Assert.Contains("HS256", headerJson); + } + + [Fact] + public void ShouldRefresh_TokenNearExpiry_ReturnsTrue() + { + var options = CreateOptions(); + options.JwtExpiryMinutes = 3; // Token expires in 3 min, threshold is 5 min + var service = CreateService(options); + + var token = service.GenerateToken("User", "user", new[] { "Admin" }, null); + var principal = service.ValidateToken(token); + + Assert.True(service.ShouldRefresh(principal!)); + } + + [Fact] + public void ShouldRefresh_TokenFarFromExpiry_ReturnsFalse() + { + var service = CreateService(); // 15 min expiry, 5 min threshold + var token = service.GenerateToken("User", "user", new[] { "Admin" }, null); + var principal = service.ValidateToken(token); + + Assert.False(service.ShouldRefresh(principal!)); + } + + [Fact] + public void IsIdleTimedOut_RecentActivity_ReturnsFalse() + { + var service = CreateService(); + var token = service.GenerateToken("User", "user", new[] { "Admin" }, null); + var principal = service.ValidateToken(token); + + Assert.False(service.IsIdleTimedOut(principal!)); + } + + [Fact] + public void IsIdleTimedOut_NoLastActivityClaim_ReturnsTrue() + { + var service = CreateService(); + var principal = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(JwtTokenService.DisplayNameClaimType, "User") + })); + + Assert.True(service.IsIdleTimedOut(principal)); + } + + [Fact] + public void RefreshToken_ReturnsNewTokenWithUpdatedClaims() + { + var service = CreateService(); + var originalToken = service.GenerateToken("User", "user", new[] { "Admin" }, null); + var principal = service.ValidateToken(originalToken); + + var newToken = service.RefreshToken(principal!, new[] { "Admin", "Design" }, new[] { "1" }); + Assert.NotNull(newToken); + + var newPrincipal = service.ValidateToken(newToken!); + var roles = newPrincipal!.FindAll(JwtTokenService.RoleClaimType).Select(c => c.Value).ToList(); + Assert.Contains("Design", roles); + } + + [Fact] + public void RefreshToken_MissingClaims_ReturnsNull() + { + var service = CreateService(); + var principal = new ClaimsPrincipal(new ClaimsIdentity()); + + var result = service.RefreshToken(principal, new[] { "Admin" }, null); + Assert.Null(result); + } +} + +#endregion + +#region WP-8: RoleMapper Tests + +public class RoleMapperTests : IDisposable +{ + private readonly ScadaLinkDbContext _context; + private readonly SecurityRepository _securityRepo; + private readonly RoleMapper _roleMapper; + + public RoleMapperTests() + { + var options = new DbContextOptionsBuilder() + .UseSqlite("DataSource=:memory:") + .Options; + + _context = new ScadaLinkDbContext(options); + _context.Database.OpenConnection(); + _context.Database.EnsureCreated(); + _securityRepo = new SecurityRepository(_context); + _roleMapper = new RoleMapper(_securityRepo); + } + + public void Dispose() + { + _context.Database.CloseConnection(); + _context.Dispose(); + } + + [Fact] + public async Task MapGroupsToRoles_MultiRoleExtraction() + { + // Add mappings (note: seed data adds SCADA-Admins -> Admin) + _context.LdapGroupMappings.Add(new LdapGroupMapping("Designers", "Design")); + _context.LdapGroupMappings.Add(new LdapGroupMapping("Deployers", "Deployment")); + await _context.SaveChangesAsync(); + + var result = await _roleMapper.MapGroupsToRolesAsync(new[] { "SCADA-Admins", "Designers" }); + + Assert.Contains("Admin", result.Roles); + Assert.Contains("Design", result.Roles); + Assert.DoesNotContain("Deployment", result.Roles); + } + + [Fact] + public async Task MapGroupsToRoles_SiteScopedDeployment() + { + var site1 = new Site("Site1", "S-001"); + var site2 = new Site("Site2", "S-002"); + _context.Sites.AddRange(site1, site2); + _context.LdapGroupMappings.Add(new LdapGroupMapping("SiteDeployers", "Deployment")); + await _context.SaveChangesAsync(); + + var mapping = await _context.LdapGroupMappings.SingleAsync(m => m.LdapGroupName == "SiteDeployers"); + _context.SiteScopeRules.AddRange( + new SiteScopeRule { LdapGroupMappingId = mapping.Id, SiteId = site1.Id }, + new SiteScopeRule { LdapGroupMappingId = mapping.Id, SiteId = site2.Id }); + await _context.SaveChangesAsync(); + + var result = await _roleMapper.MapGroupsToRolesAsync(new[] { "SiteDeployers" }); + + Assert.Contains("Deployment", result.Roles); + Assert.False(result.IsSystemWideDeployment); + Assert.Contains(site1.Id.ToString(), result.PermittedSiteIds); + Assert.Contains(site2.Id.ToString(), result.PermittedSiteIds); + } + + [Fact] + public async Task MapGroupsToRoles_SystemWideDeployment_NoScopeRules() + { + _context.LdapGroupMappings.Add(new LdapGroupMapping("GlobalDeployers", "Deployment")); + await _context.SaveChangesAsync(); + + var result = await _roleMapper.MapGroupsToRolesAsync(new[] { "GlobalDeployers" }); + + Assert.Contains("Deployment", result.Roles); + Assert.True(result.IsSystemWideDeployment); + Assert.Empty(result.PermittedSiteIds); + } + + [Fact] + public async Task MapGroupsToRoles_UnrecognizedGroups_Ignored() + { + var result = await _roleMapper.MapGroupsToRolesAsync(new[] { "NonExistentGroup", "AnotherRandom" }); + + Assert.Empty(result.Roles); + Assert.Empty(result.PermittedSiteIds); + Assert.False(result.IsSystemWideDeployment); + } + + [Fact] + public async Task MapGroupsToRoles_NoMatchingGroups_NoRoles() + { + var result = await _roleMapper.MapGroupsToRolesAsync(Array.Empty()); + + Assert.Empty(result.Roles); + } + + [Fact] + public async Task MapGroupsToRoles_CaseInsensitiveGroupMatch() + { + // "SCADA-Admins" is seeded + var result = await _roleMapper.MapGroupsToRolesAsync(new[] { "scada-admins" }); + + Assert.Contains("Admin", result.Roles); + } +} + +#endregion + +#region WP-9: Authorization Policy Tests + +public class AuthorizationPolicyTests +{ + [Fact] + public async Task AdminPolicy_AdminRole_Succeeds() + { + var principal = CreatePrincipal(new[] { "Admin" }); + var result = await EvaluatePolicy(AuthorizationPolicies.RequireAdmin, principal); + Assert.True(result); + } + + [Fact] + public async Task AdminPolicy_DesignRole_Fails() + { + var principal = CreatePrincipal(new[] { "Design" }); + var result = await EvaluatePolicy(AuthorizationPolicies.RequireAdmin, principal); + Assert.False(result); + } + + [Fact] + public async Task DesignPolicy_DesignRole_Succeeds() + { + var principal = CreatePrincipal(new[] { "Design" }); + var result = await EvaluatePolicy(AuthorizationPolicies.RequireDesign, principal); + Assert.True(result); + } + + [Fact] + public async Task DeploymentPolicy_DeploymentRole_Succeeds() + { + var principal = CreatePrincipal(new[] { "Deployment" }); + var result = await EvaluatePolicy(AuthorizationPolicies.RequireDeployment, principal); + Assert.True(result); + } + + [Fact] + public async Task NoRoles_DeniedAll() + { + var principal = CreatePrincipal(Array.Empty()); + Assert.False(await EvaluatePolicy(AuthorizationPolicies.RequireAdmin, principal)); + Assert.False(await EvaluatePolicy(AuthorizationPolicies.RequireDesign, principal)); + Assert.False(await EvaluatePolicy(AuthorizationPolicies.RequireDeployment, principal)); + } + + [Fact] + public async Task SiteScope_SystemWideDeployer_Succeeds() + { + var handler = new SiteScopeAuthorizationHandler(); + var claims = new List + { + new(JwtTokenService.RoleClaimType, "Deployment") + // No SiteId claims = system-wide + }; + var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "test")); + + var requirement = new SiteScopeRequirement("42"); + var context = new AuthorizationHandlerContext(new[] { requirement }, principal, null); + + await handler.HandleAsync(context); + + Assert.True(context.HasSucceeded); + } + + [Fact] + public async Task SiteScope_PermittedSite_Succeeds() + { + var handler = new SiteScopeAuthorizationHandler(); + var claims = new List + { + new(JwtTokenService.RoleClaimType, "Deployment"), + new(JwtTokenService.SiteIdClaimType, "1"), + new(JwtTokenService.SiteIdClaimType, "2") + }; + var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "test")); + + var requirement = new SiteScopeRequirement("1"); + var context = new AuthorizationHandlerContext(new[] { requirement }, principal, null); + + await handler.HandleAsync(context); + + Assert.True(context.HasSucceeded); + } + + [Fact] + public async Task SiteScope_UnpermittedSite_Fails() + { + var handler = new SiteScopeAuthorizationHandler(); + var claims = new List + { + new(JwtTokenService.RoleClaimType, "Deployment"), + new(JwtTokenService.SiteIdClaimType, "1") + }; + var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "test")); + + var requirement = new SiteScopeRequirement("99"); + var context = new AuthorizationHandlerContext(new[] { requirement }, principal, null); + + await handler.HandleAsync(context); + + Assert.False(context.HasSucceeded); + } + + [Fact] + public async Task SiteScope_NoDeploymentRole_Fails() + { + var handler = new SiteScopeAuthorizationHandler(); + var claims = new List + { + new(JwtTokenService.RoleClaimType, "Admin") + }; + var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "test")); + + var requirement = new SiteScopeRequirement("1"); + var context = new AuthorizationHandlerContext(new[] { requirement }, principal, null); + + await handler.HandleAsync(context); + + Assert.False(context.HasSucceeded); + } + + private static ClaimsPrincipal CreatePrincipal(string[] roles, string[]? siteIds = null) + { + var claims = new List(); + foreach (var role in roles) + claims.Add(new Claim(JwtTokenService.RoleClaimType, role)); + if (siteIds != null) + foreach (var siteId in siteIds) + claims.Add(new Claim(JwtTokenService.SiteIdClaimType, siteId)); + + return new ClaimsPrincipal(new ClaimsIdentity(claims, "test")); + } + + private static async Task EvaluatePolicy(string policyName, ClaimsPrincipal principal) + { + var services = new ServiceCollection(); + services.AddScadaLinkAuthorization(); + services.AddLogging(); + + using var provider = services.BuildServiceProvider(); + var authService = provider.GetRequiredService(); + + var result = await authService.AuthorizeAsync(principal, null, policyName); + return result.Succeeded; + } +} + +#endregion