1413 lines
59 KiB
C#
1413 lines
59 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Globalization;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.RegularExpressions;
|
|
using Lskj.CommandKernel;
|
|
using Newtonsoft.Json;
|
|
using Newtonsoft.Json.Linq;
|
|
|
|
namespace Lskj.AgentBridge
|
|
{
|
|
public sealed class WorkflowUatExecutableIdentity
|
|
{
|
|
public string FileName { get; set; }
|
|
public string Version { get; set; }
|
|
public string Sha256 { get; set; }
|
|
public string SignerThumbprint { get; set; }
|
|
public bool RequiresElevation { get; set; }
|
|
public bool BridgeOnly { get; set; }
|
|
public bool DatabaseDirectAccess { get; set; }
|
|
public string SessionSource { get; set; }
|
|
}
|
|
|
|
public sealed class WorkflowUatCaseAuthorization
|
|
{
|
|
public WorkflowUatCaseAuthorization()
|
|
{
|
|
AllowedCommands = new List<string>();
|
|
}
|
|
|
|
public string CaseCode { get; set; }
|
|
public string ExpectedCommandName { get; set; }
|
|
public IList<string> AllowedCommands { get; private set; }
|
|
public string TokenSha256 { get; set; }
|
|
}
|
|
|
|
public sealed class WorkflowUatWorkflowAuthorization
|
|
{
|
|
public WorkflowUatWorkflowAuthorization()
|
|
{
|
|
Cases = new Dictionary<string, WorkflowUatCaseAuthorization>(
|
|
StringComparer.Ordinal);
|
|
}
|
|
|
|
public string Workflow { get; set; }
|
|
public string ModuleCode { get; set; }
|
|
public string AdapterId { get; set; }
|
|
public string AdapterVersion { get; set; }
|
|
public IDictionary<string, WorkflowUatCaseAuthorization> Cases { get; private set; }
|
|
}
|
|
|
|
public sealed class WorkflowUatAuthorizationEvidence
|
|
{
|
|
public WorkflowUatAuthorizationEvidence()
|
|
{
|
|
Workflows = new Dictionary<string, WorkflowUatWorkflowAuthorization>(
|
|
StringComparer.Ordinal);
|
|
}
|
|
|
|
public string SourceSha256 { get; set; }
|
|
public string ContentSha256 { get; set; }
|
|
public string CertificateThumbprint { get; set; }
|
|
public string AuthorizationId { get; set; }
|
|
public string CustomerId { get; set; }
|
|
public string EnvironmentId { get; set; }
|
|
public string AccountBook { get; set; }
|
|
public string SubSystemId { get; set; }
|
|
public string UserId { get; set; }
|
|
public string UserName { get; set; }
|
|
public string DatabaseScopeFingerprint { get; set; }
|
|
public string RuntimeConfigurationSha256 { get; set; }
|
|
public string CustomerProfileSha256 { get; set; }
|
|
public string RolloutPolicySha256 { get; set; }
|
|
public string SourceCommit { get; set; }
|
|
public string PackageSha256 { get; set; }
|
|
public WorkflowUatExecutableIdentity ErpExecutable { get; set; }
|
|
public WorkflowUatExecutableIdentity RuntimeCli { get; set; }
|
|
public WorkflowUatExecutableIdentity VerifierCli { get; set; }
|
|
public DateTime IssuedAtUtc { get; set; }
|
|
public DateTime ExpiresAtUtc { get; set; }
|
|
public string ApprovedBy { get; set; }
|
|
public IDictionary<string, WorkflowUatWorkflowAuthorization> Workflows { get; private set; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies the short-lived, customer-signed authorization used only to
|
|
/// collect the Windows integration evidence that production registration
|
|
/// later consumes. It is intentionally a different evidence type from a
|
|
/// production readiness/acceptance manifest.
|
|
/// </summary>
|
|
public static class WorkflowUatAuthorizationVerifier
|
|
{
|
|
public const int MaximumBytes = 512 * 1024;
|
|
public const int MaximumPlanAttemptsPerCase = 6;
|
|
public const int MaximumExecuteAttemptsPerCase = 3;
|
|
public static readonly TimeSpan MaximumLifetime = TimeSpan.FromHours(24);
|
|
|
|
private static readonly Regex SafeIdentifier = new Regex(
|
|
"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$",
|
|
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
private static readonly Regex SafeModule = new Regex(
|
|
"^[A-Za-z0-9_.:-]{1,64}$",
|
|
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
private static readonly Regex SafeCode = new Regex(
|
|
"^[a-z0-9_.-]{1,128}$",
|
|
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
private static readonly Regex Hash = new Regex(
|
|
"^[a-f0-9]{64}$",
|
|
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
private static readonly Regex Commit = new Regex(
|
|
"^[a-f0-9]{40}$",
|
|
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
private static readonly Regex ProductVersion = new Regex(
|
|
"^[0-9]{1,4}\\.[0-9]{1,4}\\.[0-9]{1,4}$",
|
|
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
|
|
public static WorkflowUatAuthorizationEvidence VerifyFile(
|
|
string path,
|
|
IAcceptanceSignatureVerifier signatureVerifier,
|
|
DateTime nowUtc)
|
|
{
|
|
return VerifyFileCore(
|
|
path,
|
|
signatureVerifier,
|
|
nowUtc,
|
|
true);
|
|
}
|
|
|
|
public static WorkflowUatAuthorizationEvidence VerifyHistoricalFile(
|
|
string path,
|
|
IAcceptanceSignatureVerifier signatureVerifier)
|
|
{
|
|
return VerifyFileCore(
|
|
path,
|
|
signatureVerifier,
|
|
DateTime.MinValue,
|
|
false);
|
|
}
|
|
|
|
private static WorkflowUatAuthorizationEvidence VerifyFileCore(
|
|
string path,
|
|
IAcceptanceSignatureVerifier signatureVerifier,
|
|
DateTime nowUtc,
|
|
bool requireCurrentlyValid)
|
|
{
|
|
if (signatureVerifier == null)
|
|
throw new ArgumentNullException("signatureVerifier");
|
|
string sourceHash;
|
|
JObject root = LoadStrict(path, out sourceHash);
|
|
EnsureExact(root,
|
|
"schemaVersion", "contentSha256", "signatureAlgorithm",
|
|
"certificateThumbprint", "signatureBase64", "content");
|
|
if (root.Properties().Count() != 6
|
|
|| RequiredString(root, "schemaVersion", 1, 16) != "1.2"
|
|
|| RequiredString(root, "signatureAlgorithm", 1, 32)
|
|
!= "rsa-sha256")
|
|
throw Invalid("UAT 授权顶层结构、版本或签名算法无效。");
|
|
|
|
string contentHash = RequiredHash(root, "contentSha256");
|
|
string thumbprint = RequiredString(
|
|
root, "certificateThumbprint", 40, 64);
|
|
thumbprint = WindowsTrustedPeopleSignatureVerifier.NormalizeThumbprint(
|
|
thumbprint);
|
|
if (thumbprint == null)
|
|
throw Invalid("UAT 授权签名证书指纹无效。");
|
|
byte[] signature;
|
|
try
|
|
{
|
|
signature = Convert.FromBase64String(
|
|
RequiredString(root, "signatureBase64", 32, 4096));
|
|
}
|
|
catch
|
|
{
|
|
throw Invalid("UAT 授权签名不是有效 Base64。");
|
|
}
|
|
|
|
JObject content = RequiredObject(root, "content");
|
|
EnsureExact(content,
|
|
"packageType", "authorizationId", "customerId", "environmentId",
|
|
"environmentClass", "erpScope", "runtimeConfigurationSha256",
|
|
"customerProfileSha256", "rolloutPolicySha256", "sourceCommit",
|
|
"packageSha256", "erpExecutable", "runtimeCli",
|
|
"verifierCli", "safety",
|
|
"workflows", "issuedAtUtc", "expiresAtUtc", "approvedBy", "note");
|
|
if (content.Properties().Count() != 20
|
|
|| RequiredString(content, "packageType", 1, 64)
|
|
!= "workflow_write_uat_authorization"
|
|
|| RequiredString(content, "environmentClass", 1, 32)
|
|
!= "recoverable_uat")
|
|
throw Invalid("UAT 授权内容类型、环境类型或结构无效。");
|
|
|
|
string canonical = content.ToString(Formatting.None);
|
|
if (!FixedEquals(contentHash, Sha256(Encoding.UTF8.GetBytes(canonical)))
|
|
|| !signatureVerifier.Verify(
|
|
thumbprint,
|
|
Encoding.UTF8.GetBytes(canonical),
|
|
signature))
|
|
throw Invalid("UAT 授权内容哈希或 TrustedPeople 签名无效。");
|
|
|
|
DateTime issuedAt = RequiredUtc(content, "issuedAtUtc");
|
|
DateTime expiresAt = RequiredUtc(content, "expiresAtUtc");
|
|
if (expiresAt <= issuedAt
|
|
|| expiresAt - issuedAt > MaximumLifetime)
|
|
throw Invalid("UAT 授权签发时间、有效期或最长 24 小时限制无效。");
|
|
if (requireCurrentlyValid)
|
|
{
|
|
nowUtc = nowUtc.Kind == DateTimeKind.Utc
|
|
? nowUtc : nowUtc.ToUniversalTime();
|
|
if (issuedAt > nowUtc.AddMinutes(5)
|
|
|| expiresAt <= nowUtc)
|
|
throw Invalid("UAT 授权签发时间、有效期或最长 24 小时限制无效。");
|
|
}
|
|
|
|
JObject scope = RequiredObject(content, "erpScope");
|
|
EnsureExact(
|
|
scope,
|
|
"accountBook",
|
|
"subSystemId",
|
|
"userId",
|
|
"userName",
|
|
"databaseScopeFingerprint");
|
|
if (scope.Properties().Count() != 5)
|
|
throw Invalid("UAT ERP 作用域结构无效。");
|
|
|
|
JObject safety = RequiredObject(content, "safety");
|
|
EnsureExact(safety,
|
|
"databaseBackupVerified", "restoreProcedureVerified",
|
|
"nonProductionEnvironmentVerified",
|
|
"productionUseProhibited", "nativeConfirmationRequired",
|
|
"transactionAndAuditRequired", "maximumPlanAttemptsPerCase",
|
|
"maximumExecuteAttemptsPerCase");
|
|
if (safety.Properties().Count() != 8
|
|
|| !RequiredTrue(safety, "databaseBackupVerified")
|
|
|| !RequiredTrue(safety, "restoreProcedureVerified")
|
|
|| !RequiredTrue(safety, "nonProductionEnvironmentVerified")
|
|
|| !RequiredTrue(safety, "productionUseProhibited")
|
|
|| !RequiredTrue(safety, "nativeConfirmationRequired")
|
|
|| !RequiredTrue(safety, "transactionAndAuditRequired")
|
|
|| RequiredInteger(safety, "maximumPlanAttemptsPerCase")
|
|
!= MaximumPlanAttemptsPerCase
|
|
|| RequiredInteger(safety, "maximumExecuteAttemptsPerCase")
|
|
!= MaximumExecuteAttemptsPerCase)
|
|
throw Invalid("UAT 授权没有明确固定的可恢复、非生产和尝试次数约束。");
|
|
|
|
WorkflowUatExecutableIdentity erp = ParseExecutable(
|
|
RequiredObject(content, "erpExecutable"),
|
|
"Ls_ERP.exe",
|
|
false);
|
|
WorkflowUatExecutableIdentity runtimeCli = ParseRuntimeCli(
|
|
RequiredObject(content, "runtimeCli"));
|
|
WorkflowUatExecutableIdentity cli = ParseExecutable(
|
|
RequiredObject(content, "verifierCli"),
|
|
"lserp-cli.exe",
|
|
true);
|
|
|
|
WorkflowUatAuthorizationEvidence result =
|
|
new WorkflowUatAuthorizationEvidence
|
|
{
|
|
SourceSha256 = sourceHash,
|
|
ContentSha256 = contentHash,
|
|
CertificateThumbprint = thumbprint,
|
|
AuthorizationId = RequiredSafeIdentifier(
|
|
content, "authorizationId", 8, 128),
|
|
CustomerId = RequiredSafeIdentifier(
|
|
content, "customerId", 1, 64),
|
|
EnvironmentId = RequiredSafeIdentifier(
|
|
content, "environmentId", 1, 128),
|
|
AccountBook = RequiredCleanString(scope, "accountBook", 1, 128),
|
|
SubSystemId = RequiredSafeIdentifier(
|
|
scope, "subSystemId", 1, 128),
|
|
UserId = RequiredSafeIdentifier(scope, "userId", 1, 128),
|
|
UserName = RequiredCleanString(scope, "userName", 1, 128),
|
|
DatabaseScopeFingerprint = RequiredHash(
|
|
scope,
|
|
"databaseScopeFingerprint"),
|
|
RuntimeConfigurationSha256 = RequiredHash(
|
|
content, "runtimeConfigurationSha256"),
|
|
CustomerProfileSha256 = RequiredHash(
|
|
content, "customerProfileSha256"),
|
|
RolloutPolicySha256 = RequiredHash(
|
|
content, "rolloutPolicySha256"),
|
|
SourceCommit = RequiredCommit(content, "sourceCommit"),
|
|
PackageSha256 = RequiredHash(content, "packageSha256"),
|
|
ErpExecutable = erp,
|
|
RuntimeCli = runtimeCli,
|
|
VerifierCli = cli,
|
|
IssuedAtUtc = issuedAt,
|
|
ExpiresAtUtc = expiresAt,
|
|
ApprovedBy = RequiredCleanString(content, "approvedBy", 1, 128)
|
|
};
|
|
RequiredCleanString(content, "note", 1, 500);
|
|
|
|
JArray workflows = RequiredArray(content, "workflows");
|
|
if (workflows.Count < 1 || workflows.Count > 2)
|
|
throw Invalid("UAT 授权必须精确包含 1-2 个工作流。");
|
|
HashSet<string> tokenHashes = new HashSet<string>(StringComparer.Ordinal);
|
|
foreach (JToken item in workflows)
|
|
{
|
|
WorkflowUatWorkflowAuthorization workflow = ParseWorkflow(
|
|
item as JObject,
|
|
tokenHashes);
|
|
if (result.Workflows.ContainsKey(workflow.Workflow))
|
|
throw Invalid("UAT 授权包含重复工作流。");
|
|
result.Workflows.Add(workflow.Workflow, workflow);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
internal static string[] AllowedCommandsForCase(string caseCode)
|
|
{
|
|
string expected = WorkflowWriteIntegrationEvidenceVerifier
|
|
.ExpectedCommandNameForCase(caseCode);
|
|
if (expected == "purchase.invoice.create")
|
|
return new[] { "purchase.invoice.resolve", expected };
|
|
if (expected == "hr.leave.create")
|
|
return new[] { "hr.leave.resolve", expected };
|
|
return expected == null ? new string[0] : new[] { expected };
|
|
}
|
|
|
|
internal static string Sha256File(string path, int maximumBytes)
|
|
{
|
|
string ignored;
|
|
LoadBytes(path, maximumBytes, out ignored);
|
|
return ignored;
|
|
}
|
|
|
|
private static WorkflowUatWorkflowAuthorization ParseWorkflow(
|
|
JObject value,
|
|
ISet<string> tokenHashes)
|
|
{
|
|
if (value == null) throw Invalid("UAT 工作流必须是对象。");
|
|
EnsureExact(value,
|
|
"workflow", "moduleCode", "adapterId", "adapterVersion", "cases");
|
|
if (value.Properties().Count() != 5)
|
|
throw Invalid("UAT 工作流结构无效。");
|
|
string workflow = RequiredString(value, "workflow", 1, 32);
|
|
if (workflow != "purchase" && workflow != "leave")
|
|
throw Invalid("UAT 工作流名无效。");
|
|
string module = RequiredString(value, "moduleCode", 1, 64);
|
|
if (!SafeModule.IsMatch(module))
|
|
throw Invalid("UAT 工作流模块编号无效。");
|
|
WorkflowUatWorkflowAuthorization result =
|
|
new WorkflowUatWorkflowAuthorization
|
|
{
|
|
Workflow = workflow,
|
|
ModuleCode = module,
|
|
AdapterId = RequiredSafeCode(value, "adapterId"),
|
|
AdapterVersion = RequiredSafeCode(value, "adapterVersion")
|
|
};
|
|
string[] required = WorkflowWriteIntegrationEvidenceVerifier
|
|
.RequiredCaseCodesForWorkflow(workflow);
|
|
JArray cases = RequiredArray(value, "cases");
|
|
if (cases.Count != required.Length)
|
|
throw Invalid("UAT 授权用例数与固定验收合同不一致。");
|
|
for (int index = 0; index < required.Length; index++)
|
|
{
|
|
JObject item = cases[index] as JObject;
|
|
if (item == null) throw Invalid("UAT 用例必须是对象。");
|
|
EnsureExact(item,
|
|
"caseCode", "expectedCommandName", "allowedCommands", "tokenSha256");
|
|
if (item.Properties().Count() != 4)
|
|
throw Invalid("UAT 用例结构无效。");
|
|
string caseCode = RequiredString(item, "caseCode", 1, 128);
|
|
string expectedCommand = RequiredString(
|
|
item, "expectedCommandName", 1, 128);
|
|
string expectedCase = required[index];
|
|
string contractCommand = WorkflowWriteIntegrationEvidenceVerifier
|
|
.ExpectedCommandNameForCase(expectedCase);
|
|
if (!string.Equals(caseCode, expectedCase, StringComparison.Ordinal)
|
|
|| !string.Equals(
|
|
expectedCommand,
|
|
contractCommand,
|
|
StringComparison.Ordinal))
|
|
throw Invalid("UAT 用例顺序或固定命令合同无效。");
|
|
string[] allowed = AllowedCommandsForCase(caseCode);
|
|
JArray allowedValues = RequiredArray(item, "allowedCommands");
|
|
if (allowedValues.Count != allowed.Length)
|
|
throw Invalid("UAT 用例准备命令数不符合固定合同。");
|
|
WorkflowUatCaseAuthorization parsed =
|
|
new WorkflowUatCaseAuthorization
|
|
{
|
|
CaseCode = caseCode,
|
|
ExpectedCommandName = expectedCommand,
|
|
TokenSha256 = RequiredHash(item, "tokenSha256")
|
|
};
|
|
for (int commandIndex = 0;
|
|
commandIndex < allowed.Length;
|
|
commandIndex++)
|
|
{
|
|
JToken commandToken = allowedValues[commandIndex];
|
|
if (commandToken.Type != JTokenType.String
|
|
|| !string.Equals(
|
|
(string)commandToken,
|
|
allowed[commandIndex],
|
|
StringComparison.Ordinal))
|
|
throw Invalid("UAT 用例准备命令合同无效。");
|
|
parsed.AllowedCommands.Add(allowed[commandIndex]);
|
|
}
|
|
if (!tokenHashes.Add(parsed.TokenSha256))
|
|
throw Invalid("UAT 用例不允许共享授权令牌。");
|
|
result.Cases.Add(caseCode, parsed);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
private static WorkflowUatExecutableIdentity ParseExecutable(
|
|
JObject value,
|
|
string expectedFileName,
|
|
bool requireElevation)
|
|
{
|
|
EnsureExact(value,
|
|
"fileName", "sha256", "signerThumbprint", "requiresElevation");
|
|
if (value.Properties().Count() != 4
|
|
|| RequiredString(value, "fileName", 1, 64) != expectedFileName)
|
|
throw Invalid("UAT 授权程序身份文件名无效。");
|
|
JToken elevated = value["requiresElevation"];
|
|
if (elevated == null || elevated.Type != JTokenType.Boolean
|
|
|| elevated.Value<bool>() != requireElevation)
|
|
throw Invalid("UAT 授权程序提权合同无效。");
|
|
string signer = WindowsTrustedPeopleSignatureVerifier.NormalizeThumbprint(
|
|
RequiredString(value, "signerThumbprint", 40, 64));
|
|
if (signer == null)
|
|
throw Invalid("UAT 授权程序签名者指纹无效。");
|
|
return new WorkflowUatExecutableIdentity
|
|
{
|
|
FileName = expectedFileName,
|
|
Sha256 = RequiredHash(value, "sha256"),
|
|
SignerThumbprint = signer,
|
|
RequiresElevation = requireElevation
|
|
};
|
|
}
|
|
|
|
private static WorkflowUatExecutableIdentity ParseRuntimeCli(
|
|
JObject value)
|
|
{
|
|
EnsureExact(
|
|
value,
|
|
"fileName",
|
|
"version",
|
|
"sha256",
|
|
"signerThumbprint",
|
|
"requiresElevation",
|
|
"bridgeOnly",
|
|
"databaseDirectAccess",
|
|
"sessionSource");
|
|
string version = RequiredString(value, "version", 5, 14);
|
|
string signer = WindowsTrustedPeopleSignatureVerifier
|
|
.NormalizeThumbprint(RequiredString(
|
|
value,
|
|
"signerThumbprint",
|
|
40,
|
|
64));
|
|
JToken elevation = value["requiresElevation"];
|
|
JToken bridgeOnly = value["bridgeOnly"];
|
|
JToken databaseDirectAccess = value["databaseDirectAccess"];
|
|
if (value.Properties().Count() != 8
|
|
|| RequiredString(value, "fileName", 1, 64)
|
|
!= "lserp-agent-cli.exe"
|
|
|| !ProductVersion.IsMatch(version)
|
|
|| signer == null
|
|
|| elevation == null
|
|
|| elevation.Type != JTokenType.Boolean
|
|
|| elevation.Value<bool>()
|
|
|| bridgeOnly == null
|
|
|| bridgeOnly.Type != JTokenType.Boolean
|
|
|| !bridgeOnly.Value<bool>()
|
|
|| databaseDirectAccess == null
|
|
|| databaseDirectAccess.Type != JTokenType.Boolean
|
|
|| databaseDirectAccess.Value<bool>()
|
|
|| RequiredString(value, "sessionSource", 1, 64)
|
|
!= "current_logged_in_erp_process")
|
|
throw Invalid("UAT 授权运行时 CLI 身份或权限边界无效。");
|
|
return new WorkflowUatExecutableIdentity
|
|
{
|
|
FileName = "lserp-agent-cli.exe",
|
|
Version = version,
|
|
Sha256 = RequiredHash(value, "sha256"),
|
|
SignerThumbprint = signer,
|
|
RequiresElevation = false,
|
|
BridgeOnly = true,
|
|
DatabaseDirectAccess = false,
|
|
SessionSource = "current_logged_in_erp_process"
|
|
};
|
|
}
|
|
|
|
private static JObject LoadStrict(string path, out string sourceSha256)
|
|
{
|
|
byte[] bytes = LoadBytes(path, MaximumBytes, out sourceSha256);
|
|
try
|
|
{
|
|
string json = new UTF8Encoding(false, true).GetString(bytes);
|
|
using (StringReader text = new StringReader(json))
|
|
using (RejectCommentsJsonReader reader = new RejectCommentsJsonReader(text))
|
|
{
|
|
JObject value = JObject.Load(reader, new JsonLoadSettings
|
|
{
|
|
DuplicatePropertyNameHandling =
|
|
DuplicatePropertyNameHandling.Error,
|
|
CommentHandling = CommentHandling.Ignore,
|
|
LineInfoHandling = LineInfoHandling.Ignore
|
|
});
|
|
if (reader.Read())
|
|
throw Invalid("UAT 授权包含多个 JSON 根值。");
|
|
return value;
|
|
}
|
|
}
|
|
catch (CommandKernelException) { throw; }
|
|
catch { throw Invalid("UAT 授权不是严格 UTF-8 JSON 对象。"); }
|
|
}
|
|
|
|
private static byte[] LoadBytes(
|
|
string path,
|
|
int maximumBytes,
|
|
out string sourceSha256)
|
|
{
|
|
sourceSha256 = null;
|
|
if (string.IsNullOrWhiteSpace(path))
|
|
throw Invalid("UAT 授权路径不能为空。");
|
|
try
|
|
{
|
|
FileInfo file = new FileInfo(Path.GetFullPath(path));
|
|
if (!file.Exists || file.Length <= 0 || file.Length > maximumBytes
|
|
|| (file.Attributes & (FileAttributes.Directory
|
|
| FileAttributes.Device
|
|
| FileAttributes.ReparsePoint)) != 0)
|
|
throw Invalid("UAT 授权文件不存在、超限或不是普通文件。");
|
|
byte[] bytes;
|
|
using (FileStream stream = new FileStream(
|
|
file.FullName,
|
|
FileMode.Open,
|
|
FileAccess.Read,
|
|
FileShare.Read))
|
|
{
|
|
if (stream.Length <= 0 || stream.Length > maximumBytes)
|
|
throw Invalid("UAT 授权文件大小在读取期间发生变化。");
|
|
bytes = new byte[(int)stream.Length];
|
|
int offset = 0;
|
|
while (offset < bytes.Length)
|
|
{
|
|
int count = stream.Read(bytes, offset, bytes.Length - offset);
|
|
if (count <= 0)
|
|
throw Invalid("UAT 授权文件读取不完整。");
|
|
offset += count;
|
|
}
|
|
if (stream.ReadByte() != -1)
|
|
throw Invalid("UAT 授权文件在读取期间发生变化。");
|
|
}
|
|
sourceSha256 = Sha256(bytes);
|
|
return bytes;
|
|
}
|
|
catch (CommandKernelException) { throw; }
|
|
catch { throw Invalid("UAT 授权文件无法安全读取。"); }
|
|
}
|
|
|
|
private static JObject RequiredObject(JObject source, string name)
|
|
{
|
|
JObject value = source[name] as JObject;
|
|
if (value == null) throw Invalid(name + " 必须是对象。");
|
|
return value;
|
|
}
|
|
|
|
private static JArray RequiredArray(JObject source, string name)
|
|
{
|
|
JArray value = source[name] as JArray;
|
|
if (value == null) throw Invalid(name + " 必须是数组。");
|
|
return value;
|
|
}
|
|
|
|
private static string RequiredString(
|
|
JObject source,
|
|
string name,
|
|
int minimum,
|
|
int maximum)
|
|
{
|
|
JToken token = source[name];
|
|
if (token == null || token.Type != JTokenType.String)
|
|
throw Invalid(name + " 必须是字符串。");
|
|
string value = token.Value<string>();
|
|
if (value == null || value.Length < minimum || value.Length > maximum
|
|
|| value.Any(char.IsControl))
|
|
throw Invalid(name + " 字符串格式无效。");
|
|
return value;
|
|
}
|
|
|
|
private static string RequiredCleanString(
|
|
JObject source,
|
|
string name,
|
|
int minimum,
|
|
int maximum)
|
|
{
|
|
string value = RequiredString(source, name, minimum, maximum);
|
|
if (!string.Equals(value, value.Trim(), StringComparison.Ordinal))
|
|
throw Invalid(name + " 不允许首尾空白。");
|
|
return value;
|
|
}
|
|
|
|
private static string RequiredSafeIdentifier(
|
|
JObject source,
|
|
string name,
|
|
int minimum,
|
|
int maximum)
|
|
{
|
|
string value = RequiredString(source, name, minimum, maximum);
|
|
if (!SafeIdentifier.IsMatch(value))
|
|
throw Invalid(name + " 标识符格式无效。");
|
|
return value;
|
|
}
|
|
|
|
private static string RequiredSafeCode(JObject source, string name)
|
|
{
|
|
string value = RequiredString(source, name, 1, 128);
|
|
if (!SafeCode.IsMatch(value))
|
|
throw Invalid(name + " 代码格式无效。");
|
|
return value;
|
|
}
|
|
|
|
private static string RequiredHash(JObject source, string name)
|
|
{
|
|
string value = RequiredString(source, name, 64, 64);
|
|
if (!Hash.IsMatch(value))
|
|
throw Invalid(name + " 必须是小写 SHA-256。");
|
|
return value;
|
|
}
|
|
|
|
private static string RequiredCommit(JObject source, string name)
|
|
{
|
|
string value = RequiredString(source, name, 40, 40);
|
|
if (!Commit.IsMatch(value))
|
|
throw Invalid(name + " 必须是小写 40 位提交号。");
|
|
return value;
|
|
}
|
|
|
|
private static DateTime RequiredUtc(JObject source, string name)
|
|
{
|
|
string value = RequiredString(source, name, 24, 24);
|
|
DateTime parsed;
|
|
if (!DateTime.TryParseExact(
|
|
value,
|
|
"yyyy-MM-dd'T'HH:mm:ss.fff'Z'",
|
|
CultureInfo.InvariantCulture,
|
|
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
|
|
out parsed))
|
|
throw Invalid(name + " 必须是毫秒精度 UTC 时间。");
|
|
return DateTime.SpecifyKind(parsed, DateTimeKind.Utc);
|
|
}
|
|
|
|
private static bool RequiredTrue(JObject source, string name)
|
|
{
|
|
JToken value = source[name];
|
|
if (value == null || value.Type != JTokenType.Boolean)
|
|
throw Invalid(name + " 必须是布尔值。");
|
|
return value.Value<bool>();
|
|
}
|
|
|
|
private static int RequiredInteger(JObject source, string name)
|
|
{
|
|
JToken value = source[name];
|
|
if (value == null || value.Type != JTokenType.Integer)
|
|
throw Invalid(name + " 必须是整数。");
|
|
return value.Value<int>();
|
|
}
|
|
|
|
private static void EnsureExact(JObject value, params string[] expected)
|
|
{
|
|
HashSet<string> allowed = new HashSet<string>(
|
|
expected ?? new string[0],
|
|
StringComparer.Ordinal);
|
|
foreach (JProperty property in value.Properties())
|
|
{
|
|
if (!allowed.Contains(property.Name))
|
|
throw Invalid("UAT 授权包含未知字段:" + property.Name);
|
|
}
|
|
foreach (string name in allowed)
|
|
{
|
|
if (value.Property(name, StringComparison.Ordinal) == null)
|
|
throw Invalid("UAT 授权缺少字段:" + name);
|
|
}
|
|
}
|
|
|
|
public static string Sha256(byte[] bytes)
|
|
{
|
|
using (SHA256 sha = SHA256.Create())
|
|
{
|
|
byte[] digest = sha.ComputeHash(bytes);
|
|
StringBuilder value = new StringBuilder(64);
|
|
foreach (byte item in digest)
|
|
value.Append(item.ToString("x2", CultureInfo.InvariantCulture));
|
|
return value.ToString();
|
|
}
|
|
}
|
|
|
|
internal static bool FixedEquals(string left, string right)
|
|
{
|
|
if (left == null || right == null || left.Length != right.Length)
|
|
return false;
|
|
int difference = 0;
|
|
for (int index = 0; index < left.Length; index++)
|
|
difference |= left[index] ^ right[index];
|
|
return difference == 0;
|
|
}
|
|
|
|
private static CommandKernelException Invalid(string message)
|
|
{
|
|
return new CommandKernelException(
|
|
"workflow_uat_authorization_invalid",
|
|
message,
|
|
6);
|
|
}
|
|
|
|
private sealed class RejectCommentsJsonReader : JsonTextReader
|
|
{
|
|
public RejectCommentsJsonReader(TextReader reader) : base(reader)
|
|
{
|
|
DateParseHandling = DateParseHandling.None;
|
|
FloatParseHandling = FloatParseHandling.Decimal;
|
|
MaxDepth = 64;
|
|
SupportMultipleContent = false;
|
|
}
|
|
|
|
public override bool Read()
|
|
{
|
|
bool available = base.Read();
|
|
if (available && TokenType == JsonToken.Comment)
|
|
throw new JsonReaderException("UAT 授权不允许 JSON 注释。");
|
|
return available;
|
|
}
|
|
}
|
|
}
|
|
|
|
public interface IBridgeClientProcessIdentityVerifier
|
|
{
|
|
bool Verify(
|
|
int processId,
|
|
WorkflowUatExecutableIdentity expectedIdentity);
|
|
}
|
|
|
|
public interface IWorkflowUatExecutionGate
|
|
{
|
|
void ValidateExecutionContext(
|
|
string workflow,
|
|
string moduleCode,
|
|
CommandExecutionContext context);
|
|
}
|
|
|
|
public interface IBusinessAdapterReadinessOverride
|
|
{
|
|
BusinessAdapterReadiness GetReadiness(string workflow, string moduleCode);
|
|
}
|
|
|
|
/// <summary>
|
|
/// UAT readiness override that can validate the same explicit command
|
|
/// session used by runtime command rechecks.
|
|
/// </summary>
|
|
public interface IContextualBusinessAdapterReadinessOverride
|
|
{
|
|
BusinessAdapterReadiness GetReadiness(
|
|
string workflow,
|
|
string moduleCode,
|
|
CommandExecutionContext context);
|
|
}
|
|
|
|
internal sealed class WorkflowUatPlanBinding
|
|
{
|
|
public string AuthorizationIdSha256 { get; set; }
|
|
public string CaseCode { get; set; }
|
|
public string CommandName { get; set; }
|
|
public string TokenSha256 { get; set; }
|
|
}
|
|
|
|
public sealed class WorkflowUatAuthorizationPolicy : IWorkflowUatExecutionGate
|
|
{
|
|
private const string PlanBindingKey = "lserp.workflow-uat.plan-binding";
|
|
private const string AuthorizationPathEnvironment =
|
|
"LSERP_WORKFLOW_UAT_AUTHORIZATION";
|
|
private const string AuthorizationHashEnvironment =
|
|
"LSERP_WORKFLOW_UAT_AUTHORIZATION_SHA256";
|
|
private static readonly Regex SafeToken = new Regex(
|
|
"^[A-Za-z0-9_-]{32,128}$",
|
|
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
|
|
private readonly WorkflowUatAuthorizationEvidence _authorization;
|
|
private readonly IBridgeClientProcessIdentityVerifier _clientIdentityVerifier;
|
|
private readonly ISystemClock _clock;
|
|
private readonly byte[] _leaseSecret;
|
|
private readonly object _attemptSync = new object();
|
|
private readonly Dictionary<string, int> _planAttempts =
|
|
new Dictionary<string, int>(StringComparer.Ordinal);
|
|
private readonly Dictionary<string, int> _executeAttempts =
|
|
new Dictionary<string, int>(StringComparer.Ordinal);
|
|
private readonly HashSet<string> _protectedCommands =
|
|
new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
|
|
private WorkflowUatAuthorizationPolicy(
|
|
WorkflowUatAuthorizationEvidence authorization,
|
|
IBridgeClientProcessIdentityVerifier clientIdentityVerifier,
|
|
ISystemClock clock)
|
|
{
|
|
_authorization = authorization;
|
|
_clientIdentityVerifier = clientIdentityVerifier;
|
|
_clock = clock ?? new SystemClock();
|
|
_leaseSecret = new byte[32];
|
|
using (RandomNumberGenerator random = RandomNumberGenerator.Create())
|
|
random.GetBytes(_leaseSecret);
|
|
if (_authorization != null)
|
|
{
|
|
foreach (WorkflowUatWorkflowAuthorization workflow
|
|
in _authorization.Workflows.Values)
|
|
{
|
|
foreach (WorkflowUatCaseAuthorization item in workflow.Cases.Values)
|
|
{
|
|
foreach (string command in item.AllowedCommands)
|
|
_protectedCommands.Add(command);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public bool Enabled
|
|
{
|
|
get { return _authorization != null; }
|
|
}
|
|
|
|
public string SourceSha256
|
|
{
|
|
get { return _authorization == null ? null : _authorization.SourceSha256; }
|
|
}
|
|
|
|
public string AuthorizationIdSha256
|
|
{
|
|
get
|
|
{
|
|
return _authorization == null
|
|
? null
|
|
: WorkflowUatAuthorizationVerifier.Sha256(
|
|
Encoding.UTF8.GetBytes(_authorization.AuthorizationId));
|
|
}
|
|
}
|
|
|
|
public DateTime? ExpiresAtUtc
|
|
{
|
|
get { return _authorization == null
|
|
? (DateTime?)null : _authorization.ExpiresAtUtc; }
|
|
}
|
|
|
|
public int WorkflowCount
|
|
{
|
|
get { return _authorization == null ? 0 : _authorization.Workflows.Count; }
|
|
}
|
|
|
|
public static WorkflowUatAuthorizationPolicy Disabled(ISystemClock clock)
|
|
{
|
|
return new WorkflowUatAuthorizationPolicy(null, null, clock);
|
|
}
|
|
|
|
public static WorkflowUatAuthorizationPolicy FromEnvironment(
|
|
ISystemClock clock,
|
|
IAcceptanceSignatureVerifier signatureVerifier,
|
|
IBridgeClientProcessIdentityVerifier identityVerifier,
|
|
CommandRolloutPolicy rolloutPolicy,
|
|
string runtimeConfigurationPath,
|
|
int erpProcessId)
|
|
{
|
|
string path = Environment.GetEnvironmentVariable(
|
|
AuthorizationPathEnvironment);
|
|
string expectedHash = Environment.GetEnvironmentVariable(
|
|
AuthorizationHashEnvironment);
|
|
if (path == null && expectedHash == null)
|
|
return Disabled(clock);
|
|
if (string.IsNullOrWhiteSpace(path)
|
|
|| string.IsNullOrWhiteSpace(expectedHash)
|
|
|| !Regex.IsMatch(expectedHash, "^[A-Fa-f0-9]{64}$"))
|
|
throw Invalid("UAT 授权路径和原始文件 SHA-256 必须同时配置。");
|
|
if (clock == null || signatureVerifier == null || identityVerifier == null)
|
|
throw Invalid("UAT 授权缺少受信时钟、签名或进程验证器。");
|
|
if (rolloutPolicy == null || !rolloutPolicy.Configured
|
|
|| !string.Equals(
|
|
rolloutPolicy.DefaultAction,
|
|
"deny",
|
|
StringComparison.Ordinal))
|
|
throw Invalid("UAT 授权仅允许配合已固定的默认拒绝命令发布策略。");
|
|
if (string.IsNullOrWhiteSpace(runtimeConfigurationPath))
|
|
throw Invalid("UAT 授权必须绑定最终业务适配器配置。");
|
|
|
|
WorkflowUatAuthorizationEvidence evidence =
|
|
WorkflowUatAuthorizationVerifier.VerifyFile(
|
|
path,
|
|
signatureVerifier,
|
|
clock.UtcNow);
|
|
if (!WorkflowUatAuthorizationVerifier.FixedEquals(
|
|
evidence.SourceSha256,
|
|
expectedHash.ToLowerInvariant()))
|
|
throw Invalid("UAT 授权原始文件与启动固定 SHA-256 不一致。");
|
|
|
|
BusinessAdapterConfiguration configuration =
|
|
BusinessAdapterConfiguration.Load(runtimeConfigurationPath);
|
|
string profileHash = WorkflowUatAuthorizationVerifier.Sha256File(
|
|
configuration.CustomerProfilePath,
|
|
4 * 1024 * 1024);
|
|
if (!string.Equals(
|
|
evidence.CustomerId,
|
|
rolloutPolicy.CustomerId,
|
|
StringComparison.Ordinal)
|
|
|| !WorkflowUatAuthorizationVerifier.FixedEquals(
|
|
evidence.RolloutPolicySha256,
|
|
rolloutPolicy.SourceSha256)
|
|
|| !WorkflowUatAuthorizationVerifier.FixedEquals(
|
|
evidence.DatabaseScopeFingerprint,
|
|
rolloutPolicy.DatabaseScopeFingerprint)
|
|
|| !WorkflowUatAuthorizationVerifier.FixedEquals(
|
|
evidence.RuntimeConfigurationSha256,
|
|
configuration.SourceSha256)
|
|
|| !WorkflowUatAuthorizationVerifier.FixedEquals(
|
|
evidence.CustomerProfileSha256,
|
|
profileHash))
|
|
throw Invalid("UAT 授权与当前客户、数据库作用域、发布策略、运行配置或只读画像不一致。");
|
|
if (!identityVerifier.Verify(erpProcessId, evidence.ErpExecutable))
|
|
throw Invalid("UAT 授权与当前已签名 ERP 进程身份不一致。");
|
|
return new WorkflowUatAuthorizationPolicy(
|
|
evidence,
|
|
identityVerifier,
|
|
clock);
|
|
}
|
|
|
|
internal static WorkflowUatAuthorizationPolicy CreateVerifiedForTests(
|
|
WorkflowUatAuthorizationEvidence evidence,
|
|
IBridgeClientProcessIdentityVerifier identityVerifier,
|
|
ISystemClock clock)
|
|
{
|
|
if (evidence == null || identityVerifier == null || clock == null)
|
|
throw new ArgumentNullException("evidence");
|
|
return new WorkflowUatAuthorizationPolicy(
|
|
evidence,
|
|
identityVerifier,
|
|
clock);
|
|
}
|
|
|
|
public bool IsWorkflowAuthorized(string workflow, string moduleCode)
|
|
{
|
|
WorkflowUatWorkflowAuthorization value;
|
|
return Enabled
|
|
&& _authorization.Workflows.TryGetValue(workflow ?? string.Empty, out value)
|
|
&& string.Equals(value.ModuleCode, moduleCode, StringComparison.Ordinal)
|
|
&& _clock.UtcNow <= _authorization.ExpiresAtUtc;
|
|
}
|
|
|
|
public void ValidateRuntimeConfiguration(
|
|
BusinessAdapterConfiguration configuration)
|
|
{
|
|
if (!Enabled) return;
|
|
if (configuration == null)
|
|
throw Invalid("UAT 授权缺少已加载的运行时业务配置。");
|
|
foreach (WorkflowUatWorkflowAuthorization workflow
|
|
in _authorization.Workflows.Values)
|
|
{
|
|
bool matches = workflow.Workflow == "purchase"
|
|
? configuration.Purchase != null
|
|
&& configuration.Purchase.Enabled
|
|
&& configuration.Purchase.Fields != null
|
|
&& string.Equals(
|
|
configuration.Purchase.Fields.ModuleCode,
|
|
workflow.ModuleCode,
|
|
StringComparison.Ordinal)
|
|
: configuration.Leave != null
|
|
&& configuration.Leave.Enabled
|
|
&& configuration.Leave.Fields != null
|
|
&& string.Equals(
|
|
configuration.Leave.Fields.ModuleCode,
|
|
workflow.ModuleCode,
|
|
StringComparison.Ordinal);
|
|
if (!matches)
|
|
throw Invalid("UAT 授权工作流开关或模块与最终运行配置不一致。");
|
|
}
|
|
}
|
|
|
|
public bool IsProtectedCommand(string commandName)
|
|
{
|
|
return Enabled && _protectedCommands.Contains(commandName ?? string.Empty);
|
|
}
|
|
|
|
public bool IsVisibleInGeneralCapabilities(string commandName)
|
|
{
|
|
return !IsProtectedCommand(commandName);
|
|
}
|
|
|
|
internal WorkflowUatPlanBinding AuthorizePlan(
|
|
BridgeRequest request,
|
|
CommandExecutionContext context,
|
|
string commandName)
|
|
{
|
|
if (!IsProtectedCommand(commandName))
|
|
{
|
|
if (request != null && request.UatGrant != null)
|
|
throw Denied(
|
|
"workflow_uat_grant_not_applicable",
|
|
"UAT 用例令牌不允许用于普通命令。");
|
|
return null;
|
|
}
|
|
WorkflowUatPlanBinding binding = AuthorizeCore(
|
|
request,
|
|
context,
|
|
commandName);
|
|
ConsumeAttempt(
|
|
_planAttempts,
|
|
binding.CaseCode,
|
|
WorkflowUatAuthorizationVerifier.MaximumPlanAttemptsPerCase,
|
|
"workflow_uat_plan_limit_reached");
|
|
return binding;
|
|
}
|
|
|
|
internal void BindPlan(CommandPlan plan, WorkflowUatPlanBinding binding)
|
|
{
|
|
if (binding == null) return;
|
|
if (plan == null) throw Invalid("UAT 授权无法绑定空计划。");
|
|
plan.SetServerData(PlanBindingKey, binding);
|
|
}
|
|
|
|
internal void AuthorizeExecute(
|
|
BridgeRequest request,
|
|
CommandExecutionContext context,
|
|
CommandPlan plan)
|
|
{
|
|
if (plan == null || !IsProtectedCommand(plan.CommandName))
|
|
{
|
|
if (request != null && request.UatGrant != null)
|
|
throw Denied(
|
|
"workflow_uat_grant_not_applicable",
|
|
"UAT 用例令牌不允许用于普通执行计划。");
|
|
return;
|
|
}
|
|
WorkflowUatPlanBinding stored;
|
|
if (!plan.TryGetServerData(PlanBindingKey, out stored) || stored == null)
|
|
throw Denied(
|
|
"workflow_uat_plan_binding_missing",
|
|
"UAT 执行计划缺少服务端授权绑定,请重新取证。");
|
|
WorkflowUatPlanBinding current = AuthorizeCore(
|
|
request,
|
|
context,
|
|
plan.CommandName);
|
|
if (!WorkflowUatAuthorizationVerifier.FixedEquals(
|
|
stored.AuthorizationIdSha256,
|
|
current.AuthorizationIdSha256)
|
|
|| !string.Equals(
|
|
stored.CaseCode,
|
|
current.CaseCode,
|
|
StringComparison.Ordinal)
|
|
|| !string.Equals(
|
|
stored.CommandName,
|
|
current.CommandName,
|
|
StringComparison.Ordinal)
|
|
|| !WorkflowUatAuthorizationVerifier.FixedEquals(
|
|
stored.TokenSha256,
|
|
current.TokenSha256))
|
|
throw Denied(
|
|
"workflow_uat_plan_binding_mismatch",
|
|
"UAT 执行授权与原计划不一致,请重新取证。");
|
|
ConsumeAttempt(
|
|
_executeAttempts,
|
|
stored.CaseCode,
|
|
WorkflowUatAuthorizationVerifier.MaximumExecuteAttemptsPerCase,
|
|
"workflow_uat_execute_limit_reached");
|
|
}
|
|
|
|
public void ValidateExecutionContext(
|
|
string workflow,
|
|
string moduleCode,
|
|
CommandExecutionContext context)
|
|
{
|
|
if (!Enabled || context == null)
|
|
throw Denied(
|
|
"workflow_uat_execution_lease_missing",
|
|
"UAT 业务调用缺少授权租约。");
|
|
WorkflowUatWorkflowAuthorization workflowAuthorization;
|
|
WorkflowUatCaseAuthorization caseAuthorization;
|
|
if (!_authorization.Workflows.TryGetValue(
|
|
workflow ?? string.Empty,
|
|
out workflowAuthorization)
|
|
|| !string.Equals(
|
|
workflowAuthorization.ModuleCode,
|
|
moduleCode,
|
|
StringComparison.Ordinal)
|
|
|| !workflowAuthorization.Cases.TryGetValue(
|
|
context.UatCaseCode ?? string.Empty,
|
|
out caseAuthorization)
|
|
|| !caseAuthorization.AllowedCommands.Contains(
|
|
context.UatCommandName,
|
|
StringComparer.Ordinal)
|
|
|| !ScopeMatches(context)
|
|
|| _clock.UtcNow > _authorization.ExpiresAtUtc
|
|
|| !WorkflowUatAuthorizationVerifier.FixedEquals(
|
|
context.UatAuthorizationIdSha256,
|
|
AuthorizationIdSha256))
|
|
throw Denied(
|
|
"workflow_uat_execution_scope_mismatch",
|
|
"UAT 业务调用与签名客户作用域不一致。");
|
|
string expectedLease = CreateLease(context);
|
|
if (!WorkflowUatAuthorizationVerifier.FixedEquals(
|
|
context.UatExecutionLease,
|
|
expectedLease))
|
|
throw Denied(
|
|
"workflow_uat_execution_lease_invalid",
|
|
"UAT 业务调用租约无效。");
|
|
}
|
|
|
|
internal BusinessAdapterReadiness GetReadiness(
|
|
string workflow,
|
|
string moduleCode)
|
|
{
|
|
WorkflowUatWorkflowAuthorization value;
|
|
if (!IsWorkflowAuthorized(workflow, moduleCode)
|
|
|| !_authorization.Workflows.TryGetValue(workflow, out value))
|
|
throw Denied(
|
|
"workflow_uat_scope_mismatch",
|
|
"UAT 授权未覆盖该工作流或模块。");
|
|
return new BusinessAdapterReadiness
|
|
{
|
|
AdapterId = value.AdapterId,
|
|
AdapterVersion = value.AdapterVersion,
|
|
EvidenceId = _authorization.AuthorizationId,
|
|
EvidenceSha256 = _authorization.ContentSha256,
|
|
AccountBook = _authorization.AccountBook,
|
|
SubSystemId = _authorization.SubSystemId,
|
|
ValidatedBy = _authorization.ApprovedBy,
|
|
ValidatedAtUtc = _authorization.IssuedAtUtc,
|
|
ActivationMode = "customer_uat",
|
|
UatAuthorizationVerified = true
|
|
};
|
|
}
|
|
|
|
internal BusinessAdapterReadiness GetReadiness(
|
|
string workflow,
|
|
string moduleCode,
|
|
CommandExecutionContext context)
|
|
{
|
|
if (!ScopeMatches(context))
|
|
throw Denied(
|
|
"workflow_uat_scope_mismatch",
|
|
"UAT 就绪证据与当前 ERP 用户、账套、子系统或数据库不一致。");
|
|
return GetReadiness(workflow, moduleCode);
|
|
}
|
|
|
|
public IDictionary<string, object> SafeSnapshot()
|
|
{
|
|
return new Dictionary<string, object>
|
|
{
|
|
{ "enabled", Enabled },
|
|
{ "authorizationIdSha256", AuthorizationIdSha256 },
|
|
{ "sourceSha256", SourceSha256 },
|
|
{ "expiresAtUtc", ExpiresAtUtc },
|
|
{ "workflowCount", WorkflowCount },
|
|
{ "generalCapabilitiesHidden", Enabled }
|
|
};
|
|
}
|
|
|
|
private WorkflowUatPlanBinding AuthorizeCore(
|
|
BridgeRequest request,
|
|
CommandExecutionContext context,
|
|
string commandName)
|
|
{
|
|
if (!Enabled || request == null || context == null
|
|
|| request.UatGrant == null)
|
|
throw Denied(
|
|
"workflow_uat_authorization_required",
|
|
"该命令仅在受控 UAT 取证中可用,且必须提供用例令牌。");
|
|
BridgeUatGrant grant = request.UatGrant;
|
|
if (!string.Equals(
|
|
grant.AuthorizationId,
|
|
_authorization.AuthorizationId,
|
|
StringComparison.Ordinal)
|
|
|| string.IsNullOrWhiteSpace(grant.Token)
|
|
|| !SafeToken.IsMatch(grant.Token)
|
|
|| !ScopeMatches(context)
|
|
|| _clock.UtcNow > _authorization.ExpiresAtUtc)
|
|
throw Denied(
|
|
"workflow_uat_authorization_mismatch",
|
|
"UAT 授权、令牌或 ERP 作用域不一致。");
|
|
|
|
WorkflowUatWorkflowAuthorization workflow = null;
|
|
WorkflowUatCaseAuthorization matched = null;
|
|
foreach (WorkflowUatWorkflowAuthorization candidate
|
|
in _authorization.Workflows.Values)
|
|
{
|
|
WorkflowUatCaseAuthorization item;
|
|
if (candidate.Cases.TryGetValue(grant.CaseCode ?? string.Empty, out item))
|
|
{
|
|
workflow = candidate;
|
|
matched = item;
|
|
break;
|
|
}
|
|
}
|
|
if (workflow == null || matched == null
|
|
|| !matched.AllowedCommands.Contains(
|
|
commandName,
|
|
StringComparer.Ordinal))
|
|
throw Denied(
|
|
"workflow_uat_case_command_mismatch",
|
|
"UAT 用例不允许该命令。");
|
|
string tokenHash = WorkflowUatAuthorizationVerifier.Sha256(
|
|
Encoding.UTF8.GetBytes(grant.Token));
|
|
if (!WorkflowUatAuthorizationVerifier.FixedEquals(
|
|
tokenHash,
|
|
matched.TokenSha256))
|
|
throw Denied(
|
|
"workflow_uat_token_invalid",
|
|
"UAT 用例令牌无效。");
|
|
if (request.TransportClientProcessId <= 0
|
|
|| _clientIdentityVerifier == null
|
|
|| !_clientIdentityVerifier.Verify(
|
|
request.TransportClientProcessId,
|
|
_authorization.VerifierCli))
|
|
throw Denied(
|
|
"workflow_uat_client_identity_invalid",
|
|
"UAT 取证必须由签名、固定哈希且已提权的最终 CLI 调用。");
|
|
|
|
context.UatAuthorizationIdSha256 = AuthorizationIdSha256;
|
|
context.UatCaseCode = matched.CaseCode;
|
|
context.UatCommandName = commandName;
|
|
context.UatExecutionLease = CreateLease(context);
|
|
return new WorkflowUatPlanBinding
|
|
{
|
|
AuthorizationIdSha256 = AuthorizationIdSha256,
|
|
CaseCode = matched.CaseCode,
|
|
CommandName = commandName,
|
|
TokenSha256 = tokenHash
|
|
};
|
|
}
|
|
|
|
private bool ScopeMatches(CommandExecutionContext context)
|
|
{
|
|
return context != null
|
|
&& string.Equals(
|
|
context.AccountBook,
|
|
_authorization.AccountBook,
|
|
StringComparison.OrdinalIgnoreCase)
|
|
&& string.Equals(
|
|
context.SubSystemId,
|
|
_authorization.SubSystemId,
|
|
StringComparison.OrdinalIgnoreCase)
|
|
&& string.Equals(
|
|
context.UserId,
|
|
_authorization.UserId,
|
|
StringComparison.Ordinal)
|
|
&& string.Equals(
|
|
context.UserName,
|
|
_authorization.UserName,
|
|
StringComparison.Ordinal)
|
|
&& WorkflowUatAuthorizationVerifier.FixedEquals(
|
|
context.DatabaseScopeFingerprint,
|
|
_authorization.DatabaseScopeFingerprint);
|
|
}
|
|
|
|
private string CreateLease(CommandExecutionContext context)
|
|
{
|
|
string value = string.Join("|", new[]
|
|
{
|
|
"uat-lease-v2",
|
|
AuthorizationIdSha256 ?? string.Empty,
|
|
context.UatCaseCode ?? string.Empty,
|
|
context.UatCommandName ?? string.Empty,
|
|
context.CorrelationId ?? string.Empty,
|
|
context.ClientSessionId ?? string.Empty,
|
|
context.UserId ?? string.Empty,
|
|
context.UserName ?? string.Empty,
|
|
context.AccountBook ?? string.Empty,
|
|
context.SubSystemId ?? string.Empty,
|
|
context.DatabaseScopeFingerprint ?? string.Empty
|
|
});
|
|
using (HMACSHA256 hmac = new HMACSHA256(_leaseSecret))
|
|
return WorkflowUatAuthorizationVerifier.Sha256(
|
|
hmac.ComputeHash(Encoding.UTF8.GetBytes(value)));
|
|
}
|
|
|
|
private void ConsumeAttempt(
|
|
IDictionary<string, int> attempts,
|
|
string caseCode,
|
|
int maximum,
|
|
string code)
|
|
{
|
|
lock (_attemptSync)
|
|
{
|
|
int current;
|
|
attempts.TryGetValue(caseCode, out current);
|
|
if (current >= maximum)
|
|
throw Denied(code, "UAT 用例已达本 ERP 进程的尝试上限。");
|
|
attempts[caseCode] = current + 1;
|
|
}
|
|
}
|
|
|
|
private static CommandKernelException Denied(string code, string message)
|
|
{
|
|
return new CommandKernelException(code, message, 6);
|
|
}
|
|
|
|
private static CommandKernelException Invalid(string message)
|
|
{
|
|
return new CommandKernelException(
|
|
"workflow_uat_configuration_invalid",
|
|
message,
|
|
6);
|
|
}
|
|
}
|
|
|
|
public sealed class WorkflowUatReadinessAttestor :
|
|
IBusinessAdapterReadinessAttestor,
|
|
IBusinessAdapterReadinessOverride,
|
|
IContextualBusinessAdapterReadinessOverride
|
|
{
|
|
private readonly WorkflowUatAuthorizationPolicy _policy;
|
|
|
|
public WorkflowUatReadinessAttestor(WorkflowUatAuthorizationPolicy policy)
|
|
{
|
|
if (policy == null || !policy.Enabled)
|
|
throw new ArgumentException("UAT 授权策略未启用。", "policy");
|
|
_policy = policy;
|
|
}
|
|
|
|
public BusinessAdapterReadiness GetReadiness(
|
|
string workflow,
|
|
string moduleCode)
|
|
{
|
|
return _policy.GetReadiness(workflow, moduleCode);
|
|
}
|
|
|
|
public BusinessAdapterReadiness GetReadiness(
|
|
string workflow,
|
|
string moduleCode,
|
|
CommandExecutionContext context)
|
|
{
|
|
return _policy.GetReadiness(workflow, moduleCode, context);
|
|
}
|
|
|
|
public BusinessAdapterReadiness Attest(
|
|
string workflow,
|
|
string moduleCode,
|
|
BusinessAdapterReadiness databaseReadiness)
|
|
{
|
|
return GetReadiness(workflow, moduleCode);
|
|
}
|
|
}
|
|
|
|
public sealed class WorkflowUatGuardedProcedureGateway :
|
|
IWorkflowProcedureGateway,
|
|
IContextualWorkflowProcedureGateway
|
|
{
|
|
private readonly IWorkflowProcedureGateway _inner;
|
|
private readonly IWorkflowUatExecutionGate _gate;
|
|
|
|
public WorkflowUatGuardedProcedureGateway(
|
|
IWorkflowProcedureGateway inner,
|
|
IWorkflowUatExecutionGate gate)
|
|
{
|
|
if (inner == null) throw new ArgumentNullException("inner");
|
|
if (gate == null) throw new ArgumentNullException("gate");
|
|
_inner = inner;
|
|
_gate = gate;
|
|
}
|
|
|
|
public BusinessAdapterReadiness GetReadiness(
|
|
string workflow,
|
|
string moduleCode)
|
|
{
|
|
throw new CommandKernelException(
|
|
"workflow_uat_readiness_override_required",
|
|
"UAT 网关不能伪装为生产数据库就绪证据。",
|
|
6);
|
|
}
|
|
|
|
public BusinessAdapterReadiness GetReadiness(
|
|
string workflow,
|
|
string moduleCode,
|
|
CommandExecutionContext context)
|
|
{
|
|
throw new CommandKernelException(
|
|
"workflow_uat_readiness_override_required",
|
|
"UAT 网关不能伪装为生产数据库就绪证据。",
|
|
6);
|
|
}
|
|
|
|
public System.Data.DataTable Read(
|
|
string workflow,
|
|
string action,
|
|
string moduleCode,
|
|
CommandExecutionContext context,
|
|
IDictionary<string, object> payload)
|
|
{
|
|
_gate.ValidateExecutionContext(workflow, moduleCode, context);
|
|
return _inner.Read(workflow, action, moduleCode, context, payload);
|
|
}
|
|
|
|
public BusinessWriteResult Write(
|
|
string workflow,
|
|
string action,
|
|
string moduleCode,
|
|
CommandExecutionContext context,
|
|
IDictionary<string, object> payload,
|
|
string idempotencyKey,
|
|
string inputFingerprint)
|
|
{
|
|
_gate.ValidateExecutionContext(workflow, moduleCode, context);
|
|
return _inner.Write(
|
|
workflow,
|
|
action,
|
|
moduleCode,
|
|
context,
|
|
payload,
|
|
idempotencyKey,
|
|
inputFingerprint);
|
|
}
|
|
}
|
|
}
|