From 622f015421561207eaaa363eec5604d8bdcb741e Mon Sep 17 00:00:00 2001 From: master <> Date: Sun, 8 Mar 2026 22:56:55 +0200 Subject: [PATCH] Backfill live auth scope and evidence route metadata --- devops/compose/envsettings-override.json | 2 +- .../StandardPluginBootstrapperTests.cs | 53 +++++ .../StandardPluginOptionsTests.cs | 62 +++++ .../scripts/live-frontdoor-auth.mjs | 224 ++++++++++++++++++ .../evidence-thread/evidence-thread.routes.ts | 4 +- .../src/app/routes/evidence.routes.ts | 4 +- src/Web/StellaOps.Web/src/config/config.json | 2 +- .../src/config/config.sample.json | 2 +- 8 files changed, 346 insertions(+), 7 deletions(-) create mode 100644 src/Web/StellaOps.Web/scripts/live-frontdoor-auth.mjs diff --git a/devops/compose/envsettings-override.json b/devops/compose/envsettings-override.json index 41e0faa44..139695039 100644 --- a/devops/compose/envsettings-override.json +++ b/devops/compose/envsettings-override.json @@ -6,7 +6,7 @@ "tokenEndpoint": "https://stella-ops.local/connect/token", "redirectUri": "https://stella-ops.local/auth/callback", "postLogoutRedirectUri": "https://stella-ops.local/", - "scope": "openid profile email offline_access ui.read ui.admin ui.preferences.read ui.preferences.write authority:tenants.read authority:users.read authority:roles.read authority:clients.read authority:tokens.read authority:branding.read authority.audit.read graph:read sbom:read scanner:read policy:read policy:simulate policy:author policy:review policy:approve policy:run policy:activate policy:audit policy:edit policy:operate policy:publish airgap:seal airgap:status:read orch:read analytics.read advisory:read advisory-ai:view advisory-ai:operate vex:read vexhub:read exceptions:read exceptions:approve aoc:verify findings:read release:read scheduler:read scheduler:operate notify.viewer notify.operator notify.admin notify.escalate evidence:read export.viewer export.operator export.admin vuln:view vuln:investigate vuln:operate vuln:audit platform.context.read platform.context.write doctor:run doctor:admin ops.health integration:read integration:write integration:operate timeline:read timeline:write", + "scope": "openid profile email offline_access ui.read ui.admin ui.preferences.read ui.preferences.write authority:tenants.read authority:users.read authority:roles.read authority:clients.read authority:tokens.read authority:branding.read authority.audit.read graph:read sbom:read scanner:read policy:read policy:simulate policy:author policy:review policy:approve policy:run policy:activate policy:audit policy:edit policy:operate policy:publish airgap:seal airgap:status:read orch:read analytics.read advisory:read advisory-ai:view advisory-ai:operate vex:read vexhub:read exceptions:read exceptions:approve aoc:verify findings:read release:read scheduler:read scheduler:operate notify.viewer notify.operator notify.admin notify.escalate evidence:read export.viewer export.operator export.admin vuln:view vuln:investigate vuln:operate vuln:audit platform.context.read platform.context.write doctor:run doctor:admin ops.health integration:read integration:write integration:operate registry.admin timeline:read timeline:write", "audience": "stella-ops-api", "dpopAlgorithms": [ "ES256" diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard.Tests/StandardPluginBootstrapperTests.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard.Tests/StandardPluginBootstrapperTests.cs index 6ef98ba5e..537ee1def 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard.Tests/StandardPluginBootstrapperTests.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard.Tests/StandardPluginBootstrapperTests.cs @@ -5,6 +5,8 @@ using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; +using StellaOps.Auth.Abstractions; +using StellaOps.Authority.Persistence.InMemory.Stores; using StellaOps.Authority.Persistence.Postgres.Models; using StellaOps.Authority.Persistence.Postgres.Repositories; using StellaOps.Authority.Plugins.Abstractions; @@ -21,6 +23,57 @@ namespace StellaOps.Authority.Plugin.Standard.Tests; public class StandardPluginBootstrapperTests { + [Trait("Category", TestCategories.Unit)] + [Fact] + public async Task StartAsync_EnsuresBootstrapClientsWithoutBootstrapUser() + { + var services = new ServiceCollection(); + services.AddOptions("standard") + .Configure(options => + { + options.TenantId = "demo-prod"; + options.BootstrapClients = new[] + { + new BootstrapClientOptions + { + ClientId = "stella-ops-ui", + DisplayName = "Stella Ops Console", + AllowedGrantTypes = "authorization_code refresh_token", + AllowedScopes = $"openid profile {StellaOpsScopes.UiRead} {StellaOpsScopes.RegistryAdmin}", + RedirectUris = "https://stella-ops.local/auth/callback https://stella-ops.local/auth/silent-refresh", + PostLogoutRedirectUris = "https://stella-ops.local/", + RequirePkce = true + } + }; + }); + + var clientStore = new InMemoryClientStore(); + services.AddSingleton(clientStore); + services.AddSingleton(new StubRevocationStore()); + services.AddSingleton(new FakeTimeProvider(DateTimeOffset.Parse("2025-12-29T13:00:00Z"))); + services.AddSingleton(sp => + new StandardClientProvisioningStore( + "standard", + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService())); + + services.AddSingleton(sp => + new StandardPluginBootstrapper("standard", sp.GetRequiredService(), NullLogger.Instance)); + + using var provider = services.BuildServiceProvider(); + var bootstrapper = provider.GetRequiredService(); + + await bootstrapper.StartAsync(TestContext.Current.CancellationToken); + + var client = await clientStore.FindByClientIdAsync("stella-ops-ui", TestContext.Current.CancellationToken); + Assert.NotNull(client); + Assert.Contains(StellaOpsScopes.RegistryAdmin, client!.AllowedScopes); + Assert.Contains("authorization_code", client.AllowedGrantTypes); + Assert.True(client.RequirePkce); + Assert.Equal("demo-prod", client.Properties[AuthorityClientMetadataKeys.Tenant]); + } + [Trait("Category", TestCategories.Unit)] [Fact] public async Task StartAsync_DoesNotThrow_WhenBootstrapFails() diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard.Tests/StandardPluginOptionsTests.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard.Tests/StandardPluginOptionsTests.cs index 4b7745197..2be943ee9 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard.Tests/StandardPluginOptionsTests.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard.Tests/StandardPluginOptionsTests.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using StellaOps.Auth.Abstractions; using StellaOps.Authority.Plugin.Standard; using StellaOps.Cryptography; @@ -25,6 +26,30 @@ public class StandardPluginOptionsTests options.Validate("standard"); } + [Trait("Category", TestCategories.Unit)] + [Fact] + public void Validate_AllowsBootstrapClientWhenConfigured() + { + var options = new StandardPluginOptions + { + BootstrapClients = new[] + { + new BootstrapClientOptions + { + ClientId = "stella-ops-ui", + DisplayName = "Stella Ops Console", + AllowedGrantTypes = "authorization_code refresh_token", + AllowedScopes = $"openid profile {StellaOpsScopes.UiRead} {StellaOpsScopes.RegistryAdmin}", + RedirectUris = "https://stella-ops.local/auth/callback", + PostLogoutRedirectUris = "https://stella-ops.local/" + } + } + }; + + options.Normalize(Path.Combine(Path.GetTempPath(), "config", "standard.yaml")); + options.Validate("standard"); + } + [Trait("Category", TestCategories.Unit)] [Fact] public void Validate_Throws_WhenBootstrapUserIncomplete() @@ -42,6 +67,30 @@ public class StandardPluginOptionsTests Assert.Contains("bootstrapUser", ex.Message, StringComparison.OrdinalIgnoreCase); } + [Trait("Category", TestCategories.Unit)] + [Fact] + public void Validate_Throws_WhenConfidentialBootstrapClientMissingSecret() + { + var options = new StandardPluginOptions + { + BootstrapClients = new[] + { + new BootstrapClientOptions + { + ClientId = "registry-admin", + Confidential = true, + AllowedGrantTypes = "client_credentials", + AllowedScopes = StellaOpsScopes.RegistryAdmin + } + } + }; + + options.Normalize(Path.Combine(Path.GetTempPath(), "config", "standard.yaml")); + + var ex = Assert.Throws(() => options.Validate("standard")); + Assert.Contains("clientsecret", ex.Message, StringComparison.OrdinalIgnoreCase); + } + [Trait("Category", TestCategories.Unit)] [Fact] public void Validate_Throws_WhenLockoutWindowMinutesInvalid() @@ -115,6 +164,16 @@ public class StandardPluginOptionsTests { Username = " admin ", Password = " " + }, + BootstrapClients = new[] + { + new BootstrapClientOptions + { + ClientId = " Stella-Ops-Ui ", + AllowedGrantTypes = " refresh_token authorization_code ", + AllowedScopes = $" {StellaOpsScopes.UiRead} {StellaOpsScopes.RegistryAdmin.ToUpperInvariant()} ", + RedirectUris = " https://stella-ops.local/auth/callback " + } } }; @@ -123,6 +182,9 @@ public class StandardPluginOptionsTests Assert.Equal("tenant-a", options.TenantId); Assert.Equal("admin", options.BootstrapUser?.Username); Assert.Null(options.BootstrapUser?.Password); + Assert.Equal("Stella-Ops-Ui", options.BootstrapClients[0].ClientId); + Assert.Equal("authorization_code refresh_token", options.BootstrapClients[0].AllowedGrantTypes); + Assert.Equal($"{StellaOpsScopes.RegistryAdmin} {StellaOpsScopes.UiRead}", options.BootstrapClients[0].AllowedScopes); } [Trait("Category", TestCategories.Unit)] diff --git a/src/Web/StellaOps.Web/scripts/live-frontdoor-auth.mjs b/src/Web/StellaOps.Web/scripts/live-frontdoor-auth.mjs new file mode 100644 index 000000000..ab8d2cc2c --- /dev/null +++ b/src/Web/StellaOps.Web/scripts/live-frontdoor-auth.mjs @@ -0,0 +1,224 @@ +#!/usr/bin/env node + +import { mkdirSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { chromium } from 'playwright'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const webRoot = path.resolve(__dirname, '..'); +const outputDirectory = path.join(webRoot, 'output', 'playwright'); + +const DEFAULT_BASE_URL = process.env.STELLAOPS_FRONTDOOR_BASE_URL?.trim() || 'https://stella-ops.local'; +const DEFAULT_USERNAME = process.env.STELLAOPS_FRONTDOOR_USERNAME?.trim() || 'admin'; +const DEFAULT_PASSWORD = process.env.STELLAOPS_FRONTDOOR_PASSWORD?.trim() || 'Admin@Stella2026!'; +const DEFAULT_STATE_PATH = path.join(outputDirectory, 'live-frontdoor-auth-state.json'); +const DEFAULT_REPORT_PATH = path.join(outputDirectory, 'live-frontdoor-auth-report.json'); + +function createLocator(page, selectors) { + return page.locator(selectors.join(', ')).first(); +} + +async function clickIfVisible(locator, timeoutMs = 5_000) { + if (!(await locator.isVisible().catch(() => false))) { + return false; + } + + await locator.click({ timeout: timeoutMs, noWaitAfter: true }).catch(() => {}); + return true; +} + +async function fillIfVisible(locator, value) { + if (!(await locator.isVisible().catch(() => false))) { + return false; + } + + await locator.fill(value); + return true; +} + +async function waitForShell(page) { + const shellMarkers = [ + page.locator('app-topbar'), + page.locator('aside.sidebar'), + page.locator('app-shell'), + page.locator('app-root'), + ]; + + for (const marker of shellMarkers) { + if (await marker.first().isVisible().catch(() => false)) { + return; + } + } + + await Promise.race([ + ...shellMarkers.map((marker) => marker.first().waitFor({ state: 'visible', timeout: 15_000 }).catch(() => {})), + page.waitForTimeout(15_000), + ]); +} + +export async function authenticateFrontdoor(options = {}) { + const baseUrl = options.baseUrl?.trim() || DEFAULT_BASE_URL; + const username = options.username?.trim() || DEFAULT_USERNAME; + const password = options.password?.trim() || DEFAULT_PASSWORD; + const statePath = options.statePath || DEFAULT_STATE_PATH; + const reportPath = options.reportPath || DEFAULT_REPORT_PATH; + const headless = options.headless ?? true; + + mkdirSync(path.dirname(statePath), { recursive: true }); + mkdirSync(path.dirname(reportPath), { recursive: true }); + + const browser = await chromium.launch({ + headless, + args: ['--disable-dev-shm-usage'], + }); + + const context = await browser.newContext({ ignoreHTTPSErrors: true }); + const page = await context.newPage(); + + const events = { + consoleErrors: [], + requestFailures: [], + responseErrors: [], + }; + + page.on('console', (message) => { + if (message.type() === 'error') { + events.consoleErrors.push(message.text()); + } + }); + + page.on('requestfailed', (request) => { + const url = request.url(); + if (/\.(?:css|js|map|png|jpg|jpeg|svg|woff2?)(?:$|\?)/i.test(url)) { + return; + } + + events.requestFailures.push({ + method: request.method(), + url, + error: request.failure()?.errorText ?? 'unknown', + page: page.url(), + }); + }); + + 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) { + events.responseErrors.push({ + status: response.status(), + method: response.request().method(), + url, + page: page.url(), + }); + } + }); + + await page.goto(`${baseUrl}/welcome`, { + waitUntil: 'domcontentloaded', + timeout: 30_000, + }); + await page.waitForTimeout(1_500); + + const signInTrigger = createLocator(page, [ + 'button:has-text("Sign In")', + 'button:has-text("Sign in")', + 'a:has-text("Sign In")', + 'a:has-text("Sign in")', + 'button.cta', + ]); + + await clickIfVisible(signInTrigger); + await page.waitForTimeout(1_500); + + const usernameField = createLocator(page, [ + 'input[name="username"]', + 'input[name="Username"]', + 'input[type="text"]', + 'input[type="email"]', + ]); + const passwordField = createLocator(page, [ + 'input[name="password"]', + 'input[name="Password"]', + 'input[type="password"]', + ]); + + const hasLoginForm = (await usernameField.count()) > 0 && (await passwordField.count()) > 0; + if (page.url().includes('/connect/authorize') || hasLoginForm) { + const filledUser = await fillIfVisible(usernameField, username); + const filledPassword = await fillIfVisible(passwordField, password); + + if (!filledUser || !filledPassword) { + throw new Error(`Authority login form was reached at ${page.url()} but the credentials fields were not interactable.`); + } + + const submitButton = createLocator(page, [ + 'button[type="submit"]', + 'button:has-text("Sign In")', + 'button:has-text("Sign in")', + 'button:has-text("Log in")', + 'button:has-text("Login")', + ]); + + await submitButton.click({ timeout: 10_000 }); + + await page.waitForURL( + (url) => !url.toString().includes('/connect/authorize') && !url.toString().includes('/auth/callback'), + { timeout: 30_000 }, + ).catch(() => {}); + } + + await waitForShell(page); + await page.waitForTimeout(2_500); + + await context.storageState({ path: statePath }); + + const report = { + authenticatedAtUtc: new Date().toISOString(), + baseUrl, + finalUrl: page.url(), + title: await page.title(), + cookies: (await context.cookies()).map((cookie) => ({ + name: cookie.name, + domain: cookie.domain, + path: cookie.path, + secure: cookie.secure, + sameSite: cookie.sameSite, + })), + storage: await page.evaluate(() => ({ + localStorageEntries: [...Array(localStorage.length)] + .map((_, index) => localStorage.key(index)) + .filter(Boolean) + .map((key) => [key, localStorage.getItem(key)]), + sessionStorageEntries: [...Array(sessionStorage.length)] + .map((_, index) => sessionStorage.key(index)) + .filter(Boolean) + .map((key) => [key, sessionStorage.getItem(key)]), + })), + events, + statePath, + }; + + writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8'); + await browser.close(); + + return report; +} + +async function main() { + const report = await authenticateFrontdoor(); + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === __filename) { + main().catch((error) => { + process.stderr.write(`[live-frontdoor-auth] ${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); + }); +} diff --git a/src/Web/StellaOps.Web/src/app/features/evidence-thread/evidence-thread.routes.ts b/src/Web/StellaOps.Web/src/app/features/evidence-thread/evidence-thread.routes.ts index f80090500..fd64ac0e1 100644 --- a/src/Web/StellaOps.Web/src/app/features/evidence-thread/evidence-thread.routes.ts +++ b/src/Web/StellaOps.Web/src/app/features/evidence-thread/evidence-thread.routes.ts @@ -10,7 +10,7 @@ import { Routes } from '@angular/router'; * Mounted under /evidence/threads by evidence.routes.ts (Sprint 021). * Canonical URLs: * /evidence/threads - Thread list - * /evidence/threads/:artifactDigest - Thread detail for a specific artifact + * /evidence/threads/:canonicalId - Canonical evidence record detail */ export const EVIDENCE_THREAD_ROUTES: Routes = [ { @@ -23,7 +23,7 @@ export const EVIDENCE_THREAD_ROUTES: Routes = [ data: { breadcrumb: 'Threads' }, }, { - path: ':artifactDigest', + path: ':canonicalId', loadComponent: () => import('./components/evidence-thread-view/evidence-thread-view.component').then( (m) => m.EvidenceThreadViewComponent diff --git a/src/Web/StellaOps.Web/src/app/routes/evidence.routes.ts b/src/Web/StellaOps.Web/src/app/routes/evidence.routes.ts index 486ed1caf..3928b8804 100644 --- a/src/Web/StellaOps.Web/src/app/routes/evidence.routes.ts +++ b/src/Web/StellaOps.Web/src/app/routes/evidence.routes.ts @@ -6,8 +6,8 @@ import { Routes } from '@angular/router'; * Canonical URL contract (Sprint 021): * /evidence - Evidence overview * /evidence/overview - Evidence overview (alias) - * /evidence/threads - Evidence thread list - * /evidence/threads/:artifactDigest - Evidence thread detail + * /evidence/threads - Evidence thread lookup by PURL + * /evidence/threads/:canonicalId - Canonical evidence record detail * /evidence/workspaces/auditor/:artifactDigest - Auditor workspace lens * /evidence/workspaces/developer/:artifactDigest - Developer workspace lens * /evidence/capsules - Decision capsule list diff --git a/src/Web/StellaOps.Web/src/config/config.json b/src/Web/StellaOps.Web/src/config/config.json index 5a28e435c..0957c4a4f 100644 --- a/src/Web/StellaOps.Web/src/config/config.json +++ b/src/Web/StellaOps.Web/src/config/config.json @@ -8,7 +8,7 @@ "redirectUri": "/auth/callback", "silentRefreshRedirectUri": "/auth/silent-refresh", "postLogoutRedirectUri": "/", - "scope": "openid profile email offline_access ui.read ui.admin authority:tenants.read authority:users.read authority:roles.read authority:clients.read authority:tokens.read authority:branding.read authority.audit.read graph:read sbom:read scanner:read policy:read policy:simulate policy:author policy:review policy:approve orch:read analytics.read advisory:read vex:read exceptions:read exceptions:approve aoc:verify findings:read release:read scheduler:read scheduler:operate notify.viewer notify.operator notify.admin notify.escalate export.viewer export.operator export.admin vuln:view vuln:investigate vuln:operate vuln:audit", + "scope": "openid profile email offline_access ui.read ui.admin authority:tenants.read authority:users.read authority:roles.read authority:clients.read authority:tokens.read authority:branding.read authority.audit.read graph:read sbom:read scanner:read policy:read policy:simulate policy:author policy:review policy:approve orch:read analytics.read advisory:read vex:read exceptions:read exceptions:approve aoc:verify findings:read release:read scheduler:read scheduler:operate notify.viewer notify.operator notify.admin notify.escalate export.viewer export.operator export.admin vuln:view vuln:investigate vuln:operate vuln:audit registry.admin", "audience": "/scanner", "dpopAlgorithms": ["ES256"], "refreshLeewaySeconds": 60 diff --git a/src/Web/StellaOps.Web/src/config/config.sample.json b/src/Web/StellaOps.Web/src/config/config.sample.json index 66eb42c57..cbcaa65b1 100644 --- a/src/Web/StellaOps.Web/src/config/config.sample.json +++ b/src/Web/StellaOps.Web/src/config/config.sample.json @@ -7,7 +7,7 @@ "logoutEndpoint": "https://authority.example.dev/connect/logout", "redirectUri": "http://localhost:4400/auth/callback", "postLogoutRedirectUri": "http://localhost:4400/", - "scope": "openid profile email ui.read authority:tenants.read advisory:read vex:read exceptions:read exceptions:approve aoc:verify findings:read orch:read vuln:view vuln:investigate vuln:operate vuln:audit", + "scope": "openid profile email ui.read authority:tenants.read advisory:read vex:read exceptions:read exceptions:approve aoc:verify findings:read orch:read vuln:view vuln:investigate vuln:operate vuln:audit registry.admin", "audience": "https://scanner.example.dev", "dpopAlgorithms": ["ES256"], "refreshLeewaySeconds": 60