Repair live watchlist frontdoor routing
This commit is contained in:
@@ -15,6 +15,8 @@ using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StackExchange.Redis;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Attestor.Core.Bulk;
|
||||
using StellaOps.Attestor.Core.Offline;
|
||||
using StellaOps.Attestor.Core.Storage;
|
||||
@@ -99,6 +101,10 @@ public class AttestorTestWebApplicationFactory : WebApplicationFactory<Program>
|
||||
authenticationScheme: TestAuthHandler.SchemeName,
|
||||
displayName: null,
|
||||
configureOptions: options => { options.TimeProvider ??= TimeProvider.System; });
|
||||
authBuilder.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(
|
||||
authenticationScheme: StellaOpsAuthenticationDefaults.AuthenticationScheme,
|
||||
displayName: null,
|
||||
configureOptions: options => { options.TimeProvider ??= TimeProvider.System; });
|
||||
services.TryAddSingleton<TimeProvider>(TimeProvider.System);
|
||||
});
|
||||
}
|
||||
@@ -121,14 +127,48 @@ public class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions
|
||||
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
var claims = new[]
|
||||
if (!Request.Headers.TryGetValue("Authorization", out var authorizationHeader) ||
|
||||
string.IsNullOrWhiteSpace(authorizationHeader.ToString()))
|
||||
{
|
||||
new Claim(ClaimTypes.Name, "test-user"),
|
||||
new Claim(ClaimTypes.NameIdentifier, "test-user-id"),
|
||||
new Claim("tenant_id", "test-tenant"),
|
||||
new Claim("scope", "attestor:read attestor:write attestor.read attestor.write attestor.verify")
|
||||
return Task.FromResult(AuthenticateResult.NoResult());
|
||||
}
|
||||
|
||||
var tenantId = Request.Headers.TryGetValue("X-Test-Tenant", out var tenantHeader) &&
|
||||
!string.IsNullOrWhiteSpace(tenantHeader.ToString())
|
||||
? tenantHeader.ToString()
|
||||
: "test-tenant";
|
||||
var subject = Request.Headers.TryGetValue("X-Test-Subject", out var subjectHeader) &&
|
||||
!string.IsNullOrWhiteSpace(subjectHeader.ToString())
|
||||
? subjectHeader.ToString()
|
||||
: "test-user-id";
|
||||
var name = Request.Headers.TryGetValue("X-Test-Name", out var nameHeader) &&
|
||||
!string.IsNullOrWhiteSpace(nameHeader.ToString())
|
||||
? nameHeader.ToString()
|
||||
: "test-user";
|
||||
var scopeValue = Request.Headers.TryGetValue("X-Test-Scopes", out var scopesHeader) &&
|
||||
!string.IsNullOrWhiteSpace(scopesHeader.ToString())
|
||||
? scopesHeader.ToString()
|
||||
: "attestor:read attestor:write attestor.read attestor.write attestor.verify trust:read trust:write trust:admin";
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.Name, name),
|
||||
new(ClaimTypes.NameIdentifier, subject),
|
||||
new(StellaOpsClaimTypes.Subject, subject),
|
||||
new(StellaOpsClaimTypes.Tenant, tenantId),
|
||||
new("tenant_id", tenantId),
|
||||
new("scope", scopeValue)
|
||||
};
|
||||
|
||||
if (Request.Headers.TryGetValue("X-Test-Roles", out var rolesHeader) &&
|
||||
!string.IsNullOrWhiteSpace(rolesHeader.ToString()))
|
||||
{
|
||||
foreach (var role in rolesHeader.ToString().Split(' ', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
claims.Add(new Claim(ClaimTypes.Role, role));
|
||||
}
|
||||
}
|
||||
|
||||
var identity = new ClaimsIdentity(claims, SchemeName);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
var ticket = new AuthenticationTicket(principal, SchemeName);
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Attestor.Tests.Fixtures;
|
||||
using StellaOps.Attestor.Watchlist.Models;
|
||||
using StellaOps.Attestor.WebService;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Tests.Watchlist;
|
||||
|
||||
public sealed class WatchlistEndpointAuthorizationTests : IClassFixture<AttestorTestWebApplicationFactory>
|
||||
{
|
||||
private readonly AttestorTestWebApplicationFactory _factory;
|
||||
|
||||
public WatchlistEndpointAuthorizationTests(AttestorTestWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListWatchlistEntries_WithTrustReadScope_ReturnsOk()
|
||||
{
|
||||
using var client = CreateClient("trust:read", tenantId: "read-tenant");
|
||||
|
||||
var response = await client.GetAsync("/api/v1/watchlist?includeGlobal=false");
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var payload = await response.Content.ReadFromJsonAsync<WatchlistListResponse>();
|
||||
payload.Should().NotBeNull();
|
||||
payload!.TotalCount.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateWatchlistEntry_WithTrustWriteScope_UsesResolvedTenant()
|
||||
{
|
||||
using var client = CreateClient("trust:write", tenantId: "Demo-Prod");
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/watchlist", new WatchlistEntryRequest
|
||||
{
|
||||
DisplayName = "GitHub Actions",
|
||||
Issuer = "https://token.actions.githubusercontent.com",
|
||||
Scope = WatchlistScope.Tenant
|
||||
});
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Created);
|
||||
var payload = await response.Content.ReadFromJsonAsync<WatchlistEntryResponse>();
|
||||
payload.Should().NotBeNull();
|
||||
payload!.TenantId.Should().Be("demo-prod");
|
||||
payload.CreatedBy.Should().Be("test-user-id");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateGlobalWatchlistEntry_WithTrustWriteScope_ReturnsForbidden()
|
||||
{
|
||||
using var client = CreateClient("trust:write");
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/watchlist", new WatchlistEntryRequest
|
||||
{
|
||||
DisplayName = "Global watch",
|
||||
Issuer = "https://issuer.example",
|
||||
Scope = WatchlistScope.Global
|
||||
});
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateGlobalWatchlistEntry_WithTrustAdminScope_ReturnsCreated()
|
||||
{
|
||||
using var client = CreateClient("trust:admin");
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/watchlist", new WatchlistEntryRequest
|
||||
{
|
||||
DisplayName = "Global watch",
|
||||
Issuer = "https://issuer.example",
|
||||
Scope = WatchlistScope.Global
|
||||
});
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Created);
|
||||
}
|
||||
|
||||
private HttpClient CreateClient(string scopes, string tenantId = "test-tenant")
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "test-token");
|
||||
client.DefaultRequestHeaders.Add("X-Test-Scopes", scopes);
|
||||
client.DefaultRequestHeaders.Add("X-Test-Tenant", tenantId);
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ using StellaOps.Attestor.Persistence;
|
||||
using StellaOps.Attestor.ProofChain;
|
||||
using StellaOps.Attestor.Spdx3;
|
||||
using StellaOps.Attestor.Watchlist;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Attestor.WebService.Endpoints;
|
||||
using StellaOps.Attestor.WebService.Options;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
@@ -297,19 +298,40 @@ internal static class AttestorWebServiceComposition
|
||||
options.AddPolicy("watchlist:read", policy =>
|
||||
{
|
||||
policy.RequireAuthenticatedUser();
|
||||
policy.RequireAssertion(context => HasAnyScope(context.User, "watchlist.read", "watchlist.write", "attestor.write"));
|
||||
policy.RequireAssertion(context => HasAnyScope(
|
||||
context.User,
|
||||
StellaOpsScopes.TrustRead,
|
||||
StellaOpsScopes.TrustWrite,
|
||||
StellaOpsScopes.TrustAdmin,
|
||||
"watchlist:read",
|
||||
"watchlist:write",
|
||||
"watchlist:admin",
|
||||
"watchlist.read",
|
||||
"watchlist.write",
|
||||
"watchlist.admin"));
|
||||
});
|
||||
|
||||
options.AddPolicy("watchlist:write", policy =>
|
||||
{
|
||||
policy.RequireAuthenticatedUser();
|
||||
policy.RequireAssertion(context => HasAnyScope(context.User, "watchlist.write", "attestor.write"));
|
||||
policy.RequireAssertion(context => HasAnyScope(
|
||||
context.User,
|
||||
StellaOpsScopes.TrustWrite,
|
||||
StellaOpsScopes.TrustAdmin,
|
||||
"watchlist:write",
|
||||
"watchlist:admin",
|
||||
"watchlist.write",
|
||||
"watchlist.admin"));
|
||||
});
|
||||
|
||||
options.AddPolicy("watchlist:admin", policy =>
|
||||
{
|
||||
policy.RequireAuthenticatedUser();
|
||||
policy.RequireAssertion(context => HasAnyScope(context.User, "watchlist.admin", "attestor.write"));
|
||||
policy.RequireAssertion(context => HasAnyScope(
|
||||
context.User,
|
||||
StellaOpsScopes.TrustAdmin,
|
||||
"watchlist:admin",
|
||||
"watchlist.admin"));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| WATCHLIST-LIVE-017 | DONE | Sprint 20260309-017: repaired frontdoor watchlist routing and aligned watchlist auth with canonical trust scopes. |
|
||||
| ATTESTOR-225-002 | DOING | Sprint 225: enforce roster-based trust verification before verdict append. |
|
||||
| ATTESTOR-225-003 | DOING | Sprint 225: resolve tenant from authenticated claims and block spoofing. |
|
||||
| ATTESTOR-225-004 | DOING | Sprint 225: implement verdict-by-hash retrieval and tenant-scoped access checks. |
|
||||
|
||||
@@ -7,9 +7,12 @@
|
||||
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Attestor.Watchlist.Matching;
|
||||
using StellaOps.Attestor.Watchlist.Models;
|
||||
using StellaOps.Attestor.Watchlist.Storage;
|
||||
using System.Security.Claims;
|
||||
using static StellaOps.Localization.T;
|
||||
|
||||
namespace StellaOps.Attestor.WebService;
|
||||
@@ -286,20 +289,56 @@ internal static class WatchlistEndpoints
|
||||
|
||||
private static string GetTenantId(HttpContext context)
|
||||
{
|
||||
return context.User.FindFirst("tenant_id")?.Value ?? "default";
|
||||
return StellaOpsTenantResolver.ResolveTenantIdOrDefault(context);
|
||||
}
|
||||
|
||||
private static string GetUserId(HttpContext context)
|
||||
{
|
||||
return context.User.FindFirst("sub")?.Value ??
|
||||
context.User.FindFirst("name")?.Value ??
|
||||
"anonymous";
|
||||
return StellaOpsTenantResolver.ResolveActor(context);
|
||||
}
|
||||
|
||||
private static bool IsAdmin(HttpContext context)
|
||||
{
|
||||
return context.User.IsInRole("admin") ||
|
||||
context.User.HasClaim("scope", "watchlist:admin");
|
||||
HasAnyScope(context.User, StellaOpsScopes.TrustAdmin, "watchlist:admin", "watchlist.admin");
|
||||
}
|
||||
|
||||
private static bool HasAnyScope(ClaimsPrincipal user, params string[] scopes)
|
||||
{
|
||||
if (user.Identity is not { IsAuthenticated: true } || scopes.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var claim in user.FindAll("scope"))
|
||||
{
|
||||
foreach (var granted in claim.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
foreach (var required in scopes)
|
||||
{
|
||||
if (string.Equals(granted, required, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var claim in user.FindAll("scp"))
|
||||
{
|
||||
foreach (var granted in claim.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
foreach (var required in scopes)
|
||||
{
|
||||
if (string.Equals(granted, required, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool CanAccessEntry(WatchedIdentity entry, string tenantId)
|
||||
|
||||
Reference in New Issue
Block a user