OPC # 0001: Extract Clarity into standalone repo

This commit is contained in:
amadzarak
2026-04-25 17:26:35 -04:00
commit 60821e219c
65 changed files with 10203 additions and 0 deletions
+34
View File
@@ -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>
+6
View File
@@ -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);
}
}
+37
View File
@@ -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));
}
+12
View File
@@ -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;
}
}
+38
View File
@@ -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;
}
}
}
+32
View File
@@ -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;
}
}
+38
View File
@@ -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);
}
}
}
}
+179
View File
@@ -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"
}
}
}
}
+42
View File
@@ -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;
}
}
}
+26
View File
@@ -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}_"
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}