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:
@@ -1,5 +1,7 @@
|
||||
using ERPCore.Common.Http;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
@@ -8,10 +10,12 @@ namespace ERPCore.Controllers;
|
||||
/// Base for the v1 API controllers. Centralises ETag / If-Match handling
|
||||
/// (docs/11-BACKEND-PHASE1.md §1.6) so concurrency behaviour is uniform.
|
||||
/// Each controller declares its own explicit lowercase <c>[Route]</c> to match
|
||||
/// the API contract paths (docs/11 §1.1).
|
||||
/// the API contract paths (docs/11 §1.1). Every v1 endpoint requires a valid
|
||||
/// AuthHex token satisfying the ERP door policy (docs/10 A.4).
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Produces("application/json")]
|
||||
[Authorize(JwtAuthExtensions.ErpAccessPolicy)]
|
||||
public abstract class ApiControllerBase : ControllerBase
|
||||
{
|
||||
/// <summary>Parse a mandatory <c>If-Match</c> header, or 428 if absent/malformed.</summary>
|
||||
|
||||
@@ -3,18 +3,23 @@ using ERPCore.Domain.Enums;
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Application user (FR-X-01). In Phase 1 authentication/RBAC are deferred; this
|
||||
/// table exists so mutations can be stamped with an audit actor and documents can
|
||||
/// carry a `createdBy`/`requestedBy` FK. A seeded <c>system</c> user (id 1) is the
|
||||
/// fallback actor until `/auth/login` lands (§6). Model: docs/10 Part C.7.
|
||||
/// Application user (FR-X-01) — a **local shadow/projection** of an AuthHex identity.
|
||||
/// The local <see cref="UserId"/> (long) is what every `createdBy`/`requestedBy`/
|
||||
/// audit/ledger FK references; <see cref="AuthUserId"/> maps it to the AuthHex
|
||||
/// <c>UserId</c> (GUID) and is JIT-provisioned on first authenticated request
|
||||
/// (docs/10 A.4/C.7). A seeded <c>system</c> user (id 1, null AuthUserId) is the
|
||||
/// fallback actor for unauthenticated/system operations. Model: docs/10 Part C.7.
|
||||
/// </summary>
|
||||
public class User
|
||||
{
|
||||
/// <summary>Seeded fallback actor used while auth is deferred.</summary>
|
||||
/// <summary>Seeded fallback actor for unauthenticated/system operations.</summary>
|
||||
public const long SystemUserId = 1;
|
||||
|
||||
public long UserId { get; set; }
|
||||
public string Username { get; set; } = string.Empty;
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||
|
||||
/// <summary>AuthHex identity (token <c>UserId</c> GUID); null for the seeded system user.</summary>
|
||||
public Guid? AuthUserId { get; set; }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
+2229
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();
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ using ERPCore.Services;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.Services.Stock;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.OpenApi;
|
||||
using Serilog;
|
||||
@@ -31,12 +32,14 @@ builder.Services.AddDbContext<ErpDbContext>(o =>
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.AddExceptionHandler<DomainExceptionHandler>();
|
||||
|
||||
// JWT bearer auth (RBAC deferred; identity used only for the audit stamp)
|
||||
// Auth: validate external AuthHex RS256 tokens + ERP door policy (docs/10 A.4)
|
||||
builder.Services.AddErpJwtAuth(builder.Configuration);
|
||||
|
||||
// Current-user (audit actor) derived from token `sub`
|
||||
// Current-user (audit actor). AuthHex has no sub/nameid → a claims transformation
|
||||
// JIT-provisions a local shadow user and injects the local `long` id as `nameid`.
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddScoped<ICurrentUser, CurrentUser>();
|
||||
builder.Services.AddScoped<IClaimsTransformation, ShadowUserClaimsTransformation>();
|
||||
|
||||
// Unit of work + generic repository base
|
||||
builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();
|
||||
|
||||
@@ -7,8 +7,5 @@
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Host=localhost;Port=5432;Database=ERPCore;Username=postgres;Password=root"
|
||||
},
|
||||
"Jwt": {
|
||||
"SigningKey": "dev-only-signing-key-please-change-me-0123456789"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,11 +8,12 @@
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Host=localhost;Port=5432;Database=ERPCore;Username=CHANGE_ME;Password=CHANGE_ME"
|
||||
},
|
||||
"Jwt": {
|
||||
"Issuer": "ERPCore",
|
||||
"Audience": "ERPCore.Clients",
|
||||
"SigningKey": "CHANGE_ME_DEV_ONLY_32+_CHARS",
|
||||
"AccessTokenMinutes": 120
|
||||
"Auth": {
|
||||
"Issuer": "AuthHex",
|
||||
"Audience": "AuthHexClient",
|
||||
"RsaPublicKeyXml": "<RSAKeyValue><Modulus>1LlNkMBQNdpXJiDal7XMxkG/3ad+YBsMCuY9JD/abHMzniFXtQlovjfbeaaHJ0v1kvSo9731CJ0YC1qhPU5rPQwZwxOWZ9BOBZlMDghONdjOH/HyCUbb5Z18ibqc0QenFSnEYz+jkVZiayj8DV/+VUe+eKzpQTlU6aWtHvlbwfuXaDu+QvFlpLJ7/m8na+0s2nYhLX8Wfi4C/2AoNaYhFkIwYhMMGoaSHuIoQ5R6181Rh0gKvYopRW+IpTD5RV8bXV3AM6zOcoisOifBYROHwA5ZZpoHXuTvHYmPWW8kL8PKme7BwBldPi8KrJUroRE+WXA87aAA5Wtt1oxePcXvhQ==</Modulus><Exponent>AQAB</Exponent></RSAKeyValue>",
|
||||
"RequiredUserTypeCode": "",
|
||||
"RequiredRoleCode": ""
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user