33 KiB
Backend — PROGRESS (Phase 1: Inventory & Supply Chain)
Legend: [ ] not started · [~] in progress · [x] done
Spec: docs/10-BACKEND-PHASE1.md (model + rules) · docs/11-BACKEND-PHASE1.md (API)
Convention: docs/01-DOC-GUIDE.md §6. Update this file in the same commit as the code. When ticking [x], append a short note + any deviation.
0. Bootstrap
- Solution + Web API project (
net10.0), packages restored (00-CORE §5.4) - Folder structure per 00-CORE §5.3
ErpDbContext+ Npgsql wired;InitialCreatemigration created and applied (2026-07-10, 8 master-data tables)./health→Healthy.- Serilog, JWT, Swagger, HealthChecks, ProblemDetails in
Program.cs— JWT bearer now validates RS256 tokens from the external AuthHex IdP (issuerAuthHex/ audienceAuthHexClient/ static RSA public key). v1 endpoints[Authorize]-gated via theErpAccessdoor policy (§6);/health,/api/meta, Swagger stay anonymous. IUnitOfWork+UnitOfWork(transaction boundary)- Generic repository base + interfaces
ICurrentUser(audit stamp from token identity claimnameid/sub) — with AuthHex the actor comes from theUserIdGUID → local shadow user (nameidinjected by the §6 provisioning step)- ProblemDetails middleware + domain exception →
codemapping (System/Errors; full §7 catalog added toErrorCodes)
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=truenesting;pageSize=9999clamped 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.
- 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-conversionsfull-replace upsert) - Category (hierarchy,
GET /categories?tree=truenested 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}/reorderfull-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 example112100/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}/comparisonmatrix) - Purchase Order: create (auto-approve,
approvalRequiredflag), edit-while-open (If-Match), approve (no-op), cancel - Purchase Return (outbound movement, reason code) —
POST /purchase-returnsauto-posts an outbound FIFO consume via sharedStockMutator; 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):
VendorQuotationis modelled as header +VendorQuotationLine(per-item pricing) to satisfy the API contract (docs/11 §3.2); docs/10 Part C.2's scalarVENDOR_QUOTATION(unit_price, lead_days)with no item ref cannot represent it. Update the ER model doc to match. RFQvendorIdsare 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 —
unitCostPO-derived server-side (client999verified ignored → PO price used, 02-SECURITY C.3); direct receipt requiresvendorId+ 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-Keyaccepted 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).
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).
- StockLayer + StockLedger entities/config — ledger append-only at the app level (never updated/deleted); DB-role
UPDATE/DELETErevoke 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).
reservedstays a 0 stub until Sales. - 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
StockAdjustmentvia sharedStockMutator+ 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.
6. Cross-cutting
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.
- Audit log on every mutation (who/when/old→new) —
AuditLog(jsonbchangeSet), written by anErpDbContext.SaveChangesoverride (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 fromICurrentUser(system=1 until auth). Read viaGET /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. - Document numbering sequences (per type, per year) —
NumberSequence+NumberSequenceService(atomicINSERT … ON CONFLICT … RETURNINGinside the doc's UoW txn; gap-controlled). Verified issuing + incrementing PR/RFQ/PO. - Auth: external AuthHex IdP integration (2026-07-14) — ERPCore is a resource server.
JwtAuthExtensionsvalidates RS256 against AuthHex's RSA public key (configAuth:RsaPublicKeyXml→RsaSecurityKey;MapInboundClaims=false), issuerAuthHex, audienceAuthHexClient(no JWKS → static key).[Authorize(ErpAccess)]onApiControllerBasegates every v1 endpoint; theErpAccesspolicyRequireAuthenticatedUser+ optionalRequireClaim(UserTypeCode/RoleCode)fromAuth:RequiredUserTypeCode/RequiredRoleCode(empty ⇒ any valid ERP token — AuthHex is ERP-dedicated). Shadow-user JIT provisioning:ShadowUserClaimsTransformation(IClaimsTransformation) maps the token'sUserIdGUID → a localusersrow (auth_user_idunique; Username/DisplayName =NIC), idempotent, and injects the localintid asnameidsoICurrentUser.AuditUserIdresolves the real actor. MigrationAddAuthUserId. 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. - Auth proxy:
AuthControllerfronting AuthHex (2026-07-16) — the frontend no longer calls AuthHex directly;Controllers/AuthController.cs+Services/Auth/{AuthUserService,AuthRecoveryService,AuthAltService}proxy all 24 AuthHex functions (register/login/OTP-login/refresh/sessions/status/lock/change-password/verify-password/logout/update/2FA×5/recovery×4/alt×3) viaInfra/Auth/AuthHex/{IAuthHexClient,AuthHexClient}(AuthHex:BaseUrlconfig). Sessions delivered as httpOnly Secureerp_at/erp_rtcookies +XSRF-TOKENdouble-submit cookie (Infra/Auth/AuthCookieWriter.cs, 02-SECURITY §B.2);ValidateCsrfAttributeguards every mutating action; the JWT bearer handler now also acceptserp_atas a fallback (JwtAuthExtensions'sOnMessageReceived) so every other v1 controller keeps working unchanged. Seedocs/11-BACKEND-PHASE1.md §2.0for the full route table anddocs/02-SECURITY.mdAR-07/AR-08 for the two carried-over exposures (anonymousgetUserDetails/LogoutUser, no rate limiting yet). Not done this pass: CORS (needed once a browser frontend calls these endpoints cross-origin), rate limiting, and the frontend wiring itself (lib/api/auth.ts+ login/OTP/reset pages) — all deliberately deferred follow-ups. - JournalEntryStub emitted per stock movement (data only) —
JournalEntryStubwritten inFifoCostingService.PostLedgerAsyncfor every ledger entry (In → Dr Inventory1300/ Cr Clearing2100; Out reverses; amount = movement value). Placeholder accounts until a chart of accounts exists. Read viaGET /journal-entries. Verified (GRN In 700, ADJ Out 70). - 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. - Reason codes (FR-X-04) —
ReasonCodeentity +GET/POST /reason-codes; standard set (docs/10 §B.8.3) seeded idempotently at startup (DataSeeder). Verified.
Deferred (Phase 2+ — do NOT build now, hooks only)
- Vendor invoice + three-way match
- Reservation/allocation fulfilment
- RBAC policy enforcement + approval workflow activation
Done
2026-07-09 — Bootstrap verified + Master Data (§1) implemented
- Bootstrap scaffolding confirmed against 00-CORE §5 (solution, packages,
Program.cswiring, UoW, generic repo,ICurrentUser, ProblemDetails handler). Added enum-as-string JSON (JsonStringEnumConverter) and registered the 5 master-data services. - Domain: 3 enums (
ItemType,TrackingMode,EntityStatus) + 8 entities (Category, Uom, UomConversion, Item, ItemReorder, Vendor, Warehouse, Bin) with oneIEntityTypeConfigurationeach; FKsRestrict(masters deactivate, not cascade-delete), unique indexes (SKU, vendor/warehouse code, uom name, bin code per-warehouse), decimal precision,xminconcurrency token on Item/Vendor. - API: 5 controllers, lowercase routes matching
docs/11 §2exactly (verified via generatedswagger.json). ETag/If-Match (428 if missing, 412 on mismatch), narrow request DTOs (no over-posting),PagedResponse<T>list envelope (§1.4),PageQuerywith pageSize clamp ≤200 (B.6). - Migration
InitialCreategenerated (xmincorrectly produces no DDL — uses the PG system column). - Verified:
dotnet buildclean (0 warn/0 err); app boots (Now listening… Application started);/api/meta200;swagger.json200 with all 13 master-data paths; DI resolves controller→service→repo→DbContext (a DB-backed call reaches Npgsql, failing only on creds). - Blocked / follow-ups: (1) auth enforcement + audit trail — §6 (the remaining security gate for
[x]); (2) noDELETEmaster endpoints —MASTER_IN_USEcode reserved until transaction tables exist (deactivate-only per FR-MD-08); (3) minor: bad-enum bind error leaks the CLR type name indetail(02-SECURITY B.5) — fine in Dev, tidy before prod.
2026-07-10 — Migration applied + live smoke test PASSED
dotnet ef database updateappliedInitialCreateto local Postgres;/health→Healthy.- End-to-end curl smoke across all 5 controllers — all green: warehouse/bin create+list; uom create; category + child +
?tree=truenesting; vendor create + PUT (If-Match 200 / stale 412 / missing 428); item create (201, referencing category/uom/vendor) + GET (ETag header) + list/filterq+pageSize=9999→clamped 200; reorder PUT; uom-conversions PUT; full item PUT with fresh ETag→200; PATCH status Inactive→204; duplicate SKU→400SKU_DUPLICATE; bad reference→422; missing required→400 ValidationProblemDetails; bad enum→400. Concurrency token (xmin) confirmed incrementing per mutation. - Note: local dev DB now holds smoke-test rows (warehouse/bin/uom×2/category×2/vendor/item, item left Inactive). Reset any time with
dotnet ef database drop -f && dotnet ef database update.
2026-07-10 — Procurement (§2, minus returns) + cross-cutting foundations
- Cross-cutting:
Userentity (+ seededsystemuser viaHasData),ICurrentUser.AuditUserId(numeric actor, system fallback),NumberSequence+NumberSequenceService(atomic per-type/per-year doc numbers issued inside the UoW txn). - Procurement: enums (RequisitionStatus, RfqStatus, PurchaseOrderStatus); 8 entities (Requisition/Line, Rfq/Line, VendorQuotation/Line, PurchaseOrder/Line) + configs; DTOs; 3 services; 3 controllers (
/requisitions,/rfqs,/purchase-orders). PO carries thexminETag token; totals computed server-side; create/edit wrapped inIUnitOfWork.ExecuteInTransactionAsyncso the reserved doc number rolls back with the doc. - Migration
AddProcurementgenerated + applied (10 tables incl. users/number_sequences; system-user seed; POxminemits no DDL). - Verified: build clean; app boots; full procurement smoke green (see §2 note) — requisition→submit, RFQ→quotation→comparison, PO create/get/edit/approve/cancel, numbering increment, error paths 409/412/422.
- Deferred/next: Purchase Return (needs GRN+stock), then §3 GRN, §4 Stock Core (FIFO/ledger), §5 stock transactions. Auth/audit (§6) still the gate for flipping §1/§2 to
[x]. - Housekeeping: an empty user-created migration
20260709124415_initialsits between InitialCreate and AddProcurement (applied, harmless no-op; emits a cosmetic CS8981 lowercase-name warning).
2026-07-13 — Stock Core (§4) + Goods Receipt (§3, minus purchase return)
- Entities: Batch, Serial, StockLayer (FIFO), StockLedger (append-only), Grn/GrnLine + configs; enums Direction, HoldStatus, GrnStatus. DbSets + migration
AddStockAndGrn(6 tables) applied. - Services:
FifoCostingService(Services/Stock — inbound layer, ledger post, on-hand, valuation),StockService(enquiry/ledger/valuation),GrnService(create with PO-derived cost + over-receipt + batch resolve + UOM→base; confirm inExecuteInTransactionAsync; release/reject). Controllers/stock,/grns. - Verified against Postgres: PO→GRN receive (client cost ignored, PO price used) → confirm → 2 FIFO layers + 2 ledger rows (running balance) → PO FullyReceived; on-hand 5000 / valuation 62500 / ledger In; idempotent re-confirm (on-hand stayed 5000); over-receipt
422at the open-qty boundary, exact-fill201; batch receive OnHold → excluded fromavailable→ release → available; UOM conversion 10×Box-12 → 120 base units @10 (value 1200). - Deferred (next): §5 stock transactions — Transfer (dispatch/receive, in-transit, cost-preserving), Adjustment (auto-post + reason code), Count, Reorder alerts — which is where the FIFO oldest-first consume + row lock lands; then Purchase Return (§3.4) and GRN reject→return linkage.
ReasonCode(§6) needed for adjustments/returns. Auth/audit (§6) remains the[x]gate.
2026-07-13 (2) — Stock Transactions (§5: Adjustment, Transfer, Reorder) + FIFO consume + Reason codes
- FIFO consume engine in
FifoCostingService.ConsumeAsync: oldest-first, row-locked viaSELECT … FOR UPDATE(raw SQL, no LINQ composed on top so the lock reaches PG;{batchId}::bigintcast avoids a 42P18 null-param error), excludes on-hold + expired layers, throwsSTOCK_NEGATIVE_BLOCKED/ONHOLD_NOT_ISSUABLE/EXPIRED_BATCH_BLOCKED, returns consumed segments for cost-preserving moves. - Entities: ReasonCode, StockAdjustment/Line, StockTransfer/Line (+
UnitCost/QtyReceivedextension on the transfer line for cost-preservation) + configs; enums TransferStatus, AdjustmentStatus, ReasonContext. MigrationAddStockTransactions(5 tables) applied. - Services/controllers:
ReasonCodeService(/reason-codes, startup seed viaDataSeeder),AdjustmentService(/stock-adjustments),TransferService(/stock-transferscreate/dispatch/receive),ReorderService(/stock/reorder-alerts+ suggest-requisition).StockService.GetOnHandAsyncnow computes real inTransit. - Verified against Postgres: adjustment decrease FIFO-consume with blended cost 10.25 across two layers, negative→409,
REASON_CODE_REQUIRED→400, wrong-context reason→422, increase-at-last-cost; transfer create→dispatch (source onHand↓, inTransit↑, consumedLayers)→receive (dest layer cost-preserved @12, value 3600), dest==src→422, dispatch-short→409; reorder alerts list + draft-PR suggestion; reason codes seeded (5 Adjustment + 4 Return). - Deferred (next): §5 Count (create snapshot → enter counts → post variance via the Adjustment engine), Purchase Return (§3.4, + GRN reject→return linkage), FEFO pick ordering,
JournalEntryStub. Auth/audit (§6) remains the[x]gate.
2026-07-13 (3) — Count (§5.6) + Purchase Return (§3.4) + shared StockMutator
StockMutator(Services/Stock): shared signed-delta poster (negative → FIFO consume; positive → layer at last cost) + ledger, run inside the caller's txn. Adjustment/Count-post/Return all delegate to it → one code path for stock-affecting postings.- Refactored
AdjustmentServiceontoStockMutatorand added an intermediateSaveChangesso the header id is flushed before ledger posting — fixes a latent bug where new-in-txn documents wrotesourceDocId=0(verified:ADJledger nowsourceDocId=5). Also fixedledgerRefs:[0]by mapping ledger ids after commit (Count + Return). - Entities: StockCount/Line (+ CountType, CountStatus), PurchaseReturn/Line (+ ReturnStatus) + configs. Migration
AddCountsAndReturns(4 tables) applied. - Services/controllers:
CountService(/stock-countscreate/enter/post),PurchaseReturnService(/purchase-returns). - Verified against Postgres: count snapshot 500 → counted 485 → post variance −15 (on-hand→485, adjustmentId+ledgerRefs), positive variance +10, re-post→409; return 100 outbound (on-hand→385),
REASON_CODE_REQUIRED→400, non-Return reason→422, over-return→409STOCK_NEGATIVE_BLOCKED. - §5 is now complete. Remaining Phase-1 backend: §6 (auth/login +
[Authorize], audit trail,JournalEntryStub, negative-stock per-item override, FEFO pick ordering). Auth/audit is still the gate for flipping §1–§5[~]→[x].
2026-07-13 (4) — §6 audit trail + JournalEntryStub (auth deferred by request)
- Audit trail (FR-X-02):
AuditLog(jsonbchangeSet) written by anErpDbContext.SaveChanges/Asyncoverride viaAuditScribe— captures before save (accurate old→new), writes rows after inserts get their keys. Create = field set, Update = only changed fields{old,new}, Delete = prior row; excludes PK/RowVersion and the ledger/layer/sequence/journal/self tables. Actor fromICurrentUser(system=1).ErpDbContextnow takesICurrentUser(design-time migration still works via DI). - JournalEntryStub (FR-STK-13): emitted for every ledger entry in
FifoCostingService.PostLedgerAsync(In → Dr1300/Cr2100; 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. MigrationAddAuditAndJournal(2 tables, jsonb) applied. - Verified against Postgres: item Create logged full field set (userId 1); item Update logged only
Name+UpdatedAtas{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 (
— superseded 2026-07-14, now external AuthHex IdP integration; see the next entry) is intentionally deferred per request; wiring it is what flips §1–§5POST /auth/login+ global[Authorize][~]→[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/logininside ERPCore. A separate AuthHex IdP (runs on:5011, source atc:\Users\WAS\Documents\Developments\ERP_Auth_Service\) owns login/registration/recovery; ERPCore becomes a resource server that only validates AuthHex tokens. Updateddocs/10-BACKEND-PHASE1.md(header, A.4 auth/audit-actor, A.5 DI, B.2.3, FR-X-01, NFR-03, C.7USER, 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_idGUID (unique) tousers, keep allintFKs; (2) authorization = door-gate to an ERPUserType/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, audienceAuthHexClient, lifetime 1000 min prod / 60 min dev; claimsUserId(GUID)/UserTypeCode/RoleCode/NIC/jti/iat(nosub/nameid); BCrypt password hashing; loginPOST /api/loginUser {identifier,password}. - Open blockers (resolve before the code phase): exact ERP
UserTypeCode/RoleCodefor the door gate (must exist in AuthHex); RSA public-key distribution + rotation process (no JWKS); shadow-userUsername/DisplayNamesource (token has no name); secrets hygiene in AuthHex config (private key/SMTP/DB in plaintext);docs/11 §2.0still 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:
JwtAuthExtensionsrewritten —RsaSecurityKeyfromAuth:RsaPublicKeyXml(AuthHex public key),ValidIssuer=AuthHex,ValidAudience=AuthHexClient,ValidAlgorithms=[RS256],MapInboundClaims=false(keepsUserId/UserTypeCode/RoleCodeverbatim).appsettings.jsonJwt→Auth(public key + issuer/audience +RequiredUserTypeCode/RequiredRoleCode); removed the HS256 dev signing key. - Door policy
ErpAccess:RequireAuthenticatedUser+ optionalRequireClaim(UserTypeCode/RoleCode)when configured (AuthHex is ERP-dedicated → empty default = any valid token).[Authorize(ErpAccess)]onApiControllerBase;MetaController/health/Swagger stay anonymous. - Shadow-user provisioning:
ShadowUserClaimsTransformation(IClaimsTransformation, scoped) maps tokenUserIdGUID → localusersrow (auth_user_idunique, Username/DisplayName=NIC), idempotent w/ race-safe re-read, injects localintid asnameid.User.AuthUserId(Guid?) +AuthHexClaimsconsts + migrationAddAuthUserId. - 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 gateRequiredUserTypeCode=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 ERPAuth:RequiredUserTypeCode/RoleCodefor production; secure the RSA key rotation process.
2026-07-16 — Auth proxy: AuthController fronting AuthHex (frontend no longer calls AuthHex directly)
- Architecture reversal: the 2026-07-14 "resource-server-only, no login proxy" decision (docs/10, docs/11 §2.0) is reversed — the frontend was found to have zero existing AuthHex integration (login/OTP/reset screens were UI-only mocks with no network calls), so this was greenfield backend work, not a migration.
docs/10-BACKEND-PHASE1.md(header, A.4, NFR-03) anddocs/11-BACKEND-PHASE1.md §2.0updated in place;docs/02-SECURITY.mdgained AR-07/AR-08 and ticked 3 of 5 B.2 boxes. Infra/Auth/AuthHex/—IAuthHexClient/AuthHexClient(typedHttpClient,AuthHex:BaseUrlconfig =http://localhost:5011dev), one C# method per AuthHexfunctionName, hides the{functionName,payload,reference}/{statusCode,success,message,data}dispatcher envelope entirely; upstream failures →DomainException(AUTH_UPSTREAM_ERROR/AUTH_SERVICE_UNAVAILABLE).Dtos/Auth/*— REST-shaped request/response DTOs per function (not a functionName-dispatcher passthrough), matching ERPCore's existing DTO-at-boundary convention. Session-issuing responses (AuthSessionResponse,OtpLoginVerifiedResponse) deliberately omit tokens.Services/Auth/{AuthUserService,AuthRecoveryService,AuthAltService}— orchestrateIAuthHexClientcalls;AuthSessionResult/OtpAuthSessionResult(Services/Auth/AuthSessionResult.cs) carry tokens from service → controller only, never serialized.Controllers/AuthController.cs—api/v1/auth/*, 24 actions (seedocs/11 §2.0table); inheritsControllerBasedirectly (notApiControllerBase) since most actions need[AllowAnonymous]and its ETag/If-Match handling doesn't apply here.- Cookie/CSRF (
Infra/Auth/AuthCookieWriter.cs,ValidateCsrfAttribute.cs,JwtAuthExtensions.cs):erp_at(Path/),erp_rt(Path/api/v1/auth/refresh-token, scoped so it's only sent to the refresh call),XSRF-TOKEN(non-httpOnly) — allHttpOnly(except CSRF)/Secure/SameSite=Strict.ValidateCsrfAttributedouble-submit-checksX-XSRF-TOKENagainst the cookie on every mutating action, exempting Bearer-header callers.JwtAuthExtensions'sOnMessageReceivedfalls back to theerp_atcookie when noAuthorizationheader is present — every existing v1 controller keeps working unchanged under either auth mode. - Verified:
dotnet buildclean (0 warn/0 err) after two passes — first pass hitCS0051(a public interface/constructor can't expose aninternalparameter type) onIAuthHexClientand its supportingAuthHex*wire types, fixed by making thempublic; second pass caught a cookie-path bug (erp_rt'sPathwas written as/api/auth/refresh-token, not matching the actual/api/v1/auth/refresh-tokenroute — the browser would never have sent the cookie back on refresh) before it shipped. - Not done this pass (tracked as follow-ups, not silently skipped): CORS (needed once a browser frontend calls cross-origin —
docs/02-SECURITY.md §B.2left unticked), rate limiting on the anonymous endpoints (docs/02-SECURITY.mdAR-08), and the frontend wiring itself (lib/api/auth.ts+ wiringapp/login/**'s mock pages to these endpoints) — deliberately out of scope per user decision. - Live-verified against the running AuthHex instance (
:5011) and ERPCore (:5224, dev):register→200withSet-Cookie: erp_at(httpOnly/Secure/Strict/maxAge=3600) +erp_rt(httpOnly/Secure/Strict/Path=/api/v1/auth/refresh-token/30d) +XSRF-TOKEN(Secure/Strict, JS-readable), body carriesuser+expiresInonly, no tokens; theerp_atcookie alone (zeroAuthorizationheader) authenticatedGET /api/v1/items— confirms theOnMessageReceivedcookie fallback works for every existing v1 controller unchanged;GET /api/v1/auth/sessions(protected, cookie-authenticated) →200; mutatingPOST /api/v1/auth/change-passwordwithoutX-XSRF-TOKEN→403 CSRF_TOKEN_MISMATCH, with the matching header →204+ all three cookies cleared, exactly as designed. - Found + fixed a real bug during live testing:
AuthHexClienttrusted the envelope'ssuccessflag alone; AuthHex was observed returningHTTP 500with"success": true, "data": nullon a business failure (invalid-credentials login), which slipped past the!envelope.Successcheck and null-derefed insideAuthUserService.ToSessionResult(NullReferenceException→ bare unhandled500, nocode). FixedAuthHexClient.CallAsyncto also fail on!httpResponse.IsSuccessStatusCoderegardless ofenvelope.Success, plus addedresult is nullguards inToSessionResult/ToOtpSessionResult/AuthAltService.VerifyOtpAsyncas defense-in-depth. Re-verified: the same invalid-credentials case now returns a clean500 AUTH_UPSTREAM_ERRORProblemDetails instead of crashing. - Login/refresh/logout left unverified live —
loginUsercurrently fails with{"statusCode":500,"success":true,"message":"Invalid credentials","data":null}on AuthHex even immediately after a fresh registration with the exact same credentials, confirmed by calling AuthHex's/api/userdirectly with the identical payload (bypassing ERPCore entirely) — this is a bug in AuthHex's ownloginUser/password-verification path, not in this proxy. Blocked on an AuthHex-side fix; re-run the register→login→cookie→refresh→logout pass once that's resolved.