Files
lserp_cs_6.0/插件库/Lskj.AgentBridge/WorkflowUatAuthorization.cs
2026-08-14 14:28:28 +08:00

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);
}
}
}