w4c-workflows-api/Services/Security/NodePermissionPolicy.cs
Vitali sharp8n 42ffcb9adc workflows
2026-09-13 19:28:47 +03:00

117 lines
5.1 KiB
C#

using System.Collections.Concurrent;
using System.Text.RegularExpressions;
using w4c_workflows.Models.Nodes;
namespace w4c_workflows.Services.Security;
/// <summary>
/// Decides whether a blueprint may run under the operator's
/// <see cref="NodePermissionOptions"/>. 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).
/// </summary>
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<string, Regex> PatternCache = new(StringComparer.Ordinal);
private readonly NodePermissionOptions _options;
public NodePermissionPolicy(NodePermissionOptions options) => _options = options;
/// <summary>True when the blueprint is permitted for the given tenant.</summary>
public bool IsPermitted(NodeBlueprint blueprint, string? tenantId = null)
=> Evaluate(blueprint, tenantId).Allowed;
/// <summary>
/// Evaluates one blueprint. A tenant with an explicit rule is judged by that
/// rule alone; everyone else falls back to the global rule.
/// </summary>
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);
/// <summary>True when <paramref name="value"/> matches any non-empty pattern.</summary>
public static bool MatchesAny(string value, IEnumerable<string> patterns)
{
foreach (var pattern in patterns)
{
if (!string.IsNullOrWhiteSpace(pattern) && MatchesPattern(value, pattern))
return true;
}
return false;
}
/// <summary>
/// Case-insensitive glob match where <c>*</c> stands for any run of
/// characters. A pattern without <c>*</c> is an exact match. Anchored at both
/// ends, so <c>core</c> does not match <c>core.set</c> but <c>core.*</c> does.
/// </summary>
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);
}
}