backend phase 6: nurse verification & credentials (mocked vendors)
The trust engine. New `verif` schema (5 tables) + a data-driven verification pipeline: steps are rows (6 seeded step-types), not a code enum. - nurse_verifications.status is the single source of verification truth; nurse_profiles.is_verified is flipped ONLY inside the finalize transaction (VerificationAggregator: tracked verification + tracked profile -> one commit) and reversed on suspension/expiry — no in-between state. - is_automated snapshotted onto each step at submit; steps seeded from active required step-types; automated runs (identity-KYC, Shahkar, IBAN ownership) find their step by code. - users.national_id populated only on identity-KYC pass; Shahkar + IBAN owner compare against it (money-mule guard); shared-SIM -> shared_sim support alert. - Documents are metadata-only behind signed URLs; credential_number encrypted and never serialized; public trust badge exposes credential TYPES, not numbers; holder-name cross-checked against the verified identity before recording. - Admin-triggered credential-expiry scan reverts lapsed steps, re-gates bookability, raises a verification_expired alert + verification_expiry_prompt notification (scheduled cron deferred; config key verification_expiry_scan_cadence_hours). Three new mock vendor seams (IShahkarVerifier / IIdentityKycProvider / ICredentialVerifier) behind DI; reuses b3 IBankAccountOwnershipVerifier and b0 IObjectStorage/IFieldEncryptor. 15 endpoints across 4 controllers. Two migrations (tables + step-type seed). 154 tests pass, zero new warnings. Contract dev/contracts/domains/verification.md + swagger snapshot refreshed; handoff/report/mocks-registry updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+23
@@ -0,0 +1,23 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Domain.Entities.Verification;
|
||||
|
||||
namespace Baya.Infrastructure.CrossCutting.Seams;
|
||||
|
||||
/// <summary>
|
||||
/// Default <see cref="ICredentialVerifier"/> — and the mock. MoH پروانه صلاحیت حرفهای and INO membership
|
||||
/// have <b>no public B2B API</b>, so verification is a manual admin review of the uploaded document against
|
||||
/// the official portal: every call returns <see cref="CredentialVerificationStatus.RequiresManualReview"/>
|
||||
/// with <c>verification_method = manual</c>. When an <c>api</c>/<c>portal</c> source becomes available, a
|
||||
/// real implementation replaces this registration and starts returning
|
||||
/// <see cref="CredentialVerificationStatus.Verified"/>/<see cref="CredentialVerificationStatus.Failed"/>
|
||||
/// with the matching method — callers are unchanged.
|
||||
/// </summary>
|
||||
public sealed class MockCredentialVerifier : ICredentialVerifier
|
||||
{
|
||||
public Task<CredentialVerificationResult> VerifyAsync(string credentialType, string? credentialNumber, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new CredentialVerificationResult(
|
||||
CredentialVerificationStatus.RequiresManualReview,
|
||||
VerificationMethods.Manual,
|
||||
ExternalResponseJson: null));
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
#nullable enable
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Baya.Infrastructure.CrossCutting.Seams;
|
||||
|
||||
/// <summary>
|
||||
/// Mock <see cref="IIdentityKycProvider"/>: a deterministic fake identity + liveness check — no real
|
||||
/// OCR/liveness call. Passes every well-formed national id except the configured
|
||||
/// <see cref="IdentityKycOptions.FailNationalId"/>. On pass it reports a matched name and populates the
|
||||
/// verified identity. A real Iranian e-KYC vendor (Finnotech / U-ID / Jibbit / Farashensa / Verify /
|
||||
/// Kavoshak) swaps in by a registration change — callers are unchanged.
|
||||
/// </summary>
|
||||
public sealed class MockIdentityKycProvider(IOptions<SeamOptions> options) : IIdentityKycProvider
|
||||
{
|
||||
private readonly IdentityKycOptions _options = options.Value.IdentityKyc;
|
||||
|
||||
public Task<IdentityKycResult> VerifyAsync(string nationalId, string? livenessPayload, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var id = (nationalId ?? string.Empty).Trim();
|
||||
var vendorRef = $"MOCK-KYC-{Token(id)}";
|
||||
var wellFormed = id.Length == 10 && id.All(char.IsDigit);
|
||||
|
||||
if (!wellFormed || string.Equals(id, _options.FailNationalId, StringComparison.Ordinal))
|
||||
{
|
||||
return Task.FromResult(new IdentityKycResult(
|
||||
Passed: false,
|
||||
MatchedName: null,
|
||||
VendorRef: vendorRef,
|
||||
ExternalResponseJson: Payload("fail", id, livenessPayload, passed: false),
|
||||
FailureReason: "Identity could not be verified against the civil registry."));
|
||||
}
|
||||
|
||||
return Task.FromResult(new IdentityKycResult(
|
||||
Passed: true,
|
||||
MatchedName: _options.MatchedName,
|
||||
VendorRef: vendorRef,
|
||||
ExternalResponseJson: Payload("pass", id, livenessPayload, passed: true),
|
||||
FailureReason: null));
|
||||
}
|
||||
|
||||
private static string Payload(string outcome, string nationalId, string? liveness, bool passed)
|
||||
=> JsonSerializer.Serialize(new
|
||||
{
|
||||
provider = "mock_identity_kyc",
|
||||
outcome,
|
||||
passed,
|
||||
national_id_present = !string.IsNullOrEmpty(nationalId),
|
||||
liveness_present = !string.IsNullOrEmpty(liveness)
|
||||
});
|
||||
|
||||
private static string Token(string value)
|
||||
=> Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value)))[..12];
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
#nullable enable
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Baya.Infrastructure.CrossCutting.Seams;
|
||||
|
||||
/// <summary>
|
||||
/// Mock <see cref="IShahkarVerifier"/>: a deterministic fake شاهکار phone↔national-id inquiry — no real
|
||||
/// Shahkar/KYC call. Matches every pair except the configured <see cref="ShahkarOptions.SharedSimPhone"/>
|
||||
/// (returns the explicit shared-SIM failure state) and <see cref="ShahkarOptions.MismatchNationalId"/>
|
||||
/// (returns a plain mismatch). The vendor ref is derived from the inputs, so re-running is idempotent. A
|
||||
/// real Finnotech/KYC client swaps in by a registration change — callers are unchanged.
|
||||
/// </summary>
|
||||
public sealed class MockShahkarVerifier(IOptions<SeamOptions> options) : IShahkarVerifier
|
||||
{
|
||||
private readonly ShahkarOptions _options = options.Value.Shahkar;
|
||||
|
||||
public Task<ShahkarMatchResult> MatchAsync(string? phoneNumber, string? nationalId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var phone = (phoneNumber ?? string.Empty).Trim();
|
||||
var vendorRef = $"MOCK-SHAHKAR-{Token(phone + "|" + (nationalId ?? string.Empty))}";
|
||||
|
||||
if (string.Equals(phone, _options.SharedSimPhone, StringComparison.Ordinal))
|
||||
{
|
||||
var json = Payload("shared_sim", phone, nationalId, matched: false);
|
||||
return Task.FromResult(new ShahkarMatchResult(
|
||||
Matched: false,
|
||||
IsSharedSim: true,
|
||||
VendorRef: vendorRef,
|
||||
ExternalResponseJson: json,
|
||||
FailureReason: "This SIM appears to be registered to a family member. Please use a SIM registered in your own name."));
|
||||
}
|
||||
|
||||
if (string.Equals(nationalId, _options.MismatchNationalId, StringComparison.Ordinal))
|
||||
{
|
||||
var json = Payload("mismatch", phone, nationalId, matched: false);
|
||||
return Task.FromResult(new ShahkarMatchResult(
|
||||
Matched: false,
|
||||
IsSharedSim: false,
|
||||
VendorRef: vendorRef,
|
||||
ExternalResponseJson: json,
|
||||
FailureReason: "The phone number is not registered to your national ID."));
|
||||
}
|
||||
|
||||
return Task.FromResult(new ShahkarMatchResult(
|
||||
Matched: true,
|
||||
IsSharedSim: false,
|
||||
VendorRef: vendorRef,
|
||||
ExternalResponseJson: Payload("match", phone, nationalId, matched: true),
|
||||
FailureReason: null));
|
||||
}
|
||||
|
||||
private static string Payload(string outcome, string phone, string? nationalId, bool matched)
|
||||
=> JsonSerializer.Serialize(new
|
||||
{
|
||||
provider = "mock_shahkar",
|
||||
outcome,
|
||||
matched,
|
||||
phone_last4 = phone.Length >= 4 ? phone[^4..] : phone,
|
||||
national_id_present = !string.IsNullOrEmpty(nationalId)
|
||||
});
|
||||
|
||||
private static string Token(string value)
|
||||
=> Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value)))[..12];
|
||||
}
|
||||
@@ -12,6 +12,38 @@ public sealed class SeamOptions
|
||||
public ObjectStorageOptions ObjectStorage { get; set; } = new();
|
||||
public BankOwnershipOptions BankOwnership { get; set; } = new();
|
||||
public GeocodingOptions Geocoding { get; set; } = new();
|
||||
public ShahkarOptions Shahkar { get; set; } = new();
|
||||
public IdentityKycOptions IdentityKyc { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tunes the mock <c>IShahkarVerifier</c> (phone↔national-id binding). A submitted phone equal to
|
||||
/// <see cref="SharedSimPhone"/> returns the explicit shared-SIM failure; a national id equal to
|
||||
/// <see cref="MismatchNationalId"/> returns a plain mismatch; every other pair matches. The real vendor
|
||||
/// implementation ignores these.
|
||||
/// </summary>
|
||||
public sealed class ShahkarOptions
|
||||
{
|
||||
/// <summary>The designated test phone that returns the shared-SIM failure state.</summary>
|
||||
public string SharedSimPhone { get; set; } = "09120000000";
|
||||
|
||||
/// <summary>The designated test national id that returns a plain phone↔national-id mismatch.</summary>
|
||||
public string MismatchNationalId { get; set; } = "1111111111";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tunes the mock <c>IIdentityKycProvider</c>. A national id equal to <see cref="FailNationalId"/> fails
|
||||
/// KYC; every other (well-formed) national id passes with <see cref="MatchedName"/>. The real e-KYC vendor
|
||||
/// implementation ignores these.
|
||||
/// </summary>
|
||||
public sealed class IdentityKycOptions
|
||||
{
|
||||
/// <summary>The designated test national id that fails identity KYC.</summary>
|
||||
public string FailNationalId { get; set; } = "0000000000";
|
||||
|
||||
/// <summary>The name the mock reports as matched on a passing KYC (informational; the authoritative
|
||||
/// identity name for credential cross-check comes from the users row).</summary>
|
||||
public string MatchedName { get; set; } = "Verified Nurse";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
+7
@@ -36,6 +36,13 @@ public static class ServiceCollectionExtension
|
||||
// centroid with no network call; a real Neshan/Google geocoding client replaces this registration.
|
||||
services.AddSingleton<IGeocoder, MockGeocoder>();
|
||||
|
||||
// Nurse-verification vendors (backend-phase-6). All three are deterministic mocks; a real Iranian
|
||||
// e-KYC vendor / Shahkar bridge / (future) MoH-INO portal swaps in by a registration change only —
|
||||
// no mock behaviour is baked into any handler call site.
|
||||
services.AddSingleton<IShahkarVerifier, MockShahkarVerifier>();
|
||||
services.AddSingleton<IIdentityKycProvider, MockIdentityKycProvider>();
|
||||
services.AddSingleton<ICredentialVerifier, MockCredentialVerifier>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user