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);
}
}