Align live console and policy governance clients
This commit is contained in:
@@ -354,7 +354,7 @@ services:
|
|||||||
Platform__EnvironmentSettings__TokenEndpoint: "https://stella-ops.local/connect/token"
|
Platform__EnvironmentSettings__TokenEndpoint: "https://stella-ops.local/connect/token"
|
||||||
Platform__EnvironmentSettings__RedirectUri: "https://stella-ops.local/auth/callback"
|
Platform__EnvironmentSettings__RedirectUri: "https://stella-ops.local/auth/callback"
|
||||||
Platform__EnvironmentSettings__PostLogoutRedirectUri: "https://stella-ops.local/"
|
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_ROUTER_URL: "http://router.stella-ops.local"
|
||||||
STELLAOPS_PLATFORM_URL: "http://platform.stella-ops.local"
|
STELLAOPS_PLATFORM_URL: "http://platform.stella-ops.local"
|
||||||
STELLAOPS_AUTHORITY_URL: "http://authority.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__ClientId: "stella-ops-ui"
|
||||||
STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__BootstrapClients__0__DisplayName: "Stella Ops Console"
|
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__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__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__PostLogoutRedirectUris: "https://stella-ops.local/ https://127.1.0.1/"
|
||||||
STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__BootstrapClients__0__RequirePkce: "true"
|
STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__BootstrapClients__0__RequirePkce: "true"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://stella-ops.org/api/console/console-status.schema.json",
|
"$schema": "https://stella-ops.org/api/console/console-status.schema.json",
|
||||||
"_meta": {
|
"_meta": {
|
||||||
"description": "Sample response for GET /console/status",
|
"description": "Sample response for GET /api/console/status",
|
||||||
"task": "WEB-CONSOLE-23-002",
|
"task": "WEB-CONSOLE-23-002",
|
||||||
"generatedAt": "2025-12-04T12:00:00Z"
|
"generatedAt": "2025-12-04T12:00:00Z"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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).
|
- **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.
|
- **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.
|
- **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 |
|
| 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`. |
|
| `/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`. |
|
||||||
| `/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/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/telemetry/stream` | `metricSample`, `alert`, `collectorStatus` | Observability aggregator | Gated by `ui.telemetry` scope; disabled when `CONSOLE_TELEMETRY_SSE_ENABLED=false`. |
|
||||||
|
|
||||||
Sequence overview:
|
Sequence overview:
|
||||||
|
|
||||||
@@ -110,7 +110,7 @@ sequenceDiagram
|
|||||||
participant GW as Console Gateway
|
participant GW as Console Gateway
|
||||||
participant SCHED as Scheduler WebService
|
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)
|
GW->>SCHED: GET /runs/42/stream (X-Stella-Tenant)
|
||||||
SCHED-->>GW: event: stateChanged data: {...}
|
SCHED-->>GW: event: stateChanged data: {...}
|
||||||
GW-->>UI: event: stateChanged data: {..., traceId}
|
GW-->>UI: event: stateChanged data: {..., traceId}
|
||||||
@@ -122,7 +122,7 @@ sequenceDiagram
|
|||||||
|
|
||||||
Offline behaviour:
|
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.
|
- When `console.offlineMode=true`, SSE endpoints return `204` immediately; UI suppresses auto-reconnect to preserve resources.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -645,7 +645,7 @@ export const appConfig: ApplicationConfig = {
|
|||||||
deps: [AppConfigService],
|
deps: [AppConfigService],
|
||||||
useFactory: (config: AppConfigService) => {
|
useFactory: (config: AppConfigService) => {
|
||||||
const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority;
|
const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority;
|
||||||
return resolveApiBaseUrl(gatewayBase, '/console');
|
return resolveApiBaseUrl(gatewayBase, '/api/console');
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -23,7 +23,7 @@ export class AuditLogClient {
|
|||||||
// Endpoint paths for each module's audit API
|
// Endpoint paths for each module's audit API
|
||||||
private readonly endpoints: Record<AuditModule, string> = {
|
private readonly endpoints: Record<AuditModule, string> = {
|
||||||
authority: '/console/admin/audit',
|
authority: '/console/admin/audit',
|
||||||
policy: '/api/v1/policy/audit/events',
|
policy: '/api/v1/governance/audit/events',
|
||||||
jobengine: '/api/v1/jobengine/audit/events',
|
jobengine: '/api/v1/jobengine/audit/events',
|
||||||
integrations: '/api/v1/integrations/audit/events',
|
integrations: '/api/v1/integrations/audit/events',
|
||||||
vex: '/api/v1/vex/audit/events',
|
vex: '/api/v1/vex/audit/events',
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ describe('ConsoleStatusClient', () => {
|
|||||||
imports: [],
|
imports: [],
|
||||||
providers: [
|
providers: [
|
||||||
ConsoleStatusClient,
|
ConsoleStatusClient,
|
||||||
{ provide: CONSOLE_API_BASE_URL, useValue: '/console' },
|
{ provide: CONSOLE_API_BASE_URL, useValue: '/api/console' },
|
||||||
{ provide: AuthSessionStore, useClass: FakeAuthSessionStore },
|
{ provide: AuthSessionStore, useClass: FakeAuthSessionStore },
|
||||||
{ provide: EVENT_SOURCE_FACTORY, useValue: eventSourceFactory },
|
{ provide: EVENT_SOURCE_FACTORY, useValue: eventSourceFactory },
|
||||||
provideHttpClient(withInterceptorsFromDi()),
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
@@ -81,7 +81,7 @@ describe('ConsoleStatusClient', () => {
|
|||||||
expect(result.healthy).toBeTrue();
|
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.method).toBe('GET');
|
||||||
expect(req.request.headers.get('X-StellaOps-Tenant')).toBe('tenant-dev');
|
expect(req.request.headers.get('X-StellaOps-Tenant')).toBe('tenant-dev');
|
||||||
expect(req.request.headers.get('X-Stella-Trace-Id')).toBeTruthy();
|
expect(req.request.headers.get('X-Stella-Trace-Id')).toBeTruthy();
|
||||||
@@ -95,7 +95,7 @@ describe('ConsoleStatusClient', () => {
|
|||||||
|
|
||||||
expect(eventSourceFactory).toHaveBeenCalled();
|
expect(eventSourceFactory).toHaveBeenCalled();
|
||||||
const url = eventSourceFactory.calls.mostRecent()!.args[0];
|
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=');
|
expect(url).toContain('traceId=');
|
||||||
|
|
||||||
// Simulate incoming message
|
// Simulate incoming message
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Injectable, InjectionToken, inject } from '@angular/core';
|
import { Injectable, InjectionToken, inject } from '@angular/core';
|
||||||
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
|
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
|
||||||
import { Observable, delay, of, throwError } from 'rxjs';
|
import { Observable, delay, of, throwError } from 'rxjs';
|
||||||
|
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||||
|
import { TenantActivationService } from '../auth/tenant-activation.service';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
RiskBudgetGovernance,
|
RiskBudgetGovernance,
|
||||||
@@ -89,6 +91,14 @@ export interface PolicyGovernanceApi {
|
|||||||
|
|
||||||
export const POLICY_GOVERNANCE_API = new InjectionToken<PolicyGovernanceApi>('POLICY_GOVERNANCE_API');
|
export const POLICY_GOVERNANCE_API = new InjectionToken<PolicyGovernanceApi>('POLICY_GOVERNANCE_API');
|
||||||
|
|
||||||
|
const LEGACY_POLICY_TENANT_PLACEHOLDERS = new Set(['acme-tenant']);
|
||||||
|
|
||||||
|
interface GovernanceScopeRequest {
|
||||||
|
tenantId?: string;
|
||||||
|
projectId?: string;
|
||||||
|
traceId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Mock Data
|
// Mock Data
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -903,177 +913,199 @@ export class MockPolicyGovernanceApi implements PolicyGovernanceApi {
|
|||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class HttpPolicyGovernanceApi implements PolicyGovernanceApi {
|
export class HttpPolicyGovernanceApi implements PolicyGovernanceApi {
|
||||||
private readonly http = inject(HttpClient);
|
private readonly http = inject(HttpClient);
|
||||||
|
private readonly authSession = inject(AuthSessionStore);
|
||||||
|
private readonly tenantService = inject(TenantActivationService);
|
||||||
private readonly baseUrl = '/api/v1/governance';
|
private readonly baseUrl = '/api/v1/governance';
|
||||||
|
|
||||||
private buildHeaders(traceId?: string): HttpHeaders {
|
private buildHeaders(options: GovernanceScopeRequest = {}): HttpHeaders {
|
||||||
let headers = new HttpHeaders({ 'Content-Type': 'application/json' });
|
let headers = new HttpHeaders({ 'Content-Type': 'application/json' });
|
||||||
|
const traceId = options.traceId?.trim();
|
||||||
if (traceId) {
|
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;
|
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
|
// Risk Budget
|
||||||
getRiskBudgetDashboard(options: GovernanceQueryOptions): Observable<RiskBudgetDashboard> {
|
getRiskBudgetDashboard(options: GovernanceQueryOptions): Observable<RiskBudgetDashboard> {
|
||||||
const params = new HttpParams()
|
const params = this.buildScopeParams(options);
|
||||||
.set('tenantId', options.tenantId)
|
|
||||||
.set('projectId', options.projectId || '');
|
|
||||||
return this.http.get<RiskBudgetDashboard>(`${this.baseUrl}/risk-budget/dashboard`, {
|
return this.http.get<RiskBudgetDashboard>(`${this.baseUrl}/risk-budget/dashboard`, {
|
||||||
params,
|
params,
|
||||||
headers: this.buildHeaders(options.traceId),
|
headers: this.buildHeaders(options),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
updateRiskBudgetConfig(config: RiskBudgetGovernance, options: GovernanceQueryOptions): Observable<RiskBudgetGovernance> {
|
updateRiskBudgetConfig(config: RiskBudgetGovernance, options: GovernanceQueryOptions): Observable<RiskBudgetGovernance> {
|
||||||
return this.http.put<RiskBudgetGovernance>(`${this.baseUrl}/risk-budget/config`, config, {
|
return this.http.put<RiskBudgetGovernance>(`${this.baseUrl}/risk-budget/config`, config, {
|
||||||
headers: this.buildHeaders(options.traceId),
|
headers: this.buildHeaders(options),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
acknowledgeAlert(alertId: string, options: GovernanceQueryOptions): Observable<RiskBudgetAlert> {
|
acknowledgeAlert(alertId: string, options: GovernanceQueryOptions): Observable<RiskBudgetAlert> {
|
||||||
return this.http.post<RiskBudgetAlert>(`${this.baseUrl}/risk-budget/alerts/${alertId}/acknowledge`, {}, {
|
return this.http.post<RiskBudgetAlert>(`${this.baseUrl}/risk-budget/alerts/${alertId}/acknowledge`, {}, {
|
||||||
headers: this.buildHeaders(options.traceId),
|
headers: this.buildHeaders(options),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trust Weights
|
// Trust Weights
|
||||||
getTrustWeightConfig(options: GovernanceQueryOptions): Observable<TrustWeightConfig> {
|
getTrustWeightConfig(options: GovernanceQueryOptions): Observable<TrustWeightConfig> {
|
||||||
const params = new HttpParams()
|
const params = this.buildScopeParams(options);
|
||||||
.set('tenantId', options.tenantId)
|
|
||||||
.set('projectId', options.projectId || '');
|
|
||||||
return this.http.get<TrustWeightConfig>(`${this.baseUrl}/trust-weights`, {
|
return this.http.get<TrustWeightConfig>(`${this.baseUrl}/trust-weights`, {
|
||||||
params,
|
params,
|
||||||
headers: this.buildHeaders(options.traceId),
|
headers: this.buildHeaders(options),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
updateTrustWeight(weight: TrustWeight, options: GovernanceQueryOptions): Observable<TrustWeight> {
|
updateTrustWeight(weight: TrustWeight, options: GovernanceQueryOptions): Observable<TrustWeight> {
|
||||||
return this.http.put<TrustWeight>(`${this.baseUrl}/trust-weights/${weight.id}`, weight, {
|
return this.http.put<TrustWeight>(`${this.baseUrl}/trust-weights/${weight.id}`, weight, {
|
||||||
headers: this.buildHeaders(options.traceId),
|
headers: this.buildHeaders(options),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteTrustWeight(weightId: string, options: GovernanceQueryOptions): Observable<void> {
|
deleteTrustWeight(weightId: string, options: GovernanceQueryOptions): Observable<void> {
|
||||||
return this.http.delete<void>(`${this.baseUrl}/trust-weights/${weightId}`, {
|
return this.http.delete<void>(`${this.baseUrl}/trust-weights/${weightId}`, {
|
||||||
headers: this.buildHeaders(options.traceId),
|
headers: this.buildHeaders(options),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
previewTrustWeightImpact(weights: TrustWeight[], options: GovernanceQueryOptions): Observable<TrustWeightImpact> {
|
previewTrustWeightImpact(weights: TrustWeight[], options: GovernanceQueryOptions): Observable<TrustWeightImpact> {
|
||||||
return this.http.post<TrustWeightImpact>(`${this.baseUrl}/trust-weights/preview-impact`, { weights }, {
|
return this.http.post<TrustWeightImpact>(`${this.baseUrl}/trust-weights/preview-impact`, { weights }, {
|
||||||
headers: this.buildHeaders(options.traceId),
|
headers: this.buildHeaders(options),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Staleness
|
// Staleness
|
||||||
getStalenessConfig(options: GovernanceQueryOptions): Observable<StalenessConfigContainer> {
|
getStalenessConfig(options: GovernanceQueryOptions): Observable<StalenessConfigContainer> {
|
||||||
const params = new HttpParams()
|
const params = this.buildScopeParams(options);
|
||||||
.set('tenantId', options.tenantId)
|
|
||||||
.set('projectId', options.projectId || '');
|
|
||||||
return this.http.get<StalenessConfigContainer>(`${this.baseUrl}/staleness/config`, {
|
return this.http.get<StalenessConfigContainer>(`${this.baseUrl}/staleness/config`, {
|
||||||
params,
|
params,
|
||||||
headers: this.buildHeaders(options.traceId),
|
headers: this.buildHeaders(options),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
updateStalenessConfig(config: StalenessConfig, options: GovernanceQueryOptions): Observable<StalenessConfig> {
|
updateStalenessConfig(config: StalenessConfig, options: GovernanceQueryOptions): Observable<StalenessConfig> {
|
||||||
return this.http.put<StalenessConfig>(`${this.baseUrl}/staleness/config/${config.dataType}`, config, {
|
return this.http.put<StalenessConfig>(`${this.baseUrl}/staleness/config/${config.dataType}`, config, {
|
||||||
headers: this.buildHeaders(options.traceId),
|
headers: this.buildHeaders(options),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
getStalenessStatus(options: GovernanceQueryOptions): Observable<StalenessStatus[]> {
|
getStalenessStatus(options: GovernanceQueryOptions): Observable<StalenessStatus[]> {
|
||||||
const params = new HttpParams()
|
const params = this.buildScopeParams(options);
|
||||||
.set('tenantId', options.tenantId)
|
|
||||||
.set('projectId', options.projectId || '');
|
|
||||||
return this.http.get<StalenessStatus[]>(`${this.baseUrl}/staleness/status`, {
|
return this.http.get<StalenessStatus[]>(`${this.baseUrl}/staleness/status`, {
|
||||||
params,
|
params,
|
||||||
headers: this.buildHeaders(options.traceId),
|
headers: this.buildHeaders(options),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sealed Mode
|
// Sealed Mode
|
||||||
getSealedModeStatus(options: GovernanceQueryOptions): Observable<SealedModeStatus> {
|
getSealedModeStatus(options: GovernanceQueryOptions): Observable<SealedModeStatus> {
|
||||||
return this.http.get<SealedModeStatus>(`${this.baseUrl}/sealed-mode/status`, {
|
return this.http.get<SealedModeStatus>(`${this.baseUrl}/sealed-mode/status`, {
|
||||||
headers: this.buildHeaders(options.traceId),
|
headers: this.buildHeaders(options),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
getSealedModeOverrides(options: GovernanceQueryOptions): Observable<SealedModeOverride[]> {
|
getSealedModeOverrides(options: GovernanceQueryOptions): Observable<SealedModeOverride[]> {
|
||||||
const params = new HttpParams()
|
const params = this.buildScopeParams(options);
|
||||||
.set('tenantId', options.tenantId)
|
|
||||||
.set('projectId', options.projectId || '');
|
|
||||||
return this.http.get<SealedModeOverride[]>(`${this.baseUrl}/sealed-mode/overrides`, {
|
return this.http.get<SealedModeOverride[]>(`${this.baseUrl}/sealed-mode/overrides`, {
|
||||||
params,
|
params,
|
||||||
headers: this.buildHeaders(options.traceId),
|
headers: this.buildHeaders(options),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleSealedMode(request: SealedModeToggleRequest, options: GovernanceQueryOptions): Observable<SealedModeStatus> {
|
toggleSealedMode(request: SealedModeToggleRequest, options: GovernanceQueryOptions): Observable<SealedModeStatus> {
|
||||||
return this.http.post<SealedModeStatus>(`${this.baseUrl}/sealed-mode/toggle`, request, {
|
return this.http.post<SealedModeStatus>(`${this.baseUrl}/sealed-mode/toggle`, request, {
|
||||||
headers: this.buildHeaders(options.traceId),
|
headers: this.buildHeaders(options),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
createSealedModeOverride(request: SealedModeOverrideRequest, options: GovernanceQueryOptions): Observable<SealedModeOverride> {
|
createSealedModeOverride(request: SealedModeOverrideRequest, options: GovernanceQueryOptions): Observable<SealedModeOverride> {
|
||||||
return this.http.post<SealedModeOverride>(`${this.baseUrl}/sealed-mode/overrides`, request, {
|
return this.http.post<SealedModeOverride>(`${this.baseUrl}/sealed-mode/overrides`, request, {
|
||||||
headers: this.buildHeaders(options.traceId),
|
headers: this.buildHeaders(options),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
revokeSealedModeOverride(overrideId: string, reason: string, options: GovernanceQueryOptions): Observable<void> {
|
revokeSealedModeOverride(overrideId: string, reason: string, options: GovernanceQueryOptions): Observable<void> {
|
||||||
return this.http.post<void>(`${this.baseUrl}/sealed-mode/overrides/${overrideId}/revoke`, { reason }, {
|
return this.http.post<void>(`${this.baseUrl}/sealed-mode/overrides/${overrideId}/revoke`, { reason }, {
|
||||||
headers: this.buildHeaders(options.traceId),
|
headers: this.buildHeaders(options),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Risk Profiles
|
// Risk Profiles
|
||||||
listRiskProfiles(options: GovernanceQueryOptions & { status?: RiskProfileGovernanceStatus }): Observable<RiskProfileGov[]> {
|
listRiskProfiles(options: GovernanceQueryOptions & { status?: RiskProfileGovernanceStatus }): Observable<RiskProfileGov[]> {
|
||||||
let params = new HttpParams()
|
let params = this.buildScopeParams(options);
|
||||||
.set('tenantId', options.tenantId)
|
|
||||||
.set('projectId', options.projectId || '');
|
|
||||||
if (options.status) {
|
if (options.status) {
|
||||||
params = params.set('status', options.status);
|
params = params.set('status', options.status);
|
||||||
}
|
}
|
||||||
return this.http.get<RiskProfileGov[]>(`${this.baseUrl}/risk-profiles`, {
|
return this.http.get<RiskProfileGov[]>(`${this.baseUrl}/risk-profiles`, {
|
||||||
params,
|
params,
|
||||||
headers: this.buildHeaders(options.traceId),
|
headers: this.buildHeaders(options),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
getRiskProfile(profileId: string, options: GovernanceQueryOptions): Observable<RiskProfileGov> {
|
getRiskProfile(profileId: string, options: GovernanceQueryOptions): Observable<RiskProfileGov> {
|
||||||
return this.http.get<RiskProfileGov>(`${this.baseUrl}/risk-profiles/${profileId}`, {
|
return this.http.get<RiskProfileGov>(`${this.baseUrl}/risk-profiles/${profileId}`, {
|
||||||
headers: this.buildHeaders(options.traceId),
|
headers: this.buildHeaders(options),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
createRiskProfile(profile: Partial<RiskProfileGov>, options: GovernanceQueryOptions): Observable<RiskProfileGov> {
|
createRiskProfile(profile: Partial<RiskProfileGov>, options: GovernanceQueryOptions): Observable<RiskProfileGov> {
|
||||||
return this.http.post<RiskProfileGov>(`${this.baseUrl}/risk-profiles`, profile, {
|
return this.http.post<RiskProfileGov>(`${this.baseUrl}/risk-profiles`, profile, {
|
||||||
headers: this.buildHeaders(options.traceId),
|
headers: this.buildHeaders(options),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
updateRiskProfile(profileId: string, profile: Partial<RiskProfileGov>, options: GovernanceQueryOptions): Observable<RiskProfileGov> {
|
updateRiskProfile(profileId: string, profile: Partial<RiskProfileGov>, options: GovernanceQueryOptions): Observable<RiskProfileGov> {
|
||||||
return this.http.put<RiskProfileGov>(`${this.baseUrl}/risk-profiles/${profileId}`, profile, {
|
return this.http.put<RiskProfileGov>(`${this.baseUrl}/risk-profiles/${profileId}`, profile, {
|
||||||
headers: this.buildHeaders(options.traceId),
|
headers: this.buildHeaders(options),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteRiskProfile(profileId: string, options: GovernanceQueryOptions): Observable<void> {
|
deleteRiskProfile(profileId: string, options: GovernanceQueryOptions): Observable<void> {
|
||||||
return this.http.delete<void>(`${this.baseUrl}/risk-profiles/${profileId}`, {
|
return this.http.delete<void>(`${this.baseUrl}/risk-profiles/${profileId}`, {
|
||||||
headers: this.buildHeaders(options.traceId),
|
headers: this.buildHeaders(options),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
activateRiskProfile(profileId: string, options: GovernanceQueryOptions): Observable<RiskProfileGov> {
|
activateRiskProfile(profileId: string, options: GovernanceQueryOptions): Observable<RiskProfileGov> {
|
||||||
return this.http.post<RiskProfileGov>(`${this.baseUrl}/risk-profiles/${profileId}/activate`, {}, {
|
return this.http.post<RiskProfileGov>(`${this.baseUrl}/risk-profiles/${profileId}/activate`, {}, {
|
||||||
headers: this.buildHeaders(options.traceId),
|
headers: this.buildHeaders(options),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
deprecateRiskProfile(profileId: string, reason: string, options: GovernanceQueryOptions): Observable<RiskProfileGov> {
|
deprecateRiskProfile(profileId: string, reason: string, options: GovernanceQueryOptions): Observable<RiskProfileGov> {
|
||||||
return this.http.post<RiskProfileGov>(`${this.baseUrl}/risk-profiles/${profileId}/deprecate`, { reason }, {
|
return this.http.post<RiskProfileGov>(`${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
|
// Audit
|
||||||
getAuditEvents(options: AuditQueryOptions): Observable<AuditResponse> {
|
getAuditEvents(options: AuditQueryOptions): Observable<AuditResponse> {
|
||||||
let params = new HttpParams()
|
let params = this.buildScopeParams(options)
|
||||||
.set('tenantId', options.tenantId)
|
|
||||||
.set('page', (options.page || 1).toString())
|
.set('page', (options.page || 1).toString())
|
||||||
.set('pageSize', (options.pageSize || 20).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.endDate) params = params.set('endDate', options.endDate);
|
||||||
if (options.sortOrder) params = params.set('sortOrder', options.sortOrder);
|
if (options.sortOrder) params = params.set('sortOrder', options.sortOrder);
|
||||||
|
|
||||||
return this.http.get<AuditResponse>(`${this.baseUrl}/audit/events`, { params });
|
return this.http.get<AuditResponse>(`${this.baseUrl}/audit/events`, {
|
||||||
|
params,
|
||||||
|
headers: this.buildHeaders(options),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
getAuditEvent(eventId: string, options: GovernanceQueryOptions): Observable<GovernanceAuditEvent> {
|
getAuditEvent(eventId: string, options: GovernanceQueryOptions): Observable<GovernanceAuditEvent> {
|
||||||
return this.http.get<GovernanceAuditEvent>(`${this.baseUrl}/audit/events/${eventId}`, {
|
return this.http.get<GovernanceAuditEvent>(`${this.baseUrl}/audit/events/${eventId}`, {
|
||||||
headers: this.buildHeaders(options.traceId),
|
headers: this.buildHeaders(options),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Conflicts
|
// Conflicts
|
||||||
getConflictDashboard(options: GovernanceQueryOptions): Observable<PolicyConflictDashboard> {
|
getConflictDashboard(options: GovernanceQueryOptions): Observable<PolicyConflictDashboard> {
|
||||||
const params = new HttpParams()
|
const params = this.buildScopeParams(options);
|
||||||
.set('tenantId', options.tenantId)
|
|
||||||
.set('projectId', options.projectId || '');
|
|
||||||
return this.http.get<PolicyConflictDashboard>(`${this.baseUrl}/conflicts/dashboard`, {
|
return this.http.get<PolicyConflictDashboard>(`${this.baseUrl}/conflicts/dashboard`, {
|
||||||
params,
|
params,
|
||||||
headers: this.buildHeaders(options.traceId),
|
headers: this.buildHeaders(options),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
getConflicts(options: GovernanceQueryOptions & { type?: PolicyConflictType; severity?: PolicyConflictSeverity }): Observable<PolicyConflict[]> {
|
getConflicts(options: GovernanceQueryOptions & { type?: PolicyConflictType; severity?: PolicyConflictSeverity }): Observable<PolicyConflict[]> {
|
||||||
let params = new HttpParams()
|
let params = this.buildScopeParams(options);
|
||||||
.set('tenantId', options.tenantId)
|
|
||||||
.set('projectId', options.projectId || '');
|
|
||||||
if (options.type) params = params.set('type', options.type);
|
if (options.type) params = params.set('type', options.type);
|
||||||
if (options.severity) params = params.set('severity', options.severity);
|
if (options.severity) params = params.set('severity', options.severity);
|
||||||
|
|
||||||
return this.http.get<PolicyConflict[]>(`${this.baseUrl}/conflicts`, {
|
return this.http.get<PolicyConflict[]>(`${this.baseUrl}/conflicts`, {
|
||||||
params,
|
params,
|
||||||
headers: this.buildHeaders(options.traceId),
|
headers: this.buildHeaders(options),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
resolveConflict(conflictId: string, resolution: string, options: GovernanceQueryOptions): Observable<PolicyConflict> {
|
resolveConflict(conflictId: string, resolution: string, options: GovernanceQueryOptions): Observable<PolicyConflict> {
|
||||||
return this.http.post<PolicyConflict>(`${this.baseUrl}/conflicts/${conflictId}/resolve`, { resolution }, {
|
return this.http.post<PolicyConflict>(`${this.baseUrl}/conflicts/${conflictId}/resolve`, { resolution }, {
|
||||||
headers: this.buildHeaders(options.traceId),
|
headers: this.buildHeaders(options),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ignoreConflict(conflictId: string, reason: string, options: GovernanceQueryOptions): Observable<PolicyConflict> {
|
ignoreConflict(conflictId: string, reason: string, options: GovernanceQueryOptions): Observable<PolicyConflict> {
|
||||||
return this.http.post<PolicyConflict>(`${this.baseUrl}/conflicts/${conflictId}/ignore`, { reason }, {
|
return this.http.post<PolicyConflict>(`${this.baseUrl}/conflicts/${conflictId}/ignore`, { reason }, {
|
||||||
headers: this.buildHeaders(options.traceId),
|
headers: this.buildHeaders(options),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,4 +73,12 @@ describe('ConsoleStatusService', () => {
|
|||||||
|
|
||||||
sub.unsubscribe();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -68,6 +68,11 @@ export class ConsoleStatusService {
|
|||||||
*/
|
*/
|
||||||
subscribeToRun(runId: string, options?: RunStreamOptions): Subscription {
|
subscribeToRun(runId: string, options?: RunStreamOptions): Subscription {
|
||||||
this.store.clearEvents();
|
this.store.clearEvents();
|
||||||
|
this.store.setError(null);
|
||||||
|
|
||||||
|
if (this.isCompatibilityRunId(runId)) {
|
||||||
|
return new Subscription();
|
||||||
|
}
|
||||||
|
|
||||||
const traceId = options?.traceId ?? generateTraceId();
|
const traceId = options?.traceId ?? generateTraceId();
|
||||||
const heartbeatMs = options?.heartbeatMs ?? 15000;
|
const heartbeatMs = options?.heartbeatMs ?? 15000;
|
||||||
@@ -131,4 +136,8 @@ export class ConsoleStatusService {
|
|||||||
clear(): void {
|
clear(): void {
|
||||||
this.store.clear();
|
this.store.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isCompatibilityRunId(runId: string): boolean {
|
||||||
|
return runId.trim().startsWith('run::');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
|
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 { Component, Input, Output, EventEmitter } from '@angular/core';
|
||||||
import { of, throwError } from 'rxjs';
|
import { of, throwError } from 'rxjs';
|
||||||
import { delay } from 'rxjs/operators';
|
import { delay } from 'rxjs/operators';
|
||||||
@@ -27,7 +27,7 @@ class MockShadowModeIndicatorComponent {
|
|||||||
describe('SimulationDashboardComponent', () => {
|
describe('SimulationDashboardComponent', () => {
|
||||||
let component: SimulationDashboardComponent;
|
let component: SimulationDashboardComponent;
|
||||||
let fixture: ComponentFixture<SimulationDashboardComponent>;
|
let fixture: ComponentFixture<SimulationDashboardComponent>;
|
||||||
let mockApi: jasmine.SpyObj<PolicySimulationApi>;
|
let mockApi: jasmine.SpyObj<Pick<PolicySimulationApi, 'getShadowModeConfig' | 'enableShadowMode' | 'disableShadowMode'>>;
|
||||||
let router: Router;
|
let router: Router;
|
||||||
|
|
||||||
const mockConfig: ShadowModeConfig = {
|
const mockConfig: ShadowModeConfig = {
|
||||||
@@ -46,7 +46,7 @@ describe('SimulationDashboardComponent', () => {
|
|||||||
'getShadowModeConfig',
|
'getShadowModeConfig',
|
||||||
'enableShadowMode',
|
'enableShadowMode',
|
||||||
'disableShadowMode',
|
'disableShadowMode',
|
||||||
]);
|
]) as jasmine.SpyObj<Pick<PolicySimulationApi, 'getShadowModeConfig' | 'enableShadowMode' | 'disableShadowMode'>>;
|
||||||
mockApi.getShadowModeConfig.and.returnValue(of(mockConfig));
|
mockApi.getShadowModeConfig.and.returnValue(of(mockConfig));
|
||||||
mockApi.enableShadowMode.and.returnValue(of(mockConfig));
|
mockApi.enableShadowMode.and.returnValue(of(mockConfig));
|
||||||
mockApi.disableShadowMode.and.returnValue(of(undefined));
|
mockApi.disableShadowMode.and.returnValue(of(undefined));
|
||||||
@@ -56,12 +56,12 @@ describe('SimulationDashboardComponent', () => {
|
|||||||
SimulationDashboardComponent,
|
SimulationDashboardComponent,
|
||||||
MockShadowModeIndicatorComponent,
|
MockShadowModeIndicatorComponent,
|
||||||
],
|
],
|
||||||
providers: [provideRouter([]), { provide: POLICY_SIMULATION_API, useValue: mockApi }],
|
providers: [provideRouter([]), { provide: POLICY_SIMULATION_API, useValue: mockApi as unknown as PolicySimulationApi }],
|
||||||
})
|
})
|
||||||
.overrideComponent(SimulationDashboardComponent, {
|
.overrideComponent(SimulationDashboardComponent, {
|
||||||
set: {
|
set: {
|
||||||
imports: [MockShadowModeIndicatorComponent],
|
imports: [RouterModule, MockShadowModeIndicatorComponent],
|
||||||
providers: [{ provide: POLICY_SIMULATION_API, useValue: mockApi }],
|
providers: [{ provide: POLICY_SIMULATION_API, useValue: mockApi as unknown as PolicySimulationApi }],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.compileComponents();
|
.compileComponents();
|
||||||
@@ -104,6 +104,7 @@ describe('SimulationDashboardComponent', () => {
|
|||||||
tick();
|
tick();
|
||||||
|
|
||||||
expect(mockApi.getShadowModeConfig).toHaveBeenCalled();
|
expect(mockApi.getShadowModeConfig).toHaveBeenCalled();
|
||||||
|
expect(mockApi.getShadowModeConfig.calls.mostRecent()!.args.length).toBe(0);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
it('should set shadow config on successful load', fakeAsync(() => {
|
it('should set shadow config on successful load', fakeAsync(() => {
|
||||||
@@ -190,6 +191,7 @@ describe('SimulationDashboardComponent', () => {
|
|||||||
tick();
|
tick();
|
||||||
|
|
||||||
expect(mockApi.enableShadowMode).toHaveBeenCalled();
|
expect(mockApi.enableShadowMode).toHaveBeenCalled();
|
||||||
|
expect(mockApi.enableShadowMode.calls.mostRecent()!.args.length).toBe(1);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
it('should set loading state during API call', fakeAsync(() => {
|
it('should set loading state during API call', fakeAsync(() => {
|
||||||
@@ -216,6 +218,7 @@ describe('SimulationDashboardComponent', () => {
|
|||||||
tick();
|
tick();
|
||||||
|
|
||||||
expect(mockApi.disableShadowMode).toHaveBeenCalled();
|
expect(mockApi.disableShadowMode).toHaveBeenCalled();
|
||||||
|
expect(mockApi.disableShadowMode.calls.mostRecent()!.args.length).toBe(0);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
it('should update config to disabled state', fakeAsync(() => {
|
it('should update config to disabled state', fakeAsync(() => {
|
||||||
@@ -233,7 +236,7 @@ describe('SimulationDashboardComponent', () => {
|
|||||||
|
|
||||||
component['navigateToHistory']();
|
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(() => {
|
it('should navigate to promotion on navigateToPromotion', fakeAsync(() => {
|
||||||
@@ -241,7 +244,7 @@ describe('SimulationDashboardComponent', () => {
|
|||||||
|
|
||||||
component['navigateToPromotion']();
|
component['navigateToPromotion']();
|
||||||
|
|
||||||
expect(router.navigate).toHaveBeenCalledWith(['/policy/simulation/promotion']);
|
expect(router.navigate).toHaveBeenCalledWith(['/ops/policy/simulation/promotion']);
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -554,7 +554,7 @@ export class SimulationDashboardComponent implements OnInit {
|
|||||||
|
|
||||||
protected loadShadowStatus(): void {
|
protected loadShadowStatus(): void {
|
||||||
this.shadowLoading.set(true);
|
this.shadowLoading.set(true);
|
||||||
this.api.getShadowModeConfig({ tenantId: 'default' }).pipe(
|
this.api.getShadowModeConfig().pipe(
|
||||||
finalize(() => this.shadowLoading.set(false))
|
finalize(() => this.shadowLoading.set(false))
|
||||||
).subscribe({
|
).subscribe({
|
||||||
next: (config) => {
|
next: (config) => {
|
||||||
@@ -586,7 +586,7 @@ export class SimulationDashboardComponent implements OnInit {
|
|||||||
activePackId: 'policy-pack-001',
|
activePackId: 'policy-pack-001',
|
||||||
activeVersion: 1,
|
activeVersion: 1,
|
||||||
trafficPercentage: 10,
|
trafficPercentage: 10,
|
||||||
}, { tenantId: 'default' }).pipe(
|
}).pipe(
|
||||||
finalize(() => this.shadowLoading.set(false))
|
finalize(() => this.shadowLoading.set(false))
|
||||||
).subscribe({
|
).subscribe({
|
||||||
next: (config) => {
|
next: (config) => {
|
||||||
@@ -597,7 +597,7 @@ export class SimulationDashboardComponent implements OnInit {
|
|||||||
|
|
||||||
protected disableShadowMode(): void {
|
protected disableShadowMode(): void {
|
||||||
this.shadowLoading.set(true);
|
this.shadowLoading.set(true);
|
||||||
this.api.disableShadowMode({ tenantId: 'default' }).pipe(
|
this.api.disableShadowMode().pipe(
|
||||||
finalize(() => this.shadowLoading.set(false))
|
finalize(() => this.shadowLoading.set(false))
|
||||||
).subscribe({
|
).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user