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

721 lines
28 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
{
/// <summary>
/// Narrows the commands already authorized by the ERP. This policy never
/// grants an ERP permission; it only applies a customer deployment rollout
/// boundary after the normal ERP permission checks have succeeded.
/// </summary>
public sealed class CommandRolloutPolicy : ICommandAuthorizer
{
public const int MaximumBytes = 256 * 1024;
public const int MaximumRules = 128;
public const int MaximumScopeValues = 64;
private const string ConfigEnvironment = "LSERP_AGENT_ROLLOUT_CONFIG";
private const string Sha256Environment = "LSERP_AGENT_ROLLOUT_SHA256";
private const string CustomerEnvironment = "LSERP_AGENT_ROLLOUT_CUSTOMER_ID";
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 SafeSha256 = new Regex(
"^[A-Fa-f0-9]{64}$",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
private readonly Dictionary<string, CommandRolloutRule> _rules;
private readonly bool _defaultAllow;
private CommandRolloutPolicy(
bool configured,
string customerId,
string databaseScopeFingerprint,
string sourceSha256,
bool defaultAllow,
IEnumerable<CommandRolloutRule> rules)
{
Configured = configured;
CustomerId = customerId;
DatabaseScopeFingerprint = databaseScopeFingerprint;
SourceSha256 = sourceSha256;
_defaultAllow = defaultAllow;
_rules = new Dictionary<string, CommandRolloutRule>(
StringComparer.OrdinalIgnoreCase);
foreach (CommandRolloutRule rule in rules ?? new CommandRolloutRule[0])
_rules.Add(rule.Command, rule);
}
public bool Configured { get; private set; }
public string CustomerId { get; private set; }
public string DatabaseScopeFingerprint { get; private set; }
public string SourceSha256 { get; private set; }
public string DefaultAction
{
get { return _defaultAllow ? "allow" : "deny"; }
}
public int RuleCount
{
get { return _rules.Count; }
}
public static CommandRolloutPolicy AllowAll()
{
return new CommandRolloutPolicy(
false,
null,
null,
null,
true,
new CommandRolloutRule[0]);
}
public static CommandRolloutPolicy FromEnvironment()
{
return FromEnvironment(false);
}
public static CommandRolloutPolicy FromEnvironment(
bool requireFailClosedConfiguration)
{
string path = Environment.GetEnvironmentVariable(ConfigEnvironment);
string expectedSha256 =
Environment.GetEnvironmentVariable(Sha256Environment);
string expectedCustomerId =
Environment.GetEnvironmentVariable(CustomerEnvironment);
if (path == null && expectedSha256 == null && expectedCustomerId == null)
{
if (requireFailClosedConfiguration)
throw Invalid("商用命令桥必须配置默认拒绝的命令发布策略。");
return AllowAll();
}
if (string.IsNullOrWhiteSpace(path)
|| string.IsNullOrWhiteSpace(expectedSha256)
|| string.IsNullOrWhiteSpace(expectedCustomerId))
{
throw Invalid(
"命令发布配置路径、SHA-256 和客户部署标识必须同时设置。");
}
CommandRolloutPolicy result = Load(
path,
expectedSha256,
expectedCustomerId);
if (requireFailClosedConfiguration && result._defaultAllow)
throw Invalid("商用命令桥的 defaultAction 必须为 deny。");
return result;
}
public static CommandRolloutPolicy Load(
string path,
string expectedSha256,
string expectedCustomerId)
{
if (string.IsNullOrWhiteSpace(path))
throw Invalid("命令发布配置路径不能为空。");
if (string.IsNullOrEmpty(expectedSha256)
|| !SafeSha256.IsMatch(expectedSha256))
{
throw Invalid("命令发布配置 SHA-256 必须是 64 位十六进制值。");
}
if (!IsSafeIdentifier(expectedCustomerId, 64))
throw Invalid("客户部署标识格式无效。");
try
{
byte[] sourceBytes = ReadRegularFile(path);
string sourceSha256 = Sha256(sourceBytes);
if (!FixedTimeEquals(
sourceSha256,
expectedSha256.ToLowerInvariant()))
{
throw Invalid("命令发布配置与固定 SHA-256 不一致。");
}
JObject root = ParseStrictObject(sourceBytes);
EnsureOnly(
root,
"schemaVersion",
"customerId",
"databaseScopeFingerprint",
"defaultAction",
"rules");
string schemaVersion = RequiredString(root, "schemaVersion");
string customerId = RequiredString(root, "customerId");
string databaseScopeFingerprint = RequiredString(
root,
"databaseScopeFingerprint");
string defaultAction = RequiredString(root, "defaultAction");
JArray ruleValues = RequiredArray(root, "rules");
if (!string.Equals(schemaVersion, "1.1", StringComparison.Ordinal))
throw Invalid("命令发布配置 schemaVersion 必须为 1.1。");
if (!IsSafeIdentifier(customerId, 64)
|| !string.Equals(
customerId,
expectedCustomerId,
StringComparison.Ordinal))
{
throw Invalid("命令发布配置与当前客户部署标识不一致。");
}
if (!SafeSha256.IsMatch(databaseScopeFingerprint))
throw Invalid("命令发布配置数据库作用域指纹无效。");
databaseScopeFingerprint =
databaseScopeFingerprint.ToLowerInvariant();
bool defaultAllow;
if (string.Equals(defaultAction, "allow", StringComparison.Ordinal))
defaultAllow = true;
else if (string.Equals(defaultAction, "deny", StringComparison.Ordinal))
defaultAllow = false;
else
throw Invalid("defaultAction 只允许 allow 或 deny。");
if (ruleValues.Count > MaximumRules)
throw Invalid("命令发布规则数量超过上限。");
List<CommandRolloutRule> rules = new List<CommandRolloutRule>();
HashSet<string> commandNames = new HashSet<string>(
StringComparer.OrdinalIgnoreCase);
foreach (JToken token in ruleValues)
{
JObject ruleObject = token as JObject;
if (ruleObject == null)
throw Invalid("每条命令发布规则必须是 JSON 对象。");
CommandRolloutRule rule = ParseRule(ruleObject);
if (!commandNames.Add(rule.Command))
throw Invalid("命令发布配置包含重复命令规则。");
rules.Add(rule);
}
return new CommandRolloutPolicy(
true,
customerId,
databaseScopeFingerprint,
sourceSha256,
defaultAllow,
rules);
}
catch (CommandKernelException)
{
throw;
}
catch
{
throw Invalid("命令发布配置不是有效的严格 UTF-8 JSON。");
}
}
public void ValidateRegisteredCommands(
IEnumerable<CommandDescriptor> registeredCommands)
{
if (!Configured) return;
if (registeredCommands == null)
throw Invalid("无法校验当前命令注册表。");
Dictionary<string, CommandDescriptor> descriptors =
new Dictionary<string, CommandDescriptor>(
StringComparer.OrdinalIgnoreCase);
foreach (CommandDescriptor descriptor in registeredCommands)
{
if (descriptor == null
|| string.IsNullOrWhiteSpace(descriptor.Name)
|| descriptors.ContainsKey(descriptor.Name))
{
throw Invalid("当前命令注册表包含无效或重复命令。");
}
descriptors.Add(descriptor.Name, descriptor);
}
foreach (CommandRolloutRule rule in _rules.Values)
{
CommandDescriptor descriptor;
if (!descriptors.TryGetValue(rule.Command, out descriptor))
throw Invalid("命令发布规则引用了当前未注册的命令。");
if (!string.Equals(
rule.CommandVersion,
descriptor.Version ?? string.Empty,
StringComparison.Ordinal)
|| !string.Equals(
rule.RequiredPermission,
descriptor.RequiredPermission ?? string.Empty,
StringComparison.OrdinalIgnoreCase))
{
throw Invalid("命令发布规则与当前命令版本或权限契约不一致。");
}
}
}
public CommandAuthorizationDecision Authorize(
CommandDescriptor descriptor,
CommandExecutionContext context,
bool execution)
{
if (!Configured) return CommandAuthorizationDecision.Allow();
if (descriptor == null || context == null)
return Deny();
if (!SafeSha256.IsMatch(
context.DatabaseScopeFingerprint ?? string.Empty)
|| !FixedTimeEquals(
DatabaseScopeFingerprint,
context.DatabaseScopeFingerprint.ToLowerInvariant()))
{
return Deny();
}
CommandRolloutRule rule;
if (!_rules.TryGetValue(descriptor.Name ?? string.Empty, out rule))
return _defaultAllow
? CommandAuthorizationDecision.Allow()
: Deny();
if (!string.Equals(
rule.CommandVersion,
descriptor.Version ?? string.Empty,
StringComparison.Ordinal)
|| !string.Equals(
rule.RequiredPermission,
descriptor.RequiredPermission ?? string.Empty,
StringComparison.OrdinalIgnoreCase))
{
return Deny();
}
if (string.IsNullOrWhiteSpace(context.AccountBook)
|| string.IsNullOrWhiteSpace(context.SubSystemId)
|| string.IsNullOrWhiteSpace(context.UserId)
|| !rule.AccountBooks.Contains(context.AccountBook)
|| !rule.SubSystemIds.Contains(context.SubSystemId))
{
return Deny();
}
if (string.Equals(
rule.Audience,
CommandRolloutRule.Administrators,
StringComparison.Ordinal))
{
return AdministratorIdentity.IsBuiltIn(
context.UserId,
context.UserName)
? CommandAuthorizationDecision.Allow()
: Deny();
}
if (string.Equals(
rule.Audience,
CommandRolloutRule.Users,
StringComparison.Ordinal))
{
return rule.UserIds.Contains(context.UserId)
? CommandAuthorizationDecision.Allow()
: Deny();
}
return CommandAuthorizationDecision.Allow();
}
private static CommandRolloutRule ParseRule(JObject value)
{
EnsureOnly(
value,
"command",
"commandVersion",
"requiredPermission",
"accountBooks",
"subSystemIds",
"audience",
"userIds");
string command = RequiredString(value, "command");
string commandVersion = RequiredString(value, "commandVersion");
string requiredPermission = RequiredString(
value,
"requiredPermission");
string audience = RequiredString(value, "audience");
if (!IsSafeIdentifier(command, 128)
|| !IsSafeIdentifier(commandVersion, 64))
{
throw Invalid("命令名或命令版本格式无效。");
}
EnsureCleanText(requiredPermission, 160, "requiredPermission");
if (!string.Equals(
audience,
CommandRolloutRule.AllAuthorized,
StringComparison.Ordinal)
&& !string.Equals(
audience,
CommandRolloutRule.Administrators,
StringComparison.Ordinal)
&& !string.Equals(
audience,
CommandRolloutRule.Users,
StringComparison.Ordinal))
{
throw Invalid(
"audience 只允许 all_authorized、administrators 或 users。");
}
CommandRolloutScope accountBooks = ParseScope(
RequiredObject(value, "accountBooks"),
true,
"accountBooks");
CommandRolloutScope subSystemIds = ParseScope(
RequiredObject(value, "subSystemIds"),
false,
"subSystemIds");
HashSet<string> userIds = ParseStringSet(
RequiredArray(value, "userIds"),
false,
"userIds");
if (string.Equals(
audience,
CommandRolloutRule.Users,
StringComparison.Ordinal))
{
if (userIds.Count == 0)
throw Invalid("users audience 必须提供至少一个 userId。");
}
else if (userIds.Count != 0)
{
throw Invalid("非 users audience 不允许配置 userIds。");
}
return new CommandRolloutRule
{
Command = command,
CommandVersion = commandVersion,
RequiredPermission = requiredPermission,
AccountBooks = accountBooks,
SubSystemIds = subSystemIds,
Audience = audience,
UserIds = userIds
};
}
private static CommandRolloutScope ParseScope(
JObject value,
bool allowUnicode,
string fieldName)
{
EnsureOnly(value, "all", "values");
bool all = RequiredBoolean(value, "all");
HashSet<string> values = ParseStringSet(
RequiredArray(value, "values"),
allowUnicode,
fieldName + ".values");
if (all && values.Count != 0)
throw Invalid(fieldName + " 在 all=true 时 values 必须为空。");
if (!all && values.Count == 0)
throw Invalid(fieldName + " 在 all=false 时 values 不能为空。");
return new CommandRolloutScope(all, values);
}
private static HashSet<string> ParseStringSet(
JArray values,
bool allowUnicode,
string fieldName)
{
if (values.Count > MaximumScopeValues)
throw Invalid(fieldName + " 数量超过上限。");
HashSet<string> result = new HashSet<string>(StringComparer.Ordinal);
foreach (JToken token in values)
{
if (token.Type != JTokenType.String)
throw Invalid(fieldName + " 只允许字符串值。");
string item = (string)token;
if (allowUnicode)
EnsureCleanText(item, 128, fieldName);
else if (!IsSafeIdentifier(item, 128))
throw Invalid(fieldName + " 包含格式无效的标识符。");
if (!result.Add(item))
throw Invalid(fieldName + " 包含重复值。");
}
return result;
}
private static byte[] ReadRegularFile(string path)
{
string fullPath = Path.GetFullPath(path);
FileInfo file = new FileInfo(fullPath);
if (!file.Exists)
throw Invalid("命令发布配置文件不存在。");
FileAttributes attributes = file.Attributes;
if ((attributes & (FileAttributes.Directory
| FileAttributes.Device
| FileAttributes.ReparsePoint)) != 0)
{
throw Invalid("命令发布配置必须是普通文件,不能是目录、设备或链接。");
}
using (FileStream stream = new FileStream(
fullPath,
FileMode.Open,
FileAccess.Read,
FileShare.Read))
{
if (stream.Length <= 0 || stream.Length > MaximumBytes)
throw Invalid("命令发布配置为空或超过大小上限。");
byte[] result = new byte[(int)stream.Length];
int offset = 0;
while (offset < result.Length)
{
int count = stream.Read(result, offset, result.Length - offset);
if (count <= 0)
throw Invalid("命令发布配置读取不完整。");
offset += count;
}
if (stream.ReadByte() != -1)
throw Invalid("命令发布配置读取期间发生变化。");
return result;
}
}
private static JObject ParseStrictObject(byte[] sourceBytes)
{
string source = new UTF8Encoding(false, true).GetString(sourceBytes);
if (!StrictRuntimeJsonSyntax.IsStandard(source))
throw Invalid("命令发布配置必须使用无 BOM、无注释、无尾逗号的标准 JSON。");
using (StringReader input = new StringReader(source))
using (JsonTextReader reader = new JsonTextReader(input))
{
reader.DateParseHandling = DateParseHandling.None;
reader.FloatParseHandling = FloatParseHandling.Decimal;
reader.MaxDepth = 32;
while (reader.Read())
{
if (reader.TokenType == JsonToken.Comment)
throw Invalid("命令发布配置不允许 JSON 注释。");
}
}
return JObject.Parse(
source,
new JsonLoadSettings
{
DuplicatePropertyNameHandling =
DuplicatePropertyNameHandling.Error,
CommentHandling = CommentHandling.Ignore,
LineInfoHandling = LineInfoHandling.Ignore
});
}
private static JObject RequiredObject(JObject value, string name)
{
JToken token = value[name];
JObject result = token as JObject;
if (result == null)
throw Invalid(name + " 必须是 JSON 对象。");
return result;
}
private static JArray RequiredArray(JObject value, string name)
{
JToken token = value[name];
JArray result = token as JArray;
if (result == null)
throw Invalid(name + " 必须是 JSON 数组。");
return result;
}
private static string RequiredString(JObject value, string name)
{
JToken token = value[name];
if (token == null || token.Type != JTokenType.String)
throw Invalid(name + " 必须是字符串。");
return (string)token;
}
private static bool RequiredBoolean(JObject value, string name)
{
JToken token = value[name];
if (token == null || token.Type != JTokenType.Boolean)
throw Invalid(name + " 必须是布尔值。");
return (bool)token;
}
private static void EnsureOnly(JObject value, params string[] allowed)
{
HashSet<string> names = new HashSet<string>(
allowed,
StringComparer.Ordinal);
JProperty unknown = value.Properties().FirstOrDefault(
item => !names.Contains(item.Name));
if (unknown != null)
throw Invalid("命令发布配置包含未知字段:" + unknown.Name);
}
private static bool IsSafeIdentifier(string value, int maximumLength)
{
return !string.IsNullOrEmpty(value)
&& value.Length <= maximumLength
&& SafeIdentifier.IsMatch(value);
}
private static void EnsureCleanText(
string value,
int maximumLength,
string fieldName)
{
if (string.IsNullOrEmpty(value)
|| value.Length > maximumLength
|| !string.Equals(value, value.Trim(), StringComparison.Ordinal))
{
throw Invalid(fieldName + " 为空、过长或包含首尾空白。");
}
foreach (char item in value)
{
UnicodeCategory category = char.GetUnicodeCategory(item);
if (char.IsControl(item)
|| char.IsSurrogate(item)
|| category == UnicodeCategory.Format
|| category == UnicodeCategory.LineSeparator
|| category == UnicodeCategory.ParagraphSeparator)
{
throw Invalid(fieldName + " 包含不允许的控制或格式字符。");
}
}
}
private static string Sha256(byte[] value)
{
using (SHA256 sha = SHA256.Create())
{
byte[] hash = sha.ComputeHash(value);
StringBuilder result = new StringBuilder(hash.Length * 2);
foreach (byte item in hash) result.Append(item.ToString("x2"));
return result.ToString();
}
}
private static bool FixedTimeEquals(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 CommandAuthorizationDecision Deny()
{
return CommandAuthorizationDecision.Deny(
"command_rollout_denied",
"该命令不在当前客户 ERP 会话的发布范围内。");
}
private static CommandKernelException Invalid(string message)
{
return new CommandKernelException(
"command_rollout_policy_invalid",
message,
6);
}
}
/// <summary>
/// Json.NET intentionally accepts several JavaScript extensions. Runtime
/// configuration files are human-reviewed and hash-pinned, so accepting a
/// BOM, comments, trailing commas or non-JSON whitespace would make the
/// reviewed text contract ambiguous. The normal Json.NET parser still
/// performs the complete structural/type validation after this bounded
/// lexical gate.
/// </summary>
internal static class StrictRuntimeJsonSyntax
{
public static bool IsStandard(string source)
{
if (string.IsNullOrEmpty(source) || source[0] == '\uFEFF')
return false;
bool inString = false;
bool escaped = false;
char previousSignificant = '\0';
for (int index = 0; index < source.Length; index++)
{
char current = source[index];
if (inString)
{
if (escaped)
{
escaped = false;
continue;
}
if (current == '\\')
{
escaped = true;
continue;
}
if (current == '"')
{
inString = false;
continue;
}
if (current < 0x20)
return false;
continue;
}
if (current == '"')
{
inString = true;
previousSignificant = current;
continue;
}
if (current == '/'
&& index + 1 < source.Length
&& (source[index + 1] == '/'
|| source[index + 1] == '*'))
return false;
if ((current == '}' || current == ']')
&& previousSignificant == ',')
return false;
if (current == ' ' || current == '\t'
|| current == '\r' || current == '\n')
continue;
if (char.IsWhiteSpace(current))
return false;
previousSignificant = current;
}
return !inString && !escaped;
}
}
internal sealed class CommandRolloutScope
{
private readonly HashSet<string> _values;
public CommandRolloutScope(bool all, HashSet<string> values)
{
All = all;
_values = values ?? new HashSet<string>(StringComparer.Ordinal);
}
public bool All { get; private set; }
public bool Contains(string value)
{
return All || _values.Contains(value ?? string.Empty);
}
}
internal sealed class CommandRolloutRule
{
public const string AllAuthorized = "all_authorized";
public const string Administrators = "administrators";
public const string Users = "users";
public string Command { get; set; }
public string CommandVersion { get; set; }
public string RequiredPermission { get; set; }
public CommandRolloutScope AccountBooks { get; set; }
public CommandRolloutScope SubSystemIds { get; set; }
public string Audience { get; set; }
public HashSet<string> UserIds { get; set; }
}
}