using System.Collections.Concurrent; using System.Text.RegularExpressions; using w4c_workflows.Models.Nodes; namespace w4c_workflows.Services.Security; /// /// Decides whether a blueprint may run under the operator's /// . The policy is pure and needs no I/O, so /// it is enforced at three layers with the same answer: the palette endpoint /// (what the author can pick), the compiler (what can be saved) and the run /// kernel (defence in depth, so a graph persisted before a policy change cannot /// bypass it). /// public sealed class NodePermissionPolicy { // Glob patterns expand to a compiled regex; caching keeps the per-node check // cheap even though patterns are matched on every invocation. private static readonly ConcurrentDictionary PatternCache = new(StringComparer.Ordinal); private readonly NodePermissionOptions _options; public NodePermissionPolicy(NodePermissionOptions options) => _options = options; /// True when the blueprint is permitted for the given tenant. public bool IsPermitted(NodeBlueprint blueprint, string? tenantId = null) => Evaluate(blueprint, tenantId).Allowed; /// /// Evaluates one blueprint. A tenant with an explicit rule is judged by that /// rule alone; everyone else falls back to the global rule. /// public PolicyDecision Evaluate(NodeBlueprint blueprint, string? tenantId = null) { var rule = ResolveRule(tenantId); // Deny lists win over everything, including an explicit allow entry. if (MatchesAny(blueprint.Type, rule.DenyTypes)) return PolicyDecision.Deny( "node_type_blocked", $"node type '{blueprint.Type}' is blocked by policy"); if (IsCodeNode(blueprint) && !rule.AllowCodeNodes) return PolicyDecision.Deny( "code_node_disabled", "code nodes are disabled by policy"); if (IsSubWorkflowNode(blueprint) && !rule.AllowSubWorkflows) return PolicyDecision.Deny( "sub_workflow_disabled", "sub-workflow nodes are disabled by policy"); if (MatchesAny(blueprint.Kind, rule.DenyKinds)) return PolicyDecision.Deny( "node_kind_blocked", $"node kind '{blueprint.Kind}' is blocked by policy"); if (MatchesAny(blueprint.Origin, rule.DenyOrigins)) return PolicyDecision.Deny( "node_origin_blocked", $"node origin '{blueprint.Origin}' is blocked by policy"); // A non-empty allow list is a whitelist: nothing outside it may run. if (rule.AllowTypes.Count > 0 && !MatchesAny(blueprint.Type, rule.AllowTypes)) return PolicyDecision.Deny( "node_type_not_permitted", $"node type '{blueprint.Type}' is not permitted by policy"); if (rule.AllowKinds.Count > 0 && !MatchesAny(blueprint.Kind, rule.AllowKinds)) return PolicyDecision.Deny( "node_kind_not_permitted", $"node kind '{blueprint.Kind}' is not permitted by policy"); if (rule.AllowOrigins.Count > 0 && !MatchesAny(blueprint.Origin, rule.AllowOrigins)) return PolicyDecision.Deny( "node_origin_not_permitted", $"node origin '{blueprint.Origin}' is not permitted by policy"); return PolicyDecision.Permit(); } private NodePermissionRule ResolveRule(string? tenantId) => !string.IsNullOrEmpty(tenantId) && _options.Tenants.TryGetValue(tenantId, out var tenantRule) ? tenantRule : _options; private static bool IsCodeNode(NodeBlueprint blueprint) => string.Equals(blueprint.Type, "core.code", StringComparison.OrdinalIgnoreCase); private static bool IsSubWorkflowNode(NodeBlueprint blueprint) => string.Equals(blueprint.Type, "core.executeWorkflow", StringComparison.OrdinalIgnoreCase); /// True when matches any non-empty pattern. public static bool MatchesAny(string value, IEnumerable patterns) { foreach (var pattern in patterns) { if (!string.IsNullOrWhiteSpace(pattern) && MatchesPattern(value, pattern)) return true; } return false; } /// /// Case-insensitive glob match where * stands for any run of /// characters. A pattern without * is an exact match. Anchored at both /// ends, so core does not match core.set but core.* does. /// public static bool MatchesPattern(string value, string pattern) { pattern = pattern.Trim(); if (pattern.Length == 0) return false; if (string.Equals(pattern, "*", StringComparison.Ordinal)) return true; var regex = PatternCache.GetOrAdd(pattern, static p => new Regex( "^" + Regex.Escape(p).Replace("\\*", ".*", StringComparison.Ordinal) + "$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled)); return regex.IsMatch(value); } }