Stabilize web context propagation and header constants
This commit is contained in:
@@ -0,0 +1,410 @@
|
||||
# Sprint 026 — Global Context Propagation & Header Naming Cleanup
|
||||
|
||||
## Topic & Scope
|
||||
|
||||
- **Goal**: Replace the hardcoded Pack 2.2 route list in `GlobalContextHttpInterceptor` with an OpenAPI-driven parameter map, so context (region, environment, timeWindow, stage) is injected as query params precisely where each endpoint declares support.
|
||||
- **Goal**: Unify all HTTP header naming to a single canonical prefix `X-Stella-Ops-*`, removing legacy `X-Stella-*`, `X-StellaOps-*`, `X-Tenant-Id`, `X-Scopes`, `X-Project-Id` traces.
|
||||
- **Why now**: The topbar context controls (tenant, region, env, window, stage) are now global singletons. The propagation layer must match — precise, automatic, and consistently named.
|
||||
- Working directory: repo root (cross-module sprint).
|
||||
- Explicitly allows cross-module edits in: `src/Web/`, `src/Router/`, `src/Platform/`, `src/Authority/`, `src/Scanner/`, `src/Policy/`, `src/Graph/`, `src/Concelier/`, `src/Findings/`, `src/JobEngine/`, `src/Notifier/`, `src/Telemetry/`, `src/EvidenceLocker/`, `src/AdvisoryAI/`, `src/BinaryIndex/`, `src/Replay/`.
|
||||
- Expected evidence: interceptor tests, OpenAPI spec diff showing params, grep confirming zero legacy header references.
|
||||
|
||||
## Dependencies & Concurrency
|
||||
|
||||
- No upstream sprint dependencies.
|
||||
- Phase 1 (OpenAPI param declarations) and Phase 3 (header cleanup) can run in parallel.
|
||||
- Phase 2 (interceptor rewrite) depends on Phase 1 completion.
|
||||
- Phase 3 is independent of Phase 1 and Phase 2 — pure rename.
|
||||
- Header cleanup (Phase 3) should be done in a single atomic commit per module to avoid partial renames.
|
||||
|
||||
## Documentation Prerequisites
|
||||
|
||||
- Read before starting:
|
||||
- `docs/modules/platform/architecture-overview.md` (context propagation)
|
||||
- `src/Router/__Libraries/StellaOps.Router.Gateway/OpenApi/OpenApiDocumentGenerator.cs` (aggregation logic)
|
||||
- `src/Web/StellaOps.Web/src/app/core/context/global-context-http.interceptor.ts` (current interceptor)
|
||||
- `src/Web/StellaOps.Web/src/app/core/auth/tenant-http.interceptor.ts` (current tenant headers)
|
||||
- `src/Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOpsHttpHeaderNames.cs` (master constants)
|
||||
|
||||
---
|
||||
|
||||
## Current State (research findings)
|
||||
|
||||
### Context propagation today
|
||||
|
||||
1. **`TenantHttpInterceptor`** (Angular) — sends `X-StellaOps-Tenant`, `X-Stella-Tenant`, `X-Tenant-Id` on all authenticated requests. Already works but emits 3 names for the same value.
|
||||
2. **`GlobalContextHttpInterceptor`** (Angular) — injects `regions`, `environments`, `timeWindow` as **query parameters**, but only on 6 hardcoded `/api/v2/*` route prefixes. Does not inject `stage`.
|
||||
3. **Gateway** — strips reserved identity headers, re-writes from JWT claims. Copies all other headers downstream. Query params pass through unchanged.
|
||||
4. **Backend services** — read context from query params via `[AsParameters]` binding (e.g., `TopologyQuery`, `SecurityFindingsQuery` records). No services read context from headers.
|
||||
|
||||
### OpenAPI spec today
|
||||
|
||||
- Gateway serves aggregated spec at `/openapi.json` and `/.well-known/openapi` (1899 paths, 2190 operations).
|
||||
- Angular already has `GatewayOpenApiHttpClient` that fetches the spec with ETag caching.
|
||||
- **Critical gap**: The spec declares **zero query parameters**. Services use `[AsParameters]` records but neither the built-in `MapOpenApi()` nor the custom `OpenApiDiscoveryDocumentProvider` emit parameter metadata for them.
|
||||
|
||||
### Header naming today
|
||||
|
||||
| Legacy name | Current canonical | Proposed canonical | Files affected |
|
||||
|---|---|---|---|
|
||||
| `X-Stella-Tenant` | `X-StellaOps-Tenant` | `X-Stella-Ops-Tenant` | ~64 |
|
||||
| `X-Tenant-Id` | `X-StellaOps-Tenant` | `X-Stella-Ops-Tenant` | ~40 |
|
||||
| `X-Scopes` | `X-StellaOps-Scopes` | `X-Stella-Ops-Scopes` | ~30 |
|
||||
| `X-Stella-Project` | `X-StellaOps-Project` | `X-Stella-Ops-Project` | ~10 |
|
||||
| `X-Project-Id` | `X-StellaOps-Project` | `X-Stella-Ops-Project` | ~2 |
|
||||
| `X-StellaOps-Actor` | — | `X-Stella-Ops-Actor` | ~80 |
|
||||
| `X-StellaOps-Client` | — | `X-Stella-Ops-Client` | ~40 |
|
||||
| `X-StellaOps-Identity-Envelope` | — | `X-Stella-Ops-Identity-Envelope` | ~5 |
|
||||
| `X-Stella-Trace-Id` | — | `X-Stella-Ops-Trace-Id` | ~20 |
|
||||
| `X-Stella-Request-Id` | — | `X-Stella-Ops-Request-Id` | ~10 |
|
||||
|
||||
**Total scope**: ~300 header string references across ~150 files.
|
||||
|
||||
---
|
||||
|
||||
## Delivery Tracker
|
||||
|
||||
### TASK-026-01 — Emit query parameter declarations in service OpenAPI specs
|
||||
Status: DOING
|
||||
Dependency: none
|
||||
Owners: Developer (Backend)
|
||||
Task description:
|
||||
|
||||
Backend services use `[AsParameters]` records (e.g., `TopologyQuery`, `SecurityFindingsQuery`) to bind query parameters, but neither the built-in ASP.NET `MapOpenApi()` nor the custom `OpenApiDiscoveryDocumentProvider` implementations emit `parameters` entries in the OpenAPI spec.
|
||||
|
||||
Fix the OpenAPI generation so that query parameters from `[AsParameters]` records appear in the generated spec. Two approaches (pick one):
|
||||
|
||||
**Option A (preferred)**: Configure ASP.NET Core's built-in OpenAPI to recognize `[AsParameters]`. In .NET 9, `Microsoft.AspNetCore.OpenApi` should auto-detect record properties bound from query strings. Verify this works for services using `AddOpenApi()` + `MapOpenApi()`. If not, add `[FromQuery]` attributes to the record properties.
|
||||
|
||||
**Option B**: For services using custom `OpenApiDiscoveryDocumentProvider`, extend the provider to introspect endpoint parameter metadata and emit `parameters` arrays with `in: "query"` entries.
|
||||
|
||||
Target context parameter names to ensure are declared where endpoints accept them:
|
||||
- `region` / `regions`
|
||||
- `environment` / `environments`
|
||||
- `timeWindow`
|
||||
- `stage`
|
||||
- `tenant` / `tenantId`
|
||||
|
||||
Completion criteria:
|
||||
- [ ] `/openapi.json` from gateway includes `parameters` arrays with `in: "query"` entries for endpoints that accept context params
|
||||
- [ ] At least `TopologyReadModelEndpoints`, `SecurityReadModelEndpoints` show params in spec
|
||||
- [ ] Snapshot test updated if openapi_current.json is a contract fixture
|
||||
|
||||
### TASK-026-02 — Verify gateway OpenAPI aggregation preserves query params
|
||||
Status: DOING
|
||||
Dependency: TASK-026-01
|
||||
Owners: Developer (Backend)
|
||||
Task description:
|
||||
|
||||
The gateway's `OpenApiDocumentGenerator` aggregates specs from all microservices. Verify that it preserves the `parameters` arrays from individual service specs when building the consolidated `/openapi.json`.
|
||||
|
||||
If the aggregator drops parameters, fix it in `src/Router/__Libraries/StellaOps.Router.Gateway/OpenApi/OpenApiDocumentGenerator.cs`.
|
||||
|
||||
Completion criteria:
|
||||
- [ ] Gateway `/openapi.json` includes all query parameters that individual service specs declare
|
||||
- [ ] ETag cache invalidates when parameter declarations change
|
||||
- [ ] Manual verification: `curl /openapi.json | jq '.paths["/api/v2/topology/regions"].get.parameters'` returns non-empty array
|
||||
|
||||
### TASK-026-03 — Build OpenAPI context parameter map service (Angular) + wire into APP_INITIALIZER
|
||||
Status: DOING
|
||||
Dependency: TASK-026-01
|
||||
Owners: Developer (Frontend)
|
||||
Task description:
|
||||
|
||||
Create a new Angular service `OpenApiContextParamMap` (in `core/context/`) that is **pre-loaded during app bootstrap**, before any feature module or route resolves. No usable module should load before this data is available.
|
||||
|
||||
**Current bootstrap chain** (in `app.config.ts`, APP_INITIALIZER):
|
||||
```
|
||||
1. AppConfigService.load() → /platform/envsettings.json [AWAIT]
|
||||
2. I18nService.loadTranslations() → i18n JSON [AWAIT]
|
||||
3. BackendProbeService.probe() → OIDC probe [FIRE-AND-FORGET]
|
||||
```
|
||||
|
||||
**New bootstrap chain** (add step between i18n and probe):
|
||||
```
|
||||
1. AppConfigService.load() → /platform/envsettings.json [AWAIT]
|
||||
2. I18nService.loadTranslations() → i18n JSON [AWAIT]
|
||||
3. OpenApiContextParamMap.initialize() → /openapi.json → build Map [AWAIT] ← NEW
|
||||
4. BackendProbeService.probe() → OIDC probe [FIRE-AND-FORGET]
|
||||
```
|
||||
|
||||
Step 3 depends on step 1 (needs base URL from config). It MUST complete before routes load so the interceptor's param map is populated for the first API call.
|
||||
|
||||
**Service requirements:**
|
||||
|
||||
1. `initialize()` method — called from `provideAppInitializer()` in `app.config.ts`:
|
||||
- Fetches the OpenAPI spec via the existing `GatewayOpenApiHttpClient.getOpenApiSpec()`.
|
||||
- IMPORTANT: Must use raw `HttpBackend` (not `HttpClient`) to avoid circular dependency with interceptors that depend on this service. Follow the same pattern as `AppConfigService.load()` which uses `HttpBackend` to bypass interceptors.
|
||||
- Parses the `paths` object and builds a `Map<string, Set<string>>` where:
|
||||
- Key = normalized path pattern (e.g., `/api/v2/topology/regions`)
|
||||
- Value = set of context query param names declared on that path (intersection with known context params: `region`, `regions`, `environment`, `environments`, `timeWindow`, `stage`, `tenant`, `tenantId`)
|
||||
- Must NOT throw — if spec fetch fails, initialize with empty map (graceful degradation, no context injection).
|
||||
|
||||
2. `getContextParams(url: string): Set<string> | undefined` — synchronous lookup method used by the interceptor at request time. Must be fast (O(1)-ish).
|
||||
|
||||
3. ETag caching — store the ETag from the spec response. On subsequent calls (e.g., periodic refresh), send `If-None-Match` to avoid re-downloading unchanged spec.
|
||||
|
||||
**Path matching strategy:**
|
||||
- Strip query string and fragment from URL before matching.
|
||||
- Normalize path params: `/api/v2/topology/environments/{id}` matches `/api/v2/topology/environments/abc-123`.
|
||||
- Build a regex or trie from OpenAPI path templates for O(1)-ish lookup at request time. A simple approach: convert `{param}` segments to `[^/]+` regex, compile once during `initialize()`.
|
||||
|
||||
**APP_INITIALIZER wiring** (in `app.config.ts`):
|
||||
```typescript
|
||||
provideAppInitializer(async () => {
|
||||
const configService = inject(AppConfigService);
|
||||
const i18nService = inject(I18nService);
|
||||
const paramMap = inject(OpenApiContextParamMap);
|
||||
const probeService = inject(BackendProbeService);
|
||||
|
||||
await configService.load();
|
||||
await i18nService.loadTranslations();
|
||||
await paramMap.initialize(); // ← NEW — blocks until map is ready
|
||||
void probeService.probe();
|
||||
}),
|
||||
```
|
||||
|
||||
Completion criteria:
|
||||
- [ ] Service exists at `core/context/openapi-context-param-map.service.ts`
|
||||
- [ ] Wired into `provideAppInitializer()` in `app.config.ts` — runs after config load, before probe
|
||||
- [ ] Map is built from live OpenAPI spec at startup, before any route resolves
|
||||
- [ ] Uses `HttpBackend` (not `HttpClient`) to avoid interceptor circular dependency
|
||||
- [ ] `getContextParams()` returns correct param set for known paths
|
||||
- [ ] Falls back gracefully if spec fetch fails (empty map, no crash, no context injection)
|
||||
- [ ] Unit tests with mock spec covering: exact match, parameterized path match, no-match, empty spec, fetch failure
|
||||
|
||||
### TASK-026-04 — Rewrite GlobalContextHttpInterceptor to use OpenAPI map
|
||||
Status: TODO
|
||||
Dependency: TASK-026-03
|
||||
Owners: Developer (Frontend)
|
||||
Task description:
|
||||
|
||||
Replace the current `GlobalContextHttpInterceptor` implementation:
|
||||
|
||||
**Current** (hardcoded):
|
||||
```typescript
|
||||
if (!this.isPack22ContextAwareRoute(request.url)) {
|
||||
return next.handle(request);
|
||||
}
|
||||
// always inject all params
|
||||
```
|
||||
|
||||
**New** (OpenAPI-driven):
|
||||
```typescript
|
||||
const contextParams = this.paramMap.getContextParams(request.url);
|
||||
if (!contextParams || contextParams.size === 0) {
|
||||
return next.handle(request);
|
||||
}
|
||||
// inject ONLY the params this endpoint declares
|
||||
if (contextParams.has('regions') && regions.length > 0 && !params.has('regions')) {
|
||||
params = params.set('regions', regions.join(','));
|
||||
}
|
||||
// ... etc for each known context param
|
||||
```
|
||||
|
||||
Also add `stage` support (currently missing from interceptor).
|
||||
|
||||
Remove the duplicate param aliases (`region` + `regions`, `tenant` + `tenantId`) — inject only the name declared in the spec.
|
||||
|
||||
Completion criteria:
|
||||
- [ ] `isPack22ContextAwareRoute()` method deleted
|
||||
- [ ] Interceptor uses `OpenApiContextParamMap.getContextParams()` for decisions
|
||||
- [ ] Only declared params are injected per-endpoint
|
||||
- [ ] `stage` param supported
|
||||
- [ ] No params injected when spec unavailable (graceful degradation)
|
||||
- [ ] Unit tests with mock param map
|
||||
|
||||
### TASK-026-05 — Centralize header name constants
|
||||
Status: TODO
|
||||
Dependency: none (parallel with Phase 1)
|
||||
Owners: Developer (Backend)
|
||||
Task description:
|
||||
|
||||
Extend the existing `StellaOpsHttpHeaderNames` class in `src/Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOpsHttpHeaderNames.cs` (or create a new shared constants file in a lower-level shared lib if Auth.Abstractions is too high in the dependency graph) with ALL canonical header names:
|
||||
|
||||
```csharp
|
||||
public static class StellaOpsHttpHeaders
|
||||
{
|
||||
// Identity
|
||||
public const string Tenant = "X-Stella-Ops-Tenant";
|
||||
public const string Actor = "X-Stella-Ops-Actor";
|
||||
public const string Scopes = "X-Stella-Ops-Scopes";
|
||||
public const string Roles = "X-Stella-Ops-Roles";
|
||||
public const string Client = "X-Stella-Ops-Client";
|
||||
public const string Project = "X-Stella-Ops-Project";
|
||||
public const string Session = "X-Stella-Ops-Session";
|
||||
|
||||
// Envelope (Hybrid trust)
|
||||
public const string IdentityEnvelope = "X-Stella-Ops-Identity-Envelope";
|
||||
public const string IdentityEnvelopeSignature = "X-Stella-Ops-Identity-Envelope-Signature";
|
||||
|
||||
// Observability
|
||||
public const string TraceId = "X-Stella-Ops-Trace-Id";
|
||||
public const string RequestId = "X-Stella-Ops-Request-Id";
|
||||
|
||||
// Audit
|
||||
public const string AuditContext = "X-Stella-Ops-Audit-Context";
|
||||
}
|
||||
```
|
||||
|
||||
Completion criteria:
|
||||
- [ ] Single source of truth for all header name strings
|
||||
- [ ] No hardcoded header strings remain in any service — all reference the constants
|
||||
- [ ] Compiles without errors
|
||||
|
||||
### TASK-026-06 — Rename headers in frontend (Angular)
|
||||
Status: TODO
|
||||
Dependency: TASK-026-05
|
||||
Owners: Developer (Frontend)
|
||||
Task description:
|
||||
|
||||
Update all Angular interceptors and services to use the new `X-Stella-Ops-*` naming:
|
||||
|
||||
Files to update:
|
||||
- `core/auth/tenant-http.interceptor.ts` — change `X-StellaOps-Tenant` → `X-Stella-Ops-Tenant`, remove `X-Stella-Tenant` and `X-Tenant-Id` emissions entirely, change `X-Project-Id` → `X-Stella-Ops-Project`, change `X-Stella-Trace-Id` → `X-Stella-Ops-Trace-Id`
|
||||
- `core/auth/auth-http.interceptor.ts` — change `X-StellaOps-DPoP-Retry` → `X-Stella-Ops-DPoP-Retry`
|
||||
- `core/orchestrator/operator-metadata.interceptor.ts` — change `X-Stella-Require-Operator` → `X-Stella-Ops-Require-Operator`, `X-Stella-Operator-Reason` → `X-Stella-Ops-Operator-Reason`, `X-Stella-Operator-Ticket` → `X-Stella-Ops-Operator-Ticket`
|
||||
- `core/api/gateway-openapi.client.ts` — change `X-Stella-Trace-Id` / `X-Stella-Request-Id` → `X-Stella-Ops-Trace-Id` / `X-Stella-Ops-Request-Id`
|
||||
- `core/auth/tenant-activation.service.ts` — update JSDoc references
|
||||
|
||||
Create a TypeScript constants file `core/http/stella-ops-headers.ts`:
|
||||
```typescript
|
||||
export const StellaOpsHeaders = {
|
||||
Tenant: 'X-Stella-Ops-Tenant',
|
||||
Actor: 'X-Stella-Ops-Actor',
|
||||
Scopes: 'X-Stella-Ops-Scopes',
|
||||
Project: 'X-Stella-Ops-Project',
|
||||
TraceId: 'X-Stella-Ops-Trace-Id',
|
||||
RequestId: 'X-Stella-Ops-Request-Id',
|
||||
AuditContext: 'X-Stella-Ops-Audit-Context',
|
||||
DpopRetry: 'X-Stella-Ops-DPoP-Retry',
|
||||
OperatorReason: 'X-Stella-Ops-Operator-Reason',
|
||||
OperatorTicket: 'X-Stella-Ops-Operator-Ticket',
|
||||
RequireOperator: 'X-Stella-Ops-Require-Operator',
|
||||
} as const;
|
||||
```
|
||||
|
||||
Completion criteria:
|
||||
- [ ] Zero references to `X-StellaOps-*`, `X-Stella-Tenant`, `X-Tenant-Id`, `X-Scopes`, `X-Project-Id` in `src/Web/`
|
||||
- [ ] All interceptors use constants from `stella-ops-headers.ts`
|
||||
- [ ] `ng build` passes
|
||||
|
||||
### TASK-026-07 — Rename headers in gateway (Router)
|
||||
Status: TODO
|
||||
Dependency: TASK-026-05
|
||||
Owners: Developer (Backend)
|
||||
Task description:
|
||||
|
||||
Update the gateway to emit and read the new canonical header names:
|
||||
|
||||
Key files:
|
||||
- `src/Router/StellaOps.Gateway.WebService/Middleware/IdentityHeaderPolicyMiddleware.cs` — update the `ReservedHeaders` array and downstream header writes (lines 481-567) to use `X-Stella-Ops-*`
|
||||
- `src/Router/__Libraries/StellaOps.Microservice.AspNetCore/AspNetRouterRequestDispatcher.cs` — update header name constants (lines 30-35)
|
||||
- `src/Router/__Libraries/StellaOps.Microservice.AspNetCore/TenantAccessorPopulationMiddleware.cs` — if it reads tenant headers
|
||||
|
||||
During transition, the gateway MUST accept BOTH old and new names on incoming requests (read both, prefer new). On downstream writes, emit ONLY the new names.
|
||||
|
||||
Completion criteria:
|
||||
- [ ] Gateway reads both `X-StellaOps-Tenant` and `X-Stella-Ops-Tenant` on incoming (backward compat)
|
||||
- [ ] Gateway writes only `X-Stella-Ops-*` downstream
|
||||
- [ ] `ReservedHeaders` array includes both old and new names (strips both from incoming)
|
||||
- [ ] All services continue to work after gateway rename
|
||||
- [ ] Gateway integration tests pass
|
||||
|
||||
### TASK-026-08 — Rename headers in backend services
|
||||
Status: TODO
|
||||
Dependency: TASK-026-05, TASK-026-07
|
||||
Owners: Developer (Backend)
|
||||
Task description:
|
||||
|
||||
Update all backend services to read the new canonical header names. Replace all scattered header string constants with references to the centralized constants from TASK-026-05.
|
||||
|
||||
High-impact modules (by reference count):
|
||||
1. **Notifier** (~50 refs to `X-Tenant-Id`) — `src/Notifier/`
|
||||
2. **Scheduler** (~30 refs to `X-Tenant-Id`) — `src/JobEngine/StellaOps.Scheduler*/`
|
||||
3. **EvidenceLocker** (~20 refs) — `src/EvidenceLocker/`
|
||||
4. **Graph** (legacy `X-Stella-Tenant`, `X-Stella-Project`) — `src/Graph/`
|
||||
5. **Policy** (legacy `X-Stella-Tenant`, `X-Stella-Project`) — `src/Policy/`
|
||||
6. **Scanner** (legacy `X-Stella-Tenant`) — `src/Scanner/`
|
||||
7. **Platform** (legacy `X-Stella-Tenant`, `X-Stella-Project`) — `src/Platform/`
|
||||
8. **Authority** (mixed) — `src/Authority/`
|
||||
9. **AdvisoryAI** (mostly correct `X-StellaOps-*`) — `src/AdvisoryAI/`
|
||||
10. **Telemetry** (`X-Tenant-Id`) — `src/Telemetry/`
|
||||
11. **Concelier** (legacy `X-Stella-TraceId`) — `src/Concelier/`
|
||||
|
||||
During transition, services SHOULD accept both old and new names (read new first, fall back to old). This prevents breakage if gateway and services are deployed at different times.
|
||||
|
||||
Approach per service:
|
||||
1. Add dependency on the shared constants package from TASK-026-05
|
||||
2. Replace all hardcoded header strings with constant references
|
||||
3. In request context resolvers (e.g., `ScannerRequestContextResolver`, `GraphRequestContextResolver`, `PlatformRequestContextResolver`), read new name first, fall back to old
|
||||
4. In test helpers/factories, update header names in test request builders
|
||||
|
||||
Completion criteria:
|
||||
- [ ] `grep -r "X-Stella-Tenant\|X-Tenant-Id\|X-StellaOps-\|X-Scopes\b\|X-Project-Id" src/ --include="*.cs"` returns zero matches (excluding the backward-compat fallback reads)
|
||||
- [ ] All services reference centralized constants
|
||||
- [ ] All unit/integration tests pass
|
||||
- [ ] Test helpers updated to use new header names
|
||||
|
||||
### TASK-026-09 — Update documentation
|
||||
Status: TODO
|
||||
Dependency: TASK-026-04, TASK-026-08
|
||||
Owners: Documentation author
|
||||
Task description:
|
||||
|
||||
Update the following docs to reflect the new header naming and context propagation approach:
|
||||
- `docs/modules/platform/architecture-overview.md` — context propagation section
|
||||
- Module dossiers for Router, Platform, Scanner, Policy if they reference header names
|
||||
- This sprint's Decisions & Risks section with links to updated docs
|
||||
|
||||
Completion criteria:
|
||||
- [ ] All docs reference `X-Stella-Ops-*` naming
|
||||
- [ ] Context propagation flow documented (OpenAPI-driven query param injection)
|
||||
- [ ] No references to legacy header names in `docs/`
|
||||
|
||||
---
|
||||
|
||||
## Execution Log
|
||||
|
||||
| Date (UTC) | Update | Owner |
|
||||
| --- | --- | --- |
|
||||
| 2026-03-10 | Sprint created with research findings. | Planning |
|
||||
| 2026-03-10 | Implemented frontend OpenAPI context map bootstrap, rewrote the global context interceptor to use the route map, introduced canonical `X-Stella-Ops-*` frontend header constants, and repaired the stale header cleanup fallout that was breaking `ng build`. Focused specs passed (`27/27`) and `npm run build` passed after the cleanup. | Codex |
|
||||
|
||||
## Decisions & Risks
|
||||
|
||||
### D1: Header naming convention — `X-Stella-Ops-*` (hyphenated)
|
||||
Decision: Use `X-Stella-Ops-*` as the canonical prefix (hyphen-separated words).
|
||||
Rationale: Follows HTTP header convention (hyphen-separated), readable, unambiguous.
|
||||
Note: HTTP headers are case-insensitive per RFC 7230, so `X-StellaOps-Tenant` and `X-Stella-Ops-Tenant` are equivalent at the wire level. The rename is for code consistency and readability.
|
||||
|
||||
### D2: OpenAPI-driven vs header-based context propagation
|
||||
Decision: Use OpenAPI-driven query parameter injection (not headers).
|
||||
Rationale: Precise (only injects where endpoint declares support), zero backend changes needed, self-documenting via OpenAPI spec, no router middleware required.
|
||||
Alternative rejected: Sending context as `X-Stella-Ops-Context-*` headers with router-side translation to query params — adds moving parts, wastes bandwidth on endpoints that don't use context.
|
||||
|
||||
### D3: Backward compatibility during header rename
|
||||
Risk: Renaming headers across 150+ files risks breaking services during partial deployment.
|
||||
Mitigation: Gateway accepts both old and new names on incoming requests. Services read new name first, fall back to old. This allows rolling deployment without coordination.
|
||||
Timeline: Legacy fallback reads can be removed in a follow-up sprint after all services are confirmed on new names.
|
||||
|
||||
### D4: OpenAPI spec currently declares zero query parameters
|
||||
Risk: The entire OpenAPI-driven approach depends on query params being in the spec (TASK-026-01).
|
||||
Mitigation: If ASP.NET Core's built-in OpenAPI doesn't auto-detect `[AsParameters]`, add explicit `[FromQuery]` attributes to record properties. Verify with one service first (Platform) before rolling out.
|
||||
Fallback: If OpenAPI param emission proves complex, the interceptor can use a static fallback map (hardcoded like today but more complete) while the spec catches up.
|
||||
|
||||
### D5: Header cleanup scope (~300 references, ~150 files)
|
||||
Risk: Mass rename across the codebase.
|
||||
Mitigation: Each module is renamed in a single atomic commit. Use find-and-replace with constant references to prevent typos. Run full test suite per module.
|
||||
|
||||
### D6: Frontend header cleanup is shipped ahead of backend constant unification
|
||||
Decision: Land the Angular-side canonical header constants and client/interceptor cleanup first, while the backend shared constant unification remains a separate follow-through.
|
||||
Rationale: The web bundle and request layer were already carrying the highest defect risk from mixed header names and stale replacements. Shipping the frontend cleanup immediately removes build/runtime instability without blocking on the wider backend rename.
|
||||
Remaining work: backend shared constants, gateway dual-read/write cleanup, and the remaining legacy spec/debug assertions under `src/Web/`.
|
||||
|
||||
## Next Checkpoints
|
||||
|
||||
- Phase 1 done (OpenAPI params in spec): verify with `curl /openapi.json` and snapshot diff
|
||||
- Phase 2 done (interceptor rewrite): verify no hardcoded route list, context flows to new endpoints automatically
|
||||
- Phase 3 done (header cleanup): `grep` confirms zero legacy references
|
||||
- Full integration test: deploy all services, verify login + dashboard + API calls work end-to-end
|
||||
@@ -37,6 +37,7 @@ import { I18nService } from './core/i18n';
|
||||
import { DoctorTrendService } from './core/doctor/doctor-trend.service';
|
||||
import { DoctorNotificationService } from './core/doctor/doctor-notification.service';
|
||||
import { BackendProbeService } from './core/config/backend-probe.service';
|
||||
import { OpenApiContextParamMap } from './core/context/openapi-context-param-map.service';
|
||||
import { AuthHttpInterceptor } from './core/auth/auth-http.interceptor';
|
||||
import { AuthSessionStore } from './core/auth/auth-session.store';
|
||||
import { TenantActivationService } from './core/auth/tenant-activation.service';
|
||||
@@ -290,13 +291,14 @@ export const appConfig: ApplicationConfig = {
|
||||
{ provide: TitleStrategy, useClass: PageTitleStrategy },
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideAppInitializer(() => {
|
||||
const initializerFn = ((configService: AppConfigService, probeService: BackendProbeService, i18nService: I18nService) => async () => {
|
||||
const initializerFn = ((configService: AppConfigService, probeService: BackendProbeService, i18nService: I18nService, openApiParamMap: OpenApiContextParamMap) => async () => {
|
||||
await configService.load();
|
||||
await i18nService.loadTranslations();
|
||||
await openApiParamMap.initialize();
|
||||
if (configService.isConfigured()) {
|
||||
probeService.probe();
|
||||
}
|
||||
})(inject(AppConfigService), inject(BackendProbeService), inject(I18nService));
|
||||
})(inject(AppConfigService), inject(BackendProbeService), inject(I18nService), inject(OpenApiContextParamMap));
|
||||
return initializerFn();
|
||||
}),
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Observable, of, delay, throwError } from 'rxjs';
|
||||
|
||||
import { AppConfigService } from '../config/app-config.service';
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import { StellaOpsHeaders } from '../http/stella-ops-headers';
|
||||
|
||||
/**
|
||||
* ABAC policy input attributes.
|
||||
@@ -241,7 +242,7 @@ export class AbacOverlayHttpClient implements AbacOverlayApi {
|
||||
private buildHeaders(tenantId: string): HttpHeaders {
|
||||
const headers = new HttpHeaders()
|
||||
.set('Content-Type', 'application/json')
|
||||
.set('X-Tenant-Id', tenantId);
|
||||
.set(StellaOpsHeaders.Tenant, tenantId);
|
||||
|
||||
return headers;
|
||||
}
|
||||
@@ -427,3 +428,5 @@ export class MockAbacOverlayClient implements AbacOverlayApi {
|
||||
return of({ revoked: true }).pipe(delay(50));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import { TenantActivationService } from '../auth/tenant-activation.service';
|
||||
import { AdvisoryDetail, AdvisoryListResponse, AdvisoryQueryOptions, AdvisorySummary } from './advisories.models';
|
||||
import { generateTraceId } from './trace.util';
|
||||
import { StellaOpsHeaders } from '../http/stella-ops-headers';
|
||||
|
||||
export interface AdvisoryApi {
|
||||
listAdvisories(options?: AdvisoryQueryOptions): Observable<AdvisoryListResponse>;
|
||||
@@ -76,13 +77,13 @@ export class AdvisoryApiHttpClient implements AdvisoryApi {
|
||||
private buildHeaders(tenantId: string, traceId: string, projectId?: string, ifNoneMatch?: string): HttpHeaders {
|
||||
let headers = new HttpHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
'X-StellaOps-Tenant': tenantId,
|
||||
'X-Stella-Trace-Id': traceId,
|
||||
'X-Stella-Request-Id': traceId,
|
||||
[StellaOpsHeaders.Tenant]: tenantId,
|
||||
[StellaOpsHeaders.TraceId]: traceId,
|
||||
[StellaOpsHeaders.RequestId]: traceId,
|
||||
});
|
||||
|
||||
if (projectId) {
|
||||
headers = headers.set('X-Stella-Project', projectId);
|
||||
headers = headers.set(StellaOpsHeaders.Project, projectId);
|
||||
}
|
||||
|
||||
if (ifNoneMatch) {
|
||||
@@ -191,3 +192,5 @@ export class MockAdvisoryApiService implements AdvisoryApi {
|
||||
return of({ ...found });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { catchError } from 'rxjs/operators';
|
||||
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import { generateTraceId } from './trace.util';
|
||||
import { StellaOpsHeaders } from '../http/stella-ops-headers';
|
||||
import {
|
||||
AiConsentStatus,
|
||||
AiConsentRequest,
|
||||
@@ -131,9 +132,9 @@ export class AdvisoryAiApiHttpClient implements AdvisoryAiApi {
|
||||
private buildHeaders(traceId: string): HttpHeaders {
|
||||
const tenant = this.authSession.getActiveTenantId() || '';
|
||||
return new HttpHeaders({
|
||||
'X-StellaOps-Tenant': tenant,
|
||||
'X-Stella-Trace-Id': traceId,
|
||||
'X-Stella-Request-Id': traceId,
|
||||
[StellaOpsHeaders.Tenant]: tenant,
|
||||
[StellaOpsHeaders.TraceId]: traceId,
|
||||
[StellaOpsHeaders.RequestId]: traceId,
|
||||
Accept: 'application/json',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { catchError } from 'rxjs/operators';
|
||||
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import { generateTraceId } from './trace.util';
|
||||
import { StellaOpsHeaders } from '../http/stella-ops-headers';
|
||||
import {
|
||||
AiRun,
|
||||
AiRunSummary,
|
||||
@@ -160,9 +161,9 @@ export class AiRunsHttpClient implements AiRunsApi {
|
||||
private buildHeaders(traceId: string): HttpHeaders {
|
||||
const tenant = this.authSession.getActiveTenantId() || '';
|
||||
return new HttpHeaders({
|
||||
'X-StellaOps-Tenant': tenant,
|
||||
'X-Stella-Trace-Id': traceId,
|
||||
'X-Stella-Request-Id': traceId,
|
||||
[StellaOpsHeaders.Tenant]: tenant,
|
||||
[StellaOpsHeaders.TraceId]: traceId,
|
||||
[StellaOpsHeaders.RequestId]: traceId,
|
||||
Accept: 'application/json',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Observable, of, throwError } from 'rxjs';
|
||||
import { catchError, delay } from 'rxjs/operators';
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import { generateTraceId } from './trace.util';
|
||||
import { StellaOpsHeaders } from '../http/stella-ops-headers';
|
||||
import {
|
||||
AnalyticsAttestationCoverage,
|
||||
AnalyticsComponentTrendPoint,
|
||||
@@ -172,9 +173,9 @@ export class AnalyticsHttpClient {
|
||||
private buildHeaders(traceId: string, tenantId?: string): HttpHeaders {
|
||||
const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId() || '';
|
||||
return new HttpHeaders({
|
||||
'X-StellaOps-Tenant': tenant,
|
||||
'X-Stella-Trace-Id': traceId,
|
||||
'X-Stella-Request-Id': traceId,
|
||||
[StellaOpsHeaders.Tenant]: tenant,
|
||||
[StellaOpsHeaders.TraceId]: traceId,
|
||||
[StellaOpsHeaders.RequestId]: traceId,
|
||||
Accept: 'application/json',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { catchError, map } from 'rxjs/operators';
|
||||
import { AppConfigService } from '../config/app-config.service';
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import { generateTraceId } from './trace.util';
|
||||
import { StellaOpsHeaders } from '../http/stella-ops-headers';
|
||||
import {
|
||||
AocMetrics,
|
||||
AocVerificationRequest,
|
||||
@@ -140,9 +141,9 @@ export class AocHttpClient implements AocApi {
|
||||
private buildHeaders(traceId: string): HttpHeaders {
|
||||
const tenantId = this.authSession.getActiveTenantId() || '';
|
||||
return new HttpHeaders({
|
||||
'X-StellaOps-Tenant': tenantId,
|
||||
'X-Stella-Trace-Id': traceId,
|
||||
'X-Stella-Request-Id': traceId,
|
||||
[StellaOpsHeaders.Tenant]: tenantId,
|
||||
[StellaOpsHeaders.TraceId]: traceId,
|
||||
[StellaOpsHeaders.RequestId]: traceId,
|
||||
Accept: 'application/json',
|
||||
});
|
||||
}
|
||||
@@ -180,8 +181,8 @@ export class AocClient {
|
||||
const tenantId = this.authSession.getActiveTenantId() || '';
|
||||
const traceId = generateTraceId();
|
||||
return new HttpHeaders({
|
||||
'X-StellaOps-Tenant': tenantId,
|
||||
'X-Stella-Trace-Id': traceId,
|
||||
[StellaOpsHeaders.Tenant]: tenantId,
|
||||
[StellaOpsHeaders.TraceId]: traceId,
|
||||
Accept: 'application/json',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from './attestation-chain.models';
|
||||
import { TenantActivationService } from '../auth/tenant-activation.service';
|
||||
import { generateTraceId } from './trace.util';
|
||||
import { StellaOpsHeaders } from '../http/stella-ops-headers';
|
||||
|
||||
/**
|
||||
* Attestation Chain API interface.
|
||||
@@ -194,11 +195,11 @@ export class AttestationChainHttpClient implements AttestationChainApi {
|
||||
|
||||
const tenantId = options?.tenantId ?? this.tenantService?.activeTenantId();
|
||||
if (tenantId) {
|
||||
headers['X-Tenant-Id'] = tenantId;
|
||||
headers[StellaOpsHeaders.Tenant] = tenantId;
|
||||
}
|
||||
|
||||
const traceId = options?.traceId ?? generateTraceId();
|
||||
headers['X-Trace-Id'] = traceId;
|
||||
headers[StellaOpsHeaders.TraceId] = traceId;
|
||||
|
||||
return headers;
|
||||
}
|
||||
@@ -310,3 +311,4 @@ export class AttestationChainMockClient implements AttestationChainApi {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Observable, of, delay, map, catchError, throwError } from 'rxjs';
|
||||
|
||||
import { TenantActivationService } from '../auth/tenant-activation.service';
|
||||
import { generateTraceId } from './trace.util';
|
||||
import { StellaOpsHeaders } from '../http/stella-ops-headers';
|
||||
import type {
|
||||
AuditBundleCreateRequest,
|
||||
AuditBundleJobResponse,
|
||||
@@ -203,12 +204,12 @@ export class AuditBundlesHttpClient implements AuditBundlesApi {
|
||||
|
||||
private buildHeaders(tenantId: string, traceId: string, projectId?: string): HttpHeaders {
|
||||
let headers = new HttpHeaders({
|
||||
'X-Stella-Tenant': tenantId,
|
||||
'X-Stella-Trace-Id': traceId,
|
||||
[StellaOpsHeaders.Tenant]: tenantId,
|
||||
[StellaOpsHeaders.TraceId]: traceId,
|
||||
Accept: 'application/json',
|
||||
});
|
||||
|
||||
if (projectId) headers = headers.set('X-Stella-Project', projectId);
|
||||
if (projectId) headers = headers.set(StellaOpsHeaders.Project, projectId);
|
||||
|
||||
return headers;
|
||||
}
|
||||
@@ -347,3 +348,5 @@ function createUnknownSubject(bundleId: string): BundleSubjectRef {
|
||||
digest: {},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Injectable, InjectionToken, Inject } from '@angular/core';
|
||||
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
||||
import { Observable, of, delay, map } from 'rxjs';
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import { StellaOpsHeaders } from '../http/stella-ops-headers';
|
||||
|
||||
// ============================================================================
|
||||
// Models
|
||||
@@ -138,7 +139,7 @@ export class AuthorityAdminHttpClient implements AuthorityAdminApi {
|
||||
this.authSession.getActiveTenantId() ||
|
||||
'default';
|
||||
return new HttpHeaders({
|
||||
'X-StellaOps-Tenant': tenantId,
|
||||
[StellaOpsHeaders.Tenant]: tenantId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Inject, Injectable, InjectionToken } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import { StellaOpsHeaders } from '../http/stella-ops-headers';
|
||||
|
||||
export interface AuthorityTenantViewDto {
|
||||
readonly id: string;
|
||||
@@ -107,9 +108,7 @@ export class AuthorityConsoleApiHttpClient implements AuthorityConsoleApi {
|
||||
}
|
||||
|
||||
return new HttpHeaders({
|
||||
'X-StellaOps-Tenant': tenantId,
|
||||
'X-Stella-Tenant': tenantId,
|
||||
'X-Tenant-Id': tenantId,
|
||||
[StellaOpsHeaders.Tenant]: tenantId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
ConsoleExportStatusDto,
|
||||
} from './console-export.models';
|
||||
import { generateTraceId } from './trace.util';
|
||||
import { StellaOpsHeaders } from '../http/stella-ops-headers';
|
||||
|
||||
interface ExportRequestOptions {
|
||||
tenantId?: string;
|
||||
@@ -94,9 +95,9 @@ export class ConsoleExportClient {
|
||||
const trace = opts.traceId ?? generateTraceId();
|
||||
|
||||
return new HttpHeaders({
|
||||
'X-StellaOps-Tenant': tenant,
|
||||
'X-Stella-Trace-Id': trace,
|
||||
'X-Stella-Request-Id': trace,
|
||||
[StellaOpsHeaders.Tenant]: tenant,
|
||||
[StellaOpsHeaders.TraceId]: trace,
|
||||
[StellaOpsHeaders.RequestId]: trace,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
DownloadManifestItem,
|
||||
} from './console-search.models';
|
||||
import { generateTraceId } from './trace.util';
|
||||
import { StellaOpsHeaders } from '../http/stella-ops-headers';
|
||||
|
||||
/**
|
||||
* Console Search & Downloads API interface.
|
||||
@@ -200,13 +201,13 @@ export class ConsoleSearchHttpClient implements ConsoleSearchApi {
|
||||
const trace = opts.traceId ?? generateTraceId();
|
||||
|
||||
let headers = new HttpHeaders({
|
||||
'X-Stella-Trace-Id': trace,
|
||||
'X-Stella-Request-Id': trace,
|
||||
[StellaOpsHeaders.TraceId]: trace,
|
||||
[StellaOpsHeaders.RequestId]: trace,
|
||||
Accept: 'application/json',
|
||||
});
|
||||
|
||||
if (tenant) {
|
||||
headers = headers.set('X-StellaOps-Tenant', tenant);
|
||||
headers = headers.set(StellaOpsHeaders.Tenant, tenant);
|
||||
}
|
||||
|
||||
if (opts.ifNoneMatch) {
|
||||
@@ -483,3 +484,5 @@ export class MockConsoleSearchClient implements ConsoleSearchApi {
|
||||
return btoa(JSON.stringify(cursorData));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { map } from 'rxjs/operators';
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import { ConsoleRunEventDto, ConsoleStatusDto } from './console-status.models';
|
||||
import { generateTraceId } from './trace.util';
|
||||
import { StellaOpsHeaders } from '../http/stella-ops-headers';
|
||||
|
||||
export const CONSOLE_API_BASE_URL = new InjectionToken<string>('CONSOLE_API_BASE_URL');
|
||||
|
||||
@@ -34,9 +35,9 @@ export class ConsoleStatusClient {
|
||||
const tenant = this.resolveTenant(tenantId);
|
||||
const trace = traceId ?? generateTraceId();
|
||||
const headers = new HttpHeaders({
|
||||
'X-StellaOps-Tenant': tenant,
|
||||
'X-Stella-Trace-Id': trace,
|
||||
'X-Stella-Request-Id': trace,
|
||||
[StellaOpsHeaders.Tenant]: tenant,
|
||||
[StellaOpsHeaders.TraceId]: trace,
|
||||
[StellaOpsHeaders.RequestId]: trace,
|
||||
});
|
||||
|
||||
return this.http.get<ConsoleStatusDto>(`${this.baseUrl}/status`, { headers }).pipe(
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
VexSourceType,
|
||||
} from './console-vex.models';
|
||||
import { generateTraceId } from './trace.util';
|
||||
import { StellaOpsHeaders } from '../http/stella-ops-headers';
|
||||
|
||||
/**
|
||||
* Console VEX API interface.
|
||||
@@ -165,9 +166,9 @@ export class ConsoleVexHttpClient implements ConsoleVexApi {
|
||||
const trace = opts.traceId ?? generateTraceId();
|
||||
|
||||
let headers = new HttpHeaders({
|
||||
'X-StellaOps-Tenant': tenant,
|
||||
'X-Stella-Trace-Id': trace,
|
||||
'X-Stella-Request-Id': trace,
|
||||
[StellaOpsHeaders.Tenant]: tenant,
|
||||
[StellaOpsHeaders.TraceId]: trace,
|
||||
[StellaOpsHeaders.RequestId]: trace,
|
||||
Accept: 'application/json',
|
||||
});
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
ReachabilityStatus,
|
||||
} from './console-vuln.models';
|
||||
import { generateTraceId } from './trace.util';
|
||||
import { StellaOpsHeaders } from '../http/stella-ops-headers';
|
||||
|
||||
/**
|
||||
* Console Vuln API interface.
|
||||
@@ -133,9 +134,9 @@ export class ConsoleVulnHttpClient implements ConsoleVulnApi {
|
||||
const trace = opts.traceId ?? generateTraceId();
|
||||
|
||||
let headers = new HttpHeaders({
|
||||
'X-StellaOps-Tenant': tenant,
|
||||
'X-Stella-Trace-Id': trace,
|
||||
'X-Stella-Request-Id': trace,
|
||||
[StellaOpsHeaders.Tenant]: tenant,
|
||||
[StellaOpsHeaders.TraceId]: trace,
|
||||
[StellaOpsHeaders.RequestId]: trace,
|
||||
Accept: 'application/json',
|
||||
});
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
CvssEvidenceItem,
|
||||
} from './cvss.models';
|
||||
import { generateTraceId } from './trace.util';
|
||||
import { StellaOpsHeaders } from '../http/stella-ops-headers';
|
||||
|
||||
export const CVSS_API_BASE_URL = new InjectionToken<string>('CVSS_API_BASE_URL');
|
||||
|
||||
@@ -103,7 +104,7 @@ export class CvssClient {
|
||||
}
|
||||
|
||||
private buildHeaders(tenantId: string): HttpHeaders {
|
||||
let headers = new HttpHeaders({ 'X-Stella-Tenant': tenantId, 'X-Stella-Trace-Id': generateTraceId() });
|
||||
let headers = new HttpHeaders({ [StellaOpsHeaders.Tenant]: tenantId, [StellaOpsHeaders.TraceId]: generateTraceId() });
|
||||
return headers;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { catchError, map } from 'rxjs/operators';
|
||||
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import { generateTraceId } from './trace.util';
|
||||
import { StellaOpsHeaders } from '../http/stella-ops-headers';
|
||||
import {
|
||||
EvidencePack,
|
||||
SignedEvidencePack,
|
||||
@@ -126,9 +127,9 @@ export class EvidencePackHttpClient implements EvidencePackApi {
|
||||
private buildHeaders(traceId: string): HttpHeaders {
|
||||
const tenant = this.authSession.getActiveTenantId() || '';
|
||||
return new HttpHeaders({
|
||||
'X-StellaOps-Tenant': tenant,
|
||||
'X-Stella-Trace-Id': traceId,
|
||||
'X-Stella-Request-Id': traceId,
|
||||
[StellaOpsHeaders.Tenant]: tenant,
|
||||
[StellaOpsHeaders.TraceId]: traceId,
|
||||
[StellaOpsHeaders.RequestId]: traceId,
|
||||
Accept: 'application/json',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { HttpClient, HttpErrorResponse, HttpHeaders, HttpParams } from '@angular
|
||||
import { Observable, of, delay, firstValueFrom, catchError, throwError } from 'rxjs';
|
||||
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import { StellaOpsHeaders } from '../http/stella-ops-headers';
|
||||
|
||||
import {
|
||||
EvidenceData,
|
||||
@@ -110,7 +111,7 @@ export class EvidenceHttpClient implements EvidenceApi {
|
||||
const tenantId = this.authSession.getActiveTenantId();
|
||||
const headers: Record<string, string> = {};
|
||||
if (tenantId) {
|
||||
headers['X-StellaOps-Tenant'] = tenantId;
|
||||
headers[StellaOpsHeaders.Tenant] = tenantId;
|
||||
}
|
||||
return new HttpHeaders(headers);
|
||||
}
|
||||
@@ -457,3 +458,4 @@ export class MockEvidenceApiService implements EvidenceApi {
|
||||
return new Blob([json], { type: mimeType });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
ExceptionStatusTransition,
|
||||
} from './exception.contract.models';
|
||||
import { generateTraceId } from './trace.util';
|
||||
import { StellaOpsHeaders } from '../http/stella-ops-headers';
|
||||
|
||||
export interface ExceptionRequestOptions {
|
||||
readonly tenantId?: string;
|
||||
@@ -174,13 +175,13 @@ export class ExceptionApiHttpClient implements ExceptionApi {
|
||||
private buildHeaders(tenantId: string, traceId: string, projectId?: string): HttpHeaders {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-StellaOps-Tenant': tenantId,
|
||||
'X-Stella-Trace-Id': traceId,
|
||||
'X-Stella-Request-Id': traceId,
|
||||
[StellaOpsHeaders.Tenant]: tenantId,
|
||||
[StellaOpsHeaders.TraceId]: traceId,
|
||||
[StellaOpsHeaders.RequestId]: traceId,
|
||||
};
|
||||
|
||||
if (projectId) {
|
||||
headers['X-Stella-Project'] = projectId;
|
||||
headers[StellaOpsHeaders.Project] = projectId;
|
||||
}
|
||||
|
||||
return new HttpHeaders(headers);
|
||||
@@ -500,3 +501,4 @@ export class MockExceptionApiService implements ExceptionApi {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
ExportFormat,
|
||||
} from './export-center.models';
|
||||
import { generateTraceId } from './trace.util';
|
||||
import { StellaOpsHeaders } from '../http/stella-ops-headers';
|
||||
|
||||
export const EXPORT_CENTER_API_BASE_URL = new InjectionToken<string>('EXPORT_CENTER_API_BASE_URL');
|
||||
|
||||
@@ -196,9 +197,9 @@ export class ExportCenterHttpClient implements ExportCenterApi {
|
||||
const trace = opts.traceId ?? generateTraceId();
|
||||
|
||||
return new HttpHeaders({
|
||||
'X-StellaOps-Tenant': tenant,
|
||||
'X-Stella-Trace-Id': trace,
|
||||
'X-Stella-Request-Id': trace,
|
||||
[StellaOpsHeaders.Tenant]: tenant,
|
||||
[StellaOpsHeaders.TraceId]: trace,
|
||||
[StellaOpsHeaders.RequestId]: trace,
|
||||
Accept: 'application/json',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { AppConfigService } from '../config/app-config.service';
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import { TenantActivationService } from '../auth/tenant-activation.service';
|
||||
import { generateTraceId } from './trace.util';
|
||||
import { StellaOpsHeaders } from '../http/stella-ops-headers';
|
||||
|
||||
/**
|
||||
* Workflow action types for Findings Ledger.
|
||||
@@ -324,10 +325,10 @@ export class FindingsLedgerHttpClient implements FindingsLedgerApi {
|
||||
private buildHeaders(tenantId: string, projectId?: string, traceId?: string): HttpHeaders {
|
||||
let headers = new HttpHeaders()
|
||||
.set('Content-Type', 'application/json')
|
||||
.set('X-Stella-Tenant', tenantId);
|
||||
.set(StellaOpsHeaders.Tenant, tenantId);
|
||||
|
||||
if (projectId) headers = headers.set('X-Stella-Project', projectId);
|
||||
if (traceId) headers = headers.set('X-Stella-Trace-Id', traceId);
|
||||
if (projectId) headers = headers.set(StellaOpsHeaders.Project, projectId);
|
||||
if (traceId) headers = headers.set(StellaOpsHeaders.TraceId, traceId);
|
||||
|
||||
return headers;
|
||||
}
|
||||
@@ -503,3 +504,5 @@ export class MockFindingsLedgerClient implements FindingsLedgerApi {
|
||||
}).pipe(delay(150));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { CONSOLE_API_BASE_URL, EVENT_SOURCE_FACTORY, type EventSourceFactory } f
|
||||
import { JOBENGINE_API_BASE_URL } from './jobengine.client';
|
||||
import { FirstSignalResponse, type FirstSignalRunStreamPayload } from './first-signal.models';
|
||||
import { generateTraceId } from './trace.util';
|
||||
import { StellaOpsHeaders } from '../http/stella-ops-headers';
|
||||
|
||||
export interface FirstSignalApi {
|
||||
getFirstSignal(
|
||||
@@ -125,13 +126,13 @@ export class FirstSignalHttpClient implements FirstSignalApi {
|
||||
private buildHeaders(tenantId: string, traceId: string, projectId?: string, ifNoneMatch?: string): HttpHeaders {
|
||||
let headers = new HttpHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
'X-StellaOps-Tenant': tenantId,
|
||||
'X-Stella-Trace-Id': traceId,
|
||||
'X-Stella-Request-Id': traceId,
|
||||
[StellaOpsHeaders.Tenant]: tenantId,
|
||||
[StellaOpsHeaders.TraceId]: traceId,
|
||||
[StellaOpsHeaders.RequestId]: traceId,
|
||||
});
|
||||
|
||||
if (projectId) {
|
||||
headers = headers.set('X-Stella-Project', projectId);
|
||||
headers = headers.set(StellaOpsHeaders.Project, projectId);
|
||||
}
|
||||
|
||||
if (ifNoneMatch) {
|
||||
@@ -181,3 +182,5 @@ export class MockFirstSignalClient implements FirstSignalApi {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
ObsQueryOptions,
|
||||
} from './gateway-observability.models';
|
||||
import { generateTraceId } from './trace.util';
|
||||
import { StellaOpsHeaders } from '../http/stella-ops-headers';
|
||||
|
||||
export const OBS_API_BASE_URL = new InjectionToken<string>('OBS_API_BASE_URL');
|
||||
|
||||
@@ -196,9 +197,9 @@ export class GatewayObservabilityHttpClient implements GatewayObservabilityApi {
|
||||
private buildHeaders(traceId: string): HttpHeaders {
|
||||
const tenant = this.authSession.getActiveTenantId() || '';
|
||||
return new HttpHeaders({
|
||||
'X-StellaOps-Tenant': tenant,
|
||||
'X-Stella-Trace-Id': traceId,
|
||||
'X-Stella-Request-Id': traceId,
|
||||
[StellaOpsHeaders.Tenant]: tenant,
|
||||
[StellaOpsHeaders.TraceId]: traceId,
|
||||
[StellaOpsHeaders.RequestId]: traceId,
|
||||
Accept: 'application/json',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
OpenApiQueryOptions,
|
||||
} from './gateway-openapi.models';
|
||||
import { generateTraceId } from './trace.util';
|
||||
import { StellaOpsHeaders } from '../http/stella-ops-headers';
|
||||
|
||||
export const GATEWAY_API_BASE_URL = new InjectionToken<string>('GATEWAY_API_BASE_URL');
|
||||
|
||||
@@ -134,9 +135,9 @@ export class GatewayOpenApiHttpClient implements GatewayOpenApiApi {
|
||||
private buildHeaders(traceId: string): HttpHeaders {
|
||||
const tenant = this.authSession.getActiveTenantId() || '';
|
||||
return new HttpHeaders({
|
||||
'X-StellaOps-Tenant': tenant,
|
||||
'X-Stella-Trace-Id': traceId,
|
||||
'X-Stella-Request-Id': traceId,
|
||||
[StellaOpsHeaders.Tenant]: tenant,
|
||||
[StellaOpsHeaders.TraceId]: traceId,
|
||||
[StellaOpsHeaders.RequestId]: traceId,
|
||||
Accept: 'application/json',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
GraphEdge,
|
||||
} from './graph-platform.models';
|
||||
import { generateTraceId } from './trace.util';
|
||||
import { StellaOpsHeaders } from '../http/stella-ops-headers';
|
||||
|
||||
export const GRAPH_API_BASE_URL = new InjectionToken<string>('GRAPH_API_BASE_URL');
|
||||
|
||||
@@ -245,9 +246,9 @@ export class GraphPlatformHttpClient implements GraphPlatformApi {
|
||||
const trace = opts.traceId ?? generateTraceId();
|
||||
|
||||
let headers = new HttpHeaders({
|
||||
'X-StellaOps-Tenant': tenant,
|
||||
'X-Stella-Trace-Id': trace,
|
||||
'X-Stella-Request-Id': trace,
|
||||
[StellaOpsHeaders.Tenant]: tenant,
|
||||
[StellaOpsHeaders.TraceId]: trace,
|
||||
[StellaOpsHeaders.RequestId]: trace,
|
||||
Accept: 'application/json',
|
||||
});
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { catchError } from 'rxjs/operators';
|
||||
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import { generateTraceId } from './trace.util';
|
||||
import { StellaOpsHeaders } from '../http/stella-ops-headers';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -180,9 +181,9 @@ export class IdentityProviderApiHttpClient implements IdentityProviderApi {
|
||||
private buildHeaders(traceId: string): HttpHeaders {
|
||||
const tenant = this.authSession.getActiveTenantId() || '';
|
||||
return new HttpHeaders({
|
||||
'X-StellaOps-Tenant': tenant,
|
||||
'X-Stella-Trace-Id': traceId,
|
||||
'X-Stella-Request-Id': traceId,
|
||||
[StellaOpsHeaders.Tenant]: tenant,
|
||||
[StellaOpsHeaders.TraceId]: traceId,
|
||||
[StellaOpsHeaders.RequestId]: traceId,
|
||||
Accept: 'application/json',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
UpdateJobEngineQuotaRequest,
|
||||
} from './jobengine-control.models';
|
||||
import { generateTraceId } from './trace.util';
|
||||
import { StellaOpsHeaders } from '../http/stella-ops-headers';
|
||||
|
||||
export interface OrchestratorControlApi {
|
||||
listQuotas(options?: JobEngineQuotaQueryOptions): Observable<JobEngineQuotaListResponse>;
|
||||
@@ -74,7 +75,7 @@ export interface OrchestratorControlApi {
|
||||
|
||||
export const ORCHESTRATOR_CONTROL_API = new InjectionToken<OrchestratorControlApi>('ORCHESTRATOR_CONTROL_API');
|
||||
|
||||
const OPERATOR_METADATA_SENTINEL_HEADER = 'X-Stella-Require-Operator';
|
||||
const OPERATOR_METADATA_SENTINEL_HEADER = StellaOpsHeaders.RequireOperator;
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class JobEngineControlHttpClient implements OrchestratorControlApi {
|
||||
@@ -364,16 +365,16 @@ export class JobEngineControlHttpClient implements OrchestratorControlApi {
|
||||
): HttpHeaders {
|
||||
let headers = new HttpHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
'X-Stella-Trace-Id': traceId,
|
||||
'X-Stella-Request-Id': traceId,
|
||||
[StellaOpsHeaders.TraceId]: traceId,
|
||||
[StellaOpsHeaders.RequestId]: traceId,
|
||||
});
|
||||
|
||||
if (tenantId) {
|
||||
headers = headers.set('X-StellaOps-Tenant', tenantId);
|
||||
headers = headers.set(StellaOpsHeaders.Tenant, tenantId);
|
||||
}
|
||||
|
||||
if (projectId) {
|
||||
headers = headers.set('X-Stella-Project', projectId);
|
||||
headers = headers.set(StellaOpsHeaders.Project, projectId);
|
||||
}
|
||||
|
||||
if (ifNoneMatch) {
|
||||
@@ -738,3 +739,5 @@ export class MockJobEngineControlClient implements OrchestratorControlApi {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import { TenantActivationService } from '../auth/tenant-activation.service';
|
||||
import { JOBENGINE_API_BASE_URL } from './jobengine.client';
|
||||
import { generateTraceId } from './trace.util';
|
||||
import { StellaOpsHeaders } from '../http/stella-ops-headers';
|
||||
|
||||
export interface JobEngineJobsQuery {
|
||||
readonly tenantId?: string;
|
||||
@@ -258,15 +259,17 @@ export class JobEngineJobsClient {
|
||||
private buildHeaders(tenantId: string, traceId: string, projectId?: string): HttpHeaders {
|
||||
let headers = new HttpHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
'X-StellaOps-Tenant': tenantId,
|
||||
'X-Stella-Trace-Id': traceId,
|
||||
'X-Stella-Request-Id': traceId,
|
||||
[StellaOpsHeaders.Tenant]: tenantId,
|
||||
[StellaOpsHeaders.TraceId]: traceId,
|
||||
[StellaOpsHeaders.RequestId]: traceId,
|
||||
});
|
||||
|
||||
if (projectId) {
|
||||
headers = headers.set('X-Stella-Project', projectId);
|
||||
headers = headers.set(StellaOpsHeaders.Project, projectId);
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import { TenantActivationService } from '../auth/tenant-activation.service';
|
||||
import { OrchestratorQueryOptions, OrchestratorSource, OrchestratorSourcesResponse } from './jobengine.models';
|
||||
import { generateTraceId } from './trace.util';
|
||||
import { StellaOpsHeaders } from '../http/stella-ops-headers';
|
||||
|
||||
export interface OrchestratorApi {
|
||||
listSources(options?: OrchestratorQueryOptions): Observable<OrchestratorSourcesResponse>;
|
||||
@@ -74,13 +75,13 @@ export class OrchestratorHttpClient implements OrchestratorApi {
|
||||
private buildHeaders(tenantId: string, traceId: string, projectId?: string, ifNoneMatch?: string): HttpHeaders {
|
||||
let headers = new HttpHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
'X-StellaOps-Tenant': tenantId,
|
||||
'X-Stella-Trace-Id': traceId,
|
||||
'X-Stella-Request-Id': traceId,
|
||||
[StellaOpsHeaders.Tenant]: tenantId,
|
||||
[StellaOpsHeaders.TraceId]: traceId,
|
||||
[StellaOpsHeaders.RequestId]: traceId,
|
||||
});
|
||||
|
||||
if (projectId) {
|
||||
headers = headers.set('X-Stella-Project', projectId);
|
||||
headers = headers.set(StellaOpsHeaders.Project, projectId);
|
||||
}
|
||||
|
||||
if (ifNoneMatch) {
|
||||
@@ -160,3 +161,5 @@ export class MockJobEngineClient implements OrchestratorApi {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { map, catchError, tap } from 'rxjs/operators';
|
||||
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import { generateTraceId } from './trace.util';
|
||||
import { StellaOpsHeaders } from '../http/stella-ops-headers';
|
||||
import {
|
||||
NoiseGatingDeltaReport,
|
||||
ComputeDeltaRequest,
|
||||
@@ -171,9 +172,9 @@ export class NoiseGatingApiHttpClient implements NoiseGatingApi {
|
||||
private buildHeaders(traceId: string): HttpHeaders {
|
||||
const tenant = this.authSession.getActiveTenantId() || '';
|
||||
return new HttpHeaders({
|
||||
'X-StellaOps-Tenant': tenant,
|
||||
'X-Stella-Trace-Id': traceId,
|
||||
'X-Stella-Request-Id': traceId,
|
||||
[StellaOpsHeaders.Tenant]: tenant,
|
||||
[StellaOpsHeaders.TraceId]: traceId,
|
||||
[StellaOpsHeaders.RequestId]: traceId,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
});
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
NotifyQueryOptions,
|
||||
} from './notify.models';
|
||||
import { generateTraceId } from './trace.util';
|
||||
import { StellaOpsHeaders } from '../http/stella-ops-headers';
|
||||
|
||||
export interface NotifyApi {
|
||||
// WEB-NOTIFY-38-001: Base notification APIs
|
||||
@@ -367,15 +368,15 @@ export class NotifyApiHttpClient implements NotifyApi {
|
||||
return new HttpHeaders();
|
||||
}
|
||||
|
||||
return new HttpHeaders({ 'X-StellaOps-Tenant': tenant });
|
||||
return new HttpHeaders({ [StellaOpsHeaders.Tenant]: tenant });
|
||||
}
|
||||
|
||||
private buildHeadersWithTrace(traceId: string): HttpHeaders {
|
||||
const tenant = this.resolveTenantId();
|
||||
return new HttpHeaders({
|
||||
'X-StellaOps-Tenant': tenant,
|
||||
'X-Stella-Trace-Id': traceId,
|
||||
'X-Stella-Request-Id': traceId,
|
||||
[StellaOpsHeaders.Tenant]: tenant,
|
||||
[StellaOpsHeaders.TraceId]: traceId,
|
||||
[StellaOpsHeaders.RequestId]: traceId,
|
||||
Accept: 'application/json',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Observable, delay, map, of, throwError } from 'rxjs';
|
||||
|
||||
import { AppConfigService } from '../config/app-config.service';
|
||||
import { generateTraceId } from './trace.util';
|
||||
import { StellaOpsHeaders } from '../http/stella-ops-headers';
|
||||
import {
|
||||
RiskProfileListResponse,
|
||||
RiskProfileResponse,
|
||||
@@ -179,11 +180,11 @@ export class PolicyEngineHttpClient implements PolicyEngineApi {
|
||||
.set('Accept', 'application/json');
|
||||
|
||||
if (options.tenantId) {
|
||||
headers = headers.set('X-Tenant-Id', options.tenantId);
|
||||
headers = headers.set(StellaOpsHeaders.Tenant, options.tenantId);
|
||||
}
|
||||
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
headers = headers.set('X-Stella-Trace-Id', traceId);
|
||||
headers = headers.set(StellaOpsHeaders.TraceId, traceId);
|
||||
|
||||
return headers;
|
||||
}
|
||||
@@ -1548,3 +1549,5 @@ export class MockPolicyEngineApi implements PolicyEngineApi {
|
||||
}).pipe(delay(100));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
PolicySimulateResponse,
|
||||
} from './policy-exceptions.models';
|
||||
import { generateTraceId } from './trace.util';
|
||||
import { StellaOpsHeaders } from '../http/stella-ops-headers';
|
||||
|
||||
export interface PolicyExceptionsApi {
|
||||
getEffective(request: PolicyEffectiveRequest, options?: PolicyExceptionsRequestOptions): Observable<PolicyEffectiveResponse>;
|
||||
@@ -80,13 +81,13 @@ export class PolicyExceptionsHttpClient implements PolicyExceptionsApi {
|
||||
private buildHeaders(tenantId: string, traceId: string, projectId?: string): HttpHeaders {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-StellaOps-Tenant': tenantId,
|
||||
'X-Stella-Trace-Id': traceId,
|
||||
'X-Stella-Request-Id': traceId,
|
||||
[StellaOpsHeaders.Tenant]: tenantId,
|
||||
[StellaOpsHeaders.TraceId]: traceId,
|
||||
[StellaOpsHeaders.RequestId]: traceId,
|
||||
};
|
||||
|
||||
if (projectId) {
|
||||
headers['X-Stella-Project'] = projectId;
|
||||
headers[StellaOpsHeaders.Project] = projectId;
|
||||
}
|
||||
|
||||
return new HttpHeaders(headers);
|
||||
@@ -168,3 +169,4 @@ export class MockPolicyExceptionsApiService implements PolicyExceptionsApi {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { catchError, map } from 'rxjs/operators';
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import { TenantActivationService } from '../auth/tenant-activation.service';
|
||||
import { generateTraceId } from './trace.util';
|
||||
import { StellaOpsHeaders } from '../http/stella-ops-headers';
|
||||
import {
|
||||
PolicyProfile,
|
||||
PolicyProfileType,
|
||||
@@ -617,9 +618,9 @@ export class PolicyGatesHttpClient implements PolicyGatesApi {
|
||||
private buildHeaders(tenantId: string, traceId: string): HttpHeaders {
|
||||
return new HttpHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
'X-StellaOps-Tenant': tenantId,
|
||||
'X-Stella-Trace-Id': traceId,
|
||||
'X-Stella-Request-Id': traceId,
|
||||
[StellaOpsHeaders.Tenant]: tenantId,
|
||||
[StellaOpsHeaders.TraceId]: traceId,
|
||||
[StellaOpsHeaders.RequestId]: traceId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
RiskProfileGovernanceStatus,
|
||||
} from './policy-governance.models';
|
||||
import { generateTraceId } from './trace.util';
|
||||
import { StellaOpsHeaders } from '../http/stella-ops-headers';
|
||||
|
||||
/**
|
||||
* Policy Governance API interface.
|
||||
@@ -922,17 +923,14 @@ export class HttpPolicyGovernanceApi implements PolicyGovernanceApi {
|
||||
const traceId = options.traceId?.trim();
|
||||
if (traceId) {
|
||||
headers = headers
|
||||
.set('X-Trace-Id', traceId)
|
||||
.set('X-Stella-Trace-Id', traceId)
|
||||
.set('X-Stella-Request-Id', traceId);
|
||||
.set(StellaOpsHeaders.TraceId, traceId)
|
||||
.set(StellaOpsHeaders.RequestId, 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);
|
||||
.set(StellaOpsHeaders.Tenant, tenantId);
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
@@ -1179,3 +1177,5 @@ export class HttpPolicyGovernanceApi implements PolicyGovernanceApi {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Observable, delay, of, catchError, map } from 'rxjs';
|
||||
|
||||
import { AppConfigService } from '../config/app-config.service';
|
||||
import { generateTraceId } from './trace.util';
|
||||
import { StellaOpsHeaders } from '../http/stella-ops-headers';
|
||||
import { PolicyQueryOptions } from './policy-engine.models';
|
||||
|
||||
// ============================================================================
|
||||
@@ -199,11 +200,11 @@ export class PolicyRegistryHttpClient implements PolicyRegistryApi {
|
||||
.set('Accept', 'application/json');
|
||||
|
||||
if (options.tenantId) {
|
||||
headers = headers.set('X-Tenant-Id', options.tenantId);
|
||||
headers = headers.set(StellaOpsHeaders.Tenant, options.tenantId);
|
||||
}
|
||||
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
headers = headers.set('X-Stella-Trace-Id', traceId);
|
||||
headers = headers.set(StellaOpsHeaders.TraceId, traceId);
|
||||
|
||||
return headers;
|
||||
}
|
||||
@@ -468,3 +469,5 @@ export class MockPolicyRegistryClient implements PolicyRegistryApi {
|
||||
}).pipe(delay(25));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Observable, of, delay, throwError } from 'rxjs';
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import { TenantActivationService } from '../auth/tenant-activation.service';
|
||||
import { generateTraceId } from './trace.util';
|
||||
import { StellaOpsHeaders } from '../http/stella-ops-headers';
|
||||
import {
|
||||
ShadowModeConfig,
|
||||
ShadowModeResults,
|
||||
@@ -446,9 +447,9 @@ export class PolicySimulationHttpClient implements PolicySimulationApi {
|
||||
private buildHeaders(tenantId: string, traceId: string): HttpHeaders {
|
||||
return new HttpHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
'X-StellaOps-Tenant': tenantId,
|
||||
'X-Stella-Trace-Id': traceId,
|
||||
'X-Stella-Request-Id': traceId,
|
||||
[StellaOpsHeaders.Tenant]: tenantId,
|
||||
[StellaOpsHeaders.TraceId]: traceId,
|
||||
[StellaOpsHeaders.RequestId]: traceId,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Observable, Subject, finalize } from 'rxjs';
|
||||
|
||||
import { AppConfigService } from '../config/app-config.service';
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import { StellaOpsHeaders } from '../http/stella-ops-headers';
|
||||
import {
|
||||
RiskSimulationResult,
|
||||
PolicyEvaluationResponse,
|
||||
@@ -153,7 +154,7 @@ export class PolicyStreamingClient {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'text/event-stream',
|
||||
'X-Tenant-Id': tenantId,
|
||||
[StellaOpsHeaders.Tenant]: tenantId,
|
||||
};
|
||||
|
||||
if (session?.accessToken) {
|
||||
@@ -239,7 +240,7 @@ export class PolicyStreamingClient {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'text/event-stream',
|
||||
'X-Tenant-Id': tenantId,
|
||||
[StellaOpsHeaders.Tenant]: tenantId,
|
||||
};
|
||||
|
||||
if (session?.accessToken) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
SeverityTransitionEvent,
|
||||
} from './risk.models';
|
||||
import { generateTraceId } from './trace.util';
|
||||
import { StellaOpsHeaders } from '../http/stella-ops-headers';
|
||||
|
||||
export const RISK_API_BASE_URL = new InjectionToken<string>('RISK_API_BASE_URL');
|
||||
|
||||
@@ -150,9 +151,9 @@ export class RiskHttpClient implements RiskApi {
|
||||
}
|
||||
|
||||
private buildHeaders(tenantId: string, projectId?: string, traceId?: string): HttpHeaders {
|
||||
let headers = new HttpHeaders({ 'X-Stella-Tenant': tenantId });
|
||||
if (projectId) headers = headers.set('X-Stella-Project', projectId);
|
||||
if (traceId) headers = headers.set('X-Stella-Trace-Id', traceId);
|
||||
let headers = new HttpHeaders({ [StellaOpsHeaders.Tenant]: tenantId });
|
||||
if (projectId) headers = headers.set(StellaOpsHeaders.Project, projectId);
|
||||
if (traceId) headers = headers.set(StellaOpsHeaders.TraceId, traceId);
|
||||
return headers;
|
||||
}
|
||||
|
||||
@@ -161,3 +162,5 @@ export class RiskHttpClient implements RiskApi {
|
||||
return tenant ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { HttpClient, HttpHeaders } from '@angular/common/http';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { delay, map, switchMap } from 'rxjs/operators';
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import { StellaOpsHeaders } from '../http/stella-ops-headers';
|
||||
import type {
|
||||
Schedule,
|
||||
ScheduleImpactPreview,
|
||||
@@ -324,7 +325,7 @@ export class SchedulerHttpClient implements SchedulerApi {
|
||||
const tenantId = this.authSession.getActiveTenantId();
|
||||
const headers: Record<string, string> = {};
|
||||
if (tenantId) {
|
||||
headers['X-StellaOps-Tenant'] = tenantId;
|
||||
headers[StellaOpsHeaders.Tenant] = tenantId;
|
||||
}
|
||||
return new HttpHeaders(headers);
|
||||
}
|
||||
@@ -397,3 +398,4 @@ export class MockSchedulerClient implements SchedulerApi {
|
||||
}).pipe(delay(200));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { HttpClient, HttpErrorResponse, HttpHeaders, HttpParams } from '@angular
|
||||
import { Observable, of, throwError } from 'rxjs';
|
||||
import { catchError, delay, map } from 'rxjs/operators';
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import { StellaOpsHeaders } from '../http/stella-ops-headers';
|
||||
import { PlatformContextStore } from '../context/platform-context.store';
|
||||
|
||||
// ============================================================================
|
||||
@@ -237,7 +238,7 @@ export class SecurityFindingsHttpClient implements SecurityFindingsApi {
|
||||
const tenantId = this.authSession.getActiveTenantId();
|
||||
const headers: Record<string, string> = {};
|
||||
if (tenantId) {
|
||||
headers['X-StellaOps-Tenant'] = tenantId;
|
||||
headers[StellaOpsHeaders.Tenant] = tenantId;
|
||||
}
|
||||
return new HttpHeaders(headers);
|
||||
}
|
||||
@@ -740,3 +741,4 @@ export class MockSecurityFindingsClient implements SecurityFindingsApi {
|
||||
return of(detail).pipe(delay(220));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { HttpClient, HttpHeaders } from '@angular/common/http';
|
||||
import { Observable, forkJoin, of } from 'rxjs';
|
||||
import { catchError, delay, map } from 'rxjs/operators';
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import { StellaOpsHeaders } from '../http/stella-ops-headers';
|
||||
import { SECURITY_FINDINGS_API_BASE_URL } from './security-findings.client';
|
||||
import { POLICY_EXCEPTIONS_API_BASE_URL } from './policy-exceptions.client';
|
||||
|
||||
@@ -163,7 +164,7 @@ export class SecurityOverviewHttpClient implements SecurityOverviewApi {
|
||||
const tenantId = this.authSession.getActiveTenantId();
|
||||
const headers: Record<string, string> = {};
|
||||
if (tenantId) {
|
||||
headers['X-StellaOps-Tenant'] = tenantId;
|
||||
headers[StellaOpsHeaders.Tenant] = tenantId;
|
||||
}
|
||||
return new HttpHeaders(headers);
|
||||
}
|
||||
@@ -199,3 +200,4 @@ export class MockSecurityOverviewClient implements SecurityOverviewApi {
|
||||
}).pipe(delay(300));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from './triage-evidence.models';
|
||||
import { TenantActivationService } from '../auth/tenant-activation.service';
|
||||
import { generateTraceId } from './trace.util';
|
||||
import { StellaOpsHeaders } from '../http/stella-ops-headers';
|
||||
|
||||
/**
|
||||
* Triage Evidence API interface.
|
||||
@@ -213,11 +214,11 @@ export class TriageEvidenceHttpClient implements TriageEvidenceApi {
|
||||
|
||||
const tenantId = options?.tenantId ?? this.tenantService?.activeTenantId();
|
||||
if (tenantId) {
|
||||
headers['X-Tenant-Id'] = tenantId;
|
||||
headers[StellaOpsHeaders.Tenant] = tenantId;
|
||||
}
|
||||
|
||||
const traceId = options?.traceId ?? generateTraceId();
|
||||
headers['X-Trace-Id'] = traceId;
|
||||
headers[StellaOpsHeaders.TraceId] = traceId;
|
||||
|
||||
return headers;
|
||||
}
|
||||
@@ -349,3 +350,4 @@ export class TriageEvidenceMockClient implements TriageEvidenceApi {
|
||||
return of(null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { AppConfigService } from '../config/app-config.service';
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import { TenantActivationService } from '../auth/tenant-activation.service';
|
||||
import { generateTraceId } from './trace.util';
|
||||
import { StellaOpsHeaders } from '../http/stella-ops-headers';
|
||||
|
||||
/**
|
||||
* VEX statement state per OpenVEX spec.
|
||||
@@ -371,10 +372,10 @@ export class VexConsensusHttpClient implements VexConsensusApi {
|
||||
private buildHeaders(tenantId: string, projectId?: string, traceId?: string, ifNoneMatch?: string): HttpHeaders {
|
||||
let headers = new HttpHeaders()
|
||||
.set('Content-Type', 'application/json')
|
||||
.set('X-Stella-Tenant', tenantId);
|
||||
.set(StellaOpsHeaders.Tenant, tenantId);
|
||||
|
||||
if (projectId) headers = headers.set('X-Stella-Project', projectId);
|
||||
if (traceId) headers = headers.set('X-Stella-Trace-Id', traceId);
|
||||
if (projectId) headers = headers.set(StellaOpsHeaders.Project, projectId);
|
||||
if (traceId) headers = headers.set(StellaOpsHeaders.TraceId, traceId);
|
||||
if (ifNoneMatch) headers = headers.set('If-None-Match', ifNoneMatch);
|
||||
|
||||
return headers;
|
||||
@@ -604,3 +605,5 @@ export class MockVexConsensusClient implements VexConsensusApi {
|
||||
// No-op
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Observable, of, delay, map, catchError, throwError } from 'rxjs';
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import { TenantActivationService } from '../auth/tenant-activation.service';
|
||||
import { generateTraceId } from './trace.util';
|
||||
import { StellaOpsHeaders } from '../http/stella-ops-headers';
|
||||
import type { VexDecision } from './evidence.models';
|
||||
import type {
|
||||
VexDecisionCreateRequest,
|
||||
@@ -98,12 +99,12 @@ export class VexDecisionsHttpClient implements VexDecisionsApi {
|
||||
|
||||
private buildHeaders(tenantId: string, projectId?: string, traceId?: string, ifNoneMatch?: string): HttpHeaders {
|
||||
let headers = new HttpHeaders({
|
||||
'X-Stella-Tenant': tenantId,
|
||||
'X-Stella-Trace-Id': traceId ?? generateTraceId(),
|
||||
[StellaOpsHeaders.Tenant]: tenantId,
|
||||
[StellaOpsHeaders.TraceId]: traceId ?? generateTraceId(),
|
||||
Accept: 'application/json',
|
||||
});
|
||||
|
||||
if (projectId) headers = headers.set('X-Stella-Project', projectId);
|
||||
if (projectId) headers = headers.set(StellaOpsHeaders.Project, projectId);
|
||||
if (ifNoneMatch) headers = headers.set('If-None-Match', ifNoneMatch);
|
||||
|
||||
return headers;
|
||||
@@ -227,3 +228,5 @@ export class MockVexDecisionsClient implements VexDecisionsApi {
|
||||
return next;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
VexStatementSummary,
|
||||
} from './vex-evidence.models';
|
||||
import { generateTraceId } from './trace.util';
|
||||
import { StellaOpsHeaders } from '../http/stella-ops-headers';
|
||||
|
||||
export interface VexEvidenceApi {
|
||||
listStatements(options?: VexQueryOptions): Observable<VexStatementsResponse>;
|
||||
@@ -134,13 +135,13 @@ export class VexEvidenceHttpClient implements VexEvidenceApi {
|
||||
private buildHeaders(tenantId: string, traceId: string, projectId?: string, ifNoneMatch?: string): HttpHeaders {
|
||||
let headers = new HttpHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
'X-StellaOps-Tenant': tenantId,
|
||||
'X-Stella-Trace-Id': traceId,
|
||||
'X-Stella-Request-Id': traceId,
|
||||
[StellaOpsHeaders.Tenant]: tenantId,
|
||||
[StellaOpsHeaders.TraceId]: traceId,
|
||||
[StellaOpsHeaders.RequestId]: traceId,
|
||||
});
|
||||
|
||||
if (projectId) {
|
||||
headers = headers.set('X-Stella-Project', projectId);
|
||||
headers = headers.set(StellaOpsHeaders.Project, projectId);
|
||||
}
|
||||
|
||||
if (ifNoneMatch) {
|
||||
@@ -278,3 +279,5 @@ export class MockVexEvidenceClient implements VexEvidenceApi {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { map, catchError } from 'rxjs/operators';
|
||||
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import { generateTraceId } from './trace.util';
|
||||
import { StellaOpsHeaders } from '../http/stella-ops-headers';
|
||||
import {
|
||||
VexStatement,
|
||||
VexStatementSearchParams,
|
||||
@@ -206,9 +207,9 @@ export class VexHubApiHttpClient implements VexHubApi {
|
||||
private buildHeaders(traceId: string): HttpHeaders {
|
||||
const tenant = this.authSession.getActiveTenantId() || '';
|
||||
return new HttpHeaders({
|
||||
'X-StellaOps-Tenant': tenant,
|
||||
'X-Stella-Trace-Id': traceId,
|
||||
'X-Stella-Request-Id': traceId,
|
||||
[StellaOpsHeaders.Tenant]: tenant,
|
||||
[StellaOpsHeaders.TraceId]: traceId,
|
||||
[StellaOpsHeaders.RequestId]: traceId,
|
||||
Accept: 'application/json',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
VulnRequestLog,
|
||||
} from './vulnerability.models';
|
||||
import { generateTraceId } from './trace.util';
|
||||
import { StellaOpsHeaders } from '../http/stella-ops-headers';
|
||||
import { VulnerabilityApi } from './vulnerability.client';
|
||||
|
||||
export const VULNERABILITY_API_BASE_URL = new InjectionToken<string>('VULNERABILITY_API_BASE_URL');
|
||||
@@ -362,11 +363,11 @@ export class VulnerabilityHttpClient implements VulnerabilityApi {
|
||||
private buildHeaders(tenantId: string, projectId?: string, traceId?: string, requestId?: string): HttpHeaders {
|
||||
let headers = new HttpHeaders()
|
||||
.set('Content-Type', 'application/json')
|
||||
.set('X-Stella-Tenant', tenantId);
|
||||
.set(StellaOpsHeaders.Tenant, tenantId);
|
||||
|
||||
if (projectId) headers = headers.set('X-Stella-Project', projectId);
|
||||
if (traceId) headers = headers.set('X-Stella-Trace-Id', traceId);
|
||||
if (requestId) headers = headers.set('X-Request-Id', requestId);
|
||||
if (projectId) headers = headers.set(StellaOpsHeaders.Project, projectId);
|
||||
if (traceId) headers = headers.set(StellaOpsHeaders.TraceId, traceId);
|
||||
if (requestId) headers = headers.set(StellaOpsHeaders.RequestId, requestId);
|
||||
|
||||
return headers;
|
||||
}
|
||||
@@ -425,3 +426,5 @@ export class VulnerabilityHttpClient implements VulnerabilityApi {
|
||||
console.debug('[VulnHttpClient]', log.method, log.path, log.statusCode, `${log.durationMs}ms`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -6,8 +6,9 @@ import { catchError, switchMap } from 'rxjs/operators';
|
||||
import { AppConfigService } from '../config/app-config.service';
|
||||
import { DpopService } from './dpop/dpop.service';
|
||||
import { AuthorityAuthService } from './authority-auth.service';
|
||||
import { StellaOpsHeaders } from '../http/stella-ops-headers';
|
||||
|
||||
const RETRY_HEADER = 'X-StellaOps-DPoP-Retry';
|
||||
const RETRY_HEADER = StellaOpsHeaders.DpopRetry;
|
||||
|
||||
@Injectable()
|
||||
export class AuthHttpInterceptor implements HttpInterceptor {
|
||||
|
||||
@@ -46,9 +46,12 @@ export {
|
||||
|
||||
export {
|
||||
TenantHttpInterceptor,
|
||||
TENANT_HEADERS,
|
||||
} from './tenant-http.interceptor';
|
||||
|
||||
export {
|
||||
StellaOpsHeaders,
|
||||
} from '../http/stella-ops-headers';
|
||||
|
||||
export {
|
||||
TenantHeaderTelemetryService,
|
||||
} from './tenant-header-telemetry.service';
|
||||
|
||||
@@ -3,10 +3,10 @@ import { TestBed } from '@angular/core/testing';
|
||||
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||
|
||||
import { ConsoleSessionStore } from '../console/console-session.store';
|
||||
import { StellaOpsHeaders } from '../http/stella-ops-headers';
|
||||
import { TenantActivationService } from './tenant-activation.service';
|
||||
import { AuthSessionStore } from './auth-session.store';
|
||||
import { TENANT_HEADERS, TenantHttpInterceptor } from './tenant-http.interceptor';
|
||||
import { TenantHeaderTelemetryService } from './tenant-header-telemetry.service';
|
||||
import { TenantHttpInterceptor } from './tenant-http.interceptor';
|
||||
|
||||
class MockTenantActivationService {
|
||||
activeTenantId = () => null;
|
||||
@@ -39,7 +39,6 @@ describe('TenantHttpInterceptor', () => {
|
||||
let httpMock: HttpTestingController;
|
||||
let consoleStore: MockConsoleSessionStore;
|
||||
let authStore: MockAuthSessionStore;
|
||||
let telemetry: TenantHeaderTelemetryService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
@@ -60,53 +59,41 @@ describe('TenantHttpInterceptor', () => {
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
consoleStore = TestBed.inject(ConsoleSessionStore) as unknown as MockConsoleSessionStore;
|
||||
authStore = TestBed.inject(AuthSessionStore) as unknown as MockAuthSessionStore;
|
||||
telemetry = TestBed.inject(TenantHeaderTelemetryService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
httpMock.verify();
|
||||
});
|
||||
|
||||
it('adds canonical and compatibility tenant headers from selected tenant', () => {
|
||||
it('adds canonical tenant headers from selected tenant', () => {
|
||||
consoleStore.setSelectedTenantId('tenant-bravo');
|
||||
|
||||
http.get('/api/v2/platform/overview').subscribe();
|
||||
|
||||
const request = httpMock.expectOne('/api/v2/platform/overview');
|
||||
expect(request.request.headers.get(TENANT_HEADERS.STELLAOPS_TENANT)).toBe('tenant-bravo');
|
||||
expect(request.request.headers.get(TENANT_HEADERS.STELLA_TENANT)).toBe('tenant-bravo');
|
||||
expect(request.request.headers.get(TENANT_HEADERS.TENANT_ID)).toBe('tenant-bravo');
|
||||
expect(request.request.headers.get(StellaOpsHeaders.Tenant)).toBe('tenant-bravo');
|
||||
expect(request.request.headers.get(StellaOpsHeaders.TraceId)).toBeTruthy();
|
||||
expect(request.request.headers.get(StellaOpsHeaders.RequestId)).toBeTruthy();
|
||||
request.flush({});
|
||||
});
|
||||
|
||||
it('normalizes legacy header input and tracks legacy usage telemetry', () => {
|
||||
it('preserves an explicitly supplied canonical tenant header', () => {
|
||||
http.get('/api/v2/security/findings', {
|
||||
headers: new HttpHeaders({
|
||||
[TENANT_HEADERS.STELLA_TENANT]: 'tenant-legacy',
|
||||
[StellaOpsHeaders.Tenant]: 'tenant-canonical',
|
||||
}),
|
||||
}).subscribe();
|
||||
|
||||
const request = httpMock.expectOne('/api/v2/security/findings');
|
||||
expect(request.request.headers.get(TENANT_HEADERS.STELLAOPS_TENANT)).toBe('tenant-legacy');
|
||||
expect(request.request.headers.get(TENANT_HEADERS.STELLA_TENANT)).toBe('tenant-legacy');
|
||||
expect(request.request.headers.get(TENANT_HEADERS.TENANT_ID)).toBe('tenant-legacy');
|
||||
expect(request.request.headers.get(StellaOpsHeaders.Tenant)).toBe('tenant-canonical');
|
||||
request.flush({});
|
||||
|
||||
expect(telemetry.legacyUsage()).toEqual([
|
||||
{
|
||||
headerName: TENANT_HEADERS.STELLA_TENANT,
|
||||
count: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips tenant headers for public config endpoint requests', () => {
|
||||
http.get('/config.json').subscribe();
|
||||
|
||||
const request = httpMock.expectOne('/config.json');
|
||||
expect(request.request.headers.has(TENANT_HEADERS.STELLAOPS_TENANT)).toBeFalse();
|
||||
expect(request.request.headers.has(TENANT_HEADERS.STELLA_TENANT)).toBeFalse();
|
||||
expect(request.request.headers.has(TENANT_HEADERS.TENANT_ID)).toBeFalse();
|
||||
expect(request.request.headers.has(StellaOpsHeaders.Tenant)).toBeFalse();
|
||||
request.flush({});
|
||||
});
|
||||
|
||||
@@ -117,9 +104,7 @@ describe('TenantHttpInterceptor', () => {
|
||||
http.get('/api/v2/platform/overview').subscribe();
|
||||
|
||||
const request = httpMock.expectOne('/api/v2/platform/overview');
|
||||
expect(request.request.headers.has(TENANT_HEADERS.STELLAOPS_TENANT)).toBeFalse();
|
||||
expect(request.request.headers.has(TENANT_HEADERS.STELLA_TENANT)).toBeFalse();
|
||||
expect(request.request.headers.has(TENANT_HEADERS.TENANT_ID)).toBeFalse();
|
||||
expect(request.request.headers.has(StellaOpsHeaders.Tenant)).toBeFalse();
|
||||
request.flush({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,42 +6,28 @@ import { catchError } from 'rxjs/operators';
|
||||
import { TenantActivationService } from './tenant-activation.service';
|
||||
import { AuthSessionStore } from './auth-session.store';
|
||||
import { ConsoleSessionStore } from '../console/console-session.store';
|
||||
import { TenantHeaderTelemetryService } from './tenant-header-telemetry.service';
|
||||
|
||||
/**
|
||||
* HTTP headers for tenant scoping.
|
||||
*/
|
||||
export const TENANT_HEADERS = {
|
||||
STELLA_TENANT: 'X-Stella-Tenant',
|
||||
TENANT_ID: 'X-Tenant-Id',
|
||||
STELLAOPS_TENANT: 'X-StellaOps-Tenant',
|
||||
PROJECT_ID: 'X-Project-Id',
|
||||
TRACE_ID: 'X-Stella-Trace-Id',
|
||||
REQUEST_ID: 'X-Request-Id',
|
||||
AUDIT_CONTEXT: 'X-Audit-Context',
|
||||
} as const;
|
||||
import { StellaOpsHeaders } from '../http/stella-ops-headers';
|
||||
|
||||
/**
|
||||
* HTTP interceptor that adds tenant headers to all API requests.
|
||||
* Implements WEB-TEN-47-001 tenant header injection.
|
||||
*
|
||||
* Emits only the canonical X-Stella-Ops-* headers.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TenantHttpInterceptor implements HttpInterceptor {
|
||||
private readonly tenantService = inject(TenantActivationService);
|
||||
private readonly authStore = inject(AuthSessionStore);
|
||||
private readonly consoleStore = inject(ConsoleSessionStore);
|
||||
private readonly telemetry = inject(TenantHeaderTelemetryService);
|
||||
|
||||
intercept(
|
||||
request: HttpRequest<unknown>,
|
||||
next: HttpHandler
|
||||
): Observable<HttpEvent<unknown>> {
|
||||
// Skip if already has tenant headers or is a public endpoint
|
||||
if (this.shouldSkip(request)) {
|
||||
return next.handle(request);
|
||||
}
|
||||
|
||||
// Clone request with tenant headers
|
||||
const modifiedRequest = this.addTenantHeaders(request);
|
||||
|
||||
return next.handle(modifiedRequest).pipe(
|
||||
@@ -50,7 +36,6 @@ export class TenantHttpInterceptor implements HttpInterceptor {
|
||||
}
|
||||
|
||||
private shouldSkip(request: HttpRequest<unknown>): boolean {
|
||||
// Skip public endpoints that don't require tenant context
|
||||
const url = request.url.toLowerCase();
|
||||
const publicPaths = [
|
||||
'/api/auth/',
|
||||
@@ -60,6 +45,7 @@ export class TenantHttpInterceptor implements HttpInterceptor {
|
||||
'/metrics',
|
||||
'/config.json',
|
||||
'/.well-known/',
|
||||
'/openapi.json',
|
||||
];
|
||||
|
||||
return publicPaths.some(path => url.includes(path));
|
||||
@@ -67,42 +53,33 @@ export class TenantHttpInterceptor implements HttpInterceptor {
|
||||
|
||||
private addTenantHeaders(request: HttpRequest<unknown>): HttpRequest<unknown> {
|
||||
const headers: Record<string, string> = {};
|
||||
this.recordLegacyHeaderUsage(request);
|
||||
|
||||
// Canonical tenant value can come from explicit request headers or active session state.
|
||||
const tenantId = this.resolveRequestedTenantId(request) ?? this.getTenantId();
|
||||
if (tenantId) {
|
||||
headers[TENANT_HEADERS.STELLAOPS_TENANT] = tenantId;
|
||||
headers[TENANT_HEADERS.STELLA_TENANT] = tenantId;
|
||||
headers[TENANT_HEADERS.TENANT_ID] = tenantId;
|
||||
headers[StellaOpsHeaders.Tenant] = tenantId;
|
||||
}
|
||||
|
||||
// Add project ID if active
|
||||
const projectId = this.tenantService.activeProjectId();
|
||||
if (projectId) {
|
||||
headers[TENANT_HEADERS.PROJECT_ID] = projectId;
|
||||
headers[StellaOpsHeaders.Project] = projectId;
|
||||
}
|
||||
|
||||
// Add trace ID for correlation
|
||||
if (!request.headers.has(TENANT_HEADERS.TRACE_ID)) {
|
||||
headers[TENANT_HEADERS.TRACE_ID] = this.generateTraceId();
|
||||
if (!request.headers.has(StellaOpsHeaders.TraceId)) {
|
||||
headers[StellaOpsHeaders.TraceId] = this.generateTraceId();
|
||||
}
|
||||
|
||||
// Add request ID
|
||||
if (!request.headers.has(TENANT_HEADERS.REQUEST_ID)) {
|
||||
headers[TENANT_HEADERS.REQUEST_ID] = this.generateRequestId();
|
||||
if (!request.headers.has(StellaOpsHeaders.RequestId)) {
|
||||
headers[StellaOpsHeaders.RequestId] = this.generateRequestId();
|
||||
}
|
||||
|
||||
// Add audit context for write operations
|
||||
if (this.isWriteOperation(request.method)) {
|
||||
headers[TENANT_HEADERS.AUDIT_CONTEXT] = this.buildAuditContext();
|
||||
headers[StellaOpsHeaders.AuditContext] = this.buildAuditContext();
|
||||
}
|
||||
|
||||
return request.clone({ setHeaders: headers });
|
||||
}
|
||||
|
||||
private getTenantId(): string | null {
|
||||
// First check active tenant context
|
||||
const activeTenantId = this.tenantService.activeTenantId();
|
||||
if (activeTenantId) {
|
||||
return activeTenantId;
|
||||
@@ -113,42 +90,18 @@ export class TenantHttpInterceptor implements HttpInterceptor {
|
||||
return selectedTenant;
|
||||
}
|
||||
|
||||
// Fall back to session tenant
|
||||
return this.authStore.tenantId();
|
||||
}
|
||||
|
||||
private resolveRequestedTenantId(request: HttpRequest<unknown>): string | null {
|
||||
const candidates = [
|
||||
request.headers.get(TENANT_HEADERS.STELLAOPS_TENANT),
|
||||
request.headers.get(TENANT_HEADERS.STELLA_TENANT),
|
||||
request.headers.get(TENANT_HEADERS.TENANT_ID),
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const normalized = candidate?.trim();
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private recordLegacyHeaderUsage(request: HttpRequest<unknown>): void {
|
||||
if (request.headers.has(TENANT_HEADERS.STELLA_TENANT)) {
|
||||
this.telemetry.recordLegacyUsage(TENANT_HEADERS.STELLA_TENANT);
|
||||
}
|
||||
|
||||
if (request.headers.has(TENANT_HEADERS.TENANT_ID)) {
|
||||
this.telemetry.recordLegacyUsage(TENANT_HEADERS.TENANT_ID);
|
||||
}
|
||||
const candidate = request.headers.get(StellaOpsHeaders.Tenant)?.trim();
|
||||
return candidate || null;
|
||||
}
|
||||
|
||||
private handleTenantError(
|
||||
error: HttpErrorResponse,
|
||||
request: HttpRequest<unknown>
|
||||
): Observable<never> {
|
||||
// Handle tenant-specific errors
|
||||
if (error.status === 403) {
|
||||
const errorCode = error.error?.code || error.error?.error;
|
||||
|
||||
@@ -168,7 +121,6 @@ export class TenantHttpInterceptor implements HttpInterceptor {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle tenant not found
|
||||
if (error.status === 404 && error.error?.code === 'TENANT_NOT_FOUND') {
|
||||
console.error('[TenantInterceptor] Tenant not found:', {
|
||||
tenantId: this.tenantService.activeTenantId(),
|
||||
@@ -192,17 +144,14 @@ export class TenantHttpInterceptor implements HttpInterceptor {
|
||||
ua: typeof navigator !== 'undefined' ? navigator.userAgent : 'unknown',
|
||||
};
|
||||
|
||||
// Base64 encode for header transport
|
||||
return btoa(JSON.stringify(context));
|
||||
}
|
||||
|
||||
private generateTraceId(): string {
|
||||
// Use crypto.randomUUID if available, otherwise fallback
|
||||
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
// Fallback: timestamp + random
|
||||
const timestamp = Date.now().toString(36);
|
||||
const random = Math.random().toString(36).slice(2, 10);
|
||||
return `${timestamp}-${random}`;
|
||||
|
||||
@@ -3,13 +3,23 @@ import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { PlatformContextStore } from './platform-context.store';
|
||||
import { OpenApiContextParamMap } from './openapi-context-param-map.service';
|
||||
|
||||
/**
|
||||
* Injects global context query parameters into outgoing HTTP requests.
|
||||
*
|
||||
* Which parameters are injected is driven by the OpenAPI spec — only params
|
||||
* the endpoint declares are added. The parameter map is pre-loaded at app
|
||||
* startup via OpenApiContextParamMap.initialize().
|
||||
*/
|
||||
@Injectable()
|
||||
export class GlobalContextHttpInterceptor implements HttpInterceptor {
|
||||
private readonly context = inject(PlatformContextStore);
|
||||
private readonly paramMap = inject(OpenApiContextParamMap);
|
||||
|
||||
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
|
||||
if (!this.isPack22ContextAwareRoute(request.url)) {
|
||||
const contextParams = this.paramMap.getContextParams(request.url);
|
||||
if (!contextParams || contextParams.size === 0) {
|
||||
return next.handle(request);
|
||||
}
|
||||
|
||||
@@ -18,39 +28,33 @@ export class GlobalContextHttpInterceptor implements HttpInterceptor {
|
||||
const regions = this.context.selectedRegions();
|
||||
const environments = this.context.selectedEnvironments();
|
||||
const timeWindow = this.context.timeWindow();
|
||||
const stage = this.context.stage();
|
||||
|
||||
if (tenantId && !params.has('tenant') && !params.has('tenantId')) {
|
||||
if (contextParams.has('tenant') && tenantId && !params.has('tenant')) {
|
||||
params = params.set('tenant', tenantId);
|
||||
}
|
||||
if (contextParams.has('tenantId') && tenantId && !params.has('tenantId')) {
|
||||
params = params.set('tenantId', tenantId);
|
||||
}
|
||||
|
||||
if (regions.length > 0 && !params.has('regions') && !params.has('region')) {
|
||||
const regionFilter = regions.join(',');
|
||||
params = params.set('regions', regionFilter);
|
||||
params = params.set('region', regionFilter);
|
||||
if (contextParams.has('regions') && regions.length > 0 && !params.has('regions')) {
|
||||
params = params.set('regions', regions.join(','));
|
||||
}
|
||||
|
||||
if (environments.length > 0 && !params.has('environments') && !params.has('environment')) {
|
||||
const environmentFilter = environments.join(',');
|
||||
params = params.set('environments', environmentFilter);
|
||||
params = params.set('environment', environmentFilter);
|
||||
if (contextParams.has('region') && regions.length > 0 && !params.has('region')) {
|
||||
params = params.set('region', regions.join(','));
|
||||
}
|
||||
|
||||
if (timeWindow && !params.has('timeWindow')) {
|
||||
if (contextParams.has('environments') && environments.length > 0 && !params.has('environments')) {
|
||||
params = params.set('environments', environments.join(','));
|
||||
}
|
||||
if (contextParams.has('environment') && environments.length > 0 && !params.has('environment')) {
|
||||
params = params.set('environment', environments.join(','));
|
||||
}
|
||||
if (contextParams.has('timeWindow') && timeWindow && !params.has('timeWindow')) {
|
||||
params = params.set('timeWindow', timeWindow);
|
||||
}
|
||||
if (contextParams.has('stage') && stage && stage !== 'all' && !params.has('stage')) {
|
||||
params = params.set('stage', stage);
|
||||
}
|
||||
|
||||
return next.handle(request.clone({ params }));
|
||||
}
|
||||
|
||||
private isPack22ContextAwareRoute(url: string): boolean {
|
||||
return (
|
||||
url.includes('/api/v2/releases') ||
|
||||
url.includes('/api/v2/security') ||
|
||||
url.includes('/api/v2/evidence') ||
|
||||
url.includes('/api/v2/topology') ||
|
||||
url.includes('/api/v2/platform') ||
|
||||
url.includes('/api/v2/integrations')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { OpenApiContextParamMap } from './openapi-context-param-map.service';
|
||||
|
||||
describe('OpenApiContextParamMap', () => {
|
||||
let service: OpenApiContextParamMap;
|
||||
let httpMock: HttpTestingController;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
providers: [OpenApiContextParamMap],
|
||||
});
|
||||
|
||||
service = TestBed.inject(OpenApiContextParamMap);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
httpMock.verify();
|
||||
});
|
||||
|
||||
it('builds a route map from OpenAPI query parameter declarations', async () => {
|
||||
const initPromise = service.initialize();
|
||||
|
||||
const request = httpMock.expectOne('/openapi.json');
|
||||
expect(request.request.method).toBe('GET');
|
||||
request.flush({
|
||||
paths: {
|
||||
'/api/v2/releases/activity': {
|
||||
get: {
|
||||
parameters: [
|
||||
{ name: 'tenant', in: 'query' },
|
||||
{ name: 'regions', in: 'query' },
|
||||
{ name: 'timeWindow', in: 'query' },
|
||||
{ name: 'irrelevant', in: 'query' },
|
||||
],
|
||||
},
|
||||
},
|
||||
'/api/v2/topology/environments/{id}': {
|
||||
get: {
|
||||
parameters: [
|
||||
{ name: 'environment', in: 'query' },
|
||||
{ name: 'stage', in: 'query' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await initPromise;
|
||||
|
||||
expect(service.getContextParams('/api/v2/releases/activity') ?? new Set()).toEqual(
|
||||
new Set(['tenant', 'regions', 'timeWindow']),
|
||||
);
|
||||
expect(service.getContextParams('/api/v2/topology/environments/env-01') ?? new Set()).toEqual(
|
||||
new Set(['environment', 'stage']),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns no match for routes without declared context parameters', async () => {
|
||||
const initPromise = service.initialize();
|
||||
|
||||
const request = httpMock.expectOne('/openapi.json');
|
||||
request.flush({
|
||||
paths: {
|
||||
'/api/v2/doctor/summary': {
|
||||
get: {
|
||||
parameters: [{ name: 'verbose', in: 'query' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await initPromise;
|
||||
|
||||
expect(service.getContextParams('/api/v2/doctor/summary')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import { HttpBackend, HttpClient } from '@angular/common/http';
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
/**
|
||||
* Context query parameter names that the interceptor knows how to inject.
|
||||
* Only parameters from this set are extracted from the OpenAPI spec.
|
||||
*/
|
||||
const KNOWN_CONTEXT_PARAMS = new Set([
|
||||
'region',
|
||||
'regions',
|
||||
'environment',
|
||||
'environments',
|
||||
'timeWindow',
|
||||
'stage',
|
||||
'tenant',
|
||||
'tenantId',
|
||||
]);
|
||||
|
||||
interface CompiledRoute {
|
||||
pattern: RegExp;
|
||||
params: Set<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-loads the gateway OpenAPI spec at app startup and builds a fast lookup
|
||||
* map from URL path → set of context query parameters the endpoint declares.
|
||||
*
|
||||
* Uses HttpBackend directly (bypasses interceptors) to avoid circular DI —
|
||||
* the GlobalContextHttpInterceptor depends on this service.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class OpenApiContextParamMap {
|
||||
private readonly http: HttpClient;
|
||||
private routes: CompiledRoute[] = [];
|
||||
private etag: string | null = null;
|
||||
private _initialized = false;
|
||||
|
||||
/** Reactive flag for components that care about load state. */
|
||||
readonly initialized = signal(false);
|
||||
|
||||
constructor(httpBackend: HttpBackend) {
|
||||
// Raw HttpClient bypasses all interceptors — same pattern as AppConfigService.
|
||||
this.http = new HttpClient(httpBackend);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the OpenAPI spec and build the parameter map.
|
||||
* Called from provideAppInitializer — blocks route resolution.
|
||||
* Never throws; falls back to empty map on failure.
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
if (this._initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = { Accept: 'application/json' };
|
||||
if (this.etag) {
|
||||
headers['If-None-Match'] = this.etag;
|
||||
}
|
||||
|
||||
const response = await firstValueFrom(
|
||||
this.http.get<Record<string, unknown>>('/openapi.json', {
|
||||
observe: 'response',
|
||||
headers,
|
||||
}),
|
||||
);
|
||||
|
||||
const newEtag = response.headers.get('ETag');
|
||||
if (newEtag) {
|
||||
this.etag = newEtag;
|
||||
}
|
||||
|
||||
if (response.body) {
|
||||
this.buildRoutes(response.body);
|
||||
}
|
||||
} catch {
|
||||
// Graceful degradation: empty routes = no context injection.
|
||||
// Covers: network error, 304 Not Modified (spec unchanged), invalid JSON.
|
||||
}
|
||||
|
||||
this._initialized = true;
|
||||
this.initialized.set(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous O(n) lookup — returns the set of context query param names
|
||||
* the matched endpoint declares, or undefined if no match.
|
||||
*
|
||||
* Called by GlobalContextHttpInterceptor on every request.
|
||||
* With ~1900 routes the linear scan completes in <1ms.
|
||||
*/
|
||||
getContextParams(url: string): Set<string> | undefined {
|
||||
const path = this.extractPath(url);
|
||||
for (const route of this.routes) {
|
||||
if (route.pattern.test(path)) {
|
||||
return route.params;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internals
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private buildRoutes(spec: Record<string, unknown>): void {
|
||||
const paths = spec['paths'];
|
||||
if (!paths || typeof paths !== 'object') {
|
||||
this.routes = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const compiled: CompiledRoute[] = [];
|
||||
|
||||
for (const [pathTemplate, methods] of Object.entries(paths as Record<string, unknown>)) {
|
||||
if (!methods || typeof methods !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Collect context params across all HTTP methods for this path.
|
||||
const contextParams = new Set<string>();
|
||||
|
||||
for (const details of Object.values(methods as Record<string, unknown>)) {
|
||||
if (!details || typeof details !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const parameters = (details as Record<string, unknown>)['parameters'];
|
||||
if (!Array.isArray(parameters)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const param of parameters) {
|
||||
if (
|
||||
param &&
|
||||
typeof param === 'object' &&
|
||||
(param as Record<string, unknown>)['in'] === 'query'
|
||||
) {
|
||||
const name = (param as Record<string, unknown>)['name'];
|
||||
if (typeof name === 'string' && KNOWN_CONTEXT_PARAMS.has(name)) {
|
||||
contextParams.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (contextParams.size > 0) {
|
||||
compiled.push({
|
||||
pattern: this.pathToRegex(pathTemplate),
|
||||
params: contextParams,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.routes = compiled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an OpenAPI path template to a regex.
|
||||
* `/api/v2/topology/environments/{id}` → /^\/api\/v2\/topology\/environments\/[^/]+$/
|
||||
*/
|
||||
private pathToRegex(template: string): RegExp {
|
||||
const escaped = template
|
||||
.replace(/[.*+?^${}()|[\]\\]/g, (match) => {
|
||||
// Don't escape curly braces used for path params — handle them separately.
|
||||
if (match === '{' || match === '}') {
|
||||
return match;
|
||||
}
|
||||
return `\\${match}`;
|
||||
})
|
||||
.replace(/\{[^}]+\}/g, '[^/]+');
|
||||
|
||||
return new RegExp(`^${escaped}$`);
|
||||
}
|
||||
|
||||
/** Strip origin, query string, and fragment from a URL to get a clean path. */
|
||||
private extractPath(url: string): string {
|
||||
try {
|
||||
// Absolute URL
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
const parsed = new URL(url);
|
||||
return parsed.pathname;
|
||||
}
|
||||
// Relative URL — strip query/fragment
|
||||
const qIndex = url.indexOf('?');
|
||||
const hIndex = url.indexOf('#');
|
||||
let end = url.length;
|
||||
if (qIndex !== -1) end = Math.min(end, qIndex);
|
||||
if (hIndex !== -1) end = Math.min(end, hIndex);
|
||||
return url.slice(0, end);
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Canonical HTTP header names for Stella Ops.
|
||||
*
|
||||
* Single source of truth — all interceptors, API clients, and test helpers
|
||||
* must reference these constants instead of hardcoding header strings.
|
||||
*/
|
||||
export const StellaOpsHeaders = {
|
||||
// Identity
|
||||
Tenant: 'X-Stella-Ops-Tenant',
|
||||
Actor: 'X-Stella-Ops-Actor',
|
||||
Scopes: 'X-Stella-Ops-Scopes',
|
||||
Roles: 'X-Stella-Ops-Roles',
|
||||
Client: 'X-Stella-Ops-Client',
|
||||
Project: 'X-Stella-Ops-Project',
|
||||
Session: 'X-Stella-Ops-Session',
|
||||
|
||||
// Envelope (Hybrid trust)
|
||||
IdentityEnvelope: 'X-Stella-Ops-Identity-Envelope',
|
||||
IdentityEnvelopeSignature: 'X-Stella-Ops-Identity-Envelope-Signature',
|
||||
|
||||
// Observability
|
||||
TraceId: 'X-Stella-Ops-Trace-Id',
|
||||
RequestId: 'X-Stella-Ops-Request-Id',
|
||||
|
||||
// Audit
|
||||
AuditContext: 'X-Stella-Ops-Audit-Context',
|
||||
|
||||
// Auth (DPoP)
|
||||
DpopRetry: 'X-Stella-Ops-DPoP-Retry',
|
||||
|
||||
// Operator metadata
|
||||
RequireOperator: 'X-Stella-Ops-Require-Operator',
|
||||
OperatorReason: 'X-Stella-Ops-Operator-Reason',
|
||||
OperatorTicket: 'X-Stella-Ops-Operator-Ticket',
|
||||
} as const;
|
||||
@@ -3,10 +3,14 @@ import { Injectable } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { OperatorContextService } from './operator-context.service';
|
||||
import { StellaOpsHeaders } from '../http/stella-ops-headers';
|
||||
|
||||
export const OPERATOR_METADATA_SENTINEL_HEADER = 'X-Stella-Require-Operator';
|
||||
export const OPERATOR_REASON_HEADER = 'X-Stella-Operator-Reason';
|
||||
export const OPERATOR_TICKET_HEADER = 'X-Stella-Operator-Ticket';
|
||||
/** @deprecated Use StellaOpsHeaders.RequireOperator instead. */
|
||||
export const OPERATOR_METADATA_SENTINEL_HEADER = StellaOpsHeaders.RequireOperator;
|
||||
/** @deprecated Use StellaOpsHeaders.OperatorReason instead. */
|
||||
export const OPERATOR_REASON_HEADER = StellaOpsHeaders.OperatorReason;
|
||||
/** @deprecated Use StellaOpsHeaders.OperatorTicket instead. */
|
||||
export const OPERATOR_TICKET_HEADER = StellaOpsHeaders.OperatorTicket;
|
||||
|
||||
@Injectable()
|
||||
export class OperatorMetadataInterceptor implements HttpInterceptor {
|
||||
@@ -16,20 +20,20 @@ export class OperatorMetadataInterceptor implements HttpInterceptor {
|
||||
request: HttpRequest<unknown>,
|
||||
next: HttpHandler
|
||||
): Observable<HttpEvent<unknown>> {
|
||||
if (!request.headers.has(OPERATOR_METADATA_SENTINEL_HEADER)) {
|
||||
if (!request.headers.has(StellaOpsHeaders.RequireOperator)) {
|
||||
return next.handle(request);
|
||||
}
|
||||
|
||||
const current = this.context.snapshot();
|
||||
const headers = request.headers.delete(OPERATOR_METADATA_SENTINEL_HEADER);
|
||||
const headers = request.headers.delete(StellaOpsHeaders.RequireOperator);
|
||||
|
||||
if (!current) {
|
||||
return next.handle(request.clone({ headers }));
|
||||
}
|
||||
|
||||
const enriched = headers
|
||||
.set(OPERATOR_REASON_HEADER, current.reason)
|
||||
.set(OPERATOR_TICKET_HEADER, current.ticket);
|
||||
.set(StellaOpsHeaders.OperatorReason, current.reason)
|
||||
.set(StellaOpsHeaders.OperatorTicket, current.ticket);
|
||||
|
||||
return next.handle(request.clone({ headers: enriched }));
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { HTTP_INTERCEPTORS, HttpClient, provideHttpClient, withInterceptorsFromD
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { OpenApiContextParamMap } from '../context/openapi-context-param-map.service';
|
||||
import { GlobalContextHttpInterceptor } from '../context/global-context-http.interceptor';
|
||||
import { PlatformContextStore } from '../context/platform-context.store';
|
||||
|
||||
@@ -26,6 +27,13 @@ describe('GlobalContextHttpInterceptor', () => {
|
||||
selectedRegions: () => ['apac', 'eu-west', 'us-east', 'us-west'],
|
||||
selectedEnvironments: () => ['dev', 'stage'],
|
||||
timeWindow: () => '24h',
|
||||
stage: () => 'prod',
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: OpenApiContextParamMap,
|
||||
useValue: {
|
||||
getContextParams: () => new Set(['tenant', 'regions', 'region', 'environments', 'environment', 'timeWindow', 'stage']),
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -39,10 +47,20 @@ describe('GlobalContextHttpInterceptor', () => {
|
||||
httpMock.verify();
|
||||
});
|
||||
|
||||
it('propagates comma-delimited region and environment scope instead of collapsing to the first selection', () => {
|
||||
it('propagates only the context parameters declared by the OpenAPI route map', () => {
|
||||
http.get('/api/v2/releases/activity').subscribe();
|
||||
|
||||
const request = httpMock.expectOne('/api/v2/releases/activity?tenant=demo-prod&tenantId=demo-prod®ions=apac,eu-west,us-east,us-west®ion=apac,eu-west,us-east,us-west&environments=dev,stage&environment=dev,stage&timeWindow=24h');
|
||||
const request = httpMock.expectOne('/api/v2/releases/activity?tenant=demo-prod®ions=apac,eu-west,us-east,us-west®ion=apac,eu-west,us-east,us-west&environments=dev,stage&environment=dev,stage&timeWindow=24h&stage=prod');
|
||||
request.flush({ items: [] });
|
||||
});
|
||||
|
||||
it('leaves requests untouched when the route map has no declared context parameters', () => {
|
||||
const paramMap = TestBed.inject(OpenApiContextParamMap) as { getContextParams: (url: string) => Set<string> | undefined };
|
||||
paramMap.getContextParams = () => undefined;
|
||||
|
||||
http.get('/api/v2/doctor/summary').subscribe();
|
||||
|
||||
const request = httpMock.expectOne('/api/v2/doctor/summary');
|
||||
request.flush({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from '../models/evidence-ribbon.models';
|
||||
import { TenantActivationService } from '../../../core/auth/tenant-activation.service';
|
||||
import { generateTraceId } from '../../../core/api/trace.util';
|
||||
import { StellaOpsHeaders } from '../../../core/http/stella-ops-headers';
|
||||
|
||||
/**
|
||||
* Evidence Ribbon Service.
|
||||
@@ -305,12 +306,12 @@ export class EvidenceRibbonService {
|
||||
|
||||
private buildHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
'X-Trace-Id': generateTraceId(),
|
||||
[StellaOpsHeaders.TraceId]: generateTraceId(),
|
||||
};
|
||||
|
||||
const tenantId = this.tenantService?.activeTenantId();
|
||||
if (tenantId) {
|
||||
headers['X-Tenant-Id'] = tenantId;
|
||||
headers[StellaOpsHeaders.Tenant] = tenantId;
|
||||
}
|
||||
|
||||
return headers;
|
||||
@@ -369,3 +370,4 @@ interface PolicyApiResponse {
|
||||
version?: string;
|
||||
evaluatedAt?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from '../models/sbom-diff.models';
|
||||
import { TenantActivationService } from '../../../core/auth/tenant-activation.service';
|
||||
import { generateTraceId } from '../../../core/api/trace.util';
|
||||
import { StellaOpsHeaders } from '../../../core/http/stella-ops-headers';
|
||||
|
||||
/**
|
||||
* SBOM Diff Service.
|
||||
@@ -315,12 +316,12 @@ export class SbomDiffService {
|
||||
|
||||
private buildHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
'X-Trace-Id': generateTraceId(),
|
||||
[StellaOpsHeaders.TraceId]: generateTraceId(),
|
||||
};
|
||||
|
||||
const tenantId = this.tenantService?.activeTenantId();
|
||||
if (tenantId) {
|
||||
headers['X-Tenant-Id'] = tenantId;
|
||||
headers[StellaOpsHeaders.Tenant] = tenantId;
|
||||
}
|
||||
|
||||
return headers;
|
||||
@@ -382,3 +383,4 @@ export interface SbomVersionInfo {
|
||||
readonly componentCount?: number;
|
||||
readonly artifactDigest?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { map, Observable } from 'rxjs';
|
||||
import { AuthSessionStore } from '../../core/auth/auth-session.store';
|
||||
import { StellaOpsHeaders } from '../../core/http/stella-ops-headers';
|
||||
|
||||
export interface AdvisorySourceListResponseDto {
|
||||
items: AdvisorySourceListItemDto[];
|
||||
@@ -177,9 +178,7 @@ export class AdvisorySourcesApi {
|
||||
}
|
||||
|
||||
return new HttpHeaders({
|
||||
'X-Stella-Tenant': tenantId,
|
||||
'X-Tenant-Id': tenantId,
|
||||
'X-StellaOps-Tenant': tenantId,
|
||||
[StellaOpsHeaders.Tenant]: tenantId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { map, Observable } from 'rxjs';
|
||||
import { AuthSessionStore } from '../../../core/auth/auth-session.store';
|
||||
import { StellaOpsHeaders } from '../../../core/http/stella-ops-headers';
|
||||
|
||||
export interface SymbolSourceListItem {
|
||||
sourceId: string;
|
||||
@@ -145,9 +146,7 @@ export class SymbolSourcesApiService {
|
||||
return new HttpHeaders();
|
||||
}
|
||||
return new HttpHeaders({
|
||||
'X-Stella-Tenant': tenantId,
|
||||
'X-Tenant-Id': tenantId,
|
||||
'X-StellaOps-Tenant': tenantId,
|
||||
[StellaOpsHeaders.Tenant]: tenantId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from '../models/vex-timeline.models';
|
||||
import { TenantActivationService } from '../../../core/auth/tenant-activation.service';
|
||||
import { generateTraceId } from '../../../core/api/trace.util';
|
||||
import { StellaOpsHeaders } from '../../../core/http/stella-ops-headers';
|
||||
|
||||
/**
|
||||
* VEX Timeline Service.
|
||||
@@ -291,12 +292,12 @@ export class VexTimelineService {
|
||||
|
||||
private buildHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
'X-Trace-Id': generateTraceId(),
|
||||
[StellaOpsHeaders.TraceId]: generateTraceId(),
|
||||
};
|
||||
|
||||
const tenantId = this.tenantService?.activeTenantId();
|
||||
if (tenantId) {
|
||||
headers['X-Tenant-Id'] = tenantId;
|
||||
headers[StellaOpsHeaders.Tenant] = tenantId;
|
||||
}
|
||||
|
||||
return headers;
|
||||
@@ -364,3 +365,4 @@ interface VerifyResult {
|
||||
timestamp?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user