audit, advisories and doctors/setup work

This commit is contained in:
master
2026-01-13 18:53:39 +02:00
parent 9ca7cb183e
commit d7be6ba34b
811 changed files with 54242 additions and 4056 deletions

View File

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

View File

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

View File

@@ -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)