audit, advisories and doctors/setup work
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Policy.Engine.Snapshots;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ using System.Security.Claims;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Services;
|
||||
|
||||
internal static class ScopeAuthorization
|
||||
public static class ScopeAuthorization
|
||||
{
|
||||
private static readonly StringComparer ScopeComparer = StringComparer.OrdinalIgnoreCase;
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Contracts;
|
||||
|
||||
public sealed record ToolAccessRequest
|
||||
{
|
||||
public string? TenantId { get; init; }
|
||||
public string? Tool { get; init; }
|
||||
public string? Action { get; init; }
|
||||
public string? Resource { get; init; }
|
||||
public IReadOnlyList<string>? Scopes { get; init; }
|
||||
public IReadOnlyList<string>? Roles { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ToolAccessResponse
|
||||
{
|
||||
public bool Allowed { get; init; }
|
||||
public string Reason { get; init; } = string.Empty;
|
||||
public string? RuleId { get; init; }
|
||||
public string? RuleEffect { get; init; }
|
||||
public IReadOnlyList<string> RequiredScopes { get; init; } = Array.Empty<string>();
|
||||
public IReadOnlyList<string> RequiredRoles { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Policy.Gateway.Contracts;
|
||||
using StellaOps.Policy.ToolLattice;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Endpoints;
|
||||
|
||||
public static class ToolLatticeEndpoints
|
||||
{
|
||||
public static void MapToolLatticeEndpoints(this WebApplication app)
|
||||
{
|
||||
var tools = app.MapGroup("/api/v1/policy/assistant/tools")
|
||||
.WithTags("Assistant Tools");
|
||||
|
||||
tools.MapPost("/evaluate", (HttpContext httpContext, ToolAccessRequest request, IToolAccessEvaluator evaluator) =>
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Request body required",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
var tenantId = !string.IsNullOrWhiteSpace(request.TenantId)
|
||||
? request.TenantId
|
||||
: GetTenantId(httpContext);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Tenant id required",
|
||||
Status = StatusCodes.Status400BadRequest,
|
||||
Detail = "Provide tenant_id claim or X-Tenant-Id header."
|
||||
});
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Tool))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Tool name required",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Action))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Tool action required",
|
||||
Status = StatusCodes.Status400BadRequest,
|
||||
Detail = "Use read or action for tool requests."
|
||||
});
|
||||
}
|
||||
|
||||
var scopes = ResolveScopes(request, httpContext.User);
|
||||
var roles = ResolveRoles(request, httpContext.User);
|
||||
|
||||
var decision = evaluator.Evaluate(new ToolAccessContext
|
||||
{
|
||||
TenantId = tenantId.Trim(),
|
||||
Tool = request.Tool.Trim(),
|
||||
Action = request.Action.Trim(),
|
||||
Resource = request.Resource?.Trim(),
|
||||
Scopes = scopes,
|
||||
Roles = roles
|
||||
});
|
||||
|
||||
return Results.Ok(new ToolAccessResponse
|
||||
{
|
||||
Allowed = decision.Allowed,
|
||||
Reason = decision.Reason,
|
||||
RuleId = decision.RuleId,
|
||||
RuleEffect = decision.RuleEffect?.ToString().ToLowerInvariant(),
|
||||
RequiredScopes = decision.RequiredScopes,
|
||||
RequiredRoles = decision.RequiredRoles
|
||||
});
|
||||
})
|
||||
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
|
||||
.WithName("EvaluateToolAccess")
|
||||
.WithDescription("Evaluate assistant tool access using the tool lattice rules.");
|
||||
}
|
||||
|
||||
private static string? GetTenantId(HttpContext httpContext)
|
||||
{
|
||||
return httpContext.User.FindFirstValue(StellaOpsClaimTypes.Tenant)
|
||||
?? httpContext.User.FindFirstValue("tenant_id")
|
||||
?? httpContext.Request.Headers["X-Tenant-Id"].FirstOrDefault()
|
||||
?? httpContext.Request.Headers["X-StellaOps-Tenant"].FirstOrDefault()
|
||||
?? httpContext.Request.Headers["X-Stella-Tenant"].FirstOrDefault();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ResolveScopes(ToolAccessRequest request, ClaimsPrincipal user)
|
||||
{
|
||||
if (request.Scopes is { Count: > 0 })
|
||||
{
|
||||
return NormalizeList(request.Scopes);
|
||||
}
|
||||
|
||||
var scopes = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var claim in user.FindAll(StellaOpsClaimTypes.ScopeItem))
|
||||
{
|
||||
var normalized = StellaOpsScopes.Normalize(claim.Value);
|
||||
if (normalized is not null)
|
||||
{
|
||||
scopes.Add(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var claim in user.FindAll(StellaOpsClaimTypes.Scope))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(claim.Value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var parts = claim.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
foreach (var part in parts)
|
||||
{
|
||||
var normalized = StellaOpsScopes.Normalize(part);
|
||||
if (normalized is not null)
|
||||
{
|
||||
scopes.Add(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return scopes.Count == 0
|
||||
? Array.Empty<string>()
|
||||
: scopes.OrderBy(static scope => scope, StringComparer.Ordinal).ToArray();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ResolveRoles(ToolAccessRequest request, ClaimsPrincipal user)
|
||||
{
|
||||
if (request.Roles is { Count: > 0 })
|
||||
{
|
||||
return NormalizeList(request.Roles);
|
||||
}
|
||||
|
||||
var roles = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var claim in user.FindAll(ClaimTypes.Role))
|
||||
{
|
||||
AddRoleValue(roles, claim.Value);
|
||||
}
|
||||
|
||||
foreach (var claim in user.FindAll("role"))
|
||||
{
|
||||
AddRoleValue(roles, claim.Value);
|
||||
}
|
||||
|
||||
foreach (var claim in user.FindAll("roles"))
|
||||
{
|
||||
AddRoleValue(roles, claim.Value);
|
||||
}
|
||||
|
||||
return roles.Count == 0
|
||||
? Array.Empty<string>()
|
||||
: roles.OrderBy(static role => role, StringComparer.Ordinal).ToArray();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> NormalizeList(IReadOnlyCollection<string> values)
|
||||
{
|
||||
var normalized = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var value in values)
|
||||
{
|
||||
var trimmed = value?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(trimmed))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
normalized.Add(trimmed.ToLowerInvariant());
|
||||
}
|
||||
|
||||
return normalized.Count == 0
|
||||
? Array.Empty<string>()
|
||||
: normalized.OrderBy(static value => value, StringComparer.Ordinal).ToArray();
|
||||
}
|
||||
|
||||
private static void AddRoleValue(ISet<string> roles, string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var parts = value.Split(
|
||||
new[] { ' ', ',' },
|
||||
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
foreach (var part in parts)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(part))
|
||||
{
|
||||
roles.Add(part.Trim().ToLowerInvariant());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ using StellaOps.Policy.Gateway.Services;
|
||||
using StellaOps.Policy.Deltas;
|
||||
using StellaOps.Policy.Engine.Gates;
|
||||
using StellaOps.Policy.Snapshots;
|
||||
using StellaOps.Policy.ToolLattice;
|
||||
using StellaOps.Policy.Persistence.Postgres;
|
||||
using Polly;
|
||||
using Polly.Extensions.Http;
|
||||
@@ -100,6 +101,25 @@ builder.Services.AddOptions<PolicyGatewayOptions>()
|
||||
})
|
||||
.ValidateOnStart();
|
||||
|
||||
builder.Services.AddOptions<ToolLatticeOptions>()
|
||||
.Bind(builder.Configuration.GetSection($"{PolicyGatewayOptions.SectionName}:{ToolLatticeOptions.SectionName}"))
|
||||
.Validate(options =>
|
||||
{
|
||||
try
|
||||
{
|
||||
options.Validate();
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new OptionsValidationException(
|
||||
ToolLatticeOptions.SectionName,
|
||||
typeof(ToolLatticeOptions),
|
||||
new[] { ex.Message });
|
||||
}
|
||||
})
|
||||
.ValidateOnStart();
|
||||
|
||||
builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<PolicyGatewayOptions>>().Value);
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
builder.Services.AddRouting(options => options.LowercaseUrls = true);
|
||||
@@ -151,6 +171,8 @@ builder.Services.AddScoped<StellaOps.Policy.Persistence.Postgres.Repositories.IE
|
||||
builder.Services.AddScoped<StellaOps.Policy.Engine.Services.IExceptionApprovalRulesService,
|
||||
StellaOps.Policy.Engine.Services.ExceptionApprovalRulesService>();
|
||||
|
||||
builder.Services.AddSingleton<IToolAccessEvaluator, ToolAccessEvaluator>();
|
||||
|
||||
builder.Services.AddStellaOpsResourceServerAuthentication(
|
||||
builder.Configuration,
|
||||
configurationSection: $"{PolicyGatewayOptions.SectionName}:ResourceServer");
|
||||
@@ -533,6 +555,9 @@ app.MapExceptionApprovalEndpoints();
|
||||
// Governance endpoints (Sprint: SPRINT_20251229_021a_FE_policy_governance_controls, Task: GOV-018)
|
||||
app.MapGovernanceEndpoints();
|
||||
|
||||
// Assistant tool lattice endpoints (Sprint: SPRINT_20260113_005_POLICY_assistant_tool_lattice)
|
||||
app.MapToolLatticeEndpoints();
|
||||
|
||||
app.Run();
|
||||
|
||||
static IAsyncPolicy<HttpResponseMessage> CreateAuthorityRetryPolicy(IServiceProvider provider)
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Policy.ToolLattice;
|
||||
|
||||
public sealed record ToolAccessContext
|
||||
{
|
||||
public required string TenantId { get; init; }
|
||||
public required string Tool { get; init; }
|
||||
public string? Action { get; init; }
|
||||
public string? Resource { get; init; }
|
||||
public IReadOnlyList<string> Scopes { get; init; } = Array.Empty<string>();
|
||||
public IReadOnlyList<string> Roles { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Policy.ToolLattice;
|
||||
|
||||
public sealed record ToolAccessDecision
|
||||
{
|
||||
public required bool Allowed { get; init; }
|
||||
public required string Reason { get; init; }
|
||||
public string? RuleId { get; init; }
|
||||
public ToolAccessEffect? RuleEffect { get; init; }
|
||||
public IReadOnlyList<string> RequiredScopes { get; init; } = Array.Empty<string>();
|
||||
public IReadOnlyList<string> RequiredRoles { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace StellaOps.Policy.ToolLattice;
|
||||
|
||||
public enum ToolAccessEffect
|
||||
{
|
||||
Allow,
|
||||
Deny
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Policy.ToolLattice;
|
||||
|
||||
public interface IToolAccessEvaluator
|
||||
{
|
||||
ToolAccessDecision Evaluate(ToolAccessContext context);
|
||||
}
|
||||
|
||||
public sealed class ToolAccessEvaluator : IToolAccessEvaluator
|
||||
{
|
||||
private readonly ToolLatticeOptions options;
|
||||
private readonly IReadOnlyList<NormalizedToolAccessRule> rules;
|
||||
|
||||
public ToolAccessEvaluator(IOptions<ToolLatticeOptions> options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
this.options = options.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
rules = ToolLatticeRuleSet.Build(this.options);
|
||||
}
|
||||
|
||||
public ToolAccessDecision Evaluate(ToolAccessContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
if (!options.Enabled)
|
||||
{
|
||||
return new ToolAccessDecision
|
||||
{
|
||||
Allowed = true,
|
||||
Reason = "tool_lattice_disabled"
|
||||
};
|
||||
}
|
||||
|
||||
var normalized = ToolLatticeContext.Normalize(context);
|
||||
|
||||
foreach (var rule in rules)
|
||||
{
|
||||
if (ToolLatticeRuleMatcher.Matches(rule, normalized))
|
||||
{
|
||||
var allowed = rule.Effect == ToolAccessEffect.Allow;
|
||||
return new ToolAccessDecision
|
||||
{
|
||||
Allowed = allowed,
|
||||
Reason = allowed ? "rule_allow" : "rule_deny",
|
||||
RuleId = rule.Id,
|
||||
RuleEffect = rule.Effect,
|
||||
RequiredScopes = rule.Scopes,
|
||||
RequiredRoles = rule.Roles
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return options.AllowByDefault
|
||||
? new ToolAccessDecision
|
||||
{
|
||||
Allowed = true,
|
||||
Reason = "default_allow"
|
||||
}
|
||||
: new ToolAccessDecision
|
||||
{
|
||||
Allowed = false,
|
||||
Reason = "default_deny"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record NormalizedToolAccessRule(
|
||||
string Id,
|
||||
string Tool,
|
||||
string? Action,
|
||||
string? Resource,
|
||||
ToolAccessEffect Effect,
|
||||
int Priority,
|
||||
IReadOnlyList<string> Scopes,
|
||||
IReadOnlyList<string> Roles,
|
||||
IReadOnlyList<string> Tenants,
|
||||
int Order);
|
||||
|
||||
internal sealed record NormalizedToolAccessContext(
|
||||
string TenantId,
|
||||
string Tool,
|
||||
string? Action,
|
||||
string? Resource,
|
||||
IReadOnlyList<string> Scopes,
|
||||
IReadOnlyList<string> Roles);
|
||||
|
||||
internal static class ToolLatticeRuleSet
|
||||
{
|
||||
public static IReadOnlyList<NormalizedToolAccessRule> Build(ToolLatticeOptions options)
|
||||
{
|
||||
if (!options.Enabled)
|
||||
{
|
||||
return Array.Empty<NormalizedToolAccessRule>();
|
||||
}
|
||||
|
||||
var defaults = options.UseDefaultRules
|
||||
? ToolLatticeDefaults.CreateDefaults()
|
||||
: Array.Empty<ToolAccessRule>();
|
||||
|
||||
var rules = new List<ToolAccessRule>(defaults.Count + options.Rules.Count);
|
||||
rules.AddRange(defaults);
|
||||
rules.AddRange(options.Rules);
|
||||
|
||||
var normalized = new List<NormalizedToolAccessRule>(rules.Count);
|
||||
var order = 0;
|
||||
foreach (var rule in defaults)
|
||||
{
|
||||
ValidateRule(rule, "default");
|
||||
|
||||
var id = NormalizeScalar(rule.Id);
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
id = $"rule-{order}";
|
||||
}
|
||||
|
||||
var tool = NormalizeScalar(rule.Tool) ?? string.Empty;
|
||||
var action = NormalizeScalar(rule.Action);
|
||||
var resource = NormalizeScalar(rule.Resource);
|
||||
var scopes = NormalizeList(rule.Scopes);
|
||||
var roles = NormalizeList(rule.Roles);
|
||||
var tenants = NormalizeList(rule.Tenants);
|
||||
|
||||
normalized.Add(new NormalizedToolAccessRule(
|
||||
id,
|
||||
tool,
|
||||
action,
|
||||
resource,
|
||||
rule.Effect,
|
||||
rule.Priority,
|
||||
scopes,
|
||||
roles,
|
||||
tenants,
|
||||
order));
|
||||
order++;
|
||||
}
|
||||
|
||||
foreach (var rule in options.Rules)
|
||||
{
|
||||
ValidateRule(rule, "custom");
|
||||
|
||||
var id = NormalizeScalar(rule.Id);
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
id = $"rule-{order}";
|
||||
}
|
||||
|
||||
var tool = NormalizeScalar(rule.Tool) ?? string.Empty;
|
||||
var action = NormalizeScalar(rule.Action);
|
||||
var resource = NormalizeScalar(rule.Resource);
|
||||
var scopes = NormalizeList(rule.Scopes);
|
||||
var roles = NormalizeList(rule.Roles);
|
||||
var tenants = NormalizeList(rule.Tenants);
|
||||
|
||||
normalized.Add(new NormalizedToolAccessRule(
|
||||
id,
|
||||
tool,
|
||||
action,
|
||||
resource,
|
||||
rule.Effect,
|
||||
rule.Priority,
|
||||
scopes,
|
||||
roles,
|
||||
tenants,
|
||||
order));
|
||||
order++;
|
||||
}
|
||||
|
||||
return normalized
|
||||
.OrderByDescending(static rule => rule.Priority)
|
||||
.ThenBy(static rule => rule.Order)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public static void ValidateRule(ToolAccessRule rule, string source)
|
||||
{
|
||||
if (rule is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Tool lattice {source} rule cannot be null.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(rule.Tool))
|
||||
{
|
||||
throw new InvalidOperationException($"Tool lattice {source} rule requires a tool pattern.");
|
||||
}
|
||||
|
||||
if (!Enum.IsDefined(typeof(ToolAccessEffect), rule.Effect))
|
||||
{
|
||||
throw new InvalidOperationException($"Tool lattice {source} rule has invalid effect '{rule.Effect}'.");
|
||||
}
|
||||
}
|
||||
|
||||
private static string? NormalizeScalar(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> NormalizeList(IList<string> values)
|
||||
{
|
||||
if (values is null || values.Count == 0)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var normalized = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var value in values)
|
||||
{
|
||||
var scalar = NormalizeScalar(value);
|
||||
if (string.IsNullOrWhiteSpace(scalar))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
normalized.Add(scalar);
|
||||
}
|
||||
|
||||
return normalized.Count == 0
|
||||
? Array.Empty<string>()
|
||||
: normalized.OrderBy(static item => item, StringComparer.Ordinal).ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
internal static class ToolLatticeContext
|
||||
{
|
||||
public static NormalizedToolAccessContext Normalize(ToolAccessContext context)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(context.TenantId))
|
||||
{
|
||||
throw new InvalidOperationException("Tool lattice context requires a tenant id.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(context.Tool))
|
||||
{
|
||||
throw new InvalidOperationException("Tool lattice context requires a tool name.");
|
||||
}
|
||||
|
||||
var tenant = context.TenantId.Trim().ToLowerInvariant();
|
||||
var tool = context.Tool.Trim().ToLowerInvariant();
|
||||
var action = NormalizeScalar(context.Action);
|
||||
var resource = NormalizeScalar(context.Resource);
|
||||
var scopes = NormalizeList(context.Scopes);
|
||||
var roles = NormalizeList(context.Roles);
|
||||
|
||||
return new NormalizedToolAccessContext(
|
||||
tenant,
|
||||
tool,
|
||||
action,
|
||||
resource,
|
||||
scopes,
|
||||
roles);
|
||||
}
|
||||
|
||||
private static string? NormalizeScalar(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> NormalizeList(IReadOnlyList<string> values)
|
||||
{
|
||||
if (values is null || values.Count == 0)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var normalized = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var value in values)
|
||||
{
|
||||
var scalar = NormalizeScalar(value);
|
||||
if (string.IsNullOrWhiteSpace(scalar))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
normalized.Add(scalar);
|
||||
}
|
||||
|
||||
return normalized.Count == 0
|
||||
? Array.Empty<string>()
|
||||
: normalized.OrderBy(static item => item, StringComparer.Ordinal).ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
internal static class ToolLatticeRuleMatcher
|
||||
{
|
||||
public static bool Matches(NormalizedToolAccessRule rule, NormalizedToolAccessContext context)
|
||||
{
|
||||
if (!MatchesPattern(context.Tool, rule.Tool))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!MatchesPattern(context.Action, rule.Action))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!MatchesPattern(context.Resource, rule.Resource))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!MatchesValue(context.TenantId, rule.Tenants))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!MatchesAny(context.Roles, rule.Roles))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!MatchesAny(context.Scopes, rule.Scopes))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool MatchesAny(IReadOnlyList<string> values, IReadOnlyList<string> patterns)
|
||||
{
|
||||
if (patterns.Count == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (values.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var value in values)
|
||||
{
|
||||
if (MatchesValue(value, patterns))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool MatchesValue(string? value, IReadOnlyList<string> patterns)
|
||||
{
|
||||
if (patterns.Count == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var pattern in patterns)
|
||||
{
|
||||
if (MatchesPattern(value, pattern))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool MatchesPattern(string? value, string? pattern)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pattern))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (pattern == "*")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!pattern.Contains('*'))
|
||||
{
|
||||
return string.Equals(value, pattern, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
var segments = pattern.Split('*', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (segments.Length == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pattern[0] != '*' &&
|
||||
!value.StartsWith(segments[0], StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var index = 0;
|
||||
foreach (var segment in segments)
|
||||
{
|
||||
var matchIndex = value.IndexOf(segment, index, StringComparison.Ordinal);
|
||||
if (matchIndex < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
index = matchIndex + segment.Length;
|
||||
}
|
||||
|
||||
if (pattern[^1] != '*' &&
|
||||
!value.EndsWith(segments[^1], StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Policy.ToolLattice;
|
||||
|
||||
public sealed class ToolAccessRule
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string Tool { get; set; } = string.Empty;
|
||||
public string? Action { get; set; }
|
||||
public string? Resource { get; set; }
|
||||
public ToolAccessEffect Effect { get; set; } = ToolAccessEffect.Allow;
|
||||
public int Priority { get; set; }
|
||||
public IList<string> Scopes { get; } = new List<string>();
|
||||
public IList<string> Roles { get; } = new List<string>();
|
||||
public IList<string> Tenants { get; } = new List<string>();
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Policy.ToolLattice;
|
||||
|
||||
internal static class ToolLatticeDefaults
|
||||
{
|
||||
public static IReadOnlyList<ToolAccessRule> CreateDefaults()
|
||||
{
|
||||
return new[]
|
||||
{
|
||||
new ToolAccessRule
|
||||
{
|
||||
Id = "default-vex-query",
|
||||
Tool = "vex.query",
|
||||
Action = "read",
|
||||
Effect = ToolAccessEffect.Allow,
|
||||
Priority = -100,
|
||||
Scopes = { "vex:read" }
|
||||
},
|
||||
new ToolAccessRule
|
||||
{
|
||||
Id = "default-sbom-read",
|
||||
Tool = "sbom.read",
|
||||
Action = "read",
|
||||
Effect = ToolAccessEffect.Allow,
|
||||
Priority = -100,
|
||||
Scopes = { "sbom:read" }
|
||||
},
|
||||
new ToolAccessRule
|
||||
{
|
||||
Id = "default-scanner-findings-topk",
|
||||
Tool = "scanner.findings.topk",
|
||||
Action = "read",
|
||||
Effect = ToolAccessEffect.Allow,
|
||||
Priority = -100,
|
||||
Scopes = { "scanner:read", "findings:read" }
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Policy.ToolLattice;
|
||||
|
||||
public sealed class ToolLatticeOptions
|
||||
{
|
||||
public const string SectionName = "ToolLattice";
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
public bool AllowByDefault { get; set; }
|
||||
public bool UseDefaultRules { get; set; } = true;
|
||||
public IList<ToolAccessRule> Rules { get; } = new List<ToolAccessRule>();
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (!Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var rule in Rules)
|
||||
{
|
||||
// TODO: Re-enable when ToolLatticeRuleSet is implemented
|
||||
// ToolLatticeRuleSet.ValidateRule(rule, "custom");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,7 +99,7 @@ public sealed class PolicyEngineWebServiceFixture : WebServiceFixture<StellaOps.
|
||||
_ => { });
|
||||
}
|
||||
|
||||
private static void ConfigureWebHost(IWebHostBuilder builder)
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.ConfigureAppConfiguration((_, config) =>
|
||||
{
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using StellaOps.Policy.Gateway.Contracts;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
using GatewayProgram = StellaOps.Policy.Gateway.Program;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Tests;
|
||||
|
||||
public sealed class ToolLatticeEndpointsTests : IClassFixture<WebApplicationFactory<GatewayProgram>>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public ToolLatticeEndpointsTests(WebApplicationFactory<GatewayProgram> factory)
|
||||
{
|
||||
_client = factory.CreateClient();
|
||||
_client.DefaultRequestHeaders.Add("X-Tenant-Id", "tenant-a");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Evaluate_ReturnsDecision()
|
||||
{
|
||||
var request = new ToolAccessRequest
|
||||
{
|
||||
Tool = "vex.query",
|
||||
Action = "read",
|
||||
Scopes = new[] { "vex:read" }
|
||||
};
|
||||
|
||||
var response = await _client.PostAsJsonAsync("/api/v1/policy/assistant/tools/evaluate", request, CancellationToken.None);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var payload = await response.Content.ReadFromJsonAsync<ToolAccessResponse>(cancellationToken: CancellationToken.None);
|
||||
Assert.NotNull(payload);
|
||||
Assert.True(payload!.Allowed);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Evaluate_ReturnsBadRequest_WhenToolMissing()
|
||||
{
|
||||
var request = new ToolAccessRequest
|
||||
{
|
||||
Action = "read",
|
||||
Scopes = new[] { "vex:read" }
|
||||
};
|
||||
|
||||
var response = await _client.PostAsJsonAsync("/api/v1/policy/assistant/tools/evaluate", request, CancellationToken.None);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
var payload = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: CancellationToken.None);
|
||||
Assert.True(payload.TryGetProperty("title", out _));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy.ToolLattice;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Tests.ToolLattice;
|
||||
|
||||
public sealed class ToolAccessEvaluatorTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void DefaultRules_AllowReadTool_WhenScopeMatches()
|
||||
{
|
||||
var options = new ToolLatticeOptions
|
||||
{
|
||||
UseDefaultRules = true
|
||||
};
|
||||
var evaluator = new ToolAccessEvaluator(Options.Create(options));
|
||||
|
||||
var decision = evaluator.Evaluate(new ToolAccessContext
|
||||
{
|
||||
TenantId = "tenant-a",
|
||||
Tool = "vex.query",
|
||||
Action = "read",
|
||||
Scopes = new[] { "vex:read" }
|
||||
});
|
||||
|
||||
Assert.True(decision.Allowed);
|
||||
Assert.Equal("rule_allow", decision.Reason);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void DefaultRules_DenyWhenScopeMissing()
|
||||
{
|
||||
var options = new ToolLatticeOptions
|
||||
{
|
||||
UseDefaultRules = true
|
||||
};
|
||||
var evaluator = new ToolAccessEvaluator(Options.Create(options));
|
||||
|
||||
var decision = evaluator.Evaluate(new ToolAccessContext
|
||||
{
|
||||
TenantId = "tenant-a",
|
||||
Tool = "sbom.read",
|
||||
Action = "read",
|
||||
Scopes = new[] { "vex:read" }
|
||||
});
|
||||
|
||||
Assert.False(decision.Allowed);
|
||||
Assert.Equal("default_deny", decision.Reason);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void DefaultRules_DenyWhenActionMismatch()
|
||||
{
|
||||
var options = new ToolLatticeOptions
|
||||
{
|
||||
UseDefaultRules = true
|
||||
};
|
||||
var evaluator = new ToolAccessEvaluator(Options.Create(options));
|
||||
|
||||
var decision = evaluator.Evaluate(new ToolAccessContext
|
||||
{
|
||||
TenantId = "tenant-a",
|
||||
Tool = "scanner.findings.topk",
|
||||
Action = "action",
|
||||
Scopes = new[] { "scanner:read" }
|
||||
});
|
||||
|
||||
Assert.False(decision.Allowed);
|
||||
Assert.Equal("default_deny", decision.Reason);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CustomRule_RespectsTenantConstraint()
|
||||
{
|
||||
var options = new ToolLatticeOptions
|
||||
{
|
||||
UseDefaultRules = false
|
||||
};
|
||||
|
||||
var rule = new ToolAccessRule
|
||||
{
|
||||
Tool = "vex.query",
|
||||
Action = "read",
|
||||
Effect = ToolAccessEffect.Allow
|
||||
};
|
||||
rule.Scopes.Add("vex:read");
|
||||
rule.Tenants.Add("tenant-a");
|
||||
options.Rules.Add(rule);
|
||||
|
||||
var evaluator = new ToolAccessEvaluator(Options.Create(options));
|
||||
|
||||
var decision = evaluator.Evaluate(new ToolAccessContext
|
||||
{
|
||||
TenantId = "tenant-b",
|
||||
Tool = "vex.query",
|
||||
Action = "read",
|
||||
Scopes = new[] { "vex:read" }
|
||||
});
|
||||
|
||||
Assert.False(decision.Allowed);
|
||||
Assert.Equal("default_deny", decision.Reason);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ScopeOrdering_DoesNotChangeDecision()
|
||||
{
|
||||
var options = new ToolLatticeOptions
|
||||
{
|
||||
UseDefaultRules = true
|
||||
};
|
||||
var evaluator = new ToolAccessEvaluator(Options.Create(options));
|
||||
|
||||
var decisionA = evaluator.Evaluate(new ToolAccessContext
|
||||
{
|
||||
TenantId = "tenant-a",
|
||||
Tool = "scanner.findings.topk",
|
||||
Action = "read",
|
||||
Scopes = new[] { "findings:read", "scanner:read" }
|
||||
});
|
||||
|
||||
var decisionB = evaluator.Evaluate(new ToolAccessContext
|
||||
{
|
||||
TenantId = "tenant-a",
|
||||
Tool = "scanner.findings.topk",
|
||||
Action = "read",
|
||||
Scopes = new[] { "scanner:read", "findings:read" }
|
||||
});
|
||||
|
||||
Assert.Equal(decisionA.Allowed, decisionB.Allowed);
|
||||
Assert.Equal(decisionA.Reason, decisionB.Reason);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user