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 { /// /// 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. /// 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 _rules; private readonly bool _defaultAllow; private CommandRolloutPolicy( bool configured, string customerId, string databaseScopeFingerprint, string sourceSha256, bool defaultAllow, IEnumerable rules) { Configured = configured; CustomerId = customerId; DatabaseScopeFingerprint = databaseScopeFingerprint; SourceSha256 = sourceSha256; _defaultAllow = defaultAllow; _rules = new Dictionary( 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 rules = new List(); HashSet commandNames = new HashSet( 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 registeredCommands) { if (!Configured) return; if (registeredCommands == null) throw Invalid("无法校验当前命令注册表。"); Dictionary descriptors = new Dictionary( 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 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 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 ParseStringSet( JArray values, bool allowUnicode, string fieldName) { if (values.Count > MaximumScopeValues) throw Invalid(fieldName + " 数量超过上限。"); HashSet result = new HashSet(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 names = new HashSet( 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); } } /// /// 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. /// 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 _values; public CommandRolloutScope(bool all, HashSet values) { All = all; _values = values ?? new HashSet(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 UserIds { get; set; } } }