From 18246cd74c244be1fe11ad73613b674e23563bcf Mon Sep 17 00:00:00 2001 From: master <> Date: Tue, 10 Mar 2026 01:37:42 +0200 Subject: [PATCH] Align live console and policy governance clients --- devops/compose/docker-compose.stella-ops.yml | 4 +- .../samples/console-status-sample.json | 2 +- ...auth_scope_console_and_policy_alignment.md | 65 ++++++++ docs/modules/ui/console-architecture.md | 12 +- src/Web/StellaOps.Web/src/app/app.config.ts | 2 +- .../src/app/core/api/audit-log.client.spec.ts | 36 +++++ .../src/app/core/api/audit-log.client.ts | 2 +- .../core/api/console-status.client.spec.ts | 6 +- .../core/api/policy-governance.client.spec.ts | 69 +++++++++ .../app/core/api/policy-governance.client.ts | 142 +++++++++++------- .../console/console-status.service.spec.ts | 8 + .../core/console/console-status.service.ts | 9 ++ .../simulation-dashboard.component.spec.ts | 19 ++- .../simulation-dashboard.component.ts | 6 +- 14 files changed, 301 insertions(+), 81 deletions(-) create mode 100644 docs/implplan/SPRINT_20260309_010_FE_live_auth_scope_console_and_policy_alignment.md create mode 100644 src/Web/StellaOps.Web/src/app/core/api/audit-log.client.spec.ts create mode 100644 src/Web/StellaOps.Web/src/app/core/api/policy-governance.client.spec.ts diff --git a/devops/compose/docker-compose.stella-ops.yml b/devops/compose/docker-compose.stella-ops.yml index e200251cb..d63fb9ba5 100644 --- a/devops/compose/docker-compose.stella-ops.yml +++ b/devops/compose/docker-compose.stella-ops.yml @@ -354,7 +354,7 @@ services: Platform__EnvironmentSettings__TokenEndpoint: "https://stella-ops.local/connect/token" Platform__EnvironmentSettings__RedirectUri: "https://stella-ops.local/auth/callback" Platform__EnvironmentSettings__PostLogoutRedirectUri: "https://stella-ops.local/" - Platform__EnvironmentSettings__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 orch:quota 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 packs.read packs.write packs.run packs.approve registry.admin timeline:read timeline:write" + Platform__EnvironmentSettings__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 orch:quota 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 packs.read packs.write packs.run packs.approve registry.admin timeline:read timeline:write trust:read trust:write trust:admin signer:read signer:sign signer:rotate signer:admin" STELLAOPS_ROUTER_URL: "http://router.stella-ops.local" STELLAOPS_PLATFORM_URL: "http://platform.stella-ops.local" STELLAOPS_AUTHORITY_URL: "http://authority.stella-ops.local" @@ -456,7 +456,7 @@ services: STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__BootstrapClients__0__ClientId: "stella-ops-ui" STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__BootstrapClients__0__DisplayName: "Stella Ops Console" STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__BootstrapClients__0__AllowedGrantTypes: "authorization_code refresh_token" - STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__BootstrapClients__0__AllowedScopes: "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" + STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__BootstrapClients__0__AllowedScopes: "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 orch:quota 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 packs.read packs.write packs.run packs.approve registry.admin timeline:read timeline:write trust:read trust:write trust:admin signer:read signer:sign signer:rotate signer:admin" STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__BootstrapClients__0__RedirectUris: "https://stella-ops.local/auth/callback https://stella-ops.local/auth/silent-refresh https://127.1.0.1/auth/callback https://127.1.0.1/auth/silent-refresh" STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__BootstrapClients__0__PostLogoutRedirectUris: "https://stella-ops.local/ https://127.1.0.1/" STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__BootstrapClients__0__RequirePkce: "true" diff --git a/docs/api/console/samples/console-status-sample.json b/docs/api/console/samples/console-status-sample.json index fba26ad32..08d22a9e8 100644 --- a/docs/api/console/samples/console-status-sample.json +++ b/docs/api/console/samples/console-status-sample.json @@ -1,7 +1,7 @@ { "$schema": "https://stella-ops.org/api/console/console-status.schema.json", "_meta": { - "description": "Sample response for GET /console/status", + "description": "Sample response for GET /api/console/status", "task": "WEB-CONSOLE-23-002", "generatedAt": "2025-12-04T12:00:00Z" }, diff --git a/docs/implplan/SPRINT_20260309_010_FE_live_auth_scope_console_and_policy_alignment.md b/docs/implplan/SPRINT_20260309_010_FE_live_auth_scope_console_and_policy_alignment.md new file mode 100644 index 000000000..de0e741f7 --- /dev/null +++ b/docs/implplan/SPRINT_20260309_010_FE_live_auth_scope_console_and_policy_alignment.md @@ -0,0 +1,65 @@ +# Sprint 20260309-010 - FE Live Auth Scope, Console, and Policy Alignment + +## Topic & Scope +- Repair the post-rebuild live failures that are now clearly contract/alignment defects instead of generic service outages: trust-signing authorization, console status frontdoor pathing, and policy-governance tenant drift. +- Keep this iteration focused on live canonical routes already failing in the authenticated sweep: `/ops/platform-setup/trust-signing`, `/setup/trust-signing`, `/ops/operations/status`, `/ops/policy/trust-weights`, `/ops/policy/staleness`, and `/ops/policy/audit`. +- Working directory: `src/Web/StellaOps.Web`. +- Allowed cross-module edits: `devops/compose/docker-compose.stella-ops.yml`, `docs/api/console/samples/console-status-sample.json`, `docs/modules/ui/console-architecture.md`, `docs/implplan/SPRINT_20260309_002_FE_live_frontdoor_canonical_route_sweep.md`, `docs/implplan/SPRINT_20260309_009_FE_live_contract_alignment_titles_trust_feeds.md`. +- Expected evidence: focused frontend specs, rebuilt/redeployed live stack, refreshed authenticated Playwright auth report, and a new canonical route sweep artifact. + +## Dependencies & Concurrency +- Depends on `SPRINT_20260309_002_FE_live_frontdoor_canonical_route_sweep.md` for the current live failure inventory and on `SPRINT_20260309_009_FE_live_contract_alignment_titles_trust_feeds.md` for the completed trust-route frontend adapter. +- Safe parallelism: keep code edits in `src/Web/StellaOps.Web/**` and the single compose auth bootstrap file only; do not edit backend service implementations in this sprint. + +## Documentation Prerequisites +- `AGENTS.md` +- `docs/implplan/AGENTS.md` +- `src/Web/StellaOps.Web/AGENTS.md` +- `docs/code-of-conduct/CODE_OF_CONDUCT.md` +- `docs/qa/feature-checks/FLOW.md` +- `docs/technical/architecture/console-admin-rbac.md` +- `docs/security/console-security.md` +- `docs/modules/ui/console-architecture.md` + +## Delivery Tracker + +### FE-AUTH-010-001 - Restore live trust-signing bootstrap scopes +Status: DOING +Dependency: none +Owners: Developer, QA +Task description: +- Align the demo console bootstrap client scope request and allowed scope catalog with the live Platform trust-signing authorization policies so authenticated Playwright sessions can load the Trust & Signing overview and operator actions without `403` responses. +- Keep the change limited to the scratch-setup compose bootstrap path used for clean redeploys. + +Completion criteria: +- [ ] The compose bootstrap client requests and is allowed to receive the trust/signer scopes required by the setup trust pages. +- [ ] A fresh authenticated session issued after redeploy includes the expected trust scopes. +- [ ] Live `/ops/platform-setup/trust-signing` and `/setup/trust-signing` stop failing on `403`. + +### FE-AUTH-010-002 - Align console status and policy-governance clients with live frontdoor contracts +Status: TODO +Dependency: FE-AUTH-010-001 +Owners: Developer, Test Automation +Task description: +- Repoint console status polling/streaming onto the canonical frontdoor path used by the rebuilt stack and replace policy-governance placeholder tenant leakage with active tenant resolution so live query contracts do not collapse to stale demo IDs. +- Repair stale audit module wiring where the policy audit shell still targets retired policy audit endpoints. + +Completion criteria: +- [ ] `ConsoleStatusClient` no longer requests `/console/status` on the live frontdoor. +- [ ] Policy-governance HTTP requests stop emitting `tenantId=acme-tenant` during authenticated live page loads. +- [ ] The policy audit shell uses the live governance audit endpoint. +- [ ] Focused frontend tests cover the console path and policy tenant/audit contract alignment. + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-03-09 | Sprint created after the fresh full rebuild improved the authenticated route sweep to 95/111 and isolated the remaining frontend-owned failures to trust-signing authorization, console status frontdoor pathing, and policy-governance tenant/audit drift. | Developer | + +## Decisions & Risks +- Decision: treat the trust-signing `403` as a bootstrap scope defect, not a web routing defect; the previous sprint already moved the UI to the live `/api/v1/administration/trust-signing*` contract and removed the retired `404` paths. +- Decision: fix policy-governance tenant drift centrally in the HTTP client layer for this iteration to clear the entire component family without colliding with the other agent's component-revival work. +- Risk: the console status frontdoor contract is documented inconsistently (`/console/status` vs `/api/console/status`); this sprint will follow the live deployment/security docs and verify the result against the rebuilt stack. + +## Next Checkpoints +- 2026-03-09: land the trust bootstrap scope repair and confirm new tokens include trust scopes. +- 2026-03-09: land the console/policy client alignment and rerun the authenticated canonical route sweep. diff --git a/docs/modules/ui/console-architecture.md b/docs/modules/ui/console-architecture.md index c54da6473..7e6d1fc27 100644 --- a/docs/modules/ui/console-architecture.md +++ b/docs/modules/ui/console-architecture.md @@ -87,7 +87,7 @@ Key interactions: - **Tenant switch:** Picker issues `Authority /fresh-auth` when required, then refreshes UI caches (`ui.tenant.switch` log). Gateway injects canonical `X-StellaOps-Tenant` headers downstream (legacy `X-Stella-Tenant`/`X-Tenant-Id` aliases are compatibility-only during migration). - **Aggregation-only reads:** Gateway proxies `/console/advisories`, `/console/vex`, `/console/findings`, etc., without mutating Concelier or Policy data. Provenance badges and merge hashes come directly from upstream responses. - **Downloads parity:** `/console/downloads` merges DevOps signed manifest and Offline Kit metadata; UI renders digest, signature, and CLI parity command. -- **Offline resilience:** Gateway exposes `/console/status` heartbeat. If unavailable, UI enters offline mode, disables SSE, and surfaces CLI fallbacks. +- **Offline resilience:** Gateway exposes `/api/console/status` heartbeat. If unavailable, UI enters offline mode, disables SSE, and surfaces CLI fallbacks. --- @@ -97,9 +97,9 @@ Live surfaces use HTTP/1.1 SSE with heartbeat frames to keep operators informed | Endpoint | Payload | Source | Behaviour | |----------|---------|--------|-----------| -| `/console/status/stream` | `statusChanged`, `ingestionDelta`, `attestorQueue`, `offlineBanner` events | Concelier WebService, Excititor WebService, Attestor metrics | 5 s heartbeat; gateway disables proxy buffering (`X-Accel-Buffering: no`) and sets `Cache-Control: no-store`. | -| `/console/runs/{id}/stream` | `stateChanged`, `segmentProgress`, `deltaSummary`, `log` | Scheduler WebService SSE fan-out | Event payloads carry `traceId`, `runId`, `tenant`; UI reconnects with exponential backoff and resumes using `Last-Event-ID`. | -| `/console/telemetry/stream` | `metricSample`, `alert`, `collectorStatus` | Observability aggregator | Gated by `ui.telemetry` scope; disabled when `CONSOLE_TELEMETRY_SSE_ENABLED=false`. | +| `/api/console/status/stream` | `statusChanged`, `ingestionDelta`, `attestorQueue`, `offlineBanner` events | Concelier WebService, Excititor WebService, Attestor metrics | 5 s heartbeat; gateway disables proxy buffering (`X-Accel-Buffering: no`) and sets `Cache-Control: no-store`. | +| `/api/console/runs/{id}/stream` | `stateChanged`, `segmentProgress`, `deltaSummary`, `log` | Scheduler WebService SSE fan-out | Event payloads carry `traceId`, `runId`, `tenant`; UI reconnects with exponential backoff and resumes using `Last-Event-ID`. | +| `/api/console/telemetry/stream` | `metricSample`, `alert`, `collectorStatus` | Observability aggregator | Gated by `ui.telemetry` scope; disabled when `CONSOLE_TELEMETRY_SSE_ENABLED=false`. | Sequence overview: @@ -110,7 +110,7 @@ sequenceDiagram participant GW as Console Gateway participant SCHED as Scheduler WebService - UI->>GW: GET /console/runs/42/stream (Authorization + DPoP) + UI->>GW: GET /api/console/runs/42/stream (Authorization + DPoP) GW->>SCHED: GET /runs/42/stream (X-Stella-Tenant) SCHED-->>GW: event: stateChanged data: {...} GW-->>UI: event: stateChanged data: {..., traceId} @@ -122,7 +122,7 @@ sequenceDiagram Offline behaviour: -- If SSE fails three times within 60 s, UI falls back to polling (`/console/status`, `/console/runs/{id}`) every 30 s and shows an amber banner. +- If SSE fails three times within 60 s, UI falls back to polling (`/api/console/status`, `/api/console/runs/{id}`) every 30 s and shows an amber banner. - When `console.offlineMode=true`, SSE endpoints return `204` immediately; UI suppresses auto-reconnect to preserve resources. --- diff --git a/src/Web/StellaOps.Web/src/app/app.config.ts b/src/Web/StellaOps.Web/src/app/app.config.ts index 37670a3b6..9ce71fd98 100644 --- a/src/Web/StellaOps.Web/src/app/app.config.ts +++ b/src/Web/StellaOps.Web/src/app/app.config.ts @@ -645,7 +645,7 @@ export const appConfig: ApplicationConfig = { deps: [AppConfigService], useFactory: (config: AppConfigService) => { const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; - return resolveApiBaseUrl(gatewayBase, '/console'); + return resolveApiBaseUrl(gatewayBase, '/api/console'); }, }, { diff --git a/src/Web/StellaOps.Web/src/app/core/api/audit-log.client.spec.ts b/src/Web/StellaOps.Web/src/app/core/api/audit-log.client.spec.ts new file mode 100644 index 000000000..e47488792 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/core/api/audit-log.client.spec.ts @@ -0,0 +1,36 @@ +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; + +import { AuditLogClient } from './audit-log.client'; + +describe('AuditLogClient', () => { + let client: AuditLogClient; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + AuditLogClient, + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting(), + ], + }); + + client = TestBed.inject(AuditLogClient); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('routes policy audit requests to the governance audit endpoint', () => { + client.getPolicyAudit(undefined, undefined, 50).subscribe(); + + const req = httpMock.expectOne((request) => request.url === '/api/v1/governance/audit/events'); + expect(req.request.method).toBe('GET'); + expect(req.request.params.get('limit')).toBe('50'); + req.flush({ items: [], cursor: null, hasMore: false }); + }); +}); diff --git a/src/Web/StellaOps.Web/src/app/core/api/audit-log.client.ts b/src/Web/StellaOps.Web/src/app/core/api/audit-log.client.ts index 323a51fef..89f2280d2 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/audit-log.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/audit-log.client.ts @@ -23,7 +23,7 @@ export class AuditLogClient { // Endpoint paths for each module's audit API private readonly endpoints: Record = { authority: '/console/admin/audit', - policy: '/api/v1/policy/audit/events', + policy: '/api/v1/governance/audit/events', jobengine: '/api/v1/jobengine/audit/events', integrations: '/api/v1/integrations/audit/events', vex: '/api/v1/vex/audit/events', diff --git a/src/Web/StellaOps.Web/src/app/core/api/console-status.client.spec.ts b/src/Web/StellaOps.Web/src/app/core/api/console-status.client.spec.ts index 2cd259ef2..ddde7a14c 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/console-status.client.spec.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/console-status.client.spec.ts @@ -48,7 +48,7 @@ describe('ConsoleStatusClient', () => { imports: [], providers: [ ConsoleStatusClient, - { provide: CONSOLE_API_BASE_URL, useValue: '/console' }, + { provide: CONSOLE_API_BASE_URL, useValue: '/api/console' }, { provide: AuthSessionStore, useClass: FakeAuthSessionStore }, { provide: EVENT_SOURCE_FACTORY, useValue: eventSourceFactory }, provideHttpClient(withInterceptorsFromDi()), @@ -81,7 +81,7 @@ describe('ConsoleStatusClient', () => { expect(result.healthy).toBeTrue(); }); - const req = httpMock.expectOne('/console/status'); + const req = httpMock.expectOne('/api/console/status'); expect(req.request.method).toBe('GET'); expect(req.request.headers.get('X-StellaOps-Tenant')).toBe('tenant-dev'); expect(req.request.headers.get('X-Stella-Trace-Id')).toBeTruthy(); @@ -95,7 +95,7 @@ describe('ConsoleStatusClient', () => { expect(eventSourceFactory).toHaveBeenCalled(); const url = eventSourceFactory.calls.mostRecent()!.args[0]; - expect(url).toContain('/console/runs/run-123/stream?tenant=tenant-dev'); + expect(url).toContain('/api/console/runs/run-123/stream?tenant=tenant-dev'); expect(url).toContain('traceId='); // Simulate incoming message diff --git a/src/Web/StellaOps.Web/src/app/core/api/policy-governance.client.spec.ts b/src/Web/StellaOps.Web/src/app/core/api/policy-governance.client.spec.ts new file mode 100644 index 000000000..a463990ce --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/core/api/policy-governance.client.spec.ts @@ -0,0 +1,69 @@ +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; + +import { AuthSessionStore } from '../auth/auth-session.store'; +import { TenantActivationService } from '../auth/tenant-activation.service'; +import { HttpPolicyGovernanceApi } from './policy-governance.client'; + +describe('HttpPolicyGovernanceApi', () => { + let api: HttpPolicyGovernanceApi; + let httpMock: HttpTestingController; + let authSession: { getActiveTenantId: jasmine.Spy }; + const tenantServiceStub = { + activeTenantId: () => 'demo-prod', + }; + + beforeEach(() => { + authSession = { + getActiveTenantId: jasmine.createSpy('getActiveTenantId'), + }; + authSession.getActiveTenantId.and.returnValue('demo-prod'); + + TestBed.configureTestingModule({ + providers: [ + HttpPolicyGovernanceApi, + { provide: TenantActivationService, useValue: tenantServiceStub }, + { provide: AuthSessionStore, useValue: authSession }, + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting(), + ], + }); + + api = TestBed.inject(HttpPolicyGovernanceApi); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('replaces the legacy acme tenant placeholder with the active tenant for scoped queries', () => { + api.getTrustWeightConfig({ tenantId: 'acme-tenant', projectId: '' }).subscribe(); + + const req = httpMock.expectOne((request) => request.url === '/api/v1/governance/trust-weights'); + expect(req.request.params.get('tenantId')).toBe('demo-prod'); + expect(req.request.headers.get('X-StellaOps-Tenant')).toBe('demo-prod'); + expect(req.request.headers.get('X-Stella-Tenant')).toBe('demo-prod'); + req.flush({ tenantId: 'demo-prod', projectId: null, weights: [], defaultWeight: 1, modifiedAt: '2026-03-09T00:00:00Z' }); + }); + + it('preserves explicit non-placeholder tenant ids', () => { + api.getStalenessConfig({ tenantId: 'tenant-blue', projectId: 'proj-a' }).subscribe(); + + const req = httpMock.expectOne((request) => request.url === '/api/v1/governance/staleness/config'); + expect(req.request.params.get('tenantId')).toBe('tenant-blue'); + expect(req.request.params.get('projectId')).toBe('proj-a'); + expect(req.request.headers.get('X-StellaOps-Tenant')).toBe('tenant-blue'); + req.flush({ tenantId: 'tenant-blue', projectId: 'proj-a', configs: [], modifiedAt: '2026-03-09T00:00:00Z', etag: '"staleness"' }); + }); + + it('uses the governance audit endpoint with resolved tenant context', () => { + api.getAuditEvents({ tenantId: 'acme-tenant', page: 1, pageSize: 20, sortOrder: 'desc' }).subscribe(); + + const req = httpMock.expectOne((request) => request.url === '/api/v1/governance/audit/events'); + expect(req.request.params.get('tenantId')).toBe('demo-prod'); + expect(req.request.headers.get('X-StellaOps-Tenant')).toBe('demo-prod'); + req.flush({ events: [], total: 0, page: 1, pageSize: 20, hasMore: false }); + }); +}); diff --git a/src/Web/StellaOps.Web/src/app/core/api/policy-governance.client.ts b/src/Web/StellaOps.Web/src/app/core/api/policy-governance.client.ts index ec5f903f0..70b1ab783 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/policy-governance.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/policy-governance.client.ts @@ -1,6 +1,8 @@ import { Injectable, InjectionToken, inject } from '@angular/core'; import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'; import { Observable, delay, of, throwError } from 'rxjs'; +import { AuthSessionStore } from '../auth/auth-session.store'; +import { TenantActivationService } from '../auth/tenant-activation.service'; import { RiskBudgetGovernance, @@ -89,6 +91,14 @@ export interface PolicyGovernanceApi { export const POLICY_GOVERNANCE_API = new InjectionToken('POLICY_GOVERNANCE_API'); +const LEGACY_POLICY_TENANT_PLACEHOLDERS = new Set(['acme-tenant']); + +interface GovernanceScopeRequest { + tenantId?: string; + projectId?: string; + traceId?: string; +} + // ============================================================================ // Mock Data // ============================================================================ @@ -903,177 +913,199 @@ export class MockPolicyGovernanceApi implements PolicyGovernanceApi { @Injectable({ providedIn: 'root' }) export class HttpPolicyGovernanceApi implements PolicyGovernanceApi { private readonly http = inject(HttpClient); + private readonly authSession = inject(AuthSessionStore); + private readonly tenantService = inject(TenantActivationService); private readonly baseUrl = '/api/v1/governance'; - private buildHeaders(traceId?: string): HttpHeaders { + private buildHeaders(options: GovernanceScopeRequest = {}): HttpHeaders { let headers = new HttpHeaders({ 'Content-Type': 'application/json' }); + const traceId = options.traceId?.trim(); if (traceId) { - headers = headers.set('X-Trace-Id', traceId); + headers = headers + .set('X-Trace-Id', traceId) + .set('X-Stella-Trace-Id', traceId) + .set('X-Stella-Request-Id', traceId); + } + + const tenantId = this.resolveTenantId(options.tenantId); + if (tenantId) { + headers = headers + .set('X-StellaOps-Tenant', tenantId) + .set('X-Stella-Tenant', tenantId) + .set('X-Tenant-Id', tenantId); } return headers; } + private buildScopeParams(options: GovernanceScopeRequest): HttpParams { + return new HttpParams() + .set('tenantId', this.resolveTenantId(options.tenantId)) + .set('projectId', options.projectId || ''); + } + + private resolveTenantId(requestedTenantId?: string): string { + const requestedTenant = requestedTenantId?.trim() ?? ''; + const activeTenant = + this.tenantService.activeTenantId()?.trim() ?? + this.authSession.getActiveTenantId()?.trim() ?? + ''; + + if (requestedTenant && !(activeTenant && LEGACY_POLICY_TENANT_PLACEHOLDERS.has(requestedTenant.toLowerCase()))) { + return requestedTenant; + } + + return activeTenant || requestedTenant; + } + // Risk Budget getRiskBudgetDashboard(options: GovernanceQueryOptions): Observable { - const params = new HttpParams() - .set('tenantId', options.tenantId) - .set('projectId', options.projectId || ''); + const params = this.buildScopeParams(options); return this.http.get(`${this.baseUrl}/risk-budget/dashboard`, { params, - headers: this.buildHeaders(options.traceId), + headers: this.buildHeaders(options), }); } updateRiskBudgetConfig(config: RiskBudgetGovernance, options: GovernanceQueryOptions): Observable { return this.http.put(`${this.baseUrl}/risk-budget/config`, config, { - headers: this.buildHeaders(options.traceId), + headers: this.buildHeaders(options), }); } acknowledgeAlert(alertId: string, options: GovernanceQueryOptions): Observable { return this.http.post(`${this.baseUrl}/risk-budget/alerts/${alertId}/acknowledge`, {}, { - headers: this.buildHeaders(options.traceId), + headers: this.buildHeaders(options), }); } // Trust Weights getTrustWeightConfig(options: GovernanceQueryOptions): Observable { - const params = new HttpParams() - .set('tenantId', options.tenantId) - .set('projectId', options.projectId || ''); + const params = this.buildScopeParams(options); return this.http.get(`${this.baseUrl}/trust-weights`, { params, - headers: this.buildHeaders(options.traceId), + headers: this.buildHeaders(options), }); } updateTrustWeight(weight: TrustWeight, options: GovernanceQueryOptions): Observable { return this.http.put(`${this.baseUrl}/trust-weights/${weight.id}`, weight, { - headers: this.buildHeaders(options.traceId), + headers: this.buildHeaders(options), }); } deleteTrustWeight(weightId: string, options: GovernanceQueryOptions): Observable { return this.http.delete(`${this.baseUrl}/trust-weights/${weightId}`, { - headers: this.buildHeaders(options.traceId), + headers: this.buildHeaders(options), }); } previewTrustWeightImpact(weights: TrustWeight[], options: GovernanceQueryOptions): Observable { return this.http.post(`${this.baseUrl}/trust-weights/preview-impact`, { weights }, { - headers: this.buildHeaders(options.traceId), + headers: this.buildHeaders(options), }); } // Staleness getStalenessConfig(options: GovernanceQueryOptions): Observable { - const params = new HttpParams() - .set('tenantId', options.tenantId) - .set('projectId', options.projectId || ''); + const params = this.buildScopeParams(options); return this.http.get(`${this.baseUrl}/staleness/config`, { params, - headers: this.buildHeaders(options.traceId), + headers: this.buildHeaders(options), }); } updateStalenessConfig(config: StalenessConfig, options: GovernanceQueryOptions): Observable { return this.http.put(`${this.baseUrl}/staleness/config/${config.dataType}`, config, { - headers: this.buildHeaders(options.traceId), + headers: this.buildHeaders(options), }); } getStalenessStatus(options: GovernanceQueryOptions): Observable { - const params = new HttpParams() - .set('tenantId', options.tenantId) - .set('projectId', options.projectId || ''); + const params = this.buildScopeParams(options); return this.http.get(`${this.baseUrl}/staleness/status`, { params, - headers: this.buildHeaders(options.traceId), + headers: this.buildHeaders(options), }); } // Sealed Mode getSealedModeStatus(options: GovernanceQueryOptions): Observable { return this.http.get(`${this.baseUrl}/sealed-mode/status`, { - headers: this.buildHeaders(options.traceId), + headers: this.buildHeaders(options), }); } getSealedModeOverrides(options: GovernanceQueryOptions): Observable { - const params = new HttpParams() - .set('tenantId', options.tenantId) - .set('projectId', options.projectId || ''); + const params = this.buildScopeParams(options); return this.http.get(`${this.baseUrl}/sealed-mode/overrides`, { params, - headers: this.buildHeaders(options.traceId), + headers: this.buildHeaders(options), }); } toggleSealedMode(request: SealedModeToggleRequest, options: GovernanceQueryOptions): Observable { return this.http.post(`${this.baseUrl}/sealed-mode/toggle`, request, { - headers: this.buildHeaders(options.traceId), + headers: this.buildHeaders(options), }); } createSealedModeOverride(request: SealedModeOverrideRequest, options: GovernanceQueryOptions): Observable { return this.http.post(`${this.baseUrl}/sealed-mode/overrides`, request, { - headers: this.buildHeaders(options.traceId), + headers: this.buildHeaders(options), }); } revokeSealedModeOverride(overrideId: string, reason: string, options: GovernanceQueryOptions): Observable { return this.http.post(`${this.baseUrl}/sealed-mode/overrides/${overrideId}/revoke`, { reason }, { - headers: this.buildHeaders(options.traceId), + headers: this.buildHeaders(options), }); } // Risk Profiles listRiskProfiles(options: GovernanceQueryOptions & { status?: RiskProfileGovernanceStatus }): Observable { - let params = new HttpParams() - .set('tenantId', options.tenantId) - .set('projectId', options.projectId || ''); + let params = this.buildScopeParams(options); if (options.status) { params = params.set('status', options.status); } return this.http.get(`${this.baseUrl}/risk-profiles`, { params, - headers: this.buildHeaders(options.traceId), + headers: this.buildHeaders(options), }); } getRiskProfile(profileId: string, options: GovernanceQueryOptions): Observable { return this.http.get(`${this.baseUrl}/risk-profiles/${profileId}`, { - headers: this.buildHeaders(options.traceId), + headers: this.buildHeaders(options), }); } createRiskProfile(profile: Partial, options: GovernanceQueryOptions): Observable { return this.http.post(`${this.baseUrl}/risk-profiles`, profile, { - headers: this.buildHeaders(options.traceId), + headers: this.buildHeaders(options), }); } updateRiskProfile(profileId: string, profile: Partial, options: GovernanceQueryOptions): Observable { return this.http.put(`${this.baseUrl}/risk-profiles/${profileId}`, profile, { - headers: this.buildHeaders(options.traceId), + headers: this.buildHeaders(options), }); } deleteRiskProfile(profileId: string, options: GovernanceQueryOptions): Observable { return this.http.delete(`${this.baseUrl}/risk-profiles/${profileId}`, { - headers: this.buildHeaders(options.traceId), + headers: this.buildHeaders(options), }); } activateRiskProfile(profileId: string, options: GovernanceQueryOptions): Observable { return this.http.post(`${this.baseUrl}/risk-profiles/${profileId}/activate`, {}, { - headers: this.buildHeaders(options.traceId), + headers: this.buildHeaders(options), }); } deprecateRiskProfile(profileId: string, reason: string, options: GovernanceQueryOptions): Observable { return this.http.post(`${this.baseUrl}/risk-profiles/${profileId}/deprecate`, { reason }, { - headers: this.buildHeaders(options.traceId), + headers: this.buildHeaders(options), }); } @@ -1091,8 +1123,7 @@ export class HttpPolicyGovernanceApi implements PolicyGovernanceApi { // Audit getAuditEvents(options: AuditQueryOptions): Observable { - let params = new HttpParams() - .set('tenantId', options.tenantId) + let params = this.buildScopeParams(options) .set('page', (options.page || 1).toString()) .set('pageSize', (options.pageSize || 20).toString()); @@ -1104,48 +1135,47 @@ export class HttpPolicyGovernanceApi implements PolicyGovernanceApi { if (options.endDate) params = params.set('endDate', options.endDate); if (options.sortOrder) params = params.set('sortOrder', options.sortOrder); - return this.http.get(`${this.baseUrl}/audit/events`, { params }); + return this.http.get(`${this.baseUrl}/audit/events`, { + params, + headers: this.buildHeaders(options), + }); } getAuditEvent(eventId: string, options: GovernanceQueryOptions): Observable { return this.http.get(`${this.baseUrl}/audit/events/${eventId}`, { - headers: this.buildHeaders(options.traceId), + headers: this.buildHeaders(options), }); } // Conflicts getConflictDashboard(options: GovernanceQueryOptions): Observable { - const params = new HttpParams() - .set('tenantId', options.tenantId) - .set('projectId', options.projectId || ''); + const params = this.buildScopeParams(options); return this.http.get(`${this.baseUrl}/conflicts/dashboard`, { params, - headers: this.buildHeaders(options.traceId), + headers: this.buildHeaders(options), }); } getConflicts(options: GovernanceQueryOptions & { type?: PolicyConflictType; severity?: PolicyConflictSeverity }): Observable { - let params = new HttpParams() - .set('tenantId', options.tenantId) - .set('projectId', options.projectId || ''); + let params = this.buildScopeParams(options); if (options.type) params = params.set('type', options.type); if (options.severity) params = params.set('severity', options.severity); return this.http.get(`${this.baseUrl}/conflicts`, { params, - headers: this.buildHeaders(options.traceId), + headers: this.buildHeaders(options), }); } resolveConflict(conflictId: string, resolution: string, options: GovernanceQueryOptions): Observable { return this.http.post(`${this.baseUrl}/conflicts/${conflictId}/resolve`, { resolution }, { - headers: this.buildHeaders(options.traceId), + headers: this.buildHeaders(options), }); } ignoreConflict(conflictId: string, reason: string, options: GovernanceQueryOptions): Observable { return this.http.post(`${this.baseUrl}/conflicts/${conflictId}/ignore`, { reason }, { - headers: this.buildHeaders(options.traceId), + headers: this.buildHeaders(options), }); } } diff --git a/src/Web/StellaOps.Web/src/app/core/console/console-status.service.spec.ts b/src/Web/StellaOps.Web/src/app/core/console/console-status.service.spec.ts index 7931c9dcb..8f62f5955 100644 --- a/src/Web/StellaOps.Web/src/app/core/console/console-status.service.spec.ts +++ b/src/Web/StellaOps.Web/src/app/core/console/console-status.service.spec.ts @@ -73,4 +73,12 @@ describe('ConsoleStatusService', () => { sub.unsubscribe(); }); + + it('skips live SSE when console status reports a compatibility run id', () => { + const sub = service.subscribeToRun('run::demo-prod::20260309'); + + expect(client.streams.length).toBe(0); + + sub.unsubscribe(); + }); }); diff --git a/src/Web/StellaOps.Web/src/app/core/console/console-status.service.ts b/src/Web/StellaOps.Web/src/app/core/console/console-status.service.ts index d30bb541c..8961ccaeb 100644 --- a/src/Web/StellaOps.Web/src/app/core/console/console-status.service.ts +++ b/src/Web/StellaOps.Web/src/app/core/console/console-status.service.ts @@ -68,6 +68,11 @@ export class ConsoleStatusService { */ subscribeToRun(runId: string, options?: RunStreamOptions): Subscription { this.store.clearEvents(); + this.store.setError(null); + + if (this.isCompatibilityRunId(runId)) { + return new Subscription(); + } const traceId = options?.traceId ?? generateTraceId(); const heartbeatMs = options?.heartbeatMs ?? 15000; @@ -131,4 +136,8 @@ export class ConsoleStatusService { clear(): void { this.store.clear(); } + + private isCompatibilityRunId(runId: string): boolean { + return runId.trim().startsWith('run::'); + } } diff --git a/src/Web/StellaOps.Web/src/app/features/policy-simulation/simulation-dashboard.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/policy-simulation/simulation-dashboard.component.spec.ts index 649852e84..d4e2c2e18 100644 --- a/src/Web/StellaOps.Web/src/app/features/policy-simulation/simulation-dashboard.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/features/policy-simulation/simulation-dashboard.component.spec.ts @@ -1,5 +1,5 @@ import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; -import { provideRouter, Router } from '@angular/router'; +import { provideRouter, Router, RouterModule } from '@angular/router'; import { Component, Input, Output, EventEmitter } from '@angular/core'; import { of, throwError } from 'rxjs'; import { delay } from 'rxjs/operators'; @@ -27,7 +27,7 @@ class MockShadowModeIndicatorComponent { describe('SimulationDashboardComponent', () => { let component: SimulationDashboardComponent; let fixture: ComponentFixture; - let mockApi: jasmine.SpyObj; + let mockApi: jasmine.SpyObj>; let router: Router; const mockConfig: ShadowModeConfig = { @@ -46,7 +46,7 @@ describe('SimulationDashboardComponent', () => { 'getShadowModeConfig', 'enableShadowMode', 'disableShadowMode', - ]); + ]) as jasmine.SpyObj>; mockApi.getShadowModeConfig.and.returnValue(of(mockConfig)); mockApi.enableShadowMode.and.returnValue(of(mockConfig)); mockApi.disableShadowMode.and.returnValue(of(undefined)); @@ -56,12 +56,12 @@ describe('SimulationDashboardComponent', () => { SimulationDashboardComponent, MockShadowModeIndicatorComponent, ], - providers: [provideRouter([]), { provide: POLICY_SIMULATION_API, useValue: mockApi }], + providers: [provideRouter([]), { provide: POLICY_SIMULATION_API, useValue: mockApi as unknown as PolicySimulationApi }], }) .overrideComponent(SimulationDashboardComponent, { set: { - imports: [MockShadowModeIndicatorComponent], - providers: [{ provide: POLICY_SIMULATION_API, useValue: mockApi }], + imports: [RouterModule, MockShadowModeIndicatorComponent], + providers: [{ provide: POLICY_SIMULATION_API, useValue: mockApi as unknown as PolicySimulationApi }], }, }) .compileComponents(); @@ -104,6 +104,7 @@ describe('SimulationDashboardComponent', () => { tick(); expect(mockApi.getShadowModeConfig).toHaveBeenCalled(); + expect(mockApi.getShadowModeConfig.calls.mostRecent()!.args.length).toBe(0); })); it('should set shadow config on successful load', fakeAsync(() => { @@ -190,6 +191,7 @@ describe('SimulationDashboardComponent', () => { tick(); expect(mockApi.enableShadowMode).toHaveBeenCalled(); + expect(mockApi.enableShadowMode.calls.mostRecent()!.args.length).toBe(1); })); it('should set loading state during API call', fakeAsync(() => { @@ -216,6 +218,7 @@ describe('SimulationDashboardComponent', () => { tick(); expect(mockApi.disableShadowMode).toHaveBeenCalled(); + expect(mockApi.disableShadowMode.calls.mostRecent()!.args.length).toBe(0); })); it('should update config to disabled state', fakeAsync(() => { @@ -233,7 +236,7 @@ describe('SimulationDashboardComponent', () => { component['navigateToHistory'](); - expect(router.navigate).toHaveBeenCalledWith(['/policy/simulation/history']); + expect(router.navigate).toHaveBeenCalledWith(['/ops/policy/simulation/history']); })); it('should navigate to promotion on navigateToPromotion', fakeAsync(() => { @@ -241,7 +244,7 @@ describe('SimulationDashboardComponent', () => { component['navigateToPromotion'](); - expect(router.navigate).toHaveBeenCalledWith(['/policy/simulation/promotion']); + expect(router.navigate).toHaveBeenCalledWith(['/ops/policy/simulation/promotion']); })); }); diff --git a/src/Web/StellaOps.Web/src/app/features/policy-simulation/simulation-dashboard.component.ts b/src/Web/StellaOps.Web/src/app/features/policy-simulation/simulation-dashboard.component.ts index 9ca93e6f9..6c7cc7c56 100644 --- a/src/Web/StellaOps.Web/src/app/features/policy-simulation/simulation-dashboard.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/policy-simulation/simulation-dashboard.component.ts @@ -554,7 +554,7 @@ export class SimulationDashboardComponent implements OnInit { protected loadShadowStatus(): void { this.shadowLoading.set(true); - this.api.getShadowModeConfig({ tenantId: 'default' }).pipe( + this.api.getShadowModeConfig().pipe( finalize(() => this.shadowLoading.set(false)) ).subscribe({ next: (config) => { @@ -586,7 +586,7 @@ export class SimulationDashboardComponent implements OnInit { activePackId: 'policy-pack-001', activeVersion: 1, trafficPercentage: 10, - }, { tenantId: 'default' }).pipe( + }).pipe( finalize(() => this.shadowLoading.set(false)) ).subscribe({ next: (config) => { @@ -597,7 +597,7 @@ export class SimulationDashboardComponent implements OnInit { protected disableShadowMode(): void { this.shadowLoading.set(true); - this.api.disableShadowMode({ tenantId: 'default' }).pipe( + this.api.disableShadowMode().pipe( finalize(() => this.shadowLoading.set(false)) ).subscribe({ next: () => {