OPC # 0001: Extract Clarity into standalone repo
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Aspire.Keycloak.Authentication" />
|
||||
<PackageReference Include="Aspire.Npgsql.EntityFrameworkCore.PostgreSQL" />
|
||||
<PackageReference Include="Aspire.StackExchange.Redis.OutputCaching" />
|
||||
<PackageReference Include="CommunityToolkit.Aspire.Minio.Client" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.Extensions.Http.Resilience" />
|
||||
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" />
|
||||
<PackageReference Include="Scalar.Aspire" />
|
||||
<PackageReference Include="VaultSharp" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Clarity.ServiceDefaults\Clarity.ServiceDefaults.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,6 @@
|
||||
@Server_HostAddress = http://localhost:5416
|
||||
|
||||
GET {{Server_HostAddress}}/api/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
@@ -0,0 +1,27 @@
|
||||
using Clarity.Server.Entity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Clarity.Server.Data
|
||||
{
|
||||
public class ApplicationDbContext : DbContext
|
||||
{
|
||||
public DbSet<Profile> Profiles => Set<Profile>();
|
||||
public DbSet<SysParams> SysParams => Set<SysParams>();
|
||||
|
||||
|
||||
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
|
||||
: base(options)
|
||||
{
|
||||
}
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<Profile>(entity =>
|
||||
{
|
||||
entity.HasKey(p => p.Id);
|
||||
entity.HasIndex(p => p.KeycloakSubject).IsUnique();
|
||||
entity.Property(p => p.KeycloakSubject).IsRequired();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
namespace Clarity.Server.Data;
|
||||
|
||||
public class ApplicationDbContextFactory : IDesignTimeDbContextFactory<ApplicationDbContext>
|
||||
{
|
||||
public ApplicationDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.SetBasePath(Directory.GetCurrentDirectory())
|
||||
.AddJsonFile("appsettings.json", optional: false)
|
||||
.AddJsonFile("appsettings.Development.json", optional: true)
|
||||
.AddEnvironmentVariables()
|
||||
.Build();
|
||||
|
||||
var optionsBuilder = new DbContextOptionsBuilder<ApplicationDbContext>();
|
||||
optionsBuilder.UseNpgsql(configuration.GetConnectionString("postgresdb"));
|
||||
|
||||
return new ApplicationDbContext(optionsBuilder.Options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace Clarity.Server.Data;
|
||||
|
||||
public static class BlindIndexHelper
|
||||
{
|
||||
// In a real environment, this "Pepper" comes from your TenantKeyProvider or Vault!
|
||||
// It must NEVER change once records are written, or searches will break.
|
||||
public static string Compute(string? input, byte[] staticPepper)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input)) return string.Empty;
|
||||
|
||||
// 1. Normalize (Remove dashes, spaces, make uppercase)
|
||||
var normalized = input.Replace("-", "").Replace(" ", "").ToUpperInvariant();
|
||||
|
||||
// 2. Hash using HMAC-SHA256 and the static Pepper
|
||||
using var hmac = new HMACSHA256(staticPepper);
|
||||
var hashBytes = hmac.ComputeHash(Encoding.UTF8.GetBytes(normalized));
|
||||
|
||||
// 3. Return Base64 for database storage
|
||||
return Convert.ToBase64String(hashBytes);
|
||||
}
|
||||
}
|
||||
|
||||
[AttributeUsage(AttributeTargets.Property)]
|
||||
public class BlindIndexedAttribute : Attribute
|
||||
{
|
||||
public string TargetPropertyName { get; }
|
||||
|
||||
public BlindIndexedAttribute(string targetPropertyName)
|
||||
{
|
||||
TargetPropertyName = targetPropertyName;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Clarity.Server.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Clarity.Server.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(ApplicationDbContext))]
|
||||
[Migration("20260424021033_InitialCreate")]
|
||||
partial class InitialCreate
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.6")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Clarity.Server.Data.SysParams", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("EncryptedKek")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("SysParams");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Clarity.Server.Entity.Profile", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<byte[]>("EncryptedDek")
|
||||
.IsRequired()
|
||||
.HasColumnType("bytea");
|
||||
|
||||
b.Property<string>("FirstName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("KeycloakSubject")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("LastName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("MiddleName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("OnboardingComplete")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Ssn")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Tenant")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("KeycloakSubject")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Profiles");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Clarity.Server.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialCreate : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Profiles",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
KeycloakSubject = table.Column<string>(type: "text", nullable: false),
|
||||
FirstName = table.Column<string>(type: "text", nullable: false),
|
||||
MiddleName = table.Column<string>(type: "text", nullable: false),
|
||||
LastName = table.Column<string>(type: "text", nullable: false),
|
||||
Ssn = table.Column<string>(type: "text", nullable: false),
|
||||
OnboardingComplete = table.Column<bool>(type: "boolean", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
EncryptedDek = table.Column<byte[]>(type: "bytea", nullable: false),
|
||||
Tenant = table.Column<string>(type: "text", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Profiles", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "SysParams",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
EncryptedKek = table.Column<string>(type: "text", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_SysParams", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Profiles_KeycloakSubject",
|
||||
table: "Profiles",
|
||||
column: "KeycloakSubject",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "Profiles");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "SysParams");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Clarity.Server.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Clarity.Server.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(ApplicationDbContext))]
|
||||
partial class ApplicationDbContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.6")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Clarity.Server.Data.SysParams", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("EncryptedKek")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("SysParams");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Clarity.Server.Entity.Profile", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<byte[]>("EncryptedDek")
|
||||
.IsRequired()
|
||||
.HasColumnType("bytea");
|
||||
|
||||
b.Property<string>("FirstName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("KeycloakSubject")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("LastName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("MiddleName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("OnboardingComplete")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Ssn")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Tenant")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("KeycloakSubject")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Profiles");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json.Serialization;
|
||||
using VaultSharp;
|
||||
using VaultSharp.V1.SecretsEngines;
|
||||
using VaultSharp.V1.SecretsEngines.Transit;
|
||||
using VaultSharp.V1.Commons;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Clarity.Server.Data;
|
||||
|
||||
public interface IPIIEntity
|
||||
{
|
||||
public byte[] EncryptedDek { get; set; }
|
||||
public string Tenant { get; set; }
|
||||
}
|
||||
|
||||
public interface IPIIVault
|
||||
{
|
||||
Task<(byte[] PlaintextKey, string CiphertextKey)> GenerateTenantKEKAsync(string tenantId);
|
||||
Task<byte[]> DecryptTenantKEKAsync(string encryptedKek, string tenantId);
|
||||
Task<string> RewrapTenantKEKAsync(string encryptedKek, string tenantId);
|
||||
}
|
||||
|
||||
|
||||
public class TenantKeyProvider
|
||||
{
|
||||
public byte[]? CurrentKey { get; private set; }
|
||||
public void SetKey(byte[] key) => CurrentKey = key;
|
||||
}
|
||||
|
||||
public class PIIVault : IPIIVault
|
||||
{
|
||||
private readonly IVaultClient _vaultClient;
|
||||
|
||||
private const string TransitPath = "clarity-transit";
|
||||
private const string MasterKeyName = "master-key";
|
||||
|
||||
public PIIVault(IVaultClient vaultClient)
|
||||
{
|
||||
_vaultClient = vaultClient;
|
||||
}
|
||||
|
||||
// https://github.com/rajanadar/VaultSharp/blob/master/test/VaultSharp.Samples/Backends/Secrets/TransitSecretsBackendSamples.cs
|
||||
public async Task<(byte[] PlaintextKey, string CiphertextKey)> GenerateTenantKEKAsync(string tenantId)
|
||||
{
|
||||
var encodedContext = Convert.ToBase64String(Encoding.UTF8.GetBytes(tenantId));
|
||||
var dataKeyOptions = new DataKeyRequestOptions
|
||||
{
|
||||
DataKeyType = TransitDataKeyType.plaintext,
|
||||
Base64EncodedContext = encodedContext
|
||||
};
|
||||
|
||||
Secret<DataKeyResponse> response = await _vaultClient.V1.Secrets.Transit.GenerateDataKeyAsync(
|
||||
MasterKeyName,
|
||||
dataKeyOptions,
|
||||
TransitPath);
|
||||
|
||||
byte[] rawPlaintextBytes = Convert.FromBase64String(response.Data.Base64EncodedPlainText);
|
||||
string ciphertext = response.Data.CipherText;
|
||||
|
||||
return (rawPlaintextBytes, ciphertext);
|
||||
}
|
||||
|
||||
public async Task<byte[]> DecryptTenantKEKAsync(string encryptedKek, string tenantId)
|
||||
{
|
||||
var encodedContext = Convert.ToBase64String(Encoding.UTF8.GetBytes(tenantId));
|
||||
var decryptOptions = new DecryptRequestOptions
|
||||
{
|
||||
CipherText = encryptedKek,
|
||||
Base64EncodedContext = encodedContext
|
||||
};
|
||||
|
||||
Secret<DecryptionResponse> response = await _vaultClient.V1.Secrets.Transit.DecryptAsync(
|
||||
MasterKeyName,
|
||||
decryptOptions,
|
||||
TransitPath);
|
||||
|
||||
return Convert.FromBase64String(response.Data.Base64EncodedPlainText);
|
||||
}
|
||||
|
||||
public async Task<string> RewrapTenantKEKAsync(string encryptedKek, string tenantId)
|
||||
{
|
||||
var encodedContext = Convert.ToBase64String(Encoding.UTF8.GetBytes(tenantId));
|
||||
|
||||
var rewrapOptions = new RewrapRequestOptions
|
||||
{
|
||||
CipherText = encryptedKek,
|
||||
Base64EncodedContext = encodedContext
|
||||
};
|
||||
|
||||
var response = await _vaultClient.V1.Secrets.Transit.RewrapAsync(
|
||||
MasterKeyName,
|
||||
rewrapOptions,
|
||||
TransitPath);
|
||||
|
||||
return response.Data.CipherText;
|
||||
}
|
||||
}
|
||||
public static class TenantSecurityExtensions
|
||||
{
|
||||
public static async Task InitializeTenantSecurityAsync(this WebApplication app)
|
||||
{
|
||||
using var scope = app.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
var piiVault = scope.ServiceProvider.GetRequiredService<IPIIVault>();
|
||||
var keyProvider = scope.ServiceProvider.GetRequiredService<TenantKeyProvider>();
|
||||
|
||||
var tenantId = app.Configuration["Tenant__Id"] ?? "CLARITY_01000000";
|
||||
|
||||
var settings = await db.SysParams.FirstOrDefaultAsync();
|
||||
|
||||
if (settings == null)
|
||||
{
|
||||
// Generate the key!
|
||||
var (plaintext, ciphertext) = await piiVault.GenerateTenantKEKAsync(tenantId);
|
||||
|
||||
// Save ciphertext to DB so we have it for the next restart
|
||||
db.SysParams.Add(new SysParams { EncryptedKek = ciphertext });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Store plaintext in RAM
|
||||
keyProvider.SetKey(plaintext);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Ask Vault to unwrap the existing key using the Master Key + Context
|
||||
var plaintext = await piiVault.DecryptTenantKEKAsync(settings.EncryptedKek, tenantId);
|
||||
|
||||
// Store plaintext back in RAM
|
||||
keyProvider.SetKey(plaintext);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[AttributeUsage(AttributeTargets.Property)]
|
||||
public class PiiDataAttribute : Attribute
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
|
||||
public class EnvelopeEncryptionInterceptor : SaveChangesInterceptor, IMaterializationInterceptor
|
||||
{
|
||||
private readonly TenantKeyProvider _keyProvider;
|
||||
|
||||
public EnvelopeEncryptionInterceptor(TenantKeyProvider keyProvider)
|
||||
{
|
||||
_keyProvider = keyProvider;
|
||||
}
|
||||
|
||||
public override InterceptionResult<int> SavingChanges(DbContextEventData eventData, InterceptionResult<int> result)
|
||||
{
|
||||
EncryptEntities(eventData.Context);
|
||||
return base.SavingChanges(eventData, result);
|
||||
}
|
||||
|
||||
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default)
|
||||
{
|
||||
EncryptEntities(eventData.Context);
|
||||
return base.SavingChangesAsync(eventData, result, cancellationToken);
|
||||
}
|
||||
|
||||
public object InitializedInstance(MaterializationInterceptionData materializationData, object entity)
|
||||
{
|
||||
if (entity is not IPIIEntity piiEntity || _keyProvider.CurrentKey == null || piiEntity.EncryptedDek == null)
|
||||
{
|
||||
return entity;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// 1. Open the Envelope (Unwrap Row DEK)
|
||||
byte[] rowDek = AesGcmHelper.Decrypt(piiEntity.EncryptedDek, _keyProvider.CurrentKey);
|
||||
|
||||
var props = entity.GetType().GetProperties()
|
||||
.Where(p => p.PropertyType == typeof(string) && Attribute.IsDefined(p, typeof(PiiDataAttribute)));
|
||||
|
||||
foreach (var prop in props)
|
||||
{
|
||||
var cipherText = (string?)prop.GetValue(entity);
|
||||
if (!string.IsNullOrEmpty(cipherText))
|
||||
{
|
||||
prop.SetValue(entity, AesGcmHelper.DecryptString(cipherText, rowDek));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (CryptographicException) { /* Log unauthorized tampering! */ }
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
private void EncryptEntities(DbContext? context)
|
||||
{
|
||||
if (context == null || _keyProvider.CurrentKey == null) return;
|
||||
|
||||
var entries = context.ChangeTracker.Entries<IPIIEntity>()
|
||||
.Where(e => e.State == EntityState.Added || e.State == EntityState.Modified);
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
var entity = entry.Entity;
|
||||
byte[] rowDek = RandomNumberGenerator.GetBytes(32);
|
||||
|
||||
// 2. Wrap the Row DEK
|
||||
entity.EncryptedDek = AesGcmHelper.Encrypt(rowDek, _keyProvider.CurrentKey);
|
||||
|
||||
var props = entity.GetType().GetProperties()
|
||||
.Where(p => p.PropertyType == typeof(string) && Attribute.IsDefined(p, typeof(PiiDataAttribute)));
|
||||
|
||||
foreach (var prop in props)
|
||||
{
|
||||
var plainText = (string?)prop.GetValue(entity);
|
||||
if (!string.IsNullOrEmpty(plainText))
|
||||
{
|
||||
prop.SetValue(entity, AesGcmHelper.EncryptString(plainText, rowDek));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// AES-GCM High-Performance Helper
|
||||
/// Uses Span<byte> for speed, but returns byte[] for EF Core storage.
|
||||
/// </summary>
|
||||
public static class AesGcmHelper
|
||||
{
|
||||
private const int NonceSize = 12; // AesGcm.NonceByteSizes.MaxSize
|
||||
private const int TagSize = 16; // AesGcm.TagByteSizes.MaxSize
|
||||
|
||||
public static byte[] Encrypt(ReadOnlySpan<byte> data, ReadOnlySpan<byte> key)
|
||||
{
|
||||
// Final buffer: [Nonce (12)] [Tag (16)] [Ciphertext (N)]
|
||||
byte[] result = new byte[NonceSize + TagSize + data.Length];
|
||||
|
||||
Span<byte> nonce = result.AsSpan(0, NonceSize);
|
||||
Span<byte> tag = result.AsSpan(NonceSize, TagSize);
|
||||
Span<byte> cipher = result.AsSpan(NonceSize + TagSize);
|
||||
|
||||
RandomNumberGenerator.Fill(nonce);
|
||||
|
||||
using var aes = new AesGcm(key);
|
||||
aes.Encrypt(nonce, data, cipher, tag);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public static byte[] Decrypt(ReadOnlySpan<byte> encryptedData, ReadOnlySpan<byte> key)
|
||||
{
|
||||
if (encryptedData.Length < NonceSize + TagSize)
|
||||
throw new CryptographicException("Ciphertext too short.");
|
||||
|
||||
ReadOnlySpan<byte> nonce = encryptedData.Slice(0, NonceSize);
|
||||
ReadOnlySpan<byte> tag = encryptedData.Slice(NonceSize, TagSize);
|
||||
ReadOnlySpan<byte> cipher = encryptedData.Slice(NonceSize + TagSize);
|
||||
|
||||
byte[] result = new byte[cipher.Length];
|
||||
using var aes = new AesGcm(key);
|
||||
aes.Decrypt(nonce, cipher, tag, result);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public static string EncryptString(string text, byte[] key) =>
|
||||
Convert.ToBase64String(Encrypt(System.Text.Encoding.UTF8.GetBytes(text), key));
|
||||
|
||||
public static string DecryptString(string cipher, byte[] key) =>
|
||||
System.Text.Encoding.UTF8.GetString(Decrypt(Convert.FromBase64String(cipher), key));
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Clarity.Server.Data
|
||||
{
|
||||
public class SysParams
|
||||
{
|
||||
[Key]
|
||||
public int Id { get; set; } = 1;
|
||||
|
||||
public string EncryptedKek { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
# -- Stage 1: Build Vite frontend ---------------------------------------------
|
||||
FROM node:22-alpine AS frontend
|
||||
WORKDIR /frontend
|
||||
|
||||
COPY frontend/package.json frontend/package-lock.json* ./
|
||||
RUN npm ci --legacy-peer-deps
|
||||
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
# Output lands in /frontend/dist
|
||||
|
||||
# -- Stage 2: Build .NET server -----------------------------------------------
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
|
||||
COPY ["Clarity.Server/Clarity.Server.csproj", "Clarity.Server/"]
|
||||
COPY ["Clarity.ServiceDefaults/Clarity.ServiceDefaults.csproj", "Clarity.ServiceDefaults/"]
|
||||
COPY ["Directory.Packages.props", "./"]
|
||||
|
||||
RUN dotnet restore "Clarity.Server/Clarity.Server.csproj"
|
||||
|
||||
COPY . .
|
||||
|
||||
# Embed the Vite build into wwwroot (mirrors Aspire PublishWithContainerFiles)
|
||||
COPY --from=frontend /frontend/dist ./Clarity.Server/wwwroot/
|
||||
|
||||
RUN dotnet publish "Clarity.Server/Clarity.Server.csproj" \
|
||||
-c Release -o /app/publish
|
||||
|
||||
# -- Stage 3: Runtime ----------------------------------------------------------
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=build /app/publish .
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
ENTRYPOINT ["dotnet", "Clarity.Server.dll"]
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace Clarity.Server.Endpoints
|
||||
{
|
||||
public static class DebugEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapDebugEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var api = app.MapGroup("/api").RequireAuthorization();
|
||||
|
||||
api.MapGet("debug/claims", (ClaimsPrincipal user) =>
|
||||
{
|
||||
var sub = user.FindFirstValue(ClaimTypes.NameIdentifier) ?? user.FindFirstValue("sub");
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
Subject = sub,
|
||||
Username = user.FindFirstValue("preferred_username"),
|
||||
Email = user.FindFirstValue(ClaimTypes.Email) ?? user.FindFirstValue("email"),
|
||||
IsAuthenticated = user.Identity?.IsAuthenticated,
|
||||
AllClaims = user.Claims.Select(c => new { c.Type, c.Value })
|
||||
});
|
||||
});
|
||||
|
||||
// Sanity check - no auth required, confirms API is reachable
|
||||
api.MapGet("auth/ping", () => Results.Ok(new { Status = "ok", Time = DateTimeOffset.UtcNow }))
|
||||
.AllowAnonymous();
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using Clarity.Server.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace Clarity.Server.Endpoints
|
||||
{
|
||||
public static class ProfileEndpoints
|
||||
{
|
||||
public record OnboardingRequest(string FirstName, string? MiddleName, string LastName, string Ssn);
|
||||
|
||||
public static IEndpointRouteBuilder MapProfileEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/profile").RequireAuthorization();
|
||||
|
||||
group.MapGet("/", async (ClaimsPrincipal user, ProfileService svc, CancellationToken ct) =>
|
||||
{
|
||||
var sub = user.FindFirstValue(ClaimTypes.NameIdentifier) ?? user.FindFirstValue("sub");
|
||||
if (sub is null) return Results.Unauthorized();
|
||||
|
||||
var profile = await svc.GetBySubjectAsync(sub, ct);
|
||||
if (profile is null)
|
||||
return Results.NotFound(new { onboardingComplete = false });
|
||||
|
||||
return Results.Ok(profile);
|
||||
});
|
||||
|
||||
group.MapGet("/{subject}", async (string subject, ProfileService profileService) =>
|
||||
{
|
||||
var profile = await profileService.GetBySubjectAsync(subject);
|
||||
|
||||
if (profile == null)
|
||||
return Results.NotFound(new { message = "Profile not found!" });
|
||||
|
||||
return Results.Ok(profile);
|
||||
});
|
||||
|
||||
group.MapPost("/onboarding", async (
|
||||
[FromBody] OnboardingRequest req,
|
||||
ClaimsPrincipal user,
|
||||
ProfileService svc,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var sub = user.FindFirstValue(ClaimTypes.NameIdentifier) ?? user.FindFirstValue("sub");
|
||||
if (sub is null) return Results.Unauthorized();
|
||||
|
||||
var existing = await svc.GetBySubjectAsync(sub, ct);
|
||||
if (existing is not null)
|
||||
return Results.Conflict(new { message = "Profile already exists." });
|
||||
|
||||
var profile = await svc.CreateAsync(sub, req.FirstName, req.MiddleName, req.LastName, req.Ssn, ct);
|
||||
return Results.Ok(profile);
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using Clarity.Server.Data;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Clarity.Server.Entity
|
||||
{
|
||||
public class Profile : IPIIEntity
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
/// <summary>Keycloak subject ID (the "sub" claim). Unique per user.</summary>
|
||||
public string KeycloakSubject { get; set; } = string.Empty;
|
||||
|
||||
|
||||
// Actual Application data
|
||||
[PiiData]
|
||||
public string FirstName { get; set; }
|
||||
[PiiData]
|
||||
public string MiddleName { get; set; }
|
||||
[PiiData]
|
||||
public string LastName { get; set; }
|
||||
|
||||
[PiiData]
|
||||
public string Ssn { get; set; }
|
||||
|
||||
// Audit
|
||||
// Onboarding Flow
|
||||
public bool OnboardingComplete { get; set; } = false;
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
public byte[] EncryptedDek { get; set; } = Array.Empty<byte>();
|
||||
public string Tenant { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using Minio;
|
||||
using Minio.DataModel.Args;
|
||||
|
||||
namespace Clarity.Server.Extensions;
|
||||
|
||||
public static class MinioExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Idempotently provisions MinIO buckets on application startup.
|
||||
/// </summary>
|
||||
public static async Task ProvisionBucketsAsync(this WebApplication app, params string[] bucketNames)
|
||||
{
|
||||
// Skip entirely if Minio isn't configured (e.g. tenant containers without Minio env var)
|
||||
var connStr = app.Configuration.GetConnectionString("minio");
|
||||
if (string.IsNullOrWhiteSpace(connStr)) return;
|
||||
|
||||
using var scope = app.Services.CreateScope();
|
||||
var minioClient = scope.ServiceProvider.GetRequiredService<IMinioClient>();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILogger<Program>>();
|
||||
|
||||
foreach (var bucket in bucketNames)
|
||||
{
|
||||
try
|
||||
{
|
||||
var exists = await minioClient.BucketExistsAsync(new BucketExistsArgs().WithBucket(bucket));
|
||||
if (!exists)
|
||||
{
|
||||
await minioClient.MakeBucketAsync(new MakeBucketArgs().WithBucket(bucket));
|
||||
logger.LogInformation("🚀 Project Clarity: Successfully provisioned MinIO bucket '{Bucket}'", bucket);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "❌ Project Clarity: Failed to provision MinIO bucket '{Bucket}'", bucket);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
using Clarity.Server;
|
||||
using Clarity.Server.Data;
|
||||
using Clarity.Server.Endpoints;
|
||||
using Clarity.Server.Extensions;
|
||||
using Clarity.Server.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Net;
|
||||
using System.Security.Claims;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Add service defaults & Aspire client integrations.
|
||||
builder.AddServiceDefaults();
|
||||
if (!string.IsNullOrEmpty(builder.Configuration.GetConnectionString("cache")))
|
||||
{
|
||||
builder.AddRedisClientBuilder("cache")
|
||||
.WithOutputCache();
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Services.AddOutputCache();
|
||||
}
|
||||
|
||||
#region HASHICORP_VAULT
|
||||
var keyProvider = new TenantKeyProvider();
|
||||
var encryptionInterceptor = new EnvelopeEncryptionInterceptor(keyProvider);
|
||||
|
||||
// Add them to DI so your bootstrapper can still resolve them!
|
||||
builder.Services.AddSingleton(keyProvider);
|
||||
builder.Services.AddSingleton(encryptionInterceptor);
|
||||
builder.Services.AddClarityVaultCryptography(builder.Configuration);
|
||||
//builder.Services.AddSingleton<EnvelopeEncryptionInterceptor>();
|
||||
//builder.Services.AddSingleton<TenantKeyProvider>();
|
||||
builder.Services.AddSingleton<IPIIVault, PIIVault>();
|
||||
#endregion
|
||||
|
||||
#region POSTGRESQL
|
||||
//builder.AddNpgsqlDbContext<ApplicationDbContext>(connectionName: "postgresdb");
|
||||
builder.AddNpgsqlDbContext<ApplicationDbContext>(
|
||||
connectionName: "postgresdb",
|
||||
configureDbContextOptions: options =>
|
||||
{
|
||||
options.AddInterceptors(encryptionInterceptor);
|
||||
});
|
||||
#endregion
|
||||
|
||||
#region MINIO
|
||||
builder.AddMinioClient("minio");
|
||||
#endregion
|
||||
|
||||
builder.Services.AddScoped<ProfileService>();
|
||||
|
||||
// Add services to the container.
|
||||
builder.Services.AddProblemDetails();
|
||||
|
||||
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
|
||||
builder.Services.AddOpenApi();
|
||||
|
||||
// Keycloak__BaseUrl and Keycloak__Realm are injected by ClarityContainerService at container creation.
|
||||
// Fall back to Aspire service-discovery var for local dev (running outside Docker).
|
||||
// BaseUrl = internal URL for fetching OIDC discovery (host.docker.internal:8080 inside Docker)
|
||||
// PublicUrl = public-facing issuer URL that appears in JWT `iss` claim (localhost:8080)
|
||||
// PublicUrl = browser-facing issuer (matches `iss` claim in JWT) e.g. https://keycloak.clarity.test
|
||||
// InternalUrl = container-internal URL for OIDC metadata fetch — avoids self-signed cert issues
|
||||
// BaseUrl is legacy fallback kept for local dev outside Docker
|
||||
var keycloakPublicUrl = builder.Configuration["Keycloak:BaseUrl"]
|
||||
?? builder.Configuration["services__keycloak__http__0"]
|
||||
?? "http://localhost:8080";
|
||||
var keycloakInternalUrl = builder.Configuration["Keycloak:InternalUrl"]
|
||||
?? "http://keycloak:8080";
|
||||
var keycloakRealm = builder.Configuration["Keycloak:Realm"] ?? "clarity";
|
||||
|
||||
// AddKeycloakJwtBearer uses the named IHttpClientFactory client "KeycloakBackchannel" for all
|
||||
// backchannel requests (discovery + JWKS). Register our rewriting handler on that named client
|
||||
// so every request to keycloak.clarity.test[:port] is rewritten to http://keycloak:8080 before
|
||||
// it leaves the container — avoiding self-signed cert issues and DNS failures.
|
||||
builder.Services.AddTransient<InternalKeycloakHandler>();
|
||||
builder.Services.AddHttpClient("KeycloakBackchannel")
|
||||
.AddHttpMessageHandler<InternalKeycloakHandler>();
|
||||
|
||||
builder.Services.AddAuthentication()
|
||||
.AddKeycloakJwtBearer("keycloak", realm: keycloakRealm,
|
||||
options =>
|
||||
{
|
||||
// Authority = public issuer, must match `iss` claim in the JWT
|
||||
options.Authority = $"{keycloakPublicUrl}/realms/{keycloakRealm}";
|
||||
options.Audience = "clarity-rest-api";
|
||||
options.RequireHttpsMetadata = false;
|
||||
// Fetch OIDC discovery doc over internal HTTP to avoid self-signed cert trust issues
|
||||
options.MetadataAddress = $"{keycloakInternalUrl}/realms/{keycloakRealm}/.well-known/openid-configuration";
|
||||
options.TokenValidationParameters.ValidIssuers =
|
||||
[
|
||||
$"{keycloakPublicUrl}/realms/{keycloakRealm}",
|
||||
$"{keycloakInternalUrl}/realms/{keycloakRealm}",
|
||||
// Keycloak advertises http://keycloak.clarity.test:8080 as issuer when accessed
|
||||
// directly on port 8080 (before KC_HOSTNAME_URL takes full effect).
|
||||
$"http://keycloak.clarity.test:8080/realms/{keycloakRealm}",
|
||||
$"http://keycloak.clarity.test/realms/{keycloakRealm}",
|
||||
];
|
||||
});
|
||||
|
||||
builder.Services.AddAuthorization();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Run EF Core migrations on every cold start — safe, idempotent, self-healing.
|
||||
// One container = one tenant = no concurrent migrator race.
|
||||
await using (var scope = app.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
await db.Database.MigrateAsync();
|
||||
}
|
||||
|
||||
await app.InitializeTenantSecurityAsync();
|
||||
|
||||
#region MINIO PROVISION BUCKETS
|
||||
await app.ProvisionBucketsAsync("clarity-documents", "clarity-profile-pictures");
|
||||
#endregion
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
app.UseExceptionHandler();
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapOpenApi();
|
||||
}
|
||||
|
||||
app.UseOutputCache();
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
// Serves Keycloak config to the frontend at runtime so the realm (which is per-tenant)
|
||||
// doesn't have to be baked in at image build time.
|
||||
app.MapGet("/api/config", (IConfiguration cfg) => Results.Ok(new
|
||||
{
|
||||
keycloakUrl = cfg["Keycloak:BaseUrl"] ?? "http://localhost:8080",
|
||||
realm = cfg["Keycloak:Realm"] ?? "clarity",
|
||||
clientId = "clarity-web-app",
|
||||
})).AllowAnonymous();
|
||||
|
||||
#if DEBUG
|
||||
app.MapDebugEndpoints();
|
||||
#endif
|
||||
|
||||
app.MapProfileEndpoints();
|
||||
app.MapDefaultEndpoints();
|
||||
|
||||
app.UseFileServer();
|
||||
|
||||
app.Run();
|
||||
|
||||
/// <summary>
|
||||
/// Delegating handler registered on the "KeycloakBackchannel" IHttpClientFactory named client.
|
||||
/// Rewrites any backchannel request targeting the public Keycloak hostname
|
||||
/// (e.g. jwks_uri returned by the OIDC discovery doc) to the internal Docker DNS URL,
|
||||
/// avoiding self-signed cert issues inside tenant containers.
|
||||
/// </summary>
|
||||
sealed class InternalKeycloakHandler : DelegatingHandler
|
||||
{
|
||||
// Matches http:// or https:// + keycloak.clarity.test + optional :port
|
||||
private static readonly System.Text.RegularExpressions.Regex PublicHostPattern =
|
||||
new(@"https?://keycloak\.clarity\.test(:\d+)?", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
|
||||
private const string InternalUrl = "http://keycloak:8080";
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
|
||||
{
|
||||
if (request.RequestUri is not null)
|
||||
{
|
||||
var rewritten = PublicHostPattern.Replace(request.RequestUri.ToString(), InternalUrl);
|
||||
request.RequestUri = new Uri(rewritten);
|
||||
}
|
||||
return base.SendAsync(request, ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://localhost:5416",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "https://localhost:7308;http://localhost:5416",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using Clarity.Server.Data;
|
||||
using Clarity.Server.Entity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Clarity.Server.Services
|
||||
{
|
||||
public class ProfileService
|
||||
{
|
||||
private readonly ApplicationDbContext db;
|
||||
private readonly IPIIVault piiVault;
|
||||
|
||||
public ProfileService(ApplicationDbContext db, IPIIVault piiVault)
|
||||
{
|
||||
this.db = db;
|
||||
this.piiVault = piiVault;
|
||||
}
|
||||
|
||||
public Task<Profile?> GetBySubjectAsync(string sub, CancellationToken ct = default) =>
|
||||
db.Profiles.FirstOrDefaultAsync(p => p.KeycloakSubject == sub, ct);
|
||||
|
||||
|
||||
|
||||
public async Task<Profile> CreateAsync(string sub, string firstName, string? middleName, string lastName, string ssn, CancellationToken ct = default)
|
||||
{
|
||||
var profile = new Profile
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
KeycloakSubject = sub,
|
||||
FirstName = firstName,
|
||||
MiddleName = middleName ?? string.Empty,
|
||||
LastName = lastName,
|
||||
Ssn = ssn,
|
||||
OnboardingComplete = true,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
db.Profiles.Add(profile);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return profile;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using Clarity.Server.Data;
|
||||
using System.Runtime.Serialization;
|
||||
using VaultSharp;
|
||||
using VaultSharp.Core;
|
||||
using VaultSharp.V1.AuthMethods;
|
||||
using VaultSharp.V1.AuthMethods.Token;
|
||||
|
||||
namespace Clarity.Server
|
||||
{
|
||||
public static class VaultSharpExtensions
|
||||
{
|
||||
public static IServiceCollection AddClarityVaultCryptography(this IServiceCollection services, IConfiguration config)
|
||||
{
|
||||
var vaultAddress = config["Vault:Address"] ?? "http://localhost:8200";
|
||||
var vaultToken = config["Vault:Token"] ?? "root";
|
||||
|
||||
IAuthMethodInfo authMethod = new TokenAuthMethodInfo(vaultToken);
|
||||
var vaultClientSettings = new VaultClientSettings(vaultAddress, authMethod);
|
||||
IVaultClient vaultClient = new VaultClient(vaultClientSettings);
|
||||
services.AddSingleton<IVaultClient>(vaultClient);
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"postgresdb": "Host=localhost;Port=56235;Database=postgresdb;Username=postgres;Password=WdW+Q3wzq.Ssuzq6rT4A}_"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
Reference in New Issue
Block a user