Add migration to add auth_user_id column to users table

- Introduced a new column 'auth_user_id' of type UUID to the 'users' table.
- Updated existing user data to set 'auth_user_id' to null for UserId 1.
- Created a unique index on 'auth_user_id' to enforce uniqueness.
- Implemented rollback functionality to remove the column and index if needed.
This commit is contained in:
2026-07-14 16:30:45 +05:30
parent 22f86451e3
commit 67150425e4
15 changed files with 2506 additions and 69 deletions
@@ -0,0 +1,17 @@
namespace ERPCore.Infra.Auth;
/// <summary>
/// Claim type names emitted by the AuthHex IdP (see its <c>JwtTokenHelper</c>).
/// AuthHex uses no standard <c>sub</c>/<c>nameid</c>; identity is the custom
/// <see cref="UserId"/> (GUID). These are read verbatim (JWT bearer is configured
/// with <c>MapInboundClaims = false</c>).
/// </summary>
public static class AuthHexClaims
{
public const string UserId = "UserId";
public const string UserTypeId = "UserTypeId";
public const string UserTypeCode = "UserTypeCode";
public const string RoleId = "RoleId";
public const string RoleCode = "RoleCode";
public const string Nic = "NIC";
}
@@ -1,25 +1,42 @@
using System.Text;
using System.Security.Cryptography;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
namespace ERPCore.Infra.Auth;
/// <summary>
/// JWT bearer wiring. Authentication only — RBAC/authorization policies are
/// deferred for Phase 1; the validated principal exists solely so that
/// <see cref="ICurrentUser"/> can stamp the audit actor.
/// Auth wiring for ERPCore as a **resource server** for the external AuthHex IdP
/// (docs/10 A.4). Validates AuthHex's **RS256** tokens against AuthHex's RSA public
/// key (configured statically — no JWKS), issuer <c>AuthHex</c>, audience
/// <c>AuthHexClient</c>. A single door policy (<see cref="ErpAccessPolicy"/>) admits
/// only ERP <c>UserType</c>/<c>Role</c> holders when those codes are configured;
/// per-endpoint RBAC stays deferred. Identity → audit actor is resolved by
/// <see cref="ShadowUserClaimsTransformation"/> + <see cref="ICurrentUser"/>.
/// </summary>
public static class JwtAuthExtensions
{
/// <summary>Authorization policy applied to every v1 controller (via ApiControllerBase).</summary>
public const string ErpAccessPolicy = "ErpAccess";
public static IServiceCollection AddErpJwtAuth(this IServiceCollection services, IConfiguration config)
{
var issuer = config["Jwt:Issuer"];
var audience = config["Jwt:Audience"];
var signingKey = config["Jwt:SigningKey"] ?? string.Empty;
var issuer = config["Auth:Issuer"];
var audience = config["Auth:Audience"];
var publicKeyXml = config["Auth:RsaPublicKeyXml"]
?? throw new InvalidOperationException("Auth:RsaPublicKeyXml (AuthHex RSA public key) is not configured.");
var requiredUserType = config["Auth:RequiredUserTypeCode"];
var requiredRole = config["Auth:RequiredRoleCode"];
// AuthHex publishes no JWKS; the RSA public key is configured statically.
var rsa = RSA.Create();
rsa.FromXmlString(publicKeyXml);
var signingKey = new RsaSecurityKey(rsa);
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
// Keep AuthHex's claim names verbatim (UserId, UserTypeCode, RoleCode …).
options.MapInboundClaims = false;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
@@ -28,12 +45,26 @@ public static class JwtAuthExtensions
ValidAudience = audience,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(signingKey)),
IssuerSigningKey = signingKey,
ValidAlgorithms = new[] { SecurityAlgorithms.RsaSha256 },
ClockSkew = TimeSpan.FromSeconds(30)
};
});
services.AddAuthorization();
services.AddAuthorization(options =>
{
options.AddPolicy(ErpAccessPolicy, policy =>
{
policy.RequireAuthenticatedUser();
// Door gate: only enforce a UserType/Role when configured (AuthHex is a
// shared IdP). Empty config = require a valid ERP token only.
if (!string.IsNullOrWhiteSpace(requiredUserType))
policy.RequireClaim(AuthHexClaims.UserTypeCode, requiredUserType);
if (!string.IsNullOrWhiteSpace(requiredRole))
policy.RequireClaim(AuthHexClaims.RoleCode, requiredRole);
});
});
return services;
}
}
@@ -0,0 +1,76 @@
using System.Security.Claims;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Infra.Persistence;
using Microsoft.AspNetCore.Authentication;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Infra.Auth;
/// <summary>
/// Maps an authenticated AuthHex principal to ERPCore's local identity (docs/10 A.4/A.5).
/// AuthHex tokens carry the user as a custom <c>UserId</c> (GUID) claim and no
/// <c>sub</c>/<c>nameid</c>. This transformation JIT-provisions a local shadow
/// <see cref="User"/> (keyed by <c>auth_user_id</c>) and injects the local
/// <c>long</c> id as <see cref="ClaimTypes.NameIdentifier"/>, so
/// <see cref="ICurrentUser"/>/<c>AuditUserId</c> resolve the real user unchanged.
/// Idempotent — <see cref="IClaimsTransformation"/> may run several times per request.
/// </summary>
public sealed class ShadowUserClaimsTransformation : IClaimsTransformation
{
private readonly ErpDbContext _db;
public ShadowUserClaimsTransformation(ErpDbContext db) => _db = db;
public async Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
{
if (principal.Identity is not ClaimsIdentity identity || !identity.IsAuthenticated)
return principal;
if (identity.HasClaim(c => c.Type == ClaimTypes.NameIdentifier))
return principal; // already resolved this request
var raw = principal.FindFirstValue(AuthHexClaims.UserId);
if (!Guid.TryParse(raw, out var authUserId))
return principal; // no mappable identity → CurrentUser falls back to system
var nic = principal.FindFirstValue(AuthHexClaims.Nic);
var localId = await ResolveOrProvisionAsync(authUserId, nic);
identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, localId.ToString()));
return principal;
}
private async Task<long> ResolveOrProvisionAsync(Guid authUserId, string? nic)
{
var existing = await _db.Users.AsNoTracking()
.Where(u => u.AuthUserId == authUserId)
.Select(u => u.UserId)
.FirstOrDefaultAsync();
if (existing != 0) return existing;
var label = string.IsNullOrWhiteSpace(nic) ? authUserId.ToString() : nic.Trim();
var user = new User
{
AuthUserId = authUserId,
Username = label,
DisplayName = string.IsNullOrWhiteSpace(nic) ? "AuthHex User" : nic.Trim(),
Status = EntityStatus.Active
};
try
{
_db.Users.Add(user);
await _db.SaveChangesAsync();
return user.UserId;
}
catch (DbUpdateException)
{
// Lost a race (unique auth_user_id) — the row now exists; re-read it.
_db.Entry(user).State = EntityState.Detached;
return await _db.Users.AsNoTracking()
.Where(u => u.AuthUserId == authUserId)
.Select(u => u.UserId)
.FirstAsync();
}
}
}
@@ -18,6 +18,11 @@ public sealed class UserConfiguration : IEntityTypeConfiguration<User>
builder.Property(u => u.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired();
// Maps the local shadow user to its AuthHex identity (unique; NULL for the
// system user — Postgres allows multiple NULLs in a unique index).
builder.Property(u => u.AuthUserId).HasColumnName("auth_user_id");
builder.HasIndex(u => u.AuthUserId).IsUnique();
// Seeded fallback audit actor while auth is deferred (§6).
builder.HasData(new User
{
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,46 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Infra.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddAuthUserId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "auth_user_id",
table: "users",
type: "uuid",
nullable: true);
migrationBuilder.UpdateData(
table: "users",
keyColumn: "UserId",
keyValue: 1L,
column: "auth_user_id",
value: null);
migrationBuilder.CreateIndex(
name: "IX_users_auth_user_id",
table: "users",
column: "auth_user_id",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_users_auth_user_id",
table: "users");
migrationBuilder.DropColumn(
name: "auth_user_id",
table: "users");
}
}
}
@@ -1301,6 +1301,10 @@ namespace ERPCore.Infra.Persistence.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("UserId"));
b.Property<Guid?>("AuthUserId")
.HasColumnType("uuid")
.HasColumnName("auth_user_id");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(200)
@@ -1318,6 +1322,9 @@ namespace ERPCore.Infra.Persistence.Migrations
b.HasKey("UserId");
b.HasIndex("AuthUserId")
.IsUnique();
b.HasIndex("Username")
.IsUnique();