diff --git a/backend/ApprovalFlow/ApprovalFlow.csproj b/backend/ApprovalFlow/ApprovalFlow.csproj
new file mode 100644
index 00000000..19f78402
--- /dev/null
+++ b/backend/ApprovalFlow/ApprovalFlow.csproj
@@ -0,0 +1,20 @@
+
+
+
+ net6.0
+ enable
+ enable
+ 1.0.0
+ Linux
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/ApprovalFlow/ApprovalFlowConfiguration.cs b/backend/ApprovalFlow/ApprovalFlowConfiguration.cs
new file mode 100644
index 00000000..65261e1d
--- /dev/null
+++ b/backend/ApprovalFlow/ApprovalFlowConfiguration.cs
@@ -0,0 +1,93 @@
+namespace ApprovalFlow;
+using common.Constants.Auth;
+
+public class ApprovalFlowConfiguration
+{
+ public static bool IsProduction() => EnvironmentName == Environments.Production;
+ public static bool IsDevelopment() => EnvironmentName == Environments.Development;
+ private static readonly string? EnvironmentName = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT");
+
+ public ConnectionStringConfiguration ConnectionStrings { get; set; } = new();
+ public KafkaClusterConfiguration KafkaCluster { get; set; } = new();
+ public ApprovalConfiguration ApprovalConfig { get; set; } = new();
+ public KeycloakConfiguration Keycloak { get; set; } = new();
+
+ public SchemaRegistryConfiguration SchemaRegistry { get; set; } = new();
+ public TelemeteryConfiguration Telemetry { get; set; } = new TelemeteryConfiguration();
+ public SplunkConfiguration SplunkConfig { get; set; } = new SplunkConfiguration();
+
+
+ public class SplunkConfiguration
+ {
+ public string Host { get; set; } = string.Empty;
+ public string CollectorToken { get; set; } = string.Empty;
+ }
+
+ public class ApprovalConfiguration
+ {
+ public string NotifyEmail { get; set; } = string.Empty;
+ public string Subject { get; set; } = string.Empty;
+ }
+
+ // ------- Configuration Objects -------
+
+ public class TelemeteryConfiguration
+ {
+ public string CollectorUrl { get; set; } = string.Empty;
+ public string AzureConnectionString { get; set; } = string.Empty;
+ public bool LogToConsole { get; set; }
+
+ }
+
+
+ public class ConnectionStringConfiguration
+ {
+ public string ApprovalFlowDataStore { get; set; } = string.Empty;
+ }
+
+ public class KeycloakConfiguration
+ {
+ public string RealmUrl { get; set; } = string.Empty;
+ public string WellKnownConfig => KeycloakUrls.WellKnownConfig(this.RealmUrl);
+ public string TokenUrl => KeycloakUrls.Token(this.RealmUrl);
+ public string AdministrationUrl { get; set; } = string.Empty;
+ public string AdministrationClientId { get; set; } = string.Empty;
+ public string AdministrationClientSecret { get; set; } = string.Empty;
+ public string HcimClientId { get; set; } = string.Empty;
+ }
+
+
+
+ public class SchemaRegistryConfiguration
+ {
+ public string Url { get; set; } = string.Empty;
+ public string ClientId { get; set; } = string.Empty;
+ public string ClientSecret { get; set; } = string.Empty;
+
+ }
+
+
+ public class KafkaClusterConfiguration
+ {
+ public string Url { get; set; } = string.Empty;
+ public string BootstrapServers { get; set; } = string.Empty;
+ public string SaslOauthbearerTokenEndpointUrl { get; set; } = string.Empty;
+ public string IncomingApprovalCreationTopic { get; set; } = string.Empty;
+ public string ApprovalResponseTopic { get; set; } = string.Empty;
+ public string NotificationTopic { get;set; } = string.Empty;
+
+ public string SaslOauthbearerProducerClientId { get; set; } = string.Empty;
+ public string SaslOauthbearerProducerClientSecret { get; set; } = string.Empty;
+ public string SaslOauthbearerConsumerClientId { get; set; } = string.Empty;
+ public string SaslOauthbearerConsumerClientSecret { get; set; } = string.Empty;
+ public string SslCaLocation { get; set; } = string.Empty;
+ public string SslCertificateLocation { get; set; } = string.Empty;
+ public string SslKeyLocation { get; set; } = string.Empty;
+ public string Scope { get; set; } = "openid";
+ public string ConsumerGroupId { get; set; } = "approval-consumer-group";
+
+
+ }
+
+}
+
diff --git a/backend/ApprovalFlow/Data/Approval/ApprovalHistory.cs b/backend/ApprovalFlow/Data/Approval/ApprovalHistory.cs
new file mode 100644
index 00000000..85744359
--- /dev/null
+++ b/backend/ApprovalFlow/Data/Approval/ApprovalHistory.cs
@@ -0,0 +1,21 @@
+namespace ApprovalFlow.Data.Approval;
+
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+using Common.Models.Approval;
+using DIAM.Common.Models;
+using NodaTime;
+
+[Table(nameof(ApprovalHistory))]
+public class ApprovalHistory : BaseAuditable
+{
+ [Key]
+ public int Id { get; set; }
+ public string DecisionNote { get; set; } = string.Empty;
+ public string Approver { get; set; } = string.Empty;
+ public int RequestId { get; set; }
+ public Request AccessRequest { get; set; }
+ public Instant? Deleted { get; set; }
+ public ApprovalStatus Status { get; set; } = ApprovalStatus.PENDING;
+
+}
diff --git a/backend/ApprovalFlow/Data/Approval/ApprovalRequest.cs b/backend/ApprovalFlow/Data/Approval/ApprovalRequest.cs
new file mode 100644
index 00000000..fa846182
--- /dev/null
+++ b/backend/ApprovalFlow/Data/Approval/ApprovalRequest.cs
@@ -0,0 +1,24 @@
+namespace ApprovalFlow.Data.Approval;
+
+using NodaTime;
+using System.ComponentModel.DataAnnotations.Schema;
+using System.ComponentModel.DataAnnotations;
+using DIAM.Common.Models;
+
+[Table(nameof(ApprovalRequest))]
+public class ApprovalRequest : BaseAuditable
+{
+ [Key]
+ public int Id { get; set; }
+ public string Reason { get; set; } = string.Empty;
+ [Required]
+ public string MessageKey { get; set; } = string.Empty;
+ public string UserId { get; set; } = string.Empty;
+ public string IdentityProvider { get; set; } = string.Empty;
+ public int NoOfApprovalsRequired { get; set; }
+ public string RequiredAccess { get; set; } = string.Empty;
+ public Instant? Approved { get; set; }
+ public Instant? Completed { get; set; }
+ public ICollection Requests { get; set; } = new List();
+
+}
diff --git a/backend/ApprovalFlow/Data/Approval/MappingProfile.cs b/backend/ApprovalFlow/Data/Approval/MappingProfile.cs
new file mode 100644
index 00000000..724503d3
--- /dev/null
+++ b/backend/ApprovalFlow/Data/Approval/MappingProfile.cs
@@ -0,0 +1,15 @@
+namespace ApprovalFlow.Data.Approval;
+
+using AutoMapper;
+using Common.Models.Approval;
+
+public class MappingProfile : Profile
+{
+ public MappingProfile()
+ {
+ this.CreateMap();
+ this.CreateMap();
+ this.CreateMap();
+
+ }
+}
diff --git a/backend/ApprovalFlow/Data/Approval/Request.cs b/backend/ApprovalFlow/Data/Approval/Request.cs
new file mode 100644
index 00000000..fcd00348
--- /dev/null
+++ b/backend/ApprovalFlow/Data/Approval/Request.cs
@@ -0,0 +1,32 @@
+namespace ApprovalFlow.Data.Approval;
+
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+using DIAM.Common.Models;
+
+///
+/// An ApprovalRequest can contain one or more requests, this allows us to group requests for a user
+/// together. E.g. a lawyer might be requesting to be a participant in core and user in disclosure.
+/// Or in future a user may request to have access to something and also change their email address
+///
+[Table(nameof(Request))]
+public class Request : BaseAuditable
+{
+ [Key]
+ public int Id { get; set; }
+ [Required]
+ public int ApprovalRequestId { get; set; }
+ public ApprovalRequest? ApprovalRequest { get; set; } // navigation property
+ public int RequestId { get; set; }
+
+ public ApprovalType ApprovalType { get; set; } = ApprovalType.AccessRequest;
+ public string RequestType { get; set; } = string.Empty;
+ public ICollection History { get; set; }
+
+}
+
+public enum ApprovalType
+{
+ AccessRequest,
+ AccountChange
+}
diff --git a/backend/ApprovalFlow/Data/ApprovalFlowDataStoreDbContext.cs b/backend/ApprovalFlow/Data/ApprovalFlowDataStoreDbContext.cs
new file mode 100644
index 00000000..37fc0c14
--- /dev/null
+++ b/backend/ApprovalFlow/Data/ApprovalFlowDataStoreDbContext.cs
@@ -0,0 +1,87 @@
+namespace ApprovalFlow.Data;
+
+using ApprovalFlow.Data.Approval;
+using ApprovalFlow.Models;
+using DIAM.Common.Models;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using NodaTime;
+
+public class ApprovalFlowDataStoreDbContext : DbContext
+{
+ private readonly IClock clock;
+
+ public ApprovalFlowDataStoreDbContext(DbContextOptions options, IClock clock) : base(options) => this.clock = clock;
+ public DbSet IdempotentConsumers { get; set; } = default!;
+ public DbSet ApprovalRequests { get; set; } = default!;
+ public DbSet Requests { get; set; } = default!;
+ public DbSet ApprovalHistories { get; set; } = default!;
+
+
+
+ public override int SaveChanges()
+ {
+ this.ApplyAudits();
+
+ return base.SaveChanges();
+ }
+
+ public override async Task SaveChangesAsync(CancellationToken cancellationToken = default)
+ {
+ this.ApplyAudits();
+
+ return await base.SaveChangesAsync(cancellationToken);
+ }
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.HasDefaultSchema("approvalflow");
+ base.OnModelCreating(modelBuilder);
+
+ modelBuilder
+ .Entity()
+ .Property(d => d.ApprovalType)
+ .HasConversion(new EnumToStringConverter());
+
+ modelBuilder.ApplyConfigurationsFromAssembly(typeof(ApprovalFlowDataStoreDbContext).Assembly);
+ }
+
+ private void ApplyAudits()
+ {
+ this.ChangeTracker.DetectChanges();
+ var updated = this.ChangeTracker.Entries()
+ .Where(x => x.Entity is BaseAuditable
+ && (x.State == EntityState.Added || x.State == EntityState.Modified));
+
+ var currentInstant = this.clock.GetCurrentInstant();
+
+ foreach (var entry in updated)
+ {
+ entry.CurrentValues[nameof(BaseAuditable.Modified)] = currentInstant;
+
+ if (entry.State == EntityState.Added)
+ {
+ entry.CurrentValues[nameof(BaseAuditable.Created)] = currentInstant;
+ }
+ else
+ {
+ entry.Property(nameof(BaseAuditable.Created)).IsModified = false;
+ }
+ }
+ }
+
+ public async Task IdempotentConsumer(string messageId, string consumer, Instant consumeDate)
+ {
+ await this.IdempotentConsumers.AddAsync(new IdempotentConsumer
+ {
+ MessageId = messageId,
+ Consumer = consumer,
+ ConsumeDate = consumeDate
+ });
+ await this.SaveChangesAsync();
+ }
+
+ public async Task HasBeenProcessed(string messageId, string consumer) => await this.IdempotentConsumers.AnyAsync(x => x.MessageId == messageId && x.Consumer == consumer);
+
+
+}
diff --git a/backend/ApprovalFlow/Data/Migrations/20230815232634_Initial.Designer.cs b/backend/ApprovalFlow/Data/Migrations/20230815232634_Initial.Designer.cs
new file mode 100644
index 00000000..94b3ea7f
--- /dev/null
+++ b/backend/ApprovalFlow/Data/Migrations/20230815232634_Initial.Designer.cs
@@ -0,0 +1,208 @@
+//
+using System;
+using ApprovalFlow.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using NodaTime;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace ApprovalFlow.Data.Migrations
+{
+ [DbContext(typeof(ApprovalFlowDataStoreDbContext))]
+ [Migration("20230815232634_Initial")]
+ partial class Initial
+ {
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasDefaultSchema("approvalflow")
+ .HasAnnotation("ProductVersion", "6.0.8")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("ApprovalFlow.Data.Approval.ApprovalHistory", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Approver")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Created")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DecisionNote")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Deleted")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Modified")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("RequestId")
+ .HasColumnType("integer");
+
+ b.Property("Status")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RequestId");
+
+ b.ToTable("ApprovalHistory", "approvalflow");
+ });
+
+ modelBuilder.Entity("ApprovalFlow.Data.Approval.ApprovalRequest", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Approved")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Completed")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Created")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("IdentityProvider")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("MessageKey")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Modified")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("NoOfApprovalsRequired")
+ .HasColumnType("integer");
+
+ b.Property("Reason")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("RequiredAccess")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("UserId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.ToTable("ApprovalRequest", "approvalflow");
+ });
+
+ modelBuilder.Entity("ApprovalFlow.Data.Approval.Request", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ApprovalRequestId")
+ .HasColumnType("integer");
+
+ b.Property("ApprovalType")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Created")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Modified")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("RequestId")
+ .HasColumnType("integer");
+
+ b.Property("RequestType")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ApprovalRequestId");
+
+ b.ToTable("Request", "approvalflow");
+ });
+
+ modelBuilder.Entity("ApprovalFlow.Models.IdempotentConsumer", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ConsumeDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Consumer")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("MessageId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.ToTable("IdempotentConsumers", "approvalflow");
+ });
+
+ modelBuilder.Entity("ApprovalFlow.Data.Approval.ApprovalHistory", b =>
+ {
+ b.HasOne("ApprovalFlow.Data.Approval.Request", "AccessRequest")
+ .WithMany("History")
+ .HasForeignKey("RequestId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("AccessRequest");
+ });
+
+ modelBuilder.Entity("ApprovalFlow.Data.Approval.Request", b =>
+ {
+ b.HasOne("ApprovalFlow.Data.Approval.ApprovalRequest", "ApprovalRequest")
+ .WithMany("Requests")
+ .HasForeignKey("ApprovalRequestId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("ApprovalRequest");
+ });
+
+ modelBuilder.Entity("ApprovalFlow.Data.Approval.ApprovalRequest", b =>
+ {
+ b.Navigation("Requests");
+ });
+
+ modelBuilder.Entity("ApprovalFlow.Data.Approval.Request", b =>
+ {
+ b.Navigation("History");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/backend/ApprovalFlow/Data/Migrations/20230815232634_Initial.cs b/backend/ApprovalFlow/Data/Migrations/20230815232634_Initial.cs
new file mode 100644
index 00000000..f7d2d9c8
--- /dev/null
+++ b/backend/ApprovalFlow/Data/Migrations/20230815232634_Initial.cs
@@ -0,0 +1,140 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+using NodaTime;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace ApprovalFlow.Data.Migrations
+{
+ public partial class Initial : Migration
+ {
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.EnsureSchema(
+ name: "approvalflow");
+
+ migrationBuilder.CreateTable(
+ name: "ApprovalRequest",
+ schema: "approvalflow",
+ columns: table => new
+ {
+ Id = table.Column(type: "integer", nullable: false)
+ .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
+ Reason = table.Column(type: "text", nullable: false),
+ MessageKey = table.Column(type: "text", nullable: false),
+ UserId = table.Column(type: "text", nullable: false),
+ IdentityProvider = table.Column(type: "text", nullable: false),
+ NoOfApprovalsRequired = table.Column(type: "integer", nullable: false),
+ RequiredAccess = table.Column(type: "text", nullable: false),
+ Approved = table.Column(type: "timestamp with time zone", nullable: true),
+ Completed = table.Column(type: "timestamp with time zone", nullable: true),
+ Created = table.Column(type: "timestamp with time zone", nullable: false),
+ Modified = table.Column(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_ApprovalRequest", x => x.Id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "IdempotentConsumers",
+ schema: "approvalflow",
+ columns: table => new
+ {
+ Id = table.Column(type: "integer", nullable: false)
+ .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
+ MessageId = table.Column(type: "text", nullable: false),
+ Consumer = table.Column(type: "text", nullable: false),
+ ConsumeDate = table.Column(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_IdempotentConsumers", x => x.Id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "Request",
+ schema: "approvalflow",
+ columns: table => new
+ {
+ Id = table.Column(type: "integer", nullable: false)
+ .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
+ ApprovalRequestId = table.Column(type: "integer", nullable: false),
+ RequestId = table.Column(type: "integer", nullable: false),
+ ApprovalType = table.Column(type: "text", nullable: false),
+ RequestType = table.Column(type: "text", nullable: false),
+ Created = table.Column(type: "timestamp with time zone", nullable: false),
+ Modified = table.Column(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Request", x => x.Id);
+ table.ForeignKey(
+ name: "FK_Request_ApprovalRequest_ApprovalRequestId",
+ column: x => x.ApprovalRequestId,
+ principalSchema: "approvalflow",
+ principalTable: "ApprovalRequest",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "ApprovalHistory",
+ schema: "approvalflow",
+ columns: table => new
+ {
+ Id = table.Column(type: "integer", nullable: false)
+ .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
+ DecisionNote = table.Column(type: "text", nullable: false),
+ Approver = table.Column(type: "text", nullable: false),
+ RequestId = table.Column(type: "integer", nullable: false),
+ Deleted = table.Column(type: "timestamp with time zone", nullable: true),
+ Status = table.Column(type: "integer", nullable: false),
+ Created = table.Column(type: "timestamp with time zone", nullable: false),
+ Modified = table.Column(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_ApprovalHistory", x => x.Id);
+ table.ForeignKey(
+ name: "FK_ApprovalHistory_Request_RequestId",
+ column: x => x.RequestId,
+ principalSchema: "approvalflow",
+ principalTable: "Request",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_ApprovalHistory_RequestId",
+ schema: "approvalflow",
+ table: "ApprovalHistory",
+ column: "RequestId");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Request_ApprovalRequestId",
+ schema: "approvalflow",
+ table: "Request",
+ column: "ApprovalRequestId");
+ }
+
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "ApprovalHistory",
+ schema: "approvalflow");
+
+ migrationBuilder.DropTable(
+ name: "IdempotentConsumers",
+ schema: "approvalflow");
+
+ migrationBuilder.DropTable(
+ name: "Request",
+ schema: "approvalflow");
+
+ migrationBuilder.DropTable(
+ name: "ApprovalRequest",
+ schema: "approvalflow");
+ }
+ }
+}
diff --git a/backend/ApprovalFlow/Data/Migrations/ApprovalFlowDataStoreDbContextModelSnapshot.cs b/backend/ApprovalFlow/Data/Migrations/ApprovalFlowDataStoreDbContextModelSnapshot.cs
new file mode 100644
index 00000000..37064147
--- /dev/null
+++ b/backend/ApprovalFlow/Data/Migrations/ApprovalFlowDataStoreDbContextModelSnapshot.cs
@@ -0,0 +1,206 @@
+//
+using System;
+using ApprovalFlow.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using NodaTime;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace ApprovalFlow.Data.Migrations
+{
+ [DbContext(typeof(ApprovalFlowDataStoreDbContext))]
+ partial class ApprovalFlowDataStoreDbContextModelSnapshot : ModelSnapshot
+ {
+ protected override void BuildModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasDefaultSchema("approvalflow")
+ .HasAnnotation("ProductVersion", "6.0.8")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("ApprovalFlow.Data.Approval.ApprovalHistory", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Approver")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Created")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DecisionNote")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Deleted")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Modified")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("RequestId")
+ .HasColumnType("integer");
+
+ b.Property("Status")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RequestId");
+
+ b.ToTable("ApprovalHistory", "approvalflow");
+ });
+
+ modelBuilder.Entity("ApprovalFlow.Data.Approval.ApprovalRequest", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Approved")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Completed")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Created")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("IdentityProvider")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("MessageKey")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Modified")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("NoOfApprovalsRequired")
+ .HasColumnType("integer");
+
+ b.Property("Reason")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("RequiredAccess")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("UserId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.ToTable("ApprovalRequest", "approvalflow");
+ });
+
+ modelBuilder.Entity("ApprovalFlow.Data.Approval.Request", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ApprovalRequestId")
+ .HasColumnType("integer");
+
+ b.Property("ApprovalType")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Created")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Modified")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("RequestId")
+ .HasColumnType("integer");
+
+ b.Property("RequestType")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ApprovalRequestId");
+
+ b.ToTable("Request", "approvalflow");
+ });
+
+ modelBuilder.Entity("ApprovalFlow.Models.IdempotentConsumer", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ConsumeDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Consumer")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("MessageId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.ToTable("IdempotentConsumers", "approvalflow");
+ });
+
+ modelBuilder.Entity("ApprovalFlow.Data.Approval.ApprovalHistory", b =>
+ {
+ b.HasOne("ApprovalFlow.Data.Approval.Request", "AccessRequest")
+ .WithMany("History")
+ .HasForeignKey("RequestId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("AccessRequest");
+ });
+
+ modelBuilder.Entity("ApprovalFlow.Data.Approval.Request", b =>
+ {
+ b.HasOne("ApprovalFlow.Data.Approval.ApprovalRequest", "ApprovalRequest")
+ .WithMany("Requests")
+ .HasForeignKey("ApprovalRequestId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("ApprovalRequest");
+ });
+
+ modelBuilder.Entity("ApprovalFlow.Data.Approval.ApprovalRequest", b =>
+ {
+ b.Navigation("Requests");
+ });
+
+ modelBuilder.Entity("ApprovalFlow.Data.Approval.Request", b =>
+ {
+ b.Navigation("History");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/backend/ApprovalFlow/Exceptions/ApprovalResponseException.cs b/backend/ApprovalFlow/Exceptions/ApprovalResponseException.cs
new file mode 100644
index 00000000..1bb795b5
--- /dev/null
+++ b/backend/ApprovalFlow/Exceptions/ApprovalResponseException.cs
@@ -0,0 +1,12 @@
+namespace ApprovalFlow.Exceptions;
+
+using Common.Models.Approval;
+
+public class ApprovalResponseException : Exception
+{
+ public ApprovalResponseException(string? message) : base(message)
+ {
+ }
+
+
+}
diff --git a/backend/ApprovalFlow/Exceptions/IncomingApprovalException.cs b/backend/ApprovalFlow/Exceptions/IncomingApprovalException.cs
new file mode 100644
index 00000000..234f15af
--- /dev/null
+++ b/backend/ApprovalFlow/Exceptions/IncomingApprovalException.cs
@@ -0,0 +1,8 @@
+namespace ApprovalFlow.Exceptions;
+
+public class IncomingApprovalException : Exception
+{
+ public IncomingApprovalException(string? message) : base(message)
+ {
+ }
+}
diff --git a/backend/ApprovalFlow/Features/Approvals/ApprovalResponseCommand.cs b/backend/ApprovalFlow/Features/Approvals/ApprovalResponseCommand.cs
new file mode 100644
index 00000000..9ad31a1b
--- /dev/null
+++ b/backend/ApprovalFlow/Features/Approvals/ApprovalResponseCommand.cs
@@ -0,0 +1,145 @@
+namespace ApprovalFlow.Features.Approvals;
+
+using ApprovalFlow.Data;
+using ApprovalFlow.Exceptions;
+using Common.Models.Approval;
+using FluentValidation;
+using MediatR;
+using NodaTime;
+using Microsoft.EntityFrameworkCore;
+using AutoMapper;
+using Microsoft.AspNetCore.Http;
+using Common.Kafka;
+using DIAM.Common.Models;
+using Newtonsoft.Json;
+using Serilog;
+
+public class ApprovalResponseCommand : IRequestHandler
+{
+ private readonly ApprovalFlowDataStoreDbContext dbContext;
+ private readonly IClock clock;
+ private readonly IMapper mapper;
+ private readonly ApprovalFlowConfiguration configuration;
+ private readonly IKafkaProducer producer;
+
+ public ApprovalResponseCommand(ApprovalFlowDataStoreDbContext dbContext,
+ IClock clock,
+ IMapper mapper,
+ IKafkaProducer producer, ApprovalFlowConfiguration configuration)
+ {
+ this.dbContext = dbContext;
+ this.clock = clock;
+ this.mapper = mapper;
+ this.producer = producer;
+ this.configuration = configuration;
+ }
+
+
+
+ public class CommandValidator : AbstractValidator
+ {
+ public CommandValidator()
+ {
+ this.RuleFor(x => x.ApprovalRequestId).GreaterThan(0);
+ }
+ }
+
+ public async Task Handle(ApproveDenyInput input, CancellationToken cancellationToken)
+ {
+ Serilog.Log.Information($"Handling incoming approval request {input.ApprovalRequestId} Approver {input.ApproverUserId} - Approved {input.Approved}");
+
+ var trx = this.dbContext.Database.BeginTransaction();
+ // check request valid for approval
+ var approvalEntity = this.dbContext.ApprovalRequests.AsSplitQuery().Include(req => req.Requests).ThenInclude(req => req.History).Where(req => req.Id == input.ApprovalRequestId).FirstOrDefault();
+
+ if (approvalEntity == null)
+ {
+ throw new ApprovalResponseException($"No approval request found for ID {input.ApprovalRequestId} - user {input.ApproverUserId}");
+ }
+
+ if (input.Approved)
+ {
+ approvalEntity.Approved = clock.GetCurrentInstant();
+ }
+
+
+
+ foreach (var request in approvalEntity.Requests)
+ {
+ Serilog.Log.Information($"Updating request {approvalEntity.Id}.{request.Id} to approved={input.Approved} by {input.ApproverUserId}");
+
+ request.History.Add(new Data.Approval.ApprovalHistory
+ {
+ AccessRequest = request,
+ Approver = input.ApproverUserId,
+ Created = clock.GetCurrentInstant(),
+ Status = input.Approved ? ApprovalStatus.APPROVED : ApprovalStatus.DENIED,
+ DecisionNote = input.DecisionNotes
+ });
+ }
+
+ approvalEntity.Completed = clock.GetCurrentInstant();
+
+ var addedRows = await this.dbContext.SaveChangesAsync(cancellationToken);
+ Serilog.Log.Information($"{addedRows} added for request {input.ApprovalRequestId}");
+
+
+ await trx.CommitAsync(cancellationToken);
+
+ // publish message for completion of approval
+ var allRequestsComplete = true;
+ foreach ( var request in approvalEntity.Requests )
+ {
+ if ( request.History.Count != approvalEntity.NoOfApprovalsRequired )
+ {
+ allRequestsComplete = false;
+ }
+ }
+
+ var responseData = this.mapper.Map(approvalEntity);
+
+
+ if ( allRequestsComplete )
+ {
+ var msgId = Guid.NewGuid().ToString();
+ Log.Information($"Publishing approval response message {msgId} for {approvalEntity.Id}");
+
+
+
+ var responseDataJSON = JsonConvert.SerializeObject(responseData, new JsonSerializerSettings
+ {
+ ReferenceLoopHandling = ReferenceLoopHandling.Ignore
+ });
+
+
+ var eventData = new Dictionary
+ {
+ { "approved", "" + approvalEntity.Approved },
+ { "approvalModel", responseDataJSON }
+ };
+
+ var delivered = await this.producer.ProduceAsync(this.configuration.KafkaCluster.ApprovalResponseTopic, msgId, new GenericProcessStatusResponse
+ {
+ DomainEvent = "digitalevidence-approvalresponse-complete",
+ Status = approvalEntity.Approved != null ? "Approved" : "Denied",
+ Id = approvalEntity.Id,
+ PartId = approvalEntity.UserId,
+ ResponseData = eventData
+ });
+
+ if ( delivered.Status == Confluent.Kafka.PersistenceStatus.Persisted )
+ {
+ Log.Information($"Message {msgId} sent to partition {delivered.Partition.Value}");
+ }
+ else
+ {
+ Log.Error($"Message {msgId} failed to send {delivered.Status}");
+ }
+
+
+ }
+
+ return responseData;
+
+ }
+}
diff --git a/backend/ApprovalFlow/Features/Approvals/ApprovalsController.cs b/backend/ApprovalFlow/Features/Approvals/ApprovalsController.cs
new file mode 100644
index 00000000..cf614a17
--- /dev/null
+++ b/backend/ApprovalFlow/Features/Approvals/ApprovalsController.cs
@@ -0,0 +1,49 @@
+namespace ApprovalFlow.Features.Approvals;
+
+using common.Constants.Auth;
+using Common.Models.Approval;
+using DomainResults.Common;
+using MediatR;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Prometheus;
+
+[Route("api/[controller]")]
+[ApiController]
+public class ApprovalsController : ControllerBase
+{
+
+
+ private readonly IMediator _mediator;
+ private static readonly Histogram ApprovalLookupDuration = Metrics.CreateHistogram("approval_lookup_duration", "Histogram of approval searches.");
+
+ public ApprovalsController(IMediator mediator) => this._mediator = mediator;
+
+ [HttpGet("pending")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [Authorize(Policy = Policies.ApprovalAuthorization)]
+
+ public async Task>> GetPendingApprovals([FromQuery] bool pendingOnly)
+ {
+ using (ApprovalLookupDuration.NewTimer())
+ {
+ var response = await this._mediator.Send(new ApprovalsQuery(pendingOnly));
+ return this.Ok(response);
+ }
+ }
+
+ [HttpPost("response")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [Authorize(Policy = Policies.ApprovalAuthorization)]
+
+ public async Task> PostApprovalResponse([FromBody] ApproveDenyInput command)
+ {
+ var user = HttpContext.User.Identities.First().Claims.FirstOrDefault( claim => claim.Type.Equals(Claims.PreferredUsername))?.Value;
+ command.ApproverUserId = user;
+ var response = this._mediator.Send(command).Result;
+
+ return response;
+ }
+}
diff --git a/backend/ApprovalFlow/Features/Approvals/ApprovalsQuery.cs b/backend/ApprovalFlow/Features/Approvals/ApprovalsQuery.cs
new file mode 100644
index 00000000..5920af5d
--- /dev/null
+++ b/backend/ApprovalFlow/Features/Approvals/ApprovalsQuery.cs
@@ -0,0 +1,50 @@
+namespace ApprovalFlow.Features.Approvals;
+
+using System.Threading;
+using System.Threading.Tasks;
+using ApprovalFlow.Data;
+using ApprovalFlow.Data.Approval;
+using AutoMapper;
+
+using Common.Models.Approval;
+using MediatR;
+using Microsoft.EntityFrameworkCore;
+
+public record ApprovalsQuery(bool PendingOnly) : IRequest>;
+
+public class PendingApprovalQueryHandler : IRequestHandler>
+{
+ private readonly ApprovalFlowDataStoreDbContext context;
+ private readonly IMapper mapper;
+ public PendingApprovalQueryHandler(ApprovalFlowDataStoreDbContext context, IMapper mapper)
+ {
+ this.context = context;
+ this.mapper = mapper;
+ }
+
+ public async Task> Handle(ApprovalsQuery request, CancellationToken cancellationToken)
+ {
+ List results;
+ if (request.PendingOnly)
+ {
+ results = this.context.ApprovalRequests.Include(req => req.Requests).ThenInclude(req => req.History).Where(req => req.Completed == null).ToList();
+
+ }
+ else
+ {
+ results = this.context.ApprovalRequests.Include(req => req.Requests).ThenInclude(req => req.History).ToList();
+
+ }
+
+
+ if ( results.Any())
+ {
+ Serilog.Log.Information($"Found {results.Count()} results");
+ return mapper.Map>(results);
+ }
+ else
+ {
+ return new List();
+ }
+ }
+}
diff --git a/backend/ApprovalFlow/Features/WebSockets/WebSocketController.cs b/backend/ApprovalFlow/Features/WebSockets/WebSocketController.cs
new file mode 100644
index 00000000..35c76a03
--- /dev/null
+++ b/backend/ApprovalFlow/Features/WebSockets/WebSocketController.cs
@@ -0,0 +1,67 @@
+namespace ApprovalFlow.Features.WebSockets;
+
+using System.Net.WebSockets;
+using System.Text;
+using common.Constants.Auth;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Routing;
+using Polly;
+using static Microsoft.EntityFrameworkCore.DbLoggerCategory.Database;
+
+[ApiController]
+[Route("[controller]")]
+[AllowAnonymous]
+public class WebSocketController : ControllerBase
+{
+ private WebSocketService _websocketService = WebSocketService.GetInstance();
+
+ [HttpGet("/ws")]
+ public async Task Get()
+ {
+ if (HttpContext.WebSockets.IsWebSocketRequest)
+ {
+ string protocol = HttpContext.WebSockets.WebSocketRequestedProtocols[0];
+
+ using var webSocket = await HttpContext.WebSockets.AcceptWebSocketAsync(protocol);
+ var message = "Hello World!";
+ _websocketService.AddConnection(webSocket);
+ var bytes = Encoding.UTF8.GetBytes(message);
+ var arraySegment = new ArraySegment(bytes, 0, bytes.Length);
+ await webSocket.SendAsync(arraySegment,
+ WebSocketMessageType.Text,
+ true,
+ CancellationToken.None);
+ await Echo(webSocket);
+ }
+ else
+ {
+ HttpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
+ }
+ }
+
+
+ private static async Task Echo(WebSocket webSocket)
+ {
+ var buffer = new byte[1024 * 4];
+ var receiveResult = await webSocket.ReceiveAsync(
+ new ArraySegment(buffer), CancellationToken.None);
+
+ while (!receiveResult.CloseStatus.HasValue)
+ {
+ await webSocket.SendAsync(
+ new ArraySegment(buffer, 0, receiveResult.Count),
+ receiveResult.MessageType,
+ receiveResult.EndOfMessage,
+ CancellationToken.None);
+
+ receiveResult = await webSocket.ReceiveAsync(
+ new ArraySegment(buffer), CancellationToken.None);
+ }
+
+ await webSocket.CloseAsync(
+ receiveResult.CloseStatus.Value,
+ receiveResult.CloseStatusDescription,
+ CancellationToken.None);
+ }
+}
diff --git a/backend/ApprovalFlow/Features/WebSockets/WebSocketService.cs b/backend/ApprovalFlow/Features/WebSockets/WebSocketService.cs
new file mode 100644
index 00000000..d5cafa8b
--- /dev/null
+++ b/backend/ApprovalFlow/Features/WebSockets/WebSocketService.cs
@@ -0,0 +1,33 @@
+namespace ApprovalFlow.Features.WebSockets;
+
+using System.Net.WebSockets;
+using System.Text;
+
+public class WebSocketService
+{
+ public static WebSocketService webSocketInstance = new WebSocketService();
+
+ private WebSocketService() { }
+
+ public static WebSocketService GetInstance() => webSocketInstance;
+
+ private List connections = new();
+
+ public void AddConnection(WebSocket ws) => this.connections.Add(ws);
+
+
+ public async Task Broadcast(string message)
+ {
+ var bytes = Encoding.UTF8.GetBytes(message);
+
+ foreach (var connection in this.connections)
+ {
+ if (connection != null && connection.State == WebSocketState.Open)
+ {
+ Serilog.Log.Information($"Broadcast to {connection}");
+ var arraySegment = new ArraySegment(bytes, 0, bytes.Length);
+ await connection.SendAsync(arraySegment, WebSocketMessageType.Text, true, CancellationToken.None);
+ }
+ }
+ }
+}
diff --git a/backend/ApprovalFlow/Infra/Auth/AuthenticationSetup.cs b/backend/ApprovalFlow/Infra/Auth/AuthenticationSetup.cs
new file mode 100644
index 00000000..bb6a0608
--- /dev/null
+++ b/backend/ApprovalFlow/Infra/Auth/AuthenticationSetup.cs
@@ -0,0 +1,139 @@
+namespace ApprovalFlow.Auth;
+
+using common.Constants.Auth;
+using Common.Extensions;
+using DIAM.Common.Helpers.Extensions;
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.Extensions.DependencyInjection;
+using System.IdentityModel.Tokens.Jwt;
+using System.Security.Claims;
+
+
+public static class AuthenticationSetup
+{
+ public static IServiceCollection AddKeycloakAuth(this IServiceCollection services, ApprovalFlowConfiguration config)
+ {
+ services.ThrowIfNull(nameof(services));
+ config.ThrowIfNull(nameof(config));
+
+ JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
+
+ services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
+ .AddJwtBearer(options =>
+ {
+ options.Authority = config.Keycloak.RealmUrl;
+ //options.Audience = Resources.PidpApi;
+ options.RequireHttpsMetadata = false;
+ options.Audience = Clients.AdminApi;
+ options.MetadataAddress = config.Keycloak.WellKnownConfig;
+ options.Events = new JwtBearerEvents
+ {
+ OnTokenValidated = async context => await OnTokenValidatedAsync(context)
+ };
+ });
+
+ services.AddAuthorization(options =>
+ {
+ options.AddPolicy(Policies.BcscAuthentication, policy => policy
+ .RequireAuthenticatedUser()
+ .RequireClaim(Claims.IdentityProvider, ClaimValues.BCServicesCard));
+
+ options.AddPolicy(Policies.ApprovalAuthorization, policy => policy.RequireAuthenticatedUser()
+ .RequireAuthenticatedUser().RequireAssertion(context =>
+ {
+ var hasAdminRole = context.User.IsInRole(Roles.Admin);
+ var hasApprovalRole = context.User.IsInRole(Roles.Approver);
+ var hasReadOnlyApprovalRole = context.User.IsInRole(Roles.ApprovalViewer);
+
+ var hasClaim = context.User.HasClaim(c => c.Type == Claims.IdentityProvider && (c.Value == ClaimValues.Idir || c.Value == ClaimValues.Adfs));
+ return (hasAdminRole || hasApprovalRole || hasReadOnlyApprovalRole) && hasClaim;
+ }));
+
+ options.AddPolicy(Policies.IdirAuthentication, policy => policy
+ .RequireAuthenticatedUser()
+ .RequireClaim(Claims.IdentityProvider, ClaimValues.Idir));
+
+ //options.AddPolicy(Policies.VerifiedCredentialsProvider, policy => policy
+ // .RequireAuthenticatedUser()
+ // .RequireClaim(Claims.IdentityProvider, ClaimValues.VerifiedCredentials));
+
+ options.AddPolicy(Policies.VerifiedCredentialsProvider, policy => policy
+ .RequireAuthenticatedUser().RequireAssertion(context =>
+ {
+ var hasDutyRole = context.User.IsInRole(Roles.DutyCounsel);
+ var hasDefenceRole = context.User.IsInRole(Roles.DefenceCounsel);
+ var hasClaim = context.User.HasClaim(c => c.Type == Claims.IdentityProvider && (c.Value == ClaimValues.VerifiedCredentials || c.Value == ClaimValues.Idir));
+ return (hasDutyRole || hasDefenceRole) && hasClaim;
+ }));
+
+
+ options.AddPolicy(Policies.BcpsAuthentication, policy => policy
+ .RequireAuthenticatedUser()
+ .RequireClaim(Claims.IdentityProvider, ClaimValues.Bcps));
+
+ options.AddPolicy(Policies.AnyPartyIdentityProvider, policy => policy
+ .RequireAuthenticatedUser().RequireAssertion(context =>
+ {
+ var hasRole = context.User.IsInRole(Roles.SubmittingAgency);
+ var hasClaim = context.User.HasClaim(c => c.Type == Claims.IdentityProvider &&
+ (c.Value == ClaimValues.BCServicesCard ||
+ c.Value == ClaimValues.Idir ||
+ c.Value == ClaimValues.Phsa ||
+ c.Value == ClaimValues.Bcps ||
+ c.Value == ClaimValues.VerifiedCredentials));
+
+ return hasRole || hasClaim;
+ }));
+
+ options.AddPolicy(Policies.AllDemsIdentityProvider, policy => policy
+ .RequireAuthenticatedUser().RequireAssertion(context =>
+ {
+ var hasSARole = context.User.IsInRole(Roles.SubmittingAgency);
+ var hasClaim = context.User.HasClaim(c => c.Type == Claims.IdentityProvider &&
+ (c.Value == ClaimValues.BCServicesCard ||
+ c.Value == ClaimValues.Idir ||
+ c.Value == ClaimValues.Phsa ||
+ c.Value == ClaimValues.Bcps ||
+ c.Value == ClaimValues.VerifiedCredentials));
+ return hasSARole || hasClaim;
+ }));
+
+ options.AddPolicy(Policies.AdminAuthentication, policy => policy
+ .RequireAuthenticatedUser().RequireAssertion(context =>
+ {
+ var hasRole = context.User.IsInRole(Roles.Admin);
+ var hasClaim = context.User.HasClaim(c => c.Type == Claims.IdentityProvider &&
+ (
+ c.Value == ClaimValues.Idir || c.Value == ClaimValues.Adfs ||
+ c.Value == ClaimValues.Bcps));
+ return hasRole || hasClaim;
+ }));
+
+ options.AddPolicy(Policies.SubAgencyIdentityProvider, policy => policy
+ .RequireAuthenticatedUser()
+ .RequireRole(Roles.SubmittingAgency));
+
+
+ options.FallbackPolicy = new AuthorizationPolicyBuilder()
+ .RequireAuthenticatedUser()
+ .RequireClaim(Claims.IdentityProvider, ClaimValues.BCServicesCard, ClaimValues.Idir, ClaimValues.Phsa, ClaimValues.Bcps)
+ .Build();
+ });
+
+ return services;
+ }
+
+ private static Task OnTokenValidatedAsync(TokenValidatedContext context)
+ {
+ if (context.Principal?.Identity is ClaimsIdentity identity
+ && identity.IsAuthenticated)
+ {
+ // Flatten the Resource Access claim
+ identity.AddClaims(identity.GetResourceAccessRoles(Clients.AdminApi)
+ .Select(role => new Claim(ClaimTypes.Role, role)));
+ }
+
+ return Task.CompletedTask;
+ }
+}
diff --git a/backend/ApprovalFlow/Infra/Telemetry/OtelMetrics.cs b/backend/ApprovalFlow/Infra/Telemetry/OtelMetrics.cs
new file mode 100644
index 00000000..fe4bc3cb
--- /dev/null
+++ b/backend/ApprovalFlow/Infra/Telemetry/OtelMetrics.cs
@@ -0,0 +1,24 @@
+namespace ApprovalFlow.Telemetry;
+
+using System.Diagnostics.Metrics;
+
+public class OtelMetrics
+{
+ private Counter IncomingApprovalCounter { get; }
+
+
+
+ public string MetricName { get; }
+
+ public OtelMetrics(string meterName = "ApprovalFlow")
+ {
+ var meter = new Meter(meterName);
+ MetricName = meterName;
+ IncomingApprovalCounter = meter.CreateCounter("incoming-approvals", "Approvals");
+
+ }
+
+ public void AddApproval() => IncomingApprovalCounter.Add(1);
+
+
+}
diff --git a/backend/ApprovalFlow/Kafka/ConsumerSetup.cs b/backend/ApprovalFlow/Kafka/ConsumerSetup.cs
new file mode 100644
index 00000000..8312dd2c
--- /dev/null
+++ b/backend/ApprovalFlow/Kafka/ConsumerSetup.cs
@@ -0,0 +1,91 @@
+namespace ApprovalFlow.Kafka;
+
+using System.Net;
+using ApprovalFlow.ServiceEvents.IncomingApproval;
+using Common.Kafka;
+using Common.Models.Approval;
+using Confluent.Kafka;
+using DIAM.Common.Helpers.Extensions;
+
+public static class ConsumerSetup
+{
+
+ private static ProducerConfig? producerConfig;
+
+ public static IServiceCollection AddKafkaConsumer(this IServiceCollection services, ApprovalFlowConfiguration config)
+ {
+ //Configuration = configuration;
+ services.ThrowIfNull(nameof(services));
+ config.ThrowIfNull(nameof(config));
+
+ var clientConfig = new ClientConfig()
+ {
+ BootstrapServers = config.KafkaCluster.BootstrapServers,
+ SaslMechanism = SaslMechanism.OAuthBearer,
+ SecurityProtocol = SecurityProtocol.SaslSsl,
+ SaslOauthbearerTokenEndpointUrl = config.KafkaCluster.SaslOauthbearerTokenEndpointUrl,
+ SaslOauthbearerMethod = SaslOauthbearerMethod.Oidc,
+ SocketKeepaliveEnable = true,
+ SaslOauthbearerScope = config.KafkaCluster.Scope,
+ SslEndpointIdentificationAlgorithm = SslEndpointIdentificationAlgorithm.Https,
+ SslCaLocation = config.KafkaCluster.SslCaLocation,
+ ConnectionsMaxIdleMs = 600000,
+ SslCertificateLocation = config.KafkaCluster.SslCertificateLocation,
+ SslKeyLocation = config.KafkaCluster.SslKeyLocation
+ };
+ producerConfig = new ProducerConfig()
+ {
+ BootstrapServers = config.KafkaCluster.BootstrapServers,
+ Acks = Acks.All,
+ SaslMechanism = SaslMechanism.OAuthBearer,
+ SecurityProtocol = SecurityProtocol.SaslSsl,
+ SaslOauthbearerTokenEndpointUrl = config.KafkaCluster.SaslOauthbearerTokenEndpointUrl,
+ SaslOauthbearerMethod = SaslOauthbearerMethod.Oidc,
+ SaslOauthbearerScope = config.KafkaCluster.Scope,
+ ClientId = Dns.GetHostName(),
+ RequestTimeoutMs = 60000,
+ SslEndpointIdentificationAlgorithm = SslEndpointIdentificationAlgorithm.Https,
+ SslCaLocation = config.KafkaCluster.SslCaLocation,
+ SaslOauthbearerClientId = config.KafkaCluster.SaslOauthbearerProducerClientId,
+ SaslOauthbearerClientSecret = config.KafkaCluster.SaslOauthbearerProducerClientSecret,
+ SslCertificateLocation = config.KafkaCluster.SslCertificateLocation,
+ SslKeyLocation = config.KafkaCluster.SslKeyLocation,
+ EnableIdempotence = true,
+ RetryBackoffMs = 1000,
+ MessageSendMaxRetries = 3
+ };
+
+
+
+
+ var consumerConfig = new ConsumerConfig(clientConfig)
+ {
+ GroupId = config.KafkaCluster.ConsumerGroupId,
+ EnableAutoCommit = true,
+ AutoOffsetReset = AutoOffsetReset.Earliest,
+ ClientId = Dns.GetHostName(),
+ EnableAutoOffsetStore = false,
+ AutoCommitIntervalMs = 4000,
+ BootstrapServers = config.KafkaCluster.BootstrapServers,
+ SaslOauthbearerClientId = config.KafkaCluster.SaslOauthbearerConsumerClientId,
+ SaslOauthbearerClientSecret = config.KafkaCluster.SaslOauthbearerConsumerClientSecret,
+ SaslMechanism = SaslMechanism.OAuthBearer,
+ SecurityProtocol = SecurityProtocol.SaslSsl
+ };
+ services.AddSingleton(consumerConfig);
+ services.AddSingleton(producerConfig);
+
+ services.AddSingleton(typeof(IKafkaProducer<,>), typeof(KafkaProducer<,>));
+
+
+ services.AddScoped, IncomingApprovalHandler>();
+
+ services.AddSingleton(typeof(IKafkaConsumer<,>), typeof(KafkaConsumer<,>));
+
+ services.AddHostedService();
+
+ return services;
+ }
+
+ public static ProducerConfig GetProducerConfig() => producerConfig;
+}
diff --git a/backend/ApprovalFlow/Kafka/KafkaConsumer.cs b/backend/ApprovalFlow/Kafka/KafkaConsumer.cs
new file mode 100644
index 00000000..7d09b413
--- /dev/null
+++ b/backend/ApprovalFlow/Kafka/KafkaConsumer.cs
@@ -0,0 +1,158 @@
+namespace ApprovalFlow.Kafka;
+
+using System.Globalization;
+using Common.Kafka;
+using Common.Kafka.Deserializer;
+using Confluent.Kafka;
+using IdentityModel.Client;
+using Serilog;
+using static ApprovalFlowConfiguration;
+
+public class KafkaConsumer : IKafkaConsumer where TValue : class
+{
+ private readonly ConsumerConfig config;
+ private IKafkaHandler handler;
+ private IConsumer consumer;
+ private string topic;
+ private readonly IServiceScopeFactory serviceScopeFactory;
+ private readonly ApprovalFlowConfiguration configuration;
+ private const string EXPIRY_CLAIM = "exp";
+ private const string SUBJECT_CLAIM = "sub";
+
+ public KafkaConsumer(ConsumerConfig config, IServiceScopeFactory serviceScopeFactory, ApprovalFlowConfiguration configuration)
+ {
+ this.serviceScopeFactory = serviceScopeFactory;
+ this.config = config;
+ this.configuration = configuration;
+ //this.handler = handler;
+ //this.consumer = consumer;
+ //this.topic = topic;
+ }
+
+ public async Task Consume(string topic, CancellationToken stoppingToken)
+ {
+ using var scope = this.serviceScopeFactory.CreateScope();
+
+ this.handler = scope.ServiceProvider.GetRequiredService>();
+ this.consumer = new ConsumerBuilder(this.config).SetOAuthBearerTokenRefreshHandler(OauthTokenRefreshCallback).SetValueDeserializer(new DefaultKafkaDeserializer()).Build();
+ this.topic = topic;
+
+ await Task.Run(() => this.StartConsumerLoop(stoppingToken), stoppingToken);
+ }
+
+ ///
+ /// This will close the consumer, commit offsets and leave the group cleanly.
+ ///
+ public void Close() => this.consumer.Close();
+ ///
+ /// Releases all resources used by the current instance of the consumer
+ ///
+ public void Dispose() => this.consumer.Dispose();
+
+ private async Task StartConsumerLoop(CancellationToken cancellationToken)
+ {
+ this.consumer.Subscribe(this.topic);
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ try
+ {
+ var result = this.consumer.Consume(cancellationToken);
+ if (result != null)
+ {
+ var consumerResult = await this.handler.HandleAsync(this.consumer.MemberId, result.Message.Key, result.Message.Value);
+
+ if (consumerResult.Status == TaskStatus.RanToCompletion && consumerResult.Exception == null)
+ {
+ this.consumer.Commit(result);
+ this.consumer.StoreOffset(result);
+ }
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ break;
+ }
+ catch (ConsumeException e)
+ {
+ // Consumer errors should generally be ignored (or logged) unless fatal.
+ Console.WriteLine($"Consume error: {e.Error.Reason}");
+
+ if (e.Error.IsFatal)
+ {
+ break;
+ }
+ }
+ catch (Exception e)
+ {
+ Console.WriteLine($"Unexpected error: {e}");
+ break;
+ }
+ }
+ }
+
+ private static async void OauthTokenRefreshCallback(IClient client, string config)
+ {
+ try
+ {
+
+ var settingsFile = IsDevelopment() ? "appsettings.Development.json" : "appsettings.json";
+
+ var clusterConfig = new ConfigurationBuilder()
+ .SetBasePath(Directory.GetCurrentDirectory())
+ .AddJsonFile(settingsFile).Build();
+
+ var tokenEndpoint = Environment.GetEnvironmentVariable("KafkaCluster__SaslOauthbearerTokenEndpointUrl");
+ var clientId = Environment.GetEnvironmentVariable("KafkaCluster__SaslOauthbearerConsumerClientId");
+ var clientSecret = Environment.GetEnvironmentVariable("KafkaCluster__SaslOauthbearerConsumerClientSecret");
+
+ clientSecret ??= clusterConfig.GetValue("KafkaCluster:SaslOauthbearerConsumerClientSecret");
+ clientId ??= clusterConfig.GetValue("KafkaCluster:SaslOauthbearerConsumerClientId");
+ tokenEndpoint ??= clusterConfig.GetValue("KafkaCluster:SaslOauthbearerTokenEndpointUrl");
+ Log.Logger.Debug("EDT Kafka Consumer getting token {0} {1} ", tokenEndpoint, clientId);
+
+ var accessTokenClient = new HttpClient();
+
+ var accessToken = await accessTokenClient.RequestClientCredentialsTokenAsync(new ClientCredentialsTokenRequest
+ {
+ Address = tokenEndpoint,
+ ClientId = clientId,
+ ClientSecret = clientSecret,
+ GrantType = "client_credentials"
+ });
+ var tokenTicks = GetTokenExpirationTime(accessToken.AccessToken);
+ var subject = GetTokenSubject(accessToken.AccessToken);
+ var tokenDate = DateTimeOffset.FromUnixTimeSeconds(tokenTicks);
+ var timeSpan = new DateTime() - tokenDate;
+ var ms = tokenDate.ToUnixTimeMilliseconds();
+ Log.Logger.Debug("Consumer got token {0}", ms);
+
+ client.OAuthBearerSetToken(accessToken.AccessToken, ms, subject);
+ }
+ catch (Exception ex)
+ {
+ Log.Logger.Error(ex.Message);
+ client.OAuthBearerSetTokenFailure(ex.ToString());
+ }
+ }
+
+ private static long GetTokenExpirationTime(string token)
+ {
+ var handler = new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler();
+ var jwtSecurityToken = handler.ReadJwtToken(token);
+
+ var tokenExp = jwtSecurityToken.Claims.First(claim => claim.Type.Equals(KafkaConsumer.EXPIRY_CLAIM, StringComparison.Ordinal)).Value;
+ var ticks = long.Parse(tokenExp, CultureInfo.InvariantCulture);
+ return ticks;
+ }
+
+ private static string GetTokenSubject(string token)
+ {
+ var handler = new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler();
+ var jwtSecurityToken = handler.ReadJwtToken(token);
+ return jwtSecurityToken.Claims.First(claim => claim.Type.Equals(KafkaConsumer.SUBJECT_CLAIM, StringComparison.Ordinal)).Value;
+
+ }
+
+
+
+}
diff --git a/backend/ApprovalFlow/Models/IdempotentConsumer.cs b/backend/ApprovalFlow/Models/IdempotentConsumer.cs
new file mode 100644
index 00000000..7774bcfc
--- /dev/null
+++ b/backend/ApprovalFlow/Models/IdempotentConsumer.cs
@@ -0,0 +1,13 @@
+namespace ApprovalFlow.Models;
+
+using NodaTime;
+using System.ComponentModel.DataAnnotations;
+
+public class IdempotentConsumer
+{
+ [Key]
+ public int Id { get; set; }
+ public string MessageId { get; set; } = string.Empty;
+ public string Consumer { get; set; } = string.Empty;
+ public Instant ConsumeDate { get; set; }
+}
diff --git a/backend/ApprovalFlow/Program.cs b/backend/ApprovalFlow/Program.cs
new file mode 100644
index 00000000..299d8371
--- /dev/null
+++ b/backend/ApprovalFlow/Program.cs
@@ -0,0 +1,126 @@
+namespace ApprovalFlow;
+
+using System.Reflection;
+using Serilog;
+using Serilog.Events;
+using Serilog.Formatting.Json;
+using Serilog.Sinks.SystemConsole.Themes;
+
+public class Program
+{
+
+
+ public static int Main(string[] args)
+ {
+ CreateLogger();
+
+ try
+ {
+ Log.Information("Starting web host");
+ CreateHostBuilder(args)
+ .Build()
+ .Run();
+ return 0;
+ }
+ catch (Exception ex)
+ {
+ Log.Fatal(ex, "Host terminated unexpectedly");
+ return 1;
+ }
+ finally
+ {
+ // Ensure buffered logs are written to their target sink
+ Log.CloseAndFlush();
+ }
+ }
+
+ public static IHostBuilder CreateHostBuilder(string[] args) =>
+ Host.CreateDefaultBuilder(args)
+ .ConfigureWebHostDefaults(webBuilder => webBuilder.UseStartup())
+ .UseSerilog();
+
+ private static void CreateLogger(
+ )
+ {
+ var path = Environment.GetEnvironmentVariable("LogFilePath") ?? "logs";
+ var environmentName = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT");
+
+ var config = new ConfigurationBuilder()
+ .AddJsonFile("appsettings.json", optional: true)
+ .AddJsonFile($"appsettings.{environmentName}.json", optional: true)
+ .Build();
+
+ var splunkHost = Environment.GetEnvironmentVariable("SplunkConfig__Host");
+ splunkHost ??= config.GetValue("SplunkConfig:Host");
+ var splunkToken = Environment.GetEnvironmentVariable("SplunkConfig__CollectorToken");
+ splunkToken ??= config.GetValue("SplunkConfig:CollectorToken");
+
+
+
+ var seqEndpoint = Environment.GetEnvironmentVariable("Seq__Url");
+ seqEndpoint ??= config.GetValue("Seq:Url");
+
+ if (string.IsNullOrEmpty(seqEndpoint))
+ {
+ Console.WriteLine("SEQ Log Host is not configured - check Seq environment");
+ Environment.Exit(100);
+ }
+
+
+ try
+ {
+ if (ApprovalFlowConfiguration.IsDevelopment())
+ {
+ Directory.CreateDirectory(path);
+ }
+ }
+ catch (Exception e)
+ {
+ Console.WriteLine("Creating the logging directory failed: {0}", e.ToString());
+ }
+
+ var name = Assembly.GetExecutingAssembly().GetName();
+ var outputTemplate = "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}";
+
+ var loggerConfiguration = new LoggerConfiguration()
+ .MinimumLevel.Information()
+ .Filter.ByExcluding("RequestPath like '/health%'")
+ .Filter.ByExcluding("RequestPath like '/metrics%'")
+ .MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
+ .MinimumLevel.Override("Microsoft.Hosting.Lifetime", LogEventLevel.Information)
+ .MinimumLevel.Override("System", LogEventLevel.Warning)
+ .Enrich.FromLogContext()
+ .Enrich.WithMachineName()
+ .Enrich.WithProperty("Assembly", $"{name.Name}")
+ .Enrich.WithProperty("Version", $"{name.Version}")
+ .WriteTo.Seq(seqEndpoint)
+ .WriteTo.Console(
+ outputTemplate: outputTemplate,
+ theme: AnsiConsoleTheme.Code)
+ .WriteTo.Async(a => a.File(
+ $@"{path}/edtdisclosure.log",
+ outputTemplate: outputTemplate,
+ rollingInterval: RollingInterval.Day,
+ shared: true))
+ .WriteTo.Async(a => a.File(
+ new JsonFormatter(),
+ $@"{path}/disclosure.json",
+ rollingInterval: RollingInterval.Day));
+
+ if (!string.IsNullOrEmpty(splunkHost))
+ {
+ loggerConfiguration.WriteTo.EventCollector(splunkHost, splunkToken);
+ }
+
+ Log.Logger = loggerConfiguration.CreateLogger();
+
+ if (string.IsNullOrEmpty(splunkHost))
+ {
+ Log.Warning("*** Splunk Host is not configured - check Splunk environment *** ");
+ }
+ else
+ {
+ Log.Information($"*** Splunk logging to {splunkHost} ***");
+ }
+ }
+}
diff --git a/backend/ApprovalFlow/ServiceEvents/IncomingApproval/ApprovalConsumer.cs b/backend/ApprovalFlow/ServiceEvents/IncomingApproval/ApprovalConsumer.cs
new file mode 100644
index 00000000..524ebc77
--- /dev/null
+++ b/backend/ApprovalFlow/ServiceEvents/IncomingApproval/ApprovalConsumer.cs
@@ -0,0 +1,39 @@
+namespace ApprovalFlow.ServiceEvents.IncomingApproval;
+
+using System.Net;
+using Common.Kafka;
+using Common.Models.Approval;
+
+public class ApprovalConsumer : BackgroundService
+{
+ private readonly IKafkaConsumer consumer;
+
+ private readonly ApprovalFlowConfiguration config;
+ public ApprovalConsumer(IKafkaConsumer kafkaConsumer, ApprovalFlowConfiguration config)
+ {
+ this.consumer = kafkaConsumer;
+ this.config = config;
+ }
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+
+ Serilog.Log.Information("Starting consumer {0}", this.config.KafkaCluster.IncomingApprovalCreationTopic);
+ try
+ {
+ await this.consumer.Consume(this.config.KafkaCluster.IncomingApprovalCreationTopic, stoppingToken);
+ }
+ catch (Exception ex)
+ {
+ Serilog.Log.Warning($"{(int)HttpStatusCode.InternalServerError} ConsumeFailedOnTopic - {this.config.KafkaCluster.IncomingApprovalCreationTopic}, {ex}");
+ }
+ }
+
+ public override void Dispose()
+ {
+ this.consumer.Close();
+ this.consumer.Dispose();
+
+ base.Dispose();
+ GC.SuppressFinalize(this);
+ }
+}
diff --git a/backend/ApprovalFlow/ServiceEvents/IncomingApproval/IncomingApprovalHandler.cs b/backend/ApprovalFlow/ServiceEvents/IncomingApproval/IncomingApprovalHandler.cs
new file mode 100644
index 00000000..b9bb887e
--- /dev/null
+++ b/backend/ApprovalFlow/ServiceEvents/IncomingApproval/IncomingApprovalHandler.cs
@@ -0,0 +1,186 @@
+namespace ApprovalFlow.ServiceEvents.IncomingApproval;
+
+using System.Reflection.Metadata;
+using System.Threading.Tasks;
+using ApprovalFlow.Data;
+using ApprovalFlow.Data.Approval;
+using ApprovalFlow.Exceptions;
+using ApprovalFlow.Features.WebSockets;
+using Common.Kafka;
+using Common.Models.Approval;
+using Common.Models.Notification;
+using Confluent.Kafka;
+
+public class IncomingApprovalHandler : IKafkaHandler
+{
+ private readonly ApprovalFlowDataStoreDbContext context;
+ private readonly IKafkaProducer producer;
+ private readonly ApprovalFlowConfiguration configuration;
+ private readonly WebSocketService websocketService = WebSocketService.GetInstance();
+
+ public IncomingApprovalHandler(
+ ApprovalFlowDataStoreDbContext approvalFlowDataStoreDbContext,
+ ApprovalFlowConfiguration config,
+ IKafkaProducer producer
+ )
+ {
+ this.context = approvalFlowDataStoreDbContext;
+ this.configuration = config;
+ this.producer = producer;
+ }
+
+ public async Task HandleAsync(string consumerName, string key, ApprovalRequestModel incomingRequest)
+ {
+ Serilog.Log.Information($"Received approval request {key}");
+
+ // see if we've handled this message before
+ var existingRequest = this.context.ApprovalRequests.Where(req => req.MessageKey == key).FirstOrDefault();
+
+ if (existingRequest != null)
+ {
+ Serilog.Log.Warning($"Approval request already processed - message {key} will be ignored");
+ return Task.CompletedTask;
+ }
+ else
+ {
+
+
+
+ using var trx = this.context.Database.BeginTransaction();
+
+ try
+ {
+ // make sure we have some access requests
+
+ if (incomingRequest.AccessRequests.Count == 0)
+ {
+ throw new IncomingApprovalException($"No access requests in message {key} - request will be ignored");
+ }
+ if (incomingRequest.Reasons.Count == 0)
+ {
+ throw new IncomingApprovalException($"No reasons provided for approal request {key} - request will be ignored");
+ }
+
+ Serilog.Log.Information($"Adding new approval request {key} {incomingRequest.UserId}");
+
+ var accessRequests = new List();
+
+ // add request info
+ foreach (var request in incomingRequest.AccessRequests)
+ {
+ accessRequests.Add(new Request
+ {
+ RequestType = request.RequestType,
+ RequestId = request.AccessRequestId,
+ ApprovalType = ApprovalType.AccessRequest
+ });
+ }
+
+ var approvalRequest = new ApprovalRequest
+ {
+ MessageKey = key,
+ RequiredAccess = incomingRequest.RequiredAccess,
+ UserId = incomingRequest.UserId,
+ IdentityProvider = incomingRequest.IdentityProvider,
+ Reason = string.Join(", ", incomingRequest.Reasons),
+ NoOfApprovalsRequired = incomingRequest.NoOfApprovalsRequired > 0 ? incomingRequest.NoOfApprovalsRequired : 1,
+ Requests = accessRequests
+ };
+
+ // add the entry to the context
+ this.context.ApprovalRequests.Add(approvalRequest);
+
+ // create a new entry in the approval tables
+
+ var saved = await this.context.SaveChangesAsync();
+
+ if (saved > 0)
+ {
+ Serilog.Log.Information($"New approval request created for {key} {approvalRequest.Id}");
+ await trx.CommitAsync();
+
+ // broadcast to any listening clients
+ this.websocketService.Broadcast($"New approval {approvalRequest.Id}");
+
+ var data = new Dictionary {
+ { "reasons", string.Join(",",incomingRequest.Reasons )},
+ { "user", incomingRequest.UserId },
+ { "firstName", incomingRequest.FirstName},
+ { "idp", incomingRequest.IdentityProvider }
+ };
+
+ // send a notification if enabled to admin email address(es)
+ if (!string.IsNullOrEmpty(incomingRequest.EMailAddress))
+ {
+ var notifyKey = Guid.NewGuid().ToString();
+ var notified = await this.producer.ProduceAsync( this.configuration.KafkaCluster.NotificationTopic, notifyKey, new Notification
+ {
+ To = this.configuration.ApprovalConfig.NotifyEmail,
+ DomainEvent = "digitalevidence-approvalrequest-created",
+ Subject = this.configuration.ApprovalConfig.Subject,
+ EventData = data
+ });
+
+ if (notified.Status == PersistenceStatus.Persisted)
+ {
+ Serilog.Log.Information($"Entry {notifyKey} was delivered {notified.Partition.Value} for {this.configuration.ApprovalConfig.NotifyEmail}");
+ }
+ else
+ {
+ Serilog.Log.Error($"There was an error delivering to {this.configuration.KafkaCluster.NotificationTopic} for message {notifyKey}");
+ }
+ }
+ else
+ {
+ Serilog.Log.Information($"No email address was provided for {incomingRequest.UserId} [{approvalRequest.Id} - unable to send notification email");
+ }
+
+
+ if (!string.IsNullOrEmpty(incomingRequest.EMailAddress))
+ {
+ var messageKey = Guid.NewGuid().ToString();
+
+ var domainEvent = approvalRequest.IdentityProvider == "verified" ? "digitalevidence-bclaw-approvalrequest-created" : "digitalevidence-bcsc-approvalrequest-created";
+
+ var delivered = await this.producer.ProduceAsync( this.configuration.KafkaCluster.NotificationTopic, messageKey, new Notification
+ {
+ To = this.configuration.ApprovalConfig.NotifyEmail,
+ DomainEvent = domainEvent,
+ Subject = this.configuration.ApprovalConfig.Subject,
+ EventData = data
+ });
+
+ if (delivered.Status == PersistenceStatus.Persisted)
+ {
+ Serilog.Log.Information($"Message {messageKey} was delivered {delivered.Partition.Value}");
+ }
+ else
+ {
+ Serilog.Log.Error($"There was an error delivering to {this.configuration.KafkaCluster.NotificationTopic} for message {messageKey}");
+ }
+
+ }
+
+
+
+
+ }
+ else
+ {
+ Serilog.Log.Error($"There was a problem saving request {key}");
+ return Task.FromException(new IncomingApprovalException($"Failed to store request {key} in Db"));
+ }
+
+
+ }
+ catch (Exception ex)
+ {
+ Serilog.Log.Error($"Error during approval processing {ex.Message}");
+ await trx.RollbackAsync();
+ }
+
+ }
+ return Task.CompletedTask;
+
+ }
+}
diff --git a/backend/ApprovalFlow/Startup.cs b/backend/ApprovalFlow/Startup.cs
new file mode 100644
index 00000000..7b17005c
--- /dev/null
+++ b/backend/ApprovalFlow/Startup.cs
@@ -0,0 +1,269 @@
+namespace ApprovalFlow;
+
+
+
+using System.Reflection;
+using System.Text.Json;
+
+using Microsoft.AspNetCore.Mvc.Versioning;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+using Microsoft.OpenApi.Models;
+using NodaTime;
+using OpenTelemetry.Resources;
+using OpenTelemetry.Trace;
+using OpenTelemetry;
+using OpenTelemetry.Exporter;
+using OpenTelemetry.Logs;
+using OpenTelemetry.Metrics;
+using Serilog;
+using Swashbuckle.AspNetCore.Filters;
+using Azure.Monitor.OpenTelemetry.Exporter;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Serialization;
+using Prometheus;
+using MediatR;
+using Microsoft.AspNetCore.Mvc.ApplicationModels;
+using FluentValidation.AspNetCore;
+using NodaTime.Serialization.SystemTextJson;
+using Microsoft.Extensions.Hosting;
+using ApprovalFlow.Data;
+using Common.Constants.Telemetry;
+using ApprovalFlow.Telemetry;
+using DIAM.Common.Helpers.Transformers;
+using ApprovalFlow.Kafka;
+using Microsoft.Extensions.DependencyInjection;
+using ApprovalFlow.Auth;
+
+public class Startup
+{
+ public IConfiguration Configuration { get; }
+ private readonly string _policyName = "CorsPolicy";
+
+ public Startup(IConfiguration configuration)
+ {
+ Configuration = configuration;
+ StaticConfig = configuration;
+ }
+
+ public static IConfiguration StaticConfig { get; private set; }
+
+
+ public void ConfigureServices(IServiceCollection services)
+ {
+ var config = this.InitializeConfiguration(services);
+
+ if (string.IsNullOrEmpty(config.SchemaRegistry.Url))
+ {
+ Log.Error("Schema registry is not configured - please resolve configuration and retry");
+ Environment.Exit(-1);
+ }
+
+ if (!string.IsNullOrEmpty(config.Telemetry.CollectorUrl))
+ {
+
+ var meters = new OtelMetrics();
+
+ Action configureResource = r => r.AddService(
+ serviceName: TelemetryConstants.ServiceName + "-ApprovalFlow",
+ serviceVersion: Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown",
+ serviceInstanceId: Environment.MachineName);
+
+ Log.Logger.Information("Telemetry logging is enabled {0}", config.Telemetry.CollectorUrl);
+ var resource = ResourceBuilder.CreateDefault().AddService(TelemetryConstants.ServiceName);
+
+ services.AddOpenTelemetry()
+ .ConfigureResource(configureResource)
+ .WithTracing(builder =>
+ {
+ builder.SetSampler(new AlwaysOnSampler())
+ .AddHttpClientInstrumentation()
+ .AddEntityFrameworkCoreInstrumentation(options => options.SetDbStatementForText = true)
+ .AddAspNetCoreInstrumentation();
+
+ if (config.Telemetry.LogToConsole)
+ {
+ builder.AddConsoleExporter();
+ }
+ if (config.Telemetry.AzureConnectionString != null)
+ {
+ Log.Information("*** Azure trace exporter enabled ***");
+ builder.AddAzureMonitorTraceExporter(o => o.ConnectionString = config.Telemetry.AzureConnectionString);
+ }
+ if (config.Telemetry.CollectorUrl != null)
+ {
+ builder.AddOtlpExporter(options =>
+ {
+ Log.Information("*** OpenTelemetry trace exporter enabled ***");
+
+ options.Endpoint = new Uri(config.Telemetry.CollectorUrl);
+ options.Protocol = OtlpExportProtocol.HttpProtobuf;
+ });
+ }
+ })
+ .WithMetrics(builder =>
+ builder.AddHttpClientInstrumentation()
+ .AddAspNetCoreInstrumentation()).StartWithHost();
+
+ }
+
+
+ services
+ .AddAutoMapper(typeof(Startup))
+ .AddKafkaConsumer(config)
+ .AddKeycloakAuth(config)
+
+
+ .AddSingleton(SystemClock.Instance);
+ // .AddSingleton(svc => svc.GetRequiredService>());
+
+ services.AddDbContext(options => options
+ .UseNpgsql(config.ConnectionStrings.ApprovalFlowDataStore, sql => sql.UseNodaTime())
+ .EnableSensitiveDataLogging(sensitiveDataLoggingEnabled: false));
+
+ services.AddMediatR(typeof(Startup).Assembly);
+
+ services.AddHealthChecks()
+ .AddCheck("liveliness", () => HealthCheckResult.Healthy())
+ .AddNpgSql(config.ConnectionStrings.ApprovalFlowDataStore, tags: new[] { "services" }).ForwardToPrometheus();
+
+ services.AddCors(opt =>
+ {
+ opt.AddPolicy(name: _policyName, builder =>
+ {
+ builder.AllowAnyOrigin()
+ .AllowAnyHeader()
+ .AllowAnyMethod();
+ });
+ });
+
+
+ services.AddControllers(options => options.Conventions.Add(new RouteTokenTransformerConvention(new KabobCaseParameterTransformer())))
+ .AddFluentValidation(options => options.RegisterValidatorsFromAssemblyContaining())
+ .AddJsonOptions(options =>
+ {
+ options.JsonSerializerOptions.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb);
+ options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
+ });
+ services.AddHttpClient();
+
+ services.AddSingleton();
+
+
+
+ services.AddApiVersioning(options =>
+ {
+ options.ReportApiVersions = true;
+ options.AssumeDefaultVersionWhenUnspecified = true;
+ options.ApiVersionReader = new HeaderApiVersionReader("api-version");
+ });
+
+ services.AddSwaggerGen(options =>
+ {
+ options.SwaggerDoc("v1", new OpenApiInfo { Title = "Approval Service API", Version = "v1" });
+
+ options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
+ {
+ Description = "Standard Authorization header using the Bearer scheme. Example: \"bearer {token}\"",
+ In = ParameterLocation.Header,
+ Name = "Authorization",
+ Type = SecuritySchemeType.ApiKey
+ });
+ options.AddSecurityRequirement(new OpenApiSecurityRequirement()
+ {
+ {
+ new OpenApiSecurityScheme
+ {
+ Reference = new OpenApiReference
+ {
+ Type = ReferenceType.SecurityScheme,
+ Id = "Bearer"
+ },
+ Scheme = "oauth2",
+ Name = "Bearer",
+ In = ParameterLocation.Header,
+
+ },
+ new List()
+ }
+ });
+ options.OperationFilter();
+ options.CustomSchemaIds(x => x.FullName);
+ });
+
+ JsonConvert.DefaultSettings = () => new JsonSerializerSettings
+ {
+ ContractResolver = new CamelCasePropertyNamesContractResolver()
+ };
+
+ services.AddKafkaConsumer(config);
+
+ // Validate EF migrations on startup
+ using (var serviceScope = services.BuildServiceProvider().CreateScope())
+ {
+ var dbContext = serviceScope.ServiceProvider.GetRequiredService();
+ try
+ {
+ dbContext.Database.Migrate();
+ }
+ catch (Exception ex)
+ {
+ Log.Error($"Database migration failure {string.Join(",", ex.Message)}");
+ throw;
+ }
+ }
+
+
+ Log.Logger.Information("### Approval Flow Configuration complete");
+
+
+ }
+ private ApprovalFlowConfiguration InitializeConfiguration(IServiceCollection services)
+ {
+ var config = new ApprovalFlowConfiguration();
+ this.Configuration.Bind(config);
+ services.AddSingleton(config);
+
+ Log.Logger.Information("### Approval Flow Service Version:{0} ###", Assembly.GetExecutingAssembly().GetName().Version);
+ Log.Logger.Debug("### Approval Flow Configuration:{0} ###", System.Text.Json.JsonSerializer.Serialize(config));
+
+ return config;
+ }
+ public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
+ {
+ if (env.IsDevelopment())
+ {
+ app.UseDeveloperExceptionPage();
+
+ }
+ //app.UseMiddleware();
+ app.UseExceptionHandler("/error");
+ app.UseSwagger();
+ app.UseSwaggerUI(options => options.SwaggerEndpoint("/swagger/v1/swagger.yaml", "Approval Service API"));
+
+ app.UseSerilogRequestLogging(options => options.EnrichDiagnosticContext = (diagnosticContext, httpContext) =>
+ {
+ //var userId = httpContext.User.GetUserId();
+ //if (!userId.Equals(Guid.Empty))
+ //{
+ // diagnosticContext.Set("User", userId);
+ //}
+ });
+ app.UseRouting();
+
+ app.UseCors("CorsPolicy");
+ app.UseAuthentication();
+ app.UseAuthorization();
+ app.UseWebSockets(); // websocket support for auto-ui updates
+ app.UseEndpoints(endpoints =>
+ {
+ endpoints.MapControllers();
+ endpoints.MapMetrics();
+ endpoints.MapHealthChecks("/health/liveness").AllowAnonymous();
+ });
+
+ app.UseMetricServer();
+ app.UseHttpMetrics();
+
+ }
+}
diff --git a/backend/ApprovalFlow/appsettings.json b/backend/ApprovalFlow/appsettings.json
new file mode 100644
index 00000000..17914b10
--- /dev/null
+++ b/backend/ApprovalFlow/appsettings.json
@@ -0,0 +1,50 @@
+{
+ "ConnectionStrings": {
+ "ApprovalDataStore": "Host=fedora;Port=5444;Database=approvalflow;Username=approvalflow;Password="
+ },
+ "Keycloak": {
+ "RealmUrl": "https://sso-dev-5b7aa5-dev.apps.silver.devops.gov.bc.ca/auth/realms/DEMSPOC"
+ },
+
+
+ "Seq": {
+ "Url": "https://seq-e27db1-test.apps.gold.devops.gov.bc.ca/"
+ },
+ "SplunkConfig": {
+ "Host": "",
+ "CollectorToken": ""
+ },
+
+ "SchemaRegistry": {
+ "Url": "",
+ "ClientId": "",
+ "ClientSecret": ""
+ },
+
+ "ApprovalConfig": {
+ "NotifyEmail": "lee.wright@nttdata.com",
+ "Subject": "Approval Required"
+ },
+
+ "KafkaCluster": {
+ "BootstrapServers": "pidp-kafka-cluster-5b7aa5-test.apps.silver.devops.gov.bc.ca:443",
+ "IncomingApprovalCreationTopic": "some-topic",
+ "ApprovalResponseTopic": "some-other-topic",
+ "NotificationTopic": "yet-another-topic",
+ "SaslOauthbearerTokenEndpointUrl": "https://pidp-sso-e27db1-test.apps.gold.devops.gov.bc.ca/auth/realms/Kafka/protocol/openid-connect/token",
+ "SaslOauthbearerProducerClientId": "kafka-producer",
+ "SaslOauthbearerProducerClientSecret": "",
+ "SaslOauthbearerConsumerClientId": "kafka-consumer",
+ "SaslOauthbearerConsumerClientSecret": "",
+ "SslCaLocation": "C:\\certs\\pidp\\ca.crt",
+ "SslCertificateLocation": "C:\\certs\\pidp\\client\\ca.crt",
+ "SslKeyLocation": "C:\\certs\\pidp\\client\\ca.key"
+ },
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*"
+}
diff --git a/backend/Dockerfile.ApprovalFlow b/backend/Dockerfile.ApprovalFlow
new file mode 100644
index 00000000..2b47fc6c
--- /dev/null
+++ b/backend/Dockerfile.ApprovalFlow
@@ -0,0 +1,37 @@
+#------------------------------------------------------------------------------------
+# Case Management service Dockerfile
+#------------------------------------------------------------------------------------
+
+FROM mcr.microsoft.com/dotnet/aspnet:6.0-alpine AS base
+WORKDIR /app
+EXPOSE 8080
+ENV ASPNETCORE_URLS=http://*:8080
+ENV ASPNETCORE_ENVIRONMENT="Production"
+ENV \
+ DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 \
+ LC_ALL=en_US.UTF-8 \
+ LANG=en_US.UTF-8
+
+RUN apk add --no-cache icu-libs
+
+FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build
+
+RUN dotnet tool install --tool-path /tools dotnet-trace
+RUN dotnet tool install --tool-path /tools dotnet-counters
+RUN dotnet tool install --tool-path /tools dotnet-dump
+
+WORKDIR /src
+COPY ["ApprovalFlow/ApprovalFlow.csproj", "ApprovalFlow/"]
+COPY ["common/Common.csproj", "common/"]
+RUN dotnet restore -r linux-musl-x64 "ApprovalFlow/ApprovalFlow.csproj"
+COPY . .
+WORKDIR "/src/ApprovalFlow"
+RUN dotnet build -r linux-musl-x64 "ApprovalFlow.csproj" -c Release -o /app/build
+
+FROM build AS publish
+RUN dotnet publish -r linux-musl-x64 "ApprovalFlow.csproj" -c Release -o /app/publish
+
+FROM base AS final
+WORKDIR /app
+COPY --from=publish /app/publish .
+ENTRYPOINT ["dotnet", "ApprovalFlow.dll"]
diff --git a/backend/common/Common.csproj b/backend/common/Common.csproj
index 82b4e8e3..314a18ce 100644
--- a/backend/common/Common.csproj
+++ b/backend/common/Common.csproj
@@ -26,7 +26,7 @@
- all
+
runtime; build; native; contentfiles; analyzers; buildtransitive
@@ -71,5 +71,8 @@
+
+
+
diff --git a/backend/common/Constants/Auth/AuthConstants.cs b/backend/common/Constants/Auth/AuthConstants.cs
index 4810a174..7fe11601 100644
--- a/backend/common/Constants/Auth/AuthConstants.cs
+++ b/backend/common/Constants/Auth/AuthConstants.cs
@@ -15,6 +15,9 @@ public static class Claims
public const string ResourceAccess = "resource_access";
public const string Subject = "sub";
public const string Roles = "roles";
+ public const string BcPersonFamilyName = "BCPerID_last_name";
+ public const string BcPersonGivenName = "BCPerID_first_name";
+ public const string MembershipStatusCode = "membership_status_code";
}
public static class DefaultRoles
@@ -28,11 +31,9 @@ public static class ClaimValues
public const string Idir = "idir";
public const string Phsa = "phsa";
public const string Bcps = "adfscert";
- public const string VerifiedCredentials = "vc";
public const string Adfs = "adfs"; // test
- public const string AzureIdir = "oidcazure";
-
- public const string SubmittingAgency = "subgenc";
+ public const string SubmittingAgency = "SUBMITTING_AGENCY";
+ public const string VerifiedCredentials = "verified";
}
@@ -43,17 +44,21 @@ public static class Policies
public const string AnyPartyIdentityProvider = "party-idp-policy";
public const string SubAgencyIdentityProvider = "subgency-idp-policy";
public const string UserOwnsResource = "user-owns-resource-policy";
+ public const string VerifiedCredentialsProvider = "verified-credentials-authentication-policy";
+
public const string AllDemsIdentityProvider = "dems-idp-policy";
public const string AllDefenceIdentityProvider = "all-defense-idp-policy";
public const string DefenceConselIdentityProvider = "defense-counsel-idp-policy";
public const string DutyConselIdentityProvider = "duty-counsel-idp-policy";
public const string BcpsAuthentication = "bcps-authentication-policy";
public const string AdminAuthentication = "admin-authentication-policy";
+ public const string ApprovalAuthorization = "approval-authentication-policy";
+
}
public static class Clients
{
- public const string PidpApi = "PIDP-SERVICE";
+ public const string AdminApi = "DIAM-BCPS-ADMIN";
}
public static class Roles
@@ -64,6 +69,9 @@ public static class Roles
public const string SubmittingAgency = "SUBMITTING_AGENCY";
public const string DefenceCounsel = "DEFENCE_COUNSEL";
public const string DutyCounsel = "DUTY_COUNSEL";
+ public const string Approver = "APPROVER";
+ public const string ApprovalViewer = "APPROVAL-VIEWER";
+
}
diff --git a/backend/common/Extensions/ClaimsPrincipalExtensions.cs b/backend/common/Extensions/ClaimsPrincipalExtensions.cs
new file mode 100644
index 00000000..e22cf3ef
--- /dev/null
+++ b/backend/common/Extensions/ClaimsPrincipalExtensions.cs
@@ -0,0 +1,122 @@
+namespace Common.Extensions;
+
+using common.Constants.Auth;
+using NodaTime;
+using NodaTime.Text;
+using System.Security.Claims;
+using System.Text.Json;
+
+
+public static class ClaimsPrincipalExtensions
+{
+ ///
+ /// Returns the UserId of the logged in user (from the 'sub' claim). If there is no logged in user, this will return Guid.Empty
+ ///
+ public static Guid GetUserId(this ClaimsPrincipal? user)
+ {
+ var userId = user?.FindFirstValue(Claims.Subject);
+
+ return Guid.TryParse(userId, out var parsed)
+ ? parsed
+ : Guid.Empty;
+ }
+
+ ///
+ /// Returns the Birthdate Claim of the User, parsed in ISO format (yyyy-MM-dd)
+ ///
+ public static LocalDate? GetBirthdate(this ClaimsPrincipal user)
+ {
+ var birthdate = user.FindFirstValue(Claims.Birthdate);
+
+ var parsed = LocalDatePattern.Iso.Parse(birthdate);
+ if (parsed.Success)
+ {
+ return parsed.Value;
+ }
+ else
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// Returns the Gender Claim of the User, parsed in ISO format (M/F)
+ ///
+ public static string? GetGender(this ClaimsPrincipal user)
+ {
+ var gender = user.FindFirstValue(Claims.Gender);
+
+ if (string.IsNullOrEmpty(gender))
+ return null;
+
+ return gender;
+ }
+
+ ///
+ /// Returns the Identity Provider of the User, or null if User is null
+ ///
+ public static string? GetIdentityProvider(this ClaimsPrincipal? user) => user?.FindFirstValue(Claims.IdentityProvider);
+
+ ///
+ /// check wheather the user is a valid bcps user using ad groups
+ ///
+ ///
+ ///
+ public static IEnumerable GetUserRoles(this ClaimsIdentity identity)
+ {
+ var roleClaim = identity.Claims
+ .SingleOrDefault(claim => claim.Type == Claims.ResourceAccess)
+ ?.Value;
+
+ if (string.IsNullOrWhiteSpace(roleClaim))
+ {
+ return Enumerable.Empty();
+ }
+
+ try
+ {
+ var userRoles = JsonSerializer.Deserialize>(roleClaim, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
+
+ return userRoles?.TryGetValue(roleClaim, out var access) == true
+ ? access.Roles
+ : Enumerable.Empty();
+ }
+ catch
+ {
+ return Enumerable.Empty();
+ }
+ }
+ ///
+ /// Parses the Resource Access claim and returns the roles for the given resource
+ ///
+ /// The name of the resource to retrive the roles from
+ public static IEnumerable GetResourceAccessRoles(this ClaimsIdentity identity, string resourceName)
+ {
+ var resourceAccessClaim = identity.Claims
+ .SingleOrDefault(claim => claim.Type == Claims.ResourceAccess)
+ ?.Value;
+
+ if (string.IsNullOrWhiteSpace(resourceAccessClaim))
+ {
+ return Enumerable.Empty();
+ }
+
+ try
+ {
+ var resources = JsonSerializer.Deserialize>(resourceAccessClaim, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
+
+ return resources?.TryGetValue(resourceName, out var access) == true
+ ? access.Roles
+ : Enumerable.Empty();
+ }
+ catch
+ {
+ return Enumerable.Empty();
+ }
+ }
+
+ private class ResourceAccess
+ {
+ public IEnumerable Roles { get; set; } = Enumerable.Empty();
+ }
+}
diff --git a/backend/common/Helpers/Extensions/ObjectExtensions.cs b/backend/common/Helpers/Extensions/ObjectExtensions.cs
new file mode 100644
index 00000000..06c077ff
--- /dev/null
+++ b/backend/common/Helpers/Extensions/ObjectExtensions.cs
@@ -0,0 +1,20 @@
+namespace DIAM.Common.Helpers.Extensions;
+using System;
+
+
+public static class ObjectExtensions
+{
+ ///
+ /// Throws an ArgumentNullException if the given data item is null.
+ ///
+ /// The item to check for nullity.
+ /// The name to use when throwing an exception
+ public static void ThrowIfNull(this T data, string name) where T : class
+ {
+ if (data == null)
+ {
+ throw new ArgumentNullException(name);
+ }
+ }
+}
+
diff --git a/backend/common/Helpers/Transformers/KabobCaseParameterTransformer.cs b/backend/common/Helpers/Transformers/KabobCaseParameterTransformer.cs
new file mode 100644
index 00000000..9264fa34
--- /dev/null
+++ b/backend/common/Helpers/Transformers/KabobCaseParameterTransformer.cs
@@ -0,0 +1,18 @@
+namespace DIAM.Common.Helpers.Transformers;
+
+using System.Text.RegularExpressions;
+using Microsoft.AspNetCore.Routing;
+
+public class KabobCaseParameterTransformer : IOutboundParameterTransformer
+{
+ public string? TransformOutbound(object? value)
+ {
+ if (value == null || value.ToString() == null)
+ {
+ return null;
+ }
+
+ return Regex.Replace(value.ToString()!, "([a-z])([A-Z])", "$1-$2", RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds(100))
+ .ToLowerInvariant();
+ }
+}
diff --git a/backend/common/Kafka/Deserializer/DefaultKafkaDeserializer.cs b/backend/common/Kafka/Deserializer/DefaultKafkaDeserializer.cs
new file mode 100644
index 00000000..7ef6f769
--- /dev/null
+++ b/backend/common/Kafka/Deserializer/DefaultKafkaDeserializer.cs
@@ -0,0 +1,49 @@
+namespace Common.Kafka.Deserializer;
+using System;
+using System.Globalization;
+using System.Text;
+using Confluent.Kafka;
+using Google.Protobuf.WellKnownTypes;
+using Newtonsoft.Json;
+using NodaTime;
+using NodaTime.Extensions;
+using NodaTime.Serialization.JsonNet;
+
+public sealed class DefaultKafkaDeserializer : IDeserializer
+{
+ public T Deserialize(ReadOnlySpan data, bool isNull, SerializationContext context)
+ {
+ if (typeof(T) == typeof(Null))
+ {
+ if (data.Length > 0)
+ throw new ArgumentException("The data is null.");
+ return default;
+ }
+
+ if (typeof(T) == typeof(Ignore))
+ return default;
+
+ var dataJson = Encoding.UTF8.GetString(data);
+
+
+ if (typeof(T) == typeof(Guid))
+ {
+ if (!Guid.TryParse(dataJson, out var guid))
+ {
+ throw new ArgumentException("The data is not a valid Guid.");
+ }
+ return (T)(object)guid;
+ }
+
+ if (typeof(T) == typeof(Instant))
+ {
+ var parsed = DateTime.Parse(dataJson, null, DateTimeStyles.RoundtripKind);
+ return (T)(object)parsed.ToInstant();
+ }
+
+ Serilog.Log.Information("Message {0}", dataJson);
+
+
+ return JsonConvert.DeserializeObject(dataJson);
+ }
+}
diff --git a/backend/common/Kafka/IKafkaConsumer.cs b/backend/common/Kafka/IKafkaConsumer.cs
new file mode 100644
index 00000000..fbdb2daa
--- /dev/null
+++ b/backend/common/Kafka/IKafkaConsumer.cs
@@ -0,0 +1,22 @@
+namespace Common.Kafka;
+
+public interface IKafkaConsumer where TValue : class
+{
+ ///
+ /// Triggered when the service is ready to consume the Kafka topic.
+ ///
+ /// Indicates the message's key for Kafka Topic
+ /// Indicates cancellation token
+ ///
+ Task Consume(string topic, CancellationToken stoppingToken);
+
+ ///
+ /// This will close the consumer, commit offsets and leave the group cleanly.
+ ///
+ void Close();
+ ///
+ /// Releases all resources used by the current instance of the consumer
+ ///
+ void Dispose();
+
+}
diff --git a/backend/common/Kafka/IKafkaHandler.cs b/backend/common/Kafka/IKafkaHandler.cs
new file mode 100644
index 00000000..d554fbba
--- /dev/null
+++ b/backend/common/Kafka/IKafkaHandler.cs
@@ -0,0 +1,13 @@
+namespace Common.Kafka;
+using System.Threading.Tasks;
+
+public interface IKafkaHandler
+{
+ ///
+ /// Provide mechanism to handle the consumer message from Kafka
+ ///
+ /// Indicates the message's key for Kafka Topic
+ /// Indicates the message's value for Kafka Topic
+ ///
+ Task HandleAsync(string consumerName, Tk key, Tv value);
+}
diff --git a/backend/common/Models/Approval/ApprovalModel.cs b/backend/common/Models/Approval/ApprovalModel.cs
new file mode 100644
index 00000000..243e74f5
--- /dev/null
+++ b/backend/common/Models/Approval/ApprovalModel.cs
@@ -0,0 +1,47 @@
+namespace Common.Models.Approval;
+
+using NodaTime;
+
+public class ApprovalModel
+{
+ public int Id { get; set; }
+ public string Reason { get; set; } = string.Empty;
+ public string RequiredAccess { get; set; } = string.Empty;
+ public int NoOfApprovalsRequired { get; set; }
+ public Instant? Approved { get; set; }
+ public Instant? Deleted { get; set; }
+ public Instant? Created { get; set; }
+ public Instant? Modified { get; set; }
+ public string UserId { get; set; } = string.Empty;
+ public string IdentityProvider { get; set; } = string.Empty;
+ public IEnumerable Requests { get; set; } = Enumerable.Empty();
+}
+
+public class ApprovalHistoryModel
+{
+ public string DecisionNote { get; set; } = string.Empty;
+ public string Approver { get; set; } = string.Empty;
+ public int ApprovalRequestId { get; set; }
+ public Instant? Deleted { get; set; }
+}
+
+public class RequestModel
+{
+ public int RequestId { get; set; }
+ public string RequestType { get; set; }
+ public string ApprovalType { get; set; } = string.Empty;
+ public ApprovalStatus Status { get; set; } = ApprovalStatus.PENDING;
+ public IEnumerable History { get; set; } = Enumerable.Empty();
+
+}
+
+
+public enum ApprovalStatus
+{
+ APPROVED,
+ DENIED,
+ PENDING,
+ DEFERRED,
+ OTHER
+
+}
diff --git a/backend/common/Models/Approval/ApprovalRequestModel.cs b/backend/common/Models/Approval/ApprovalRequestModel.cs
new file mode 100644
index 00000000..6e45f437
--- /dev/null
+++ b/backend/common/Models/Approval/ApprovalRequestModel.cs
@@ -0,0 +1,24 @@
+namespace Common.Models.Approval;
+
+using NodaTime;
+
+public class ApprovalRequestModel
+{
+ public List AccessRequests { get; set; }
+ public List? Reasons { get; set; }
+ public string RequiredAccess { get; set; } = string.Empty;
+ public DateTime? Created { get; set; }
+ public string FirstName { get; set; } = string.Empty;
+ public int NoOfApprovalsRequired { get; set; } = 1; // by default just a single approver required
+ public string EMailAddress { get; set; } = string.Empty;
+ public string UserId { get; set; } = string.Empty;
+ public string IdentityProvider { get; set; } = string.Empty;
+}
+
+public class ApprovalAccessRequest
+{
+ public int AccessRequestId { get; set; }
+ public string RequestType { get; set; } = string.Empty;
+ public List? Reasons { get; set; }
+
+}
diff --git a/backend/common/Models/Approval/ApproveDenyInput.cs b/backend/common/Models/Approval/ApproveDenyInput.cs
new file mode 100644
index 00000000..3b92a1e9
--- /dev/null
+++ b/backend/common/Models/Approval/ApproveDenyInput.cs
@@ -0,0 +1,16 @@
+namespace Common.Models.Approval;
+using System;
+using MediatR;
+
+///
+/// Used to approve/deny a request
+///
+public class ApproveDenyInput : IRequest
+{
+ public int ApprovalRequestId { get; set; }
+ public DateTime Created { get; set; } = DateTime.Now;
+ public string DecisionNotes { get; set; } = string.Empty;
+ public bool Approved { get; set; }
+ public string? ApproverUserId { get; set; }
+
+}
diff --git a/backend/common/Models/Notification/Notification.cs b/backend/common/Models/Notification/Notification.cs
new file mode 100644
index 00000000..152e3d25
--- /dev/null
+++ b/backend/common/Models/Notification/Notification.cs
@@ -0,0 +1,17 @@
+namespace Common.Models.Notification;
+
+using System.ComponentModel.DataAnnotations;
+
+public class Notification
+{
+ [EmailAddress(ErrorMessage = "Invalid Email Address")]
+ public string? To { get; set; }
+ [EmailAddress(ErrorMessage = "Invalid Email Address")]
+ public string? From { get; set; }
+ public string? Subject { get; set; }
+
+ public string DomainEvent { get; set; } = string.Empty;
+
+ public Dictionary EventData { get; set; } = new Dictionary();
+
+}
diff --git a/backend/edt.casemanagement/Startup.cs b/backend/edt.casemanagement/Startup.cs
index 651c4f84..6b18930d 100644
--- a/backend/edt.casemanagement/Startup.cs
+++ b/backend/edt.casemanagement/Startup.cs
@@ -294,6 +294,7 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
endpoints.MapControllers();
endpoints.MapMetrics();
+
endpoints.MapHealthChecks("/health/liveness").AllowAnonymous();
});
diff --git a/backend/jumwebapi/Features/Participants/Queries/GetParticipantByUsername.cs b/backend/jumwebapi/Features/Participants/Queries/GetParticipantByUsername.cs
index ccecbeb1..d5961fbd 100644
--- a/backend/jumwebapi/Features/Participants/Queries/GetParticipantByUsername.cs
+++ b/backend/jumwebapi/Features/Participants/Queries/GetParticipantByUsername.cs
@@ -13,7 +13,7 @@ public class GetParticipantByUsername : IRequestHandler
+using System;
+using System.Collections.Generic;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using NodaTime;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using Pidp.Data;
+using Pidp.Models;
+
+#nullable disable
+
+namespace Pidp.Data.Migrations
+{
+ [DbContext(typeof(PidpDbContext))]
+ [Migration("20230817153905_DeferredEvents")]
+ partial class DeferredEvents
+ {
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "6.0.8")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("Pidp.Models.AccessRequest", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("AccessTypeCode")
+ .HasColumnType("integer");
+
+ b.Property("Created")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Details")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Modified")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("PartyId")
+ .HasColumnType("integer");
+
+ b.Property("RequestedOn")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("PartyId");
+
+ b.ToTable("AccessRequest");
+ });
+
+ modelBuilder.Entity("Pidp.Models.Address", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("City")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("CountryCode")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Created")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Discriminator")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Modified")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Postal")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ProvinceCode")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Street")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CountryCode");
+
+ b.HasIndex("ProvinceCode");
+
+ b.ToTable("Address");
+
+ b.HasDiscriminator("Discriminator").HasValue("Address");
+ });
+
+ modelBuilder.Entity("Pidp.Models.AgencyRequestAttachment", b =>
+ {
+ b.Property("AttachmentId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AttachmentId"));
+
+ b.Property("AttachmentName")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("AttachmentType")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Created")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Modified")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("SubmittingAgencyRequestRequestId")
+ .HasColumnType("integer");
+
+ b.Property("UploadStatus")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("AttachmentId");
+
+ b.HasIndex("SubmittingAgencyRequestRequestId");
+
+ b.ToTable("AgencyRequestAttachment");
+ });
+
+ modelBuilder.Entity("Pidp.Models.ClientLog", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("AdditionalInformation")
+ .HasColumnType("text");
+
+ b.Property("Created")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("LogLevel")
+ .HasColumnType("integer");
+
+ b.Property("Message")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Modified")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.ToTable("ClientLog");
+ });
+
+ modelBuilder.Entity("Pidp.Models.CorrectionServiceDetail", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CorrectionServiceCode")
+ .HasColumnType("integer");
+
+ b.Property("OrgainizationDetailId")
+ .HasColumnType("integer");
+
+ b.Property("PeronalId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CorrectionServiceCode");
+
+ b.HasIndex("OrgainizationDetailId");
+
+ b.ToTable("CorrectionServiceDetails");
+ });
+
+ modelBuilder.Entity("Pidp.Models.CourtLocationAccessRequest", b =>
+ {
+ b.Property("RequestId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RequestId"));
+
+ b.Property("CourtLocationCode")
+ .HasColumnType("text");
+
+ b.Property("CourtSubLocationId")
+ .HasColumnType("integer");
+
+ b.Property("Created")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DeletedOn")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Details")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("MessageId")
+ .HasColumnType("uuid");
+
+ b.Property