mirror of
https://github.com/MAKS-IT-COM/maksit-certs-ui.git
synced 2026-06-10 00:28:11 +02:00
(bugfix): startup phase health/readiness hardening, Helm/RBAC/E2E documentation updates, and aligned dependency/configuration refinements across server, engine, and WebUI auth refresh flow.
This commit is contained in:
parent
c3efd64702
commit
4543cfd02b
8
.gitignore
vendored
8
.gitignore
vendored
@ -193,7 +193,6 @@ ClientBin/
|
||||
*.pfx
|
||||
*.publishsettings
|
||||
node_modules/
|
||||
dist/
|
||||
orleans.codegen.cs
|
||||
.directory
|
||||
|
||||
@ -262,7 +261,6 @@ paket-files/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# SonarQube
|
||||
.scannerwork/
|
||||
.sonarlint/
|
||||
src/MaksIT.WebUI/public/pdf.worker.min.mjs
|
||||
# Custom
|
||||
dist/
|
||||
![Uu]tils/**
|
||||
36
CHANGELOG.md
36
CHANGELOG.md
@ -4,6 +4,42 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [3.5.1] - 2026-06-02
|
||||
|
||||
**Release status:** **3.3.4** is the last published release. **3.5.1** is a patch on **3.5.0** (startup health, Helm secrets alignment, RBAC docs, Web UI token refresh).
|
||||
|
||||
### Breaking
|
||||
|
||||
- **Helm secrets (`certsServerSecrets`):** Renamed **`authSecret`** → **`jwtSecret`**, **`authPepper`** → **`passwordPepper`**. Bootstrap admin username moved from ConfigMap to secrets as **`adminUsername`** (removed **`certsServerConfig.configuration.certsEngineConfiguration.admin.username`**). Update custom values and external secret templates before upgrade.
|
||||
|
||||
### Added
|
||||
|
||||
- **`GET /health/startup`:** JSON snapshot of phased database and bootstrap startup (`CertsStartupState`, `IDatabaseStartupObserver`, `DatabaseStartupPhaseRunner`).
|
||||
- **Web UI:** **`webUiAuthRefresh.ts`** — single in-flight JWT refresh shared by axios (and **`resolveWebUiAccessToken`** for future SignalR hubs).
|
||||
- **Docs:** **`assets/docs/RBAC_REFERENCE.md`**, **`assets/docs/USER_AND_API_KEY_RBAC.md`**; README RBAC overview and table-of-contents links.
|
||||
- **E2E:** **`src/e2e-tests/README.md`** — PowerShell API-key E2E credentials and compose URL guidance (replaces **`MaksIT.CertsUI.Client.Tests/README.E2E.md`**).
|
||||
|
||||
### Changed
|
||||
|
||||
- **`GET /health/ready`:** Returns **HTTP 503** until bootstrap coordination completes (`CertsStartupState.IsApplicationReady`), then checks PostgreSQL as before.
|
||||
- **`RunMigrationsService`:** Reports phased startup; waits on maintenance database **`postgres`** before **`EnsureDatabaseExists`** and on the application database before **`MigrateUp`**.
|
||||
- **`SchemaSyncService`:** Reports **`schema_sync`** phase via **`IDatabaseStartupObserver`** (no-op timing when **`AutoSyncSchema`** is false).
|
||||
- **`InitializationHostedService`:** Records bootstrap coordination phase success/failure in **`CertsStartupState`**.
|
||||
- **`CertsEngineConfiguration.ConnectionString`:** **`required`** in host configuration (no empty default).
|
||||
- **`IdentityDomainService`:** Configuration field and error messages use **`CertsEngineConfiguration`** naming (not Vault-era **`VaultEngineConfiguration`**).
|
||||
- **Helm:** Server **`startupProbe`** on **`/health/ready`** (up to ~5 minutes); readiness **`initialDelaySeconds`** reduced to **5**; **`NOTES.txt`** documents **`/health/startup`** and external Postgres.
|
||||
- **README / Compose examples:** Admin username in **`appsecrets.json`** only; JWT/pepper placeholder names aligned with Helm.
|
||||
- **Dependencies:** linq2db **6.3.0**, Microsoft.Extensions.\* **10.0.8**, Npgsql **10.0.3**, Swashbuckle **10.2.1**, Testcontainers.PostgreSql **4.12.0**, **Microsoft.NET.Test.Sdk** **18.6.0**, PowerShell **System.Management.Automation** **7.6.2**.
|
||||
|
||||
### Removed
|
||||
|
||||
- **`MaksIT.CertsUI.Client.Tests`:** **`CertsUiApiKeyE2ETests`** and **`README.E2E.md`** (API-key E2E remains under **`src/e2e-tests/`** and PowerShell scripts).
|
||||
|
||||
### Upgrade notes (from 3.5.0)
|
||||
|
||||
- **Helm:** Rename secret keys in **`certsServerSecrets`** and set **`adminUsername`**; remove admin username from non-secret ConfigMap overrides if you duplicated it there.
|
||||
- **Probes:** Use **`/health/startup`** for startup diagnostics; keep load balancers on **`/health/ready`** (503 until the cluster has a bootstrapped user).
|
||||
|
||||
## [3.5.0] - 2026-05-24
|
||||
|
||||
**Release status:** **3.3.4** is the last published release. **3.5.0** consolidates all changes since **3.3.4** (HA, Engine/Vault alignment, client libraries, Web UI shared packages).
|
||||
|
||||
@ -16,7 +16,7 @@ Large or architectural changes are best discussed first (see [Contact](#contact)
|
||||
|
||||
**Where the rules live:** layering, folder responsibilities, persistence vs host boundaries, DI lifetimes, and an AI/contributor checklist are documented in **[assets/docs/ARCHITECTURE_LAYERING.md](assets/docs/ARCHITECTURE_LAYERING.md)**. Read that before adding new Engine persistence, services, or cross-project dependencies.
|
||||
|
||||
**Summary:** `MaksIT.CertsUI.Engine` holds domain, PostgreSQL persistence (Linq2Db), migrations, and ACME engine code; it returns `MaksIT.Results` types, not HTTP responses. `MaksIT.CertsUI` is the web host (controllers, app services, ProblemDetails). Topic-specific design notes also live under [assets/docs/](assets/docs/) (HA, auth, proxy, etc.).
|
||||
**Summary:** `MaksIT.CertsUI.Engine` holds domain, PostgreSQL persistence (Linq2Db), migrations, and ACME engine code; it returns `MaksIT.Results` types, not HTTP responses. `MaksIT.CertsUI` is the web host (controllers, app services, ProblemDetails). Topic-specific design notes also live under [assets/docs/](assets/docs/) (HA, auth, RBAC, proxy, etc.). For authorization work, read [USER_AND_API_KEY_RBAC.md](assets/docs/USER_AND_API_KEY_RBAC.md) and [RBAC_REFERENCE.md](assets/docs/RBAC_REFERENCE.md) before changing filters or identity/API-key services.
|
||||
|
||||
## Development setup
|
||||
|
||||
@ -40,7 +40,7 @@ Use `Debug` while iterating locally if you prefer.
|
||||
|
||||
Follow [README.md](README.md) for Podman Compose, Docker Compose, or Kubernetes (Helm). That is the supported way to exercise the WebAPI, WebUI, and reverse proxy together.
|
||||
|
||||
**Automated tests:** from the repo root, `dotnet test src/MaksIT.CertsUI.Engine.Tests` and `dotnet test src/MaksIT.CertsUI.Tests` (the latter may require a reachable PostgreSQL when integration tests run). For UI-only or deployment changes, manual verification through the WebUI and compose or cluster setup still applies.
|
||||
**Automated tests:** CI runs `utils/engines/test` (Engine, Client, and main test projects). From the repo root: `dotnet test src/MaksIT.CertsUI.Engine.Tests`, `dotnet test src/MaksIT.CertsUI.Client.Tests` (mock HTTP only), and `dotnet test src/MaksIT.CertsUI.Tests` (Testcontainers PostgreSQL for integration tests). **E2E** against a live deployment is manual only: [`src/e2e-tests/README.md`](src/e2e-tests/README.md). For UI-only or deployment changes, manual verification through the WebUI and compose or cluster setup still applies.
|
||||
|
||||
## Pull requests
|
||||
|
||||
|
||||
106
README.md
106
README.md
@ -6,6 +6,10 @@ MaksIT.CertsUI is a powerful, container-native ACMEv2 client built to simplify a
|
||||
|
||||
Designed for modern infrastructure, it combines a robust WebAPI, intuitive WebUI, and lightweight edge Agent to deliver fully automated certificate issuance, renewal, and deployment across Docker, Podman, and Kubernetes environments. MaksIT.CertsUI supports the HTTP-01 challenge and follows the official [Let’s Encrypt guidelines](https://letsencrypt.org/docs/) while implementing recommended security and operational best practices.
|
||||
|
||||
Authorization is **scope-based RBAC** for **users** and **API keys** (organization-scoped **Identity** / **ApiKey** flags). **Global administrator** on a signed-in user (JWT) and on an API key are evaluated **separately**—a user being admin does not automatically grant the same to a key they create. Certificate and account endpoints today accept **any authenticated** principal; see the matrices for detail.
|
||||
|
||||
Permission matrices and scope semantics are documented in the [RBAC reference](assets/docs/RBAC_REFERENCE.md); authentication mechanics and routes are in [User and API key RBAC](assets/docs/USER_AND_API_KEY_RBAC.md).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -22,6 +26,8 @@ If you find this project useful, please consider supporting its development:
|
||||
- [Table of Contents](#table-of-contents)
|
||||
- [Changelog](#changelog)
|
||||
- [Contributing](#contributing)
|
||||
- [User and API key RBAC](#user-and-api-key-rbac)
|
||||
- [RBAC reference](#rbac-reference)
|
||||
- [Patch and delta reference](#patch-and-delta-reference)
|
||||
- [Login and refresh token architecture](#login-and-refresh-token-architecture)
|
||||
- [Reverse proxy routing (YARP)](#reverse-proxy-routing-yarp)
|
||||
@ -60,6 +66,35 @@ Version history and release notes live in [CHANGELOG.md](CHANGELOG.md).
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for setup, pull request expectations, and security reporting.
|
||||
|
||||
## User and API key RBAC
|
||||
|
||||
How JWT and **`X-API-KEY`** principals are resolved, how **`CertsUIAuthorizationFilter`** differs from Vault’s route split, **`GetActingJwtTokenData`**, and where rules live in code: **[assets/docs/USER_AND_API_KEY_RBAC.md](assets/docs/USER_AND_API_KEY_RBAC.md)**.
|
||||
|
||||
- [1. Two authentication mechanisms](assets/docs/USER_AND_API_KEY_RBAC.md#1-two-authentication-mechanisms)
|
||||
- [2. Two principal types (what RBAC sees)](assets/docs/USER_AND_API_KEY_RBAC.md#2-two-principal-types-what-rbac-sees)
|
||||
- [2.1 Global administrator: user vs key](assets/docs/USER_AND_API_KEY_RBAC.md#21-global-administrator-user-vs-key-easy-to-confuse)
|
||||
- [2.2 Loading API key authorization](assets/docs/USER_AND_API_KEY_RBAC.md#22-loading-api-key-authorization)
|
||||
- [3. Shared RBAC helpers (`ServiceBase`)](assets/docs/USER_AND_API_KEY_RBAC.md#3-shared-rbac-helpers-servicebase)
|
||||
- [4. Example: accounts and ACME](assets/docs/USER_AND_API_KEY_RBAC.md#4-example-accounts-and-acme-accountservice-certsflowservice)
|
||||
- [5. Identity and API key administration](assets/docs/USER_AND_API_KEY_RBAC.md#5-identity-and-api-key-administration-getactingjwttokendata)
|
||||
- [6. Troubleshooting](assets/docs/USER_AND_API_KEY_RBAC.md#6-troubleshooting)
|
||||
- [7. Code map](assets/docs/USER_AND_API_KEY_RBAC.md#7-code-map)
|
||||
|
||||
## RBAC reference
|
||||
|
||||
Scope flags, intended vs enforced rules, and permission matrices for Identity, API keys, and ACME endpoints: **[assets/docs/RBAC_REFERENCE.md](assets/docs/RBAC_REFERENCE.md)**.
|
||||
|
||||
- [1. Scope model](assets/docs/RBAC_REFERENCE.md#1-scope-model)
|
||||
- [2. Shorthand columns (matrices below)](assets/docs/RBAC_REFERENCE.md#2-shorthand-columns-matrices-below)
|
||||
- [3. Global administrator](assets/docs/RBAC_REFERENCE.md#3-global-administrator)
|
||||
- [4. Identity (users)](assets/docs/RBAC_REFERENCE.md#4-identity-users)
|
||||
- [4.1 Enforced in code today](assets/docs/RBAC_REFERENCE.md#41-enforced-in-code-today-source-of-truth)
|
||||
- [4.2 Intended policy](assets/docs/RBAC_REFERENCE.md#42-intended-policy-target-behavior-align-crud-with-this)
|
||||
- [5. ACME, accounts, cache, and agent](assets/docs/RBAC_REFERENCE.md#5-acme-accounts-cache-and-agent)
|
||||
- [6. Managing API keys](assets/docs/RBAC_REFERENCE.md#6-managing-api-keys)
|
||||
- [7. Calling the API with an API key](assets/docs/RBAC_REFERENCE.md#7-calling-the-api-with-an-api-key)
|
||||
- [8. Comparison with MaksIT.Vault](assets/docs/RBAC_REFERENCE.md#8-comparison-with-maksitvault)
|
||||
|
||||
## Patch and delta reference
|
||||
|
||||
How PATCH payloads (deltas) are built and applied is documented in **[assets/docs/PATCH_DELTA_REFERENCE.md](assets/docs/PATCH_DELTA_REFERENCE.md)**. It matches the **MaksIT.Core** contract; this repo focuses on **account** PATCH and **`hostnames`** in the WebUI.
|
||||
@ -277,11 +312,12 @@ sudo tee /opt/Compose/MaksIT.CertsUI/secrets/appsecrets.json > /dev/null <<EOF
|
||||
"CertsEngineConfiguration": {
|
||||
"ConnectionString": "Host=postgres;Port=5432;Database=certsui;Username=certsui;Password=certsui;SslMode=Prefer",
|
||||
"Admin": {
|
||||
"Username": "admin",
|
||||
"Password": "<your-admin-password>"
|
||||
},
|
||||
"JwtSettingsConfiguration": {
|
||||
"JwtSecret": "<your-auth-secret>",
|
||||
"PasswordPepper": "<your-pepper>"
|
||||
"JwtSecret": "<your-jwt-secret>",
|
||||
"PasswordPepper": "<your-password-pepper>"
|
||||
},
|
||||
"Agent": {
|
||||
"AgentKey": "<your-agent-key>"
|
||||
@ -293,7 +329,7 @@ EOF
|
||||
```
|
||||
|
||||
**Note:**
|
||||
Secrets use **`Configuration:CertsEngineConfiguration`** (same shape as [`src/helm/values.yaml`](src/helm/values.yaml) templated `appsecrets.json`). Set **`ConnectionString`** to the Compose Postgres service hostname (**`postgres`**) and credentials that match the **`postgres`** service below (**`certsui`** / **`certsui`** / database **`certsui`** by default, aligned with [`src/docker-compose.override.yml`](src/docker-compose.override.yml)). Legacy **`ConnectionStrings:Certs`** is still accepted if **`ConnectionString`** is empty. Replace `<your-admin-password>`, `<your-auth-secret>`, `<your-pepper>`, and `<your-agent-key>` with secure values. Ensure `<your-agent-key>` matches your edge agent deployment.
|
||||
Secrets use **`Configuration:CertsEngineConfiguration`** (same shape as [`src/helm/values.yaml`](src/helm/values.yaml) templated `appsecrets.json`). Set **`ConnectionString`** to the Compose Postgres service hostname (**`postgres`**) and credentials that match the **`postgres`** service below (**`certsui`** / **`certsui`** / database **`certsui`** by default, aligned with [`src/docker-compose.override.yml`](src/docker-compose.override.yml)). Legacy **`ConnectionStrings:Certs`** is still accepted if **`ConnectionString`** is empty. Replace `<your-admin-password>`, `<your-jwt-secret>`, `<your-password-pepper>`, and `<your-agent-key>` with secure values. Ensure `<your-agent-key>` matches your edge agent deployment.
|
||||
|
||||
**2. Create the file `/opt/Compose/MaksIT.CertsUI/configMap/appsettings.json` with this command:**
|
||||
|
||||
@ -310,9 +346,6 @@ sudo tee /opt/Compose/MaksIT.CertsUI/configMap/appsettings.json <<EOF
|
||||
"Configuration": {
|
||||
"CertsEngineConfiguration": {
|
||||
"AutoSyncSchema": true,
|
||||
"Admin": {
|
||||
"Username": "admin"
|
||||
},
|
||||
"JwtSettingsConfiguration": {
|
||||
"JwtSecret": "",
|
||||
"Issuer": "<your-issuer>",
|
||||
@ -506,11 +539,12 @@ Set-Content -Path 'C:\Compose\MaksIT.CertsUI\secrets\appsecrets.json' -Value @'
|
||||
"CertsEngineConfiguration": {
|
||||
"ConnectionString": "Host=postgres;Port=5432;Database=certsui;Username=certsui;Password=certsui;SslMode=Prefer",
|
||||
"Admin": {
|
||||
"Username": "admin",
|
||||
"Password": "<your-admin-password>"
|
||||
},
|
||||
"JwtSettingsConfiguration": {
|
||||
"JwtSecret": "<your-auth-secret>",
|
||||
"PasswordPepper": "<your-pepper>"
|
||||
"JwtSecret": "<your-jwt-secret>",
|
||||
"PasswordPepper": "<your-password-pepper>"
|
||||
},
|
||||
"Agent": {
|
||||
"AgentKey": "<your-agent-key>"
|
||||
@ -539,9 +573,6 @@ Set-Content -Path 'C:\Compose\MaksIT.CertsUI\configMap\appsettings.json' -Value
|
||||
"Configuration": {
|
||||
"CertsEngineConfiguration": {
|
||||
"AutoSyncSchema": true,
|
||||
"Admin": {
|
||||
"Username": "admin"
|
||||
},
|
||||
"JwtSettingsConfiguration": {
|
||||
"JwtSecret": "",
|
||||
"Issuer": "<your-issuer>",
|
||||
@ -718,11 +749,12 @@ Replace the placeholder values with your actual secrets. This secret contains th
|
||||
"CertsEngineConfiguration": {
|
||||
"ConnectionString": "Host=<postgres-host>;Port=5432;Database=certsui;Username=certsui;Password=certsui;SslMode=Prefer",
|
||||
"Admin": {
|
||||
"Username": "admin",
|
||||
"Password": "<your-admin-password>"
|
||||
},
|
||||
"JwtSettingsConfiguration": {
|
||||
"JwtSecret": "<your-auth-secret>",
|
||||
"PasswordPepper": "<your-pepper>"
|
||||
"JwtSecret": "<your-jwt-secret>",
|
||||
"PasswordPepper": "<your-password-pepper>"
|
||||
},
|
||||
"Agent": {
|
||||
"AgentKey": "<your-agent-key>"
|
||||
@ -738,10 +770,13 @@ kubectl create secret generic certs-ui-server-secrets \
|
||||
"Configuration": {
|
||||
"CertsEngineConfiguration": {
|
||||
"ConnectionString": "Host=<postgres-host>;Port=5432;Database=certsui;Username=certsui;Password=certsui;SslMode=Prefer",
|
||||
"Admin": { "Password": "<your-admin-password>" },
|
||||
"Admin": {
|
||||
"Username": "admin",
|
||||
"Password": "<your-admin-password>"
|
||||
},
|
||||
"JwtSettingsConfiguration": {
|
||||
"JwtSecret": "<your-auth-secret>",
|
||||
"PasswordPepper": "<your-pepper>"
|
||||
"JwtSecret": "<your-jwt-secret>",
|
||||
"PasswordPepper": "<your-password-pepper>"
|
||||
},
|
||||
"Agent": { "AgentKey": "<your-agent-key>" }
|
||||
}
|
||||
@ -769,9 +804,6 @@ Edit the values as needed for your environment. This configmap contains applicat
|
||||
"Configuration": {
|
||||
"CertsEngineConfiguration": {
|
||||
"AutoSyncSchema": true,
|
||||
"Admin": {
|
||||
"Username": "admin"
|
||||
},
|
||||
"JwtSettingsConfiguration": {
|
||||
"JwtSecret": "",
|
||||
"Issuer": "<your-issuer>",
|
||||
@ -809,7 +841,6 @@ kubectl create configmap certs-ui-server-configmap \
|
||||
"Configuration": {
|
||||
"CertsEngineConfiguration": {
|
||||
"AutoSyncSchema": true,
|
||||
"Admin": { "Username": "admin" },
|
||||
"JwtSettingsConfiguration": {
|
||||
"JwtSecret": "",
|
||||
"Issuer": "<your-issuer>",
|
||||
@ -934,53 +965,44 @@ helm uninstall certs-ui -n certs-ui
|
||||
|
||||
## Run E2E Against k3s Ingress (PowerShell)
|
||||
|
||||
Use the API-key E2E tests to validate health, authorization, and multi-replica routing behavior through your ingress.
|
||||
Use the PowerShell API-key E2E suite to validate health, authorization, and multi-replica routing through your ingress. Details: [`src/e2e-tests/README.md`](src/e2e-tests/README.md). E2E is **not** run in CI.
|
||||
|
||||
- **dotnet test:** [`src/MaksIT.CertsUI.Client.Tests/README.E2E.md`](src/MaksIT.CertsUI.Client.Tests/README.E2E.md)
|
||||
- **PowerShell module + scenarios:** [`src/e2e-tests/`](src/e2e-tests/) — [`MaksIT.CertsUI.Client.PowerShell`](src/MaksIT.CertsUI.Client.PowerShell/) cmdlets; run `Test-CertsUiApiKeyE2E.ps1` or `Test-CertsUiApiKeyE2E.bat`
|
||||
- **Client unit tests (mock HTTP):** `dotnet test src/MaksIT.CertsUI.Client.Tests`
|
||||
|
||||
### 1) Create a read-capable API key
|
||||
|
||||
Create the API key in the WebUI (or API) and copy the plaintext key once. The E2E flow expects this key in `X-API-KEY`.
|
||||
Create the API key in the WebUI (or API) and copy the plaintext key once. Encode it into `CERTSUI_E2E_CREDENTIALS` (see e2e README).
|
||||
|
||||
### 2) Set E2E environment variables
|
||||
### 2) Set credentials and optional HA env
|
||||
|
||||
```powershell
|
||||
$env:CERTSUI_E2E_BASE_URL = "https://certs-ui.<your-domain>"
|
||||
$env:CERTSUI_E2E_API_KEY = "<paste-api-key>"
|
||||
$env:CERTSUI_E2E_EXPECT_MIN_DISTINCT_INSTANCES = "2"
|
||||
# See src/e2e-tests/README.md for Base64 encoding of <baseUrl><US><apiKey>
|
||||
[Environment]::SetEnvironmentVariable('CERTSUI_E2E_CREDENTIALS', '<base64>', 'User')
|
||||
$env:CERTSUI_E2E_EXPECT_MIN_DISTINCT_INSTANCES = '2' # k8s HA only
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `CERTSUI_E2E_BASE_URL` must be the public ingress URL (no `/api` suffix).
|
||||
- **PowerShell E2E:** `MultiReplica` defaults to **1** instance (Docker Compose). Set `CERTSUI_E2E_EXPECT_MIN_DISTINCT_INSTANCES=2` for k8s HA.
|
||||
- **dotnet test E2E:** `CERTSUI_E2E_EXPECT_MIN_DISTINCT_INSTANCES` defaults to `2` if omitted.
|
||||
- Base URL must be the public ingress URL (no `/api` suffix).
|
||||
- `MultiReplica` defaults to **1** instance (Docker Compose). Set `CERTSUI_E2E_EXPECT_MIN_DISTINCT_INSTANCES=2` for k8s HA.
|
||||
- For HA runs, ensure ingress session affinity is disabled (or not sticky).
|
||||
|
||||
### 3) Run the API-key E2E suite
|
||||
|
||||
**dotnet test:**
|
||||
|
||||
```powershell
|
||||
dotnet test .\src\MaksIT.CertsUI.Client.Tests\MaksIT.CertsUI.Client.Tests.csproj --filter "Category=E2E"
|
||||
```
|
||||
|
||||
**PowerShell scenarios** (set `CERTSUI_E2E_CREDENTIALS` — same encoding as Vault `VAULT_E2E_CREDENTIALS`):
|
||||
|
||||
```powershell
|
||||
pwsh -File .\src\e2e-tests\Test-CertsUiApiKeyE2E.ps1
|
||||
# or: .\src\e2e-tests\Test-CertsUiApiKeyE2E.bat
|
||||
```
|
||||
|
||||
See [README.E2E.md](src/MaksIT.CertsUI.Client.Tests/README.E2E.md) for credential encoding and scenario filters (`-Scenario Health`, etc.).
|
||||
Scenario filters: `pwsh -File .\src\e2e-tests\Test-CertsUiApiKeyE2E.ps1 -Scenario MultiReplica`
|
||||
|
||||
### 4) Optional: run only the replica-distribution assertion
|
||||
### 4) Optional: run only the replica-distribution scenario
|
||||
|
||||
```powershell
|
||||
dotnet test .\src\MaksIT.CertsUI.Client.Tests\MaksIT.CertsUI.Client.Tests.csproj --filter "FullyQualifiedName~ApiKey_StickyLessRequests_RuntimeInstanceId_ObservesMultipleReplicas"
|
||||
pwsh -File .\src\e2e-tests\Test-CertsUiApiKeyE2E.ps1 -Scenario MultiReplica
|
||||
```
|
||||
|
||||
If this test reports fewer instances than expected, check:
|
||||
If this scenario reports fewer instances than expected, check:
|
||||
- `components.server.replicaCount` in Helm values;
|
||||
- ingress/load-balancer session affinity settings;
|
||||
- rollout completion (`kubectl -n certs-ui get pods -l app.kubernetes.io/component=server`).
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="147.5" height="20" role="img" aria-label="Branch Coverage: 8.4%">
|
||||
<title>Branch Coverage: 8.4%</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="147.5" height="20" role="img" aria-label="Branch Coverage: 8.2%">
|
||||
<title>Branch Coverage: 8.2%</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@ -15,7 +15,7 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
||||
<text aria-hidden="true" x="53.75" y="15" fill="#010101" fill-opacity=".3">Branch Coverage</text>
|
||||
<text x="53.75" y="14" fill="#fff">Branch Coverage</text>
|
||||
<text aria-hidden="true" x="127.5" y="15" fill="#010101" fill-opacity=".3">8.4%</text>
|
||||
<text x="127.5" y="14" fill="#fff">8.4%</text>
|
||||
<text aria-hidden="true" x="127.5" y="15" fill="#010101" fill-opacity=".3">8.2%</text>
|
||||
<text x="127.5" y="14" fill="#fff">8.2%</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="137" height="20" role="img" aria-label="Line Coverage: 16.7%">
|
||||
<title>Line Coverage: 16.7%</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="137" height="20" role="img" aria-label="Line Coverage: 16.8%">
|
||||
<title>Line Coverage: 16.8%</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@ -15,7 +15,7 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
||||
<text aria-hidden="true" x="47.25" y="15" fill="#010101" fill-opacity=".3">Line Coverage</text>
|
||||
<text x="47.25" y="14" fill="#fff">Line Coverage</text>
|
||||
<text aria-hidden="true" x="115.75" y="15" fill="#010101" fill-opacity=".3">16.7%</text>
|
||||
<text x="115.75" y="14" fill="#fff">16.7%</text>
|
||||
<text aria-hidden="true" x="115.75" y="15" fill="#010101" fill-opacity=".3">16.8%</text>
|
||||
<text x="115.75" y="14" fill="#fff">16.8%</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="20" role="img" aria-label="Method Coverage: 21.9%">
|
||||
<title>Method Coverage: 21.9%</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="20" role="img" aria-label="Method Coverage: 21.8%">
|
||||
<title>Method Coverage: 21.8%</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@ -15,7 +15,7 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
||||
<text aria-hidden="true" x="53.75" y="15" fill="#010101" fill-opacity=".3">Method Coverage</text>
|
||||
<text x="53.75" y="14" fill="#fff">Method Coverage</text>
|
||||
<text aria-hidden="true" x="128.75" y="15" fill="#010101" fill-opacity=".3">21.9%</text>
|
||||
<text x="128.75" y="14" fill="#fff">21.9%</text>
|
||||
<text aria-hidden="true" x="128.75" y="15" fill="#010101" fill-opacity=".3">21.8%</text>
|
||||
<text x="128.75" y="14" fill="#fff">21.8%</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
@ -46,7 +46,7 @@ flowchart TB
|
||||
|:----:|-------|------|
|
||||
| 1 | Host | Call **one** app service; `Result` → `ToActionResult()` (or `Content` for ACME token). |
|
||||
| 2 | Host | Route + `MaksIT.Models` (or host models). |
|
||||
| 3 | Host | **`App Service`**: **`Mappers/`** · Request → domain / engine inputs; orchestration; then **`IDomainService`** *or* (thin search) **`I*QueryService`**—see **Response** for **`Mappers/`** → Response DTOs. **JWT** (and similar) is enforced at the **controller**; extra filters or tenancy rules belong in the app service when you add them. |
|
||||
| 3 | Host | **`App Service`**: **`Mappers/`** · Request → domain / engine inputs; orchestration; then **`IDomainService`** *or* (thin search) **`I*QueryService`**—see **Response** for **`Mappers/`** → Response DTOs. **`CertsUIAuthorizationFilter`** (JWT or **`X-API-KEY`**) runs on the **controller**; RBAC and tenancy rules live in app **`Services/`** — see [USER_AND_API_KEY_RBAC.md](./USER_AND_API_KEY_RBAC.md). |
|
||||
| 5 | Engine | **`IDomainService`**: rules + orchestration; may call Engine **`Services/`** (HTTP). |
|
||||
| 6 | Engine | **`IPersistenceService`** *or* **`IQueryService`** (one style per hop). |
|
||||
| 7 | Engine | Linq2Db + **`Dto/`**; **persist mappers** for row / JSON columns. |
|
||||
@ -133,7 +133,7 @@ flowchart TB
|
||||
|
||||
### Pattern B: Thin search
|
||||
|
||||
Use when **only filtering + paging + projection** are needed and **no extra engine rules** apply. **This repo:** **`IdentityService.SearchUsersAsync`** and **`ApiKeyService.Search…`** (JWT on the **controller**; predicate built in the app service).
|
||||
Use when **only filtering + paging + projection** are needed and **no extra engine rules** apply. **This repo:** **`IdentityService.SearchUsers`** and **`ApiKeyService.Search…`** (`GetActingJwtTokenData` on the **controller**; RBAC predicate built in the app service).
|
||||
|
||||
```text
|
||||
Controller → App Service → IQueryService (impl: PostgreSQL → Linq2Db Dto → map → Query/) → Result back up
|
||||
@ -271,6 +271,17 @@ Each row fits the **spine** above; only **persist vs query** and **skipped** ste
|
||||
| Registration cache | `CacheController` | `CacheService` | `IRegistrationCacheDomainService` |
|
||||
| Accounts / certs | `AccountController` | `AccountService` | `ICertsFlowService` → `ICertsFlowDomainService` |
|
||||
|
||||
### Authorization (filters)
|
||||
|
||||
| Route prefix | Filter | Principal |
|
||||
|--------------|--------|-----------|
|
||||
| `/api/identity/...` (except login, refresh) | `CertsUIAuthorizationFilter` | JWT **or** `X-API-KEY` → `GetActingJwtTokenData()` on admin actions |
|
||||
| `/api/apikey/...` | `CertsUIAuthorizationFilter` | JWT **or** `X-API-KEY` |
|
||||
| `/api/account/...`, `/api/certs/...`, `/api/cache/...`, `/api/agent/...`, `/api/debug/...` | `CertsUIAuthorizationFilter` | JWT **or** `X-API-KEY` (`CertsUIAuthorizationData`) |
|
||||
| `/.well-known/acme-challenge/...` | *(none)* | Public HTTP-01 |
|
||||
|
||||
See [USER_AND_API_KEY_RBAC.md](./USER_AND_API_KEY_RBAC.md) and [RBAC_REFERENCE.md](./RBAC_REFERENCE.md).
|
||||
|
||||
### Other
|
||||
|
||||
| Area | Entry | Notes |
|
||||
@ -338,7 +349,10 @@ Avoid **Scoped** inside **Singleton** without `IServiceScopeFactory`; use scoped
|
||||
|
||||
## Related docs
|
||||
|
||||
- [USER_AND_API_KEY_RBAC.md](./USER_AND_API_KEY_RBAC.md)
|
||||
- [RBAC_REFERENCE.md](./RBAC_REFERENCE.md)
|
||||
- [HA_ARCHITECTURE.md](./HA_ARCHITECTURE.md)
|
||||
- [LOGIN_AND_REFRESH_TOKEN_ARCHITECTURE.md](./LOGIN_AND_REFRESH_TOKEN_ARCHITECTURE.md)
|
||||
- [REVERSE_PROXY_ROUTING.md](./REVERSE_PROXY_ROUTING.md)
|
||||
- [PATCH_DELTA_REFERENCE.md](./PATCH_DELTA_REFERENCE.md)
|
||||
- [POWERSHELL_CLIENT_MODULE.md](./POWERSHELL_CLIENT_MODULE.md)
|
||||
|
||||
@ -38,11 +38,24 @@ This is implemented as an optimistic single-statement `INSERT ... ON CONFLICT ..
|
||||
|
||||
## Kubernetes behavior
|
||||
|
||||
- `server` can run with `replicaCount >= 2` when your storage/network setup allows it.
|
||||
- Server readiness and liveness probes are wired to:
|
||||
- `GET /health/ready` (DB roundtrip check),
|
||||
- `GET /health/live` (process liveness).
|
||||
- Helm now sets `POD_NAME` from `metadata.name` for stable per-pod identity.
|
||||
- Set `components.server.replicaCount >= 2` with **shared external PostgreSQL** (the Helm chart does not deploy Postgres).
|
||||
- Set **`certsServerSecrets.certsEngineConfiguration.connectionString`**, **`adminUsername`** / **`adminPassword`**, **`jwtSecret`**, and **`passwordPepper`** (or an existing Secret with `appsecrets.json`).
|
||||
- Probes: `GET /health/live` (process up), `GET /health/ready` (PostgreSQL + migrations + bootstrap coordination complete), `GET /health/startup` (JSON phase timings for debugging).
|
||||
- Server pods use a **startupProbe** on `/health/ready` so slow first boot (FluentMigrator, admin bootstrap) does not fail liveness/readiness prematurely.
|
||||
- Helm sets `POD_NAME` from `metadata.name` for stable per-pod identity.
|
||||
- No application-data PVC is required (ACME sessions, HTTP-01 tokens, and identity state live in PostgreSQL).
|
||||
|
||||
## Startup sequence
|
||||
|
||||
1. **PostgreSQL** — accept connections on maintenance DB (`postgres`), then create app database if missing.
|
||||
2. **FluentMigrator** — `MigrateUp` with retries while Postgres is still initializing.
|
||||
3. **Coordination DDL** — `app_runtime_leases`.
|
||||
4. **Schema sync** — optional add-only column sync when `AutoSyncSchema` is enabled.
|
||||
5. **Bootstrap coordination** — one replica acquires the `certs-ui-bootstrap` lease and seeds the global admin; followers wait until an admin exists.
|
||||
|
||||
**Docker Compose (local dev):** bundled `postgres` service with `pg_isready` healthcheck; `server` starts only after `service_healthy`. Connection string comes from mounted `appsecrets.json`, not Helm values.
|
||||
|
||||
Phase timings are tracked in **`CertsStartupState`** and exposed at **`GET /health/startup`**.
|
||||
|
||||
## Current non-goals and boundaries
|
||||
|
||||
@ -67,6 +80,13 @@ This is implemented as an optimistic single-statement `INSERT ... ON CONFLICT ..
|
||||
- `src/MaksIT.CertsUI.Engine/FluentMigrations/20260425130000_AcmeChallengesAndRuntimeLeases.cs`
|
||||
- `src/MaksIT.CertsUI.Engine/Infrastructure/SchemaSyncService.cs`
|
||||
|
||||
### Startup tracking
|
||||
|
||||
- `src/MaksIT.CertsUI/Infrastructure/CertsStartupState.cs`
|
||||
- `src/MaksIT.CertsUI.Engine/Infrastructure/IDatabaseStartupObserver.cs`
|
||||
- `src/MaksIT.CertsUI.Engine/Infrastructure/DatabaseStartupPhaseRunner.cs`
|
||||
- `src/MaksIT.CertsUI.Engine/Infrastructure/RunMigrationsService.cs`
|
||||
|
||||
### Runtime usage in app flows
|
||||
|
||||
- `src/MaksIT.CertsUI.Engine/DomainServices/CertsFlowDomainService.cs`
|
||||
@ -87,3 +107,6 @@ This is implemented as an optimistic single-statement `INSERT ... ON CONFLICT ..
|
||||
|
||||
- `src/MaksIT.CertsUI.Tests/Services/CertsFlowServiceTests.cs`
|
||||
|
||||
## Related docs
|
||||
|
||||
- [ARCHITECTURE_LAYERING.md](./ARCHITECTURE_LAYERING.md) · [USER_AND_API_KEY_RBAC.md](./USER_AND_API_KEY_RBAC.md) · [REVERSE_PROXY_ROUTING.md](./REVERSE_PROXY_ROUTING.md)
|
||||
|
||||
@ -4,6 +4,8 @@ This document describes how authentication (login), token refresh, and logout wo
|
||||
|
||||
**Audience:** Backend (C# / ASP.NET) and Frontend (TypeScript / React) developers.
|
||||
|
||||
**See also:** [User and API key RBAC](./USER_AND_API_KEY_RBAC.md) — how JWT identity relates to `X-API-KEY` principals, global admin on user vs on key, and `CertsUIAuthorizationFilter` on protected routes.
|
||||
|
||||
**MaksIT.CertsUI** persists users and refresh tokens in **PostgreSQL**. Shared **MaksIT.Models** types may include optional **2FA** fields; behavior follows this WebAPI and WebUI implementation.
|
||||
|
||||
---
|
||||
|
||||
@ -220,6 +220,7 @@ Item exists; fields change; no `collectionItemOperation` on the item (or only ne
|
||||
## 6. Related docs
|
||||
|
||||
- **MaksIT.Core:** `PatchOperation`, `PatchRequestModelBase` (README / XML).
|
||||
- [User and API key RBAC](./USER_AND_API_KEY_RBAC.md) · [RBAC reference](./RBAC_REFERENCE.md)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
PowerShell module that exposes the **MaksIT CertsUI API** via custom cmdlets, built on **MaksIT.CertsUI.Client** (C# / .NET).
|
||||
|
||||
**Source:** `src/MaksIT.CertsUI.Client.PowerShell/` · **E2E:** [README.E2E.md](../../src/MaksIT.CertsUI.Client.Tests/README.E2E.md)
|
||||
**Source:** `src/MaksIT.CertsUI.Client.PowerShell/` · **Auth & routes:** [USER_AND_API_KEY_RBAC.md](./USER_AND_API_KEY_RBAC.md) · **Permission matrices:** [RBAC_REFERENCE.md](./RBAC_REFERENCE.md) · **E2E:** [src/e2e-tests/README.md](../../src/e2e-tests/README.md) · **Repo entry:** [README.md](../../README.md)
|
||||
|
||||
---
|
||||
|
||||
@ -66,7 +66,7 @@ PowerShell module that exposes the **MaksIT CertsUI API** via custom cmdlets, bu
|
||||
|--------|-------------|
|
||||
| `Connect-CertsUI` | Set base URL and API key for the session |
|
||||
| `Disconnect-CertsUI` | Clear session |
|
||||
| `Test-CertsUIHealth` | `GET /health/live` and `/health/ready` |
|
||||
| `Test-CertsUIHealth` | `GET /health/live` and `/health/ready` (ready returns 503 until migrations and bootstrap finish) |
|
||||
| `Get-CertsUIAccounts` | `GET /api/accounts` |
|
||||
| `Get-CertsUIAccount` | `GET /api/account/{id}` |
|
||||
| `Get-CertsUIRuntimeInstanceId` | `GET /api/debug/runtime-instance-id` |
|
||||
@ -76,4 +76,4 @@ PowerShell module that exposes the **MaksIT CertsUI API** via custom cmdlets, bu
|
||||
|
||||
## E2E scenarios
|
||||
|
||||
PowerShell scenarios under [`src/e2e-tests/`](../src/e2e-tests/) build this module and run registered tests. See [README.E2E.md](../../src/MaksIT.CertsUI.Client.Tests/README.E2E.md).
|
||||
PowerShell scenarios under [`src/e2e-tests/`](../src/e2e-tests/) build this module and run registered tests. See [src/e2e-tests/README.md](../../src/e2e-tests/README.md).
|
||||
|
||||
167
assets/docs/RBAC_REFERENCE.md
Normal file
167
assets/docs/RBAC_REFERENCE.md
Normal file
@ -0,0 +1,167 @@
|
||||
# RBAC reference — scopes and permission matrices
|
||||
|
||||
This document is the **policy reference** for **MaksIT.CertsUI**: scope flags, shorthand roles, and **who may perform which actions** on Identity, API-key management, and ACME/certificate endpoints.
|
||||
|
||||
**Read first:** [User vs API key RBAC](./USER_AND_API_KEY_RBAC.md) — how JWT and `X-API-KEY` are resolved, `CertsUIAuthorizationFilter` on all protected routes, `GetActingJwtTokenData`, global admin on user vs key, and troubleshooting.
|
||||
|
||||
---
|
||||
|
||||
## 1. Scope model
|
||||
|
||||
Storage and wire shape follow **MaksIT.Vault** (organization id + flags on `user_entity_scopes` / `api_key_entity_scopes`). CertsUI uses a **smaller** scope type enum than Vault.
|
||||
|
||||
Each **scope** is:
|
||||
|
||||
| Field | Meaning |
|
||||
|-------|---------|
|
||||
| **`EntityId`** | **Organization** id this grant applies to (same as Vault org scopes) |
|
||||
| **`EntityType`** | **`Identity`** or **`ApiKey`** — which *administration* surface the flags govern (not “organization” as a type) |
|
||||
| **`Scope`** | `ScopePermission` flags bitmask |
|
||||
|
||||
### 1.1 Permission flags (`ScopePermission`)
|
||||
|
||||
| Flag | Value | Typical use |
|
||||
|------|-------|-------------|
|
||||
| `Read` | `1 << 0` | List/search and read in that org for the given `EntityType` |
|
||||
| `Write` | `1 << 1` | Patch targets tied to that org |
|
||||
| `Delete` | `1 << 2` | Delete targets tied to that org |
|
||||
| `Create` | `1 << 3` | Create principals whose requested scopes include that org |
|
||||
|
||||
**Check:** `RbacHelpers.Has(granted, required)` — all bits in `required` must be set.
|
||||
|
||||
**Search/list:** RBAC filters results in **`IdentityService`** / **`ApiKeyService`** query predicates first; request/UI filters apply on top. The acting user is excluded from user search.
|
||||
|
||||
A principal may hold many scopes; effective access is the **union** of matching rows.
|
||||
|
||||
---
|
||||
|
||||
## 2. Shorthand columns (matrices below)
|
||||
|
||||
Tables use **Admin** (global, no scopes required) and two **scoped** columns keyed by organization **O**:
|
||||
|
||||
| Column | Meaning |
|
||||
|--------|---------|
|
||||
| **Identity manager (org O)** | `EntityType = Identity`, `EntityId = O`, with `Read` + `Write` + `Create` / `Delete` as required by the action |
|
||||
| **Identity reader (org O)** | `EntityType = Identity`, `EntityId = O`, with `Read` only |
|
||||
|
||||
For API-key administration, replace **Identity** with **ApiKey** in the column names (**API key manager** / **API key reader** on org **O**).
|
||||
|
||||
CertsUI has **no** Vault-style Application or Secret scopes; certificate work is not scoped per org in the database today.
|
||||
|
||||
---
|
||||
|
||||
## 3. Global administrator
|
||||
|
||||
A **global administrator** is a JWT user with **`IsGlobalAdmin`** or an API key with **`IsGlobalAdmin`** on the key row (independent flags — see [User vs API key RBAC](./USER_AND_API_KEY_RBAC.md) §2.1).
|
||||
|
||||
Global admins bypass scoped checks in `RBACWrapper*` helpers. Only global admins may **assign or remove** `IsGlobalAdmin` on users or keys (`RbacHelpers`).
|
||||
|
||||
---
|
||||
|
||||
## 4. Identity (users)
|
||||
|
||||
Target users carry **entity scopes** (org ids + flags). A scoped caller must cover **every organization** the target belongs to (same rule as Vault identity).
|
||||
|
||||
### 4.1 Enforced in code today (source of truth)
|
||||
|
||||
| Area | Global admin | Non–global-admin |
|
||||
|------|--------------|------------------|
|
||||
| **Search users / user scopes** | All (minus self in user search) | Only users whose org ids ⊆ actor’s org ids with **`Identity` + `Read`** |
|
||||
| **Create / patch `IsGlobalAdmin`** | Allowed | **Forbidden** if request touches `IsGlobalAdmin` |
|
||||
| **Read / create / patch / delete user by id** | Allowed | **RBAC wrapper returns success without org checks** — scoped enforcement **not** implemented in `ReadUserRBAC` / `CreateUserRBAC` / `PatchUserRBAC` / `DeleteUserRBAC` lambdas yet (XML comments describe the **intended** Vault-aligned rules) |
|
||||
|
||||
If shorthand tables below disagree with §4.1, **§4.1 wins** until CRUD wrappers are brought in line with Vault (`GetEntityIdsWithScope` checks like `maksit-vault` `IdentityService`).
|
||||
|
||||
### 4.2 Intended policy (target behavior; align CRUD with this)
|
||||
|
||||
| Action | Admin | Identity manager | Identity reader |
|
||||
|--------|-------|------------------|-----------------|
|
||||
| Read user | Yes (any) | Yes if `Read` on **Identity** for **all** target orgs | Yes if `Read` on **all** target orgs |
|
||||
| Create user | Yes (any) | Yes if `Create` on **Identity** for **all** orgs in create request | No |
|
||||
| Patch user | Yes (any) | Yes if `Write` on **all** target orgs (and touched orgs on scope patch) | Self only for profile fields; no role/org changes |
|
||||
| Delete user | Yes (any, not self) | Yes if `Delete` on **all** target orgs; not self | No |
|
||||
|
||||
**Self:** Vault allows self read/patch with restrictions; Certs **search** excludes self; self-service rules for patch should match Vault when CRUD is completed.
|
||||
|
||||
---
|
||||
|
||||
## 5. ACME, accounts, cache, and agent
|
||||
|
||||
There is **no** per-organization scope on certificate accounts or ACME sessions in the current schema.
|
||||
|
||||
### 5.1 Enforced permissions (from code)
|
||||
|
||||
| Resource / area | Routes | Non–global-admin JWT | Non–global-admin API key |
|
||||
|-----------------|--------|----------------------|---------------------------|
|
||||
| Accounts | `/api/account/...` | Any authenticated principal | Any authenticated principal |
|
||||
| ACME flow | `/api/certs/...` | Any authenticated principal | Any authenticated principal |
|
||||
| Registration cache | `/api/cache/...` | Any authenticated principal | Any authenticated principal |
|
||||
| Agent hello | `/api/agent/...` | Any authenticated principal | Any authenticated principal |
|
||||
| HTTP-01 challenge | `GET /.well-known/acme-challenge/...` | **Anonymous** (no filter) | **Anonymous** |
|
||||
|
||||
All of the above use `RBACWrapper(..., _ => Result.Ok(), _ => Result.Ok())` except Well-Known. **Treat every valid API key like a full automation principal** for certificate operations until account-scoped RBAC exists.
|
||||
|
||||
### 5.2 Operational guidance
|
||||
|
||||
| Goal | Recommendation |
|
||||
|------|----------------|
|
||||
| Least privilege for **cert automation** | Issue keys only to trusted pipelines; rotate and expire keys; network-restrict the API. Do not rely on org scopes for ACME yet. |
|
||||
| Least privilege for **user/key admin** | Use scoped **Identity** / **ApiKey** grants; use **search** for operators without global admin. |
|
||||
| Full platform control | Global-admin JWT or global-admin API key. |
|
||||
|
||||
---
|
||||
|
||||
## 6. Managing API keys
|
||||
|
||||
Routes: `/api/apikey/...`. Callers use **`GetActingJwtTokenData()`** — JWT **or** API key (see [User vs API key RBAC](./USER_AND_API_KEY_RBAC.md) §5).
|
||||
|
||||
### 6.1 Enforced in code today
|
||||
|
||||
Same pattern as §4.1:
|
||||
|
||||
| Area | Global admin | Non–global-admin |
|
||||
|------|--------------|------------------|
|
||||
| **Search keys / key scopes** | All | Keys whose org ids ⊆ actor’s org ids with **`ApiKey` + `Read`** |
|
||||
| **Patch `IsGlobalAdmin` on a key** | Allowed | **Forbidden** |
|
||||
| **Read / create / patch / delete key by id** | Allowed | **No org scope check in RBAC lambdas yet** |
|
||||
|
||||
### 6.2 Intended policy (target behavior)
|
||||
|
||||
| Action | Admin | API key manager | API key reader |
|
||||
|--------|-------|-----------------|---------------|
|
||||
| Read API key | Yes (any) | Yes if `Read` on **ApiKey** for **all** key orgs | Yes if `Read` on **all** key orgs |
|
||||
| Create API key | Yes (any); may set key `IsGlobalAdmin` | Yes if `Create` on **all** orgs in request; cannot set key `IsGlobalAdmin` | No |
|
||||
| Patch API key | Yes (any) | Yes if `Write` on **all** key orgs | No |
|
||||
| Delete API key | Yes (any) | Yes if `Delete` on **all** key orgs | No |
|
||||
|
||||
---
|
||||
|
||||
## 7. Calling the API with an API key
|
||||
|
||||
Use **`X-API-KEY`** on any protected route that uses **`CertsUIAuthorizationFilter`**. RBAC uses the **key’s** scopes and **`IsGlobalAdmin` on the key** — not the human operator’s JWT.
|
||||
|
||||
| Use case | Key type |
|
||||
|----------|----------|
|
||||
| PowerShell / `MaksIT.CertsUI.Client` automation (accounts, ACME) | Any valid key (prefer dedicated non-global keys only if you accept §5.1) |
|
||||
| Create accounts, run `FullFlow`, cache | Authenticated key (global admin not required today) |
|
||||
| Manage users or API keys via API | Key needs appropriate **Identity** / **ApiKey** scopes or global admin |
|
||||
| Match WebUI “full admin” for certs + identity | **`IsGlobalAdmin: true`** on the key |
|
||||
|
||||
Details: [User vs API key RBAC](./USER_AND_API_KEY_RBAC.md) §1–2, [POWERSHELL_CLIENT_MODULE.md](./POWERSHELL_CLIENT_MODULE.md).
|
||||
|
||||
---
|
||||
|
||||
## 8. Comparison with MaksIT.Vault
|
||||
|
||||
| Topic | Vault | CertsUI |
|
||||
|-------|-------|---------|
|
||||
| Dual auth filter | `VaultAuthorizationFilter` on `/api/vault/...` only | `CertsUIAuthorizationFilter` on all protected API controllers |
|
||||
| Identity / API key routes | JWT only | JWT **or** API key via `GetActingJwtTokenData()` |
|
||||
| Resource scopes | Organization, Application, secrets | **None** for ACME; Identity/ApiKey admin only |
|
||||
| Scoped CRUD on identity | Enforced in `IdentityService` | **Search enforced**; CRUD wrappers **stub** (May 2026) |
|
||||
|
||||
When porting fixes from Vault, copy **`GetEntityIdsWithScope`** patterns from `maksit-vault` `IdentityService` / `APIKeyService` into this repo’s RBAC lambdas.
|
||||
|
||||
---
|
||||
|
||||
*Last updated: May 2026*
|
||||
@ -58,6 +58,10 @@ If authentication succeeds but API calls fail, confirm traffic reaches the **sam
|
||||
|
||||
---
|
||||
|
||||
## Related docs
|
||||
|
||||
- [User and API key RBAC](./USER_AND_API_KEY_RBAC.md) · [RBAC reference](./RBAC_REFERENCE.md) · [LOGIN_AND_REFRESH_TOKEN_ARCHITECTURE.md](./LOGIN_AND_REFRESH_TOKEN_ARCHITECTURE.md)
|
||||
|
||||
## Related files
|
||||
|
||||
- [`src/docker-compose.yml`](../../src/docker-compose.yml), [`src/docker-compose.override.yml`](../../src/docker-compose.override.yml)
|
||||
|
||||
149
assets/docs/USER_AND_API_KEY_RBAC.md
Normal file
149
assets/docs/USER_AND_API_KEY_RBAC.md
Normal file
@ -0,0 +1,149 @@
|
||||
# User (JWT) and API Key RBAC
|
||||
|
||||
This document explains how **role-based access control** differs when the caller is a **logged-in user** (JWT) versus an **API key**, how those principals are loaded, and where the rules live in code.
|
||||
|
||||
**Audience:** Backend developers, security reviewers, and anyone automating against the CertsUI API (WebUI, PowerShell module, `MaksIT.CertsUI.Client`).
|
||||
|
||||
**Related:** [Login and refresh token architecture](./LOGIN_AND_REFRESH_TOKEN_ARCHITECTURE.md) (how JWTs are issued). [RBAC reference](./RBAC_REFERENCE.md) has scope flags and permission matrices. **This** file covers JWT vs API key principals, routes, `GetActingJwtTokenData`, and code references.
|
||||
|
||||
---
|
||||
|
||||
## 1. Two authentication mechanisms
|
||||
|
||||
| Mechanism | Header | Typical use |
|
||||
|-----------|--------|-------------|
|
||||
| **User (JWT)** | `Authorization: Bearer <access_token>` | Browser (WebUI), interactive tools, services that can refresh tokens |
|
||||
| **API key** | `X-API-KEY: <secret>` | Scripts, CI, PowerShell (`Connect-CertsUI`), `MaksIT.CertsUI.Client`; long-lived secret shown once at creation |
|
||||
|
||||
**Request resolution:** `CertsUIAuthorizationFilter` (`src/MaksIT.CertsUI/Authorization/Filters/CertsUIAuthorizationFilter.cs`) checks **`X-API-KEY` first**. If that header is absent or invalid, it falls back to **Bearer JWT**. If neither yields a valid principal, the request gets **403 Forbidden** (`Authorization required`).
|
||||
|
||||
**Unlike MaksIT.Vault:** Vault uses **`JwtAuthorizationFilter`** on `/api/identity/...` and `/api/apikey/...` (JWT only) and **`VaultAuthorizationFilter`** on `/api/vault/...` (JWT or API key). In **CertsUI**, every protected controller action listed below uses **`CertsUIAuthorizationFilter`** — the same dual path for **identity**, **API keys**, **accounts**, **ACME flow**, and **cache**.
|
||||
|
||||
| Route family | Filter | JWT | `X-API-KEY` |
|
||||
|--------------|--------|-----|-------------|
|
||||
| `/api/identity/...` (except login / refresh) | `CertsUIAuthorizationFilter` | Yes | Yes |
|
||||
| `/api/apikey/...` | `CertsUIAuthorizationFilter` | Yes | Yes |
|
||||
| `/api/account/...`, `/api/certs/...`, `/api/cache/...`, `/api/agent/...`, `/api/debug/...` | `CertsUIAuthorizationFilter` | Yes | Yes |
|
||||
|
||||
**Exceptions (no JWT / API key on the action):**
|
||||
|
||||
- `POST /api/identity/login` and `POST /api/identity/refresh` — anonymous.
|
||||
- `GET /.well-known/acme-challenge/{token}` (`WellKnownController`) — no authorization filter; public HTTP-01 challenge responses (see [REVERSE_PROXY_ROUTING.md](./REVERSE_PROXY_ROUTING.md)).
|
||||
|
||||
---
|
||||
|
||||
## 2. Two principal types (what RBAC sees)
|
||||
|
||||
After authentication, the filter stores **`CertsUIAuthorizationData`** on `HttpContext` (`HttpContextValue.CertsUIAuthorizationData`).
|
||||
|
||||
| Principal | Type | Where it comes from |
|
||||
|-----------|------|---------------------|
|
||||
| **User** | `JwtTokenData` | Validated JWT; includes `UserId`, `IsGlobalAdmin`, and **entity scopes** from the user record |
|
||||
| **API key** | `ApiKeyData` | Key row by secret + **`ReadApiKeyAuthorization`**; includes `ApiKeyId`, `IsGlobalAdmin` **for that key**, and **entity scopes** stored for the key |
|
||||
|
||||
Services that manage users or API keys do not take `CertsUIAuthorizationData` directly on every method. Controllers call **`GetActingJwtTokenData()`**, which:
|
||||
|
||||
- Returns the real **`JwtTokenData`** for Bearer sessions.
|
||||
- Maps an API key to a **synthetic** `JwtTokenData` (`UserId = Guid.Empty`, `Username = apikey:{id}`, scopes and `IsGlobalAdmin` from the key).
|
||||
|
||||
So identity and API-key **admin** code paths are written against **`JwtTokenData`**, but the acting principal may be a **user** or an **API key** after mapping.
|
||||
|
||||
### 2.1 Global administrator: user vs key (easy to confuse)
|
||||
|
||||
- **`JwtTokenData.IsGlobalAdmin`** — the **signed-in user** is a global administrator (or the key was mapped with this flag).
|
||||
- **`ApiKeyData.IsGlobalAdmin`** — **this API key** was created (or patched) with the global-administrator flag.
|
||||
|
||||
These are **independent**. A global-admin **user** who creates an API key does **not** automatically create a global-admin key unless the create request sets **`IsGlobalAdmin: true`** on the key (UI: separate checkbox). Automation that must bypass scoped limits on **accounts / ACME** needs a **global-admin API key**, not merely a key created by an admin user.
|
||||
|
||||
**Who may set `IsGlobalAdmin` on a key?** Only a principal that is already a **global administrator** after `GetActingJwtTokenData()` may **create** or **patch** a user or API key with `IsGlobalAdmin` enabled (`RbacHelpers.EnsureActorMayAssignGlobalAdmin` / `EnsureActorMayPatchGlobalAdminFlag` in `IdentityService` / `ApiKeyService`).
|
||||
|
||||
### 2.2 Loading API key authorization
|
||||
|
||||
For API key requests, `IsGlobalAdmin` and scopes come from persisted authorization data. If **`ReadApiKeyAuthorization`** fails after the key itself was found, the filter **propagates that failure** (it does **not** continue with `IsGlobalAdmin: false`), so misconfiguration or storage errors are not silently downgraded to “non-admin key” behavior.
|
||||
|
||||
Expired keys (`ExpiresAt <= UtcNow`) are rejected in the filter before authorization is loaded.
|
||||
|
||||
---
|
||||
|
||||
## 3. Shared RBAC helpers (`ServiceBase`)
|
||||
|
||||
App services inherit from `ServiceBase` (`src/MaksIT.CertsUI/Abstractions/Services/ServiceBase.cs`), which implements:
|
||||
|
||||
- **`RBACWrapper`** — If the request used JWT, runs **`RBACWrapperJwtToken`** with `userRules`; if API key, runs **`RBACWrapperApiKey`** with `apiKeyRules`.
|
||||
- **`RBACWrapperJwtToken` / `RBACWrapperApiKey`** — If `IsGlobalAdmin` is **true** for that principal, return **success immediately** (full access for that wrapper). Otherwise run the supplied rules delegate, or **403** if no rules were supplied.
|
||||
|
||||
So: **`userRules` / `apiKeyRules` = null** means **only global administrators** (JWT or API key, respectively) pass. Non-admins always get **forbidden** with no per-scope check.
|
||||
|
||||
Variants **`RBACWrapper<T>`** / **`RBACWrapperJwtToken<T>`** / **`RBACWrapperApiKey<T>`** follow the same pattern but carry a resource through the rules.
|
||||
|
||||
**Identity / API key services** use **`RBACWrapperJwtToken`** only (after `GetActingJwtTokenData()`), including when the caller used an API key.
|
||||
|
||||
---
|
||||
|
||||
## 4. Example: accounts and ACME (`AccountService`, `CertsFlowService`)
|
||||
|
||||
Illustrates **authentication without scoped RBAC** today.
|
||||
|
||||
| Action | RBAC pattern | Non–global-admin JWT | Non–global-admin API key |
|
||||
|--------|----------------|----------------------|---------------------------|
|
||||
| **List / read / create / patch / delete account** | `RBACWrapper(..., _ => Ok(), _ => Ok())` | Allowed if authenticated | Allowed if authenticated |
|
||||
| **Certs flow steps** (`CertsFlowService`) | Same open rules | Allowed if authenticated | Allowed if authenticated |
|
||||
| **Registration cache** (`CacheService`) | Same | Allowed if authenticated | Allowed if authenticated |
|
||||
|
||||
There is **no** organization- or account-level scope check on certificate operations yet. **Do not** treat API keys as least-privilege for ACME unless you issue **non-global** keys and accept that they can still drive any account operation once authenticated. Scoped **Identity** / **ApiKey** flags apply to **user and key administration** (see [RBAC reference](./RBAC_REFERENCE.md) §4–§6 and §5.1 for search vs CRUD).
|
||||
|
||||
---
|
||||
|
||||
## 5. Identity and API key administration (`GetActingJwtTokenData`)
|
||||
|
||||
Operations in **`IIdentityService`** / **`IApiKeyService`** and their controllers take **`JwtTokenData`** from **`GetActingJwtTokenData()`** — so **both** Bearer users and **`X-API-KEY`** callers can hit `/api/identity/...` and `/api/apikey/...` when the key’s scopes (or global admin) allow it.
|
||||
|
||||
Summary (matrices and enforcement detail: [RBAC reference](./RBAC_REFERENCE.md)):
|
||||
|
||||
| Operation | Global-admin actor | Non–global-admin actor |
|
||||
|-----------|-------------------|-------------------------|
|
||||
| **Search users / keys / scopes** | Full list (plus request filters) | Results limited to targets whose **organization** ids are covered by actor **`Identity`** / **`ApiKey`** **`Read`** scopes (enforced in query predicates) |
|
||||
| **Read / patch / delete by id** | Full access | **Intended:** same org coverage as Vault-style identity rules; **current code:** non-admin CRUD wrappers return success without scope checks — see [RBAC reference](./RBAC_REFERENCE.md) §4.1 |
|
||||
| **Create** | Any user/key; may set **`IsGlobalAdmin`** on new principal | **Intended:** `Create` on scopes for all orgs in request; **current code:** non-admin create wrapper allows any authenticated actor |
|
||||
| **Patch `IsGlobalAdmin`** | Allowed | **Forbidden** (`RbacHelpers`) |
|
||||
|
||||
E2E regression tests in `src/e2e-tests/scenarios/Scenario-05-IdentityConfigurations.ps1` assert non-global users cannot create global-admin users or keys.
|
||||
|
||||
---
|
||||
|
||||
## 6. Troubleshooting
|
||||
|
||||
| Symptom | Likely cause |
|
||||
|---------|----------------|
|
||||
| **403** `Authorization required` | Neither valid `X-API-KEY` nor Bearer JWT. |
|
||||
| **403** `Invalid API Key` / `API Key expired` | Wrong secret, revoked row, or past `ExpiresAt`. |
|
||||
| **403** `User does not have access to resource.` | JWT user is not global-admin and the service used `RBACWrapper` with **null** rules (not used on account/certs today). |
|
||||
| **403** `ApiKey does not have access to resource.` | Same for API key + null rules. |
|
||||
| **403** on **`IsGlobalAdmin`** patch/create | Acting principal is not global-admin (`RbacHelpers`). |
|
||||
| User/key **visible in UI search** but **403 on GET by id** | Unlikely today (CRUD is permissive for non-admins); if scope checks are added to match Vault, search and GET will align — until then, prefer **search** for scoped operators. |
|
||||
| Automation works in UI but **403** with API key | Key lacks **`IsGlobalAdmin`** or scopes; UI user may be global-admin while the key is not. |
|
||||
| **403** / unexpected status right after API key validation | Authorization row read failed; check logs and DB for that `ApiKeyId`. |
|
||||
|
||||
---
|
||||
|
||||
## 7. Code map
|
||||
|
||||
| Topic | Location |
|
||||
|-------|----------|
|
||||
| API key vs JWT resolution | `MaksIT.CertsUI/Authorization/Filters/CertsUIAuthorizationFilter.cs` |
|
||||
| Acting principal for identity/API key | `MaksIT.CertsUI/Authorization/Extensions/HttpContextExtension.cs` (`GetActingJwtTokenData`, `ToActingJwtTokenData`) |
|
||||
| Identity (dual auth) | `MaksIT.CertsUI/Controllers/IdentityController.cs` |
|
||||
| API keys (dual auth) | `MaksIT.CertsUI/Controllers/APIKeyController.cs` |
|
||||
| Accounts / certs / cache (dual auth) | `AccountController`, `CertsFlowController`, `CacheController` |
|
||||
| HTTP-01 (no RBAC) | `MaksIT.CertsUI/Controllers/WellKnownController.cs` |
|
||||
| `RBACWrapper*` helpers | `MaksIT.CertsUI/Abstractions/Services/ServiceBase.cs` |
|
||||
| Scope helpers | `MaksIT.CertsUI/Services/Helpers/RbacHelpers.cs` |
|
||||
| Identity RBAC | `MaksIT.CertsUI/Services/IdentityService.cs` |
|
||||
| API key RBAC | `MaksIT.CertsUI/Services/ApiKeyService.cs` |
|
||||
| Open certs RBAC | `AccountService`, `CertsFlowService`, `CacheService`, `AgentService` |
|
||||
| Principal types | `MaksIT.CertsUI/Authorization/` (`JwtTokenData`, `ApiKeyData`, `CertsUIAuthorizationData`, `IdentityScopeData`) |
|
||||
| Scope enums | `MaksIT.CertsUI.Engine/ScopeEntityType.cs`, `ScopePermission.cs` |
|
||||
|
||||
---
|
||||
|
||||
*Last updated: May 2026*
|
||||
@ -15,7 +15,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Management.Automation" Version="7.6.0" />
|
||||
<PackageReference Include="System.Management.Automation" Version="7.6.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@ -1,101 +0,0 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using MaksIT.CertsUI.Client;
|
||||
|
||||
namespace MaksIT.CertsUI.Client.Tests;
|
||||
|
||||
/// <summary>E2E tests against a live CertsUI deployment (set <c>CERTSUI_E2E_BASE_URL</c> and <c>CERTSUI_E2E_API_KEY</c>).</summary>
|
||||
public sealed class CertsUiApiKeyE2ETests {
|
||||
private static void Log(string message) =>
|
||||
Console.WriteLine($"[{DateTime.UtcNow:O}] {message}");
|
||||
|
||||
private sealed class E2ESession(HttpClient http, CertsUIClient client) : IDisposable {
|
||||
internal CertsUIClient Client { get; } = client;
|
||||
public void Dispose() => http.Dispose();
|
||||
}
|
||||
|
||||
private static E2ESession? TryCreateSession() {
|
||||
var baseUrl = Environment.GetEnvironmentVariable("CERTSUI_E2E_BASE_URL");
|
||||
var apiKey = Environment.GetEnvironmentVariable("CERTSUI_E2E_API_KEY");
|
||||
if (string.IsNullOrWhiteSpace(baseUrl) || string.IsNullOrWhiteSpace(apiKey))
|
||||
return null;
|
||||
|
||||
var http = new HttpClient();
|
||||
var client = new CertsUIClient(http, Options.Create(new CertsUIClientOptions {
|
||||
BaseAddress = baseUrl,
|
||||
ApiKey = apiKey,
|
||||
}));
|
||||
return new E2ESession(http, client);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "E2E")]
|
||||
public async Task HealthEndpoints_LiveAndReady_ReturnSuccess() {
|
||||
using var session = TryCreateSession();
|
||||
if (session is null) {
|
||||
Log("SKIP: CERTSUI_E2E_BASE_URL or CERTSUI_E2E_API_KEY not set.");
|
||||
return;
|
||||
}
|
||||
|
||||
await session.Client.CheckHealthLiveAsync(TestContext.Current.CancellationToken);
|
||||
await session.Client.CheckHealthReadyAsync(TestContext.Current.CancellationToken);
|
||||
Log("PASS: /health/live and /health/ready are healthy.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "E2E")]
|
||||
public async Task ApiKey_ConcurrentReads_OnAccountsEndpoint_AreAuthorizedAndStable() {
|
||||
using var session = TryCreateSession();
|
||||
if (session is null) {
|
||||
Log("SKIP: CERTSUI_E2E_BASE_URL or CERTSUI_E2E_API_KEY not set.");
|
||||
return;
|
||||
}
|
||||
|
||||
const int parallelRequests = 12;
|
||||
Log($"TEST: issuing {parallelRequests} concurrent GetAccountsAsync with API key.");
|
||||
|
||||
var calls = Enumerable.Range(0, parallelRequests)
|
||||
.Select(_ => session.Client.GetAccountsAsync(TestContext.Current.CancellationToken));
|
||||
var results = await Task.WhenAll(calls);
|
||||
|
||||
Assert.Equal(parallelRequests, results.Length);
|
||||
Log("PASS: all concurrent API-key reads succeeded without auth failures.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", "E2E")]
|
||||
public async Task ApiKey_StickyLessRequests_RuntimeInstanceId_ObservesMultipleReplicas() {
|
||||
using var session = TryCreateSession();
|
||||
if (session is null) {
|
||||
Log("SKIP: CERTSUI_E2E_BASE_URL or CERTSUI_E2E_API_KEY not set.");
|
||||
return;
|
||||
}
|
||||
|
||||
var minDistinct = 2;
|
||||
var minDistinctRaw = Environment.GetEnvironmentVariable("CERTSUI_E2E_EXPECT_MIN_DISTINCT_INSTANCES");
|
||||
if (!string.IsNullOrWhiteSpace(minDistinctRaw) &&
|
||||
int.TryParse(minDistinctRaw, out var parsed) &&
|
||||
parsed > 0) {
|
||||
minDistinct = parsed;
|
||||
}
|
||||
|
||||
const int requestCount = 32;
|
||||
Log($"TEST: {requestCount} GetRuntimeInstanceIdAsync calls; expected distinct instances >= {minDistinct}.");
|
||||
|
||||
var responses = await Task.WhenAll(
|
||||
Enumerable.Range(0, requestCount)
|
||||
.Select(_ => session.Client.GetRuntimeInstanceIdAsync(TestContext.Current.CancellationToken)));
|
||||
|
||||
var instanceIds = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var response in responses) {
|
||||
Assert.False(string.IsNullOrWhiteSpace(response.InstanceId));
|
||||
instanceIds.Add(response.InstanceId);
|
||||
}
|
||||
|
||||
Log($"Observed distinct instance ids: {string.Join(", ", instanceIds.OrderBy(x => x))}");
|
||||
Assert.True(
|
||||
instanceIds.Count >= minDistinct,
|
||||
$"Expected at least {minDistinct} distinct instance ids, observed {instanceIds.Count}. " +
|
||||
"Scale server replicas and ensure non-sticky load balancing for this E2E run.");
|
||||
Log("PASS: runtime instance id endpoint observed multiple replicas.");
|
||||
}
|
||||
}
|
||||
@ -9,7 +9,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.4.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.6.0" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
|
||||
@ -12,8 +12,8 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.8" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@ -7,11 +7,11 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="10.0.0">
|
||||
<PackageReference Include="coverlet.collector" Version="10.0.1">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.5.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.6.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
|
||||
@ -48,7 +48,7 @@ public class IdentityDomainService(
|
||||
ILogger<IdentityDomainService> logger,
|
||||
IIdentityPersistenceService identityPersistenceService,
|
||||
IUserAuthorizationPersistenceService userAuthorizationPersistenceService,
|
||||
ICertsEngineConfiguration vaultEngineConfiguration,
|
||||
ICertsEngineConfiguration certsEngineConfiguration,
|
||||
IAdminUser adminUser,
|
||||
IJwtSettingsConfiguration jwtSettingsConfiguration,
|
||||
ITwoFactorSettingsConfiguration twoFactorSettingsConfiguration
|
||||
@ -57,7 +57,7 @@ public class IdentityDomainService(
|
||||
private readonly ILogger<IdentityDomainService> _logger = logger;
|
||||
private readonly IIdentityPersistenceService _identityPersistenceService = identityPersistenceService;
|
||||
private readonly IUserAuthorizationPersistenceService _userAuthorizationPersistenceService = userAuthorizationPersistenceService;
|
||||
private readonly ICertsEngineConfiguration _vaultEngineConfiguration = vaultEngineConfiguration;
|
||||
private readonly ICertsEngineConfiguration _certsEngineConfiguration = certsEngineConfiguration;
|
||||
private readonly IAdminUser _adminUser = adminUser;
|
||||
private readonly IJwtSettingsConfiguration _jwtSettingsConfiguration = jwtSettingsConfiguration;
|
||||
private readonly ITwoFactorSettingsConfiguration _twoFactorSettingsConfiguration = twoFactorSettingsConfiguration;
|
||||
@ -85,9 +85,9 @@ public class IdentityDomainService(
|
||||
}
|
||||
|
||||
// Use the same pepper as login so hashed password matches validation; require it to be set (e.g. from appsecrets.json).
|
||||
var pepper = _vaultEngineConfiguration.JwtSettingsConfiguration?.PasswordPepper;
|
||||
var pepper = _certsEngineConfiguration.JwtSettingsConfiguration?.PasswordPepper;
|
||||
if (string.IsNullOrWhiteSpace(pepper)) {
|
||||
return Result<User?>.BadRequest(null, "PasswordPepper is not set. Set Configuration:VaultEngineConfiguration:JwtSettingsConfiguration:PasswordPepper in appsecrets.json (or config) so bootstrap and login use the same pepper.");
|
||||
return Result<User?>.BadRequest(null, "PasswordPepper is not set. Set Configuration:CertsEngineConfiguration:JwtSettingsConfiguration:PasswordPepper in appsecrets.json (or config) so bootstrap and login use the same pepper.");
|
||||
}
|
||||
|
||||
var usernameTrimmed = (_adminUser.Username ?? "").Trim();
|
||||
@ -151,7 +151,7 @@ public class IdentityDomainService(
|
||||
return Result<JwtToken?>.Unauthorized(null, "User is not active.");
|
||||
}
|
||||
|
||||
var pepper = _vaultEngineConfiguration.JwtSettingsConfiguration?.PasswordPepper;
|
||||
var pepper = _certsEngineConfiguration.JwtSettingsConfiguration?.PasswordPepper;
|
||||
if (string.IsNullOrWhiteSpace(pepper)) {
|
||||
_logger.LogWarning("Login failed: PasswordPepper is not set (user '{Username}')", user.Username);
|
||||
return Result<JwtToken?>.Unauthorized(null, "Invalid username or password.");
|
||||
@ -280,7 +280,7 @@ public class IdentityDomainService(
|
||||
if (user.TwoFactorEnabled)
|
||||
return Result<List<string>?>.BadRequest(null, "Two-factor authentication is already enabled.");
|
||||
|
||||
var pepper = _vaultEngineConfiguration.JwtSettingsConfiguration.PasswordPepper;
|
||||
var pepper = _certsEngineConfiguration.JwtSettingsConfiguration.PasswordPepper;
|
||||
if (string.IsNullOrWhiteSpace(pepper))
|
||||
return Result<List<string>?>.InternalServerError(null, "Password pepper is not configured.");
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
using FluentMigrator.Runner;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using MaksIT.CertsUI.Engine;
|
||||
using MaksIT.CertsUI.Engine.DomainServices;
|
||||
using MaksIT.CertsUI.Engine.FluentMigrations;
|
||||
@ -24,6 +25,8 @@ public static class ServiceCollectionExtensions {
|
||||
|
||||
services.AddSingleton<ICertsEngineConfiguration>(certsEngineConfiguration);
|
||||
|
||||
services.TryAddSingleton<IDatabaseStartupObserver, NoOpDatabaseStartupObserver>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(certsEngineConfiguration.ConnectionString))
|
||||
throw new ArgumentException("Certs engine connection string is required for FluentMigrator (empty string uses connectionless/preview mode and will not create tables).", nameof(certsEngineConfiguration));
|
||||
|
||||
@ -72,16 +75,16 @@ public static class ServiceCollectionExtensions {
|
||||
var logger = sp.GetRequiredService<ILogger<IdentityDomainService>>();
|
||||
var identityPersistenceService = sp.GetRequiredService<IIdentityPersistenceService>();
|
||||
var userAuthorizationPersistenceService = sp.GetRequiredService<IUserAuthorizationPersistenceService>();
|
||||
var vaultEngineConfiguration = sp.GetRequiredService<ICertsEngineConfiguration>();
|
||||
var adminUser = vaultEngineConfiguration.Admin;
|
||||
var jwtSettingsConfiguration = vaultEngineConfiguration.JwtSettingsConfiguration;
|
||||
var twoFactorSettingsConfiguration = vaultEngineConfiguration.TwoFactorSettingsConfiguration;
|
||||
var certsEngineConfiguration = sp.GetRequiredService<ICertsEngineConfiguration>();
|
||||
var adminUser = certsEngineConfiguration.Admin;
|
||||
var jwtSettingsConfiguration = certsEngineConfiguration.JwtSettingsConfiguration;
|
||||
var twoFactorSettingsConfiguration = certsEngineConfiguration.TwoFactorSettingsConfiguration;
|
||||
|
||||
return new IdentityDomainService(
|
||||
logger,
|
||||
identityPersistenceService,
|
||||
userAuthorizationPersistenceService,
|
||||
vaultEngineConfiguration,
|
||||
certsEngineConfiguration,
|
||||
adminUser,
|
||||
jwtSettingsConfiguration,
|
||||
twoFactorSettingsConfiguration);
|
||||
|
||||
@ -0,0 +1,23 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace MaksIT.CertsUI.Engine.Infrastructure;
|
||||
|
||||
internal static class DatabaseStartupPhaseRunner {
|
||||
public static async Task RunAsync(
|
||||
IDatabaseStartupObserver observer,
|
||||
string phase,
|
||||
Func<CancellationToken, Task> action,
|
||||
CancellationToken cancellationToken
|
||||
) {
|
||||
observer.OnPhaseStarted(phase);
|
||||
var sw = Stopwatch.StartNew();
|
||||
try {
|
||||
await action(cancellationToken).ConfigureAwait(false);
|
||||
observer.OnPhaseCompleted(phase, sw.Elapsed);
|
||||
}
|
||||
catch (Exception ex) {
|
||||
observer.OnPhaseFailed(phase, sw.Elapsed, ex);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
namespace MaksIT.CertsUI.Engine.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Optional hooks for reporting database startup phase timing (migrations, schema sync).
|
||||
/// </summary>
|
||||
public interface IDatabaseStartupObserver {
|
||||
void OnPhaseStarted(string phase);
|
||||
void OnPhaseCompleted(string phase, TimeSpan elapsed);
|
||||
void OnPhaseFailed(string phase, TimeSpan elapsed, Exception exception);
|
||||
}
|
||||
|
||||
/// <summary>Well-known phase names reported during <see cref="IRunMigrationsService.RunAsync"/> and schema sync.</summary>
|
||||
public static class DatabaseStartupPhases {
|
||||
public const string PostgresMaintenanceReady = "postgres_maintenance_ready";
|
||||
public const string DatabaseEnsured = "database_ensured";
|
||||
public const string PostgresApplicationReady = "postgres_application_ready";
|
||||
public const string MigrationsComplete = "migrations_complete";
|
||||
public const string CoordinationTablesReady = "coordination_tables_ready";
|
||||
public const string SchemaVerified = "schema_verified";
|
||||
public const string SchemaSync = "schema_sync";
|
||||
}
|
||||
|
||||
internal sealed class NoOpDatabaseStartupObserver : IDatabaseStartupObserver {
|
||||
public void OnPhaseStarted(string phase) { }
|
||||
public void OnPhaseCompleted(string phase, TimeSpan elapsed) { }
|
||||
public void OnPhaseFailed(string phase, TimeSpan elapsed, Exception exception) { }
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
namespace MaksIT.CertsUI.Engine.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Runs database migrations (e.g. FluentMigrator) at startup. Called from InitializationHostedService.
|
||||
/// Runs database migrations (e.g. FluentMigrator) at startup. Called from Program.cs before hosted services start.
|
||||
/// </summary>
|
||||
public interface IRunMigrationsService {
|
||||
Task RunAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
@ -15,7 +15,8 @@ namespace MaksIT.CertsUI.Engine.Infrastructure;
|
||||
public sealed class RunMigrationsService(
|
||||
IMigrationRunner migrationRunner,
|
||||
ILogger<RunMigrationsService> logger,
|
||||
ICertsEngineConfiguration config
|
||||
ICertsEngineConfiguration config,
|
||||
IDatabaseStartupObserver startupObserver
|
||||
) : IRunMigrationsService {
|
||||
|
||||
public async Task RunAsync(CancellationToken cancellationToken = default) {
|
||||
@ -33,11 +34,44 @@ public sealed class RunMigrationsService(
|
||||
.Count(t => t.GetCustomAttribute<MigrationAttribute>(inherit: false) is not null);
|
||||
logger.LogInformation("FluentMigrator discovered {MigrationCount} migration type(s) in {Assembly}.", migrationTypeCount, typeof(BaselineCertsSchema).Assembly.GetName().Name);
|
||||
|
||||
await PostgresStartupWait.WaitUntilReadyAsync(config.ConnectionString, logger, cancellationToken).ConfigureAwait(false);
|
||||
await EnsureDatabaseExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
await PostgresStartupWait.MigrateUpWithRetryAsync(migrationRunner, logger, cancellationToken).ConfigureAwait(false);
|
||||
await CoordinationTableProvisioner.EnsureAsync(config.ConnectionString, cancellationToken).ConfigureAwait(false);
|
||||
await VerifyCoreSchemaAsync(cancellationToken).ConfigureAwait(false);
|
||||
var maintenanceConnectionString = BuildMaintenanceConnectionString(config.ConnectionString);
|
||||
|
||||
await DatabaseStartupPhaseRunner.RunAsync(
|
||||
startupObserver,
|
||||
DatabaseStartupPhases.PostgresMaintenanceReady,
|
||||
ct => PostgresStartupWait.WaitUntilReadyAsync(maintenanceConnectionString, logger, ct),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await DatabaseStartupPhaseRunner.RunAsync(
|
||||
startupObserver,
|
||||
DatabaseStartupPhases.DatabaseEnsured,
|
||||
EnsureDatabaseExistsAsync,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await DatabaseStartupPhaseRunner.RunAsync(
|
||||
startupObserver,
|
||||
DatabaseStartupPhases.PostgresApplicationReady,
|
||||
ct => PostgresStartupWait.WaitUntilReadyAsync(config.ConnectionString, logger, ct),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await DatabaseStartupPhaseRunner.RunAsync(
|
||||
startupObserver,
|
||||
DatabaseStartupPhases.MigrationsComplete,
|
||||
ct => PostgresStartupWait.MigrateUpWithRetryAsync(migrationRunner, logger, ct),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await DatabaseStartupPhaseRunner.RunAsync(
|
||||
startupObserver,
|
||||
DatabaseStartupPhases.CoordinationTablesReady,
|
||||
ct => CoordinationTableProvisioner.EnsureAsync(config.ConnectionString, ct),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await DatabaseStartupPhaseRunner.RunAsync(
|
||||
startupObserver,
|
||||
DatabaseStartupPhases.SchemaVerified,
|
||||
VerifyCoreSchemaAsync,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
logger.LogInformation("Certs database migrations completed.");
|
||||
}
|
||||
|
||||
@ -66,13 +100,20 @@ public sealed class RunMigrationsService(
|
||||
"Confirm Database= in the connection string, role CREATE privileges, and that FluentMigrator committed (non-empty connection string).");
|
||||
}
|
||||
|
||||
private static string BuildMaintenanceConnectionString(string connectionString) {
|
||||
var builder = new NpgsqlConnectionStringBuilder(connectionString) {
|
||||
Database = "postgres",
|
||||
};
|
||||
return builder.ConnectionString;
|
||||
}
|
||||
|
||||
private async Task EnsureDatabaseExistsAsync(CancellationToken cancellationToken) {
|
||||
var builder = new NpgsqlConnectionStringBuilder(config.ConnectionString);
|
||||
var database = builder.Database?.Trim();
|
||||
if (string.IsNullOrEmpty(database)) return;
|
||||
if (string.IsNullOrEmpty(database))
|
||||
return;
|
||||
|
||||
builder.Database = "postgres";
|
||||
var postgresCs = builder.ConnectionString;
|
||||
var postgresCs = BuildMaintenanceConnectionString(config.ConnectionString);
|
||||
|
||||
try {
|
||||
await using var conn = new NpgsqlConnection(postgresCs);
|
||||
|
||||
@ -7,14 +7,30 @@ namespace MaksIT.CertsUI.Engine.Infrastructure;
|
||||
/// Syncs the database schema to match DTOs: add missing tables and columns only (no DROP).
|
||||
/// Runs after FluentMigrator so baseline tables exist; this adds any missing columns (and optionally missing tables).
|
||||
/// </summary>
|
||||
public class SchemaSyncService(ICertsEngineConfiguration config, ILogger<SchemaSyncService> logger) : ISchemaSyncService {
|
||||
public class SchemaSyncService(
|
||||
ICertsEngineConfiguration config,
|
||||
ILogger<SchemaSyncService> logger,
|
||||
IDatabaseStartupObserver startupObserver
|
||||
) : ISchemaSyncService {
|
||||
private readonly ICertsEngineConfiguration _config = config;
|
||||
private readonly ILogger<SchemaSyncService> _logger = logger;
|
||||
private readonly IDatabaseStartupObserver _startupObserver = startupObserver;
|
||||
|
||||
public async Task SyncSchemaAsync(CancellationToken cancellationToken = default) {
|
||||
if (!_config.AutoSyncSchema)
|
||||
if (!_config.AutoSyncSchema) {
|
||||
_startupObserver.OnPhaseStarted(DatabaseStartupPhases.SchemaSync);
|
||||
_startupObserver.OnPhaseCompleted(DatabaseStartupPhases.SchemaSync, TimeSpan.Zero);
|
||||
return;
|
||||
}
|
||||
|
||||
await DatabaseStartupPhaseRunner.RunAsync(
|
||||
_startupObserver,
|
||||
DatabaseStartupPhases.SchemaSync,
|
||||
RunSyncCoreAsync,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task RunSyncCoreAsync(CancellationToken cancellationToken) {
|
||||
_logger.LogInformation("Schema sync (add-only) starting...");
|
||||
|
||||
var desired = GetDesiredSchema();
|
||||
|
||||
@ -12,17 +12,17 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentMigrator" Version="8.0.1" />
|
||||
<PackageReference Include="FluentMigrator.Runner.Postgres" Version="8.0.1" />
|
||||
<PackageReference Include="linq2db" Version="6.2.1" />
|
||||
<PackageReference Include="linq2db.PostgreSQL" Version="6.2.1" />
|
||||
<PackageReference Include="linq2db" Version="6.3.0" />
|
||||
<PackageReference Include="linq2db.PostgreSQL" Version="6.3.0" />
|
||||
<PackageReference Include="MaksIT.Core" Version="1.6.5" />
|
||||
<PackageReference Include="MaksIT.Dapr" Version="2.0.0" />
|
||||
<PackageReference Include="MaksIT.Results" Version="2.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.7" />
|
||||
<PackageReference Include="Npgsql" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.8" />
|
||||
<PackageReference Include="Npgsql" Version="10.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@ -16,6 +16,7 @@ public sealed class WebApiTestFixture : IDisposable
|
||||
{
|
||||
CertsEngineConfiguration = new CertsEngineConfiguration
|
||||
{
|
||||
ConnectionString = "Host=127.0.0.1;Port=5432;Database=certsui_test;Username=test;Password=test",
|
||||
Admin = new AdminUser
|
||||
{
|
||||
Username = "admin",
|
||||
|
||||
@ -7,14 +7,14 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="10.0.7" />
|
||||
<PackageReference Include="coverlet.collector" Version="10.0.0">
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="10.0.8" />
|
||||
<PackageReference Include="coverlet.collector" Version="10.0.1">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.5.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.6.0" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="Testcontainers.PostgreSql" Version="4.11.0" />
|
||||
<PackageReference Include="Testcontainers.PostgreSql" Version="4.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
|
||||
@ -56,8 +56,7 @@ public class Agent {
|
||||
|
||||
public class CertsEngineConfiguration : ICertsEngineConfiguration {
|
||||
|
||||
/// <summary>Npgsql connection string; optional when using legacy <c>ConnectionStrings:Certs</c> (resolved in <c>Program.cs</c>).</summary>
|
||||
public string ConnectionString { get; set; } = "";
|
||||
public required string ConnectionString { get; set; }
|
||||
|
||||
/// <summary>When true, add-only schema sync after FluentMigrator (ADD COLUMN only, never DROP legacy/renamed columns). Set explicitly in appsettings/Helm (deserialization defaults missing bools to false).</summary>
|
||||
public bool AutoSyncSchema { get; set; }
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using System.Diagnostics;
|
||||
using MaksIT.CertsUI.Engine;
|
||||
using MaksIT.CertsUI.Engine.DomainServices;
|
||||
using MaksIT.CertsUI.Engine.Infrastructure;
|
||||
using MaksIT.CertsUI.Engine.Persistence.Services;
|
||||
using MaksIT.CertsUI.Engine.RuntimeCoordination;
|
||||
using MaksIT.CertsUI.Infrastructure;
|
||||
|
||||
namespace MaksIT.CertsUI.HostedServices;
|
||||
|
||||
@ -11,7 +12,8 @@ public sealed class InitializationHostedService(
|
||||
ILogger<InitializationHostedService> logger,
|
||||
IServiceProvider serviceProvider,
|
||||
IRuntimeLeaseService leaseService,
|
||||
IRuntimeInstanceId runtimeInstance
|
||||
IRuntimeInstanceId runtimeInstance,
|
||||
CertsStartupState startupState
|
||||
) : IHostedService {
|
||||
|
||||
private static readonly TimeSpan BootstrapLeaseTtl = TimeSpan.FromMinutes(5);
|
||||
@ -20,6 +22,8 @@ public sealed class InitializationHostedService(
|
||||
private readonly IServiceProvider _serviceProvider = serviceProvider;
|
||||
private readonly IRuntimeLeaseService _leaseService = leaseService;
|
||||
private readonly IRuntimeInstanceId _runtimeInstance = runtimeInstance;
|
||||
private readonly CertsStartupState _startupState = startupState;
|
||||
private readonly Stopwatch _bootstrapStopwatch = Stopwatch.StartNew();
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellationToken) {
|
||||
await Initialize(cancellationToken);
|
||||
@ -31,6 +35,7 @@ public sealed class InitializationHostedService(
|
||||
|
||||
private async Task Initialize(CancellationToken cancellationToken) {
|
||||
const int delayMilliseconds = 2000;
|
||||
((IDatabaseStartupObserver)_startupState).OnPhaseStarted(CertsApplicationStartupPhases.BootstrapCoordination);
|
||||
while (!cancellationToken.IsCancellationRequested) {
|
||||
try {
|
||||
_logger.LogInformation("Running startup coordination (Postgres bootstrap lease)...");
|
||||
@ -58,7 +63,7 @@ public sealed class InitializationHostedService(
|
||||
_logger.LogWarning("Bootstrap lease release: {Messages}", string.Join("; ", released.Messages ?? []));
|
||||
}
|
||||
|
||||
_logger.LogInformation("Startup coordination completed (this instance held the bootstrap lease).");
|
||||
CompleteBootstrapCoordination("this instance held the bootstrap lease");
|
||||
return;
|
||||
}
|
||||
|
||||
@ -66,7 +71,7 @@ public sealed class InitializationHostedService(
|
||||
var userAuthFollower = followerScope.ServiceProvider.GetRequiredService<IUserAuthorizationPersistenceService>();
|
||||
while (!cancellationToken.IsCancellationRequested) {
|
||||
if (await IsClusterIdentityReadyAsync(userAuthFollower, cancellationToken).ConfigureAwait(false)) {
|
||||
_logger.LogInformation("Startup coordination completed (another instance bootstrapped identity).");
|
||||
CompleteBootstrapCoordination("another instance bootstrapped identity");
|
||||
return;
|
||||
}
|
||||
|
||||
@ -86,6 +91,7 @@ public sealed class InitializationHostedService(
|
||||
_logger.LogInformation(ex, "Startup coordination aborted while stopping host.");
|
||||
throw new OperationCanceledException("Host stopped during startup coordination.", ex, cancellationToken);
|
||||
}
|
||||
((IDatabaseStartupObserver)_startupState).OnPhaseFailed(CertsApplicationStartupPhases.BootstrapCoordination, _bootstrapStopwatch.Elapsed, ex);
|
||||
_logger.LogError(ex, "Startup coordination failed. Retrying...");
|
||||
try {
|
||||
await Task.Delay(delayMilliseconds, cancellationToken).ConfigureAwait(false);
|
||||
@ -98,6 +104,16 @@ public sealed class InitializationHostedService(
|
||||
}
|
||||
}
|
||||
|
||||
private void CompleteBootstrapCoordination(string reason) {
|
||||
_startupState.MarkBootstrapCoordinationComplete(_bootstrapStopwatch.Elapsed);
|
||||
var snapshot = _startupState.GetSnapshot();
|
||||
_logger.LogInformation(
|
||||
"Startup coordination completed ({Reason}). Application ready after {TotalMs} ms (phase={Phase}).",
|
||||
reason,
|
||||
(int)snapshot.TotalElapsed.TotalMilliseconds,
|
||||
snapshot.CurrentPhase);
|
||||
}
|
||||
|
||||
private static Task<bool> IsClusterIdentityReadyAsync(
|
||||
IUserAuthorizationPersistenceService userAuthorizationPersistence,
|
||||
CancellationToken cancellationToken
|
||||
|
||||
139
src/MaksIT.CertsUI/Infrastructure/CertsStartupState.cs
Normal file
139
src/MaksIT.CertsUI/Infrastructure/CertsStartupState.cs
Normal file
@ -0,0 +1,139 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using MaksIT.CertsUI.Engine.Infrastructure;
|
||||
|
||||
namespace MaksIT.CertsUI.Infrastructure;
|
||||
|
||||
public enum CertsStartupPhase {
|
||||
NotStarted = 0,
|
||||
PostgresMaintenanceReady = 10,
|
||||
DatabaseEnsured = 20,
|
||||
PostgresApplicationReady = 30,
|
||||
MigrationsComplete = 40,
|
||||
CoordinationTablesReady = 50,
|
||||
SchemaVerified = 60,
|
||||
SchemaSyncComplete = 70,
|
||||
BootstrapCoordinationComplete = 80,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tracks Certs UI startup phases and timings for health probes and operational visibility.
|
||||
/// </summary>
|
||||
public sealed class CertsStartupState : IDatabaseStartupObserver {
|
||||
private static readonly IReadOnlyDictionary<string, CertsStartupPhase> PhaseMap =
|
||||
new Dictionary<string, CertsStartupPhase>(StringComparer.Ordinal) {
|
||||
[DatabaseStartupPhases.PostgresMaintenanceReady] = CertsStartupPhase.PostgresMaintenanceReady,
|
||||
[DatabaseStartupPhases.DatabaseEnsured] = CertsStartupPhase.DatabaseEnsured,
|
||||
[DatabaseStartupPhases.PostgresApplicationReady] = CertsStartupPhase.PostgresApplicationReady,
|
||||
[DatabaseStartupPhases.MigrationsComplete] = CertsStartupPhase.MigrationsComplete,
|
||||
[DatabaseStartupPhases.CoordinationTablesReady] = CertsStartupPhase.CoordinationTablesReady,
|
||||
[DatabaseStartupPhases.SchemaVerified] = CertsStartupPhase.SchemaVerified,
|
||||
[DatabaseStartupPhases.SchemaSync] = CertsStartupPhase.SchemaSyncComplete,
|
||||
[CertsApplicationStartupPhases.BootstrapCoordination] = CertsStartupPhase.BootstrapCoordinationComplete,
|
||||
};
|
||||
|
||||
private readonly ConcurrentDictionary<string, StartupPhaseRecord> _phases = new(StringComparer.Ordinal);
|
||||
private readonly object _gate = new();
|
||||
private readonly Stopwatch _total = Stopwatch.StartNew();
|
||||
private volatile CertsStartupPhase _current = CertsStartupPhase.NotStarted;
|
||||
private string? _lastFailedPhase;
|
||||
private string? _lastFailureMessage;
|
||||
|
||||
public CertsStartupPhase CurrentPhase => _current;
|
||||
|
||||
public bool IsApplicationReady => _current >= CertsStartupPhase.BootstrapCoordinationComplete;
|
||||
|
||||
public bool HasFailure => _lastFailedPhase is not null;
|
||||
|
||||
void IDatabaseStartupObserver.OnPhaseStarted(string phase) {
|
||||
_phases[phase] = new StartupPhaseRecord(phase, StartedAtUtc: DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
void IDatabaseStartupObserver.OnPhaseCompleted(string phase, TimeSpan elapsed) {
|
||||
AdvancePhase(phase, elapsed, failed: false);
|
||||
}
|
||||
|
||||
void IDatabaseStartupObserver.OnPhaseFailed(string phase, TimeSpan elapsed, Exception exception) {
|
||||
lock (_gate) {
|
||||
_lastFailedPhase = phase;
|
||||
_lastFailureMessage = exception.Message;
|
||||
}
|
||||
|
||||
if (_phases.TryGetValue(phase, out var existing))
|
||||
_phases[phase] = existing with {
|
||||
CompletedAtUtc = DateTimeOffset.UtcNow,
|
||||
Elapsed = elapsed,
|
||||
Failed = true,
|
||||
Error = exception.Message,
|
||||
};
|
||||
else
|
||||
_phases[phase] = new StartupPhaseRecord(phase, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, elapsed, true, exception.Message);
|
||||
}
|
||||
|
||||
private void AdvancePhase(string phase, TimeSpan elapsed, bool failed) {
|
||||
var completedAt = DateTimeOffset.UtcNow;
|
||||
_phases.AddOrUpdate(
|
||||
phase,
|
||||
_ => new StartupPhaseRecord(phase, completedAt, completedAt, elapsed, failed, null),
|
||||
(_, existing) => existing with {
|
||||
CompletedAtUtc = completedAt,
|
||||
Elapsed = elapsed,
|
||||
Failed = failed,
|
||||
});
|
||||
|
||||
if (!failed && PhaseMap.TryGetValue(phase, out var mapped)) {
|
||||
if (mapped > _current)
|
||||
_current = mapped;
|
||||
|
||||
if (mapped == CertsStartupPhase.BootstrapCoordinationComplete) {
|
||||
lock (_gate) {
|
||||
_lastFailedPhase = null;
|
||||
_lastFailureMessage = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void MarkBootstrapCoordinationComplete(TimeSpan elapsed) {
|
||||
AdvancePhase(CertsApplicationStartupPhases.BootstrapCoordination, elapsed, failed: false);
|
||||
}
|
||||
|
||||
public StartupStatusSnapshot GetSnapshot() {
|
||||
var phases = _phases.Values
|
||||
.OrderBy(p => p.StartedAtUtc ?? DateTimeOffset.MaxValue)
|
||||
.ThenBy(p => p.Name, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
lock (_gate) {
|
||||
return new StartupStatusSnapshot(
|
||||
IsApplicationReady,
|
||||
_current.ToString(),
|
||||
_total.Elapsed,
|
||||
_lastFailedPhase,
|
||||
_lastFailureMessage,
|
||||
phases);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static class CertsApplicationStartupPhases {
|
||||
public const string BootstrapCoordination = "bootstrap_coordination";
|
||||
}
|
||||
|
||||
public sealed record StartupPhaseRecord(
|
||||
string Name,
|
||||
DateTimeOffset? StartedAtUtc = null,
|
||||
DateTimeOffset? CompletedAtUtc = null,
|
||||
TimeSpan? Elapsed = null,
|
||||
bool Failed = false,
|
||||
string? Error = null
|
||||
);
|
||||
|
||||
public sealed record StartupStatusSnapshot(
|
||||
bool IsApplicationReady,
|
||||
string CurrentPhase,
|
||||
TimeSpan TotalElapsed,
|
||||
string? LastFailedPhase,
|
||||
string? LastFailureMessage,
|
||||
IReadOnlyList<StartupPhaseRecord> Phases
|
||||
);
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<DefaultItemExcludes>$(DefaultItemExcludes);Models\obj\**;Models\bin\**</DefaultItemExcludes>
|
||||
<Version>3.5.0</Version>
|
||||
<Version>3.5.1</Version>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||
<DockerComposeProjectPath>..\docker-compose.dcproj</DockerComposeProjectPath>
|
||||
@ -14,7 +14,7 @@
|
||||
<PackageReference Include="MaksIT.Dapr" Version="2.0.0" />
|
||||
<PackageReference Include="MaksIT.Results" Version="2.0.1" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.23.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@ -3,6 +3,7 @@ using MaksIT.Core.Logging;
|
||||
using MaksIT.Core.Webapi.Middlewares;
|
||||
using MaksIT.CertsUI.Engine.DomainServices;
|
||||
using MaksIT.CertsUI.Engine.Extensions;
|
||||
using MaksIT.CertsUI.Engine.Infrastructure;
|
||||
using MaksIT.CertsUI;
|
||||
using MaksIT.CertsUI.Authorization.Filters;
|
||||
using MaksIT.CertsUI.Engine.RuntimeCoordination;
|
||||
@ -89,6 +90,10 @@ builder.Services.AddHostedService<AutoRenewal>();
|
||||
// Single process-wide lease holder id (see IRuntimeInstanceId) — must stay Singleton for app_runtime_leases coherence.
|
||||
builder.Services.AddSingleton<IRuntimeInstanceId, RuntimeInstanceIdProvider>();
|
||||
|
||||
// Startup phase tracking (migrations, bootstrap) for probes and /health/startup.
|
||||
builder.Services.AddSingleton<CertsStartupState>();
|
||||
builder.Services.AddSingleton<IDatabaseStartupObserver>(sp => sp.GetRequiredService<CertsStartupState>());
|
||||
|
||||
// Register engine services (same bound instance as Configuration:CertsEngineConfiguration)
|
||||
builder.Services.AddCertsEngine(engineCfg);
|
||||
|
||||
@ -127,7 +132,13 @@ builder.Services.AddHealthChecks()
|
||||
var app = builder.Build();
|
||||
|
||||
// FluentMigrator must complete before any IHostedService starts; bootstrap uses app_runtime_leases.
|
||||
var startupState = app.Services.GetRequiredService<CertsStartupState>();
|
||||
await app.Services.EnsureCertsEngineMigratedAsync();
|
||||
var migrationSnapshot = startupState.GetSnapshot();
|
||||
app.Logger.LogInformation(
|
||||
"Database startup finished in {ElapsedMs} ms (phase={Phase}).",
|
||||
(int)migrationSnapshot.TotalElapsed.TotalMilliseconds,
|
||||
migrationSnapshot.CurrentPhase);
|
||||
|
||||
app.UseMiddleware<ErrorHandlingMiddleware>();
|
||||
|
||||
@ -150,7 +161,10 @@ app.MapHealthChecks("/health/live", new Microsoft.AspNetCore.Diagnostics.HealthC
|
||||
Predicate = r => r.Tags.Contains("live")
|
||||
});
|
||||
|
||||
app.MapGet("/health/ready", async (CancellationToken ct) => {
|
||||
app.MapGet("/health/ready", async (CertsStartupState startup, CancellationToken ct) => {
|
||||
if (!startup.IsApplicationReady)
|
||||
return Results.StatusCode(503);
|
||||
|
||||
try {
|
||||
await using var conn = new NpgsqlConnection(certsConnectionString);
|
||||
await conn.OpenAsync(ct);
|
||||
@ -163,4 +177,6 @@ app.MapGet("/health/ready", async (CancellationToken ct) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.MapGet("/health/startup", (CertsStartupState startup) => Results.Json(startup.GetSnapshot()));
|
||||
|
||||
await app.RunAsync();
|
||||
|
||||
@ -53,7 +53,7 @@ const LayoutWrapper: FC<LayoutWrapperProps> = (props) => {
|
||||
}
|
||||
footer={
|
||||
{
|
||||
children: <p>{import.meta.env.VITE_APP_VERSION} - © {new Date().getFullYear()} <a
|
||||
children: <p>v{import.meta.env.VITE_APP_VERSION} - © {new Date().getFullYear()} <a
|
||||
href={import.meta.env.VITE_COMPANY_URL}
|
||||
target={'_blank'}
|
||||
rel={'noopener noreferrer'}>
|
||||
|
||||
@ -2,7 +2,8 @@ import { createWebUiHttpClient, readIdentity, type ApiResponse } from '@maks-it.
|
||||
import { addToast } from '@maks-it.com/webui-components'
|
||||
import { ApiRoutes, GetApiRoute } from './apiRoutes'
|
||||
import { store } from './redux/store'
|
||||
import { refreshJwt, clearIdentity } from './redux/slices/identitySlice'
|
||||
import { clearIdentity } from './redux/slices/identitySlice'
|
||||
import { refreshWebUiAccessToken } from './webUiAuthRefresh'
|
||||
import { hideLoader, showLoader } from './redux/slices/loaderSlice'
|
||||
|
||||
const {
|
||||
@ -21,7 +22,7 @@ const {
|
||||
} = createWebUiHttpClient({
|
||||
auth: {
|
||||
readIdentity,
|
||||
refreshToken: () => store.dispatch(refreshJwt()),
|
||||
refreshToken: refreshWebUiAccessToken,
|
||||
clearIdentity: () => {
|
||||
store.dispatch(clearIdentity())
|
||||
},
|
||||
|
||||
46
src/MaksIT.WebUI/src/webUiAuthRefresh.ts
Normal file
46
src/MaksIT.WebUI/src/webUiAuthRefresh.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { readIdentity } from '@maks-it.com/webui-core'
|
||||
import { store } from './redux/store'
|
||||
import { clearIdentity, refreshJwt } from './redux/slices/identitySlice'
|
||||
|
||||
let isRefreshing = false
|
||||
let refreshPromise: Promise<unknown> | null = null
|
||||
|
||||
/** Single in-flight refresh for axios (and SignalR when an app adds hubs). */
|
||||
export const refreshWebUiAccessToken = async (): Promise<void> => {
|
||||
if (!isRefreshing) {
|
||||
isRefreshing = true
|
||||
refreshPromise = store.dispatch(refreshJwt()).finally(() => {
|
||||
isRefreshing = false
|
||||
})
|
||||
}
|
||||
await refreshPromise
|
||||
}
|
||||
|
||||
/** Access token for hub connect/reconnect — same expiry rules as axios request interceptor. */
|
||||
export const resolveWebUiAccessToken = async (): Promise<string> => {
|
||||
let identity = readIdentity()
|
||||
if (!identity)
|
||||
return ''
|
||||
|
||||
const now = new Date()
|
||||
|
||||
if (new Date(identity.expiresAt) < now) {
|
||||
if (new Date(identity.refreshTokenExpiresAt) <= now)
|
||||
return ''
|
||||
|
||||
try {
|
||||
await refreshWebUiAccessToken()
|
||||
identity = readIdentity()
|
||||
if (!identity) {
|
||||
store.dispatch(clearIdentity())
|
||||
return ''
|
||||
}
|
||||
return identity.token
|
||||
} catch {
|
||||
store.dispatch(clearIdentity())
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
return identity.token
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
# See docker-compose.yml header for maksit-certs-ui-* container/image naming (no clash with maksit-vault-*).
|
||||
# Postgres + pgAdmin layout mirrors maksit-vault/src/docker-compose.override.yml (local dev).
|
||||
# See docker-compose.yml header for maksit-certs-ui-* container/image naming.
|
||||
# Postgres + pgAdmin are for local Compose only (Helm uses external PostgreSQL — see certsServerSecrets.certsEngineConfiguration.connectionString).
|
||||
#
|
||||
# WebUI client: @maks-it.com/webui-* packages are installed from npm during docker build.
|
||||
# WebUI client: @maks-it.com/webui-* from npm; MaksIT.WebUI/Dockerfile runs npm install && npm run dev.
|
||||
networks:
|
||||
maksit-certs-ui-network:
|
||||
driver: bridge
|
||||
@ -40,7 +40,8 @@ services:
|
||||
networks:
|
||||
- maksit-certs-ui-network
|
||||
depends_on:
|
||||
- postgres
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
postgres:
|
||||
restart: unless-stopped
|
||||
@ -55,6 +56,12 @@ services:
|
||||
- D:/Compose/MaksIT.CertsUI/postgresql/data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5432:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U certsui -d certsui"]
|
||||
interval: 2s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
start_period: 10s
|
||||
|
||||
# pgAdmin: mount servers.json (see repo src/postgresql/servers.json.example). Store password for user certsui in pgAdmin or use PassFile.
|
||||
pgadmin:
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
# Naming: maksit-certs-ui-<role> for containers and local images (parallel to maksit-vault-* in the Vault repo).
|
||||
# DOCKER_REGISTRY is optional (e.g. cr.example.com/). YARP upstreams: docker-compose.override.yml sets ReverseProxy__* env (Compose DNS: client / server); Kubernetes uses Helm-injected <fullname>-client|server.
|
||||
# Naming: maksit-certs-ui-<role> for containers and local images (unique prefix per product).
|
||||
# DOCKER_REGISTRY is optional (e.g. cr.example.com/). YARP upstreams: docker-compose.override.yml sets ReverseProxy__* env (Compose DNS: client / server); Kubernetes uses Helm <fullname>-client|server.
|
||||
name: maksit-certs-ui
|
||||
|
||||
services:
|
||||
|
||||
@ -1,24 +1,6 @@
|
||||
# MaksIT.CertsUI.Client API key E2E
|
||||
# CertsUI API key E2E (PowerShell)
|
||||
|
||||
This project contains E2E tests in `CertsUiApiKeyE2ETests` that execute against a **running** CertsUI deployment using API key auth.
|
||||
|
||||
## Environment variables
|
||||
|
||||
- `CERTSUI_E2E_BASE_URL` (for example: `http://localhost:8080`)
|
||||
- `CERTSUI_E2E_API_KEY` (API key with access to the endpoints under test)
|
||||
- `CERTSUI_E2E_EXPECT_MIN_DISTINCT_INSTANCES` (optional) — **PowerShell E2E** defaults to `1` (Docker Compose); set to `2`+ for HA. **dotnet test** defaults to `2`.
|
||||
|
||||
## Run (dotnet test)
|
||||
|
||||
```powershell
|
||||
$env:CERTSUI_E2E_BASE_URL = "http://localhost:8080"
|
||||
$env:CERTSUI_E2E_API_KEY = "<api-key>"
|
||||
dotnet test .\src\MaksIT.CertsUI.Client.Tests\MaksIT.CertsUI.Client.Tests.csproj --filter "Category=E2E"
|
||||
```
|
||||
|
||||
If env vars are missing, the E2E tests exit early and do not call the API.
|
||||
|
||||
## PowerShell E2E script (`src/e2e-tests/Test-CertsUiApiKeyE2E.ps1`)
|
||||
End-to-end tests run against a **running** CertsUI deployment via [`MaksIT.CertsUI.Client.PowerShell`](../MaksIT.CertsUI.Client.PowerShell/) cmdlets. They are **not** part of CI (`utils/engines/test/scriptSettings.json`); run them manually after compose, Helm, or k3s deploy.
|
||||
|
||||
Cmdlet reference and `Import-Module` paths: [assets/docs/POWERSHELL_CLIENT_MODULE.md](../../assets/docs/POWERSHELL_CLIENT_MODULE.md).
|
||||
|
||||
@ -26,7 +8,7 @@ Requires **latest PowerShell 7** with a **.NET 10** host (install from [PowerShe
|
||||
|
||||
**Docker Compose + YARP on `http://localhost:8080`:** use `http://localhost:8080` as the base URL (no `/api` suffix).
|
||||
|
||||
### Credentials (PowerShell E2E script)
|
||||
## Credentials
|
||||
|
||||
Uses **one** environment variable: **`CERTSUI_E2E_CREDENTIALS`** — UTF-8 text, Base64-encoded.
|
||||
|
||||
@ -54,8 +36,6 @@ Or only for the **current process**:
|
||||
$env:CERTSUI_E2E_CREDENTIALS = '<paste-base64-here>'
|
||||
```
|
||||
|
||||
**dotnet test** still uses separate `CERTSUI_E2E_BASE_URL` and `CERTSUI_E2E_API_KEY` (see above).
|
||||
|
||||
### JWT credentials (optional)
|
||||
|
||||
Identity admin scenarios use the **global admin API key** from `CERTSUI_E2E_CREDENTIALS` (`X-API-KEY`) when the server supports it. Optionally set **`CERTSUI_E2E_JWT_CREDENTIALS`** — same encoding, payload `<adminUsername><US><password>` — for JWT-only probes (e.g. scoped-user login).
|
||||
@ -66,7 +46,7 @@ $b64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes("admin$us" + 'yo
|
||||
[Environment]::SetEnvironmentVariable('CERTSUI_E2E_JWT_CREDENTIALS', $b64, 'User')
|
||||
```
|
||||
|
||||
Then:
|
||||
## Run
|
||||
|
||||
```powershell
|
||||
pwsh -File .\src\e2e-tests\Test-CertsUiApiKeyE2E.ps1
|
||||
@ -74,7 +54,23 @@ pwsh -File .\src\e2e-tests\Test-CertsUiApiKeyE2E.ps1
|
||||
|
||||
Or run `src\e2e-tests\Test-CertsUiApiKeyE2E.bat` after credentials are set.
|
||||
|
||||
### Registered scenarios (all use cmdlets)
|
||||
Filter scenarios:
|
||||
|
||||
```powershell
|
||||
pwsh -File .\src\e2e-tests\Test-CertsUiApiKeyE2E.ps1 -Scenario Health
|
||||
pwsh -File .\src\e2e-tests\Test-CertsUiApiKeyE2E.ps1 -Scenario AccountReadPatch
|
||||
```
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Purpose |
|
||||
|----------|---------|
|
||||
| `CERTSUI_E2E_CREDENTIALS` | Required — Base64 `<baseUrl><US><apiKey>` |
|
||||
| `CERTSUI_E2E_JWT_CREDENTIALS` | Optional — Base64 `<adminUser><US><password>` |
|
||||
| `CERTSUI_E2E_EXPECT_MIN_DISTINCT_INSTANCES` | Optional — `MultiReplica` defaults to **1** (Docker Compose); set **2**+ for HA |
|
||||
| `CERTSUI_E2E_ACCOUNT_ID` | Optional — `AccountReadPatch` target account (else first account) |
|
||||
|
||||
## Registered scenarios
|
||||
|
||||
| Id | Cmdlets |
|
||||
|----|---------|
|
||||
@ -84,8 +80,10 @@ Or run `src\e2e-tests\Test-CertsUiApiKeyE2E.bat` after credentials are set.
|
||||
| `AccountReadPatch` | `Get-CertsUIAccounts`, `Get-CertsUIAccount`, `Invoke-CertsUIPatchAccount` |
|
||||
| `IdentityConfigurations` | Global admin API key: users/API keys (all scope configs), PATCH remove all scopes, global-admin create probe |
|
||||
|
||||
Optional: `CERTSUI_E2E_ACCOUNT_ID` for `AccountReadPatch` (otherwise the first account is used). `AccountReadPatch` skips when there are no accounts.
|
||||
`AccountReadPatch` skips when there are no accounts.
|
||||
|
||||
**Docker Compose:** no extra env needed for `MultiReplica` (single server container). For k8s with multiple pods: `$env:CERTSUI_E2E_EXPECT_MIN_DISTINCT_INSTANCES = '2'`.
|
||||
|
||||
Filter: `pwsh -File .\src\e2e-tests\Test-CertsUiApiKeyE2E.ps1 -Scenario AccountReadPatch`
|
||||
## Unit tests (mock HTTP only)
|
||||
|
||||
[`MaksIT.CertsUI.Client.Tests`](../MaksIT.CertsUI.Client.Tests/) exercises `CertsUIClient` with `FakeHttpMessageHandler` — no live server. Included in CI via `dotnet test` on that project.
|
||||
@ -28,7 +28,7 @@ With **`IfNotPresent`** (default), **`rollme`** is omitted; the node may keep a
|
||||
|
||||
Pod annotation **certs-ui.io/image** is the resolved `registry/repository:tag` for debugging.
|
||||
|
||||
Optional per workload under **`components.<name>`**: **`replicaCount`** (default **1**; stateless **client** / **reverseproxy** are safe to raise on an HA cluster), **`livenessProbe`**, **`readinessProbe`**, **`resources`** (standard Kubernetes container fields). Omit a key to leave it unset.
|
||||
Optional per workload under **`components.<name>`**: **`replicaCount`** (default **1**; stateless **client** / **reverseproxy** are safe to raise on an HA cluster), **`livenessProbe`**, **`startupProbe`**, **`readinessProbe`**, **`resources`** (standard Kubernetes container fields). Omit a key to leave it unset.
|
||||
|
||||
When **`replicaCount` > 1**, the chart creates a **PodDisruptionBudget** (`minAvailable: 1`) for that component.
|
||||
|
||||
@ -43,6 +43,13 @@ Root keys `certsServerConfig`, `certsServerSecrets`, `certsClientRuntime` feed t
|
||||
|
||||
Use `existingConfigMap` / `existingSecret` to mount resources created outside the chart. With `keep: true`, existing objects are not replaced on upgrade if already present.
|
||||
|
||||
------------------------------------------------------------
|
||||
## Data and database
|
||||
|
||||
PostgreSQL: **external** — set **`certsServerSecrets.certsEngineConfiguration.connectionString`** (or mount **`existingSecret`** with the same **`appsecrets.json`** shape). The app waits via in-process **`PostgresStartupWait`** during migrations and bootstrap. Local dev: **`docker-compose`** **`postgres`** service (not part of this chart).
|
||||
|
||||
Health while starting: **`GET /health/startup`** (JSON phase snapshot); traffic should use **`GET /health/ready`** (503 until bootstrap completes). See **`assets/docs/HA_ARCHITECTURE.md`**.
|
||||
|
||||
------------------------------------------------------------
|
||||
## Uninstall
|
||||
|
||||
|
||||
@ -99,6 +99,10 @@ spec:
|
||||
{{- end }}
|
||||
{{- with $comp.livenessProbe }}
|
||||
livenessProbe:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with $comp.startupProbe }}
|
||||
startupProbe:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with $comp.readinessProbe }}
|
||||
|
||||
@ -22,8 +22,6 @@ certsServerConfig:
|
||||
certsEngineConfiguration:
|
||||
# Add-only column sync after FluentMigrator (ALTER ADD IF NOT EXISTS; never DROP). Set false to disable.
|
||||
autoSyncSchema: true
|
||||
admin:
|
||||
username: "admin"
|
||||
jwt:
|
||||
issuer: ""
|
||||
audience: ""
|
||||
@ -44,13 +42,17 @@ certsServerConfig:
|
||||
staging: "https://acme-staging-v02.api.letsencrypt.org/directory"
|
||||
|
||||
# Server Secret (appsecrets.json); referenced from components.server.secretsFile when tpl: true
|
||||
# Configuration:CertsEngineConfiguration:ConnectionString — same structural role as MaksIT.Vault VaultEngineConfiguration:ConnectionString.
|
||||
# Configuration:CertsEngineConfiguration:ConnectionString in appsecrets.json.
|
||||
# Helm expects external PostgreSQL. Local dev uses docker-compose postgres (see docker-compose.override.yml).
|
||||
certsServerSecrets:
|
||||
authSecret: changeme-generate-a-long-random-string
|
||||
authPepper: ""
|
||||
agentKey: ""
|
||||
adminUsername: admin
|
||||
adminPassword: password
|
||||
jwtSecret: "akeFXTWRdUV40klpekpyXcnVnUL41kJV7ojiII/QIUE="
|
||||
passwordPepper: "NoJr8UWnXyHxujEIk+EffOD82VJzMCk8Kpx0SXp4nMQ="
|
||||
agentKey: ""
|
||||
certsEngineConfiguration:
|
||||
# Full Npgsql connection string (required for Helm). Example:
|
||||
# Host=my-postgres;Port=5432;Database=certsui;Username=certsui;Password=secret;SslMode=Prefer
|
||||
connectionString: ""
|
||||
|
||||
# Client ConfigMap (config.js); referenced when tpl: true. Prefer a relative URL (/api) when UI and API share one ingress origin.
|
||||
@ -106,11 +108,12 @@ components:
|
||||
"CertsEngineConfiguration": {
|
||||
"ConnectionString": {{ .Values.certsServerSecrets.certsEngineConfiguration.connectionString | toJson }},
|
||||
"Admin": {
|
||||
"Username": {{ .Values.certsServerSecrets.adminUsername | toJson }},
|
||||
"Password": {{ .Values.certsServerSecrets.adminPassword | toJson }}
|
||||
},
|
||||
"JwtSettingsConfiguration": {
|
||||
"JwtSecret": {{ .Values.certsServerSecrets.authSecret | toJson }},
|
||||
"PasswordPepper": {{ .Values.certsServerSecrets.authPepper | toJson }}
|
||||
"JwtSecret": {{ .Values.certsServerSecrets.jwtSecret | toJson }},
|
||||
"PasswordPepper": {{ .Values.certsServerSecrets.passwordPepper | toJson }}
|
||||
},
|
||||
"Agent": {
|
||||
"AgentKey": {{ .Values.certsServerSecrets.agentKey | toJson }}
|
||||
@ -136,10 +139,6 @@ components:
|
||||
"Configuration": {
|
||||
"CertsEngineConfiguration": {
|
||||
"AutoSyncSchema": {{ .Values.certsServerConfig.configuration.certsEngineConfiguration.autoSyncSchema }},
|
||||
"Admin": {
|
||||
"Username": {{ .Values.certsServerConfig.configuration.certsEngineConfiguration.admin.username | toJson }},
|
||||
"Password": ""
|
||||
},
|
||||
"JwtSettingsConfiguration": {
|
||||
"JwtSecret": "",
|
||||
"Issuer": {{ .Values.certsServerConfig.configuration.certsEngineConfiguration.jwt.issuer | toJson }},
|
||||
@ -175,11 +174,19 @@ components:
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /health/ready
|
||||
port: http
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 60
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health/ready
|
||||
port: http
|
||||
initialDelaySeconds: 10
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
|
||||
80
utils/engines/release/Invoke-ReleasePackage.ps1
Normal file
80
utils/engines/release/Invoke-ReleasePackage.ps1
Normal file
@ -0,0 +1,80 @@
|
||||
#requires -Version 7.0
|
||||
#requires -PSEdition Core
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Plugin-driven release engine entry script.
|
||||
#>
|
||||
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$srcDir = (Resolve-Path (Join-Path $scriptDir '..\..')).Path
|
||||
|
||||
. (Join-Path $srcDir 'modules/Engine/Import-EngineModules.ps1')
|
||||
Import-EngineModules -Engine Release
|
||||
|
||||
$settings = Get-ScriptSettings -ScriptDir $scriptDir
|
||||
$configuredPlugins = Get-ConfiguredPlugins -Settings $settings
|
||||
|
||||
Write-Log -Level 'STEP' -Message '=================================================='
|
||||
Write-Log -Level 'STEP' -Message 'RELEASE ENGINE'
|
||||
Write-Log -Level 'STEP' -Message '=================================================='
|
||||
|
||||
$plugins = $configuredPlugins
|
||||
$engineContext = New-EngineContext -Plugins $plugins -ScriptDir $scriptDir -SrcDir $srcDir -Settings $settings
|
||||
Write-Log -Level 'OK' -Message 'All pre-flight checks passed!'
|
||||
$sharedPluginSettings = $engineContext
|
||||
|
||||
$releaseStageInitialized = $false
|
||||
$releaseHadPluginFailures = $false
|
||||
|
||||
if ($plugins.Count -eq 0) {
|
||||
Write-Log -Level 'WARN' -Message 'No plugins configured in scriptSettings.json.'
|
||||
}
|
||||
else {
|
||||
for ($pluginIndex = 0; $pluginIndex -lt $plugins.Count; $pluginIndex++) {
|
||||
$plugin = $plugins[$pluginIndex]
|
||||
|
||||
if ((Test-IsPublishPlugin -Plugin $plugin) -and -not $releaseStageInitialized) {
|
||||
if (Test-PluginRunnable -Plugin $plugin -SharedSettings $sharedPluginSettings -EngineDirectory $scriptDir -WriteLogs:$false) {
|
||||
$remainingPlugins = @($plugins[$pluginIndex..($plugins.Count - 1)])
|
||||
Initialize-ReleaseStageContext -RemainingPlugins $remainingPlugins -SharedSettings $sharedPluginSettings -ArtifactsDirectory $engineContext.artifactsDirectory -Version $engineContext.version
|
||||
$releaseStageInitialized = $true
|
||||
}
|
||||
}
|
||||
|
||||
$pluginSucceeded = Invoke-ConfiguredPlugin -Plugin $plugin -SharedSettings $sharedPluginSettings -EngineDirectory $scriptDir -ContinueOnError:$false
|
||||
if (-not $pluginSucceeded) {
|
||||
$releaseHadPluginFailures = $true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $releaseStageInitialized) {
|
||||
$noReleasePluginsLogLevel = if ($engineContext.isNonReleaseBranch) { 'INFO' } else { 'WARN' }
|
||||
Write-Log -Level $noReleasePluginsLogLevel -Message 'No release-stage initialization ran (no enabled publish plugins reached, or none runnable).'
|
||||
}
|
||||
|
||||
Write-Log -Level 'OK' -Message '=================================================='
|
||||
if ($releaseHadPluginFailures) {
|
||||
Write-Log -Level 'ERROR' -Message 'RELEASE FAILED'
|
||||
}
|
||||
elseif ($engineContext.PSObject.Properties.Name -contains 'skipPublishPlugins' -and $engineContext.skipPublishPlugins) {
|
||||
Write-Log -Level 'OK' -Message 'RUN COMPLETE (publish skipped by ReleasePublishGuard)'
|
||||
}
|
||||
elseif ($engineContext.isNonReleaseBranch) {
|
||||
Write-Log -Level 'OK' -Message 'NON-RELEASE RUN COMPLETE'
|
||||
}
|
||||
else {
|
||||
Write-Log -Level 'OK' -Message 'RELEASE COMPLETE'
|
||||
}
|
||||
Write-Log -Level 'OK' -Message '=================================================='
|
||||
|
||||
if ($engineContext.isNonReleaseBranch -and -not ($engineContext.PSObject.Properties.Name -contains 'skipPublishPlugins' -and $engineContext.skipPublishPlugins)) {
|
||||
$preferredReleaseBranch = Get-PreferredReleaseBranch -EngineContext $engineContext
|
||||
Write-Log -Level 'INFO' -Message "For publish, use an allowed branch (see ReleasePublishGuard.branches), e.g. '$preferredReleaseBranch', and satisfy the guard requirements."
|
||||
}
|
||||
|
||||
if ($releaseHadPluginFailures) {
|
||||
exit 1
|
||||
}
|
||||
1
utils/engines/release/custom/.gitkeep
Normal file
1
utils/engines/release/custom/.gitkeep
Normal file
@ -0,0 +1 @@
|
||||
|
||||
113
utils/engines/release/scriptSettings.json
Normal file
113
utils/engines/release/scriptSettings.json
Normal file
@ -0,0 +1,113 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft-07/schema",
|
||||
"title": "Release Package Script Settings",
|
||||
"description": "maksit-certs-ui: container images (Web API + Web UI) and Helm chart to OCI. No NuGet packages or zip archives. ReleasePublishGuard (before Docker/Helm) controls allowed branches and tag rules; publish plugins omit branches. Engine aligned with maksit-repoutils.",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "DotNetReleaseVersion",
|
||||
"stageLabel": "build",
|
||||
"enabled": true,
|
||||
"projectFiles": [
|
||||
"..\\..\\..\\src\\MaksIT.CertsUI\\MaksIT.CertsUI.csproj"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "DotNetTest",
|
||||
"stageLabel": "test",
|
||||
"enabled": true,
|
||||
"projects": [
|
||||
"..\\..\\..\\src\\MaksIT.CertsUI.Engine.Tests",
|
||||
"..\\..\\..\\src\\MaksIT.CertsUI.Tests"
|
||||
],
|
||||
"resultsDir": "..\\..\\..\\testResults"
|
||||
},
|
||||
{
|
||||
"name": "QualityGate",
|
||||
"stageLabel": "qualityGate",
|
||||
"enabled": true,
|
||||
"coverageThreshold": 0,
|
||||
"scanVulnerabilities": false
|
||||
},
|
||||
{
|
||||
"name": "ReleasePublishGuard",
|
||||
"stageLabel": "release",
|
||||
"enabled": true,
|
||||
"branches": [
|
||||
"main"
|
||||
],
|
||||
"requireExactTagOnHead": true,
|
||||
"tagVersionMustMatchDotNetRelease": true,
|
||||
"whenRequirementsNotMet": "skip",
|
||||
"requireCleanWorkingTree": false,
|
||||
"ensureTagOnRemote": true,
|
||||
"remoteName": "origin"
|
||||
},
|
||||
{
|
||||
"name": "DotNetDockerPush",
|
||||
"stageLabel": "release",
|
||||
"enabled": true,
|
||||
"registryUrl": "cr.maks-it.com",
|
||||
"credentialsEnvVar": "CR_MAKS_IT",
|
||||
"projectName": "certs-ui",
|
||||
"contextPath": "..\\..\\..\\src",
|
||||
"pushLatest": true,
|
||||
"images": [
|
||||
{
|
||||
"service": "server",
|
||||
"dockerfile": "MaksIT.CertsUI/Dockerfile"
|
||||
},
|
||||
{
|
||||
"service": "client",
|
||||
"dockerfile": "MaksIT.WebUI/Dockerfile.prod",
|
||||
"versionEnvFiles": [
|
||||
"MaksIT.WebUI/.env",
|
||||
"MaksIT.WebUI/.env.prod"
|
||||
]
|
||||
},
|
||||
{
|
||||
"service": "reverseproxy",
|
||||
"dockerfile": "ReverseProxy/Dockerfile"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "DotNetHelmPush",
|
||||
"stageLabel": "release",
|
||||
"enabled": true,
|
||||
"chartPath": "..\\..\\..\\src\\helm",
|
||||
"ociRepository": "oci://cr.maks-it.com/charts",
|
||||
"credentialsEnvVar": "CR_MAKS_IT",
|
||||
"pushLatest": false
|
||||
}
|
||||
],
|
||||
"_comments": {
|
||||
"plugins": {
|
||||
"name": "Plugin module name (for example, DotNetDockerPush -> plugins/DotNet/DotNetDockerPush.psm1). Lookup: engines/release/custom, then plugins/Platform, DotNet, Npm.",
|
||||
"stageLabel": "Execution phase: test, qualityGate, build, or release (lowercase). Plugin failures stop the run and report RELEASE FAILED.",
|
||||
"enabled": "If true, the plugin is imported and Invoke-Plugin is called in the configured order.",
|
||||
"DotNetReleaseVersion": "Reads <Version> from the first projectFiles entry; ReleasePublishGuard checks tag on HEAD matches when tagVersionMustMatchDotNetRelease is true.",
|
||||
"projects": "DotNetTest: array of test project paths relative to engines/release. Requires utils/modules/TestRunner.psm1 (multi-project Cobertura aggregation).",
|
||||
"resultsDir": "DotNetTest: optional; when multiple projects are listed, TestRunner uses engines/release/TestResults if omitted.",
|
||||
"projectFiles": "QualityGate: add .csproj paths when scanVulnerabilities is true (dotnet list package --vulnerable).",
|
||||
"scanVulnerabilities": "QualityGate: false = no dotnet list; true requires projectFiles. failOnVulnerabilities optional when scan is true (default true).",
|
||||
"coverageThreshold": "QualityGate: >0 requires line % on shared context (qualityLineCoverage, coverageLineRate, or testResult.LineRate). 0 disables.",
|
||||
"registryUrl": "DotNetDockerPush: registry host without scheme.",
|
||||
"credentialsEnvVar": "DotNetDockerPush / DotNetHelmPush: env var with base64(username:password).",
|
||||
"projectName": "DotNetDockerPush: image path segment: registryUrl/projectName/service:tag.",
|
||||
"contextPath": "DotNetDockerPush: directory containing MaksIT.CertsUI and MaksIT.WebUI (repo src/).",
|
||||
"images": "DotNetDockerPush: [{ service, dockerfile, contextPath?, versionEnvFiles? }]. dockerfile and versionEnvFiles are relative to the image contextPath when set, otherwise plugin contextPath.",
|
||||
"versionEnvFiles": "DotNetDockerPush image option: array of files (relative to the image build context) where VITE_APP_VERSION is temporarily set to shared.version before docker build, then restored after build/push.",
|
||||
"chartPath": "DotNetHelmPush: directory containing Chart.yaml. Keep version/appVersion as placeholders in git (e.g. 0.0.0); DotNetHelmPush overwrites with bare semver from DotNetReleaseVersion (shared.version, e.g. 3.3.4, no v) before package/push; falls back to stripping v from shared.tag if version is missing.",
|
||||
"ociRepository": "DotNetHelmPush: OCI registry URL for helm push (e.g. oci://cr.maks-it.com/charts).",
|
||||
"pushLatest": "DotNetDockerPush: also push :latest (images also get bare :3.3.4 and :v3.3.4 from DotNetReleaseVersion). DotNetHelmPush: oras copy chart to :latest (requires oras on PATH).",
|
||||
"branches": "ReleasePublishGuard: allowed branches for publish (omit or [\"*\"] = any). Do not put branches on DotNetDockerPush/DotNetHelmPush/GitHub/DotNetNuGet.",
|
||||
"requireExactTagOnHead": "ReleasePublishGuard: require git describe --tags --exact-match HEAD (vX.Y.Z).",
|
||||
"tagVersionMustMatchDotNetRelease": "ReleasePublishGuard: tag semver must equal DotNetReleaseVersion when true.",
|
||||
"whenRequirementsNotMet": "ReleasePublishGuard: skip (suppress publish plugins) or fail (exit 1).",
|
||||
"requireCleanWorkingTree": "ReleasePublishGuard: block publish if git status is not clean.",
|
||||
"ensureTagOnRemote": "ReleasePublishGuard: push tag to remote if missing.",
|
||||
"remoteName": "ReleasePublishGuard: git remote for tag push (default origin).",
|
||||
"maksit-repoutils": "Full engine and plugin docs: https://github.com/MAKS-IT-COM/maksit-repoutils"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -12,6 +12,7 @@
|
||||
"enabled": true,
|
||||
"projects": [
|
||||
"..\\..\\..\\src\\MaksIT.CertsUI.Engine.Tests",
|
||||
"..\\..\\..\\src\\MaksIT.CertsUI.Client.Tests",
|
||||
"..\\..\\..\\src\\MaksIT.CertsUI.Tests"
|
||||
]
|
||||
},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user