Restructure solution layout by module
This commit is contained in:
16
src/Web/StellaOps.Web/.editorconfig
Normal file
16
src/Web/StellaOps.Web/.editorconfig
Normal file
@@ -0,0 +1,16 @@
|
||||
# Editor configuration, see https://editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.ts]
|
||||
quote_type = single
|
||||
|
||||
[*.md]
|
||||
max_line_length = off
|
||||
trim_trailing_whitespace = false
|
||||
3
src/Web/StellaOps.Web/.gitignore
vendored
Normal file
3
src/Web/StellaOps.Web/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
.cache/
|
||||
coverage/
|
||||
dist/
|
||||
24
src/Web/StellaOps.Web/AGENTS.md
Normal file
24
src/Web/StellaOps.Web/AGENTS.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# StellaOps Web Frontend
|
||||
|
||||
## Mission
|
||||
Design and build the StellaOps web user experience that surfaces backend capabilities (Authority, Concelier, Exporters) through an offline-friendly Angular application.
|
||||
|
||||
## Team Composition
|
||||
- **UX Specialist** – defines user journeys, interaction patterns, accessibility guidelines, and visual design language.
|
||||
- **Angular Engineers** – implement the SPA, integrate with backend APIs, and ensure deterministic builds suitable for air-gapped deployments.
|
||||
|
||||
## Operating Principles
|
||||
- Favor modular Angular architecture (feature modules, shared UI kit) with strong typing via latest TypeScript/Angular releases.
|
||||
- Align UI flows with backend contracts; coordinate with Authority and Concelier teams for API changes.
|
||||
- Keep assets and build outputs deterministic and cacheable for Offline Kit packaging.
|
||||
- Track work using the local `TASKS.md` board; keep statuses (TODO/DOING/REVIEW/BLOCKED/DONE) up to date.
|
||||
|
||||
## Key Paths
|
||||
- `src/Web/StellaOps.Web` — Angular workspace (to be scaffolded).
|
||||
- `docs/` — UX specs and mockups (to be added).
|
||||
- `ops/` — Web deployment manifests for air-gapped environments (future).
|
||||
|
||||
## Coordination
|
||||
- Sync with DevEx for project scaffolding and build pipelines.
|
||||
- Partner with Docs Guild to translate UX decisions into operator guides.
|
||||
- Collaborate with Security Guild to validate authentication flows and session handling.
|
||||
63
src/Web/StellaOps.Web/README.md
Normal file
63
src/Web/StellaOps.Web/README.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# StellaOps Web
|
||||
|
||||
Offline-first expectations mean the workspace must restore dependencies and run tests without surprise downloads. Follow the deterministic install flow below before running commands on an air-gapped runner.
|
||||
|
||||
## Deterministic Install (CI / Offline)
|
||||
|
||||
1. Pick an npm cache directory (for example `/opt/stellaops/npm-cache`) that you can copy into the Offline Kit.
|
||||
2. On a connected machine, export `NPM_CONFIG_CACHE` to that directory and run `npm run ci:install`. This executes `npm ci --prefer-offline --no-audit --no-fund`, seeding the cache without audit/fund traffic.
|
||||
3. Provision a headless Chromium binary by either:
|
||||
- installing `chromium`, `chromium-browser`, or `google-chrome-stable` through your distribution tooling; or
|
||||
- downloading one via `npx @puppeteer/browsers install chrome@stable --path .cache/chromium` and archiving the resulting `.cache/chromium/` directory.
|
||||
4. Transfer the npm cache (and optional `.cache/chromium/`) to the offline runner, export `NPM_CONFIG_CACHE`, then execute `npm run ci:install` again.
|
||||
5. Use `npm run verify:chromium` to confirm Karma can locate a browser. `npm run test:ci` enforces this check automatically.
|
||||
|
||||
See `docs/DeterministicInstall.md` for a detailed operator checklist covering cache priming and Chromium placement.
|
||||
|
||||
## Development server
|
||||
|
||||
Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The application will automatically reload if you change any of the source files.
|
||||
|
||||
## Code scaffolding
|
||||
|
||||
Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`.
|
||||
|
||||
## Build
|
||||
|
||||
Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory.
|
||||
|
||||
## Running unit tests
|
||||
|
||||
- `npm test` executes `ng test --watch=false` once after verifying a Chromium binary.
|
||||
- `npm run test:ci` first calls `npm run verify:chromium` to guarantee CI/offline setups fail fast when a browser is missing.
|
||||
- `npm run test:watch` keeps Karma in watch mode for local development.
|
||||
|
||||
`verify:chromium` prints every location inspected (environment overrides, system paths, `.cache/chromium/`). Set `CHROME_BIN` or `STELLAOPS_CHROMIUM_BIN` if you host the binary in a non-standard path.
|
||||
|
||||
## Runtime configuration
|
||||
|
||||
The SPA loads environment details from `/config.json` at startup. During development we ship a stub configuration under `src/config/config.json`; adjust the issuer, client ID, and API base URLs to match your Authority instance. To reset, copy `src/config/config.sample.json` back to `src/config/config.json`:
|
||||
|
||||
```bash
|
||||
cp src/config/config.sample.json src/config/config.json
|
||||
```
|
||||
|
||||
When packaging for another environment, replace the file before building so the generated bundle contains the correct defaults. Gateways that rewrite `/config.json` at request time can override these settings without rebuilding.
|
||||
|
||||
## End-to-end tests
|
||||
|
||||
Playwright drives the high-level auth UX using the stub configuration above. Ensure the Angular dev server can bind to `127.0.0.1:4400`, then run:
|
||||
|
||||
```bash
|
||||
npm run test:e2e
|
||||
```
|
||||
|
||||
The Playwright config auto-starts `npm run serve:test` and intercepts Authority redirects, so no live IdP is required. For CI/offline nodes, pre-install the required browsers via `npx playwright install --with-deps` and cache the results alongside your npm cache.
|
||||
|
||||
## Running end-to-end tests
|
||||
|
||||
Run `ng e2e` to execute the end-to-end tests via a platform of your choice. To use this command, you need to first add a package that implements end-to-end testing capabilities.
|
||||
|
||||
## Further help
|
||||
|
||||
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.io/cli) page.
|
||||
179
src/Web/StellaOps.Web/TASKS.md
Normal file
179
src/Web/StellaOps.Web/TASKS.md
Normal file
@@ -0,0 +1,179 @@
|
||||
# TASKS — Epic 1: Aggregation-Only Contract
|
||||
| ID | Status | Owner(s) | Depends on | Notes |
|
||||
|----|--------|----------|------------|-------|
|
||||
| WEB-AOC-19-001 `Shared AOC guard primitives` | DOING (2025-10-26) | BE-Base Platform Guild | — | Provide `AOCForbiddenKeys`, guard middleware/interceptor hooks, and error types (`AOCError`, `AOCViolationCode`) for ingestion services. Publish sample usage + analyzer to ensure guard registered. |
|
||||
> 2025-10-26: Introduced `StellaOps.Aoc` library with forbidden key list, guard result/options, and baseline write guard + tests. Middleware/analyzer wiring still pending.
|
||||
| WEB-AOC-19-002 `Provenance & signature helpers` | TODO | BE-Base Platform Guild | WEB-AOC-19-001 | Ship `ProvenanceBuilder`, checksum utilities, and signature verification helper integrated with guard logging. Cover DSSE/CMS formats with unit tests. |
|
||||
| WEB-AOC-19-003 `Analyzer + test fixtures` | TODO | QA Guild, BE-Base Platform Guild | WEB-AOC-19-001 | Author Roslyn analyzer preventing ingestion modules from writing forbidden keys without guard, and provide shared test fixtures for guard validation used by Concelier/Excititor service tests. |
|
||||
> Docs alignment (2025-10-26): Analyzer expectations detailed in `docs/ingestion/aggregation-only-contract.md` §3/5; CI integration tracked via DEVOPS-AOC-19-001.
|
||||
|
||||
## Policy Engine v2
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Notes |
|
||||
|----|--------|----------|------------|-------|
|
||||
| WEB-POLICY-20-001 `Policy endpoints` | TODO | BE-Base Platform Guild, Policy Guild | POLICY-ENGINE-20-001, POLICY-ENGINE-20-004 | Implement Policy CRUD/compile/run/simulate/findings/explain endpoints with OpenAPI, tenant scoping, and service identity enforcement. |
|
||||
| WEB-POLICY-20-002 `Pagination & filters` | TODO | BE-Base Platform Guild | WEB-POLICY-20-001 | Add pagination, filtering, sorting, and tenant guards to listings for policies, runs, and findings; include deterministic ordering and query diagnostics. |
|
||||
| WEB-POLICY-20-003 `Error mapping` | TODO | BE-Base Platform Guild, QA Guild | WEB-POLICY-20-001 | Map engine errors to `ERR_POL_*` responses with consistent payloads and contract tests; expose correlation IDs in headers. |
|
||||
| WEB-POLICY-20-004 `Simulate rate limits` | TODO | Platform Reliability Guild | WEB-POLICY-20-001, WEB-POLICY-20-002 | Introduce adaptive rate limiting + quotas for simulation endpoints, expose metrics, and document retry headers. |
|
||||
|
||||
## Graph Explorer v1
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Notes |
|
||||
|----|--------|----------|------------|-------|
|
||||
| WEB-GRAPH-21-001 `Graph endpoints` | BLOCKED (2025-10-27) | BE-Base Platform Guild, Graph Platform Guild | GRAPH-API-28-003, AUTH-VULN-24-001 | Add gateway routes for graph versions/viewport/node/path/diff/export endpoints with tenant enforcement, scope checks, and streaming responses; proxy Policy Engine diff toggles without inline logic. Adopt `StellaOpsScopes` constants for RBAC enforcement. |
|
||||
> 2025-10-27: Graph API gateway can’t proxy until upstream Graph service (`GRAPH-API-28-003`) and Authority scope update (`AUTH-VULN-24-001`) publish stable contracts.
|
||||
| WEB-GRAPH-21-002 `Request validation` | BLOCKED (2025-10-27) | BE-Base Platform Guild | WEB-GRAPH-21-001 | Implement bbox/zoom/path parameter validation, pagination tokens, and deterministic ordering; add contract tests for boundary conditions. |
|
||||
> 2025-10-27: Blocked on `WEB-GRAPH-21-001`; request envelope still undefined.
|
||||
| WEB-GRAPH-21-003 `Error mapping & exports` | BLOCKED (2025-10-27) | BE-Base Platform Guild, QA Guild | WEB-GRAPH-21-001 | Map graph service errors to `ERR_Graph_*`, support GraphML/JSONL export streaming, and document rate limits. |
|
||||
> 2025-10-27: Depends on core Graph proxy route definitions.
|
||||
| WEB-GRAPH-21-004 `Overlay pass-through` | BLOCKED (2025-10-27) | BE-Base Platform Guild, Policy Guild | WEB-GRAPH-21-001, POLICY-ENGINE-30-002 | Proxy Policy Engine overlay responses for graph endpoints while keeping gateway stateless; maintain streaming budgets and latency SLOs. |
|
||||
> 2025-10-27: Requires base Graph routing plus Policy overlay schema (`POLICY-ENGINE-30-002`).
|
||||
|
||||
## Graph Explorer (Sprint 28)
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Notes |
|
||||
|----|--------|----------|------------|-------|
|
||||
| WEB-GRAPH-24-001 `Gateway proxy refresh` | TODO | BE-Base Platform Guild | GRAPH-API-28-001, AUTH-GRAPH-21-001 | Gateway proxy for Graph API and Policy overlays with RBAC, caching, pagination, ETags, and streaming; zero business logic. |
|
||||
| WEB-GRAPH-24-004 `Telemetry aggregation` | TODO | BE-Base Platform Guild, Observability Guild | WEB-GRAPH-24-001, DEVOPS-GRAPH-28-003 | Collect gateway metrics/logs (tile latency, proxy errors, overlay cache stats) and forward to dashboards; document sampling strategy. |
|
||||
|
||||
## Link-Not-Merge v1
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Notes |
|
||||
|----|--------|----------|------------|-------|
|
||||
| WEB-LNM-21-001 `Advisory observation endpoints` | TODO | BE-Base Platform Guild, Concelier WebService Guild | CONCELIER-LNM-21-201 | Surface new `/advisories/*` APIs through gateway with caching, pagination, and RBAC enforcement (`advisory:read`). |
|
||||
| WEB-LNM-21-002 `VEX observation endpoints` | TODO | BE-Base Platform Guild, Excititor WebService Guild | EXCITITOR-LNM-21-201 | Expose `/vex/*` read APIs with evidence routes and export handlers; map `ERR_AGG_*` codes. |
|
||||
| WEB-LNM-21-003 `Policy evidence aggregation` | TODO | BE-Base Platform Guild, Policy Guild | POLICY-ENGINE-40-001 | Provide combined endpoint for Console to fetch policy result + source evidence (advisory + VEX linksets) for a component. |
|
||||
|
||||
## Policy Engine + Editor v1 (Epic 5)
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Notes |
|
||||
|----|--------|----------|------------|-------|
|
||||
| WEB-POLICY-23-001 `Policy pack CRUD` | BLOCKED (2025-10-29) | BE-Base Platform Guild, Policy Guild | POLICY-GATEWAY-18-001..002 | Implement API endpoints for creating/listing/fetching policy packs and revisions (`/policy/packs`, `/policy/packs/{id}/revisions`) with pagination, RBAC, and AOC metadata exposure. (Tracked via Sprint 18.5 gateway tasks.) |
|
||||
| WEB-POLICY-23-002 `Activation & scope` | BLOCKED (2025-10-29) | BE-Base Platform Guild | POLICY-GATEWAY-18-003 | Add activation endpoint with scope windows, conflict checks, and optional 2-person approval integration; emit events on success. (Tracked via Sprint 18.5 gateway tasks.) |
|
||||
| WEB-POLICY-23-003 `Simulation & evaluation` | TODO | BE-Base Platform Guild | POLICY-ENGINE-50-002 | Provide `/policy/simulate` and `/policy/evaluate` endpoints with streaming responses, rate limiting, and error mapping. |
|
||||
| WEB-POLICY-23-004 `Explain retrieval` | TODO | BE-Base Platform Guild | POLICY-ENGINE-50-006 | Expose explain history endpoints (`/policy/runs`, `/policy/runs/{id}`) including decision tree, sources consulted, and AOC chain. |
|
||||
|
||||
## Graph & Vuln Explorer v1
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Notes |
|
||||
|----|--------|----------|------------|-------|
|
||||
| WEB-GRAPH-24-001 `Graph endpoints` | TODO | BE-Base Platform Guild, SBOM Service Guild | SBOM-GRAPH-24-002 | Implement `/graph/assets/*` endpoints (snapshots, adjacency, search) with pagination, ETags, and tenant scoping while acting as a pure proxy. |
|
||||
| WEB-GRAPH-24-004 `AOC enrichers` | TODO | BE-Base Platform Guild | WEB-GRAPH-24-001 | Embed AOC summaries sourced from overlay services; ensure gateway does not compute derived severity or hints. |
|
||||
|
||||
## StellaOps Console (Sprint 23)
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Notes |
|
||||
|----|--------|----------|------------|-------|
|
||||
| WEB-CONSOLE-23-001 `Global posture endpoints` | TODO | BE-Base Platform Guild, Product Analytics Guild | CONCELIER-CONSOLE-23-001, EXCITITOR-CONSOLE-23-001, POLICY-CONSOLE-23-001, SBOM-CONSOLE-23-001, SCHED-CONSOLE-23-001 | Provide consolidated `/console/dashboard` and `/console/filters` APIs returning tenant-scoped aggregates (findings by severity, VEX override counts, advisory deltas, run health, policy change log). Enforce AOC labelling, deterministic ordering, and cursor-based pagination for drill-down hints. |
|
||||
| WEB-CONSOLE-23-002 `Live status & SSE proxy` | TODO | BE-Base Platform Guild, Scheduler Guild | SCHED-CONSOLE-23-001, DEVOPS-CONSOLE-23-001 | Expose `/console/status` polling endpoint and `/console/runs/{id}/stream` SSE/WebSocket proxy with heartbeat/backoff, queue lag metrics, and auth scope enforcement. Surface request IDs + retry headers. |
|
||||
| WEB-CONSOLE-23-003 `Evidence export orchestrator` | TODO | BE-Base Platform Guild, Policy Guild | EXPORT-CONSOLE-23-001, POLICY-CONSOLE-23-001 | Add `/console/exports` POST/GET routes coordinating evidence bundle creation, streaming CSV/JSON exports, checksum manifest retrieval, and signed attestation references. Ensure requests honor tenant + policy scopes and expose job tracking metadata. |
|
||||
| WEB-CONSOLE-23-004 `Global search router` | TODO | BE-Base Platform Guild | CONCELIER-CONSOLE-23-001, EXCITITOR-CONSOLE-23-001, SBOM-CONSOLE-23-001 | Implement `/console/search` endpoint accepting CVE/GHSA/PURL/SBOM identifiers, performing fan-out queries with caching, ranking, and deterministic tie-breaking. Return typed results for Console navigation; respect result caps and latency SLOs. |
|
||||
| WEB-CONSOLE-23-005 `Downloads manifest API` | TODO | BE-Base Platform Guild, DevOps Guild | DOWNLOADS-CONSOLE-23-001, DEVOPS-CONSOLE-23-002 | Serve `/console/downloads` JSON manifest (images, charts, offline bundles) sourced from signed registry metadata; include integrity hashes, release notes links, and offline instructions. Provide caching headers and documentation. |
|
||||
|
||||
## Policy Studio (Sprint 27)
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Notes |
|
||||
|----|--------|----------|------------|-------|
|
||||
| WEB-POLICY-27-001 `Policy registry proxy` | TODO | BE-Base Platform Guild, Policy Registry Guild | REGISTRY-API-27-001, AUTH-POLICY-27-001 | Surface Policy Registry APIs (`/policy/workspaces`, `/policy/versions`, `/policy/reviews`, `/policy/registry`) through gateway with tenant scoping, RBAC, and request validation; ensure streaming downloads for evidence bundles. |
|
||||
| WEB-POLICY-27-002 `Review & approval routes` | TODO | BE-Base Platform Guild | WEB-POLICY-27-001, REGISTRY-API-27-006 | Implement review lifecycle endpoints (open, comment, approve/reject) with audit headers, comment pagination, and webhook fan-out. |
|
||||
| WEB-POLICY-27-003 `Simulation orchestration endpoints` | TODO | BE-Base Platform Guild, Scheduler Guild | REGISTRY-API-27-005, SCHED-CONSOLE-27-001 | Expose quick/batch simulation endpoints with SSE progress (`/policy/simulations/{runId}/stream`), cursor-based result pagination, and manifest download routes. |
|
||||
| WEB-POLICY-27-004 `Publish & promote controls` | TODO | BE-Base Platform Guild, Security Guild | REGISTRY-API-27-007, REGISTRY-API-27-008, AUTH-POLICY-27-002 | Add publish/sign/promote/rollback endpoints with idempotent request IDs, canary parameters, and environment bindings; enforce scope checks and emit structured events. |
|
||||
| WEB-POLICY-27-005 `Policy Studio telemetry` | TODO | BE-Base Platform Guild, Observability Guild | WEB-POLICY-27-001..004, TELEMETRY-CONSOLE-27-001 | Instrument metrics/logs for compile latency, simulation queue depth, approval latency, promotion actions; expose aggregated dashboards and correlation IDs for Console. |
|
||||
|
||||
## Exceptions v1 (Epic 7)
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Notes |
|
||||
|----|--------|----------|------------|-------|
|
||||
| WEB-EXC-25-001 `Exceptions CRUD & workflow` | TODO | BE-Base Platform Guild | POLICY-ENGINE-70-002, AUTH-EXC-25-001 | Implement `/exceptions` API (create, propose, approve, revoke, list, history) with validation, pagination, and audit logging. |
|
||||
| WEB-EXC-25-002 `Policy integration surfaces` | TODO | BE-Base Platform Guild | POLICY-ENGINE-70-001 | Extend `/policy/effective` and `/policy/simulate` responses to include exception metadata and accept overrides for simulations. |
|
||||
| WEB-EXC-25-003 `Notifications & events` | TODO | BE-Base Platform Guild, Platform Events Guild | WEB-EXC-25-001 | Publish `exception.*` events, integrate with notification hooks, enforce rate limits. |
|
||||
|
||||
## Reachability v1
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Notes |
|
||||
|----|--------|----------|------------|-------|
|
||||
| WEB-SIG-26-001 `Signals proxy endpoints` | TODO | BE-Base Platform Guild, Signals Guild | SIGNALS-24-001 | Surface `/signals/callgraphs`, `/signals/facts` read/write endpoints with pagination, ETags, and RBAC. |
|
||||
| WEB-SIG-26-002 `Reachability joins` | TODO | BE-Base Platform Guild | WEB-SIG-26-001, POLICY-ENGINE-80-001 | Extend `/policy/effective` and `/vuln/explorer` responses to include reachability scores/states and allow filtering. |
|
||||
| WEB-SIG-26-003 `Simulation hooks` | TODO | BE-Base Platform Guild | WEB-SIG-26-002, POLICY-ENGINE-80-001 | Add reachability override parameters to `/policy/simulate` and related APIs for what-if analysis. |
|
||||
|
||||
## Vulnerability Explorer (Sprint 29)
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Notes |
|
||||
|----|--------|----------|------------|-------|
|
||||
| WEB-VULN-29-001 `Vuln API routing` | TODO | BE-Base Platform Guild | VULN-API-29-001, AUTH-VULN-29-001 | Expose `/vuln/*` endpoints via gateway with tenant scoping, RBAC/ABAC enforcement, anti-forgery headers, and request logging. |
|
||||
| WEB-VULN-29-002 `Ledger proxy headers` | TODO | BE-Base Platform Guild, Findings Ledger Guild | WEB-VULN-29-001, LEDGER-29-002 | Forward workflow actions to Findings Ledger with idempotency headers and correlation IDs; handle retries/backoff. |
|
||||
| WEB-VULN-29-003 `Simulation + export routing` | TODO | BE-Base Platform Guild | VULN-API-29-005, VULN-API-29-008 | Provide simulation and export orchestration routes with SSE/progress headers, signed download links, and request budgeting. |
|
||||
| WEB-VULN-29-004 `Telemetry aggregation` | TODO | BE-Base Platform Guild, Observability Guild | WEB-VULN-29-001..003, DEVOPS-VULN-29-003 | Emit gateway metrics/logs (latency, error rates, export duration), propagate query hashes for analytics dashboards. |
|
||||
| WEB-VEX-30-007 `VEX consensus routing` | TODO | BE-Base Platform Guild, VEX Lens Guild | VEXLENS-30-007, AUTH-VULN-24-001 | Route `/vex/consensus` APIs with tenant RBAC/ABAC, caching, and streaming; surface telemetry and trace IDs without gateway-side overlay logic. |
|
||||
|
||||
## Advisory AI (Sprint 31)
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Notes |
|
||||
|----|--------|----------|------------|-------|
|
||||
| WEB-AIAI-31-001 `API routing` | TODO | BE-Base Platform Guild | AIAI-31-006, AUTH-VULN-29-001 | Route `/advisory/ai/*` endpoints through gateway with RBAC/ABAC, rate limits, and telemetry headers. |
|
||||
| WEB-AIAI-31-002 `Batch orchestration` | TODO | BE-Base Platform Guild | AIAI-31-006 | Provide batching job handlers and streaming responses for CLI automation with retry/backoff. |
|
||||
| WEB-AIAI-31-003 `Telemetry & audit` | TODO | BE-Base Platform Guild, Observability Guild | WEB-AIAI-31-001, DEVOPS-AIAI-31-001 | Emit metrics/logs (latency, guardrail blocks, validation failures) and forward anonymized prompt hashes to analytics. |
|
||||
|
||||
## Orchestrator Dashboard
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Notes |
|
||||
|----|--------|----------|------------|-------|
|
||||
| WEB-ORCH-32-001 `Read-only routing` | TODO | BE-Base Platform Guild | ORCH-SVC-32-003, AUTH-ORCH-32-001 | Expose `/orchestrator/sources|runs|jobs|dag` read endpoints via gateway with tenant scoping, caching, and viewer scope enforcement. |
|
||||
| WEB-ORCH-33-001 `Control + backfill actions` | TODO | BE-Base Platform Guild | WEB-ORCH-32-001, ORCH-SVC-33-001, AUTH-ORCH-33-001 | Add POST action routes (`pause|resume|test`, `retry|cancel`, `jobs/tail`, `backfill preview`) with proper error mapping and SSE bridging. |
|
||||
| WEB-ORCH-34-001 `Quotas & telemetry` | TODO | BE-Base Platform Guild | WEB-ORCH-33-001, ORCH-SVC-33-003, ORCH-SVC-34-001 | Surface quotas/backfill APIs, queue/backpressure metrics, and error clustering routes with admin scope enforcement and audit logging. |
|
||||
|
||||
## Export Center
|
||||
| ID | Status | Owner(s) | Depends on | Notes |
|
||||
|----|--------|----------|------------|-------|
|
||||
| WEB-EXPORT-35-001 `Export routing` | TODO | BE-Base Platform Guild | EXPORT-SVC-35-006, AUTH-EXPORT-35-001 | Surface Export Center APIs (profiles/runs/download) through gateway with tenant scoping, streaming support, and viewer/operator scope checks. |
|
||||
| WEB-EXPORT-36-001 `Distribution endpoints` | TODO | BE-Base Platform Guild | WEB-EXPORT-35-001, EXPORT-SVC-36-004 | Add distribution routes (OCI/object storage), manifest/provenance proxies, and signed URL generation. |
|
||||
| WEB-EXPORT-37-001 `Scheduling & verification` | TODO | BE-Base Platform Guild | WEB-EXPORT-36-001, EXPORT-SVC-37-003 | Expose scheduling, retention, encryption parameters, and verification endpoints with admin scope enforcement and audit logs. |
|
||||
|
||||
## Notifications Studio (Epic 11)
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Notes |
|
||||
|----|--------|----------|------------|-------|
|
||||
| WEB-NOTIFY-38-001 `Gateway routing` | TODO | BE-Base Platform Guild | NOTIFY-SVC-38-004, AUTH-NOTIFY-38-001 | Route notifier APIs (`/notifications/*`) and WS feed through gateway with tenant scoping, viewer/operator scope enforcement, and SSE/WebSocket bridging. |
|
||||
| WEB-NOTIFY-39-001 `Digest & simulation endpoints` | TODO | BE-Base Platform Guild | WEB-NOTIFY-38-001, NOTIFY-SVC-39-001..003 | Surface digest scheduling, quiet-hour/throttle management, and simulation APIs; ensure rate limits and audit logging. |
|
||||
| WEB-NOTIFY-40-001 `Escalations & localization` | TODO | BE-Base Platform Guild | WEB-NOTIFY-39-001, NOTIFY-SVC-40-001..003 | Expose escalation, localization, channel health, and ack verification endpoints with admin scope enforcement and signed token validation. |
|
||||
|
||||
## Containerized Distribution (Epic 13)
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Notes |
|
||||
|----|--------|----------|------------|-------|
|
||||
| WEB-CONTAINERS-44-001 `Config discovery & quickstart flag` | TODO | BE-Base Platform Guild | COMPOSE-44-001 | Expose `/welcome` state, config discovery endpoint (safe values), and `QUICKSTART_MODE` handling for Console banner; add `/health/liveness`, `/health/readiness`, `/version` if missing. |
|
||||
| WEB-CONTAINERS-45-001 `Helm readiness support` | TODO | BE-Base Platform Guild | HELM-45-001 | Ensure readiness endpoints reflect DB/queue readiness, add feature flag toggles via config map, and document NetworkPolicy ports. |
|
||||
| WEB-CONTAINERS-46-001 `Air-gap hardening` | TODO | BE-Base Platform Guild | DEPLOY-AIRGAP-46-001 | Provide offline-friendly asset serving (no CDN), allow overriding object store endpoints via env, and document fallback behavior. |
|
||||
|
||||
## Authority-Backed Scopes & Tenancy (Epic 14)
|
||||
| ID | Status | Owner(s) | Depends on | Notes |
|
||||
|----|--------|----------|------------|-------|
|
||||
| WEB-TEN-47-001 `Auth middleware` | TODO | BE-Base Platform Guild | AUTH-TEN-47-001 | Implement JWT verification, tenant activation from headers, scope matching, and decision audit emission for all API endpoints. |
|
||||
| WEB-TEN-48-001 `Tenant context propagation` | TODO | BE-Base Platform Guild | WEB-TEN-47-001 | Set DB session `stella.tenant_id`, enforce tenant/project checks on persistence, prefix object storage paths, and stamp audit metadata. |
|
||||
| WEB-TEN-49-001 `ABAC & audit API` | TODO | BE-Base Platform Guild, Policy Guild | POLICY-TEN-48-001 | Integrate optional ABAC overlay with Policy Engine, expose `/audit/decisions` API, and support service token minting endpoints. |
|
||||
|
||||
## Observability & Forensics (Epic 15)
|
||||
| ID | Status | Owner(s) | Depends on | Notes |
|
||||
|----|--------|----------|------------|-------|
|
||||
| WEB-OBS-50-001 `Telemetry core adoption` | TODO | BE-Base Platform Guild, Observability Guild | TELEMETRY-OBS-50-001, TELEMETRY-OBS-50-002 | Integrate `StellaOps.Telemetry.Core` into gateway host, replace ad-hoc logging, ensure all routes emit trace/span IDs, tenant context, and scrubbed payload previews. |
|
||||
| WEB-OBS-51-001 `Observability health endpoints` | TODO | BE-Base Platform Guild | WEB-OBS-50-001, TELEMETRY-OBS-51-001 | Implement `/obs/health` and `/obs/slo` aggregations, pulling metrics from Prometheus/collector APIs, including burn-rate signals and exemplar links for Console widgets. |
|
||||
| WEB-OBS-52-001 `Trace & log proxies` | TODO | BE-Base Platform Guild | WEB-OBS-50-001, TIMELINE-OBS-52-003 | Deliver `/obs/trace/:id` and `/obs/logs` proxy endpoints with guardrails (time window limits, tenant scoping) forwarding to timeline indexer + log store with signed URLs. |
|
||||
| WEB-OBS-54-001 `Evidence & attestation bridges` | TODO | BE-Base Platform Guild | EVID-OBS-54-001, PROV-OBS-54-001 | Provide `/evidence/*` and `/attestations/*` pass-through endpoints, enforce `timeline:read`, `evidence:read`, `attest:read` scopes, append provenance headers, and surface verification summaries. |
|
||||
| WEB-OBS-55-001 `Incident mode controls` | TODO | BE-Base Platform Guild, Ops Guild | WEB-OBS-50-001, TELEMETRY-OBS-55-001, DEVOPS-OBS-55-001 | Add `/obs/incident-mode` API (enable/disable/status) with audit trail, sampling override, retention bump preview, and CLI/Console hooks. |
|
||||
| WEB-OBS-56-001 `Sealed status surfaces` | TODO | BE-Base Platform Guild, AirGap Guild | WEB-OBS-50-001, AIRGAP-CTL-56-002 | Extend telemetry core integration to expose sealed/unsealed status APIs, drift metrics, and Console widgets without leaking sealed-mode secrets. |
|
||||
|
||||
## SDKs & OpenAPI (Epic 17)
|
||||
| ID | Status | Owner(s) | Depends on | Notes |
|
||||
|----|--------|----------|------------|-------|
|
||||
| WEB-OAS-61-001 `Discovery endpoint` | TODO | BE-Base Platform Guild | OAS-61-002 | Implement `GET /.well-known/openapi` returning gateway spec with version metadata, cache headers, and signed ETag. |
|
||||
| WEB-OAS-61-002 `Standard error envelope` | TODO | BE-Base Platform Guild | APIGOV-61-001 | Migrate gateway errors to standard envelope and update examples; ensure telemetry logs include `error.code`. |
|
||||
| WEB-OAS-62-001 `Pagination & idempotency alignment` | TODO | BE-Base Platform Guild | WEB-OAS-61-002 | Normalize all endpoints to cursor pagination, expose `Idempotency-Key` support, and document rate-limit headers. |
|
||||
| WEB-OAS-63-001 `Deprecation support` | TODO | BE-Base Platform Guild, API Governance Guild | APIGOV-63-001 | Add deprecation header middleware, Sunset link emission, and observability metrics for deprecated routes. |
|
||||
|
||||
## Risk Profiles (Epic 18)
|
||||
| ID | Status | Owner(s) | Depends on | Notes |
|
||||
|----|--------|----------|------------|-------|
|
||||
| WEB-RISK-66-001 `Risk API routing` | TODO | BE-Base Platform Guild, Policy Guild | POLICY-RISK-67-002 | Expose risk profile/results endpoints through gateway with tenant scoping, pagination, and rate limiting. |
|
||||
| WEB-RISK-66-002 `Explainability downloads` | TODO | BE-Base Platform Guild, Risk Engine Guild | RISK-ENGINE-68-002 | Add signed URL handling for explanation blobs and enforce scope checks. |
|
||||
| WEB-RISK-67-001 `Risk status endpoint` | TODO | BE-Base Platform Guild | WEB-RISK-66-001 | Provide aggregated risk stats (`/risk/status`) for Console dashboards (counts per severity, last computation). |
|
||||
| WEB-RISK-68-001 `Notification hooks` | TODO | BE-Base Platform Guild, Notifications Guild | NOTIFY-RISK-66-001 | Emit events on severity transitions via gateway to notifier bus with trace metadata. |
|
||||
112
src/Web/StellaOps.Web/angular.json
Normal file
112
src/Web/StellaOps.Web/angular.json
Normal file
@@ -0,0 +1,112 @@
|
||||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
"stellaops-web": {
|
||||
"projectType": "application",
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"style": "scss"
|
||||
}
|
||||
},
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"prefix": "app",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:application",
|
||||
"options": {
|
||||
"outputPath": "dist/stellaops-web",
|
||||
"index": "src/index.html",
|
||||
"browser": "src/main.ts",
|
||||
"polyfills": [
|
||||
"zone.js"
|
||||
],
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"inlineStyleLanguage": "scss",
|
||||
"assets": [
|
||||
"src/favicon.ico",
|
||||
"src/assets",
|
||||
{
|
||||
"glob": "config.json",
|
||||
"input": "src/config",
|
||||
"output": "."
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.scss"
|
||||
],
|
||||
"scripts": []
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "500kb",
|
||||
"maximumError": "1mb"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "2kb",
|
||||
"maximumError": "4kb"
|
||||
}
|
||||
],
|
||||
"outputHashing": "all"
|
||||
},
|
||||
"development": {
|
||||
"optimization": false,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
"configurations": {
|
||||
"production": {
|
||||
"buildTarget": "stellaops-web:build:production"
|
||||
},
|
||||
"development": {
|
||||
"buildTarget": "stellaops-web:build:development"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
},
|
||||
"extract-i18n": {
|
||||
"builder": "@angular-devkit/build-angular:extract-i18n",
|
||||
"options": {
|
||||
"buildTarget": "stellaops-web:build"
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular-devkit/build-angular:karma",
|
||||
"options": {
|
||||
"polyfills": [
|
||||
"zone.js",
|
||||
"zone.js/testing"
|
||||
],
|
||||
"tsConfig": "tsconfig.spec.json",
|
||||
"karmaConfig": "karma.conf.cjs",
|
||||
"inlineStyleLanguage": "scss",
|
||||
"assets": [
|
||||
"src/favicon.ico",
|
||||
"src/assets",
|
||||
{
|
||||
"glob": "config.json",
|
||||
"input": "src/config",
|
||||
"output": "."
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.scss"
|
||||
],
|
||||
"scripts": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
42
src/Web/StellaOps.Web/docs/DeterministicInstall.md
Normal file
42
src/Web/StellaOps.Web/docs/DeterministicInstall.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Deterministic Install & Headless Chromium
|
||||
|
||||
Offline runners must avoid ad-hoc network calls while staying reproducible. The Angular workspace now ships a locked dependency graph and helpers for provisioning a Chromium binary without embedding it directly in `npm install`.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js **20.11.0** or newer (matches the `engines` constraint).
|
||||
- npm **10.2.0** or newer.
|
||||
- Local npm cache location available to both the connected “seed” machine and the offline runner (for example, `/opt/stellaops/npm-cache`).
|
||||
|
||||
## One-Time Cache Priming (Connected Host)
|
||||
|
||||
```bash
|
||||
export NPM_CONFIG_CACHE=/opt/stellaops/npm-cache
|
||||
npm run ci:install
|
||||
```
|
||||
|
||||
`ci:install` executes `npm ci --prefer-offline --no-audit --no-fund` so every package and integrity hash lands in the cache without touching arbitrary registries afterwards.
|
||||
|
||||
If you plan to bundle a Chromium binary, download it while still connected:
|
||||
|
||||
```bash
|
||||
npx @puppeteer/browsers install chrome@stable --path .cache/chromium
|
||||
```
|
||||
|
||||
Archive both the npm cache and `.cache/chromium/` directory; include them in your Offline Kit transfer.
|
||||
|
||||
## Offline Runner Execution
|
||||
|
||||
1. Extract the pre-warmed npm cache to the offline host and export `NPM_CONFIG_CACHE` to that directory.
|
||||
2. Optionally copy the `.cache/chromium/` folder next to `package.json` (the Karma launcher auto-detects platform-specific paths inside this directory).
|
||||
3. Run `npm run ci:install` to restore dependencies without network access.
|
||||
4. Validate Chromium availability with `npm run verify:chromium`. This command exits non-zero and prints the search paths if no binary is discovered.
|
||||
5. Execute tests via `npm run test:ci` (internally calls `verify:chromium` before running `ng test --watch=false`).
|
||||
|
||||
## Chromium Options
|
||||
|
||||
- **System package** – Install `chromium`, `chromium-browser`, or `google-chrome-stable` via your distribution repository or the Offline Kit. The launcher checks `/usr/bin/chromium-browser`, `/usr/bin/chromium`, and `/usr/bin/google-chrome(-stable)` automatically.
|
||||
- **Environment override** – Set `CHROME_BIN` or `STELLAOPS_CHROMIUM_BIN` to the executable path if you host Chromium in a custom location.
|
||||
- **Offline cache drop** – Place the extracted archive under `.cache/chromium/` (`chrome-linux64/chrome`, `chrome-win64/chrome.exe`, or `chrome-mac/Chromium.app/...`). The Karma harness resolves these automatically.
|
||||
|
||||
Consult `src/Web/StellaOps.Web/README.md` for a shortened operator flow overview.
|
||||
37
src/Web/StellaOps.Web/docs/TrivyDbSettings.md
Normal file
37
src/Web/StellaOps.Web/docs/TrivyDbSettings.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# WEB1.TRIVY-SETTINGS – Backend Contract & UI Wiring Notes
|
||||
|
||||
## 1. Known backend surfaces
|
||||
|
||||
- `POST /jobs/export:trivy-db`
|
||||
Payload is wrapped as `{ "trigger": "<source>", "parameters": { ... } }` and accepts the overrides shown in `TrivyDbExportJob` (`publishFull`, `publishDelta`, `includeFull`, `includeDelta`).
|
||||
Evidence: `src/Cli/StellaOps.Cli/Commands/CommandHandlers.cs:263`, `src/Cli/StellaOps.Cli/Services/Models/Transport/JobTriggerRequest.cs:5`, `src/Concelier/StellaOps.Concelier.PluginBinaries/StellaOps.Concelier.Exporter.TrivyDb/TrivyDbExportJob.cs:27`.
|
||||
- Export configuration defaults sit under `TrivyDbExportOptions.Oras` and `.OfflineBundle`. Both booleans default to `true`, so overriding to `false` must be explicit.
|
||||
Evidence: `src/Concelier/StellaOps.Concelier.PluginBinaries/StellaOps.Concelier.Exporter.TrivyDb/TrivyDbExportOptions.cs:8`.
|
||||
|
||||
## 2. Clarifications needed from Concelier backend
|
||||
|
||||
| Topic | Questions to resolve | Suggested owner |
|
||||
| --- | --- | --- |
|
||||
| Settings endpoint surface | `Program.cs` only exposes `/jobs/*` and health endpoints—there is currently **no** `/exporters/trivy-db/settings` route. Confirm the intended path (`/api/v1/concelier/exporters/trivy-db/settings`?), verbs (`GET`/`PUT` or `PATCH`), and DTO schema (flat booleans vs nested `oras`/`offlineBundle`). | Concelier WebService |
|
||||
| Auth scopes | Verify required roles (likely `concelier.export` or `concelier.admin`) and whether UI needs to request additional scopes beyond existing dashboard access. | Authority & Concelier teams |
|
||||
| Concurrency control | Determine if settings payload includes an ETag or timestamp we must echo (`If-Match`) to avoid stomping concurrent edits. | Concelier WebService |
|
||||
| Validation & defaults | Clarify server-side validation rules (e.g., must `publishDelta` be `false` when `publishFull` is `false`?) and shape of Problem+JSON responses. | Concelier WebService |
|
||||
| Manual run trigger | Confirm whether settings update should immediately kick an export or if UI should call `POST /jobs/export:trivy-db` separately (current CLI behaviour suggests a separate call). | Concelier WebService |
|
||||
|
||||
## 3. Proposed Angular implementation (pending contract lock)
|
||||
|
||||
- **Feature module**: `app/concelier/trivy-db-settings/` with a standalone routed page (`TrivyDbSettingsPage`) and a reusable form component (`TrivyDbSettingsForm`).
|
||||
- **State & transport**:
|
||||
- Client wrapper under `core/api/concelier-exporter.client.ts` exposing `getTrivyDbSettings`, `updateTrivyDbSettings`, and `runTrivyDbExport`.
|
||||
- Store built with `@ngrx/signals` keeping `settings`, `isDirty`, `lastFetchedAt`, and error state; optimistic updates gated on ETag confirmation once the backend specifies the shape.
|
||||
- Shared DTOs generated from the confirmed schema to keep Concelier/CLI alignment.
|
||||
- **UX flow**:
|
||||
- Load settings on navigation; show inline info about current publish/bundle defaults.
|
||||
- “Run export now” button opens confirmation modal summarising overrides, then calls `runTrivyDbExport` (separate API call) while reusing local state.
|
||||
- Surface Problem+JSON errors via existing toast/notification pattern and capture correlation IDs for ops visibility.
|
||||
- **Offline posture**: cache latest successful settings payload in IndexedDB (read-only when offline) and disable the run button when token/scopes are missing.
|
||||
|
||||
## 4. Next steps
|
||||
|
||||
1. Share section 2 with Concelier WebService owners to confirm the REST contract (blocking before scaffolding DTOs).
|
||||
2. Once confirmed, scaffold the Angular workspace and feature shell, keeping deterministic build outputs per `src/Web/StellaOps.Web/AGENTS.md`.
|
||||
63
src/Web/StellaOps.Web/karma.conf.cjs
Normal file
63
src/Web/StellaOps.Web/karma.conf.cjs
Normal file
@@ -0,0 +1,63 @@
|
||||
const { join } = require('path');
|
||||
const { resolveChromeBinary } = require('./scripts/chrome-path');
|
||||
|
||||
const { env } = process;
|
||||
|
||||
const chromeBin = resolveChromeBinary(__dirname);
|
||||
|
||||
if (chromeBin) {
|
||||
env.CHROME_BIN = chromeBin;
|
||||
} else if (!env.CHROME_BIN) {
|
||||
console.warn(
|
||||
'[karma] Unable to locate a Chromium binary automatically. ' +
|
||||
'Set CHROME_BIN or STELLAOPS_CHROMIUM_BIN, or place an offline build under .cache/chromium/. ' +
|
||||
'See docs/DeterministicInstall.md for bootstrap instructions.'
|
||||
);
|
||||
}
|
||||
|
||||
const isCI = env.CI === 'true' || env.CI === '1';
|
||||
|
||||
module.exports = function (config) {
|
||||
config.set({
|
||||
basePath: '',
|
||||
frameworks: ['jasmine', '@angular-devkit/build-angular'],
|
||||
plugins: [
|
||||
require('karma-jasmine'),
|
||||
require('karma-chrome-launcher'),
|
||||
require('karma-jasmine-html-reporter'),
|
||||
require('karma-coverage'),
|
||||
require('@angular-devkit/build-angular/plugins/karma')
|
||||
],
|
||||
client: {
|
||||
clearContext: false
|
||||
},
|
||||
jasmineHtmlReporter: {
|
||||
suppressAll: true
|
||||
},
|
||||
coverageReporter: {
|
||||
dir: join(__dirname, './coverage/stellaops-web'),
|
||||
subdir: '.',
|
||||
reporters: [
|
||||
{ type: 'html' },
|
||||
{ type: 'text-summary' }
|
||||
]
|
||||
},
|
||||
reporters: ['progress', 'kjhtml'],
|
||||
port: 9876,
|
||||
colors: true,
|
||||
logLevel: config.LOG_INFO,
|
||||
browsers: ['ChromeHeadlessOffline'],
|
||||
customLaunchers: {
|
||||
ChromeHeadlessOffline: {
|
||||
base: 'ChromeHeadless',
|
||||
flags: [
|
||||
'--no-sandbox',
|
||||
'--disable-gpu',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-setuid-sandbox'
|
||||
]
|
||||
}
|
||||
},
|
||||
restartOnFileChange: false
|
||||
});
|
||||
};
|
||||
13696
src/Web/StellaOps.Web/package-lock.json
generated
Normal file
13696
src/Web/StellaOps.Web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
49
src/Web/StellaOps.Web/package.json
Normal file
49
src/Web/StellaOps.Web/package.json
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "stellaops-web",
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
"build": "ng build",
|
||||
"watch": "ng build --watch --configuration development",
|
||||
"test": "npm run verify:chromium && ng test --watch=false",
|
||||
"test:watch": "ng test --watch",
|
||||
"test:ci": "npm run test",
|
||||
"test:e2e": "playwright test",
|
||||
"serve:test": "ng serve --configuration development --port 4400 --host 127.0.0.1",
|
||||
"verify:chromium": "node ./scripts/verify-chromium.js",
|
||||
"ci:install": "npm ci --prefer-offline --no-audit --no-fund"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.11.0",
|
||||
"npm": ">=10.2.0"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/animations": "^17.3.0",
|
||||
"@angular/common": "^17.3.0",
|
||||
"@angular/compiler": "^17.3.0",
|
||||
"@angular/core": "^17.3.0",
|
||||
"@angular/forms": "^17.3.0",
|
||||
"@angular/platform-browser": "^17.3.0",
|
||||
"@angular/platform-browser-dynamic": "^17.3.0",
|
||||
"@angular/router": "^17.3.0",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0",
|
||||
"zone.js": "~0.14.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "^17.3.17",
|
||||
"@angular/cli": "^17.3.17",
|
||||
"@angular/compiler-cli": "^17.3.0",
|
||||
"@playwright/test": "^1.47.2",
|
||||
"@types/jasmine": "~5.1.0",
|
||||
"jasmine-core": "~5.1.0",
|
||||
"karma": "~6.4.0",
|
||||
"karma-chrome-launcher": "~3.2.0",
|
||||
"karma-coverage": "~2.2.0",
|
||||
"karma-jasmine": "~5.1.0",
|
||||
"karma-jasmine-html-reporter": "~2.1.0",
|
||||
"typescript": "~5.4.2"
|
||||
}
|
||||
}
|
||||
22
src/Web/StellaOps.Web/playwright.config.ts
Normal file
22
src/Web/StellaOps.Web/playwright.config.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
const port = process.env.PLAYWRIGHT_PORT
|
||||
? Number.parseInt(process.env.PLAYWRIGHT_PORT, 10)
|
||||
: 4400;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: 'tests/e2e',
|
||||
timeout: 30_000,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
use: {
|
||||
baseURL: process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${port}`,
|
||||
trace: 'retain-on-failure',
|
||||
},
|
||||
webServer: {
|
||||
command: 'npm run serve:test',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
url: `http://127.0.0.1:${port}`,
|
||||
stdout: 'ignore',
|
||||
stderr: 'ignore',
|
||||
},
|
||||
});
|
||||
133
src/Web/StellaOps.Web/scripts/chrome-path.js
Normal file
133
src/Web/StellaOps.Web/scripts/chrome-path.js
Normal file
@@ -0,0 +1,133 @@
|
||||
const { existsSync, readdirSync, statSync } = require('fs');
|
||||
const { join } = require('path');
|
||||
|
||||
const linuxArchivePath = ['.cache', 'chromium', 'chrome-linux64', 'chrome'];
|
||||
const windowsArchivePath = ['.cache', 'chromium', 'chrome-win64', 'chrome.exe'];
|
||||
const macArchivePath = [
|
||||
'.cache',
|
||||
'chromium',
|
||||
'chrome-mac',
|
||||
'Chromium.app',
|
||||
'Contents',
|
||||
'MacOS',
|
||||
'Chromium'
|
||||
];
|
||||
|
||||
function expandVersionedArchives(rootDir = join(__dirname, '..')) {
|
||||
const base = join(rootDir, '.cache', 'chromium');
|
||||
if (!existsSync(base)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const nestedCandidates = [];
|
||||
for (const entry of readdirSync(base)) {
|
||||
const nestedRoot = join(base, entry);
|
||||
try {
|
||||
if (!statSync(nestedRoot).isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
nestedCandidates.push(
|
||||
join(nestedRoot, 'chrome-linux64', 'chrome'),
|
||||
join(nestedRoot, 'chrome-win64', 'chrome.exe'),
|
||||
join(nestedRoot, 'chrome-mac', 'Chromium.app', 'Contents', 'MacOS', 'Chromium')
|
||||
);
|
||||
}
|
||||
|
||||
return nestedCandidates;
|
||||
}
|
||||
|
||||
function expandNestedArchives(rootDir = join(__dirname, '..')) {
|
||||
const base = join(rootDir, '.cache', 'chromium');
|
||||
if (!existsSync(base)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const maxDepth = 4;
|
||||
const queue = [{ dir: base, depth: 0 }];
|
||||
const candidates = [];
|
||||
|
||||
while (queue.length) {
|
||||
const { dir, depth } = queue.shift();
|
||||
let entries;
|
||||
try {
|
||||
entries = readdirSync(dir);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const nested = join(dir, entry);
|
||||
let stats;
|
||||
try {
|
||||
stats = statSync(nested);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!stats.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
candidates.push(
|
||||
join(nested, 'chrome-linux64', 'chrome'),
|
||||
join(nested, 'chrome-win64', 'chrome.exe'),
|
||||
join(nested, 'chrome-mac', 'Chromium.app', 'Contents', 'MacOS', 'Chromium')
|
||||
);
|
||||
|
||||
if (depth + 1 <= maxDepth) {
|
||||
queue.push({ dir: nested, depth: depth + 1 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function candidatePaths(rootDir = join(__dirname, '..')) {
|
||||
const { env } = process;
|
||||
const baseCandidates = [
|
||||
env.STELLAOPS_CHROMIUM_BIN,
|
||||
env.CHROME_BIN,
|
||||
env.PUPPETEER_EXECUTABLE_PATH,
|
||||
'/usr/bin/chromium-browser',
|
||||
'/usr/bin/chromium',
|
||||
'/usr/bin/google-chrome',
|
||||
'/usr/bin/google-chrome-stable',
|
||||
join(rootDir, ...linuxArchivePath),
|
||||
join(rootDir, ...windowsArchivePath),
|
||||
join(rootDir, ...macArchivePath),
|
||||
...expandVersionedArchives(rootDir),
|
||||
...expandNestedArchives(rootDir)
|
||||
];
|
||||
|
||||
const seen = new Set();
|
||||
return baseCandidates
|
||||
.filter(Boolean)
|
||||
.filter((candidate) => {
|
||||
if (seen.has(candidate)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
seen.add(candidate);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function resolveChromeBinary(rootDir = join(__dirname, '..')) {
|
||||
for (const candidate of candidatePaths(rootDir)) {
|
||||
if (existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
candidatePaths,
|
||||
resolveChromeBinary
|
||||
};
|
||||
24
src/Web/StellaOps.Web/scripts/verify-chromium.js
Normal file
24
src/Web/StellaOps.Web/scripts/verify-chromium.js
Normal file
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const { resolveChromeBinary, candidatePaths } = require('./chrome-path');
|
||||
const { join } = require('path');
|
||||
|
||||
const projectRoot = join(__dirname, '..');
|
||||
const resolved = resolveChromeBinary(projectRoot);
|
||||
|
||||
if (resolved) {
|
||||
console.log(`Chromium binary detected: ${resolved}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.error('Chromium binary not found.');
|
||||
console.error('Checked locations:');
|
||||
for (const candidate of candidatePaths(projectRoot)) {
|
||||
console.error(` - ${candidate}`);
|
||||
}
|
||||
console.error('');
|
||||
console.error(
|
||||
'Ensure Google Chrome/Chromium is available on PATH, set CHROME_BIN/STELLAOPS_CHROMIUM_BIN, or drop an offline Chromium build under .cache/chromium/.'
|
||||
);
|
||||
console.error('See docs/DeterministicInstall.md for detailed guidance.');
|
||||
process.exit(1);
|
||||
46
src/Web/StellaOps.Web/src/app/app.component.html
Normal file
46
src/Web/StellaOps.Web/src/app/app.component.html
Normal file
@@ -0,0 +1,46 @@
|
||||
<div class="app-shell">
|
||||
<header class="app-header">
|
||||
<div class="app-brand">StellaOps Dashboard</div>
|
||||
<nav class="app-nav">
|
||||
<a routerLink="/console/profile" routerLinkActive="active">
|
||||
Console Profile
|
||||
</a>
|
||||
<a routerLink="/concelier/trivy-db-settings" routerLinkActive="active">
|
||||
Trivy DB Export
|
||||
</a>
|
||||
<a routerLink="/scans/scan-verified-001" routerLinkActive="active">
|
||||
Scan Detail
|
||||
</a>
|
||||
<a routerLink="/notify" routerLinkActive="active">
|
||||
Notify
|
||||
</a>
|
||||
</nav>
|
||||
<div class="app-auth">
|
||||
<ng-container *ngIf="isAuthenticated(); else signIn">
|
||||
<span class="app-user" aria-live="polite">{{ displayName() }}</span>
|
||||
<span class="app-tenant" *ngIf="activeTenant() as tenant">
|
||||
Tenant: <strong>{{ tenant }}</strong>
|
||||
</span>
|
||||
<span
|
||||
class="app-fresh"
|
||||
*ngIf="freshAuthSummary() as fresh"
|
||||
[class.app-fresh--active]="fresh.active"
|
||||
[class.app-fresh--stale]="!fresh.active"
|
||||
>
|
||||
Fresh auth: {{ fresh.active ? 'Active' : 'Stale' }}
|
||||
<ng-container *ngIf="fresh.expiresAt">
|
||||
(expires {{ fresh.expiresAt | date: 'shortTime' }})
|
||||
</ng-container>
|
||||
</span>
|
||||
<button type="button" (click)="onSignOut()">Sign out</button>
|
||||
</ng-container>
|
||||
<ng-template #signIn>
|
||||
<button type="button" (click)="onSignIn()">Sign in</button>
|
||||
</ng-template>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="app-content">
|
||||
<router-outlet />
|
||||
</main>
|
||||
</div>
|
||||
112
src/Web/StellaOps.Web/src/app/app.component.scss
Normal file
112
src/Web/StellaOps.Web/src/app/app.component.scss
Normal file
@@ -0,0 +1,112 @@
|
||||
:host {
|
||||
display: block;
|
||||
min-height: 100vh;
|
||||
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI',
|
||||
sans-serif;
|
||||
color: #0f172a;
|
||||
background-color: #f8fafc;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.5rem;
|
||||
background: linear-gradient(90deg, #0f172a 0%, #1e293b 45%, #4328b7 100%);
|
||||
color: #f8fafc;
|
||||
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.2);
|
||||
}
|
||||
|
||||
.app-brand {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.app-nav {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
|
||||
a {
|
||||
color: rgba(248, 250, 252, 0.8);
|
||||
text-decoration: none;
|
||||
font-size: 0.95rem;
|
||||
padding: 0.35rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
transition: background-color 0.2s ease, color 0.2s ease;
|
||||
|
||||
&.active,
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
color: #0f172a;
|
||||
background-color: rgba(248, 250, 252, 0.9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.app-auth {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
|
||||
.app-user {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
button {
|
||||
appearance: none;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
padding: 0.35rem 0.9rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
color: #0f172a;
|
||||
background-color: rgba(248, 250, 252, 0.9);
|
||||
transition: transform 0.2s ease, background-color 0.2s ease;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background-color: #facc15;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
.app-tenant {
|
||||
font-size: 0.8rem;
|
||||
color: rgba(248, 250, 252, 0.8);
|
||||
}
|
||||
|
||||
.app-fresh {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
background-color: rgba(20, 184, 166, 0.16);
|
||||
color: #0f766e;
|
||||
|
||||
&.app-fresh--stale {
|
||||
background-color: rgba(249, 115, 22, 0.16);
|
||||
color: #c2410c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.app-content {
|
||||
flex: 1;
|
||||
padding: 2rem 1.5rem;
|
||||
max-width: 960px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
35
src/Web/StellaOps.Web/src/app/app.component.spec.ts
Normal file
35
src/Web/StellaOps.Web/src/app/app.component.spec.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { AppComponent } from './app.component';
|
||||
import { AuthorityAuthService } from './core/auth/authority-auth.service';
|
||||
import { AuthSessionStore } from './core/auth/auth-session.store';
|
||||
|
||||
class AuthorityAuthServiceStub {
|
||||
beginLogin = jasmine.createSpy('beginLogin');
|
||||
logout = jasmine.createSpy('logout');
|
||||
}
|
||||
|
||||
describe('AppComponent', () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AppComponent, RouterTestingModule],
|
||||
providers: [
|
||||
AuthSessionStore,
|
||||
{ provide: AuthorityAuthService, useClass: AuthorityAuthServiceStub },
|
||||
],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
it('creates the root component', () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
const app = fixture.componentInstance;
|
||||
expect(app).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders a router outlet for child routes', () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
expect(compiled.querySelector('router-outlet')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
64
src/Web/StellaOps.Web/src/app/app.component.ts
Normal file
64
src/Web/StellaOps.Web/src/app/app.component.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
} from '@angular/core';
|
||||
import { Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
|
||||
|
||||
import { AuthorityAuthService } from './core/auth/authority-auth.service';
|
||||
import { AuthSessionStore } from './core/auth/auth-session.store';
|
||||
import { ConsoleSessionStore } from './core/console/console-session.store';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterOutlet, RouterLink, RouterLinkActive],
|
||||
templateUrl: './app.component.html',
|
||||
styleUrl: './app.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class AppComponent {
|
||||
private readonly router = inject(Router);
|
||||
private readonly auth = inject(AuthorityAuthService);
|
||||
private readonly sessionStore = inject(AuthSessionStore);
|
||||
private readonly consoleStore = inject(ConsoleSessionStore);
|
||||
|
||||
readonly status = this.sessionStore.status;
|
||||
readonly identity = this.sessionStore.identity;
|
||||
readonly subjectHint = this.sessionStore.subjectHint;
|
||||
readonly isAuthenticated = this.sessionStore.isAuthenticated;
|
||||
readonly activeTenant = this.consoleStore.selectedTenantId;
|
||||
readonly freshAuthSummary = computed(() => {
|
||||
const token = this.consoleStore.tokenInfo();
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
active: token.freshAuthActive,
|
||||
expiresAt: token.freshAuthExpiresAt,
|
||||
};
|
||||
});
|
||||
|
||||
readonly displayName = computed(() => {
|
||||
const identity = this.identity();
|
||||
if (identity?.name) {
|
||||
return identity.name;
|
||||
}
|
||||
if (identity?.email) {
|
||||
return identity.email;
|
||||
}
|
||||
const hint = this.subjectHint();
|
||||
return hint ?? 'anonymous';
|
||||
});
|
||||
|
||||
onSignIn(): void {
|
||||
const returnUrl = this.router.url === '/' ? undefined : this.router.url;
|
||||
void this.auth.beginLogin(returnUrl);
|
||||
}
|
||||
|
||||
onSignOut(): void {
|
||||
void this.auth.logout();
|
||||
}
|
||||
}
|
||||
81
src/Web/StellaOps.Web/src/app/app.config.ts
Normal file
81
src/Web/StellaOps.Web/src/app/app.config.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
|
||||
import { APP_INITIALIZER, ApplicationConfig } from '@angular/core';
|
||||
import { provideRouter } from '@angular/router';
|
||||
|
||||
import { routes } from './app.routes';
|
||||
import { CONCELIER_EXPORTER_API_BASE_URL } from './core/api/concelier-exporter.client';
|
||||
import {
|
||||
AUTHORITY_CONSOLE_API,
|
||||
AUTHORITY_CONSOLE_API_BASE_URL,
|
||||
AuthorityConsoleApiHttpClient,
|
||||
} from './core/api/authority-console.client';
|
||||
import {
|
||||
NOTIFY_API,
|
||||
NOTIFY_API_BASE_URL,
|
||||
NOTIFY_TENANT_ID,
|
||||
} from './core/api/notify.client';
|
||||
import { AppConfigService } from './core/config/app-config.service';
|
||||
import { AuthHttpInterceptor } from './core/auth/auth-http.interceptor';
|
||||
import { OperatorMetadataInterceptor } from './core/orchestrator/operator-metadata.interceptor';
|
||||
import { MockNotifyApiService } from './testing/mock-notify-api.service';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideRouter(routes),
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
{
|
||||
provide: APP_INITIALIZER,
|
||||
multi: true,
|
||||
useFactory: (configService: AppConfigService) => () =>
|
||||
configService.load(),
|
||||
deps: [AppConfigService],
|
||||
},
|
||||
{
|
||||
provide: HTTP_INTERCEPTORS,
|
||||
useClass: AuthHttpInterceptor,
|
||||
multi: true,
|
||||
},
|
||||
{
|
||||
provide: HTTP_INTERCEPTORS,
|
||||
useClass: OperatorMetadataInterceptor,
|
||||
multi: true,
|
||||
},
|
||||
{
|
||||
provide: CONCELIER_EXPORTER_API_BASE_URL,
|
||||
useValue: '/api/v1/concelier/exporters/trivy-db',
|
||||
},
|
||||
{
|
||||
provide: AUTHORITY_CONSOLE_API_BASE_URL,
|
||||
deps: [AppConfigService],
|
||||
useFactory: (config: AppConfigService) => {
|
||||
const authorityBase = config.config.apiBaseUrls.authority;
|
||||
try {
|
||||
return new URL('/console', authorityBase).toString();
|
||||
} catch {
|
||||
const normalized = authorityBase.endsWith('/')
|
||||
? authorityBase.slice(0, -1)
|
||||
: authorityBase;
|
||||
return `${normalized}/console`;
|
||||
}
|
||||
},
|
||||
},
|
||||
AuthorityConsoleApiHttpClient,
|
||||
{
|
||||
provide: AUTHORITY_CONSOLE_API,
|
||||
useExisting: AuthorityConsoleApiHttpClient,
|
||||
},
|
||||
{
|
||||
provide: NOTIFY_API_BASE_URL,
|
||||
useValue: '/api/v1/notify',
|
||||
},
|
||||
{
|
||||
provide: NOTIFY_TENANT_ID,
|
||||
useValue: 'tenant-dev',
|
||||
},
|
||||
MockNotifyApiService,
|
||||
{
|
||||
provide: NOTIFY_API,
|
||||
useExisting: MockNotifyApiService,
|
||||
},
|
||||
],
|
||||
};
|
||||
48
src/Web/StellaOps.Web/src/app/app.routes.ts
Normal file
48
src/Web/StellaOps.Web/src/app/app.routes.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
path: 'console/profile',
|
||||
loadComponent: () =>
|
||||
import('./features/console/console-profile.component').then(
|
||||
(m) => m.ConsoleProfileComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'concelier/trivy-db-settings',
|
||||
loadComponent: () =>
|
||||
import('./features/trivy-db-settings/trivy-db-settings-page.component').then(
|
||||
(m) => m.TrivyDbSettingsPageComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'scans/:scanId',
|
||||
loadComponent: () =>
|
||||
import('./features/scans/scan-detail-page.component').then(
|
||||
(m) => m.ScanDetailPageComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'notify',
|
||||
loadComponent: () =>
|
||||
import('./features/notify/notify-panel.component').then(
|
||||
(m) => m.NotifyPanelComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'auth/callback',
|
||||
loadComponent: () =>
|
||||
import('./features/auth/auth-callback.component').then(
|
||||
(m) => m.AuthCallbackComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '',
|
||||
pathMatch: 'full',
|
||||
redirectTo: 'console/profile',
|
||||
},
|
||||
{
|
||||
path: '**',
|
||||
redirectTo: 'console/profile',
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,113 @@
|
||||
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
||||
import { Inject, Injectable, InjectionToken } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
|
||||
export interface AuthorityTenantViewDto {
|
||||
readonly id: string;
|
||||
readonly displayName: string;
|
||||
readonly status: string;
|
||||
readonly isolationMode: string;
|
||||
readonly defaultRoles: readonly string[];
|
||||
}
|
||||
|
||||
export interface TenantCatalogResponseDto {
|
||||
readonly tenants: readonly AuthorityTenantViewDto[];
|
||||
}
|
||||
|
||||
export interface ConsoleProfileDto {
|
||||
readonly subjectId: string | null;
|
||||
readonly username: string | null;
|
||||
readonly displayName: string | null;
|
||||
readonly tenant: string;
|
||||
readonly sessionId: string | null;
|
||||
readonly roles: readonly string[];
|
||||
readonly scopes: readonly string[];
|
||||
readonly audiences: readonly string[];
|
||||
readonly authenticationMethods: readonly string[];
|
||||
readonly issuedAt: string | null;
|
||||
readonly authenticationTime: string | null;
|
||||
readonly expiresAt: string | null;
|
||||
readonly freshAuth: boolean;
|
||||
}
|
||||
|
||||
export interface ConsoleTokenIntrospectionDto {
|
||||
readonly active: boolean;
|
||||
readonly tenant: string;
|
||||
readonly subject: string | null;
|
||||
readonly clientId: string | null;
|
||||
readonly tokenId: string | null;
|
||||
readonly scopes: readonly string[];
|
||||
readonly audiences: readonly string[];
|
||||
readonly issuedAt: string | null;
|
||||
readonly authenticationTime: string | null;
|
||||
readonly expiresAt: string | null;
|
||||
readonly freshAuth: boolean;
|
||||
}
|
||||
|
||||
export interface AuthorityConsoleApi {
|
||||
listTenants(tenantId?: string): Observable<TenantCatalogResponseDto>;
|
||||
getProfile(tenantId?: string): Observable<ConsoleProfileDto>;
|
||||
introspectToken(
|
||||
tenantId?: string
|
||||
): Observable<ConsoleTokenIntrospectionDto>;
|
||||
}
|
||||
|
||||
export const AUTHORITY_CONSOLE_API = new InjectionToken<AuthorityConsoleApi>(
|
||||
'AUTHORITY_CONSOLE_API'
|
||||
);
|
||||
|
||||
export const AUTHORITY_CONSOLE_API_BASE_URL = new InjectionToken<string>(
|
||||
'AUTHORITY_CONSOLE_API_BASE_URL'
|
||||
);
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class AuthorityConsoleApiHttpClient implements AuthorityConsoleApi {
|
||||
constructor(
|
||||
private readonly http: HttpClient,
|
||||
@Inject(AUTHORITY_CONSOLE_API_BASE_URL) private readonly baseUrl: string,
|
||||
private readonly authSession: AuthSessionStore
|
||||
) {}
|
||||
|
||||
listTenants(tenantId?: string): Observable<TenantCatalogResponseDto> {
|
||||
return this.http.get<TenantCatalogResponseDto>(`${this.baseUrl}/tenants`, {
|
||||
headers: this.buildHeaders(tenantId),
|
||||
});
|
||||
}
|
||||
|
||||
getProfile(tenantId?: string): Observable<ConsoleProfileDto> {
|
||||
return this.http.get<ConsoleProfileDto>(`${this.baseUrl}/profile`, {
|
||||
headers: this.buildHeaders(tenantId),
|
||||
});
|
||||
}
|
||||
|
||||
introspectToken(
|
||||
tenantId?: string
|
||||
): Observable<ConsoleTokenIntrospectionDto> {
|
||||
return this.http.post<ConsoleTokenIntrospectionDto>(
|
||||
`${this.baseUrl}/token/introspect`,
|
||||
{},
|
||||
{
|
||||
headers: this.buildHeaders(tenantId),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private buildHeaders(tenantOverride?: string): HttpHeaders {
|
||||
const tenantId =
|
||||
(tenantOverride && tenantOverride.trim()) ||
|
||||
this.authSession.getActiveTenantId();
|
||||
if (!tenantId) {
|
||||
throw new Error(
|
||||
'AuthorityConsoleApiHttpClient requires an active tenant identifier.'
|
||||
);
|
||||
}
|
||||
|
||||
return new HttpHeaders({
|
||||
'X-StellaOps-Tenant': tenantId,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import {
|
||||
Injectable,
|
||||
InjectionToken,
|
||||
inject,
|
||||
} from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
export interface TrivyDbSettingsDto {
|
||||
publishFull: boolean;
|
||||
publishDelta: boolean;
|
||||
includeFull: boolean;
|
||||
includeDelta: boolean;
|
||||
}
|
||||
|
||||
export interface TrivyDbRunResponseDto {
|
||||
exportId: string;
|
||||
triggeredAt: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export const CONCELIER_EXPORTER_API_BASE_URL = new InjectionToken<string>(
|
||||
'CONCELIER_EXPORTER_API_BASE_URL'
|
||||
);
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ConcelierExporterClient {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly baseUrl = inject(CONCELIER_EXPORTER_API_BASE_URL);
|
||||
|
||||
getTrivyDbSettings(): Observable<TrivyDbSettingsDto> {
|
||||
return this.http.get<TrivyDbSettingsDto>(`${this.baseUrl}/settings`);
|
||||
}
|
||||
|
||||
updateTrivyDbSettings(
|
||||
settings: TrivyDbSettingsDto
|
||||
): Observable<TrivyDbSettingsDto> {
|
||||
return this.http.put<TrivyDbSettingsDto>(`${this.baseUrl}/settings`, settings);
|
||||
}
|
||||
|
||||
runTrivyDbExport(
|
||||
settings: TrivyDbSettingsDto
|
||||
): Observable<TrivyDbRunResponseDto> {
|
||||
return this.http.post<TrivyDbRunResponseDto>(`${this.baseUrl}/run`, {
|
||||
trigger: 'ui',
|
||||
parameters: settings,
|
||||
});
|
||||
}
|
||||
}
|
||||
142
src/Web/StellaOps.Web/src/app/core/api/notify.client.ts
Normal file
142
src/Web/StellaOps.Web/src/app/core/api/notify.client.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
|
||||
import {
|
||||
Inject,
|
||||
Injectable,
|
||||
InjectionToken,
|
||||
Optional,
|
||||
} from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import {
|
||||
ChannelHealthResponse,
|
||||
ChannelTestSendRequest,
|
||||
ChannelTestSendResponse,
|
||||
NotifyChannel,
|
||||
NotifyDeliveriesQueryOptions,
|
||||
NotifyDeliveriesResponse,
|
||||
NotifyRule,
|
||||
} from './notify.models';
|
||||
|
||||
export interface NotifyApi {
|
||||
listChannels(): Observable<NotifyChannel[]>;
|
||||
saveChannel(channel: NotifyChannel): Observable<NotifyChannel>;
|
||||
deleteChannel(channelId: string): Observable<void>;
|
||||
getChannelHealth(channelId: string): Observable<ChannelHealthResponse>;
|
||||
testChannel(
|
||||
channelId: string,
|
||||
payload: ChannelTestSendRequest
|
||||
): Observable<ChannelTestSendResponse>;
|
||||
listRules(): Observable<NotifyRule[]>;
|
||||
saveRule(rule: NotifyRule): Observable<NotifyRule>;
|
||||
deleteRule(ruleId: string): Observable<void>;
|
||||
listDeliveries(
|
||||
options?: NotifyDeliveriesQueryOptions
|
||||
): Observable<NotifyDeliveriesResponse>;
|
||||
}
|
||||
|
||||
export const NOTIFY_API = new InjectionToken<NotifyApi>('NOTIFY_API');
|
||||
|
||||
export const NOTIFY_API_BASE_URL = new InjectionToken<string>(
|
||||
'NOTIFY_API_BASE_URL'
|
||||
);
|
||||
|
||||
export const NOTIFY_TENANT_ID = new InjectionToken<string>('NOTIFY_TENANT_ID');
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class NotifyApiHttpClient implements NotifyApi {
|
||||
constructor(
|
||||
private readonly http: HttpClient,
|
||||
@Inject(NOTIFY_API_BASE_URL) private readonly baseUrl: string,
|
||||
@Optional() @Inject(NOTIFY_TENANT_ID) private readonly tenantId: string | null
|
||||
) {}
|
||||
|
||||
listChannels(): Observable<NotifyChannel[]> {
|
||||
return this.http.get<NotifyChannel[]>(`${this.baseUrl}/channels`, {
|
||||
headers: this.buildHeaders(),
|
||||
});
|
||||
}
|
||||
|
||||
saveChannel(channel: NotifyChannel): Observable<NotifyChannel> {
|
||||
return this.http.post<NotifyChannel>(`${this.baseUrl}/channels`, channel, {
|
||||
headers: this.buildHeaders(),
|
||||
});
|
||||
}
|
||||
|
||||
deleteChannel(channelId: string): Observable<void> {
|
||||
return this.http.delete<void>(`${this.baseUrl}/channels/${channelId}`, {
|
||||
headers: this.buildHeaders(),
|
||||
});
|
||||
}
|
||||
|
||||
getChannelHealth(channelId: string): Observable<ChannelHealthResponse> {
|
||||
return this.http.get<ChannelHealthResponse>(
|
||||
`${this.baseUrl}/channels/${channelId}/health`,
|
||||
{
|
||||
headers: this.buildHeaders(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
testChannel(
|
||||
channelId: string,
|
||||
payload: ChannelTestSendRequest
|
||||
): Observable<ChannelTestSendResponse> {
|
||||
return this.http.post<ChannelTestSendResponse>(
|
||||
`${this.baseUrl}/channels/${channelId}/test`,
|
||||
payload,
|
||||
{
|
||||
headers: this.buildHeaders(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
listRules(): Observable<NotifyRule[]> {
|
||||
return this.http.get<NotifyRule[]>(`${this.baseUrl}/rules`, {
|
||||
headers: this.buildHeaders(),
|
||||
});
|
||||
}
|
||||
|
||||
saveRule(rule: NotifyRule): Observable<NotifyRule> {
|
||||
return this.http.post<NotifyRule>(`${this.baseUrl}/rules`, rule, {
|
||||
headers: this.buildHeaders(),
|
||||
});
|
||||
}
|
||||
|
||||
deleteRule(ruleId: string): Observable<void> {
|
||||
return this.http.delete<void>(`${this.baseUrl}/rules/${ruleId}`, {
|
||||
headers: this.buildHeaders(),
|
||||
});
|
||||
}
|
||||
|
||||
listDeliveries(
|
||||
options?: NotifyDeliveriesQueryOptions
|
||||
): Observable<NotifyDeliveriesResponse> {
|
||||
let params = new HttpParams();
|
||||
if (options?.status) {
|
||||
params = params.set('status', options.status);
|
||||
}
|
||||
if (options?.since) {
|
||||
params = params.set('since', options.since);
|
||||
}
|
||||
if (options?.limit) {
|
||||
params = params.set('limit', options.limit);
|
||||
}
|
||||
if (options?.continuationToken) {
|
||||
params = params.set('continuationToken', options.continuationToken);
|
||||
}
|
||||
|
||||
return this.http.get<NotifyDeliveriesResponse>(`${this.baseUrl}/deliveries`, {
|
||||
headers: this.buildHeaders(),
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
private buildHeaders(): HttpHeaders {
|
||||
if (!this.tenantId) {
|
||||
return new HttpHeaders();
|
||||
}
|
||||
|
||||
return new HttpHeaders({ 'X-StellaOps-Tenant': this.tenantId });
|
||||
}
|
||||
}
|
||||
|
||||
194
src/Web/StellaOps.Web/src/app/core/api/notify.models.ts
Normal file
194
src/Web/StellaOps.Web/src/app/core/api/notify.models.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
export type NotifyChannelType =
|
||||
| 'Slack'
|
||||
| 'Teams'
|
||||
| 'Email'
|
||||
| 'Webhook'
|
||||
| 'Custom';
|
||||
|
||||
export type ChannelHealthStatus = 'Healthy' | 'Degraded' | 'Unhealthy';
|
||||
|
||||
export type NotifyDeliveryStatus =
|
||||
| 'Pending'
|
||||
| 'Sent'
|
||||
| 'Failed'
|
||||
| 'Throttled'
|
||||
| 'Digested'
|
||||
| 'Dropped';
|
||||
|
||||
export type NotifyDeliveryAttemptStatus =
|
||||
| 'Enqueued'
|
||||
| 'Sending'
|
||||
| 'Succeeded'
|
||||
| 'Failed'
|
||||
| 'Throttled'
|
||||
| 'Skipped';
|
||||
|
||||
export type NotifyDeliveryFormat =
|
||||
| 'Slack'
|
||||
| 'Teams'
|
||||
| 'Email'
|
||||
| 'Webhook'
|
||||
| 'Json';
|
||||
|
||||
export interface NotifyChannelLimits {
|
||||
readonly concurrency?: number | null;
|
||||
readonly requestsPerMinute?: number | null;
|
||||
readonly timeout?: string | null;
|
||||
readonly maxBatchSize?: number | null;
|
||||
}
|
||||
|
||||
export interface NotifyChannelConfig {
|
||||
readonly secretRef: string;
|
||||
readonly target?: string;
|
||||
readonly endpoint?: string;
|
||||
readonly properties?: Record<string, string>;
|
||||
readonly limits?: NotifyChannelLimits | null;
|
||||
}
|
||||
|
||||
export interface NotifyChannel {
|
||||
readonly schemaVersion?: string;
|
||||
readonly channelId: string;
|
||||
readonly tenantId: string;
|
||||
readonly name: string;
|
||||
readonly displayName?: string;
|
||||
readonly description?: string;
|
||||
readonly type: NotifyChannelType;
|
||||
readonly enabled: boolean;
|
||||
readonly config: NotifyChannelConfig;
|
||||
readonly labels?: Record<string, string>;
|
||||
readonly metadata?: Record<string, string>;
|
||||
readonly createdBy?: string;
|
||||
readonly createdAt?: string;
|
||||
readonly updatedBy?: string;
|
||||
readonly updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface NotifyRuleMatchVex {
|
||||
readonly includeAcceptedJustifications?: boolean;
|
||||
readonly includeRejectedJustifications?: boolean;
|
||||
readonly includeUnknownJustifications?: boolean;
|
||||
readonly justificationKinds?: readonly string[];
|
||||
}
|
||||
|
||||
export interface NotifyRuleMatch {
|
||||
readonly eventKinds?: readonly string[];
|
||||
readonly namespaces?: readonly string[];
|
||||
readonly repositories?: readonly string[];
|
||||
readonly digests?: readonly string[];
|
||||
readonly labels?: readonly string[];
|
||||
readonly componentPurls?: readonly string[];
|
||||
readonly minSeverity?: string | null;
|
||||
readonly verdicts?: readonly string[];
|
||||
readonly kevOnly?: boolean | null;
|
||||
readonly vex?: NotifyRuleMatchVex | null;
|
||||
}
|
||||
|
||||
export interface NotifyRuleAction {
|
||||
readonly actionId: string;
|
||||
readonly channel: string;
|
||||
readonly template?: string;
|
||||
readonly digest?: string;
|
||||
readonly throttle?: string | null;
|
||||
readonly locale?: string;
|
||||
readonly enabled: boolean;
|
||||
readonly metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface NotifyRule {
|
||||
readonly schemaVersion?: string;
|
||||
readonly ruleId: string;
|
||||
readonly tenantId: string;
|
||||
readonly name: string;
|
||||
readonly description?: string;
|
||||
readonly enabled: boolean;
|
||||
readonly match: NotifyRuleMatch;
|
||||
readonly actions: readonly NotifyRuleAction[];
|
||||
readonly labels?: Record<string, string>;
|
||||
readonly metadata?: Record<string, string>;
|
||||
readonly createdBy?: string;
|
||||
readonly createdAt?: string;
|
||||
readonly updatedBy?: string;
|
||||
readonly updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface NotifyDeliveryAttempt {
|
||||
readonly timestamp: string;
|
||||
readonly status: NotifyDeliveryAttemptStatus;
|
||||
readonly statusCode?: number;
|
||||
readonly reason?: string;
|
||||
}
|
||||
|
||||
export interface NotifyDeliveryRendered {
|
||||
readonly channelType: NotifyChannelType;
|
||||
readonly format: NotifyDeliveryFormat;
|
||||
readonly target: string;
|
||||
readonly title: string;
|
||||
readonly body: string;
|
||||
readonly summary?: string;
|
||||
readonly textBody?: string;
|
||||
readonly locale?: string;
|
||||
readonly bodyHash?: string;
|
||||
readonly attachments?: readonly string[];
|
||||
}
|
||||
|
||||
export interface NotifyDelivery {
|
||||
readonly deliveryId: string;
|
||||
readonly tenantId: string;
|
||||
readonly ruleId: string;
|
||||
readonly actionId: string;
|
||||
readonly eventId: string;
|
||||
readonly kind: string;
|
||||
readonly status: NotifyDeliveryStatus;
|
||||
readonly statusReason?: string;
|
||||
readonly rendered?: NotifyDeliveryRendered;
|
||||
readonly attempts?: readonly NotifyDeliveryAttempt[];
|
||||
readonly metadata?: Record<string, string>;
|
||||
readonly createdAt: string;
|
||||
readonly sentAt?: string;
|
||||
readonly completedAt?: string;
|
||||
}
|
||||
|
||||
export interface NotifyDeliveriesQueryOptions {
|
||||
readonly status?: NotifyDeliveryStatus;
|
||||
readonly since?: string;
|
||||
readonly limit?: number;
|
||||
readonly continuationToken?: string;
|
||||
}
|
||||
|
||||
export interface NotifyDeliveriesResponse {
|
||||
readonly items: readonly NotifyDelivery[];
|
||||
readonly continuationToken?: string | null;
|
||||
readonly count: number;
|
||||
}
|
||||
|
||||
export interface ChannelHealthResponse {
|
||||
readonly tenantId: string;
|
||||
readonly channelId: string;
|
||||
readonly status: ChannelHealthStatus;
|
||||
readonly message?: string | null;
|
||||
readonly checkedAt: string;
|
||||
readonly traceId: string;
|
||||
readonly metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ChannelTestSendRequest {
|
||||
readonly target?: string;
|
||||
readonly templateId?: string;
|
||||
readonly title?: string;
|
||||
readonly summary?: string;
|
||||
readonly body?: string;
|
||||
readonly textBody?: string;
|
||||
readonly locale?: string;
|
||||
readonly metadata?: Record<string, string>;
|
||||
readonly attachments?: readonly string[];
|
||||
}
|
||||
|
||||
export interface ChannelTestSendResponse {
|
||||
readonly tenantId: string;
|
||||
readonly channelId: string;
|
||||
readonly preview: NotifyDeliveryRendered;
|
||||
readonly queuedAt: string;
|
||||
readonly traceId: string;
|
||||
readonly metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
128
src/Web/StellaOps.Web/src/app/core/api/policy-preview.models.ts
Normal file
128
src/Web/StellaOps.Web/src/app/core/api/policy-preview.models.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
export interface PolicyPreviewRequestDto {
|
||||
imageDigest: string;
|
||||
findings: ReadonlyArray<PolicyPreviewFindingDto>;
|
||||
baseline?: ReadonlyArray<PolicyPreviewVerdictDto>;
|
||||
policy?: PolicyPreviewPolicyDto;
|
||||
}
|
||||
|
||||
export interface PolicyPreviewPolicyDto {
|
||||
content?: string;
|
||||
format?: string;
|
||||
actor?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface PolicyPreviewFindingDto {
|
||||
id: string;
|
||||
severity: string;
|
||||
environment?: string;
|
||||
source?: string;
|
||||
vendor?: string;
|
||||
license?: string;
|
||||
image?: string;
|
||||
repository?: string;
|
||||
package?: string;
|
||||
purl?: string;
|
||||
cve?: string;
|
||||
path?: string;
|
||||
layerDigest?: string;
|
||||
tags?: ReadonlyArray<string>;
|
||||
}
|
||||
|
||||
export interface PolicyPreviewVerdictDto {
|
||||
findingId: string;
|
||||
status: string;
|
||||
ruleName?: string | null;
|
||||
ruleAction?: string | null;
|
||||
notes?: string | null;
|
||||
score?: number | null;
|
||||
configVersion?: string | null;
|
||||
inputs?: Readonly<Record<string, number>>;
|
||||
quietedBy?: string | null;
|
||||
quiet?: boolean | null;
|
||||
unknownConfidence?: number | null;
|
||||
confidenceBand?: string | null;
|
||||
unknownAgeDays?: number | null;
|
||||
sourceTrust?: string | null;
|
||||
reachability?: string | null;
|
||||
}
|
||||
|
||||
export interface PolicyPreviewDiffDto {
|
||||
findingId: string;
|
||||
baseline: PolicyPreviewVerdictDto;
|
||||
projected: PolicyPreviewVerdictDto;
|
||||
changed: boolean;
|
||||
}
|
||||
|
||||
export interface PolicyPreviewIssueDto {
|
||||
code: string;
|
||||
message: string;
|
||||
severity: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface PolicyPreviewResponseDto {
|
||||
success: boolean;
|
||||
policyDigest: string;
|
||||
revisionId?: string | null;
|
||||
changed: number;
|
||||
diffs: ReadonlyArray<PolicyPreviewDiffDto>;
|
||||
issues: ReadonlyArray<PolicyPreviewIssueDto>;
|
||||
}
|
||||
|
||||
export interface PolicyPreviewSample {
|
||||
previewRequest: PolicyPreviewRequestDto;
|
||||
previewResponse: PolicyPreviewResponseDto;
|
||||
}
|
||||
|
||||
export interface PolicyReportRequestDto {
|
||||
imageDigest: string;
|
||||
findings: ReadonlyArray<PolicyPreviewFindingDto>;
|
||||
baseline?: ReadonlyArray<PolicyPreviewVerdictDto>;
|
||||
}
|
||||
|
||||
export interface PolicyReportResponseDto {
|
||||
report: PolicyReportDocumentDto;
|
||||
dsse?: DsseEnvelopeDto | null;
|
||||
}
|
||||
|
||||
export interface PolicyReportDocumentDto {
|
||||
reportId: string;
|
||||
imageDigest: string;
|
||||
generatedAt: string;
|
||||
verdict: string;
|
||||
policy: PolicyReportPolicyDto;
|
||||
summary: PolicyReportSummaryDto;
|
||||
verdicts: ReadonlyArray<PolicyPreviewVerdictDto>;
|
||||
issues: ReadonlyArray<PolicyPreviewIssueDto>;
|
||||
}
|
||||
|
||||
export interface PolicyReportPolicyDto {
|
||||
revisionId?: string | null;
|
||||
digest?: string | null;
|
||||
}
|
||||
|
||||
export interface PolicyReportSummaryDto {
|
||||
total: number;
|
||||
blocked: number;
|
||||
warned: number;
|
||||
ignored: number;
|
||||
quieted: number;
|
||||
}
|
||||
|
||||
export interface DsseEnvelopeDto {
|
||||
payloadType: string;
|
||||
payload: string;
|
||||
signatures: ReadonlyArray<DsseSignatureDto>;
|
||||
}
|
||||
|
||||
export interface DsseSignatureDto {
|
||||
keyId: string;
|
||||
algorithm: string;
|
||||
signature: string;
|
||||
}
|
||||
|
||||
export interface PolicyReportSample {
|
||||
reportRequest: PolicyReportRequestDto;
|
||||
reportResponse: PolicyReportResponseDto;
|
||||
}
|
||||
17
src/Web/StellaOps.Web/src/app/core/api/scanner.models.ts
Normal file
17
src/Web/StellaOps.Web/src/app/core/api/scanner.models.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export type ScanAttestationStatusKind = 'verified' | 'pending' | 'failed';
|
||||
|
||||
export interface ScanAttestationStatus {
|
||||
readonly uuid: string;
|
||||
readonly status: ScanAttestationStatusKind;
|
||||
readonly index?: number;
|
||||
readonly logUrl?: string;
|
||||
readonly checkedAt?: string;
|
||||
readonly statusMessage?: string;
|
||||
}
|
||||
|
||||
export interface ScanDetail {
|
||||
readonly scanId: string;
|
||||
readonly imageDigest: string;
|
||||
readonly completedAt: string;
|
||||
readonly attestation?: ScanAttestationStatus;
|
||||
}
|
||||
171
src/Web/StellaOps.Web/src/app/core/auth/auth-http.interceptor.ts
Normal file
171
src/Web/StellaOps.Web/src/app/core/auth/auth-http.interceptor.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import {
|
||||
HttpErrorResponse,
|
||||
HttpEvent,
|
||||
HttpHandler,
|
||||
HttpInterceptor,
|
||||
HttpRequest,
|
||||
} from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable, firstValueFrom, from, throwError } from 'rxjs';
|
||||
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';
|
||||
|
||||
const RETRY_HEADER = 'X-StellaOps-DPoP-Retry';
|
||||
|
||||
@Injectable()
|
||||
export class AuthHttpInterceptor implements HttpInterceptor {
|
||||
private excludedOrigins: Set<string> | null = null;
|
||||
private tokenEndpoint: string | null = null;
|
||||
private authorityResolved = false;
|
||||
|
||||
constructor(
|
||||
private readonly auth: AuthorityAuthService,
|
||||
private readonly config: AppConfigService,
|
||||
private readonly dpop: DpopService
|
||||
) {
|
||||
// lazy resolve authority configuration in intercept to allow APP_INITIALIZER to run first
|
||||
}
|
||||
|
||||
intercept(
|
||||
request: HttpRequest<unknown>,
|
||||
next: HttpHandler
|
||||
): Observable<HttpEvent<unknown>> {
|
||||
this.ensureAuthorityInfo();
|
||||
|
||||
if (request.headers.has('Authorization') || this.shouldSkip(request.url)) {
|
||||
return next.handle(request);
|
||||
}
|
||||
|
||||
return from(
|
||||
this.auth.getAuthHeadersForRequest(
|
||||
this.resolveAbsoluteUrl(request.url),
|
||||
request.method
|
||||
)
|
||||
).pipe(
|
||||
switchMap((headers) => {
|
||||
if (!headers) {
|
||||
return next.handle(request);
|
||||
}
|
||||
const authorizedRequest = request.clone({
|
||||
setHeaders: {
|
||||
Authorization: headers.authorization,
|
||||
DPoP: headers.dpop,
|
||||
},
|
||||
headers: request.headers.set(RETRY_HEADER, '0'),
|
||||
});
|
||||
return next.handle(authorizedRequest);
|
||||
}),
|
||||
catchError((error: HttpErrorResponse) =>
|
||||
this.handleError(request, error, next)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private handleError(
|
||||
request: HttpRequest<unknown>,
|
||||
error: HttpErrorResponse,
|
||||
next: HttpHandler
|
||||
): Observable<HttpEvent<unknown>> {
|
||||
if (error.status !== 401) {
|
||||
return throwError(() => error);
|
||||
}
|
||||
|
||||
const nonce = error.headers?.get('DPoP-Nonce');
|
||||
if (!nonce) {
|
||||
return throwError(() => error);
|
||||
}
|
||||
|
||||
if (request.headers.get(RETRY_HEADER) === '1') {
|
||||
return throwError(() => error);
|
||||
}
|
||||
|
||||
return from(this.retryWithNonce(request, nonce, next)).pipe(
|
||||
catchError(() => throwError(() => error))
|
||||
);
|
||||
}
|
||||
|
||||
private async retryWithNonce(
|
||||
request: HttpRequest<unknown>,
|
||||
nonce: string,
|
||||
next: HttpHandler
|
||||
): Promise<HttpEvent<unknown>> {
|
||||
await this.dpop.setNonce(nonce);
|
||||
const headers = await this.auth.getAuthHeadersForRequest(
|
||||
this.resolveAbsoluteUrl(request.url),
|
||||
request.method
|
||||
);
|
||||
if (!headers) {
|
||||
throw new Error('Unable to refresh authorization headers after nonce.');
|
||||
}
|
||||
|
||||
const retried = request.clone({
|
||||
setHeaders: {
|
||||
Authorization: headers.authorization,
|
||||
DPoP: headers.dpop,
|
||||
},
|
||||
headers: request.headers.set(RETRY_HEADER, '1'),
|
||||
});
|
||||
|
||||
return firstValueFrom(next.handle(retried));
|
||||
}
|
||||
|
||||
private shouldSkip(url: string): boolean {
|
||||
this.ensureAuthorityInfo();
|
||||
const absolute = this.resolveAbsoluteUrl(url);
|
||||
if (!absolute) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const resolved = new URL(absolute);
|
||||
if (resolved.pathname.endsWith('/config.json')) {
|
||||
return true;
|
||||
}
|
||||
if (this.tokenEndpoint && absolute.startsWith(this.tokenEndpoint)) {
|
||||
return true;
|
||||
}
|
||||
const origin = resolved.origin;
|
||||
return this.excludedOrigins?.has(origin) ?? false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private resolveAbsoluteUrl(url: string): string {
|
||||
try {
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
return url;
|
||||
}
|
||||
const base =
|
||||
typeof window !== 'undefined' && window.location
|
||||
? window.location.origin
|
||||
: undefined;
|
||||
return base ? new URL(url, base).toString() : url;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
private ensureAuthorityInfo(): void {
|
||||
if (this.authorityResolved) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const authority = this.config.authority;
|
||||
this.tokenEndpoint = new URL(
|
||||
authority.tokenEndpoint,
|
||||
authority.issuer
|
||||
).toString();
|
||||
this.excludedOrigins = new Set<string>([
|
||||
this.tokenEndpoint,
|
||||
new URL(authority.authorizeEndpoint, authority.issuer).origin,
|
||||
]);
|
||||
this.authorityResolved = true;
|
||||
} catch {
|
||||
// Configuration not yet loaded; interceptor will retry on the next request.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
export interface AuthTokens {
|
||||
readonly accessToken: string;
|
||||
readonly expiresAtEpochMs: number;
|
||||
readonly refreshToken?: string;
|
||||
readonly tokenType: 'Bearer';
|
||||
readonly scope: string;
|
||||
}
|
||||
|
||||
export interface AuthIdentity {
|
||||
readonly subject: string;
|
||||
readonly name?: string;
|
||||
readonly email?: string;
|
||||
readonly roles: readonly string[];
|
||||
readonly idToken?: string;
|
||||
}
|
||||
|
||||
export interface AuthSession {
|
||||
readonly tokens: AuthTokens;
|
||||
readonly identity: AuthIdentity;
|
||||
/**
|
||||
* SHA-256 JWK thumbprint of the active DPoP key pair.
|
||||
*/
|
||||
readonly dpopKeyThumbprint: string;
|
||||
readonly issuedAtEpochMs: number;
|
||||
readonly tenantId: string | null;
|
||||
readonly scopes: readonly string[];
|
||||
readonly audiences: readonly string[];
|
||||
readonly authenticationTimeEpochMs: number | null;
|
||||
readonly freshAuthActive: boolean;
|
||||
readonly freshAuthExpiresAtEpochMs: number | null;
|
||||
}
|
||||
|
||||
export interface PersistedSessionMetadata {
|
||||
readonly subject: string;
|
||||
readonly expiresAtEpochMs: number;
|
||||
readonly issuedAtEpochMs: number;
|
||||
readonly dpopKeyThumbprint: string;
|
||||
readonly tenantId?: string | null;
|
||||
}
|
||||
|
||||
export type AuthStatus =
|
||||
| 'unauthenticated'
|
||||
| 'authenticated'
|
||||
| 'refreshing'
|
||||
| 'loading';
|
||||
|
||||
export const ACCESS_TOKEN_REFRESH_THRESHOLD_MS = 60_000;
|
||||
|
||||
export const SESSION_STORAGE_KEY = 'stellaops.auth.session.info';
|
||||
|
||||
export type AuthErrorReason =
|
||||
| 'invalid_state'
|
||||
| 'token_exchange_failed'
|
||||
| 'refresh_failed'
|
||||
| 'dpop_generation_failed'
|
||||
| 'configuration_missing';
|
||||
@@ -0,0 +1,55 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { AuthSession, AuthTokens, SESSION_STORAGE_KEY } from './auth-session.model';
|
||||
import { AuthSessionStore } from './auth-session.store';
|
||||
|
||||
describe('AuthSessionStore', () => {
|
||||
let store: AuthSessionStore;
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [AuthSessionStore],
|
||||
});
|
||||
store = TestBed.inject(AuthSessionStore);
|
||||
});
|
||||
|
||||
it('persists minimal metadata when session is set', () => {
|
||||
const tokens: AuthTokens = {
|
||||
accessToken: 'token-abc',
|
||||
expiresAtEpochMs: Date.now() + 120_000,
|
||||
refreshToken: 'refresh-xyz',
|
||||
scope: 'openid ui.read',
|
||||
tokenType: 'Bearer',
|
||||
};
|
||||
|
||||
const session: AuthSession = {
|
||||
tokens,
|
||||
identity: {
|
||||
subject: 'user-123',
|
||||
name: 'Alex Operator',
|
||||
roles: ['ui.read'],
|
||||
},
|
||||
dpopKeyThumbprint: 'thumbprint-1',
|
||||
issuedAtEpochMs: Date.now(),
|
||||
tenantId: 'tenant-default',
|
||||
scopes: ['ui.read'],
|
||||
audiences: ['console'],
|
||||
authenticationTimeEpochMs: Date.now(),
|
||||
freshAuthActive: true,
|
||||
freshAuthExpiresAtEpochMs: Date.now() + 300_000,
|
||||
};
|
||||
|
||||
store.setSession(session);
|
||||
|
||||
const persisted = sessionStorage.getItem(SESSION_STORAGE_KEY);
|
||||
expect(persisted).toBeTruthy();
|
||||
const parsed = JSON.parse(persisted ?? '{}');
|
||||
expect(parsed.subject).toBe('user-123');
|
||||
expect(parsed.dpopKeyThumbprint).toBe('thumbprint-1');
|
||||
expect(parsed.tenantId).toBe('tenant-default');
|
||||
|
||||
store.clear();
|
||||
expect(sessionStorage.getItem(SESSION_STORAGE_KEY)).toBeNull();
|
||||
});
|
||||
});
|
||||
129
src/Web/StellaOps.Web/src/app/core/auth/auth-session.store.ts
Normal file
129
src/Web/StellaOps.Web/src/app/core/auth/auth-session.store.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { Injectable, computed, signal } from '@angular/core';
|
||||
|
||||
import {
|
||||
AuthSession,
|
||||
AuthStatus,
|
||||
PersistedSessionMetadata,
|
||||
SESSION_STORAGE_KEY,
|
||||
} from './auth-session.model';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class AuthSessionStore {
|
||||
private readonly sessionSignal = signal<AuthSession | null>(null);
|
||||
private readonly statusSignal = signal<AuthStatus>('unauthenticated');
|
||||
private readonly persistedSignal =
|
||||
signal<PersistedSessionMetadata | null>(this.readPersistedMetadata());
|
||||
|
||||
readonly session = computed(() => this.sessionSignal());
|
||||
readonly status = computed(() => this.statusSignal());
|
||||
|
||||
readonly identity = computed(() => this.sessionSignal()?.identity ?? null);
|
||||
readonly subjectHint = computed(
|
||||
() =>
|
||||
this.sessionSignal()?.identity.subject ??
|
||||
this.persistedSignal()?.subject ??
|
||||
null
|
||||
);
|
||||
|
||||
readonly expiresAtEpochMs = computed(
|
||||
() => this.sessionSignal()?.tokens.expiresAtEpochMs ?? null
|
||||
);
|
||||
|
||||
readonly isAuthenticated = computed(
|
||||
() => this.sessionSignal() !== null && this.statusSignal() !== 'loading'
|
||||
);
|
||||
|
||||
readonly tenantId = computed(
|
||||
() =>
|
||||
this.sessionSignal()?.tenantId ??
|
||||
this.persistedSignal()?.tenantId ??
|
||||
null
|
||||
);
|
||||
|
||||
setStatus(status: AuthStatus): void {
|
||||
this.statusSignal.set(status);
|
||||
}
|
||||
|
||||
setSession(session: AuthSession | null): void {
|
||||
this.sessionSignal.set(session);
|
||||
if (!session) {
|
||||
this.statusSignal.set('unauthenticated');
|
||||
this.persistedSignal.set(null);
|
||||
this.clearPersistedMetadata();
|
||||
return;
|
||||
}
|
||||
|
||||
this.statusSignal.set('authenticated');
|
||||
const metadata: PersistedSessionMetadata = {
|
||||
subject: session.identity.subject,
|
||||
expiresAtEpochMs: session.tokens.expiresAtEpochMs,
|
||||
issuedAtEpochMs: session.issuedAtEpochMs,
|
||||
dpopKeyThumbprint: session.dpopKeyThumbprint,
|
||||
tenantId: session.tenantId,
|
||||
};
|
||||
this.persistedSignal.set(metadata);
|
||||
this.persistMetadata(metadata);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.sessionSignal.set(null);
|
||||
this.statusSignal.set('unauthenticated');
|
||||
this.persistedSignal.set(null);
|
||||
this.clearPersistedMetadata();
|
||||
}
|
||||
|
||||
private readPersistedMetadata(): PersistedSessionMetadata | null {
|
||||
if (typeof sessionStorage === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = sessionStorage.getItem(SESSION_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const parsed = JSON.parse(raw) as PersistedSessionMetadata;
|
||||
if (
|
||||
typeof parsed.subject !== 'string' ||
|
||||
typeof parsed.expiresAtEpochMs !== 'number' ||
|
||||
typeof parsed.issuedAtEpochMs !== 'number' ||
|
||||
typeof parsed.dpopKeyThumbprint !== 'string'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const tenantId =
|
||||
typeof parsed.tenantId === 'string'
|
||||
? parsed.tenantId.trim() || null
|
||||
: null;
|
||||
return {
|
||||
subject: parsed.subject,
|
||||
expiresAtEpochMs: parsed.expiresAtEpochMs,
|
||||
issuedAtEpochMs: parsed.issuedAtEpochMs,
|
||||
dpopKeyThumbprint: parsed.dpopKeyThumbprint,
|
||||
tenantId,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private persistMetadata(metadata: PersistedSessionMetadata): void {
|
||||
if (typeof sessionStorage === 'undefined') {
|
||||
return;
|
||||
}
|
||||
sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(metadata));
|
||||
}
|
||||
|
||||
private clearPersistedMetadata(): void {
|
||||
if (typeof sessionStorage === 'undefined') {
|
||||
return;
|
||||
}
|
||||
sessionStorage.removeItem(SESSION_STORAGE_KEY);
|
||||
}
|
||||
|
||||
getActiveTenantId(): string | null {
|
||||
return this.tenantId();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
const LOGIN_REQUEST_KEY = 'stellaops.auth.login.request';
|
||||
|
||||
export interface PendingLoginRequest {
|
||||
readonly state: string;
|
||||
readonly codeVerifier: string;
|
||||
readonly createdAtEpochMs: number;
|
||||
readonly returnUrl?: string;
|
||||
readonly nonce?: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class AuthStorageService {
|
||||
savePendingLogin(request: PendingLoginRequest): void {
|
||||
if (typeof sessionStorage === 'undefined') {
|
||||
return;
|
||||
}
|
||||
sessionStorage.setItem(LOGIN_REQUEST_KEY, JSON.stringify(request));
|
||||
}
|
||||
|
||||
consumePendingLogin(expectedState: string): PendingLoginRequest | null {
|
||||
if (typeof sessionStorage === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const raw = sessionStorage.getItem(LOGIN_REQUEST_KEY);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
sessionStorage.removeItem(LOGIN_REQUEST_KEY);
|
||||
try {
|
||||
const request = JSON.parse(raw) as PendingLoginRequest;
|
||||
if (request.state !== expectedState) {
|
||||
return null;
|
||||
}
|
||||
return request;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,622 @@
|
||||
import { HttpClient, HttpHeaders, HttpResponse } from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
import { AppConfigService } from '../config/app-config.service';
|
||||
import { AuthorityConfig } from '../config/app-config.model';
|
||||
import { ConsoleSessionService } from '../console/console-session.service';
|
||||
import {
|
||||
ACCESS_TOKEN_REFRESH_THRESHOLD_MS,
|
||||
AuthErrorReason,
|
||||
AuthSession,
|
||||
AuthTokens,
|
||||
} from './auth-session.model';
|
||||
import { AuthSessionStore } from './auth-session.store';
|
||||
import {
|
||||
AuthStorageService,
|
||||
PendingLoginRequest,
|
||||
} from './auth-storage.service';
|
||||
import { DpopService } from './dpop/dpop.service';
|
||||
import { base64UrlDecode } from './dpop/jose-utilities';
|
||||
import { createPkcePair } from './pkce.util';
|
||||
|
||||
interface TokenResponse {
|
||||
readonly access_token: string;
|
||||
readonly token_type: string;
|
||||
readonly expires_in: number;
|
||||
readonly scope?: string;
|
||||
readonly refresh_token?: string;
|
||||
readonly id_token?: string;
|
||||
}
|
||||
|
||||
interface RefreshTokenResponse extends TokenResponse {}
|
||||
|
||||
export interface AuthorizationHeaders {
|
||||
readonly authorization: string;
|
||||
readonly dpop: string;
|
||||
}
|
||||
|
||||
export interface CompleteLoginResult {
|
||||
readonly returnUrl?: string;
|
||||
}
|
||||
|
||||
const TOKEN_CONTENT_TYPE = 'application/x-www-form-urlencoded';
|
||||
|
||||
interface AccessTokenMetadata {
|
||||
tenantId: string | null;
|
||||
scopes: string[];
|
||||
audiences: string[];
|
||||
authenticationTimeEpochMs: number | null;
|
||||
freshAuthActive: boolean;
|
||||
freshAuthExpiresAtEpochMs: number | null;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class AuthorityAuthService {
|
||||
private refreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private refreshInFlight: Promise<void> | null = null;
|
||||
private lastError: AuthErrorReason | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly http: HttpClient,
|
||||
private readonly config: AppConfigService,
|
||||
private readonly sessionStore: AuthSessionStore,
|
||||
private readonly storage: AuthStorageService,
|
||||
private readonly dpop: DpopService,
|
||||
private readonly consoleSession: ConsoleSessionService
|
||||
) {}
|
||||
|
||||
get error(): AuthErrorReason | null {
|
||||
return this.lastError;
|
||||
}
|
||||
|
||||
async beginLogin(returnUrl?: string): Promise<void> {
|
||||
const authority = this.config.authority;
|
||||
const pkce = await createPkcePair();
|
||||
const state = crypto.randomUUID ? crypto.randomUUID() : createRandomId();
|
||||
const nonce = crypto.randomUUID ? crypto.randomUUID() : createRandomId();
|
||||
|
||||
// Generate the DPoP key pair up-front so the same key is bound to the token.
|
||||
await this.dpop.getThumbprint();
|
||||
|
||||
const authorizeUrl = this.buildAuthorizeUrl(authority, {
|
||||
state,
|
||||
nonce,
|
||||
codeChallenge: pkce.challenge,
|
||||
codeChallengeMethod: pkce.method,
|
||||
returnUrl,
|
||||
});
|
||||
|
||||
const now = Date.now();
|
||||
this.storage.savePendingLogin({
|
||||
state,
|
||||
codeVerifier: pkce.verifier,
|
||||
createdAtEpochMs: now,
|
||||
returnUrl,
|
||||
nonce,
|
||||
});
|
||||
|
||||
window.location.assign(authorizeUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Completes the authorization code flow after the Authority redirects back with ?code & ?state.
|
||||
*/
|
||||
async completeLoginFromRedirect(
|
||||
queryParams: URLSearchParams
|
||||
): Promise<CompleteLoginResult> {
|
||||
const code = queryParams.get('code');
|
||||
const state = queryParams.get('state');
|
||||
if (!code || !state) {
|
||||
throw new Error('Missing authorization code or state.');
|
||||
}
|
||||
|
||||
const pending = this.storage.consumePendingLogin(state);
|
||||
if (!pending) {
|
||||
this.lastError = 'invalid_state';
|
||||
throw new Error('State parameter did not match pending login request.');
|
||||
}
|
||||
|
||||
try {
|
||||
const tokenResponse = await this.exchangeCodeForTokens(
|
||||
code,
|
||||
pending.codeVerifier
|
||||
);
|
||||
await this.onTokenResponse(tokenResponse, pending.nonce ?? null);
|
||||
this.lastError = null;
|
||||
return { returnUrl: pending.returnUrl };
|
||||
} catch (error) {
|
||||
this.lastError = 'token_exchange_failed';
|
||||
this.sessionStore.clear();
|
||||
this.consoleSession.clear();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async ensureValidAccessToken(): Promise<string | null> {
|
||||
const session = this.sessionStore.session();
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (now < session.tokens.expiresAtEpochMs - ACCESS_TOKEN_REFRESH_THRESHOLD_MS) {
|
||||
return session.tokens.accessToken;
|
||||
}
|
||||
|
||||
await this.refreshAccessToken();
|
||||
const refreshed = this.sessionStore.session();
|
||||
return refreshed?.tokens.accessToken ?? null;
|
||||
}
|
||||
|
||||
async getAuthHeadersForRequest(
|
||||
url: string,
|
||||
method: string
|
||||
): Promise<AuthorizationHeaders | null> {
|
||||
const accessToken = await this.ensureValidAccessToken();
|
||||
if (!accessToken) {
|
||||
return null;
|
||||
}
|
||||
const dpopProof = await this.dpop.createProof({
|
||||
htm: method,
|
||||
htu: url,
|
||||
accessToken,
|
||||
});
|
||||
return {
|
||||
authorization: `DPoP ${accessToken}`,
|
||||
dpop: dpopProof,
|
||||
};
|
||||
}
|
||||
|
||||
async refreshAccessToken(): Promise<void> {
|
||||
const session = this.sessionStore.session();
|
||||
const refreshToken = session?.tokens.refreshToken;
|
||||
if (!refreshToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.refreshInFlight) {
|
||||
await this.refreshInFlight;
|
||||
return;
|
||||
}
|
||||
|
||||
this.refreshInFlight = this.executeRefresh(refreshToken)
|
||||
.catch((error) => {
|
||||
this.lastError = 'refresh_failed';
|
||||
this.sessionStore.clear();
|
||||
this.consoleSession.clear();
|
||||
throw error;
|
||||
})
|
||||
.finally(() => {
|
||||
this.refreshInFlight = null;
|
||||
});
|
||||
|
||||
await this.refreshInFlight;
|
||||
}
|
||||
|
||||
async logout(): Promise<void> {
|
||||
const session = this.sessionStore.session();
|
||||
this.cancelRefreshTimer();
|
||||
this.sessionStore.clear();
|
||||
this.consoleSession.clear();
|
||||
await this.dpop.setNonce(null);
|
||||
|
||||
const authority = this.config.authority;
|
||||
if (!authority.logoutEndpoint) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (session?.identity.idToken) {
|
||||
const url = new URL(authority.logoutEndpoint, authority.issuer);
|
||||
url.searchParams.set('post_logout_redirect_uri', authority.postLogoutRedirectUri ?? authority.redirectUri);
|
||||
url.searchParams.set('id_token_hint', session.identity.idToken);
|
||||
window.location.assign(url.toString());
|
||||
} else {
|
||||
window.location.assign(authority.postLogoutRedirectUri ?? authority.redirectUri);
|
||||
}
|
||||
}
|
||||
|
||||
private async exchangeCodeForTokens(
|
||||
code: string,
|
||||
codeVerifier: string
|
||||
): Promise<HttpResponse<TokenResponse>> {
|
||||
const authority = this.config.authority;
|
||||
const tokenUrl = new URL(authority.tokenEndpoint, authority.issuer).toString();
|
||||
|
||||
const body = new URLSearchParams();
|
||||
body.set('grant_type', 'authorization_code');
|
||||
body.set('code', code);
|
||||
body.set('redirect_uri', authority.redirectUri);
|
||||
body.set('client_id', authority.clientId);
|
||||
body.set('code_verifier', codeVerifier);
|
||||
if (authority.audience) {
|
||||
body.set('audience', authority.audience);
|
||||
}
|
||||
|
||||
const dpopProof = await this.dpop.createProof({
|
||||
htm: 'POST',
|
||||
htu: tokenUrl,
|
||||
});
|
||||
|
||||
const headers = new HttpHeaders({
|
||||
'Content-Type': TOKEN_CONTENT_TYPE,
|
||||
DPoP: dpopProof,
|
||||
});
|
||||
|
||||
return firstValueFrom(
|
||||
this.http.post<TokenResponse>(tokenUrl, body.toString(), {
|
||||
headers,
|
||||
withCredentials: true,
|
||||
observe: 'response',
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private async executeRefresh(refreshToken: string): Promise<void> {
|
||||
const authority = this.config.authority;
|
||||
const tokenUrl = new URL(authority.tokenEndpoint, authority.issuer).toString();
|
||||
const body = new URLSearchParams();
|
||||
body.set('grant_type', 'refresh_token');
|
||||
body.set('refresh_token', refreshToken);
|
||||
body.set('client_id', authority.clientId);
|
||||
if (authority.audience) {
|
||||
body.set('audience', authority.audience);
|
||||
}
|
||||
|
||||
const proof = await this.dpop.createProof({
|
||||
htm: 'POST',
|
||||
htu: tokenUrl,
|
||||
});
|
||||
|
||||
const headers = new HttpHeaders({
|
||||
'Content-Type': TOKEN_CONTENT_TYPE,
|
||||
DPoP: proof,
|
||||
});
|
||||
|
||||
const response = await firstValueFrom(
|
||||
this.http.post<RefreshTokenResponse>(tokenUrl, body.toString(), {
|
||||
headers,
|
||||
withCredentials: true,
|
||||
observe: 'response',
|
||||
})
|
||||
);
|
||||
|
||||
await this.onTokenResponse(response, null);
|
||||
}
|
||||
|
||||
private async onTokenResponse(
|
||||
response: HttpResponse<TokenResponse>,
|
||||
expectedNonce: string | null
|
||||
): Promise<void> {
|
||||
const nonce = response.headers.get('DPoP-Nonce');
|
||||
if (nonce) {
|
||||
await this.dpop.setNonce(nonce);
|
||||
}
|
||||
|
||||
const payload = response.body;
|
||||
if (!payload) {
|
||||
throw new Error('Token response did not include a body.');
|
||||
}
|
||||
|
||||
const tokens = this.toAuthTokens(payload);
|
||||
const accessMetadata = this.parseAccessTokenMetadata(payload.access_token);
|
||||
const identity = this.parseIdentity(payload.id_token ?? '', expectedNonce);
|
||||
const thumbprint = await this.dpop.getThumbprint();
|
||||
if (!thumbprint) {
|
||||
throw new Error('DPoP thumbprint unavailable.');
|
||||
}
|
||||
|
||||
const session: AuthSession = {
|
||||
tokens,
|
||||
identity,
|
||||
dpopKeyThumbprint: thumbprint,
|
||||
issuedAtEpochMs: Date.now(),
|
||||
tenantId: accessMetadata.tenantId,
|
||||
scopes: accessMetadata.scopes,
|
||||
audiences: accessMetadata.audiences,
|
||||
authenticationTimeEpochMs: accessMetadata.authenticationTimeEpochMs,
|
||||
freshAuthActive: accessMetadata.freshAuthActive,
|
||||
freshAuthExpiresAtEpochMs: accessMetadata.freshAuthExpiresAtEpochMs,
|
||||
};
|
||||
this.sessionStore.setSession(session);
|
||||
void this.consoleSession.loadConsoleContext();
|
||||
this.scheduleRefresh(tokens, this.config.authority);
|
||||
}
|
||||
|
||||
private toAuthTokens(payload: TokenResponse): AuthTokens {
|
||||
const expiresAtEpochMs = Date.now() + payload.expires_in * 1000;
|
||||
return {
|
||||
accessToken: payload.access_token,
|
||||
tokenType: (payload.token_type ?? 'Bearer') as 'Bearer',
|
||||
refreshToken: payload.refresh_token,
|
||||
scope: payload.scope ?? '',
|
||||
expiresAtEpochMs,
|
||||
};
|
||||
}
|
||||
|
||||
private parseIdentity(
|
||||
idToken: string,
|
||||
expectedNonce: string | null
|
||||
): AuthSession['identity'] {
|
||||
if (!idToken) {
|
||||
return {
|
||||
subject: 'unknown',
|
||||
roles: [],
|
||||
};
|
||||
}
|
||||
|
||||
const claims = decodeJwt(idToken);
|
||||
const nonceClaim = claims['nonce'];
|
||||
if (
|
||||
expectedNonce &&
|
||||
typeof nonceClaim === 'string' &&
|
||||
nonceClaim !== expectedNonce
|
||||
) {
|
||||
throw new Error('OIDC nonce mismatch.');
|
||||
}
|
||||
|
||||
const subjectClaim = claims['sub'];
|
||||
const nameClaim = claims['name'];
|
||||
const emailClaim = claims['email'];
|
||||
const rolesClaim = claims['role'];
|
||||
|
||||
return {
|
||||
subject: typeof subjectClaim === 'string' ? subjectClaim : 'unknown',
|
||||
name: typeof nameClaim === 'string' ? nameClaim : undefined,
|
||||
email: typeof emailClaim === 'string' ? emailClaim : undefined,
|
||||
roles: Array.isArray(rolesClaim)
|
||||
? rolesClaim.filter((entry: unknown): entry is string =>
|
||||
typeof entry === 'string'
|
||||
)
|
||||
: [],
|
||||
idToken,
|
||||
};
|
||||
}
|
||||
|
||||
private scheduleRefresh(tokens: AuthTokens, authority: AuthorityConfig): void {
|
||||
this.cancelRefreshTimer();
|
||||
const leeway =
|
||||
(authority.refreshLeewaySeconds ?? 60) * 1000 +
|
||||
ACCESS_TOKEN_REFRESH_THRESHOLD_MS;
|
||||
const now = Date.now();
|
||||
const ttl = Math.max(tokens.expiresAtEpochMs - now - leeway, 5_000);
|
||||
this.refreshTimer = setTimeout(() => {
|
||||
void this.refreshAccessToken();
|
||||
}, ttl);
|
||||
}
|
||||
|
||||
private cancelRefreshTimer(): void {
|
||||
if (this.refreshTimer) {
|
||||
clearTimeout(this.refreshTimer);
|
||||
this.refreshTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private parseAccessTokenMetadata(accessToken: string | undefined): AccessTokenMetadata {
|
||||
if (!accessToken) {
|
||||
return {
|
||||
tenantId: null,
|
||||
scopes: [],
|
||||
audiences: [],
|
||||
authenticationTimeEpochMs: null,
|
||||
freshAuthActive: false,
|
||||
freshAuthExpiresAtEpochMs: null,
|
||||
};
|
||||
}
|
||||
|
||||
const claims = decodeJwt(accessToken);
|
||||
const tenantClaim = claims['stellaops:tenant'];
|
||||
const tenantId =
|
||||
typeof tenantClaim === 'string' && tenantClaim.trim().length > 0
|
||||
? tenantClaim.trim()
|
||||
: null;
|
||||
|
||||
const scopeSet = new Set<string>();
|
||||
const scpClaim = claims['scp'];
|
||||
if (Array.isArray(scpClaim)) {
|
||||
for (const entry of scpClaim) {
|
||||
if (typeof entry === 'string' && entry.trim().length > 0) {
|
||||
scopeSet.add(entry.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const scopeClaim = claims['scope'];
|
||||
if (typeof scopeClaim === 'string') {
|
||||
scopeClaim
|
||||
.split(/\s+/)
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0)
|
||||
.forEach((entry) => scopeSet.add(entry));
|
||||
}
|
||||
|
||||
const audiences: string[] = [];
|
||||
const audClaim = claims['aud'];
|
||||
if (Array.isArray(audClaim)) {
|
||||
for (const entry of audClaim) {
|
||||
if (typeof entry === 'string' && entry.trim().length > 0) {
|
||||
audiences.push(entry.trim());
|
||||
}
|
||||
}
|
||||
} else if (typeof audClaim === 'string' && audClaim.trim().length > 0) {
|
||||
audiences.push(audClaim.trim());
|
||||
}
|
||||
|
||||
const authenticationTimeEpochMs = this.parseEpochSeconds(
|
||||
claims['auth_time'] ?? claims['authentication_time']
|
||||
);
|
||||
|
||||
const freshAuthActive = this.parseFreshAuthFlag(
|
||||
claims['stellaops:fresh_auth'] ?? claims['fresh_auth']
|
||||
);
|
||||
|
||||
const ttlMs = this.parseDurationToMilliseconds(
|
||||
claims['stellaops:fresh_auth_ttl']
|
||||
);
|
||||
|
||||
let freshAuthExpiresAtEpochMs: number | null = null;
|
||||
if (authenticationTimeEpochMs !== null) {
|
||||
if (ttlMs !== null) {
|
||||
freshAuthExpiresAtEpochMs = authenticationTimeEpochMs + ttlMs;
|
||||
} else if (freshAuthActive) {
|
||||
freshAuthExpiresAtEpochMs = authenticationTimeEpochMs + 300_000;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tenantId,
|
||||
scopes: Array.from(scopeSet).sort(),
|
||||
audiences: audiences.sort(),
|
||||
authenticationTimeEpochMs,
|
||||
freshAuthActive,
|
||||
freshAuthExpiresAtEpochMs,
|
||||
};
|
||||
}
|
||||
|
||||
private parseFreshAuthFlag(value: unknown): boolean {
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === 'true' || normalized === '1') {
|
||||
return true;
|
||||
}
|
||||
if (normalized === 'false' || normalized === '0') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private parseDurationToMilliseconds(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return Math.max(0, value * 1000);
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
|
||||
const seconds = Number(trimmed);
|
||||
if (!Number.isFinite(seconds)) {
|
||||
return null;
|
||||
}
|
||||
return Math.max(0, seconds * 1000);
|
||||
}
|
||||
|
||||
const isoMatch =
|
||||
/^P(T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)$/i.exec(trimmed);
|
||||
if (isoMatch) {
|
||||
const hours = isoMatch[2] ? Number(isoMatch[2]) : 0;
|
||||
const minutes = isoMatch[3] ? Number(isoMatch[3]) : 0;
|
||||
const seconds = isoMatch[4] ? Number(isoMatch[4]) : 0;
|
||||
const totalSeconds = hours * 3600 + minutes * 60 + seconds;
|
||||
return Math.max(0, totalSeconds * 1000);
|
||||
}
|
||||
|
||||
const spanMatch =
|
||||
/^(-)?(?:(\d+)\.)?(\d{1,2}):([0-5]?\d):([0-5]?\d)(\.\d+)?$/.exec(trimmed);
|
||||
if (spanMatch) {
|
||||
const isNegative = !!spanMatch[1];
|
||||
const days = spanMatch[2] ? Number(spanMatch[2]) : 0;
|
||||
const hours = Number(spanMatch[3]);
|
||||
const minutes = Number(spanMatch[4]);
|
||||
const seconds =
|
||||
Number(spanMatch[5]) + (spanMatch[6] ? Number(spanMatch[6]) : 0);
|
||||
const totalSeconds =
|
||||
days * 86400 + hours * 3600 + minutes * 60 + seconds;
|
||||
if (!Number.isFinite(totalSeconds)) {
|
||||
return null;
|
||||
}
|
||||
const ms = totalSeconds * 1000;
|
||||
return isNegative ? 0 : Math.max(0, ms);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private parseEpochSeconds(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value * 1000;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
const numeric = Number(trimmed);
|
||||
if (!Number.isNaN(numeric) && Number.isFinite(numeric)) {
|
||||
return numeric * 1000;
|
||||
}
|
||||
const parsed = Date.parse(trimmed);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private buildAuthorizeUrl(
|
||||
authority: AuthorityConfig,
|
||||
options: {
|
||||
state: string;
|
||||
nonce: string;
|
||||
codeChallenge: string;
|
||||
codeChallengeMethod: 'S256';
|
||||
returnUrl?: string;
|
||||
}
|
||||
): string {
|
||||
const authorizeUrl = new URL(
|
||||
authority.authorizeEndpoint,
|
||||
authority.issuer
|
||||
);
|
||||
authorizeUrl.searchParams.set('response_type', 'code');
|
||||
authorizeUrl.searchParams.set('client_id', authority.clientId);
|
||||
authorizeUrl.searchParams.set('redirect_uri', authority.redirectUri);
|
||||
authorizeUrl.searchParams.set('scope', authority.scope);
|
||||
authorizeUrl.searchParams.set('state', options.state);
|
||||
authorizeUrl.searchParams.set('nonce', options.nonce);
|
||||
authorizeUrl.searchParams.set('code_challenge', options.codeChallenge);
|
||||
authorizeUrl.searchParams.set(
|
||||
'code_challenge_method',
|
||||
options.codeChallengeMethod
|
||||
);
|
||||
if (authority.audience) {
|
||||
authorizeUrl.searchParams.set('audience', authority.audience);
|
||||
}
|
||||
if (options.returnUrl) {
|
||||
authorizeUrl.searchParams.set('ui_return', options.returnUrl);
|
||||
}
|
||||
return authorizeUrl.toString();
|
||||
}
|
||||
}
|
||||
|
||||
function decodeJwt(token: string): Record<string, unknown> {
|
||||
const parts = token.split('.');
|
||||
if (parts.length < 2) {
|
||||
return {};
|
||||
}
|
||||
const payload = base64UrlDecode(parts[1]);
|
||||
const json = new TextDecoder().decode(payload);
|
||||
try {
|
||||
return JSON.parse(json) as Record<string, unknown>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function createRandomId(): string {
|
||||
const array = new Uint8Array(16);
|
||||
crypto.getRandomValues(array);
|
||||
return Array.from(array, (value) =>
|
||||
value.toString(16).padStart(2, '0')
|
||||
).join('');
|
||||
}
|
||||
181
src/Web/StellaOps.Web/src/app/core/auth/dpop/dpop-key-store.ts
Normal file
181
src/Web/StellaOps.Web/src/app/core/auth/dpop/dpop-key-store.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
import { DPoPAlgorithm } from '../../config/app-config.model';
|
||||
import { computeJwkThumbprint } from './jose-utilities';
|
||||
|
||||
const DB_NAME = 'stellaops-auth';
|
||||
const STORE_NAME = 'dpopKeys';
|
||||
const PRIMARY_KEY = 'primary';
|
||||
const DB_VERSION = 1;
|
||||
|
||||
interface PersistedKeyPair {
|
||||
readonly id: string;
|
||||
readonly algorithm: DPoPAlgorithm;
|
||||
readonly publicJwk: JsonWebKey;
|
||||
readonly privateJwk: JsonWebKey;
|
||||
readonly thumbprint: string;
|
||||
readonly createdAtIso: string;
|
||||
}
|
||||
|
||||
export interface LoadedDpopKeyPair {
|
||||
readonly algorithm: DPoPAlgorithm;
|
||||
readonly privateKey: CryptoKey;
|
||||
readonly publicKey: CryptoKey;
|
||||
readonly publicJwk: JsonWebKey;
|
||||
readonly thumbprint: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class DpopKeyStore {
|
||||
private dbPromise: Promise<IDBDatabase> | null = null;
|
||||
|
||||
async load(): Promise<LoadedDpopKeyPair | null> {
|
||||
const record = await this.read();
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [privateKey, publicKey] = await Promise.all([
|
||||
crypto.subtle.importKey(
|
||||
'jwk',
|
||||
record.privateJwk,
|
||||
this.toKeyAlgorithm(record.algorithm),
|
||||
true,
|
||||
['sign']
|
||||
),
|
||||
crypto.subtle.importKey(
|
||||
'jwk',
|
||||
record.publicJwk,
|
||||
this.toKeyAlgorithm(record.algorithm),
|
||||
true,
|
||||
['verify']
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
algorithm: record.algorithm,
|
||||
privateKey,
|
||||
publicKey,
|
||||
publicJwk: record.publicJwk,
|
||||
thumbprint: record.thumbprint,
|
||||
};
|
||||
}
|
||||
|
||||
async save(
|
||||
keyPair: CryptoKeyPair,
|
||||
algorithm: DPoPAlgorithm
|
||||
): Promise<LoadedDpopKeyPair> {
|
||||
const [publicJwk, privateJwk] = await Promise.all([
|
||||
crypto.subtle.exportKey('jwk', keyPair.publicKey),
|
||||
crypto.subtle.exportKey('jwk', keyPair.privateKey),
|
||||
]);
|
||||
|
||||
if (!publicJwk) {
|
||||
throw new Error('Failed to export public JWK for DPoP key pair.');
|
||||
}
|
||||
|
||||
const thumbprint = await computeJwkThumbprint(publicJwk);
|
||||
const record: PersistedKeyPair = {
|
||||
id: PRIMARY_KEY,
|
||||
algorithm,
|
||||
publicJwk,
|
||||
privateJwk,
|
||||
thumbprint,
|
||||
createdAtIso: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await this.write(record);
|
||||
|
||||
return {
|
||||
algorithm,
|
||||
privateKey: keyPair.privateKey,
|
||||
publicKey: keyPair.publicKey,
|
||||
publicJwk,
|
||||
thumbprint,
|
||||
};
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
const db = await this.openDb();
|
||||
await transactionPromise(db, STORE_NAME, 'readwrite', (store) =>
|
||||
store.delete(PRIMARY_KEY)
|
||||
);
|
||||
}
|
||||
|
||||
async generate(algorithm: DPoPAlgorithm): Promise<LoadedDpopKeyPair> {
|
||||
const algo = this.toKeyAlgorithm(algorithm);
|
||||
const keyPair = await crypto.subtle.generateKey(algo, true, [
|
||||
'sign',
|
||||
'verify',
|
||||
]);
|
||||
|
||||
const stored = await this.save(keyPair, algorithm);
|
||||
return stored;
|
||||
}
|
||||
|
||||
private async read(): Promise<PersistedKeyPair | null> {
|
||||
const db = await this.openDb();
|
||||
return transactionPromise(db, STORE_NAME, 'readonly', (store) =>
|
||||
store.get(PRIMARY_KEY)
|
||||
);
|
||||
}
|
||||
|
||||
private async write(record: PersistedKeyPair): Promise<void> {
|
||||
const db = await this.openDb();
|
||||
await transactionPromise(db, STORE_NAME, 'readwrite', (store) =>
|
||||
store.put(record)
|
||||
);
|
||||
}
|
||||
|
||||
private toKeyAlgorithm(algorithm: DPoPAlgorithm): EcKeyImportParams {
|
||||
switch (algorithm) {
|
||||
case 'ES384':
|
||||
return { name: 'ECDSA', namedCurve: 'P-384' };
|
||||
case 'EdDSA':
|
||||
throw new Error('EdDSA DPoP keys are not yet supported.');
|
||||
case 'ES256':
|
||||
default:
|
||||
return { name: 'ECDSA', namedCurve: 'P-256' };
|
||||
}
|
||||
}
|
||||
|
||||
private async openDb(): Promise<IDBDatabase> {
|
||||
if (typeof indexedDB === 'undefined') {
|
||||
throw new Error('IndexedDB is not available for DPoP key persistence.');
|
||||
}
|
||||
|
||||
if (!this.dbPromise) {
|
||||
this.dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME, { keyPath: 'id' });
|
||||
}
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
return this.dbPromise;
|
||||
}
|
||||
}
|
||||
|
||||
function transactionPromise<T>(
|
||||
db: IDBDatabase,
|
||||
storeName: string,
|
||||
mode: IDBTransactionMode,
|
||||
executor: (store: IDBObjectStore) => IDBRequest<T>
|
||||
): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const transaction = db.transaction(storeName, mode);
|
||||
const store = transaction.objectStore(storeName);
|
||||
const request = executor(store);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
transaction.onabort = () => reject(transaction.error);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { HttpClientTestingModule } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { APP_CONFIG, AppConfig } from '../../config/app-config.model';
|
||||
import { AppConfigService } from '../../config/app-config.service';
|
||||
import { base64UrlDecode } from './jose-utilities';
|
||||
import { DpopKeyStore } from './dpop-key-store';
|
||||
import { DpopService } from './dpop.service';
|
||||
|
||||
describe('DpopService', () => {
|
||||
const originalTimeout = jasmine.DEFAULT_TIMEOUT_INTERVAL;
|
||||
const config: AppConfig = {
|
||||
authority: {
|
||||
issuer: 'https://auth.stellaops.test/',
|
||||
clientId: 'ui-client',
|
||||
authorizeEndpoint: 'https://auth.stellaops.test/connect/authorize',
|
||||
tokenEndpoint: 'https://auth.stellaops.test/connect/token',
|
||||
redirectUri: 'https://ui.stellaops.test/auth/callback',
|
||||
scope: 'openid profile email ui.read authority:tenants.read advisory:read vex:read exceptions:read exceptions:approve aoc:verify findings:read orch:read vuln:read',
|
||||
audience: 'https://scanner.stellaops.test',
|
||||
},
|
||||
apiBaseUrls: {
|
||||
authority: 'https://auth.stellaops.test',
|
||||
scanner: 'https://scanner.stellaops.test',
|
||||
policy: 'https://policy.stellaops.test',
|
||||
concelier: 'https://concelier.stellaops.test',
|
||||
attestor: 'https://attestor.stellaops.test',
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jasmine.DEFAULT_TIMEOUT_INTERVAL = 20000;
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
providers: [
|
||||
AppConfigService,
|
||||
DpopKeyStore,
|
||||
DpopService,
|
||||
{
|
||||
provide: APP_CONFIG,
|
||||
useValue: config,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
jasmine.DEFAULT_TIMEOUT_INTERVAL = originalTimeout;
|
||||
const store = TestBed.inject(DpopKeyStore);
|
||||
try {
|
||||
await store.clear();
|
||||
} catch {
|
||||
// ignore cleanup issues in test environment
|
||||
}
|
||||
});
|
||||
|
||||
it('creates a DPoP proof with expected header values', async () => {
|
||||
const appConfig = TestBed.inject(AppConfigService);
|
||||
appConfig.setConfigForTesting(config);
|
||||
const service = TestBed.inject(DpopService);
|
||||
|
||||
const proof = await service.createProof({
|
||||
htm: 'get',
|
||||
htu: 'https://scanner.stellaops.test/api/v1/scans',
|
||||
});
|
||||
|
||||
const [rawHeader, rawPayload] = proof.split('.');
|
||||
const header = JSON.parse(
|
||||
new TextDecoder().decode(base64UrlDecode(rawHeader))
|
||||
);
|
||||
const payload = JSON.parse(
|
||||
new TextDecoder().decode(base64UrlDecode(rawPayload))
|
||||
);
|
||||
|
||||
expect(header.typ).toBe('dpop+jwt');
|
||||
expect(header.alg).toBe('ES256');
|
||||
expect(header.jwk.kty).toBe('EC');
|
||||
expect(payload.htm).toBe('GET');
|
||||
expect(payload.htu).toBe('https://scanner.stellaops.test/api/v1/scans');
|
||||
expect(typeof payload.iat).toBe('number');
|
||||
expect(typeof payload.jti).toBe('string');
|
||||
});
|
||||
|
||||
it('binds access token hash when provided', async () => {
|
||||
const appConfig = TestBed.inject(AppConfigService);
|
||||
appConfig.setConfigForTesting(config);
|
||||
const service = TestBed.inject(DpopService);
|
||||
|
||||
const accessToken = 'sample-access-token';
|
||||
const proof = await service.createProof({
|
||||
htm: 'post',
|
||||
htu: 'https://scanner.stellaops.test/api/v1/scans',
|
||||
accessToken,
|
||||
});
|
||||
|
||||
const payload = JSON.parse(
|
||||
new TextDecoder().decode(base64UrlDecode(proof.split('.')[1]))
|
||||
);
|
||||
|
||||
expect(payload.ath).toBeDefined();
|
||||
expect(typeof payload.ath).toBe('string');
|
||||
});
|
||||
});
|
||||
148
src/Web/StellaOps.Web/src/app/core/auth/dpop/dpop.service.ts
Normal file
148
src/Web/StellaOps.Web/src/app/core/auth/dpop/dpop.service.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { Injectable, computed, signal } from '@angular/core';
|
||||
|
||||
import { AppConfigService } from '../../config/app-config.service';
|
||||
import { DPoPAlgorithm } from '../../config/app-config.model';
|
||||
import { sha256, base64UrlEncode, derToJoseSignature } from './jose-utilities';
|
||||
import { DpopKeyStore, LoadedDpopKeyPair } from './dpop-key-store';
|
||||
|
||||
export interface DpopProofOptions {
|
||||
readonly htm: string;
|
||||
readonly htu: string;
|
||||
readonly accessToken?: string;
|
||||
readonly nonce?: string | null;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class DpopService {
|
||||
private keyPairPromise: Promise<LoadedDpopKeyPair> | null = null;
|
||||
private readonly nonceSignal = signal<string | null>(null);
|
||||
readonly nonce = computed(() => this.nonceSignal());
|
||||
|
||||
constructor(
|
||||
private readonly config: AppConfigService,
|
||||
private readonly store: DpopKeyStore
|
||||
) {}
|
||||
|
||||
async setNonce(nonce: string | null): Promise<void> {
|
||||
this.nonceSignal.set(nonce);
|
||||
}
|
||||
|
||||
async getThumbprint(): Promise<string | null> {
|
||||
const key = await this.getOrCreateKeyPair();
|
||||
return key.thumbprint ?? null;
|
||||
}
|
||||
|
||||
async rotateKey(): Promise<void> {
|
||||
const algorithm = this.resolveAlgorithm();
|
||||
this.keyPairPromise = this.store.generate(algorithm);
|
||||
}
|
||||
|
||||
async createProof(options: DpopProofOptions): Promise<string> {
|
||||
const keyPair = await this.getOrCreateKeyPair();
|
||||
|
||||
const header = {
|
||||
typ: 'dpop+jwt',
|
||||
alg: keyPair.algorithm,
|
||||
jwk: keyPair.publicJwk,
|
||||
};
|
||||
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
const payload: Record<string, unknown> = {
|
||||
htm: options.htm.toUpperCase(),
|
||||
htu: normalizeHtu(options.htu),
|
||||
iat: nowSeconds,
|
||||
jti: crypto.randomUUID ? crypto.randomUUID() : createRandomId(),
|
||||
};
|
||||
|
||||
const nonce = options.nonce ?? this.nonceSignal();
|
||||
if (nonce) {
|
||||
payload['nonce'] = nonce;
|
||||
}
|
||||
|
||||
if (options.accessToken) {
|
||||
const accessTokenHash = await sha256(
|
||||
new TextEncoder().encode(options.accessToken)
|
||||
);
|
||||
payload['ath'] = base64UrlEncode(accessTokenHash);
|
||||
}
|
||||
|
||||
const encodedHeader = base64UrlEncode(JSON.stringify(header));
|
||||
const encodedPayload = base64UrlEncode(JSON.stringify(payload));
|
||||
const signingInput = `${encodedHeader}.${encodedPayload}`;
|
||||
const signature = await crypto.subtle.sign(
|
||||
{
|
||||
name: 'ECDSA',
|
||||
hash: this.resolveHashAlgorithm(keyPair.algorithm),
|
||||
},
|
||||
keyPair.privateKey,
|
||||
new TextEncoder().encode(signingInput)
|
||||
);
|
||||
|
||||
const joseSignature = base64UrlEncode(derToJoseSignature(signature));
|
||||
return `${signingInput}.${joseSignature}`;
|
||||
}
|
||||
|
||||
private async getOrCreateKeyPair(): Promise<LoadedDpopKeyPair> {
|
||||
if (!this.keyPairPromise) {
|
||||
this.keyPairPromise = this.loadKeyPair();
|
||||
}
|
||||
try {
|
||||
return await this.keyPairPromise;
|
||||
} catch (error) {
|
||||
// Reset the memoized promise so a subsequent call can retry.
|
||||
this.keyPairPromise = null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async loadKeyPair(): Promise<LoadedDpopKeyPair> {
|
||||
const algorithm = this.resolveAlgorithm();
|
||||
try {
|
||||
const existing = await this.store.load();
|
||||
if (existing && existing.algorithm === algorithm) {
|
||||
return existing;
|
||||
}
|
||||
} catch {
|
||||
// fall through to regeneration
|
||||
}
|
||||
|
||||
return this.store.generate(algorithm);
|
||||
}
|
||||
|
||||
private resolveAlgorithm(): DPoPAlgorithm {
|
||||
const authority = this.config.authority;
|
||||
return authority.dpopAlgorithms?.[0] ?? 'ES256';
|
||||
}
|
||||
|
||||
private resolveHashAlgorithm(algorithm: DPoPAlgorithm): string {
|
||||
switch (algorithm) {
|
||||
case 'ES384':
|
||||
return 'SHA-384';
|
||||
case 'ES256':
|
||||
default:
|
||||
return 'SHA-256';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeHtu(value: string): string {
|
||||
try {
|
||||
const base =
|
||||
typeof window !== 'undefined' && window.location
|
||||
? window.location.origin
|
||||
: undefined;
|
||||
const url = base ? new URL(value, base) : new URL(value);
|
||||
url.hash = '';
|
||||
return url.toString();
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function createRandomId(): string {
|
||||
const array = new Uint8Array(16);
|
||||
crypto.getRandomValues(array);
|
||||
return base64UrlEncode(array);
|
||||
}
|
||||
123
src/Web/StellaOps.Web/src/app/core/auth/dpop/jose-utilities.ts
Normal file
123
src/Web/StellaOps.Web/src/app/core/auth/dpop/jose-utilities.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
export async function sha256(data: Uint8Array): Promise<Uint8Array> {
|
||||
const digest = await crypto.subtle.digest('SHA-256', data);
|
||||
return new Uint8Array(digest);
|
||||
}
|
||||
|
||||
export function base64UrlEncode(
|
||||
input: ArrayBuffer | Uint8Array | string
|
||||
): string {
|
||||
let bytes: Uint8Array;
|
||||
if (typeof input === 'string') {
|
||||
bytes = new TextEncoder().encode(input);
|
||||
} else if (input instanceof Uint8Array) {
|
||||
bytes = input;
|
||||
} else {
|
||||
bytes = new Uint8Array(input);
|
||||
}
|
||||
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.byteLength; i += 1) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
export function base64UrlDecode(value: string): Uint8Array {
|
||||
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padding = normalized.length % 4;
|
||||
const padded =
|
||||
padding === 0 ? normalized : normalized + '='.repeat(4 - padding);
|
||||
const binary = atob(padded);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i += 1) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export async function computeJwkThumbprint(jwk: JsonWebKey): Promise<string> {
|
||||
const canonical = canonicalizeJwk(jwk);
|
||||
const digest = await sha256(new TextEncoder().encode(canonical));
|
||||
return base64UrlEncode(digest);
|
||||
}
|
||||
|
||||
function canonicalizeJwk(jwk: JsonWebKey): string {
|
||||
if (!jwk.kty) {
|
||||
throw new Error('JWK must include "kty"');
|
||||
}
|
||||
|
||||
if (jwk.kty === 'EC') {
|
||||
const { crv, kty, x, y } = jwk;
|
||||
if (!crv || !x || !y) {
|
||||
throw new Error('EC JWK must include "crv", "x", and "y".');
|
||||
}
|
||||
return JSON.stringify({ crv, kty, x, y });
|
||||
}
|
||||
|
||||
if (jwk.kty === 'OKP') {
|
||||
const { crv, kty, x } = jwk;
|
||||
if (!crv || !x) {
|
||||
throw new Error('OKP JWK must include "crv" and "x".');
|
||||
}
|
||||
return JSON.stringify({ crv, kty, x });
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported JWK key type: ${jwk.kty}`);
|
||||
}
|
||||
|
||||
export function derToJoseSignature(der: ArrayBuffer): Uint8Array {
|
||||
const bytes = new Uint8Array(der);
|
||||
if (bytes[0] !== 0x30) {
|
||||
// Some implementations already return raw (r || s) signature bytes.
|
||||
if (bytes.length === 64) {
|
||||
return bytes;
|
||||
}
|
||||
throw new Error('Invalid DER signature: expected sequence.');
|
||||
}
|
||||
|
||||
let offset = 2; // skip SEQUENCE header and length (assume short form)
|
||||
if (bytes[1] & 0x80) {
|
||||
const lengthBytes = bytes[1] & 0x7f;
|
||||
offset = 2 + lengthBytes;
|
||||
}
|
||||
|
||||
if (bytes[offset] !== 0x02) {
|
||||
throw new Error('Invalid DER signature: expected INTEGER for r.');
|
||||
}
|
||||
const rLength = bytes[offset + 1];
|
||||
let r = bytes.slice(offset + 2, offset + 2 + rLength);
|
||||
offset = offset + 2 + rLength;
|
||||
|
||||
if (bytes[offset] !== 0x02) {
|
||||
throw new Error('Invalid DER signature: expected INTEGER for s.');
|
||||
}
|
||||
const sLength = bytes[offset + 1];
|
||||
let s = bytes.slice(offset + 2, offset + 2 + sLength);
|
||||
|
||||
r = trimLeadingZeros(r);
|
||||
s = trimLeadingZeros(s);
|
||||
|
||||
const targetLength = 32;
|
||||
const signature = new Uint8Array(targetLength * 2);
|
||||
signature.set(padStart(r, targetLength), 0);
|
||||
signature.set(padStart(s, targetLength), targetLength);
|
||||
return signature;
|
||||
}
|
||||
|
||||
function trimLeadingZeros(bytes: Uint8Array): Uint8Array {
|
||||
let start = 0;
|
||||
while (start < bytes.length - 1 && bytes[start] === 0x00) {
|
||||
start += 1;
|
||||
}
|
||||
return bytes.subarray(start);
|
||||
}
|
||||
|
||||
function padStart(bytes: Uint8Array, length: number): Uint8Array {
|
||||
if (bytes.length >= length) {
|
||||
return bytes;
|
||||
}
|
||||
const padded = new Uint8Array(length);
|
||||
padded.set(bytes, length - bytes.length);
|
||||
return padded;
|
||||
}
|
||||
24
src/Web/StellaOps.Web/src/app/core/auth/pkce.util.ts
Normal file
24
src/Web/StellaOps.Web/src/app/core/auth/pkce.util.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { base64UrlEncode, sha256 } from './dpop/jose-utilities';
|
||||
|
||||
export interface PkcePair {
|
||||
readonly verifier: string;
|
||||
readonly challenge: string;
|
||||
readonly method: 'S256';
|
||||
}
|
||||
|
||||
const VERIFIER_BYTE_LENGTH = 32;
|
||||
|
||||
export async function createPkcePair(): Promise<PkcePair> {
|
||||
const verifierBytes = new Uint8Array(VERIFIER_BYTE_LENGTH);
|
||||
crypto.getRandomValues(verifierBytes);
|
||||
|
||||
const verifier = base64UrlEncode(verifierBytes);
|
||||
const challengeBytes = await sha256(new TextEncoder().encode(verifier));
|
||||
const challenge = base64UrlEncode(challengeBytes);
|
||||
|
||||
return {
|
||||
verifier,
|
||||
challenge,
|
||||
method: 'S256',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { InjectionToken } from '@angular/core';
|
||||
|
||||
export type DPoPAlgorithm = 'ES256' | 'ES384' | 'EdDSA';
|
||||
|
||||
export interface AuthorityConfig {
|
||||
readonly issuer: string;
|
||||
readonly clientId: string;
|
||||
readonly authorizeEndpoint: string;
|
||||
readonly tokenEndpoint: string;
|
||||
readonly logoutEndpoint?: string;
|
||||
readonly redirectUri: string;
|
||||
readonly postLogoutRedirectUri?: string;
|
||||
readonly scope: string;
|
||||
readonly audience: string;
|
||||
/**
|
||||
* Preferred algorithms for DPoP proofs, in order of preference.
|
||||
* Defaults to ES256 if omitted.
|
||||
*/
|
||||
readonly dpopAlgorithms?: readonly DPoPAlgorithm[];
|
||||
/**
|
||||
* Seconds of leeway before access token expiry that should trigger a proactive refresh.
|
||||
* Defaults to 60.
|
||||
*/
|
||||
readonly refreshLeewaySeconds?: number;
|
||||
}
|
||||
|
||||
export interface ApiBaseUrlConfig {
|
||||
readonly scanner: string;
|
||||
readonly policy: string;
|
||||
readonly concelier: string;
|
||||
readonly excitor?: string;
|
||||
readonly attestor: string;
|
||||
readonly authority: string;
|
||||
readonly notify?: string;
|
||||
readonly scheduler?: string;
|
||||
}
|
||||
|
||||
export interface TelemetryConfig {
|
||||
readonly otlpEndpoint?: string;
|
||||
readonly sampleRate?: number;
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
readonly authority: AuthorityConfig;
|
||||
readonly apiBaseUrls: ApiBaseUrlConfig;
|
||||
readonly telemetry?: TelemetryConfig;
|
||||
}
|
||||
|
||||
export const APP_CONFIG = new InjectionToken<AppConfig>('STELLAOPS_APP_CONFIG');
|
||||
@@ -0,0 +1,99 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import {
|
||||
Inject,
|
||||
Injectable,
|
||||
Optional,
|
||||
computed,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
import {
|
||||
APP_CONFIG,
|
||||
AppConfig,
|
||||
AuthorityConfig,
|
||||
DPoPAlgorithm,
|
||||
} from './app-config.model';
|
||||
|
||||
const DEFAULT_CONFIG_URL = '/config.json';
|
||||
const DEFAULT_DPOP_ALG: DPoPAlgorithm = 'ES256';
|
||||
const DEFAULT_REFRESH_LEEWAY_SECONDS = 60;
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class AppConfigService {
|
||||
private readonly configSignal = signal<AppConfig | null>(null);
|
||||
private readonly authoritySignal = computed<AuthorityConfig | null>(() => {
|
||||
const config = this.configSignal();
|
||||
return config?.authority ?? null;
|
||||
});
|
||||
|
||||
constructor(
|
||||
private readonly http: HttpClient,
|
||||
@Optional() @Inject(APP_CONFIG) private readonly staticConfig: AppConfig | null
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Loads application configuration either from the injected static value or via HTTP fetch.
|
||||
* Must be called during application bootstrap (see APP_INITIALIZER wiring).
|
||||
*/
|
||||
async load(configUrl: string = DEFAULT_CONFIG_URL): Promise<void> {
|
||||
if (this.configSignal()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = this.staticConfig ?? (await this.fetchConfig(configUrl));
|
||||
this.configSignal.set(this.normalizeConfig(config));
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows tests to short-circuit configuration loading.
|
||||
*/
|
||||
setConfigForTesting(config: AppConfig): void {
|
||||
this.configSignal.set(this.normalizeConfig(config));
|
||||
}
|
||||
|
||||
get config(): AppConfig {
|
||||
const current = this.configSignal();
|
||||
if (!current) {
|
||||
throw new Error('App configuration has not been loaded yet.');
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
get authority(): AuthorityConfig {
|
||||
const authority = this.authoritySignal();
|
||||
if (!authority) {
|
||||
throw new Error('Authority configuration has not been loaded yet.');
|
||||
}
|
||||
return authority;
|
||||
}
|
||||
|
||||
private async fetchConfig(configUrl: string): Promise<AppConfig> {
|
||||
const response = await firstValueFrom(
|
||||
this.http.get<AppConfig>(configUrl, {
|
||||
headers: { 'Cache-Control': 'no-cache' },
|
||||
withCredentials: false,
|
||||
})
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
private normalizeConfig(config: AppConfig): AppConfig {
|
||||
const authority = {
|
||||
...config.authority,
|
||||
dpopAlgorithms:
|
||||
config.authority.dpopAlgorithms?.length ?? 0
|
||||
? config.authority.dpopAlgorithms
|
||||
: [DEFAULT_DPOP_ALG],
|
||||
refreshLeewaySeconds:
|
||||
config.authority.refreshLeewaySeconds ?? DEFAULT_REFRESH_LEEWAY_SECONDS,
|
||||
};
|
||||
|
||||
return {
|
||||
...config,
|
||||
authority,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import {
|
||||
AUTHORITY_CONSOLE_API,
|
||||
AuthorityConsoleApi,
|
||||
TenantCatalogResponseDto,
|
||||
} from '../api/authority-console.client';
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import { ConsoleSessionService } from './console-session.service';
|
||||
import { ConsoleSessionStore } from './console-session.store';
|
||||
|
||||
class MockConsoleApi implements AuthorityConsoleApi {
|
||||
private createTenantResponse(): TenantCatalogResponseDto {
|
||||
return {
|
||||
tenants: [
|
||||
{
|
||||
id: 'tenant-default',
|
||||
displayName: 'Tenant Default',
|
||||
status: 'active',
|
||||
isolationMode: 'shared',
|
||||
defaultRoles: ['role.console'],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
listTenants() {
|
||||
return of(this.createTenantResponse());
|
||||
}
|
||||
|
||||
getProfile() {
|
||||
return of({
|
||||
subjectId: 'user-1',
|
||||
username: 'user@example.com',
|
||||
displayName: 'Console User',
|
||||
tenant: 'tenant-default',
|
||||
sessionId: 'session-1',
|
||||
roles: ['role.console'],
|
||||
scopes: ['ui.read'],
|
||||
audiences: ['console'],
|
||||
authenticationMethods: ['pwd'],
|
||||
issuedAt: '2025-10-31T12:00:00Z',
|
||||
authenticationTime: '2025-10-31T12:00:00Z',
|
||||
expiresAt: '2025-10-31T12:10:00Z',
|
||||
freshAuth: true,
|
||||
});
|
||||
}
|
||||
|
||||
introspectToken() {
|
||||
return of({
|
||||
active: true,
|
||||
tenant: 'tenant-default',
|
||||
subject: 'user-1',
|
||||
clientId: 'console-web',
|
||||
tokenId: 'token-1',
|
||||
scopes: ['ui.read'],
|
||||
audiences: ['console'],
|
||||
issuedAt: '2025-10-31T12:00:00Z',
|
||||
authenticationTime: '2025-10-31T12:00:00Z',
|
||||
expiresAt: '2025-10-31T12:10:00Z',
|
||||
freshAuth: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class MockAuthSessionStore {
|
||||
private tenantIdValue: string | null = 'tenant-default';
|
||||
private readonly sessionValue = {
|
||||
tenantId: 'tenant-default',
|
||||
scopes: ['ui.read'],
|
||||
audiences: ['console'],
|
||||
authenticationTimeEpochMs: Date.parse('2025-10-31T12:00:00Z'),
|
||||
freshAuthActive: true,
|
||||
freshAuthExpiresAtEpochMs: Date.parse('2025-10-31T12:05:00Z'),
|
||||
};
|
||||
|
||||
session = () => this.sessionValue as any;
|
||||
|
||||
getActiveTenantId(): string | null {
|
||||
return this.tenantIdValue;
|
||||
}
|
||||
|
||||
setTenantId(tenantId: string | null): void {
|
||||
this.tenantIdValue = tenantId;
|
||||
this.sessionValue.tenantId = tenantId ?? 'tenant-default';
|
||||
}
|
||||
}
|
||||
|
||||
describe('ConsoleSessionService', () => {
|
||||
let service: ConsoleSessionService;
|
||||
let store: ConsoleSessionStore;
|
||||
let authStore: MockAuthSessionStore;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
ConsoleSessionStore,
|
||||
ConsoleSessionService,
|
||||
{ provide: AUTHORITY_CONSOLE_API, useClass: MockConsoleApi },
|
||||
{ provide: AuthSessionStore, useClass: MockAuthSessionStore },
|
||||
],
|
||||
});
|
||||
|
||||
service = TestBed.inject(ConsoleSessionService);
|
||||
store = TestBed.inject(ConsoleSessionStore);
|
||||
authStore = TestBed.inject(AuthSessionStore) as unknown as MockAuthSessionStore;
|
||||
});
|
||||
|
||||
it('loads console context for active tenant', async () => {
|
||||
await service.loadConsoleContext();
|
||||
|
||||
expect(store.tenants().length).toBe(1);
|
||||
expect(store.selectedTenantId()).toBe('tenant-default');
|
||||
expect(store.profile()?.displayName).toBe('Console User');
|
||||
expect(store.tokenInfo()?.freshAuthActive).toBeTrue();
|
||||
});
|
||||
|
||||
it('clears store when no tenant available', async () => {
|
||||
authStore.setTenantId(null);
|
||||
store.setTenants(
|
||||
[
|
||||
{
|
||||
id: 'existing',
|
||||
displayName: 'Existing',
|
||||
status: 'active',
|
||||
isolationMode: 'shared',
|
||||
defaultRoles: [],
|
||||
},
|
||||
],
|
||||
'existing'
|
||||
);
|
||||
|
||||
await service.loadConsoleContext();
|
||||
|
||||
expect(store.tenants().length).toBe(0);
|
||||
expect(store.selectedTenantId()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
import {
|
||||
AUTHORITY_CONSOLE_API,
|
||||
AuthorityConsoleApi,
|
||||
AuthorityTenantViewDto,
|
||||
ConsoleProfileDto,
|
||||
ConsoleTokenIntrospectionDto,
|
||||
} from '../api/authority-console.client';
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import {
|
||||
ConsoleProfile,
|
||||
ConsoleSessionStore,
|
||||
ConsoleTenant,
|
||||
ConsoleTokenInfo,
|
||||
} from './console-session.store';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ConsoleSessionService {
|
||||
private readonly api = inject<AuthorityConsoleApi>(AUTHORITY_CONSOLE_API);
|
||||
private readonly store = inject(ConsoleSessionStore);
|
||||
private readonly authSession = inject(AuthSessionStore);
|
||||
|
||||
async loadConsoleContext(tenantId?: string | null): Promise<void> {
|
||||
const activeTenant =
|
||||
(tenantId && tenantId.trim()) || this.authSession.getActiveTenantId();
|
||||
|
||||
if (!activeTenant) {
|
||||
this.store.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
this.store.setSelectedTenant(activeTenant);
|
||||
this.store.setLoading(true);
|
||||
this.store.setError(null);
|
||||
|
||||
try {
|
||||
const tenantResponse = await firstValueFrom(
|
||||
this.api.listTenants(activeTenant)
|
||||
);
|
||||
const tenants = (tenantResponse.tenants ?? []).map((tenant) =>
|
||||
this.mapTenant(tenant)
|
||||
);
|
||||
|
||||
const [profileDto, tokenDto] = await Promise.all([
|
||||
firstValueFrom(this.api.getProfile(activeTenant)),
|
||||
firstValueFrom(this.api.introspectToken(activeTenant)),
|
||||
]);
|
||||
|
||||
const profile = this.mapProfile(profileDto);
|
||||
const tokenInfo = this.mapTokenInfo(tokenDto);
|
||||
|
||||
this.store.setContext({
|
||||
tenants,
|
||||
profile,
|
||||
token: tokenInfo,
|
||||
selectedTenantId: activeTenant,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load console context', error);
|
||||
this.store.setError('Unable to load console context.');
|
||||
} finally {
|
||||
this.store.setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async switchTenant(tenantId: string): Promise<void> {
|
||||
if (!tenantId || tenantId === this.store.selectedTenantId()) {
|
||||
return this.loadConsoleContext(tenantId);
|
||||
}
|
||||
|
||||
this.store.setSelectedTenant(tenantId);
|
||||
await this.loadConsoleContext(tenantId);
|
||||
}
|
||||
|
||||
async refresh(): Promise<void> {
|
||||
await this.loadConsoleContext(this.store.selectedTenantId());
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.store.clear();
|
||||
}
|
||||
|
||||
private mapTenant(dto: AuthorityTenantViewDto): ConsoleTenant {
|
||||
const roles = Array.isArray(dto.defaultRoles)
|
||||
? dto.defaultRoles
|
||||
.map((role) => role.trim())
|
||||
.filter((role) => role.length > 0)
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
: [];
|
||||
|
||||
return {
|
||||
id: dto.id,
|
||||
displayName: dto.displayName || dto.id,
|
||||
status: dto.status ?? 'active',
|
||||
isolationMode: dto.isolationMode ?? 'shared',
|
||||
defaultRoles: roles,
|
||||
};
|
||||
}
|
||||
|
||||
private mapProfile(dto: ConsoleProfileDto): ConsoleProfile {
|
||||
return {
|
||||
subjectId: dto.subjectId ?? null,
|
||||
username: dto.username ?? null,
|
||||
displayName: dto.displayName ?? dto.username ?? dto.subjectId ?? null,
|
||||
tenant: dto.tenant,
|
||||
sessionId: dto.sessionId ?? null,
|
||||
roles: [...(dto.roles ?? [])].sort((a, b) => a.localeCompare(b)),
|
||||
scopes: [...(dto.scopes ?? [])].sort((a, b) => a.localeCompare(b)),
|
||||
audiences: [...(dto.audiences ?? [])].sort((a, b) =>
|
||||
a.localeCompare(b)
|
||||
),
|
||||
authenticationMethods: [...(dto.authenticationMethods ?? [])],
|
||||
issuedAt: this.parseInstant(dto.issuedAt),
|
||||
authenticationTime: this.parseInstant(dto.authenticationTime),
|
||||
expiresAt: this.parseInstant(dto.expiresAt),
|
||||
freshAuth: !!dto.freshAuth,
|
||||
};
|
||||
}
|
||||
|
||||
private mapTokenInfo(dto: ConsoleTokenIntrospectionDto): ConsoleTokenInfo {
|
||||
const session = this.authSession.session();
|
||||
const freshAuthExpiresAt =
|
||||
session?.freshAuthExpiresAtEpochMs != null
|
||||
? new Date(session.freshAuthExpiresAtEpochMs)
|
||||
: null;
|
||||
|
||||
const authenticationTime =
|
||||
session?.authenticationTimeEpochMs != null
|
||||
? new Date(session.authenticationTimeEpochMs)
|
||||
: this.parseInstant(dto.authenticationTime);
|
||||
|
||||
return {
|
||||
active: !!dto.active,
|
||||
tenant: dto.tenant,
|
||||
subject: dto.subject ?? null,
|
||||
clientId: dto.clientId ?? null,
|
||||
tokenId: dto.tokenId ?? null,
|
||||
scopes: [...(dto.scopes ?? [])].sort((a, b) => a.localeCompare(b)),
|
||||
audiences: [...(dto.audiences ?? [])].sort((a, b) =>
|
||||
a.localeCompare(b)
|
||||
),
|
||||
issuedAt: this.parseInstant(dto.issuedAt),
|
||||
authenticationTime,
|
||||
expiresAt: this.parseInstant(dto.expiresAt),
|
||||
freshAuthActive: session?.freshAuthActive ?? !!dto.freshAuth,
|
||||
freshAuthExpiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
private parseInstant(value: string | null | undefined): Date | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { ConsoleSessionStore } from './console-session.store';
|
||||
|
||||
describe('ConsoleSessionStore', () => {
|
||||
let store: ConsoleSessionStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = new ConsoleSessionStore();
|
||||
});
|
||||
|
||||
it('tracks tenants and selection', () => {
|
||||
const tenants = [
|
||||
{
|
||||
id: 'tenant-a',
|
||||
displayName: 'Tenant A',
|
||||
status: 'active',
|
||||
isolationMode: 'shared',
|
||||
defaultRoles: ['role.a'],
|
||||
},
|
||||
{
|
||||
id: 'tenant-b',
|
||||
displayName: 'Tenant B',
|
||||
status: 'active',
|
||||
isolationMode: 'shared',
|
||||
defaultRoles: ['role.b'],
|
||||
},
|
||||
];
|
||||
|
||||
const selected = store.setTenants(tenants, 'tenant-b');
|
||||
expect(selected).toBe('tenant-b');
|
||||
expect(store.selectedTenantId()).toBe('tenant-b');
|
||||
expect(store.tenants().length).toBe(2);
|
||||
});
|
||||
|
||||
it('sets context with profile and token info', () => {
|
||||
const tenants = [
|
||||
{
|
||||
id: 'tenant-a',
|
||||
displayName: 'Tenant A',
|
||||
status: 'active',
|
||||
isolationMode: 'shared',
|
||||
defaultRoles: ['role.a'],
|
||||
},
|
||||
];
|
||||
|
||||
store.setContext({
|
||||
tenants,
|
||||
selectedTenantId: 'tenant-a',
|
||||
profile: {
|
||||
subjectId: 'user-1',
|
||||
username: 'user@example.com',
|
||||
displayName: 'User Example',
|
||||
tenant: 'tenant-a',
|
||||
sessionId: 'session-123',
|
||||
roles: ['role.a'],
|
||||
scopes: ['scope.a'],
|
||||
audiences: ['aud'],
|
||||
authenticationMethods: ['pwd'],
|
||||
issuedAt: new Date('2025-10-31T12:00:00Z'),
|
||||
authenticationTime: new Date('2025-10-31T12:00:00Z'),
|
||||
expiresAt: new Date('2025-10-31T12:10:00Z'),
|
||||
freshAuth: true,
|
||||
},
|
||||
token: {
|
||||
active: true,
|
||||
tenant: 'tenant-a',
|
||||
subject: 'user-1',
|
||||
clientId: 'client',
|
||||
tokenId: 'token-1',
|
||||
scopes: ['scope.a'],
|
||||
audiences: ['aud'],
|
||||
issuedAt: new Date('2025-10-31T12:00:00Z'),
|
||||
authenticationTime: new Date('2025-10-31T12:00:00Z'),
|
||||
expiresAt: new Date('2025-10-31T12:10:00Z'),
|
||||
freshAuthActive: true,
|
||||
freshAuthExpiresAt: new Date('2025-10-31T12:05:00Z'),
|
||||
},
|
||||
});
|
||||
|
||||
expect(store.selectedTenantId()).toBe('tenant-a');
|
||||
expect(store.profile()?.displayName).toBe('User Example');
|
||||
expect(store.tokenInfo()?.freshAuthActive).toBeTrue();
|
||||
expect(store.hasContext()).toBeTrue();
|
||||
});
|
||||
|
||||
it('clears state', () => {
|
||||
store.setTenants(
|
||||
[
|
||||
{
|
||||
id: 'tenant-a',
|
||||
displayName: 'Tenant A',
|
||||
status: 'active',
|
||||
isolationMode: 'shared',
|
||||
defaultRoles: [],
|
||||
},
|
||||
],
|
||||
'tenant-a'
|
||||
);
|
||||
store.setProfile({
|
||||
subjectId: null,
|
||||
username: null,
|
||||
displayName: null,
|
||||
tenant: 'tenant-a',
|
||||
sessionId: null,
|
||||
roles: [],
|
||||
scopes: [],
|
||||
audiences: [],
|
||||
authenticationMethods: [],
|
||||
issuedAt: null,
|
||||
authenticationTime: null,
|
||||
expiresAt: null,
|
||||
freshAuth: false,
|
||||
});
|
||||
|
||||
store.clear();
|
||||
|
||||
expect(store.tenants().length).toBe(0);
|
||||
expect(store.selectedTenantId()).toBeNull();
|
||||
expect(store.profile()).toBeNull();
|
||||
expect(store.tokenInfo()).toBeNull();
|
||||
expect(store.loading()).toBeFalse();
|
||||
expect(store.error()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { Injectable, computed, signal } from '@angular/core';
|
||||
|
||||
export interface ConsoleTenant {
|
||||
readonly id: string;
|
||||
readonly displayName: string;
|
||||
readonly status: string;
|
||||
readonly isolationMode: string;
|
||||
readonly defaultRoles: readonly string[];
|
||||
}
|
||||
|
||||
export interface ConsoleProfile {
|
||||
readonly subjectId: string | null;
|
||||
readonly username: string | null;
|
||||
readonly displayName: string | null;
|
||||
readonly tenant: string;
|
||||
readonly sessionId: string | null;
|
||||
readonly roles: readonly string[];
|
||||
readonly scopes: readonly string[];
|
||||
readonly audiences: readonly string[];
|
||||
readonly authenticationMethods: readonly string[];
|
||||
readonly issuedAt: Date | null;
|
||||
readonly authenticationTime: Date | null;
|
||||
readonly expiresAt: Date | null;
|
||||
readonly freshAuth: boolean;
|
||||
}
|
||||
|
||||
export interface ConsoleTokenInfo {
|
||||
readonly active: boolean;
|
||||
readonly tenant: string;
|
||||
readonly subject: string | null;
|
||||
readonly clientId: string | null;
|
||||
readonly tokenId: string | null;
|
||||
readonly scopes: readonly string[];
|
||||
readonly audiences: readonly string[];
|
||||
readonly issuedAt: Date | null;
|
||||
readonly authenticationTime: Date | null;
|
||||
readonly expiresAt: Date | null;
|
||||
readonly freshAuthActive: boolean;
|
||||
readonly freshAuthExpiresAt: Date | null;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ConsoleSessionStore {
|
||||
private readonly tenantsSignal = signal<ConsoleTenant[]>([]);
|
||||
private readonly selectedTenantIdSignal = signal<string | null>(null);
|
||||
private readonly profileSignal = signal<ConsoleProfile | null>(null);
|
||||
private readonly tokenSignal = signal<ConsoleTokenInfo | null>(null);
|
||||
private readonly loadingSignal = signal(false);
|
||||
private readonly errorSignal = signal<string | null>(null);
|
||||
|
||||
readonly tenants = computed(() => this.tenantsSignal());
|
||||
readonly selectedTenantId = computed(() => this.selectedTenantIdSignal());
|
||||
readonly profile = computed(() => this.profileSignal());
|
||||
readonly tokenInfo = computed(() => this.tokenSignal());
|
||||
readonly loading = computed(() => this.loadingSignal());
|
||||
readonly error = computed(() => this.errorSignal());
|
||||
readonly hasContext = computed(
|
||||
() =>
|
||||
this.tenantsSignal().length > 0 ||
|
||||
this.profileSignal() !== null ||
|
||||
this.tokenSignal() !== null
|
||||
);
|
||||
|
||||
setLoading(loading: boolean): void {
|
||||
this.loadingSignal.set(loading);
|
||||
}
|
||||
|
||||
setError(message: string | null): void {
|
||||
this.errorSignal.set(message);
|
||||
}
|
||||
|
||||
setContext(context: {
|
||||
tenants: ConsoleTenant[];
|
||||
profile: ConsoleProfile | null;
|
||||
token: ConsoleTokenInfo | null;
|
||||
selectedTenantId?: string | null;
|
||||
}): void {
|
||||
const selected = this.setTenants(context.tenants, context.selectedTenantId);
|
||||
this.profileSignal.set(context.profile);
|
||||
this.tokenSignal.set(context.token);
|
||||
this.selectedTenantIdSignal.set(selected);
|
||||
}
|
||||
|
||||
setProfile(profile: ConsoleProfile | null): void {
|
||||
this.profileSignal.set(profile);
|
||||
}
|
||||
|
||||
setTokenInfo(token: ConsoleTokenInfo | null): void {
|
||||
this.tokenSignal.set(token);
|
||||
}
|
||||
|
||||
setTenants(
|
||||
tenants: ConsoleTenant[],
|
||||
preferredTenantId?: string | null
|
||||
): string | null {
|
||||
this.tenantsSignal.set(tenants);
|
||||
const currentSelection = this.selectedTenantIdSignal();
|
||||
const fallbackSelection =
|
||||
tenants.length > 0 ? tenants[0].id : null;
|
||||
|
||||
const nextSelection =
|
||||
(preferredTenantId &&
|
||||
tenants.some((tenant) => tenant.id === preferredTenantId) &&
|
||||
preferredTenantId) ||
|
||||
(currentSelection &&
|
||||
tenants.some((tenant) => tenant.id === currentSelection) &&
|
||||
currentSelection) ||
|
||||
fallbackSelection;
|
||||
|
||||
this.selectedTenantIdSignal.set(nextSelection);
|
||||
return nextSelection;
|
||||
}
|
||||
|
||||
setSelectedTenant(tenantId: string | null): void {
|
||||
this.selectedTenantIdSignal.set(tenantId);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.tenantsSignal.set([]);
|
||||
this.selectedTenantIdSignal.set(null);
|
||||
this.profileSignal.set(null);
|
||||
this.tokenSignal.set(null);
|
||||
this.loadingSignal.set(false);
|
||||
this.errorSignal.set(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
|
||||
export interface OperatorContext {
|
||||
readonly reason: string;
|
||||
readonly ticket: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class OperatorContextService {
|
||||
private readonly contextSignal = signal<OperatorContext | null>(null);
|
||||
|
||||
readonly context = this.contextSignal.asReadonly();
|
||||
|
||||
setContext(reason: string, ticket: string): void {
|
||||
const normalizedReason = reason.trim();
|
||||
const normalizedTicket = ticket.trim();
|
||||
if (!normalizedReason || !normalizedTicket) {
|
||||
throw new Error(
|
||||
'operator_reason and operator_ticket must be provided for orchestrator control actions.'
|
||||
);
|
||||
}
|
||||
|
||||
this.contextSignal.set({ reason: normalizedReason, ticket: normalizedTicket });
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.contextSignal.set(null);
|
||||
}
|
||||
|
||||
snapshot(): OperatorContext | null {
|
||||
return this.contextSignal();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
HttpEvent,
|
||||
HttpHandler,
|
||||
HttpInterceptor,
|
||||
HttpRequest,
|
||||
} from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { OperatorContextService } from './operator-context.service';
|
||||
|
||||
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';
|
||||
|
||||
@Injectable()
|
||||
export class OperatorMetadataInterceptor implements HttpInterceptor {
|
||||
constructor(private readonly context: OperatorContextService) {}
|
||||
|
||||
intercept(
|
||||
request: HttpRequest<unknown>,
|
||||
next: HttpHandler
|
||||
): Observable<HttpEvent<unknown>> {
|
||||
if (!request.headers.has(OPERATOR_METADATA_SENTINEL_HEADER)) {
|
||||
return next.handle(request);
|
||||
}
|
||||
|
||||
const current = this.context.snapshot();
|
||||
const headers = request.headers.delete(OPERATOR_METADATA_SENTINEL_HEADER);
|
||||
|
||||
if (!current) {
|
||||
return next.handle(request.clone({ headers }));
|
||||
}
|
||||
|
||||
const enriched = headers
|
||||
.set(OPERATOR_REASON_HEADER, current.reason)
|
||||
.set(OPERATOR_TICKET_HEADER, current.ticket);
|
||||
|
||||
return next.handle(request.clone({ headers: enriched }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
|
||||
import { AuthorityAuthService } from '../../core/auth/authority-auth.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-auth-callback',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<section class="auth-callback">
|
||||
<p *ngIf="state() === 'processing'">Completing sign-in…</p>
|
||||
<p *ngIf="state() === 'error'" class="error">
|
||||
We were unable to complete the sign-in flow. Please try again.
|
||||
</p>
|
||||
</section>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.auth-callback {
|
||||
margin: 4rem auto;
|
||||
max-width: 420px;
|
||||
text-align: center;
|
||||
font-size: 1rem;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #dc2626;
|
||||
font-weight: 500;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class AuthCallbackComponent implements OnInit {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly auth = inject(AuthorityAuthService);
|
||||
|
||||
readonly state = signal<'processing' | 'error'>('processing');
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
const params = this.route.snapshot.queryParamMap;
|
||||
const searchParams = new URLSearchParams();
|
||||
params.keys.forEach((key) => {
|
||||
const value = params.get(key);
|
||||
if (value != null) {
|
||||
searchParams.set(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await this.auth.completeLoginFromRedirect(searchParams);
|
||||
const returnUrl = result.returnUrl ?? '/';
|
||||
await this.router.navigateByUrl(returnUrl, { replaceUrl: true });
|
||||
} catch {
|
||||
this.state.set('error');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
<section class="console-profile">
|
||||
<header class="console-profile__header">
|
||||
<div>
|
||||
<h1>Console Session</h1>
|
||||
<p class="console-profile__subtitle">
|
||||
Session details sourced from Authority console endpoints.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
(click)="refresh()"
|
||||
[disabled]="loading()"
|
||||
aria-busy="{{ loading() }}"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="console-profile__error" *ngIf="error() as message">
|
||||
{{ message }}
|
||||
</div>
|
||||
|
||||
<div class="console-profile__loading" *ngIf="loading()">
|
||||
Loading console context…
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="!loading()">
|
||||
<section class="console-profile__card" *ngIf="profile() as profile">
|
||||
<header>
|
||||
<h2>User Profile</h2>
|
||||
<span class="tenant-chip">
|
||||
Tenant
|
||||
<strong>{{ profile.tenant }}</strong>
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Display name</dt>
|
||||
<dd>{{ profile.displayName || 'n/a' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Username</dt>
|
||||
<dd>{{ profile.username || 'n/a' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Subject</dt>
|
||||
<dd>{{ profile.subjectId || 'n/a' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Session ID</dt>
|
||||
<dd>{{ profile.sessionId || 'n/a' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Roles</dt>
|
||||
<dd>
|
||||
<span *ngIf="profile.roles.length; else noRoles">
|
||||
{{ profile.roles.join(', ') }}
|
||||
</span>
|
||||
<ng-template #noRoles>n/a</ng-template>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Scopes</dt>
|
||||
<dd>
|
||||
<span *ngIf="profile.scopes.length; else noScopes">
|
||||
{{ profile.scopes.join(', ') }}
|
||||
</span>
|
||||
<ng-template #noScopes>n/a</ng-template>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Audiences</dt>
|
||||
<dd>
|
||||
<span *ngIf="profile.audiences.length; else noAudiences">
|
||||
{{ profile.audiences.join(', ') }}
|
||||
</span>
|
||||
<ng-template #noAudiences>n/a</ng-template>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Authentication methods</dt>
|
||||
<dd>
|
||||
<span
|
||||
*ngIf="profile.authenticationMethods.length; else noAuthMethods"
|
||||
>
|
||||
{{ profile.authenticationMethods.join(', ') }}
|
||||
</span>
|
||||
<ng-template #noAuthMethods>n/a</ng-template>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Issued at</dt>
|
||||
<dd>
|
||||
{{ profile.issuedAt ? (profile.issuedAt | date : 'medium') : 'n/a' }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Authentication time</dt>
|
||||
<dd>
|
||||
{{
|
||||
profile.authenticationTime
|
||||
? (profile.authenticationTime | date : 'medium')
|
||||
: 'n/a'
|
||||
}}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Expires at</dt>
|
||||
<dd>
|
||||
{{ profile.expiresAt ? (profile.expiresAt | date : 'medium') : 'n/a' }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="console-profile__card" *ngIf="tokenInfo() as token">
|
||||
<header>
|
||||
<h2>Access Token</h2>
|
||||
<span
|
||||
class="chip"
|
||||
[class.chip--active]="token.active"
|
||||
[class.chip--inactive]="!token.active"
|
||||
>
|
||||
{{ token.active ? 'Active' : 'Inactive' }}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Token ID</dt>
|
||||
<dd>{{ token.tokenId || 'n/a' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Client ID</dt>
|
||||
<dd>{{ token.clientId || 'n/a' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Issued at</dt>
|
||||
<dd>
|
||||
{{ token.issuedAt ? (token.issuedAt | date : 'medium') : 'n/a' }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Authentication time</dt>
|
||||
<dd>
|
||||
{{
|
||||
token.authenticationTime
|
||||
? (token.authenticationTime | date : 'medium')
|
||||
: 'n/a'
|
||||
}}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Expires at</dt>
|
||||
<dd>
|
||||
{{ token.expiresAt ? (token.expiresAt | date : 'medium') : 'n/a' }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div
|
||||
class="fresh-auth"
|
||||
*ngIf="freshAuthState() as fresh"
|
||||
[class.fresh-auth--active]="fresh.active"
|
||||
[class.fresh-auth--stale]="!fresh.active"
|
||||
>
|
||||
Fresh auth:
|
||||
<strong>{{ fresh.active ? 'Active' : 'Stale' }}</strong>
|
||||
<ng-container *ngIf="fresh.expiresAt">
|
||||
(expires {{ fresh.expiresAt | date : 'mediumTime' }})
|
||||
</ng-container>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="console-profile__card" *ngIf="tenantCount() > 0">
|
||||
<header>
|
||||
<h2>Accessible Tenants</h2>
|
||||
<span class="tenant-count">{{ tenantCount() }} total</span>
|
||||
</header>
|
||||
|
||||
<ul class="tenant-list">
|
||||
<li
|
||||
*ngFor="let tenant of tenants()"
|
||||
[class.tenant-list__item--active]="tenant.id === selectedTenantId()"
|
||||
>
|
||||
<button type="button" (click)="selectTenant(tenant.id)">
|
||||
<div class="tenant-list__heading">
|
||||
<span class="tenant-name">{{ tenant.displayName }}</span>
|
||||
<span class="tenant-status">{{ tenant.status }}</span>
|
||||
</div>
|
||||
<div class="tenant-meta">
|
||||
Isolation: {{ tenant.isolationMode }} · Default roles:
|
||||
<span *ngIf="tenant.defaultRoles.length; else noTenantRoles">
|
||||
{{ tenant.defaultRoles.join(', ') }}
|
||||
</span>
|
||||
<ng-template #noTenantRoles>n/a</ng-template>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<p class="console-profile__empty" *ngIf="!hasProfile() && tenantCount() === 0">
|
||||
No console session data available for the current identity.
|
||||
</p>
|
||||
</ng-container>
|
||||
</section>
|
||||
@@ -0,0 +1,220 @@
|
||||
.console-profile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.console-profile__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.console-profile__subtitle {
|
||||
margin: 0.25rem 0 0;
|
||||
color: #475569;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
button {
|
||||
appearance: none;
|
||||
border: none;
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.5rem 1.2rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
background: linear-gradient(90deg, #4f46e5 0%, #9333ea 100%);
|
||||
color: #f8fafc;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 18px rgba(79, 70, 229, 0.28);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: progress;
|
||||
opacity: 0.75;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.console-profile__loading {
|
||||
padding: 1rem 1.25rem;
|
||||
border-radius: 0.75rem;
|
||||
background: rgba(79, 70, 229, 0.08);
|
||||
color: #4338ca;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.console-profile__error {
|
||||
padding: 1rem 1.25rem;
|
||||
border-radius: 0.75rem;
|
||||
background: rgba(220, 38, 38, 0.08);
|
||||
color: #b91c1c;
|
||||
border: 1px solid rgba(220, 38, 38, 0.24);
|
||||
}
|
||||
|
||||
.console-profile__card {
|
||||
background: linear-gradient(150deg, #ffffff 0%, #f8f9ff 100%);
|
||||
border-radius: 1rem;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.08);
|
||||
border: 1px solid rgba(79, 70, 229, 0.08);
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.25rem;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
}
|
||||
|
||||
dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 1rem 1.5rem;
|
||||
margin: 0;
|
||||
|
||||
dt {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
color: #0f172a;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chip,
|
||||
.tenant-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
background-color: rgba(15, 23, 42, 0.08);
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.chip--active {
|
||||
background-color: rgba(22, 163, 74, 0.12);
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.chip--inactive {
|
||||
background-color: rgba(220, 38, 38, 0.12);
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.tenant-chip {
|
||||
background-color: rgba(79, 70, 229, 0.12);
|
||||
color: #4338ca;
|
||||
}
|
||||
|
||||
.tenant-count {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.tenant-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.tenant-list__item--active button {
|
||||
border-color: rgba(79, 70, 229, 0.45);
|
||||
background-color: rgba(79, 70, 229, 0.08);
|
||||
}
|
||||
|
||||
.tenant-list button {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.35rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid rgba(100, 116, 139, 0.18);
|
||||
background: #ffffff;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
border-color: rgba(79, 70, 229, 0.45);
|
||||
box-shadow: 0 8px 20px rgba(79, 70, 229, 0.16);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
.tenant-list__heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.tenant-meta {
|
||||
font-size: 0.85rem;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.fresh-auth {
|
||||
margin-top: 1.25rem;
|
||||
padding: 0.6rem 0.9rem;
|
||||
border-radius: 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.fresh-auth--active {
|
||||
background-color: rgba(16, 185, 129, 0.1);
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.fresh-auth--stale {
|
||||
background-color: rgba(249, 115, 22, 0.12);
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.console-profile__empty {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
color: #475569;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { ConsoleSessionService } from '../../core/console/console-session.service';
|
||||
import { ConsoleSessionStore } from '../../core/console/console-session.store';
|
||||
import { ConsoleProfileComponent } from './console-profile.component';
|
||||
|
||||
class MockConsoleSessionService {
|
||||
loadConsoleContext = jasmine
|
||||
.createSpy('loadConsoleContext')
|
||||
.and.returnValue(Promise.resolve());
|
||||
refresh = jasmine
|
||||
.createSpy('refresh')
|
||||
.and.returnValue(Promise.resolve());
|
||||
switchTenant = jasmine
|
||||
.createSpy('switchTenant')
|
||||
.and.returnValue(Promise.resolve());
|
||||
}
|
||||
|
||||
describe('ConsoleProfileComponent', () => {
|
||||
let fixture: ComponentFixture<ConsoleProfileComponent>;
|
||||
let service: MockConsoleSessionService;
|
||||
let store: ConsoleSessionStore;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ConsoleProfileComponent],
|
||||
providers: [
|
||||
ConsoleSessionStore,
|
||||
{ provide: ConsoleSessionService, useClass: MockConsoleSessionService },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
service = TestBed.inject(
|
||||
ConsoleSessionService
|
||||
) as unknown as MockConsoleSessionService;
|
||||
store = TestBed.inject(ConsoleSessionStore);
|
||||
fixture = TestBed.createComponent(ConsoleProfileComponent);
|
||||
});
|
||||
|
||||
it('renders profile and tenant information', async () => {
|
||||
store.setContext({
|
||||
tenants: [
|
||||
{
|
||||
id: 'tenant-default',
|
||||
displayName: 'Tenant Default',
|
||||
status: 'active',
|
||||
isolationMode: 'shared',
|
||||
defaultRoles: ['role.console'],
|
||||
},
|
||||
],
|
||||
selectedTenantId: 'tenant-default',
|
||||
profile: {
|
||||
subjectId: 'user-1',
|
||||
username: 'user@example.com',
|
||||
displayName: 'Console User',
|
||||
tenant: 'tenant-default',
|
||||
sessionId: 'session-1',
|
||||
roles: ['role.console'],
|
||||
scopes: ['ui.read'],
|
||||
audiences: ['console'],
|
||||
authenticationMethods: ['pwd'],
|
||||
issuedAt: new Date('2025-10-31T12:00:00Z'),
|
||||
authenticationTime: new Date('2025-10-31T12:00:00Z'),
|
||||
expiresAt: new Date('2025-10-31T12:10:00Z'),
|
||||
freshAuth: true,
|
||||
},
|
||||
token: {
|
||||
active: true,
|
||||
tenant: 'tenant-default',
|
||||
subject: 'user-1',
|
||||
clientId: 'console-web',
|
||||
tokenId: 'token-1',
|
||||
scopes: ['ui.read'],
|
||||
audiences: ['console'],
|
||||
issuedAt: new Date('2025-10-31T12:00:00Z'),
|
||||
authenticationTime: new Date('2025-10-31T12:00:00Z'),
|
||||
expiresAt: new Date('2025-10-31T12:10:00Z'),
|
||||
freshAuthActive: true,
|
||||
freshAuthExpiresAt: new Date('2025-10-31T12:05:00Z'),
|
||||
},
|
||||
});
|
||||
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
expect(compiled.querySelector('h1')?.textContent).toContain(
|
||||
'Console Session'
|
||||
);
|
||||
expect(compiled.querySelector('.tenant-name')?.textContent).toContain(
|
||||
'Tenant Default'
|
||||
);
|
||||
expect(compiled.querySelector('dd')?.textContent).toContain('Console User');
|
||||
expect(service.loadConsoleContext).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('invokes refresh on demand', async () => {
|
||||
store.clear();
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
const button = fixture.nativeElement.querySelector(
|
||||
'button[type="button"]'
|
||||
) as HTMLButtonElement;
|
||||
button.click();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(service.refresh).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
OnInit,
|
||||
computed,
|
||||
inject,
|
||||
} from '@angular/core';
|
||||
|
||||
import { ConsoleSessionService } from '../../core/console/console-session.service';
|
||||
import { ConsoleSessionStore } from '../../core/console/console-session.store';
|
||||
|
||||
@Component({
|
||||
selector: 'app-console-profile',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './console-profile.component.html',
|
||||
styleUrls: ['./console-profile.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ConsoleProfileComponent implements OnInit {
|
||||
private readonly store = inject(ConsoleSessionStore);
|
||||
private readonly service = inject(ConsoleSessionService);
|
||||
|
||||
readonly loading = this.store.loading;
|
||||
readonly error = this.store.error;
|
||||
readonly profile = this.store.profile;
|
||||
readonly tokenInfo = this.store.tokenInfo;
|
||||
readonly tenants = this.store.tenants;
|
||||
readonly selectedTenantId = this.store.selectedTenantId;
|
||||
|
||||
readonly hasProfile = computed(() => this.profile() !== null);
|
||||
readonly tenantCount = computed(() => this.tenants().length);
|
||||
readonly freshAuthState = computed(() => {
|
||||
const token = this.tokenInfo();
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
active: token.freshAuthActive,
|
||||
expiresAt: token.freshAuthExpiresAt,
|
||||
};
|
||||
});
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
if (!this.store.hasContext()) {
|
||||
try {
|
||||
await this.service.loadConsoleContext();
|
||||
} catch {
|
||||
// error surfaced via store
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async refresh(): Promise<void> {
|
||||
try {
|
||||
await this.service.refresh();
|
||||
} catch {
|
||||
// error surfaced via store
|
||||
}
|
||||
}
|
||||
|
||||
async selectTenant(tenantId: string): Promise<void> {
|
||||
try {
|
||||
await this.service.switchTenant(tenantId);
|
||||
} catch {
|
||||
// error surfaced via store
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
<section class="notify-panel" aria-live="polite">
|
||||
<header class="notify-panel__header">
|
||||
<div>
|
||||
<p class="eyebrow">Notifications</p>
|
||||
<h1>Notify control plane</h1>
|
||||
<p>Manage channels, routing rules, deliveries, and preview payloads offline.</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="ghost-button"
|
||||
(click)="refreshAll()"
|
||||
[disabled]="channelLoading() || ruleLoading() || deliveriesLoading()"
|
||||
>
|
||||
Refresh data
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="notify-grid">
|
||||
<article class="notify-card">
|
||||
<header class="notify-card__header">
|
||||
<div>
|
||||
<h2>Channels</h2>
|
||||
<p>Destinations for Slack, Teams, Email, or Webhook notifications.</p>
|
||||
</div>
|
||||
<button type="button" class="ghost-button" (click)="createChannelDraft()">New channel</button>
|
||||
</header>
|
||||
|
||||
<p *ngIf="channelMessage()" class="notify-message" role="status">
|
||||
{{ channelMessage() }}
|
||||
</p>
|
||||
|
||||
<ul class="channel-list" role="list">
|
||||
<li *ngFor="let channel of channels(); trackBy: trackByChannel">
|
||||
<button
|
||||
type="button"
|
||||
class="channel-item"
|
||||
data-testid="channel-item"
|
||||
[class.active]="selectedChannelId() === channel.channelId"
|
||||
(click)="selectChannel(channel.channelId)"
|
||||
>
|
||||
<span class="channel-name">{{ channel.displayName || channel.name }}</span>
|
||||
<span class="channel-meta">{{ channel.type }}</span>
|
||||
<span class="channel-status" [class.channel-status--enabled]="channel.enabled">
|
||||
{{ channel.enabled ? 'Enabled' : 'Disabled' }}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<form
|
||||
class="channel-form"
|
||||
[formGroup]="channelForm"
|
||||
(ngSubmit)="saveChannel()"
|
||||
novalidate
|
||||
>
|
||||
<div class="form-grid">
|
||||
<label>
|
||||
<span>Name</span>
|
||||
<input formControlName="name" type="text" required />
|
||||
</label>
|
||||
<label>
|
||||
<span>Display name</span>
|
||||
<input formControlName="displayName" type="text" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Type</span>
|
||||
<select formControlName="type">
|
||||
<option *ngFor="let type of channelTypes" [value]="type">
|
||||
{{ type }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Secret reference</span>
|
||||
<input formControlName="secretRef" type="text" required />
|
||||
</label>
|
||||
<label>
|
||||
<span>Target</span>
|
||||
<input formControlName="target" type="text" placeholder="#alerts or email" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Endpoint</span>
|
||||
<input formControlName="endpoint" type="text" placeholder="https://example" />
|
||||
</label>
|
||||
<label class="full-width">
|
||||
<span>Description</span>
|
||||
<textarea formControlName="description" rows="2"></textarea>
|
||||
</label>
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" formControlName="enabled" />
|
||||
<span>Channel enabled</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-grid">
|
||||
<label>
|
||||
<span>Labels (key=value)</span>
|
||||
<textarea formControlName="labelsText" rows="2" placeholder="tier=critical"></textarea>
|
||||
</label>
|
||||
<label>
|
||||
<span>Metadata (key=value)</span>
|
||||
<textarea formControlName="metadataText" rows="2" placeholder="workspace=stellaops"></textarea>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="notify-actions">
|
||||
<button type="button" class="ghost-button" (click)="createChannelDraft()">
|
||||
Reset
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="ghost-button"
|
||||
(click)="deleteChannel()"
|
||||
[disabled]="channelLoading() || !selectedChannelId()"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button type="submit" [disabled]="channelLoading()">
|
||||
Save channel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section *ngIf="channelHealth() as health" class="channel-health" aria-live="polite">
|
||||
<div class="status-pill" [class.status-pill--healthy]="health.status === 'Healthy'" [class.status-pill--warning]="health.status === 'Degraded'" [class.status-pill--error]="health.status === 'Unhealthy'">
|
||||
{{ health.status }}
|
||||
</div>
|
||||
<div class="channel-health__details">
|
||||
<p>{{ health.message }}</p>
|
||||
<small>Last checked {{ health.checkedAt | date: 'medium' }} • Trace {{ health.traceId }}</small>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<form class="test-form" [formGroup]="testForm" (ngSubmit)="sendTestPreview()" novalidate>
|
||||
<h3>Test send</h3>
|
||||
<div class="form-grid">
|
||||
<label>
|
||||
<span>Preview title</span>
|
||||
<input formControlName="title" type="text" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Summary</span>
|
||||
<input formControlName="summary" type="text" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Override target</span>
|
||||
<input formControlName="target" type="text" placeholder="#alerts or user@org" />
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
<span>Body</span>
|
||||
<textarea formControlName="body" rows="3"></textarea>
|
||||
</label>
|
||||
<div class="notify-actions">
|
||||
<button type="submit" [disabled]="testSending()">
|
||||
{{ testSending() ? 'Sending…' : 'Send test' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section *ngIf="testPreview() as preview" class="test-preview" data-testid="test-preview">
|
||||
<header>
|
||||
<strong>Preview queued</strong>
|
||||
<span>{{ preview.queuedAt | date: 'short' }}</span>
|
||||
</header>
|
||||
<p><span>Target:</span> {{ preview.preview.target }}</p>
|
||||
<p><span>Title:</span> {{ preview.preview.title }}</p>
|
||||
<p><span>Summary:</span> {{ preview.preview.summary || 'n/a' }}</p>
|
||||
<p class="preview-body">{{ preview.preview.body }}</p>
|
||||
</section>
|
||||
</article>
|
||||
|
||||
<article class="notify-card">
|
||||
<header class="notify-card__header">
|
||||
<div>
|
||||
<h2>Rules</h2>
|
||||
<p>Define routing logic and throttles per channel.</p>
|
||||
</div>
|
||||
<button type="button" class="ghost-button" (click)="createRuleDraft()">New rule</button>
|
||||
</header>
|
||||
|
||||
<p *ngIf="ruleMessage()" class="notify-message" role="status">
|
||||
{{ ruleMessage() }}
|
||||
</p>
|
||||
|
||||
<ul class="rule-list" role="list">
|
||||
<li *ngFor="let rule of rules(); trackBy: trackByRule">
|
||||
<button
|
||||
type="button"
|
||||
class="rule-item"
|
||||
data-testid="rule-item"
|
||||
[class.active]="selectedRuleId() === rule.ruleId"
|
||||
(click)="selectRule(rule.ruleId)"
|
||||
>
|
||||
<span class="rule-name">{{ rule.name }}</span>
|
||||
<span class="rule-meta">{{ rule.match?.minSeverity || 'any' }}</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<form class="rule-form" [formGroup]="ruleForm" (ngSubmit)="saveRule()" novalidate>
|
||||
<div class="form-grid">
|
||||
<label>
|
||||
<span>Name</span>
|
||||
<input formControlName="name" type="text" required />
|
||||
</label>
|
||||
<label>
|
||||
<span>Minimum severity</span>
|
||||
<select formControlName="minSeverity">
|
||||
<option value="">Any</option>
|
||||
<option *ngFor="let sev of severityOptions" [value]="sev">
|
||||
{{ sev }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Channel</span>
|
||||
<select formControlName="channel" required>
|
||||
<option *ngFor="let channel of channels()" [value]="channel.channelId">
|
||||
{{ channel.displayName || channel.name }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Digest</span>
|
||||
<input formControlName="digest" type="text" placeholder="instant or 1h" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Template</span>
|
||||
<input formControlName="template" type="text" placeholder="tmpl-critical" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Locale</span>
|
||||
<input formControlName="locale" type="text" placeholder="en-US" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Throttle (seconds)</span>
|
||||
<input formControlName="throttleSeconds" type="number" min="0" />
|
||||
</label>
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" formControlName="enabled" />
|
||||
<span>Rule enabled</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
<span>Event kinds (comma or newline)</span>
|
||||
<textarea formControlName="eventKindsText" rows="2"></textarea>
|
||||
</label>
|
||||
<label>
|
||||
<span>Labels filter</span>
|
||||
<textarea formControlName="labelsText" rows="2" placeholder="kev,critical"></textarea>
|
||||
</label>
|
||||
<label>
|
||||
<span>Description</span>
|
||||
<textarea formControlName="description" rows="2"></textarea>
|
||||
</label>
|
||||
|
||||
<div class="notify-actions">
|
||||
<button type="button" class="ghost-button" (click)="createRuleDraft()">
|
||||
Reset
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="ghost-button"
|
||||
(click)="deleteRule()"
|
||||
[disabled]="ruleLoading() || !selectedRuleId()"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button type="submit" [disabled]="ruleLoading()">
|
||||
Save rule
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</article>
|
||||
|
||||
<article class="notify-card notify-card--deliveries">
|
||||
<header class="notify-card__header">
|
||||
<div>
|
||||
<h2>Deliveries</h2>
|
||||
<p>Recent delivery attempts, statuses, and preview traces.</p>
|
||||
</div>
|
||||
<button type="button" class="ghost-button" (click)="refreshDeliveries()" [disabled]="deliveriesLoading()">Refresh</button>
|
||||
</header>
|
||||
|
||||
<div class="deliveries-controls">
|
||||
<label>
|
||||
<span>Status filter</span>
|
||||
<select [value]="deliveryFilter()" (change)="onDeliveryFilterChange($any($event.target).value)">
|
||||
<option value="all">All</option>
|
||||
<option value="sent">Sent</option>
|
||||
<option value="failed">Failed</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="throttled">Throttled</option>
|
||||
<option value="digested">Digested</option>
|
||||
<option value="dropped">Dropped</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p *ngIf="deliveriesMessage()" class="notify-message" role="status">
|
||||
{{ deliveriesMessage() }}
|
||||
</p>
|
||||
|
||||
<div class="deliveries-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col">Target</th>
|
||||
<th scope="col">Kind</th>
|
||||
<th scope="col">Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
*ngFor="let delivery of filteredDeliveries(); trackBy: trackByDelivery"
|
||||
data-testid="delivery-row"
|
||||
>
|
||||
<td>
|
||||
<span class="status-badge" [class.status-badge--sent]="delivery.status === 'Sent'" [class.status-badge--failed]="delivery.status === 'Failed'" [class.status-badge--throttled]="delivery.status === 'Throttled'">
|
||||
{{ delivery.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{{ delivery.rendered?.target || 'n/a' }}
|
||||
</td>
|
||||
<td>
|
||||
{{ delivery.kind }}
|
||||
</td>
|
||||
<td>
|
||||
{{ delivery.createdAt | date: 'short' }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr *ngIf="!deliveriesLoading() && !filteredDeliveries().length">
|
||||
<td colspan="4" class="empty-row">No deliveries match this filter.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,386 @@
|
||||
:host {
|
||||
display: block;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.notify-panel {
|
||||
background: #0f172a;
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 20px 45px rgba(15, 23, 42, 0.45);
|
||||
}
|
||||
|
||||
.notify-panel__header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
|
||||
h1 {
|
||||
margin: 0.25rem 0;
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #cbd5f5;
|
||||
}
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.1em;
|
||||
color: #94a3b8;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.notify-grid {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
}
|
||||
|
||||
.notify-card {
|
||||
background: #111827;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 16px;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.notify-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #94a3b8;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
.ghost-button {
|
||||
border: 1px solid rgba(148, 163, 184, 0.4);
|
||||
background: transparent;
|
||||
color: #e2e8f0;
|
||||
border-radius: 999px;
|
||||
padding: 0.35rem 1rem;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease, border-color 0.2s ease;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
border-color: #38bdf8;
|
||||
background: rgba(56, 189, 248, 0.15);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.notify-message {
|
||||
margin: 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 8px;
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: #e0f2fe;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.channel-list,
|
||||
.rule-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.channel-item,
|
||||
.rule-item {
|
||||
width: 100%;
|
||||
border: 1px solid #1f2937;
|
||||
background: #0f172a;
|
||||
color: inherit;
|
||||
border-radius: 12px;
|
||||
padding: 0.75rem 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease, transform 0.2s ease;
|
||||
|
||||
&.active {
|
||||
border-color: #38bdf8;
|
||||
background: rgba(56, 189, 248, 0.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
.channel-meta,
|
||||
.rule-meta {
|
||||
font-size: 0.8rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.channel-status {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(248, 250, 252, 0.2);
|
||||
}
|
||||
|
||||
.channel-status--enabled {
|
||||
border-color: #34d399;
|
||||
color: #34d399;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
label span {
|
||||
color: #cbd5f5;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
background: #0f172a;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 10px;
|
||||
color: inherit;
|
||||
padding: 0.6rem;
|
||||
font-size: 0.95rem;
|
||||
font-family: inherit;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid #38bdf8;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: 500;
|
||||
|
||||
input {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.full-width {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.notify-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.notify-actions button {
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
padding: 0.45rem 1.25rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
background: linear-gradient(120deg, #38bdf8, #8b5cf6);
|
||||
color: #0f172a;
|
||||
transition: opacity 0.2s ease;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.notify-actions .ghost-button {
|
||||
background: transparent;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.channel-health {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 12px;
|
||||
background: #0b1220;
|
||||
border: 1px solid #1d2a44;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
border: 1px solid rgba(248, 250, 252, 0.3);
|
||||
}
|
||||
|
||||
.status-pill--healthy {
|
||||
border-color: #34d399;
|
||||
color: #34d399;
|
||||
}
|
||||
|
||||
.status-pill--warning {
|
||||
border-color: #facc15;
|
||||
color: #facc15;
|
||||
}
|
||||
|
||||
.status-pill--error {
|
||||
border-color: #f87171;
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.channel-health__details p {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.channel-health__details small {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.test-form h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
color: #cbd5f5;
|
||||
}
|
||||
|
||||
.test-preview {
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
background: #0b1220;
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0.25rem 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
span {
|
||||
font-weight: 600;
|
||||
color: #cbd5f5;
|
||||
}
|
||||
|
||||
.preview-body {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
background: #0f172a;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.deliveries-controls {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.deliveries-controls label {
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.deliveries-table {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
thead th {
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
padding-bottom: 0.5rem;
|
||||
color: #cbd5f5;
|
||||
}
|
||||
|
||||
tbody td {
|
||||
padding: 0.6rem 0.25rem;
|
||||
border-top: 1px solid #1f2937;
|
||||
}
|
||||
|
||||
.empty-row {
|
||||
text-align: center;
|
||||
color: #94a3b8;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
border: 1px solid rgba(148, 163, 184, 0.5);
|
||||
}
|
||||
|
||||
.status-badge--sent {
|
||||
border-color: #34d399;
|
||||
color: #34d399;
|
||||
}
|
||||
|
||||
.status-badge--failed {
|
||||
border-color: #f87171;
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.status-badge--throttled {
|
||||
border-color: #facc15;
|
||||
color: #facc15;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.notify-panel {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.notify-panel__header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { NOTIFY_API } from '../../core/api/notify.client';
|
||||
import { MockNotifyApiService } from '../../testing/mock-notify-api.service';
|
||||
import { NotifyPanelComponent } from './notify-panel.component';
|
||||
|
||||
describe('NotifyPanelComponent', () => {
|
||||
let fixture: ComponentFixture<NotifyPanelComponent>;
|
||||
let component: NotifyPanelComponent;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [NotifyPanelComponent],
|
||||
providers: [
|
||||
MockNotifyApiService,
|
||||
{ provide: NOTIFY_API, useExisting: MockNotifyApiService },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(NotifyPanelComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('renders channels from the mocked API', async () => {
|
||||
await component.refreshAll();
|
||||
fixture.detectChanges();
|
||||
const items: NodeListOf<HTMLButtonElement> =
|
||||
fixture.nativeElement.querySelectorAll('[data-testid="channel-item"]');
|
||||
expect(items.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('persists a new rule via the mocked API', async () => {
|
||||
await component.refreshAll();
|
||||
fixture.detectChanges();
|
||||
|
||||
component.createRuleDraft();
|
||||
component.ruleForm.patchValue({
|
||||
name: 'Notify preview rule',
|
||||
channel: component.channels()[0]?.channelId ?? '',
|
||||
eventKindsText: 'scanner.report.ready',
|
||||
labelsText: 'kev',
|
||||
});
|
||||
|
||||
await component.saveRule();
|
||||
fixture.detectChanges();
|
||||
|
||||
const ruleButtons: HTMLElement[] = Array.from(
|
||||
fixture.nativeElement.querySelectorAll('[data-testid="rule-item"]')
|
||||
);
|
||||
expect(
|
||||
ruleButtons.some((el) => el.textContent?.includes('Notify preview rule'))
|
||||
).toBeTrue();
|
||||
});
|
||||
|
||||
it('shows a test preview after sending', async () => {
|
||||
await component.refreshAll();
|
||||
fixture.detectChanges();
|
||||
|
||||
await component.sendTestPreview();
|
||||
fixture.detectChanges();
|
||||
|
||||
const preview = fixture.nativeElement.querySelector('[data-testid="test-preview"]');
|
||||
expect(preview).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,642 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
OnInit,
|
||||
computed,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
NonNullableFormBuilder,
|
||||
ReactiveFormsModule,
|
||||
Validators,
|
||||
} from '@angular/forms';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
import {
|
||||
NOTIFY_API,
|
||||
NotifyApi,
|
||||
} from '../../core/api/notify.client';
|
||||
import {
|
||||
ChannelHealthResponse,
|
||||
ChannelTestSendResponse,
|
||||
NotifyChannel,
|
||||
NotifyDelivery,
|
||||
NotifyDeliveriesQueryOptions,
|
||||
NotifyDeliveryStatus,
|
||||
NotifyRule,
|
||||
NotifyRuleAction,
|
||||
} from '../../core/api/notify.models';
|
||||
|
||||
type DeliveryFilter =
|
||||
| 'all'
|
||||
| 'pending'
|
||||
| 'sent'
|
||||
| 'failed'
|
||||
| 'throttled'
|
||||
| 'digested'
|
||||
| 'dropped';
|
||||
|
||||
@Component({
|
||||
selector: 'app-notify-panel',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule],
|
||||
templateUrl: './notify-panel.component.html',
|
||||
styleUrls: ['./notify-panel.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class NotifyPanelComponent implements OnInit {
|
||||
private readonly api = inject<NotifyApi>(NOTIFY_API);
|
||||
private readonly formBuilder = inject(NonNullableFormBuilder);
|
||||
|
||||
private readonly tenantId = signal<string>('tenant-dev');
|
||||
|
||||
readonly channelTypes: readonly NotifyChannel['type'][] = [
|
||||
'Slack',
|
||||
'Teams',
|
||||
'Email',
|
||||
'Webhook',
|
||||
'Custom',
|
||||
];
|
||||
|
||||
readonly severityOptions = ['critical', 'high', 'medium', 'low'];
|
||||
|
||||
readonly channels = signal<NotifyChannel[]>([]);
|
||||
readonly selectedChannelId = signal<string | null>(null);
|
||||
readonly channelLoading = signal(false);
|
||||
readonly channelMessage = signal<string | null>(null);
|
||||
readonly channelHealth = signal<ChannelHealthResponse | null>(null);
|
||||
readonly testPreview = signal<ChannelTestSendResponse | null>(null);
|
||||
readonly testSending = signal(false);
|
||||
|
||||
readonly rules = signal<NotifyRule[]>([]);
|
||||
readonly selectedRuleId = signal<string | null>(null);
|
||||
readonly ruleLoading = signal(false);
|
||||
readonly ruleMessage = signal<string | null>(null);
|
||||
|
||||
readonly deliveries = signal<NotifyDelivery[]>([]);
|
||||
readonly deliveriesLoading = signal(false);
|
||||
readonly deliveriesMessage = signal<string | null>(null);
|
||||
readonly deliveryFilter = signal<DeliveryFilter>('all');
|
||||
|
||||
readonly filteredDeliveries = computed(() => {
|
||||
const filter = this.deliveryFilter();
|
||||
const items = this.deliveries();
|
||||
if (filter === 'all') {
|
||||
return items;
|
||||
}
|
||||
return items.filter((item) =>
|
||||
item.status.toLowerCase() === filter
|
||||
);
|
||||
});
|
||||
|
||||
readonly channelForm = this.formBuilder.group({
|
||||
channelId: this.formBuilder.control(''),
|
||||
name: this.formBuilder.control('', {
|
||||
validators: [Validators.required],
|
||||
}),
|
||||
displayName: this.formBuilder.control(''),
|
||||
description: this.formBuilder.control(''),
|
||||
type: this.formBuilder.control<NotifyChannel['type']>('Slack'),
|
||||
target: this.formBuilder.control(''),
|
||||
endpoint: this.formBuilder.control(''),
|
||||
secretRef: this.formBuilder.control('', {
|
||||
validators: [Validators.required],
|
||||
}),
|
||||
enabled: this.formBuilder.control(true),
|
||||
labelsText: this.formBuilder.control(''),
|
||||
metadataText: this.formBuilder.control(''),
|
||||
});
|
||||
|
||||
readonly ruleForm = this.formBuilder.group({
|
||||
ruleId: this.formBuilder.control(''),
|
||||
name: this.formBuilder.control('', {
|
||||
validators: [Validators.required],
|
||||
}),
|
||||
description: this.formBuilder.control(''),
|
||||
enabled: this.formBuilder.control(true),
|
||||
minSeverity: this.formBuilder.control('critical'),
|
||||
eventKindsText: this.formBuilder.control('scanner.report.ready'),
|
||||
labelsText: this.formBuilder.control('kev,critical'),
|
||||
channel: this.formBuilder.control('', {
|
||||
validators: [Validators.required],
|
||||
}),
|
||||
digest: this.formBuilder.control('instant'),
|
||||
template: this.formBuilder.control('tmpl-critical'),
|
||||
locale: this.formBuilder.control('en-US'),
|
||||
throttleSeconds: this.formBuilder.control(300),
|
||||
});
|
||||
|
||||
readonly testForm = this.formBuilder.group({
|
||||
title: this.formBuilder.control('Policy verdict update'),
|
||||
summary: this.formBuilder.control('Mock preview of Notify payload.'),
|
||||
body: this.formBuilder.control(
|
||||
'Sample preview body rendered by the mocked Notify API service.'
|
||||
),
|
||||
textBody: this.formBuilder.control(''),
|
||||
target: this.formBuilder.control(''),
|
||||
});
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
await this.refreshAll();
|
||||
}
|
||||
|
||||
async refreshAll(): Promise<void> {
|
||||
await Promise.all([
|
||||
this.loadChannels(),
|
||||
this.loadRules(),
|
||||
this.loadDeliveries(),
|
||||
]);
|
||||
}
|
||||
|
||||
async loadChannels(): Promise<void> {
|
||||
this.channelLoading.set(true);
|
||||
this.channelMessage.set(null);
|
||||
try {
|
||||
const channels = await firstValueFrom(this.api.listChannels());
|
||||
this.channels.set(channels);
|
||||
if (channels.length) {
|
||||
this.tenantId.set(channels[0].tenantId);
|
||||
}
|
||||
if (!this.selectedChannelId() && channels.length) {
|
||||
this.selectChannel(channels[0].channelId);
|
||||
}
|
||||
} catch (error) {
|
||||
this.channelMessage.set(this.toErrorMessage(error));
|
||||
} finally {
|
||||
this.channelLoading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async loadRules(): Promise<void> {
|
||||
this.ruleLoading.set(true);
|
||||
this.ruleMessage.set(null);
|
||||
try {
|
||||
const rules = await firstValueFrom(this.api.listRules());
|
||||
this.rules.set(rules);
|
||||
if (!this.selectedRuleId() && rules.length) {
|
||||
this.selectRule(rules[0].ruleId);
|
||||
}
|
||||
if (!this.ruleForm.controls.channel.value && this.channels().length) {
|
||||
this.ruleForm.patchValue({ channel: this.channels()[0].channelId });
|
||||
}
|
||||
} catch (error) {
|
||||
this.ruleMessage.set(this.toErrorMessage(error));
|
||||
} finally {
|
||||
this.ruleLoading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async loadDeliveries(): Promise<void> {
|
||||
this.deliveriesLoading.set(true);
|
||||
this.deliveriesMessage.set(null);
|
||||
try {
|
||||
const options: NotifyDeliveriesQueryOptions = {
|
||||
status: this.mapFilterToStatus(this.deliveryFilter()),
|
||||
limit: 15,
|
||||
};
|
||||
const response = await firstValueFrom(
|
||||
this.api.listDeliveries(options)
|
||||
);
|
||||
this.deliveries.set([...(response.items ?? [])]);
|
||||
} catch (error) {
|
||||
this.deliveriesMessage.set(this.toErrorMessage(error));
|
||||
} finally {
|
||||
this.deliveriesLoading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
selectChannel(channelId: string): void {
|
||||
const channel = this.channels().find((c) => c.channelId === channelId);
|
||||
if (!channel) {
|
||||
return;
|
||||
}
|
||||
this.selectedChannelId.set(channelId);
|
||||
this.channelForm.patchValue({
|
||||
channelId: channel.channelId,
|
||||
name: channel.name,
|
||||
displayName: channel.displayName ?? '',
|
||||
description: channel.description ?? '',
|
||||
type: channel.type,
|
||||
target: channel.config.target ?? '',
|
||||
endpoint: channel.config.endpoint ?? '',
|
||||
secretRef: channel.config.secretRef,
|
||||
enabled: channel.enabled,
|
||||
labelsText: this.formatKeyValueMap(channel.labels),
|
||||
metadataText: this.formatKeyValueMap(channel.metadata),
|
||||
});
|
||||
this.testPreview.set(null);
|
||||
void this.loadChannelHealth(channelId);
|
||||
}
|
||||
|
||||
selectRule(ruleId: string): void {
|
||||
const rule = this.rules().find((r) => r.ruleId === ruleId);
|
||||
if (!rule) {
|
||||
return;
|
||||
}
|
||||
this.selectedRuleId.set(ruleId);
|
||||
const action = rule.actions?.[0];
|
||||
this.ruleForm.patchValue({
|
||||
ruleId: rule.ruleId,
|
||||
name: rule.name,
|
||||
description: rule.description ?? '',
|
||||
enabled: rule.enabled,
|
||||
minSeverity: rule.match?.minSeverity ?? '',
|
||||
eventKindsText: this.formatList(rule.match?.eventKinds ?? []),
|
||||
labelsText: this.formatList(rule.match?.labels ?? []),
|
||||
channel: action?.channel ?? this.channels()[0]?.channelId ?? '',
|
||||
digest: action?.digest ?? '',
|
||||
template: action?.template ?? '',
|
||||
locale: action?.locale ?? '',
|
||||
throttleSeconds: this.parseDuration(action?.throttle),
|
||||
});
|
||||
}
|
||||
|
||||
createChannelDraft(): void {
|
||||
this.selectedChannelId.set(null);
|
||||
this.channelForm.reset({
|
||||
channelId: '',
|
||||
name: '',
|
||||
displayName: '',
|
||||
description: '',
|
||||
type: 'Slack',
|
||||
target: '',
|
||||
endpoint: '',
|
||||
secretRef: '',
|
||||
enabled: true,
|
||||
labelsText: '',
|
||||
metadataText: '',
|
||||
});
|
||||
this.channelHealth.set(null);
|
||||
this.testPreview.set(null);
|
||||
}
|
||||
|
||||
createRuleDraft(): void {
|
||||
this.selectedRuleId.set(null);
|
||||
this.ruleForm.reset({
|
||||
ruleId: '',
|
||||
name: '',
|
||||
description: '',
|
||||
enabled: true,
|
||||
minSeverity: 'high',
|
||||
eventKindsText: 'scanner.report.ready',
|
||||
labelsText: '',
|
||||
channel: this.channels()[0]?.channelId ?? '',
|
||||
digest: 'instant',
|
||||
template: '',
|
||||
locale: 'en-US',
|
||||
throttleSeconds: 0,
|
||||
});
|
||||
}
|
||||
|
||||
async saveChannel(): Promise<void> {
|
||||
if (this.channelForm.invalid) {
|
||||
this.channelForm.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
this.channelLoading.set(true);
|
||||
this.channelMessage.set(null);
|
||||
|
||||
try {
|
||||
const payload = this.buildChannelPayload();
|
||||
const saved = await firstValueFrom(this.api.saveChannel(payload));
|
||||
await this.loadChannels();
|
||||
this.selectChannel(saved.channelId);
|
||||
this.channelMessage.set('Channel saved successfully.');
|
||||
} catch (error) {
|
||||
this.channelMessage.set(this.toErrorMessage(error));
|
||||
} finally {
|
||||
this.channelLoading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteChannel(): Promise<void> {
|
||||
const channelId = this.selectedChannelId();
|
||||
if (!channelId) {
|
||||
return;
|
||||
}
|
||||
this.channelLoading.set(true);
|
||||
this.channelMessage.set(null);
|
||||
try {
|
||||
await firstValueFrom(this.api.deleteChannel(channelId));
|
||||
await this.loadChannels();
|
||||
if (this.channels().length) {
|
||||
this.selectChannel(this.channels()[0].channelId);
|
||||
} else {
|
||||
this.createChannelDraft();
|
||||
}
|
||||
this.channelMessage.set('Channel deleted.');
|
||||
} catch (error) {
|
||||
this.channelMessage.set(this.toErrorMessage(error));
|
||||
} finally {
|
||||
this.channelLoading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async saveRule(): Promise<void> {
|
||||
if (this.ruleForm.invalid) {
|
||||
this.ruleForm.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
this.ruleLoading.set(true);
|
||||
this.ruleMessage.set(null);
|
||||
try {
|
||||
const payload = this.buildRulePayload();
|
||||
const saved = await firstValueFrom(this.api.saveRule(payload));
|
||||
await this.loadRules();
|
||||
this.selectRule(saved.ruleId);
|
||||
this.ruleMessage.set('Rule saved successfully.');
|
||||
} catch (error) {
|
||||
this.ruleMessage.set(this.toErrorMessage(error));
|
||||
} finally {
|
||||
this.ruleLoading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteRule(): Promise<void> {
|
||||
const ruleId = this.selectedRuleId();
|
||||
if (!ruleId) {
|
||||
return;
|
||||
}
|
||||
this.ruleLoading.set(true);
|
||||
this.ruleMessage.set(null);
|
||||
try {
|
||||
await firstValueFrom(this.api.deleteRule(ruleId));
|
||||
await this.loadRules();
|
||||
if (this.rules().length) {
|
||||
this.selectRule(this.rules()[0].ruleId);
|
||||
} else {
|
||||
this.createRuleDraft();
|
||||
}
|
||||
this.ruleMessage.set('Rule deleted.');
|
||||
} catch (error) {
|
||||
this.ruleMessage.set(this.toErrorMessage(error));
|
||||
} finally {
|
||||
this.ruleLoading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async sendTestPreview(): Promise<void> {
|
||||
const channelId = this.selectedChannelId();
|
||||
if (!channelId) {
|
||||
this.channelMessage.set('Select a channel before running a test send.');
|
||||
return;
|
||||
}
|
||||
this.testSending.set(true);
|
||||
this.channelMessage.set(null);
|
||||
try {
|
||||
const payload = this.testForm.getRawValue();
|
||||
const response = await firstValueFrom(
|
||||
this.api.testChannel(channelId, {
|
||||
target: payload.target || undefined,
|
||||
title: payload.title || undefined,
|
||||
summary: payload.summary || undefined,
|
||||
body: payload.body || undefined,
|
||||
textBody: payload.textBody || undefined,
|
||||
})
|
||||
);
|
||||
this.testPreview.set(response);
|
||||
this.channelMessage.set('Test send queued successfully.');
|
||||
await this.loadDeliveries();
|
||||
} catch (error) {
|
||||
this.channelMessage.set(this.toErrorMessage(error));
|
||||
} finally {
|
||||
this.testSending.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async refreshDeliveries(): Promise<void> {
|
||||
await this.loadDeliveries();
|
||||
}
|
||||
|
||||
onDeliveryFilterChange(rawValue: string): void {
|
||||
const filter = this.isDeliveryFilter(rawValue) ? rawValue : 'all';
|
||||
this.deliveryFilter.set(filter);
|
||||
void this.loadDeliveries();
|
||||
}
|
||||
|
||||
trackByChannel = (_: number, item: NotifyChannel) => item.channelId;
|
||||
trackByRule = (_: number, item: NotifyRule) => item.ruleId;
|
||||
trackByDelivery = (_: number, item: NotifyDelivery) => item.deliveryId;
|
||||
|
||||
private async loadChannelHealth(channelId: string): Promise<void> {
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.api.getChannelHealth(channelId)
|
||||
);
|
||||
this.channelHealth.set(response);
|
||||
} catch {
|
||||
this.channelHealth.set(null);
|
||||
}
|
||||
}
|
||||
|
||||
private buildChannelPayload(): NotifyChannel {
|
||||
const raw = this.channelForm.getRawValue();
|
||||
const existing = this.channels().find((c) => c.channelId === raw.channelId);
|
||||
const now = new Date().toISOString();
|
||||
const channelId = raw.channelId?.trim() || this.generateId('chn');
|
||||
const tenantId = existing?.tenantId ?? this.tenantId();
|
||||
|
||||
return {
|
||||
schemaVersion: existing?.schemaVersion ?? '1.0',
|
||||
channelId,
|
||||
tenantId,
|
||||
name: raw.name.trim(),
|
||||
displayName: raw.displayName?.trim() || undefined,
|
||||
description: raw.description?.trim() || undefined,
|
||||
type: raw.type,
|
||||
enabled: raw.enabled,
|
||||
config: {
|
||||
secretRef: raw.secretRef.trim(),
|
||||
target: raw.target?.trim() || undefined,
|
||||
endpoint: raw.endpoint?.trim() || undefined,
|
||||
properties: existing?.config.properties ?? {},
|
||||
limits: existing?.config.limits,
|
||||
},
|
||||
labels: this.parseKeyValueText(raw.labelsText),
|
||||
metadata: this.parseKeyValueText(raw.metadataText),
|
||||
createdBy: existing?.createdBy ?? 'ui@stella-ops.local',
|
||||
createdAt: existing?.createdAt ?? now,
|
||||
updatedBy: 'ui@stella-ops.local',
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
private buildRulePayload(): NotifyRule {
|
||||
const raw = this.ruleForm.getRawValue();
|
||||
const existing = this.rules().find((r) => r.ruleId === raw.ruleId);
|
||||
const now = new Date().toISOString();
|
||||
const ruleId = raw.ruleId?.trim() || this.generateId('rule');
|
||||
|
||||
const action: NotifyRuleAction = {
|
||||
actionId: existing?.actions?.[0]?.actionId ?? this.generateId('act'),
|
||||
channel: raw.channel ?? this.channels()[0]?.channelId ?? '',
|
||||
template: raw.template?.trim() || undefined,
|
||||
digest: raw.digest?.trim() || undefined,
|
||||
locale: raw.locale?.trim() || undefined,
|
||||
throttle:
|
||||
raw.throttleSeconds && raw.throttleSeconds > 0
|
||||
? this.formatDuration(raw.throttleSeconds)
|
||||
: null,
|
||||
enabled: true,
|
||||
metadata: existing?.actions?.[0]?.metadata ?? {},
|
||||
};
|
||||
|
||||
return {
|
||||
schemaVersion: existing?.schemaVersion ?? '1.0',
|
||||
ruleId,
|
||||
tenantId: existing?.tenantId ?? this.tenantId(),
|
||||
name: raw.name.trim(),
|
||||
description: raw.description?.trim() || undefined,
|
||||
enabled: raw.enabled,
|
||||
match: {
|
||||
eventKinds: this.parseList(raw.eventKindsText),
|
||||
labels: this.parseList(raw.labelsText),
|
||||
minSeverity: raw.minSeverity?.trim() || null,
|
||||
},
|
||||
actions: [action],
|
||||
labels: existing?.labels ?? {},
|
||||
metadata: existing?.metadata ?? {},
|
||||
createdBy: existing?.createdBy ?? 'ui@stella-ops.local',
|
||||
createdAt: existing?.createdAt ?? now,
|
||||
updatedBy: 'ui@stella-ops.local',
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
private parseKeyValueText(value?: string | null): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
if (!value) {
|
||||
return result;
|
||||
}
|
||||
value
|
||||
.split(/\r?\n|,/)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
.forEach((entry) => {
|
||||
const [key, ...rest] = entry.split('=');
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
result[key.trim()] = rest.join('=').trim();
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
private formatKeyValueMap(
|
||||
map?: Record<string, string> | null
|
||||
): string {
|
||||
if (!map) {
|
||||
return '';
|
||||
}
|
||||
return Object.entries(map)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
private parseList(value?: string | null): string[] {
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
return value
|
||||
.split(/\r?\n|,/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
private formatList(items: readonly string[]): string {
|
||||
if (!items?.length) {
|
||||
return '';
|
||||
}
|
||||
return items.join('\n');
|
||||
}
|
||||
|
||||
private parseDuration(duration?: string | null): number {
|
||||
if (!duration) {
|
||||
return 0;
|
||||
}
|
||||
if (duration.startsWith('PT')) {
|
||||
const hours = extractNumber(duration, /([0-9]+)H/);
|
||||
const minutes = extractNumber(duration, /([0-9]+)M/);
|
||||
const seconds = extractNumber(duration, /([0-9]+)S/);
|
||||
return hours * 3600 + minutes * 60 + seconds;
|
||||
}
|
||||
const parts = duration.split(':').map((p) => Number.parseInt(p, 10));
|
||||
if (parts.length === 3) {
|
||||
return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||||
}
|
||||
return Number.parseInt(duration, 10) || 0;
|
||||
}
|
||||
|
||||
private formatDuration(seconds: number): string {
|
||||
const clamped = Math.max(0, Math.floor(seconds));
|
||||
const hrs = Math.floor(clamped / 3600);
|
||||
const mins = Math.floor((clamped % 3600) / 60);
|
||||
const secs = clamped % 60;
|
||||
let result = 'PT';
|
||||
if (hrs) {
|
||||
result += `${hrs}H`;
|
||||
}
|
||||
if (mins) {
|
||||
result += `${mins}M`;
|
||||
}
|
||||
if (secs || result === 'PT') {
|
||||
result += `${secs}S`;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private mapFilterToStatus(
|
||||
filter: DeliveryFilter
|
||||
): NotifyDeliveryStatus | undefined {
|
||||
switch (filter) {
|
||||
case 'pending':
|
||||
return 'Pending';
|
||||
case 'sent':
|
||||
return 'Sent';
|
||||
case 'failed':
|
||||
return 'Failed';
|
||||
case 'throttled':
|
||||
return 'Throttled';
|
||||
case 'digested':
|
||||
return 'Digested';
|
||||
case 'dropped':
|
||||
return 'Dropped';
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private isDeliveryFilter(value: string): value is DeliveryFilter {
|
||||
return (
|
||||
value === 'all' ||
|
||||
value === 'pending' ||
|
||||
value === 'sent' ||
|
||||
value === 'failed' ||
|
||||
value === 'throttled' ||
|
||||
value === 'digested' ||
|
||||
value === 'dropped'
|
||||
);
|
||||
}
|
||||
|
||||
private toErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
if (typeof error === 'string') {
|
||||
return error;
|
||||
}
|
||||
return 'Operation failed. Please retry.';
|
||||
}
|
||||
|
||||
private generateId(prefix: string): string {
|
||||
return `${prefix}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
}
|
||||
|
||||
function extractNumber(source: string, pattern: RegExp): number {
|
||||
const match = source.match(pattern);
|
||||
return match ? Number.parseInt(match[1], 10) : 0;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<section class="attestation-panel" [attr.data-status]="statusClass">
|
||||
<header class="attestation-header">
|
||||
<h2>Attestation</h2>
|
||||
<span class="status-badge" [ngClass]="statusClass">
|
||||
{{ statusLabel }}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<dl class="attestation-meta">
|
||||
<div>
|
||||
<dt>Rekor UUID</dt>
|
||||
<dd><code>{{ attestation.uuid }}</code></dd>
|
||||
</div>
|
||||
<div *ngIf="attestation.index !== undefined">
|
||||
<dt>Log index</dt>
|
||||
<dd>{{ attestation.index }}</dd>
|
||||
</div>
|
||||
<div *ngIf="attestation.logUrl">
|
||||
<dt>Log URL</dt>
|
||||
<dd>
|
||||
<a
|
||||
[href]="attestation.logUrl"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{{ attestation.logUrl }}
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
<div *ngIf="attestation.checkedAt">
|
||||
<dt>Last checked</dt>
|
||||
<dd>{{ attestation.checkedAt }}</dd>
|
||||
</div>
|
||||
<div *ngIf="attestation.statusMessage">
|
||||
<dt>Details</dt>
|
||||
<dd>{{ attestation.statusMessage }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
@@ -0,0 +1,75 @@
|
||||
.attestation-panel {
|
||||
border: 1px solid #1f2933;
|
||||
border-radius: 8px;
|
||||
padding: 1.25rem;
|
||||
background: #111827;
|
||||
color: #f8fafc;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.attestation-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.attestation-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.35rem 0.75rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.status-badge.verified {
|
||||
background-color: rgba(34, 197, 94, 0.2);
|
||||
color: #34d399;
|
||||
}
|
||||
|
||||
.status-badge.pending {
|
||||
background-color: rgba(234, 179, 8, 0.2);
|
||||
color: #eab308;
|
||||
}
|
||||
|
||||
.status-badge.failed {
|
||||
background-color: rgba(248, 113, 113, 0.2);
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.attestation-meta {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.attestation-meta div {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.attestation-meta dt {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.attestation-meta dd {
|
||||
margin: 0;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'SFMono-Regular', monospace;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.attestation-meta a {
|
||||
color: #60a5fa;
|
||||
text-decoration: underline;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ScanAttestationPanelComponent } from './scan-attestation-panel.component';
|
||||
|
||||
describe('ScanAttestationPanelComponent', () => {
|
||||
let component: ScanAttestationPanelComponent;
|
||||
let fixture: ComponentFixture<ScanAttestationPanelComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ScanAttestationPanelComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ScanAttestationPanelComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it('renders verified attestation details', () => {
|
||||
component.attestation = {
|
||||
uuid: '1234',
|
||||
status: 'verified',
|
||||
index: 42,
|
||||
logUrl: 'https://rekor.example',
|
||||
checkedAt: '2025-10-23T10:05:00Z',
|
||||
statusMessage: 'Rekor transparency log inclusion proof verified.',
|
||||
};
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
const element: HTMLElement = fixture.nativeElement;
|
||||
expect(element.querySelector('.status-badge')?.textContent?.trim()).toBe(
|
||||
'Verified'
|
||||
);
|
||||
expect(element.textContent).toContain('1234');
|
||||
expect(element.textContent).toContain('42');
|
||||
expect(element.textContent).toContain('https://rekor.example');
|
||||
});
|
||||
|
||||
it('renders failure message when attestation verification fails', () => {
|
||||
component.attestation = {
|
||||
uuid: 'abcd',
|
||||
status: 'failed',
|
||||
statusMessage: 'Verification failed: inclusion proof mismatch.',
|
||||
};
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
const element: HTMLElement = fixture.nativeElement;
|
||||
expect(element.querySelector('.status-badge')?.textContent?.trim()).toBe(
|
||||
'Verification failed'
|
||||
);
|
||||
expect(element.textContent).toContain(
|
||||
'Verification failed: inclusion proof mismatch.'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
Input,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
ScanAttestationStatus,
|
||||
ScanAttestationStatusKind,
|
||||
} from '../../core/api/scanner.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-scan-attestation-panel',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './scan-attestation-panel.component.html',
|
||||
styleUrls: ['./scan-attestation-panel.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ScanAttestationPanelComponent {
|
||||
@Input({ required: true }) attestation!: ScanAttestationStatus;
|
||||
|
||||
get statusLabel(): string {
|
||||
return this.toStatusLabel(this.attestation?.status);
|
||||
}
|
||||
|
||||
get statusClass(): string {
|
||||
return this.attestation?.status ?? 'pending';
|
||||
}
|
||||
|
||||
private toStatusLabel(status: ScanAttestationStatusKind | undefined): string {
|
||||
switch (status) {
|
||||
case 'verified':
|
||||
return 'Verified';
|
||||
case 'failed':
|
||||
return 'Verification failed';
|
||||
case 'pending':
|
||||
default:
|
||||
return 'Pending verification';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<section class="scan-detail">
|
||||
<header class="scan-detail__header">
|
||||
<h1>Scan Detail</h1>
|
||||
<div class="scenario-toggle" role="group" aria-label="Scenario selector">
|
||||
<button
|
||||
type="button"
|
||||
class="scenario-button"
|
||||
[class.active]="scenario() === 'verified'"
|
||||
(click)="onSelectScenario('verified')"
|
||||
data-scenario="verified"
|
||||
>
|
||||
Verified
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="scenario-button"
|
||||
[class.active]="scenario() === 'failed'"
|
||||
(click)="onSelectScenario('failed')"
|
||||
data-scenario="failed"
|
||||
>
|
||||
Failure
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="scan-summary">
|
||||
<h2>Image</h2>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Scan ID</dt>
|
||||
<dd>{{ scan().scanId }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Image digest</dt>
|
||||
<dd><code>{{ scan().imageDigest }}</code></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Completed at</dt>
|
||||
<dd>{{ scan().completedAt }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<app-scan-attestation-panel
|
||||
*ngIf="scan().attestation as attestation"
|
||||
[attestation]="attestation"
|
||||
/>
|
||||
|
||||
<p *ngIf="!scan().attestation" class="attestation-empty">
|
||||
No attestation has been recorded for this scan.
|
||||
</p>
|
||||
</section>
|
||||
@@ -0,0 +1,79 @@
|
||||
.scan-detail {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
padding: 1.5rem;
|
||||
color: #e2e8f0;
|
||||
background: #0f172a;
|
||||
min-height: calc(100vh - 120px);
|
||||
}
|
||||
|
||||
.scan-detail__header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.scan-detail__header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.scenario-toggle {
|
||||
display: inline-flex;
|
||||
border: 1px solid #1f2933;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scenario-button {
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border: none;
|
||||
padding: 0.5rem 1.25rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.scenario-button.active {
|
||||
background: #1d4ed8;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.scan-summary {
|
||||
border: 1px solid #1f2933;
|
||||
border-radius: 8px;
|
||||
padding: 1.25rem;
|
||||
background: #111827;
|
||||
}
|
||||
|
||||
.scan-summary h2 {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.scan-summary dl {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.scan-summary dt {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.scan-summary dd {
|
||||
margin: 0;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'SFMono-Regular', monospace;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.attestation-empty {
|
||||
font-style: italic;
|
||||
color: #94a3b8;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { ScanDetailPageComponent } from './scan-detail-page.component';
|
||||
import {
|
||||
scanDetailWithFailedAttestation,
|
||||
scanDetailWithVerifiedAttestation,
|
||||
} from '../../testing/scan-fixtures';
|
||||
|
||||
describe('ScanDetailPageComponent', () => {
|
||||
let fixture: ComponentFixture<ScanDetailPageComponent>;
|
||||
let component: ScanDetailPageComponent;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [RouterTestingModule, ScanDetailPageComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ScanDetailPageComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it('shows the verified attestation scenario by default', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
const element: HTMLElement = fixture.nativeElement;
|
||||
expect(element.textContent).toContain(
|
||||
scanDetailWithVerifiedAttestation.attestation?.uuid ?? ''
|
||||
);
|
||||
expect(element.querySelector('.status-badge')?.textContent?.trim()).toBe(
|
||||
'Verified'
|
||||
);
|
||||
});
|
||||
|
||||
it('switches to failure scenario when toggle is clicked', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
const failureButton: HTMLButtonElement | null =
|
||||
fixture.nativeElement.querySelector('[data-scenario="failed"]');
|
||||
failureButton?.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
const element: HTMLElement = fixture.nativeElement;
|
||||
expect(element.textContent).toContain(
|
||||
scanDetailWithFailedAttestation.attestation?.uuid ?? ''
|
||||
);
|
||||
expect(element.querySelector('.status-badge')?.textContent?.trim()).toBe(
|
||||
'Verification failed'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { ScanAttestationPanelComponent } from './scan-attestation-panel.component';
|
||||
import { ScanDetail } from '../../core/api/scanner.models';
|
||||
import {
|
||||
scanDetailWithFailedAttestation,
|
||||
scanDetailWithVerifiedAttestation,
|
||||
} from '../../testing/scan-fixtures';
|
||||
|
||||
type Scenario = 'verified' | 'failed';
|
||||
|
||||
const SCENARIO_MAP: Record<Scenario, ScanDetail> = {
|
||||
verified: scanDetailWithVerifiedAttestation,
|
||||
failed: scanDetailWithFailedAttestation,
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-scan-detail-page',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ScanAttestationPanelComponent],
|
||||
templateUrl: './scan-detail-page.component.html',
|
||||
styleUrls: ['./scan-detail-page.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ScanDetailPageComponent {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
|
||||
readonly scenario = signal<Scenario>('verified');
|
||||
|
||||
readonly scan = computed<ScanDetail>(() => {
|
||||
const current = this.scenario();
|
||||
return SCENARIO_MAP[current];
|
||||
});
|
||||
|
||||
constructor() {
|
||||
const routeScenario =
|
||||
(this.route.snapshot.queryParamMap.get('scenario') as Scenario | null) ??
|
||||
null;
|
||||
if (routeScenario && routeScenario in SCENARIO_MAP) {
|
||||
this.scenario.set(routeScenario);
|
||||
return;
|
||||
}
|
||||
|
||||
const scanId = this.route.snapshot.paramMap.get('scanId');
|
||||
if (scanId === scanDetailWithFailedAttestation.scanId) {
|
||||
this.scenario.set('failed');
|
||||
} else {
|
||||
this.scenario.set('verified');
|
||||
}
|
||||
}
|
||||
|
||||
onSelectScenario(next: Scenario): void {
|
||||
this.scenario.set(next);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
<section class="settings-card" aria-labelledby="trivy-db-settings-heading">
|
||||
<header class="card-header">
|
||||
<div>
|
||||
<h1 id="trivy-db-settings-heading">Trivy DB export settings</h1>
|
||||
<p class="card-subtitle">
|
||||
Configure export behaviour for downstream mirrors. Changes apply on the next run.
|
||||
</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="secondary"
|
||||
(click)="loadSettings()"
|
||||
[disabled]="isBusy"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form [formGroup]="form" (ngSubmit)="onSave()" class="settings-form">
|
||||
<fieldset [disabled]="isBusy">
|
||||
<legend class="sr-only">Export toggles</legend>
|
||||
|
||||
<label class="toggle">
|
||||
<input type="checkbox" formControlName="publishFull" />
|
||||
<span class="toggle-label">
|
||||
Publish full database exports
|
||||
<span class="toggle-hint">
|
||||
Required for first-time consumers or when they fall behind.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="toggle">
|
||||
<input type="checkbox" formControlName="publishDelta" />
|
||||
<span class="toggle-label">
|
||||
Publish delta updates
|
||||
<span class="toggle-hint">
|
||||
Incremental exports reduce bandwidth between full releases.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="toggle">
|
||||
<input type="checkbox" formControlName="includeFull" />
|
||||
<span class="toggle-label">
|
||||
Include full archive in offline bundle
|
||||
<span class="toggle-hint">
|
||||
Bundles deliver everything required for air-gapped sites.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="toggle">
|
||||
<input type="checkbox" formControlName="includeDelta" />
|
||||
<span class="toggle-label">
|
||||
Include delta archive in offline bundle
|
||||
<span class="toggle-hint">
|
||||
Provides faster incremental sync within offline kits.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="primary" [disabled]="isBusy">
|
||||
Save changes
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="primary outline"
|
||||
(click)="onRunExport()"
|
||||
[disabled]="isBusy"
|
||||
>
|
||||
Run export now
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div
|
||||
*ngIf="message() as note"
|
||||
class="status"
|
||||
[class.status-success]="status() === 'success'"
|
||||
[class.status-error]="status() === 'error'"
|
||||
role="status"
|
||||
>
|
||||
{{ note }}
|
||||
</div>
|
||||
|
||||
<section *ngIf="lastRun() as run" class="last-run" aria-live="polite">
|
||||
<h2>Last triggered run</h2>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Export ID</dt>
|
||||
<dd>{{ run.exportId }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Triggered</dt>
|
||||
<dd>{{ run.triggeredAt | date : 'yyyy-MM-dd HH:mm:ss \'UTC\'' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Status</dt>
|
||||
<dd>{{ run.status ?? 'pending' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
</section>
|
||||
@@ -0,0 +1,230 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
background-color: #ffffff;
|
||||
border-radius: 16px;
|
||||
padding: 1.75rem;
|
||||
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.08);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.card-subtitle {
|
||||
margin: 0.25rem 0 0;
|
||||
color: #475569;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.settings-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: flex-start;
|
||||
background-color: #f8fafc;
|
||||
border-radius: 12px;
|
||||
padding: 0.9rem 1rem;
|
||||
border: 1px solid rgba(148, 163, 184, 0.4);
|
||||
transition: border-color 0.2s ease, background-color 0.2s ease;
|
||||
|
||||
&:focus-within,
|
||||
&:hover {
|
||||
border-color: rgba(67, 40, 183, 0.5);
|
||||
background-color: #eef2ff;
|
||||
}
|
||||
|
||||
input[type='checkbox'] {
|
||||
margin-top: 0.2rem;
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.toggle-label {
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.toggle-hint {
|
||||
font-weight: 400;
|
||||
font-size: 0.9rem;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
button {
|
||||
appearance: none;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
padding: 0.55rem 1.35rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
button.primary {
|
||||
color: #ffffff;
|
||||
background: linear-gradient(135deg, #4338ca 0%, #7c3aed 100%);
|
||||
box-shadow: 0 10px 20px rgba(79, 70, 229, 0.25);
|
||||
|
||||
&:hover:not(:disabled),
|
||||
&:focus-visible:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 12px 24px rgba(79, 70, 229, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
button.primary.outline {
|
||||
background: transparent;
|
||||
color: #4338ca;
|
||||
border: 1px solid rgba(79, 70, 229, 0.4);
|
||||
box-shadow: none;
|
||||
|
||||
&:hover:not(:disabled),
|
||||
&:focus-visible:not(:disabled) {
|
||||
background: rgba(79, 70, 229, 0.12);
|
||||
}
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
background: rgba(148, 163, 184, 0.2);
|
||||
color: #0f172a;
|
||||
border: 1px solid rgba(148, 163, 184, 0.4);
|
||||
|
||||
&:hover:not(:disabled),
|
||||
&:focus-visible:not(:disabled) {
|
||||
background: rgba(148, 163, 184, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: 0.85rem 1rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.95rem;
|
||||
background-color: rgba(15, 23, 42, 0.05);
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.status-success {
|
||||
background-color: rgba(16, 185, 129, 0.15);
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.status-error {
|
||||
background-color: rgba(239, 68, 68, 0.15);
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.last-run {
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.4);
|
||||
padding-top: 1rem;
|
||||
|
||||
h2 {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.75rem;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 0.75rem 1.5rem;
|
||||
|
||||
div {
|
||||
background: #f8fafc;
|
||||
border-radius: 12px;
|
||||
padding: 0.85rem 1rem;
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
}
|
||||
|
||||
dt {
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
margin-bottom: 0.3rem;
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
color: #0f172a;
|
||||
font-size: 0.95rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.card-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import {
|
||||
ConcelierExporterClient,
|
||||
TrivyDbRunResponseDto,
|
||||
TrivyDbSettingsDto,
|
||||
} from '../../core/api/concelier-exporter.client';
|
||||
import { TrivyDbSettingsPageComponent } from './trivy-db-settings-page.component';
|
||||
|
||||
describe('TrivyDbSettingsPageComponent', () => {
|
||||
let fixture: ComponentFixture<TrivyDbSettingsPageComponent>;
|
||||
let component: TrivyDbSettingsPageComponent;
|
||||
let client: jasmine.SpyObj<ConcelierExporterClient>;
|
||||
|
||||
const settings: TrivyDbSettingsDto = {
|
||||
publishFull: true,
|
||||
publishDelta: false,
|
||||
includeFull: true,
|
||||
includeDelta: false,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
client = jasmine.createSpyObj<ConcelierExporterClient>(
|
||||
'ConcelierExporterClient',
|
||||
['getTrivyDbSettings', 'updateTrivyDbSettings', 'runTrivyDbExport']
|
||||
);
|
||||
|
||||
client.getTrivyDbSettings.and.returnValue(of(settings));
|
||||
client.updateTrivyDbSettings.and.returnValue(of(settings));
|
||||
client.runTrivyDbExport.and.returnValue(
|
||||
of<TrivyDbRunResponseDto>({
|
||||
exportId: 'exp-1',
|
||||
triggeredAt: '2025-10-21T12:00:00Z',
|
||||
status: 'queued',
|
||||
})
|
||||
);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [TrivyDbSettingsPageComponent],
|
||||
providers: [{ provide: ConcelierExporterClient, useValue: client }],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(TrivyDbSettingsPageComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it('loads existing settings on init', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
expect(client.getTrivyDbSettings).toHaveBeenCalled();
|
||||
expect(component.form.value).toEqual(settings);
|
||||
}));
|
||||
|
||||
it('saves settings when submit is triggered', fakeAsync(async () => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
await component.onSave();
|
||||
|
||||
expect(client.updateTrivyDbSettings).toHaveBeenCalledWith(settings);
|
||||
expect(component.status()).toBe('success');
|
||||
}));
|
||||
|
||||
it('records error state when load fails', fakeAsync(() => {
|
||||
client.getTrivyDbSettings.and.returnValue(
|
||||
throwError(() => new Error('load failed'))
|
||||
);
|
||||
|
||||
fixture = TestBed.createComponent(TrivyDbSettingsPageComponent);
|
||||
component = fixture.componentInstance;
|
||||
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
expect(component.status()).toBe('error');
|
||||
expect(component.message()).toContain('load failed');
|
||||
}));
|
||||
|
||||
it('triggers export run after saving overrides', fakeAsync(async () => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
await component.onRunExport();
|
||||
|
||||
expect(client.updateTrivyDbSettings).toHaveBeenCalled();
|
||||
expect(client.runTrivyDbExport).toHaveBeenCalled();
|
||||
expect(component.lastRun()).toEqual({
|
||||
exportId: 'exp-1',
|
||||
triggeredAt: '2025-10-21T12:00:00Z',
|
||||
status: 'queued',
|
||||
});
|
||||
}));
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
OnInit,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
NonNullableFormBuilder,
|
||||
ReactiveFormsModule,
|
||||
} from '@angular/forms';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import {
|
||||
ConcelierExporterClient,
|
||||
TrivyDbRunResponseDto,
|
||||
TrivyDbSettingsDto,
|
||||
} from '../../core/api/concelier-exporter.client';
|
||||
|
||||
type StatusKind = 'idle' | 'loading' | 'saving' | 'running' | 'success' | 'error';
|
||||
type TrivyDbSettingsFormValue = TrivyDbSettingsDto;
|
||||
|
||||
@Component({
|
||||
selector: 'app-trivy-db-settings-page',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule],
|
||||
templateUrl: './trivy-db-settings-page.component.html',
|
||||
styleUrls: ['./trivy-db-settings-page.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class TrivyDbSettingsPageComponent implements OnInit {
|
||||
private readonly client = inject(ConcelierExporterClient);
|
||||
private readonly formBuilder = inject(NonNullableFormBuilder);
|
||||
|
||||
readonly status = signal<StatusKind>('idle');
|
||||
readonly message = signal<string | null>(null);
|
||||
readonly lastRun = signal<TrivyDbRunResponseDto | null>(null);
|
||||
|
||||
readonly form = this.formBuilder.group<TrivyDbSettingsFormValue>({
|
||||
publishFull: true,
|
||||
publishDelta: true,
|
||||
includeFull: true,
|
||||
includeDelta: true,
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
void this.loadSettings();
|
||||
}
|
||||
|
||||
async loadSettings(): Promise<void> {
|
||||
this.status.set('loading');
|
||||
this.message.set(null);
|
||||
|
||||
try {
|
||||
const settings: TrivyDbSettingsDto = await firstValueFrom(
|
||||
this.client.getTrivyDbSettings()
|
||||
);
|
||||
this.form.patchValue(settings);
|
||||
this.status.set('idle');
|
||||
} catch (error) {
|
||||
this.status.set('error');
|
||||
this.message.set(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to load Trivy DB settings.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async onSave(): Promise<void> {
|
||||
this.status.set('saving');
|
||||
this.message.set(null);
|
||||
|
||||
try {
|
||||
const payload = this.buildPayload();
|
||||
const updated: TrivyDbSettingsDto = await firstValueFrom(
|
||||
this.client.updateTrivyDbSettings(payload)
|
||||
);
|
||||
this.form.patchValue(updated);
|
||||
this.status.set('success');
|
||||
this.message.set('Settings saved successfully.');
|
||||
} catch (error) {
|
||||
this.status.set('error');
|
||||
this.message.set(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Unable to save settings. Please retry.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async onRunExport(): Promise<void> {
|
||||
this.status.set('running');
|
||||
this.message.set(null);
|
||||
|
||||
try {
|
||||
const payload = this.buildPayload();
|
||||
|
||||
// Persist overrides before triggering a run, ensuring parity.
|
||||
await firstValueFrom(this.client.updateTrivyDbSettings(payload));
|
||||
const response: TrivyDbRunResponseDto = await firstValueFrom(
|
||||
this.client.runTrivyDbExport(payload)
|
||||
);
|
||||
|
||||
this.lastRun.set(response);
|
||||
this.status.set('success');
|
||||
const formatted = new Date(response.triggeredAt).toISOString();
|
||||
this.message.set(
|
||||
`Export run ${response.exportId} triggered at ${formatted}.`
|
||||
);
|
||||
} catch (error) {
|
||||
this.status.set('error');
|
||||
this.message.set(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to trigger export run. Please retry.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
get isBusy(): boolean {
|
||||
const state = this.status();
|
||||
return state === 'loading' || state === 'saving' || state === 'running';
|
||||
}
|
||||
|
||||
private buildPayload(): TrivyDbSettingsDto {
|
||||
const raw = this.form.getRawValue();
|
||||
return {
|
||||
publishFull: !!raw.publishFull,
|
||||
publishDelta: !!raw.publishDelta,
|
||||
includeFull: !!raw.includeFull,
|
||||
includeDelta: !!raw.includeDelta,
|
||||
};
|
||||
}
|
||||
}
|
||||
290
src/Web/StellaOps.Web/src/app/testing/mock-notify-api.service.ts
Normal file
290
src/Web/StellaOps.Web/src/app/testing/mock-notify-api.service.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
import { defer, Observable, of } from 'rxjs';
|
||||
import { delay } from 'rxjs/operators';
|
||||
|
||||
import { NotifyApi } from '../core/api/notify.client';
|
||||
import {
|
||||
ChannelHealthResponse,
|
||||
ChannelTestSendRequest,
|
||||
ChannelTestSendResponse,
|
||||
ChannelHealthStatus,
|
||||
NotifyChannel,
|
||||
NotifyDeliveriesQueryOptions,
|
||||
NotifyDeliveriesResponse,
|
||||
NotifyDelivery,
|
||||
NotifyDeliveryRendered,
|
||||
NotifyRule,
|
||||
} from '../core/api/notify.models';
|
||||
import {
|
||||
inferHealthStatus,
|
||||
mockNotifyChannels,
|
||||
mockNotifyDeliveries,
|
||||
mockNotifyRules,
|
||||
mockNotifyTenant,
|
||||
} from './notify-fixtures';
|
||||
|
||||
const LATENCY_MS = 140;
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MockNotifyApiService implements NotifyApi {
|
||||
private readonly channels = signal<NotifyChannel[]>(
|
||||
clone(mockNotifyChannels)
|
||||
);
|
||||
private readonly rules = signal<NotifyRule[]>(clone(mockNotifyRules));
|
||||
private readonly deliveries = signal<NotifyDelivery[]>(
|
||||
clone(mockNotifyDeliveries)
|
||||
);
|
||||
|
||||
listChannels(): Observable<NotifyChannel[]> {
|
||||
return this.simulate(() => this.channels());
|
||||
}
|
||||
|
||||
saveChannel(channel: NotifyChannel): Observable<NotifyChannel> {
|
||||
const next = this.enrichChannel(channel);
|
||||
this.channels.update((items) => upsertById(items, next, (c) => c.channelId));
|
||||
return this.simulate(() => next);
|
||||
}
|
||||
|
||||
deleteChannel(channelId: string): Observable<void> {
|
||||
this.channels.update((items) => items.filter((c) => c.channelId !== channelId));
|
||||
return this.simulate(() => undefined);
|
||||
}
|
||||
|
||||
getChannelHealth(channelId: string): Observable<ChannelHealthResponse> {
|
||||
const channel = this.channels().find((c) => c.channelId === channelId);
|
||||
const now = new Date().toISOString();
|
||||
const status: ChannelHealthStatus = channel
|
||||
? inferHealthStatus(channel.enabled, !!channel.config.target)
|
||||
: 'Unhealthy';
|
||||
|
||||
const response: ChannelHealthResponse = {
|
||||
tenantId: mockNotifyTenant,
|
||||
channelId,
|
||||
status,
|
||||
message:
|
||||
status === 'Healthy'
|
||||
? 'Channel configuration validated.'
|
||||
: status === 'Degraded'
|
||||
? 'Channel disabled. Enable to resume deliveries.'
|
||||
: 'Channel is missing a destination target or endpoint.',
|
||||
checkedAt: now,
|
||||
traceId: this.traceId(),
|
||||
metadata: channel?.metadata ?? {},
|
||||
};
|
||||
|
||||
return this.simulate(() => response, 90);
|
||||
}
|
||||
|
||||
testChannel(
|
||||
channelId: string,
|
||||
payload: ChannelTestSendRequest
|
||||
): Observable<ChannelTestSendResponse> {
|
||||
const channel = this.channels().find((c) => c.channelId === channelId);
|
||||
const preview: NotifyDeliveryRendered = {
|
||||
channelType: channel?.type ?? 'Slack',
|
||||
format: channel?.type === 'Email' ? 'Email' : 'Slack',
|
||||
target:
|
||||
payload.target ?? channel?.config.target ?? channel?.config.endpoint ?? 'demo@stella-ops.org',
|
||||
title: payload.title ?? 'Notify preview — policy verdict change',
|
||||
body:
|
||||
payload.body ??
|
||||
'Sample preview payload emitted by the mocked Notify API integration.',
|
||||
summary: payload.summary ?? 'Mock delivery queued.',
|
||||
textBody: payload.textBody,
|
||||
locale: payload.locale ?? 'en-US',
|
||||
attachments: payload.attachments ?? [],
|
||||
};
|
||||
|
||||
const response: ChannelTestSendResponse = {
|
||||
tenantId: mockNotifyTenant,
|
||||
channelId,
|
||||
preview,
|
||||
queuedAt: new Date().toISOString(),
|
||||
traceId: this.traceId(),
|
||||
metadata: {
|
||||
source: 'mock-service',
|
||||
},
|
||||
};
|
||||
|
||||
this.appendDeliveryFromPreview(channelId, preview);
|
||||
|
||||
return this.simulate(() => response, 180);
|
||||
}
|
||||
|
||||
listRules(): Observable<NotifyRule[]> {
|
||||
return this.simulate(() => this.rules());
|
||||
}
|
||||
|
||||
saveRule(rule: NotifyRule): Observable<NotifyRule> {
|
||||
const next = this.enrichRule(rule);
|
||||
this.rules.update((items) => upsertById(items, next, (r) => r.ruleId));
|
||||
return this.simulate(() => next);
|
||||
}
|
||||
|
||||
deleteRule(ruleId: string): Observable<void> {
|
||||
this.rules.update((items) => items.filter((rule) => rule.ruleId !== ruleId));
|
||||
return this.simulate(() => undefined);
|
||||
}
|
||||
|
||||
listDeliveries(
|
||||
options?: NotifyDeliveriesQueryOptions
|
||||
): Observable<NotifyDeliveriesResponse> {
|
||||
const filtered = this.filterDeliveries(options);
|
||||
const payload: NotifyDeliveriesResponse = {
|
||||
items: filtered,
|
||||
continuationToken: null,
|
||||
count: filtered.length,
|
||||
};
|
||||
return this.simulate(() => payload);
|
||||
}
|
||||
|
||||
private enrichChannel(channel: NotifyChannel): NotifyChannel {
|
||||
const now = new Date().toISOString();
|
||||
const current = this.channels().find((c) => c.channelId === channel.channelId);
|
||||
return {
|
||||
schemaVersion: channel.schemaVersion ?? current?.schemaVersion ?? '1.0',
|
||||
channelId: channel.channelId || this.randomId('chn'),
|
||||
tenantId: channel.tenantId || mockNotifyTenant,
|
||||
name: channel.name,
|
||||
displayName: channel.displayName,
|
||||
description: channel.description,
|
||||
type: channel.type,
|
||||
enabled: channel.enabled,
|
||||
config: {
|
||||
...channel.config,
|
||||
properties: channel.config.properties ?? current?.config.properties ?? {},
|
||||
},
|
||||
labels: channel.labels ?? current?.labels ?? {},
|
||||
metadata: channel.metadata ?? current?.metadata ?? {},
|
||||
createdBy: current?.createdBy ?? 'ui@stella-ops.org',
|
||||
createdAt: current?.createdAt ?? now,
|
||||
updatedBy: 'ui@stella-ops.org',
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
private enrichRule(rule: NotifyRule): NotifyRule {
|
||||
const now = new Date().toISOString();
|
||||
const current = this.rules().find((r) => r.ruleId === rule.ruleId);
|
||||
return {
|
||||
schemaVersion: rule.schemaVersion ?? current?.schemaVersion ?? '1.0',
|
||||
ruleId: rule.ruleId || this.randomId('rule'),
|
||||
tenantId: rule.tenantId || mockNotifyTenant,
|
||||
name: rule.name,
|
||||
description: rule.description,
|
||||
enabled: rule.enabled,
|
||||
match: rule.match,
|
||||
actions: rule.actions?.length
|
||||
? rule.actions
|
||||
: current?.actions ?? [],
|
||||
labels: rule.labels ?? current?.labels ?? {},
|
||||
metadata: rule.metadata ?? current?.metadata ?? {},
|
||||
createdBy: current?.createdBy ?? 'ui@stella-ops.org',
|
||||
createdAt: current?.createdAt ?? now,
|
||||
updatedBy: 'ui@stella-ops.org',
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
private appendDeliveryFromPreview(
|
||||
channelId: string,
|
||||
preview: NotifyDeliveryRendered
|
||||
): void {
|
||||
const now = new Date().toISOString();
|
||||
const delivery: NotifyDelivery = {
|
||||
deliveryId: this.randomId('dlv'),
|
||||
tenantId: mockNotifyTenant,
|
||||
ruleId: 'rule-critical-soc',
|
||||
actionId: 'act-slack-critical',
|
||||
eventId: cryptoRandomUuid(),
|
||||
kind: 'notify.preview',
|
||||
status: 'Sent',
|
||||
statusReason: 'Preview enqueued (mock)',
|
||||
rendered: preview,
|
||||
attempts: [
|
||||
{
|
||||
timestamp: now,
|
||||
status: 'Enqueued',
|
||||
statusCode: 202,
|
||||
},
|
||||
{
|
||||
timestamp: now,
|
||||
status: 'Succeeded',
|
||||
statusCode: 200,
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
previewChannel: channelId,
|
||||
},
|
||||
createdAt: now,
|
||||
sentAt: now,
|
||||
completedAt: now,
|
||||
};
|
||||
|
||||
this.deliveries.update((items) => [delivery, ...items].slice(0, 20));
|
||||
}
|
||||
|
||||
private filterDeliveries(
|
||||
options?: NotifyDeliveriesQueryOptions
|
||||
): NotifyDelivery[] {
|
||||
const source = this.deliveries();
|
||||
const since = options?.since ? Date.parse(options.since) : null;
|
||||
const status = options?.status;
|
||||
|
||||
return source
|
||||
.filter((item) => {
|
||||
const matchStatus = status ? item.status === status : true;
|
||||
const matchSince = since ? Date.parse(item.createdAt) >= since : true;
|
||||
return matchStatus && matchSince;
|
||||
})
|
||||
.slice(0, options?.limit ?? 15);
|
||||
}
|
||||
|
||||
private simulate<T>(factory: () => T, ms: number = LATENCY_MS): Observable<T> {
|
||||
return defer(() => of(clone(factory()))).pipe(delay(ms));
|
||||
}
|
||||
|
||||
private randomId(prefix: string): string {
|
||||
const raw = cryptoRandomUuid().replace(/-/g, '').slice(0, 12);
|
||||
return `${prefix}-${raw}`;
|
||||
}
|
||||
|
||||
private traceId(): string {
|
||||
return `trace-${cryptoRandomUuid()}`;
|
||||
}
|
||||
}
|
||||
|
||||
function upsertById<T>(
|
||||
collection: readonly T[],
|
||||
entity: T,
|
||||
selector: (item: T) => string
|
||||
): T[] {
|
||||
const id = selector(entity);
|
||||
const next = [...collection];
|
||||
const index = next.findIndex((item) => selector(item) === id);
|
||||
if (index >= 0) {
|
||||
next[index] = entity;
|
||||
} else {
|
||||
next.unshift(entity);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function clone<T>(value: T): T {
|
||||
if (typeof structuredClone === 'function') {
|
||||
return structuredClone(value);
|
||||
}
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
function cryptoRandomUuid(): string {
|
||||
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
const template = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx';
|
||||
return template.replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
257
src/Web/StellaOps.Web/src/app/testing/notify-fixtures.ts
Normal file
257
src/Web/StellaOps.Web/src/app/testing/notify-fixtures.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import {
|
||||
ChannelHealthStatus,
|
||||
NotifyChannel,
|
||||
NotifyDelivery,
|
||||
NotifyDeliveryAttemptStatus,
|
||||
NotifyDeliveryStatus,
|
||||
NotifyRule,
|
||||
} from '../core/api/notify.models';
|
||||
|
||||
export const mockNotifyTenant = 'tenant-dev';
|
||||
|
||||
export const mockNotifyChannels: NotifyChannel[] = [
|
||||
{
|
||||
channelId: 'chn-slack-soc',
|
||||
tenantId: mockNotifyTenant,
|
||||
name: 'slack-soc',
|
||||
displayName: 'Slack · SOC',
|
||||
description: 'Critical scanner verdicts routed to the SOC war room.',
|
||||
type: 'Slack',
|
||||
enabled: true,
|
||||
config: {
|
||||
secretRef: 'ref://notify/slack/soc-token',
|
||||
target: '#stellaops-soc',
|
||||
properties: {
|
||||
emoji: ':rotating_light:',
|
||||
unfurl: 'false',
|
||||
},
|
||||
},
|
||||
labels: {
|
||||
tier: 'critical',
|
||||
region: 'global',
|
||||
},
|
||||
metadata: {
|
||||
workspace: 'stellaops',
|
||||
},
|
||||
createdBy: 'ops@stella-ops.org',
|
||||
createdAt: '2025-10-10T08:12:00Z',
|
||||
updatedBy: 'ops@stella-ops.org',
|
||||
updatedAt: '2025-10-23T11:05:00Z',
|
||||
},
|
||||
{
|
||||
channelId: 'chn-email-comms',
|
||||
tenantId: mockNotifyTenant,
|
||||
name: 'email-compliance',
|
||||
displayName: 'Email · Compliance Digest',
|
||||
description: 'Hourly compliance digest for licensing/secrets alerts.',
|
||||
type: 'Email',
|
||||
enabled: true,
|
||||
config: {
|
||||
secretRef: 'ref://notify/smtp/compliance',
|
||||
target: 'compliance@stella-ops.org',
|
||||
},
|
||||
labels: {
|
||||
cadence: 'hourly',
|
||||
},
|
||||
metadata: {
|
||||
smtpProfile: 'smtp.internal',
|
||||
},
|
||||
createdBy: 'legal@stella-ops.org',
|
||||
createdAt: '2025-10-08T14:31:00Z',
|
||||
updatedBy: 'legal@stella-ops.org',
|
||||
updatedAt: '2025-10-20T09:44:00Z',
|
||||
},
|
||||
{
|
||||
channelId: 'chn-webhook-intake',
|
||||
tenantId: mockNotifyTenant,
|
||||
name: 'webhook-opsbridge',
|
||||
displayName: 'Webhook · OpsBridge',
|
||||
description: 'Bridges Notify events into OpsBridge for automation.',
|
||||
type: 'Webhook',
|
||||
enabled: false,
|
||||
config: {
|
||||
secretRef: 'ref://notify/webhook/signing',
|
||||
endpoint: 'https://opsbridge.internal/hooks/notify',
|
||||
},
|
||||
labels: {
|
||||
env: 'staging',
|
||||
},
|
||||
metadata: {
|
||||
signature: 'ed25519',
|
||||
},
|
||||
createdBy: 'platform@stella-ops.org',
|
||||
createdAt: '2025-10-05T12:01:00Z',
|
||||
updatedBy: 'platform@stella-ops.org',
|
||||
updatedAt: '2025-10-18T17:22:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
export const mockNotifyRules: NotifyRule[] = [
|
||||
{
|
||||
ruleId: 'rule-critical-soc',
|
||||
tenantId: mockNotifyTenant,
|
||||
name: 'Critical scanner verdicts',
|
||||
description:
|
||||
'Route KEV-tagged critical findings to SOC Slack with zero delay.',
|
||||
enabled: true,
|
||||
match: {
|
||||
eventKinds: ['scanner.report.ready'],
|
||||
labels: ['kev', 'critical'],
|
||||
minSeverity: 'critical',
|
||||
verdicts: ['block', 'escalate'],
|
||||
kevOnly: true,
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
actionId: 'act-slack-critical',
|
||||
channel: 'chn-slack-soc',
|
||||
template: 'tmpl-critical',
|
||||
digest: 'instant',
|
||||
throttle: 'PT300S',
|
||||
locale: 'en-US',
|
||||
enabled: true,
|
||||
metadata: {
|
||||
priority: 'p1',
|
||||
},
|
||||
},
|
||||
],
|
||||
labels: {
|
||||
owner: 'soc',
|
||||
},
|
||||
metadata: {
|
||||
revision: '12',
|
||||
},
|
||||
createdBy: 'soc@stella-ops.org',
|
||||
createdAt: '2025-10-12T10:02:00Z',
|
||||
updatedBy: 'soc@stella-ops.org',
|
||||
updatedAt: '2025-10-23T15:44:00Z',
|
||||
},
|
||||
{
|
||||
ruleId: 'rule-digest-compliance',
|
||||
tenantId: mockNotifyTenant,
|
||||
name: 'Compliance hourly digest',
|
||||
description: 'Summarise licensing + secret alerts once per hour.',
|
||||
enabled: true,
|
||||
match: {
|
||||
eventKinds: ['scanner.scan.completed', 'scanner.report.ready'],
|
||||
labels: ['compliance'],
|
||||
minSeverity: 'medium',
|
||||
kevOnly: false,
|
||||
vex: {
|
||||
includeAcceptedJustifications: true,
|
||||
includeRejectedJustifications: false,
|
||||
includeUnknownJustifications: true,
|
||||
justificationKinds: ['exploitable', 'component_not_present'],
|
||||
},
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
actionId: 'act-email-compliance',
|
||||
channel: 'chn-email-comms',
|
||||
digest: '1h',
|
||||
throttle: 'PT1H',
|
||||
enabled: true,
|
||||
metadata: {
|
||||
layout: 'digest',
|
||||
},
|
||||
},
|
||||
],
|
||||
labels: {
|
||||
owner: 'compliance',
|
||||
},
|
||||
metadata: {
|
||||
frequency: 'hourly',
|
||||
},
|
||||
createdBy: 'compliance@stella-ops.org',
|
||||
createdAt: '2025-10-09T06:15:00Z',
|
||||
updatedBy: 'compliance@stella-ops.org',
|
||||
updatedAt: '2025-10-21T19:45:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
const deliveryStatuses: NotifyDeliveryStatus[] = [
|
||||
'Sent',
|
||||
'Failed',
|
||||
'Throttled',
|
||||
];
|
||||
|
||||
export const mockNotifyDeliveries: NotifyDelivery[] = deliveryStatuses.map(
|
||||
(status, index) => {
|
||||
const now = new Date('2025-10-24T12:00:00Z').getTime();
|
||||
const created = new Date(now - index * 20 * 60 * 1000).toISOString();
|
||||
const attemptsStatus: NotifyDeliveryAttemptStatus =
|
||||
status === 'Sent' ? 'Succeeded' : status === 'Failed' ? 'Failed' : 'Throttled';
|
||||
|
||||
return {
|
||||
deliveryId: `dlv-${index + 1}`,
|
||||
tenantId: mockNotifyTenant,
|
||||
ruleId: index === 0 ? 'rule-critical-soc' : 'rule-digest-compliance',
|
||||
actionId: index === 0 ? 'act-slack-critical' : 'act-email-compliance',
|
||||
eventId: `00000000-0000-0000-0000-${(index + 1)
|
||||
.toString()
|
||||
.padStart(12, '0')}`,
|
||||
kind: index === 0 ? 'scanner.report.ready' : 'scanner.scan.completed',
|
||||
status,
|
||||
statusReason:
|
||||
status === 'Sent'
|
||||
? 'Delivered'
|
||||
: status === 'Failed'
|
||||
? 'Channel timeout (Slack API)'
|
||||
: 'Rule throttled (digest window).',
|
||||
rendered: {
|
||||
channelType: index === 0 ? 'Slack' : 'Email',
|
||||
format: index === 0 ? 'Slack' : 'Email',
|
||||
target: index === 0 ? '#stellaops-soc' : 'compliance@stella-ops.org',
|
||||
title:
|
||||
index === 0
|
||||
? 'Critical CVE flagged for registry.git.stella-ops.org'
|
||||
: 'Hourly compliance digest (#23)',
|
||||
body:
|
||||
index === 0
|
||||
? 'KEV CVE-2025-1234 detected in ubuntu:24.04. Rescan triggered.'
|
||||
: '3 findings require compliance review. See attached report.',
|
||||
summary: index === 0 ? 'Immediate attention required.' : 'Digest only.',
|
||||
locale: 'en-US',
|
||||
attachments: index === 0 ? [] : ['https://scanner.local/reports/digest-23'],
|
||||
},
|
||||
attempts: [
|
||||
{
|
||||
timestamp: created,
|
||||
status: 'Sending',
|
||||
statusCode: 202,
|
||||
},
|
||||
{
|
||||
timestamp: created,
|
||||
status: attemptsStatus,
|
||||
statusCode: status === 'Sent' ? 200 : 429,
|
||||
reason:
|
||||
status === 'Failed'
|
||||
? 'Slack API returned 504'
|
||||
: status === 'Throttled'
|
||||
? 'Digest window open'
|
||||
: undefined,
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
batch: `window-${index + 1}`,
|
||||
},
|
||||
createdAt: created,
|
||||
sentAt: created,
|
||||
completedAt: created,
|
||||
} satisfies NotifyDelivery;
|
||||
}
|
||||
);
|
||||
|
||||
export function inferHealthStatus(
|
||||
enabled: boolean,
|
||||
hasTarget: boolean
|
||||
): ChannelHealthStatus {
|
||||
if (!hasTarget) {
|
||||
return 'Unhealthy';
|
||||
}
|
||||
if (!enabled) {
|
||||
return 'Degraded';
|
||||
}
|
||||
return 'Healthy';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { getPolicyPreviewFixture, getPolicyReportFixture } from './policy-fixtures';
|
||||
|
||||
describe('policy fixtures', () => {
|
||||
it('returns fresh clones for preview data', () => {
|
||||
const first = getPolicyPreviewFixture();
|
||||
const second = getPolicyPreviewFixture();
|
||||
|
||||
expect(first).not.toBe(second);
|
||||
expect(first.previewRequest).not.toBe(second.previewRequest);
|
||||
expect(first.previewResponse.diffs).not.toBe(second.previewResponse.diffs);
|
||||
});
|
||||
|
||||
it('exposes required policy preview fields', () => {
|
||||
const { previewRequest, previewResponse } = getPolicyPreviewFixture();
|
||||
|
||||
expect(previewRequest.imageDigest).toMatch(/^sha256:[0-9a-f]{64}$/);
|
||||
expect(Array.isArray(previewRequest.findings)).toBeTrue();
|
||||
expect(previewRequest.findings.length).toBeGreaterThan(0);
|
||||
expect(previewResponse.success).toBeTrue();
|
||||
expect(previewResponse.policyDigest).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(previewResponse.diffs.length).toBeGreaterThan(0);
|
||||
|
||||
const diff = previewResponse.diffs[0];
|
||||
expect(diff.projected.confidenceBand).toBeDefined();
|
||||
expect(diff.projected.unknownConfidence).toBeGreaterThan(0);
|
||||
expect(diff.projected.reachability).toBeDefined();
|
||||
});
|
||||
|
||||
it('aligns preview and report fixtures', () => {
|
||||
const preview = getPolicyPreviewFixture();
|
||||
const { reportResponse } = getPolicyReportFixture();
|
||||
|
||||
expect(reportResponse.report.policy.digest).toEqual(
|
||||
preview.previewResponse.policyDigest
|
||||
);
|
||||
expect(reportResponse.report.verdicts.length).toEqual(
|
||||
reportResponse.report.summary.total
|
||||
);
|
||||
expect(reportResponse.report.verdicts.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
reportResponse.report.verdicts.some(
|
||||
(verdict) => verdict.confidenceBand != null
|
||||
)
|
||||
).toBeTrue();
|
||||
});
|
||||
|
||||
it('provides DSSE metadata for report fixture', () => {
|
||||
const { reportResponse } = getPolicyReportFixture();
|
||||
|
||||
expect(reportResponse.dsse).toBeDefined();
|
||||
expect(reportResponse.dsse?.payloadType).toBe('application/vnd.stellaops.report+json');
|
||||
expect(reportResponse.dsse?.signatures?.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
23
src/Web/StellaOps.Web/src/app/testing/policy-fixtures.ts
Normal file
23
src/Web/StellaOps.Web/src/app/testing/policy-fixtures.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import previewSample from '../../../../../samples/policy/policy-preview-unknown.json';
|
||||
import reportSample from '../../../../../samples/policy/policy-report-unknown.json';
|
||||
import {
|
||||
PolicyPreviewSample,
|
||||
PolicyReportSample,
|
||||
} from '../core/api/policy-preview.models';
|
||||
|
||||
const previewFixture: PolicyPreviewSample =
|
||||
previewSample as unknown as PolicyPreviewSample;
|
||||
const reportFixture: PolicyReportSample =
|
||||
reportSample as unknown as PolicyReportSample;
|
||||
|
||||
export function getPolicyPreviewFixture(): PolicyPreviewSample {
|
||||
return clone(previewFixture);
|
||||
}
|
||||
|
||||
export function getPolicyReportFixture(): PolicyReportSample {
|
||||
return clone(reportFixture);
|
||||
}
|
||||
|
||||
function clone<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
30
src/Web/StellaOps.Web/src/app/testing/scan-fixtures.ts
Normal file
30
src/Web/StellaOps.Web/src/app/testing/scan-fixtures.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { ScanDetail } from '../core/api/scanner.models';
|
||||
|
||||
export const scanDetailWithVerifiedAttestation: ScanDetail = {
|
||||
scanId: 'scan-verified-001',
|
||||
imageDigest:
|
||||
'sha256:9f92a8c39f8d4f7bb1a60f2be650b3019b9a1bb50d2da839efa9bf2a278a0071',
|
||||
completedAt: '2025-10-20T18:22:04Z',
|
||||
attestation: {
|
||||
uuid: '018ed91c-9b64-7edc-b9ac-0bada2f8d501',
|
||||
index: 412398,
|
||||
logUrl: 'https://rekor.sigstore.dev',
|
||||
status: 'verified',
|
||||
checkedAt: '2025-10-23T12:04:52Z',
|
||||
statusMessage: 'Rekor transparency log inclusion proof verified.',
|
||||
},
|
||||
};
|
||||
|
||||
export const scanDetailWithFailedAttestation: ScanDetail = {
|
||||
scanId: 'scan-failed-002',
|
||||
imageDigest:
|
||||
'sha256:b0d6865de537e45bdd9dd72cdac02bc6f459f0e546ed9134e2afc2fccd6298e0',
|
||||
completedAt: '2025-10-19T07:14:33Z',
|
||||
attestation: {
|
||||
uuid: '018ed91c-ffff-4882-9955-0027c0bbb090',
|
||||
status: 'failed',
|
||||
checkedAt: '2025-10-23T09:18:11Z',
|
||||
statusMessage:
|
||||
'Verification failed: inclusion proof leaf hash mismatch at depth 4.',
|
||||
},
|
||||
};
|
||||
0
src/Web/StellaOps.Web/src/assets/.gitkeep
Normal file
0
src/Web/StellaOps.Web/src/assets/.gitkeep
Normal file
26
src/Web/StellaOps.Web/src/config/config.json
Normal file
26
src/Web/StellaOps.Web/src/config/config.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"authority": {
|
||||
"issuer": "https://authority.local",
|
||||
"clientId": "stellaops-ui",
|
||||
"authorizeEndpoint": "https://authority.local/connect/authorize",
|
||||
"tokenEndpoint": "https://authority.local/connect/token",
|
||||
"logoutEndpoint": "https://authority.local/connect/logout",
|
||||
"redirectUri": "http://localhost:4400/auth/callback",
|
||||
"postLogoutRedirectUri": "http://localhost:4400/",
|
||||
"scope": "openid profile email ui.read authority:tenants.read advisory:read vex:read exceptions:read exceptions:approve aoc:verify findings:read orch:read vuln:read",
|
||||
"audience": "https://scanner.local",
|
||||
"dpopAlgorithms": ["ES256"],
|
||||
"refreshLeewaySeconds": 60
|
||||
},
|
||||
"apiBaseUrls": {
|
||||
"authority": "https://authority.local",
|
||||
"scanner": "https://scanner.local",
|
||||
"policy": "https://scanner.local",
|
||||
"concelier": "https://concelier.local",
|
||||
"attestor": "https://attestor.local"
|
||||
},
|
||||
"telemetry": {
|
||||
"otlpEndpoint": "http://localhost:4318/v1/traces",
|
||||
"sampleRate": 0.1
|
||||
}
|
||||
}
|
||||
26
src/Web/StellaOps.Web/src/config/config.sample.json
Normal file
26
src/Web/StellaOps.Web/src/config/config.sample.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"authority": {
|
||||
"issuer": "https://authority.example.dev",
|
||||
"clientId": "stellaops-ui",
|
||||
"authorizeEndpoint": "https://authority.example.dev/connect/authorize",
|
||||
"tokenEndpoint": "https://authority.example.dev/connect/token",
|
||||
"logoutEndpoint": "https://authority.example.dev/connect/logout",
|
||||
"redirectUri": "http://localhost:4400/auth/callback",
|
||||
"postLogoutRedirectUri": "http://localhost:4400/",
|
||||
"scope": "openid profile email ui.read authority:tenants.read advisory:read vex:read exceptions:read exceptions:approve aoc:verify findings:read orch:read vuln:read",
|
||||
"audience": "https://scanner.example.dev",
|
||||
"dpopAlgorithms": ["ES256"],
|
||||
"refreshLeewaySeconds": 60
|
||||
},
|
||||
"apiBaseUrls": {
|
||||
"authority": "https://authority.example.dev",
|
||||
"scanner": "https://scanner.example.dev",
|
||||
"policy": "https://scanner.example.dev",
|
||||
"concelier": "https://concelier.example.dev",
|
||||
"attestor": "https://attestor.example.dev"
|
||||
},
|
||||
"telemetry": {
|
||||
"otlpEndpoint": "",
|
||||
"sampleRate": 0
|
||||
}
|
||||
}
|
||||
BIN
src/Web/StellaOps.Web/src/favicon.ico
Normal file
BIN
src/Web/StellaOps.Web/src/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
13
src/Web/StellaOps.Web/src/index.html
Normal file
13
src/Web/StellaOps.Web/src/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>StellaopsWeb</title>
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||
</head>
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
</html>
|
||||
6
src/Web/StellaOps.Web/src/main.ts
Normal file
6
src/Web/StellaOps.Web/src/main.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { appConfig } from './app/app.config';
|
||||
import { AppComponent } from './app/app.component';
|
||||
|
||||
bootstrapApplication(AppComponent, appConfig)
|
||||
.catch((err) => console.error(err));
|
||||
1
src/Web/StellaOps.Web/src/styles.scss
Normal file
1
src/Web/StellaOps.Web/src/styles.scss
Normal file
@@ -0,0 +1 @@
|
||||
/* You can add global styles to this file, and also import other style files */
|
||||
4
src/Web/StellaOps.Web/test-results/.last-run.json
Normal file
4
src/Web/StellaOps.Web/test-results/.last-run.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
}
|
||||
79
src/Web/StellaOps.Web/tests/e2e/auth.spec.ts
Normal file
79
src/Web/StellaOps.Web/tests/e2e/auth.spec.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const mockConfig = {
|
||||
authority: {
|
||||
issuer: 'https://authority.local',
|
||||
clientId: 'stellaops-ui',
|
||||
authorizeEndpoint: 'https://authority.local/connect/authorize',
|
||||
tokenEndpoint: 'https://authority.local/connect/token',
|
||||
logoutEndpoint: 'https://authority.local/connect/logout',
|
||||
redirectUri: 'http://127.0.0.1:4400/auth/callback',
|
||||
postLogoutRedirectUri: 'http://127.0.0.1:4400/',
|
||||
scope:
|
||||
'openid profile email ui.read authority:tenants.read advisory:read vex:read exceptions:read exceptions:approve aoc:verify findings:read orch:read vuln:read',
|
||||
audience: 'https://scanner.local',
|
||||
dpopAlgorithms: ['ES256'],
|
||||
refreshLeewaySeconds: 60,
|
||||
},
|
||||
apiBaseUrls: {
|
||||
authority: 'https://authority.local',
|
||||
scanner: 'https://scanner.local',
|
||||
policy: 'https://scanner.local',
|
||||
concelier: 'https://concelier.local',
|
||||
attestor: 'https://attestor.local',
|
||||
},
|
||||
};
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
page.on('console', (message) => {
|
||||
// bubble up browser logs for debugging
|
||||
console.log('[browser]', message.type(), message.text());
|
||||
});
|
||||
page.on('pageerror', (error) => {
|
||||
console.log('[pageerror]', error.message);
|
||||
});
|
||||
await page.addInitScript(() => {
|
||||
// Capture attempted redirects so the test can assert against them.
|
||||
(window as any).__stellaopsAssignedUrls = [];
|
||||
const originalAssign = window.location.assign.bind(window.location);
|
||||
window.location.assign = (url: string | URL) => {
|
||||
(window as any).__stellaopsAssignedUrls.push(url.toString());
|
||||
};
|
||||
|
||||
window.sessionStorage.clear();
|
||||
});
|
||||
await page.route('**/config.json', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockConfig),
|
||||
})
|
||||
);
|
||||
await page.route('https://authority.local/**', (route) => route.abort());
|
||||
});
|
||||
|
||||
test('sign-in flow builds Authority authorization URL', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
const signInButton = page.getByRole('button', { name: /sign in/i });
|
||||
await expect(signInButton).toBeVisible();
|
||||
const [request] = await Promise.all([
|
||||
page.waitForRequest('https://authority.local/connect/authorize*'),
|
||||
signInButton.click(),
|
||||
]);
|
||||
|
||||
const authorizeUrl = new URL(request.url());
|
||||
expect(authorizeUrl.origin).toBe('https://authority.local');
|
||||
expect(authorizeUrl.pathname).toBe('/connect/authorize');
|
||||
expect(authorizeUrl.searchParams.get('client_id')).toBe('stellaops-ui');
|
||||
|
||||
});
|
||||
|
||||
test('callback without pending state surfaces error message', async ({ page }) => {
|
||||
await page.route('https://authority.local/**', (route) =>
|
||||
route.fulfill({ status: 400, body: 'blocked' })
|
||||
);
|
||||
await page.goto('/auth/callback?code=test-code&state=missing');
|
||||
await expect(
|
||||
page.getByText('We were unable to complete the sign-in flow. Please try again.')
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
14
src/Web/StellaOps.Web/tsconfig.app.json
Normal file
14
src/Web/StellaOps.Web/tsconfig.app.json
Normal file
@@ -0,0 +1,14 @@
|
||||
/* To learn more about this file see: https://angular.io/config/tsconfig. */
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/app",
|
||||
"types": []
|
||||
},
|
||||
"files": [
|
||||
"src/main.ts"
|
||||
],
|
||||
"include": [
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
33
src/Web/StellaOps.Web/tsconfig.json
Normal file
33
src/Web/StellaOps.Web/tsconfig.json
Normal file
@@ -0,0 +1,33 @@
|
||||
/* To learn more about this file see: https://angular.io/config/tsconfig. */
|
||||
{
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist/out-tsc",
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"sourceMap": true,
|
||||
"declaration": false,
|
||||
"experimentalDecorators": true,
|
||||
"moduleResolution": "node",
|
||||
"importHelpers": true,
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"useDefineForClassFields": false,
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"dom"
|
||||
]
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"strictTemplates": true
|
||||
}
|
||||
}
|
||||
14
src/Web/StellaOps.Web/tsconfig.spec.json
Normal file
14
src/Web/StellaOps.Web/tsconfig.spec.json
Normal file
@@ -0,0 +1,14 @@
|
||||
/* To learn more about this file see: https://angular.io/config/tsconfig. */
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/spec",
|
||||
"types": [
|
||||
"jasmine"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user