Align release publisher scopes and preserve promotion submit context

This commit is contained in:
master
2026-03-10 19:01:16 +02:00
parent f401a7182c
commit d93006a8fa
11 changed files with 365 additions and 9 deletions

View File

@@ -39,7 +39,8 @@ public class StandardPluginBootstrapperTests
ClientId = "stella-ops-ui",
DisplayName = "Stella Ops Console",
AllowedGrantTypes = "authorization_code refresh_token",
AllowedScopes = $"openid profile {StellaOpsScopes.UiRead} {StellaOpsScopes.RegistryAdmin}",
AllowedScopes =
$"openid profile {StellaOpsScopes.UiRead} {StellaOpsScopes.RegistryAdmin} {StellaOpsScopes.ReleaseRead} {StellaOpsScopes.ReleaseWrite} {StellaOpsScopes.ReleasePublish}",
RedirectUris = "https://stella-ops.local/auth/callback https://stella-ops.local/auth/silent-refresh",
PostLogoutRedirectUris = "https://stella-ops.local/",
RequirePkce = true
@@ -69,6 +70,9 @@ public class StandardPluginBootstrapperTests
var client = await clientStore.FindByClientIdAsync("stella-ops-ui", TestContext.Current.CancellationToken);
Assert.NotNull(client);
Assert.Contains(StellaOpsScopes.RegistryAdmin, client!.AllowedScopes);
Assert.Contains(StellaOpsScopes.ReleaseRead, client.AllowedScopes);
Assert.Contains(StellaOpsScopes.ReleaseWrite, client.AllowedScopes);
Assert.Contains(StellaOpsScopes.ReleasePublish, client.AllowedScopes);
Assert.Contains("authorization_code", client.AllowedGrantTypes);
Assert.True(client.RequirePkce);
Assert.Equal("demo-prod", client.Properties[AuthorityClientMetadataKeys.Tenant]);

View File

@@ -91,7 +91,7 @@ VALUES
'airgap:seal', 'airgap:status:read',
'orch:read', 'analytics.read', 'advisory:read', 'vex:read', 'vexhub:read',
'exceptions:read', 'exceptions:approve', 'aoc:verify', 'findings:read',
'release:read', 'scheduler:read', 'scheduler:operate',
'release:read', 'release:write', 'release:publish', 'scheduler:read', 'scheduler:operate',
'notify.viewer', 'notify.operator', 'notify.admin', 'notify.escalate',
'evidence:read',
'export.viewer', 'export.operator', 'export.admin',

View File

@@ -0,0 +1,227 @@
#!/usr/bin/env node
import { mkdirSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { chromium } from 'playwright';
import { authenticateFrontdoor, createAuthenticatedContext } from './live-frontdoor-auth.mjs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const webRoot = path.resolve(__dirname, '..');
const outputDirectory = path.join(webRoot, 'output', 'playwright');
const statePath = path.join(outputDirectory, 'live-frontdoor-auth-state.json');
const reportPath = path.join(outputDirectory, 'live-frontdoor-auth-report.json');
const resultPath = path.join(outputDirectory, 'live-release-promotion-submit-check.json');
const scopeQuery = 'tenant=demo-prod&regions=us-east&environments=stage&timeWindow=7d';
const createRoute = `/releases/promotions/create?${scopeQuery}`;
const promotionDetailPattern = /^\/releases\/promotions\/(?!create$)[^/]+$/i;
const expectedScopeEntries = [
['tenant', 'demo-prod'],
['regions', 'us-east'],
['environments', 'stage'],
['timeWindow', '7d'],
];
function isStaticAsset(url) {
return /\.(?:css|js|map|png|jpg|jpeg|svg|woff2?|ico)(?:$|\?)/i.test(url);
}
function isAbortedNavigationFailure(failure) {
if (!failure) {
return false;
}
return /aborted|net::err_abort/i.test(failure);
}
function collectScopeIssues(label, targetUrl) {
const issues = [];
const parsed = new URL(targetUrl);
for (const [key, expectedValue] of expectedScopeEntries) {
const actualValue = parsed.searchParams.get(key);
if (actualValue !== expectedValue) {
issues.push(`${label} missing preserved scope ${key}=${expectedValue}; actual=${actualValue ?? '<null>'}`);
}
}
return issues;
}
async function clickNext(page) {
await page.getByRole('button', { name: 'Next ->' }).click();
}
async function main() {
mkdirSync(outputDirectory, { recursive: true });
const authReport = await authenticateFrontdoor({
statePath,
reportPath,
headless: true,
});
const browser = await chromium.launch({
headless: true,
args: ['--disable-dev-shm-usage'],
});
const context = await createAuthenticatedContext(browser, authReport, { statePath });
const page = await context.newPage();
const runtimeIssues = [];
const responseErrors = [];
const requestFailures = [];
const consoleErrors = [];
let promoteResponse = null;
page.on('console', (message) => {
if (message.type() === 'error') {
consoleErrors.push(message.text());
}
});
page.on('requestfailed', (request) => {
const url = request.url();
if (isStaticAsset(url) || isAbortedNavigationFailure(request.failure()?.errorText)) {
return;
}
requestFailures.push({
method: request.method(),
url,
error: request.failure()?.errorText ?? 'unknown',
page: page.url(),
});
});
page.on('response', async (response) => {
const url = response.url();
if (isStaticAsset(url)) {
return;
}
if (url.includes('/api/v1/release-orchestrator/releases/') && url.endsWith('/promote')) {
try {
promoteResponse = {
status: response.status(),
url,
body: await response.json(),
};
} catch {
promoteResponse = {
status: response.status(),
url,
body: null,
};
}
}
if (response.status() >= 400) {
responseErrors.push({
status: response.status(),
method: response.request().method(),
url,
page: page.url(),
});
}
});
const result = {
checkedAtUtc: new Date().toISOString(),
route: createRoute,
finalUrl: null,
promoteResponse,
scopeIssues: [],
consoleErrors,
requestFailures,
responseErrors,
runtimeIssues,
runtimeIssueCount: 0,
};
try {
await page.goto(`https://stella-ops.local${createRoute}`, {
waitUntil: 'domcontentloaded',
timeout: 30_000,
});
await page.getByLabel('Release/Bundle identity').fill('rel-001');
await page.getByRole('button', { name: 'Load Target Environments' }).click();
await page.locator('#target-env').selectOption('env-staging');
await page.getByRole('button', { name: 'Refresh Gate Preview' }).click();
await clickNext(page);
await page.getByLabel('Justification').fill('Release approval path validated end to end.');
await clickNext(page);
const submitResponsePromise = page.waitForResponse(
(response) =>
response.request().method() === 'POST' &&
response.url().includes('/api/v1/release-orchestrator/releases/') &&
response.url().endsWith('/promote'),
{ timeout: 30_000 },
);
await page.getByRole('button', { name: 'Submit Promotion Request' }).click({
noWaitAfter: true,
timeout: 10_000,
});
const submitResponse = await submitResponsePromise;
result.promoteResponse = promoteResponse ?? {
status: submitResponse.status(),
url: submitResponse.url(),
body: null,
};
await page.waitForURL((url) => promotionDetailPattern.test(url.pathname), {
timeout: 30_000,
});
result.finalUrl = page.url();
result.scopeIssues.push(...collectScopeIssues('finalUrl', result.finalUrl));
const finalPathname = new URL(result.finalUrl).pathname;
if (!promotionDetailPattern.test(finalPathname)) {
runtimeIssues.push(`Promotion submit did not land on a canonical detail route; actual path=${finalPathname}`);
}
if ((result.promoteResponse?.status ?? 0) >= 400) {
runtimeIssues.push(`Promotion submit returned ${result.promoteResponse.status}`);
}
const errorBannerVisible = await page.getByText('Failed to submit promotion request.').isVisible().catch(() => false);
if (errorBannerVisible) {
runtimeIssues.push('Promotion submit surfaced an error banner after submit.');
}
} catch (error) {
runtimeIssues.push(error instanceof Error ? error.message : String(error));
result.finalUrl = page.url();
} finally {
result.runtimeIssues = [
...runtimeIssues,
...result.scopeIssues,
...responseErrors.map((entry) => `${entry.method} ${entry.url} -> ${entry.status}`),
...requestFailures.map((entry) => `${entry.method} ${entry.url} failed: ${entry.error}`),
...consoleErrors,
];
result.runtimeIssueCount = result.runtimeIssues.length;
writeFileSync(resultPath, `${JSON.stringify(result, null, 2)}\n`, 'utf8');
await context.close();
await browser.close();
}
if (result.runtimeIssueCount > 0) {
throw new Error(result.runtimeIssues.join('; '));
}
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
}
main().catch((error) => {
process.stderr.write(`[live-release-promotion-submit-check] ${error instanceof Error ? error.message : String(error)}\n`);
process.exit(1);
});

View File

@@ -786,7 +786,10 @@ export class CreatePromotionComponent implements OnInit {
.subscribe((created) => {
this.submitting.set(false);
if (created) {
this.router.navigate(['../', created.id], { relativeTo: this.route });
this.router.navigate(['../', created.id], {
relativeTo: this.route,
queryParamsHandling: 'preserve',
});
}
});
}

View File

@@ -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 registry.admin",
"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 release:write release:publish 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

View File

@@ -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 registry.admin",
"scope": "openid profile email ui.read authority:tenants.read advisory:read vex:read exceptions:read exceptions:approve aoc:verify findings:read orch:read release:read release:write release:publish vuln:view vuln:investigate vuln:operate vuln:audit registry.admin",
"audience": "https://scanner.example.dev",
"dpopAlgorithms": ["ES256"],
"refreshLeewaySeconds": 60

View File

@@ -193,4 +193,78 @@ describe('CreatePromotionComponent release-context handoff', () => {
},
);
});
it('preserves current scope when promotion submit navigates to the canonical detail route', async () => {
const approvalApi = {
getAvailableEnvironments: jasmine.createSpy('getAvailableEnvironments').and.returnValue(of([])),
getPromotionPreview: jasmine.createSpy('getPromotionPreview').and.returnValue(of(null)),
submitPromotionRequest: jasmine.createSpy('submitPromotionRequest').and.returnValue(
of({
id: 'apr-321',
releaseId: 'rel-123',
releaseName: 'API Gateway',
releaseVersion: '2.1.0',
sourceEnvironment: 'stage',
targetEnvironment: 'production',
requestedBy: 'ops',
requestedAt: '2026-03-08T09:30:00Z',
urgency: 'normal',
justification: 'Promote',
status: 'pending',
currentApprovals: 0,
requiredApprovals: 2,
gatesPassed: true,
scheduledTime: null,
expiresAt: '2026-03-10T09:30:00Z',
}),
),
};
await TestBed.configureTestingModule({
imports: [CreatePromotionComponent],
providers: [
provideRouter([]),
{
provide: ActivatedRoute,
useValue: {
snapshot: {
queryParamMap: convertToParamMap({
tenant: 'demo-prod',
regions: 'us-east',
environments: 'stage',
timeWindow: '7d',
}),
},
},
},
{ provide: APPROVAL_API, useValue: approvalApi },
],
}).compileComponents();
const fixture = TestBed.createComponent(CreatePromotionComponent);
const component = fixture.componentInstance;
const router = TestBed.inject(Router);
const navigateSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true));
component.releaseId.set('rel-123');
component.targetEnvironmentId.set('env-production');
component.justification.set('Release approval path validated end to end.');
component.submit();
expect(approvalApi.submitPromotionRequest).toHaveBeenCalledWith('rel-123', {
targetEnvironmentId: 'env-production',
urgency: 'normal',
justification: 'Release approval path validated end to end.',
notifyApprovers: true,
scheduledTime: null,
});
expect(navigateSpy).toHaveBeenCalledWith(
['../', 'apr-321'],
{
relativeTo: TestBed.inject(ActivatedRoute),
queryParamsHandling: 'preserve',
},
);
});
});