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
@@ -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>
+10 -5
View File
@@ -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
{
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();
+5 -2
View File
@@ -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"
}
}
+6 -5
View File
@@ -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": "*"
}
+39 -26
View File
@@ -8,57 +8,57 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit**
- [x] Solution + Web API project (`net10.0`), packages restored (00-CORE §5.4)
- [x] Folder structure per 00-CORE §5.3
- [x] `ErpDbContext` + Npgsql wired; `InitialCreate` migration **created and applied** (2026-07-10, 8 master-data tables). `/health``Healthy`.
- [x] Serilog, JWT, Swagger, HealthChecks, ProblemDetails in `Program.cs` (JWT bearer *validated*; endpoints not yet `[Authorize]`-gated — see §6 auth note)
- [x] Serilog, JWT, Swagger, HealthChecks, ProblemDetails in `Program.cs` JWT bearer now validates **RS256** tokens from the external **AuthHex IdP** (issuer `AuthHex` / audience `AuthHexClient` / static RSA public key). v1 endpoints `[Authorize]`-gated via the `ErpAccess` door policy (§6); `/health`, `/api/meta`, Swagger stay anonymous.
- [x] `IUnitOfWork` + `UnitOfWork` (transaction boundary)
- [x] Generic repository base + interfaces
- [x] `ICurrentUser` (audit stamp from token `sub`)
- [x] `ICurrentUser` (audit stamp from token identity claim `nameid`/`sub`) — with AuthHex the actor comes from the `UserId` GUID → local shadow user (`nameid` injected by the §6 provisioning step)
- [x] ProblemDetails middleware + domain exception → `code` mapping (System/Errors; full §7 catalog added to `ErrorCodes`)
## 1. Master Data
> Code complete for all items below (2026-07-09). **Live smoke test PASSED against Postgres (2026-07-10):** create/get/list/update/status/reorder/uom-conversions across all 5 controllers; ETag round-trip 200 / stale→412 / missing→428; SKU_DUPLICATE→400; bad reference→422; missing-field→400 ValidationProblemDetails; category `?tree=true` nesting; `pageSize=9999` clamped to 200; deactivate via PATCH status→204. Still `[~]` (not `[x]`) for **one** reason: the **security gate** (00-CORE §8) — the foundational auth control (02-SECURITY B.1) and the audit trail (B.3, the AR-01 compensating control) land in §6. Flip to `[x]` once §6 auth+audit are wired.
> Code complete for all items below (2026-07-09). **Live smoke test PASSED against Postgres (2026-07-10):** create/get/list/update/status/reorder/uom-conversions across all 5 controllers; ETag round-trip 200 / stale→412 / missing→428; SKU_DUPLICATE→400; bad reference→422; missing-field→400 ValidationProblemDetails; category `?tree=true` nesting; `pageSize=9999` clamped to 200; deactivate via PATCH status→204. **Flipped `[x]` on 2026-07-14** — the §6 security gate (00-CORE §8: auth control 02-SECURITY B.1 + audit trail B.3) is now met (AuthHex RS256 validation + `[Authorize]` door policy + shadow-user provisioning, and the audit trail). The dated smoke-test notes in §2–§5 that reference a pending "§6 gate" are historical.
- [x] Item: entity + config + enums (ItemType, TrackingMode; EntityStatus added) — `xmin`/RowVersion concurrency token (Npgsql), unique SKU
- [~] Item: repository (generic) + service + controller (CRUD, narrow DTOs, ETag/If-Match→412, SKU_DUPLICATE, reference validation) — server-controlled fields excluded (02-SECURITY C.1)
- [~] UOM + UOM conversions (`GET/POST /uoms`, `PUT /items/{id}/uom-conversions` full-replace upsert)
- [~] Category (hierarchy, `GET /categories?tree=true` nested build, parent-exists validation)
- [~] Vendor (CRUD, ETag/If-Match, unique code, deactivate via `PATCH /vendors/{id}/status`)
- [~] Warehouse + Bin (`/warehouses`, nested `/warehouses/{id}/bins`, bin code unique per warehouse)
- [~] Item reorder settings (`PUT /items/{id}/reorder` full-replace upsert, warehouse-exists validation)
- [x] Item: repository (generic) + service + controller (CRUD, narrow DTOs, ETag/If-Match→412, SKU_DUPLICATE, reference validation) — server-controlled fields excluded (02-SECURITY C.1)
- [x] UOM + UOM conversions (`GET/POST /uoms`, `PUT /items/{id}/uom-conversions` full-replace upsert)
- [x] Category (hierarchy, `GET /categories?tree=true` nested build, parent-exists validation)
- [x] Vendor (CRUD, ETag/If-Match, unique code, deactivate via `PATCH /vendors/{id}/status`)
- [x] Warehouse + Bin (`/warehouses`, nested `/warehouses/{id}/bins`, bin code unique per warehouse)
- [x] Item reorder settings (`PUT /items/{id}/reorder` full-replace upsert, warehouse-exists validation)
## 2. Procurement
> Requisition/RFQ/PO implemented 2026-07-10. **Live smoke test PASSED** (docNo `PR/RFQ/PO-2026-#####` gap-controlled + incrementing, requestedBy/createdBy = seeded system user, RFQ comparison matrix, duplicate quotation→409, PO auto-approve + server-computed totals matching the spec example `112100/20178/132278`, edit-while-open with recomputed totals + ETag 200/412, PO_NOT_EDITABLE→409 on cancelled, cancel→200, bad reference→422). Same `[~]` reason as §1: the §6 security gate (auth + audit) is not yet wired.
- [~] Requisition (+ lines) + submit (`POST /requisitions`, `/{id}/submit`, list, get)
- [~] RFQ + quotations + comparison (`POST /rfqs`, `/{id}/quotations` [one per vendor], `GET /{id}/comparison` matrix)
- [~] Purchase Order: create (auto-approve, `approvalRequired` flag), edit-while-open (If-Match), approve (no-op), cancel
- [~] Purchase Return (outbound movement, reason code) — `POST /purchase-returns` auto-posts an outbound FIFO consume via shared `StockMutator`; mandatory Return-context reason (`REASON_CODE_REQUIRED`→400, wrong context→422), references the GRN line for traceability, over-return→`409 STOCK_NEGATIVE_BLOCKED`. Verified. (Cumulative return-vs-received cap still relies on the stock-availability guard.)
- [x] Requisition (+ lines) + submit (`POST /requisitions`, `/{id}/submit`, list, get)
- [x] RFQ + quotations + comparison (`POST /rfqs`, `/{id}/quotations` [one per vendor], `GET /{id}/comparison` matrix)
- [x] Purchase Order: create (auto-approve, `approvalRequired` flag), edit-while-open (If-Match), approve (no-op), cancel
- [x] Purchase Return (outbound movement, reason code) — `POST /purchase-returns` auto-posts an outbound FIFO consume via shared `StockMutator`; mandatory Return-context reason (`REASON_CODE_REQUIRED`→400, wrong context→422), references the GRN line for traceability, over-return→`409 STOCK_NEGATIVE_BLOCKED`. Verified. (Cumulative return-vs-received cap still relies on the stock-availability guard.)
> **Deviation (recorded):** `VendorQuotation` is modelled as header + `VendorQuotationLine` (per-item pricing) to satisfy the API contract (docs/11 §3.2); docs/10 Part C.2's scalar `VENDOR_QUOTATION(unit_price, lead_days)` with no item ref cannot represent it. Update the ER model doc to match.
> **RFQ `vendorIds`** are validated for existence but not persisted (no RFQ↔vendor link in the model); quotations reference vendors directly.
## 3. Goods Receipt
> Implemented + **smoke test PASSED** 2026-07-13 (see §4 note for the shared stock verification). Same `[~]` reason as §1/§2: the §6 auth+audit gate.
- [~] GRN create (against PO / direct), over-receipt tolerance — `unitCost` **PO-derived server-side** (client `999` verified ignored → PO price used, 02-SECURITY C.3); direct receipt requires `vendorId` + entered cost (AR-04); over-receipt → `422 OVER_RECEIPT_TOLERANCE` (verified at open-qty boundary); batch created/reused per (item, batchNo). Serial capture deferred.
- [~] GRN confirm → FIFO layer + ledger + PO `qtyReceived` (single UoW txn) — verified: layers+ledger posted, running balance, PO → PartiallyReceived/FullyReceived, UOM→base conversion (10 Box-12 → 120 base @10). **Idempotent** re-confirm verified (no double-post). Note: `Idempotency-Key` accepted but idempotency is resource-state based (already-Confirmed replays existing result); a keyed idempotency store is deferred.
- [~] Inspection hold release / reject — Release fully verified (OnHold excluded from `available`, then released). Reject removes on-hand + posts a reversing ledger entry; formal link to a Purchase Return is deferred (§3.4).
- [x] GRN create (against PO / direct), over-receipt tolerance — `unitCost` **PO-derived server-side** (client `999` verified ignored → PO price used, 02-SECURITY C.3); direct receipt requires `vendorId` + entered cost (AR-04); over-receipt → `422 OVER_RECEIPT_TOLERANCE` (verified at open-qty boundary); batch created/reused per (item, batchNo). Serial capture deferred.
- [x] GRN confirm → FIFO layer + ledger + PO `qtyReceived` (single UoW txn) — verified: layers+ledger posted, running balance, PO → PartiallyReceived/FullyReceived, UOM→base conversion (10 Box-12 → 120 base @10). **Idempotent** re-confirm verified (no double-post). Note: `Idempotency-Key` accepted but idempotency is resource-state based (already-Confirmed replays existing result); a keyed idempotency store is deferred.
- [x] Inspection hold release / reject — Release fully verified (OnHold excluded from `available`, then released). Reject removes on-hand + posts a reversing ledger entry; formal link to a Purchase Return is deferred (§3.4).
## 4. Stock Core
> Implemented + **live smoke test PASSED** 2026-07-13: receive→confirm creates FIFO layers + inbound ledger (qtyBase/unitCost/value/runningBalance correct), on-hand/valuation/ledger queries correct, OnHold excluded from `available`, UOM→base conversion applied. Same `[~]` gate (§6 auth+audit).
- [x] StockLayer + StockLedger entities/config — ledger **append-only at the app level** (never updated/deleted); DB-role `UPDATE`/`DELETE` revoke is deferred hardening (02-SECURITY B.3). Layers keyed per item **per warehouse**, base-UOM qty + unit cost; ledger polymorphic source (`sourceDocType`/`sourceDocId`), time-series indexes.
- [~] `FifoCostingService` — inbound layer + ledger posting + valuation **and oldest-first consume with row lock** (`SELECT … FOR UPDATE`, on-hold/expired exclusion, negative-stock block) all implemented + verified 2026-07-13 via §5. Blended cost on multi-layer consume verified (700@10 + 100@12 → 10.25).
- [~] Stock enquiry (onHand / available / onHold / inTransit) — onHand/available/onHold **and inTransit** now live + verified (inTransit = outstanding InTransit-transfer qty out of this warehouse). `reserved` stays a 0 stub until Sales.
- [x] `FifoCostingService` — inbound layer + ledger posting + valuation **and oldest-first consume with row lock** (`SELECT … FOR UPDATE`, on-hold/expired exclusion, negative-stock block) all implemented + verified 2026-07-13 via §5. Blended cost on multi-layer consume verified (700@10 + 100@12 → 10.25).
- [x] Stock enquiry (onHand / available / onHold / inTransit) — onHand/available/onHold **and inTransit** now live + verified (inTransit = outstanding InTransit-transfer qty out of this warehouse). `reserved` stays a 0 stub until Sales.
- [x] Ledger query · Valuation query — `GET /stock/ledger` (item/warehouse/from/to + paging), `GET /stock/valuation` (open layers, totals, FIFO); both verified.
## 5. Stock Transactions
> **All four §5 features implemented + live smoke test PASSED 2026-07-13** (Adjustment, Transfer, Count, Reorder alerts); Purchase Return (§3.4) also done this pass. Same `[~]` gate (§6 auth+audit).
- [~] Transfer: create → dispatch (consume source FIFO row-locked → In-Transit) → receive (dest layer, **cost-preserving**) — verified: dispatch reduces source onHand + reports inTransit; receive creates dest layer at inherited cost (300 @12 → dest value 3600); `destWarehouseId != srcWarehouseId`→422; dispatch short→`409 STOCK_NEGATIVE_BLOCKED`. Partial receive supported (`QtyReceived`).
- [~] Adjustment (auto-post, mandatory reason code) — **highest-risk feature (02-SECURITY C.5)**: `REASON_CODE_REQUIRED`→400, non-Adjustment reason→422, decrease FIFO-consumes (blended cost, negative→409), increase creates a layer at last cost. All verified.
- [~] Count (cycle/full → enter counts → variance → post) — create snapshots systemQty (immutable), enter sets counted+variance→Counted, post emits a variance `StockAdjustment` via shared `StockMutator` + closes the count. Verified: variance 15 (post→on-hand 485) and +10 increase; re-post→409.
- [~] Reorder alerts (query) + suggest requisition — `GET /stock/reorder-alerts` (available ≤ ROP, computed on read) + `POST …/{itemId}/requisition` (draft PR at suggested qty). Verified.
syte
- [x] Transfer: create → dispatch (consume source FIFO row-locked → In-Transit) → receive (dest layer, **cost-preserving**) — verified: dispatch reduces source onHand + reports inTransit; receive creates dest layer at inherited cost (300 @12 → dest value 3600); `destWarehouseId != srcWarehouseId`→422; dispatch short→`409 STOCK_NEGATIVE_BLOCKED`. Partial receive supported (`QtyReceived`).
- [x] Adjustment (auto-post, mandatory reason code) — **highest-risk feature (02-SECURITY C.5)**: `REASON_CODE_REQUIRED`→400, non-Adjustment reason→422, decrease FIFO-consumes (blended cost, negative→409), increase creates a layer at last cost. All verified.
- [x] Count (cycle/full → enter counts → variance → post) — create snapshots systemQty (immutable), enter sets counted+variance→Counted, post emits a variance `StockAdjustment` via shared `StockMutator` + closes the count. Verified: variance 15 (post→on-hand 485) and +10 increase; re-post→409.
- [x] Reorder alerts (query) + suggest requisition — `GET /stock/reorder-alerts` (available ≤ ROP, computed on read) + `POST …/{itemId}/requisition` (draft PR at suggested qty). Verified.
## 6. Cross-cutting
> **Status:** audit trail, doc numbering, reason codes, JournalEntryStub, negative-stock block all **done**. The **one** remaining item is authentication. **Auth-enforcement gap (open):** JWT bearer *validation* is wired, but no token issuer exists yet and controllers are **not** `[Authorize]`-gated, so endpoints are currently open. This is the AR-01/NFR-03 control surface — gate all v1 endpoints (fallback authorization policy) in the same change as `POST /auth/login`, then re-run the 02-SECURITY B.1 checklist and flip §1–§5 items to `[x]`. (The AR-01 **audit** compensating control is now in place.)
> **Status: COMPLETE.** Audit trail, doc numbering, reason codes, JournalEntryStub, negative-stock block, and now **authentication** (external AuthHex IdP integration) are all done + verified. The §6 security gate (NFR-03 auth + AR-01 audit) is met — **§1–§5 flipped `[~]`→`[x]`** (2026-07-14). FEFO pick-ordering is the only intentional deferral.
- [x] Audit log on every mutation (who/when/old→new) — `AuditLog` (jsonb `changeSet`), written by an `ErpDbContext.SaveChanges` override (`AuditScribe`): Create captures the field set, Update captures **only changed fields as {old,new}**, Delete captures the prior row; PK/RowVersion excluded; ledger/layer/seq/self/journal excluded. Actor from `ICurrentUser` (system=1 until auth). Read via `GET /audit-logs`. **Verified** (Item create+update old→new; StockAdjustment create). This is the **AR-01 compensating control** (02-SECURITY B.3) — app-level append-only; DB-role UPDATE/DELETE revoke still deferred.
- [x] Document numbering sequences (per type, per year) — `NumberSequence` + `NumberSequenceService` (atomic `INSERT … ON CONFLICT … RETURNING` inside the doc's UoW txn; gap-controlled). Verified issuing + incrementing PR/RFQ/PO.
- [~] Auth: simple in-app login → JWT (`POST /auth/login`) — foundation only: `User` table + seeded `system` user (id 1) exist and `ICurrentUser.AuditUserId` stamps docs; login endpoint + `[Authorize]` still pending.
- [x] Auth: **external AuthHex IdP integration** (2026-07-14) — ERPCore is a resource server. `JwtAuthExtensions` validates **RS256** against AuthHex's RSA **public** key (config `Auth:RsaPublicKeyXml``RsaSecurityKey`; `MapInboundClaims=false`), issuer `AuthHex`, audience `AuthHexClient` (no JWKS → static key). `[Authorize(ErpAccess)]` on `ApiControllerBase` gates every v1 endpoint; the `ErpAccess` policy `RequireAuthenticatedUser` + optional `RequireClaim(UserTypeCode/RoleCode)` from `Auth:RequiredUserTypeCode`/`RequiredRoleCode` (empty ⇒ any valid ERP token — AuthHex is ERP-dedicated). **Shadow-user JIT provisioning:** `ShadowUserClaimsTransformation` (`IClaimsTransformation`) maps the token's `UserId` **GUID** → a local `users` row (`auth_user_id` unique; Username/DisplayName = `NIC`), idempotent, and injects the local `long` id as `nameid` so `ICurrentUser.AuditUserId` resolves the real actor. Migration `AddAuthUserId`. **Verified:** no token→401; `/health`,`/api/meta`,Swagger anonymous; valid token→200; shadow user provisioned (User 2, Username=NIC, AuthUserId=GUID); item Create **audited as the shadow user (id 2, not system)**; re-request reuses the same user; door gate → **403** on UserType mismatch, **200** on match.
- [x] JournalEntryStub emitted per stock movement (data only) — `JournalEntryStub` written in `FifoCostingService.PostLedgerAsync` for every ledger entry (In → Dr Inventory `1300` / Cr Clearing `2100`; Out reverses; amount = movement value). Placeholder accounts until a chart of accounts exists. Read via `GET /journal-entries`. **Verified** (GRN In 700, ADJ Out 70).
- [x] Negative-stock policy enforcement (default block) — enforced in `FifoCostingService.ConsumeAsync``409 STOCK_NEGATIVE_BLOCKED` (verified). Per-item override still a config stub.
- [~] FEFO picking for perishables; block expired / on-hold issue — **issue-block done + verified** (`ONHOLD_NOT_ISSUABLE`, `EXPIRED_BATCH_BLOCKED`; on-hold/expired layers excluded from consume). FEFO *pick ordering* (oldest-expiry first) not yet built.
@@ -119,4 +119,17 @@ syte
- **JournalEntryStub (FR-STK-13):** emitted for every ledger entry in `FifoCostingService.PostLedgerAsync` (In → Dr `1300`/Cr `2100`; Out reverses; amount = value). Placeholder GL accounts.
- Read endpoints (auditor role, beyond documented §11): `GET /audit-logs` (entityType/entityId/userId/from/to), `GET /journal-entries` (sourceDocType/sourceDocId). `AuditService`. Migration `AddAuditAndJournal` (2 tables, jsonb) applied.
- **Verified against Postgres:** item Create logged full field set (userId 1); item Update logged only `Name` + `UpdatedAt` as `{old,new}`; GRN confirm → journal In Dr1300/Cr2100 amount 700; adjustment decrease → journal Out Dr2100/Cr1300 amount 70; StockAdjustment Create audited.
- **Only auth remains for Phase 1.** Everything else in §6 is done. Auth (`POST /auth/login` + global `[Authorize]`) is intentionally deferred per request; wiring it is what flips §1–§5 `[~]``[x]`. FEFO pick-ordering left as a documented deferral (would conflict with FIFO-costing integrity without a physical/cost layer split); negative-stock stays the resolved global block (open-decision #2).
- **Only auth remains for Phase 1.** Everything else in §6 is done. Auth (~~`POST /auth/login` + global `[Authorize]`~~ — **superseded 2026-07-14**, now external **AuthHex** IdP integration; see the next entry) is intentionally deferred per request; wiring it is what flips §1–§5 `[~]``[x]`. FEFO pick-ordering left as a documented deferral (would conflict with FIFO-costing integrity without a physical/cost layer split); negative-stock stays the resolved global block (open-decision #2).
### 2026-07-14 — Auth architecture change: external AuthHex IdP (docs-only pass)
- **Plan changed:** auth is no longer a local `POST /auth/login` inside ERPCore. A **separate AuthHex IdP** (runs on `:5011`, source at `c:\Users\WAS\Documents\Developments\ERP_Auth_Service\`) owns login/registration/recovery; ERPCore becomes a **resource server** that only validates AuthHex tokens. Updated `docs/10-BACKEND-PHASE1.md` (header, A.4 auth/audit-actor, A.5 DI, B.2.3, FR-X-01, NFR-03, C.7 `USER`, C.9, B.8.4 decision #10) and this file. **No code changed this pass.**
- **Decisions (confirmed):** (1) identity = **shadow-user JIT provisioning** — add `auth_user_id` GUID (unique) to `users`, keep all `long` FKs; (2) authorization = **door-gate to an ERP `UserType`/`Role`**, per-endpoint RBAC still deferred; (3) scope = **docs only** now, code integration is a follow-up.
- **Confirmed AuthHex facts:** RS256 (RSA 2048; ERPCore needs the static **public** key — no JWKS), issuer `AuthHex`, audience `AuthHexClient`, lifetime 1000 min prod / 60 min dev; claims `UserId`(GUID)/`UserTypeCode`/`RoleCode`/`NIC`/`jti`/`iat` (no `sub`/`nameid`); BCrypt password hashing; login `POST /api/loginUser {identifier,password}`.
- **Open blockers (resolve before the code phase):** exact ERP `UserTypeCode`/`RoleCode` for the door gate (must exist in AuthHex); RSA public-key distribution + rotation process (no JWKS); shadow-user `Username`/`DisplayName` source (token has no name); secrets hygiene in AuthHex config (private key/SMTP/DB in plaintext); `docs/11 §2.0` still documents `/auth/login` (now AuthHex-owned) — recommend a follow-up annotation.
### 2026-07-14 (2) — Auth code integration: AuthHex resource server (§6 COMPLETE → §1–§5 flipped `[x]`)
- **RS256 validation:** `JwtAuthExtensions` rewritten — `RsaSecurityKey` from `Auth:RsaPublicKeyXml` (AuthHex public key), `ValidIssuer=AuthHex`, `ValidAudience=AuthHexClient`, `ValidAlgorithms=[RS256]`, `MapInboundClaims=false` (keeps `UserId`/`UserTypeCode`/`RoleCode` verbatim). `appsettings.json` `Jwt``Auth` (public key + issuer/audience + `RequiredUserTypeCode`/`RequiredRoleCode`); removed the HS256 dev signing key.
- **Door policy** `ErpAccess`: `RequireAuthenticatedUser` + optional `RequireClaim(UserTypeCode/RoleCode)` when configured (AuthHex is ERP-dedicated → empty default = any valid token). `[Authorize(ErpAccess)]` on `ApiControllerBase`; `MetaController`/health/Swagger stay anonymous.
- **Shadow-user provisioning:** `ShadowUserClaimsTransformation` (`IClaimsTransformation`, scoped) maps token `UserId` GUID → local `users` row (`auth_user_id` unique, Username/DisplayName=`NIC`), idempotent w/ race-safe re-read, injects local `long` id as `nameid`. `User.AuthUserId` (Guid?) + `AuthHexClaims` consts + migration `AddAuthUserId`.
- **Verified (minted AuthHex-shaped RS256 token, signed with AuthHex's real private key):** no token→401; `/health`,`/api/meta`,Swagger→200 anon; valid token→200; POST item→201 **audited as shadow user id 2** (Username=NIC, AuthUserId=GUID), not system; repeat request reuses user (1 provision); door gate `RequiredUserTypeCode=WAREHOUSE` → ERP-type token **403**, WAREHOUSE-type token **200**. Build clean; migration applied.
- **§6 COMPLETE.** Only intentional deferral left in Phase 1: FEFO pick-ordering (§6, `[~]`). Follow-ups: set the real ERP `Auth:RequiredUserTypeCode`/`RoleCode` for production; secure the RSA key rotation process.