117 lines
5.1 KiB
C#
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);
|
|
}
|
|
}
|