Repair live watchlist frontdoor routing

This commit is contained in:
master
2026-03-10 00:25:34 +02:00
parent 359fafa9da
commit ac544c0064
11 changed files with 474 additions and 22 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,177 @@
#!/usr/bin/env node
import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { chromium } from 'playwright';
import { authenticateFrontdoor, createAuthenticatedContext } from './live-frontdoor-auth.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const webRoot = path.resolve(__dirname, '..');
const outputDir = path.join(webRoot, 'output', 'playwright');
const outputPath = path.join(outputDir, 'live-notifications-watchlist-recheck.json');
const authStatePath = path.join(outputDir, 'live-notifications-watchlist-recheck.state.json');
const authReportPath = path.join(outputDir, 'live-notifications-watchlist-recheck.auth.json');
const scopeQuery = 'tenant=demo-prod&regions=us-east&environments=stage&timeWindow=7d';
async function settle(page) {
await page.waitForLoadState('domcontentloaded', { timeout: 15_000 }).catch(() => {});
await page.waitForTimeout(1_500);
}
async function headingText(page) {
const headings = page.locator('h1, h2, [data-testid="page-title"], .page-title');
const count = await headings.count();
for (let index = 0; index < Math.min(count, 4); index += 1) {
const text = (await headings.nth(index).innerText().catch(() => '')).trim();
if (text) {
return text;
}
}
return '';
}
async function captureSnapshot(page, label) {
const alerts = await page
.locator('[role="alert"], .mat-mdc-snack-bar-container, .toast, .notification, .error-banner')
.evaluateAll((nodes) =>
nodes
.map((node) => (node.textContent || '').trim().replace(/\s+/g, ' '))
.filter(Boolean)
.slice(0, 5),
)
.catch(() => []);
return {
label,
url: page.url(),
title: await page.title(),
heading: await headingText(page),
alerts,
};
}
async function navigate(page, route) {
const separator = route.includes('?') ? '&' : '?';
const url = `https://stella-ops.local${route}${separator}${scopeQuery}`;
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 });
await settle(page);
return url;
}
async function clickLinkAndVerify(page, route, linkName, expectedPath) {
await navigate(page, route);
const locator = page.getByRole('link', { name: linkName }).first();
if ((await locator.count()) === 0) {
return {
action: `link:${linkName}`,
ok: false,
reason: 'missing-link',
snapshot: await captureSnapshot(page, `missing-link:${linkName}`),
};
}
await locator.click({ timeout: 10_000 });
await page.waitForURL((url) => url.pathname.includes(expectedPath), { timeout: 15_000 });
await settle(page);
return {
action: `link:${linkName}`,
ok: page.url().includes(expectedPath),
snapshot: await captureSnapshot(page, `after-link:${linkName}`),
};
}
async function main() {
await mkdir(outputDir, { recursive: true });
const authReport = await authenticateFrontdoor({
statePath: authStatePath,
reportPath: authReportPath,
headless: true,
});
const browser = await chromium.launch({
headless: true,
args: ['--disable-dev-shm-usage'],
});
const context = await createAuthenticatedContext(browser, authReport, { statePath: authStatePath });
const page = await context.newPage();
const runtime = {
consoleErrors: [],
pageErrors: [],
responseErrors: [],
requestFailures: [],
};
page.on('console', (message) => {
if (message.type() === 'error') {
runtime.consoleErrors.push({ page: page.url(), text: message.text() });
}
});
page.on('pageerror', (error) => {
runtime.pageErrors.push({ page: page.url(), message: error.message });
});
page.on('requestfailed', (request) => {
const url = request.url();
if (/\.(?:css|js|map|png|jpg|jpeg|svg|woff2?)(?:$|\?)/i.test(url)) {
return;
}
runtime.requestFailures.push({
page: page.url(),
method: request.method(),
url,
error: request.failure()?.errorText ?? 'unknown',
});
});
page.on('response', (response) => {
const url = response.url();
if (/\.(?:css|js|map|png|jpg|jpeg|svg|woff2?)(?:$|\?)/i.test(url)) {
return;
}
if (response.status() >= 400) {
runtime.responseErrors.push({
page: page.url(),
method: response.request().method(),
status: response.status(),
url,
});
}
});
const results = [];
results.push(await clickLinkAndVerify(
page,
'/ops/operations/notifications',
'Open watchlist tuning',
'/setup/trust-signing/watchlist/tuning',
));
results.push(await clickLinkAndVerify(
page,
'/ops/operations/notifications',
'Review watchlist alerts',
'/setup/trust-signing/watchlist/alerts',
));
const summary = {
generatedAtUtc: new Date().toISOString(),
results,
runtime,
};
await writeFile(outputPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8');
process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
await browser.close();
}
main().catch((error) => {
process.stderr.write(`[live-notifications-watchlist-recheck] ${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
});