diff --git a/.gitignore b/.gitignore index bf555f7..446d17f 100644 --- a/.gitignore +++ b/.gitignore @@ -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/** \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index de5562d..d0c05f3 100644 --- a/CHANGELOG.md +++ b/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). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f892729..9da2b0c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/README.md b/README.md index 2a9d11b..7ddc43b 100644 --- a/README.md +++ b/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 <" }, "JwtSettingsConfiguration": { - "JwtSecret": "", - "PasswordPepper": "" + "JwtSecret": "", + "PasswordPepper": "" }, "Agent": { "AgentKey": "" @@ -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 ``, ``, ``, and `` with secure values. Ensure `` 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 ``, ``, ``, and `` with secure values. Ensure `` 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 <", @@ -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": "" }, "JwtSettingsConfiguration": { - "JwtSecret": "", - "PasswordPepper": "" + "JwtSecret": "", + "PasswordPepper": "" }, "Agent": { "AgentKey": "" @@ -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": "", @@ -718,11 +749,12 @@ Replace the placeholder values with your actual secrets. This secret contains th "CertsEngineConfiguration": { "ConnectionString": "Host=;Port=5432;Database=certsui;Username=certsui;Password=certsui;SslMode=Prefer", "Admin": { + "Username": "admin", "Password": "" }, "JwtSettingsConfiguration": { - "JwtSecret": "", - "PasswordPepper": "" + "JwtSecret": "", + "PasswordPepper": "" }, "Agent": { "AgentKey": "" @@ -738,10 +770,13 @@ kubectl create secret generic certs-ui-server-secrets \ "Configuration": { "CertsEngineConfiguration": { "ConnectionString": "Host=;Port=5432;Database=certsui;Username=certsui;Password=certsui;SslMode=Prefer", - "Admin": { "Password": "" }, + "Admin": { + "Username": "admin", + "Password": "" + }, "JwtSettingsConfiguration": { - "JwtSecret": "", - "PasswordPepper": "" + "JwtSecret": "", + "PasswordPepper": "" }, "Agent": { "AgentKey": "" } } @@ -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": "", @@ -809,7 +841,6 @@ kubectl create configmap certs-ui-server-configmap \ "Configuration": { "CertsEngineConfiguration": { "AutoSyncSchema": true, - "Admin": { "Username": "admin" }, "JwtSettingsConfiguration": { "JwtSecret": "", "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." -$env:CERTSUI_E2E_API_KEY = "" -$env:CERTSUI_E2E_EXPECT_MIN_DISTINCT_INSTANCES = "2" +# See src/e2e-tests/README.md for Base64 encoding of +[Environment]::SetEnvironmentVariable('CERTSUI_E2E_CREDENTIALS', '', '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`). diff --git a/assets/badges/coverage-branches.svg b/assets/badges/coverage-branches.svg index 4c9e302..eb5f1c2 100644 --- a/assets/badges/coverage-branches.svg +++ b/assets/badges/coverage-branches.svg @@ -1,5 +1,5 @@ - - Branch Coverage: 8.4% + + Branch Coverage: 8.2% @@ -15,7 +15,7 @@ Branch Coverage - - 8.4% + + 8.2% diff --git a/assets/badges/coverage-lines.svg b/assets/badges/coverage-lines.svg index ce0351b..b340864 100644 --- a/assets/badges/coverage-lines.svg +++ b/assets/badges/coverage-lines.svg @@ -1,5 +1,5 @@ - - Line Coverage: 16.7% + + Line Coverage: 16.8% @@ -15,7 +15,7 @@ Line Coverage - - 16.7% + + 16.8% diff --git a/assets/badges/coverage-methods.svg b/assets/badges/coverage-methods.svg index 131ba44..5bed106 100644 --- a/assets/badges/coverage-methods.svg +++ b/assets/badges/coverage-methods.svg @@ -1,5 +1,5 @@ - - Method Coverage: 21.9% + + Method Coverage: 21.8% @@ -15,7 +15,7 @@ Method Coverage - - 21.9% + + 21.8% diff --git a/assets/docs/ARCHITECTURE_LAYERING.md b/assets/docs/ARCHITECTURE_LAYERING.md index b5141bf..ee86d3b 100644 --- a/assets/docs/ARCHITECTURE_LAYERING.md +++ b/assets/docs/ARCHITECTURE_LAYERING.md @@ -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) +- [PATCH_DELTA_REFERENCE.md](./PATCH_DELTA_REFERENCE.md) +- [POWERSHELL_CLIENT_MODULE.md](./POWERSHELL_CLIENT_MODULE.md) diff --git a/assets/docs/HA_ARCHITECTURE.md b/assets/docs/HA_ARCHITECTURE.md index ba4de2c..948b672 100644 --- a/assets/docs/HA_ARCHITECTURE.md +++ b/assets/docs/HA_ARCHITECTURE.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) diff --git a/assets/docs/LOGIN_AND_REFRESH_TOKEN_ARCHITECTURE.md b/assets/docs/LOGIN_AND_REFRESH_TOKEN_ARCHITECTURE.md index 97521ca..dfdbcdf 100644 --- a/assets/docs/LOGIN_AND_REFRESH_TOKEN_ARCHITECTURE.md +++ b/assets/docs/LOGIN_AND_REFRESH_TOKEN_ARCHITECTURE.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. --- diff --git a/assets/docs/PATCH_DELTA_REFERENCE.md b/assets/docs/PATCH_DELTA_REFERENCE.md index c9c42b5..ef26b8d 100644 --- a/assets/docs/PATCH_DELTA_REFERENCE.md +++ b/assets/docs/PATCH_DELTA_REFERENCE.md @@ -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) --- diff --git a/assets/docs/POWERSHELL_CLIENT_MODULE.md b/assets/docs/POWERSHELL_CLIENT_MODULE.md index 1c0b21a..b615051 100644 --- a/assets/docs/POWERSHELL_CLIENT_MODULE.md +++ b/assets/docs/POWERSHELL_CLIENT_MODULE.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). diff --git a/assets/docs/RBAC_REFERENCE.md b/assets/docs/RBAC_REFERENCE.md new file mode 100644 index 0000000..8833425 --- /dev/null +++ b/assets/docs/RBAC_REFERENCE.md @@ -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* diff --git a/assets/docs/REVERSE_PROXY_ROUTING.md b/assets/docs/REVERSE_PROXY_ROUTING.md index 19dcf2d..11e1a77 100644 --- a/assets/docs/REVERSE_PROXY_ROUTING.md +++ b/assets/docs/REVERSE_PROXY_ROUTING.md @@ -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) diff --git a/assets/docs/USER_AND_API_KEY_RBAC.md b/assets/docs/USER_AND_API_KEY_RBAC.md new file mode 100644 index 0000000..8b8329c --- /dev/null +++ b/assets/docs/USER_AND_API_KEY_RBAC.md @@ -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 ` | Browser (WebUI), interactive tools, services that can refresh tokens | +| **API key** | `X-API-KEY: ` | 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`** / **`RBACWrapperJwtToken`** / **`RBACWrapperApiKey`** 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* diff --git a/src/MaksIT.CertsUI.Client.PowerShell/MaksIT.CertsUI.Client.PowerShell.csproj b/src/MaksIT.CertsUI.Client.PowerShell/MaksIT.CertsUI.Client.PowerShell.csproj index 0e6bccb..4e01c65 100644 --- a/src/MaksIT.CertsUI.Client.PowerShell/MaksIT.CertsUI.Client.PowerShell.csproj +++ b/src/MaksIT.CertsUI.Client.PowerShell/MaksIT.CertsUI.Client.PowerShell.csproj @@ -15,7 +15,7 @@ - + diff --git a/src/MaksIT.CertsUI.Client.Tests/CertsUiApiKeyE2ETests.cs b/src/MaksIT.CertsUI.Client.Tests/CertsUiApiKeyE2ETests.cs deleted file mode 100644 index e760f52..0000000 --- a/src/MaksIT.CertsUI.Client.Tests/CertsUiApiKeyE2ETests.cs +++ /dev/null @@ -1,101 +0,0 @@ -using Microsoft.Extensions.Options; -using MaksIT.CertsUI.Client; - -namespace MaksIT.CertsUI.Client.Tests; - -/// E2E tests against a live CertsUI deployment (set CERTSUI_E2E_BASE_URL and CERTSUI_E2E_API_KEY). -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(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."); - } -} diff --git a/src/MaksIT.CertsUI.Client.Tests/MaksIT.CertsUI.Client.Tests.csproj b/src/MaksIT.CertsUI.Client.Tests/MaksIT.CertsUI.Client.Tests.csproj index 09d6dfd..4003a13 100644 --- a/src/MaksIT.CertsUI.Client.Tests/MaksIT.CertsUI.Client.Tests.csproj +++ b/src/MaksIT.CertsUI.Client.Tests/MaksIT.CertsUI.Client.Tests.csproj @@ -9,7 +9,7 @@ - + all diff --git a/src/MaksIT.CertsUI.Client/MaksIT.CertsUI.Client.csproj b/src/MaksIT.CertsUI.Client/MaksIT.CertsUI.Client.csproj index ec8d9a5..5894c61 100644 --- a/src/MaksIT.CertsUI.Client/MaksIT.CertsUI.Client.csproj +++ b/src/MaksIT.CertsUI.Client/MaksIT.CertsUI.Client.csproj @@ -12,8 +12,8 @@ - - + + diff --git a/src/MaksIT.CertsUI.Engine.Tests/MaksIT.CertsUI.Engine.Tests.csproj b/src/MaksIT.CertsUI.Engine.Tests/MaksIT.CertsUI.Engine.Tests.csproj index d32f7fa..ad5a885 100644 --- a/src/MaksIT.CertsUI.Engine.Tests/MaksIT.CertsUI.Engine.Tests.csproj +++ b/src/MaksIT.CertsUI.Engine.Tests/MaksIT.CertsUI.Engine.Tests.csproj @@ -7,11 +7,11 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/MaksIT.CertsUI.Engine/DomainServices/IdentityDomainService.cs b/src/MaksIT.CertsUI.Engine/DomainServices/IdentityDomainService.cs index cae4348..3c10aec 100644 --- a/src/MaksIT.CertsUI.Engine/DomainServices/IdentityDomainService.cs +++ b/src/MaksIT.CertsUI.Engine/DomainServices/IdentityDomainService.cs @@ -48,7 +48,7 @@ public class IdentityDomainService( ILogger 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 _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.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.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.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.Unauthorized(null, "Invalid username or password."); @@ -280,7 +280,7 @@ public class IdentityDomainService( if (user.TwoFactorEnabled) return Result?>.BadRequest(null, "Two-factor authentication is already enabled."); - var pepper = _vaultEngineConfiguration.JwtSettingsConfiguration.PasswordPepper; + var pepper = _certsEngineConfiguration.JwtSettingsConfiguration.PasswordPepper; if (string.IsNullOrWhiteSpace(pepper)) return Result?>.InternalServerError(null, "Password pepper is not configured."); diff --git a/src/MaksIT.CertsUI.Engine/Extensions/ServiceCollectionExtensions.cs b/src/MaksIT.CertsUI.Engine/Extensions/ServiceCollectionExtensions.cs index 01839e8..e975ee1 100644 --- a/src/MaksIT.CertsUI.Engine/Extensions/ServiceCollectionExtensions.cs +++ b/src/MaksIT.CertsUI.Engine/Extensions/ServiceCollectionExtensions.cs @@ -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(certsEngineConfiguration); + services.TryAddSingleton(); + 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>(); var identityPersistenceService = sp.GetRequiredService(); var userAuthorizationPersistenceService = sp.GetRequiredService(); - var vaultEngineConfiguration = sp.GetRequiredService(); - var adminUser = vaultEngineConfiguration.Admin; - var jwtSettingsConfiguration = vaultEngineConfiguration.JwtSettingsConfiguration; - var twoFactorSettingsConfiguration = vaultEngineConfiguration.TwoFactorSettingsConfiguration; + var certsEngineConfiguration = sp.GetRequiredService(); + var adminUser = certsEngineConfiguration.Admin; + var jwtSettingsConfiguration = certsEngineConfiguration.JwtSettingsConfiguration; + var twoFactorSettingsConfiguration = certsEngineConfiguration.TwoFactorSettingsConfiguration; return new IdentityDomainService( logger, identityPersistenceService, userAuthorizationPersistenceService, - vaultEngineConfiguration, + certsEngineConfiguration, adminUser, jwtSettingsConfiguration, twoFactorSettingsConfiguration); diff --git a/src/MaksIT.CertsUI.Engine/Infrastructure/DatabaseStartupPhaseRunner.cs b/src/MaksIT.CertsUI.Engine/Infrastructure/DatabaseStartupPhaseRunner.cs new file mode 100644 index 0000000..ee663ec --- /dev/null +++ b/src/MaksIT.CertsUI.Engine/Infrastructure/DatabaseStartupPhaseRunner.cs @@ -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 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; + } + } +} diff --git a/src/MaksIT.CertsUI.Engine/Infrastructure/IDatabaseStartupObserver.cs b/src/MaksIT.CertsUI.Engine/Infrastructure/IDatabaseStartupObserver.cs new file mode 100644 index 0000000..f0e6c0e --- /dev/null +++ b/src/MaksIT.CertsUI.Engine/Infrastructure/IDatabaseStartupObserver.cs @@ -0,0 +1,27 @@ +namespace MaksIT.CertsUI.Engine.Infrastructure; + +/// +/// Optional hooks for reporting database startup phase timing (migrations, schema sync). +/// +public interface IDatabaseStartupObserver { + void OnPhaseStarted(string phase); + void OnPhaseCompleted(string phase, TimeSpan elapsed); + void OnPhaseFailed(string phase, TimeSpan elapsed, Exception exception); +} + +/// Well-known phase names reported during and schema sync. +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) { } +} diff --git a/src/MaksIT.CertsUI.Engine/Infrastructure/IRunMigrationsService.cs b/src/MaksIT.CertsUI.Engine/Infrastructure/IRunMigrationsService.cs index 9a8ae2d..5373a46 100644 --- a/src/MaksIT.CertsUI.Engine/Infrastructure/IRunMigrationsService.cs +++ b/src/MaksIT.CertsUI.Engine/Infrastructure/IRunMigrationsService.cs @@ -1,7 +1,7 @@ namespace MaksIT.CertsUI.Engine.Infrastructure; /// -/// 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. /// public interface IRunMigrationsService { Task RunAsync(CancellationToken cancellationToken = default); diff --git a/src/MaksIT.CertsUI.Engine/Infrastructure/RunMigrationsService.cs b/src/MaksIT.CertsUI.Engine/Infrastructure/RunMigrationsService.cs index 2529812..f1c6440 100644 --- a/src/MaksIT.CertsUI.Engine/Infrastructure/RunMigrationsService.cs +++ b/src/MaksIT.CertsUI.Engine/Infrastructure/RunMigrationsService.cs @@ -15,7 +15,8 @@ namespace MaksIT.CertsUI.Engine.Infrastructure; public sealed class RunMigrationsService( IMigrationRunner migrationRunner, ILogger 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(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); diff --git a/src/MaksIT.CertsUI.Engine/Infrastructure/SchemaSyncService.cs b/src/MaksIT.CertsUI.Engine/Infrastructure/SchemaSyncService.cs index d63b232..1ef0645 100644 --- a/src/MaksIT.CertsUI.Engine/Infrastructure/SchemaSyncService.cs +++ b/src/MaksIT.CertsUI.Engine/Infrastructure/SchemaSyncService.cs @@ -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). /// -public class SchemaSyncService(ICertsEngineConfiguration config, ILogger logger) : ISchemaSyncService { +public class SchemaSyncService( + ICertsEngineConfiguration config, + ILogger logger, + IDatabaseStartupObserver startupObserver +) : ISchemaSyncService { private readonly ICertsEngineConfiguration _config = config; private readonly ILogger _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(); diff --git a/src/MaksIT.CertsUI.Engine/MaksIT.CertsUI.Engine.csproj b/src/MaksIT.CertsUI.Engine/MaksIT.CertsUI.Engine.csproj index 4e737fc..2cf4425 100644 --- a/src/MaksIT.CertsUI.Engine/MaksIT.CertsUI.Engine.csproj +++ b/src/MaksIT.CertsUI.Engine/MaksIT.CertsUI.Engine.csproj @@ -12,17 +12,17 @@ - - + + - - - - - - + + + + + + diff --git a/src/MaksIT.CertsUI.Tests/Infrastructure/WebApiTestFixture.cs b/src/MaksIT.CertsUI.Tests/Infrastructure/WebApiTestFixture.cs index ec0e4bd..52fd5a1 100644 --- a/src/MaksIT.CertsUI.Tests/Infrastructure/WebApiTestFixture.cs +++ b/src/MaksIT.CertsUI.Tests/Infrastructure/WebApiTestFixture.cs @@ -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", diff --git a/src/MaksIT.CertsUI.Tests/MaksIT.CertsUI.Tests.csproj b/src/MaksIT.CertsUI.Tests/MaksIT.CertsUI.Tests.csproj index a4af208..f9b161c 100644 --- a/src/MaksIT.CertsUI.Tests/MaksIT.CertsUI.Tests.csproj +++ b/src/MaksIT.CertsUI.Tests/MaksIT.CertsUI.Tests.csproj @@ -7,14 +7,14 @@ - - + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + - + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/MaksIT.CertsUI/Configuration.cs b/src/MaksIT.CertsUI/Configuration.cs index 7772762..ac0c2aa 100644 --- a/src/MaksIT.CertsUI/Configuration.cs +++ b/src/MaksIT.CertsUI/Configuration.cs @@ -56,8 +56,7 @@ public class Agent { public class CertsEngineConfiguration : ICertsEngineConfiguration { - /// Npgsql connection string; optional when using legacy ConnectionStrings:Certs (resolved in Program.cs). - public string ConnectionString { get; set; } = ""; + public required string ConnectionString { get; set; } /// 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). public bool AutoSyncSchema { get; set; } diff --git a/src/MaksIT.CertsUI/HostedServices/InitializationHostedService.cs b/src/MaksIT.CertsUI/HostedServices/InitializationHostedService.cs index 1b8ccda..74a8e0c 100644 --- a/src/MaksIT.CertsUI/HostedServices/InitializationHostedService.cs +++ b/src/MaksIT.CertsUI/HostedServices/InitializationHostedService.cs @@ -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 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(); 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 IsClusterIdentityReadyAsync( IUserAuthorizationPersistenceService userAuthorizationPersistence, CancellationToken cancellationToken diff --git a/src/MaksIT.CertsUI/Infrastructure/CertsStartupState.cs b/src/MaksIT.CertsUI/Infrastructure/CertsStartupState.cs new file mode 100644 index 0000000..55a28be --- /dev/null +++ b/src/MaksIT.CertsUI/Infrastructure/CertsStartupState.cs @@ -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, +} + +/// +/// Tracks Certs UI startup phases and timings for health probes and operational visibility. +/// +public sealed class CertsStartupState : IDatabaseStartupObserver { + private static readonly IReadOnlyDictionary PhaseMap = + new Dictionary(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 _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 Phases +); diff --git a/src/MaksIT.CertsUI/MaksIT.CertsUI.csproj b/src/MaksIT.CertsUI/MaksIT.CertsUI.csproj index 9c32294..9fd27d5 100644 --- a/src/MaksIT.CertsUI/MaksIT.CertsUI.csproj +++ b/src/MaksIT.CertsUI/MaksIT.CertsUI.csproj @@ -2,7 +2,7 @@ $(DefaultItemExcludes);Models\obj\**;Models\bin\** - 3.5.0 + 3.5.1 net10.0 Linux ..\docker-compose.dcproj @@ -14,7 +14,7 @@ - + diff --git a/src/MaksIT.CertsUI/Program.cs b/src/MaksIT.CertsUI/Program.cs index 844b578..77c9987 100644 --- a/src/MaksIT.CertsUI/Program.cs +++ b/src/MaksIT.CertsUI/Program.cs @@ -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(); // Single process-wide lease holder id (see IRuntimeInstanceId) — must stay Singleton for app_runtime_leases coherence. builder.Services.AddSingleton(); +// Startup phase tracking (migrations, bootstrap) for probes and /health/startup. +builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => sp.GetRequiredService()); + // 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(); 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(); @@ -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(); diff --git a/src/MaksIT.WebUI/src/AppMap.tsx b/src/MaksIT.WebUI/src/AppMap.tsx index 5892620..8c8bca8 100644 --- a/src/MaksIT.WebUI/src/AppMap.tsx +++ b/src/MaksIT.WebUI/src/AppMap.tsx @@ -53,7 +53,7 @@ const LayoutWrapper: FC = (props) => { } footer={ { - children:

{import.meta.env.VITE_APP_VERSION} - © {new Date().getFullYear()} v{import.meta.env.VITE_APP_VERSION} - © {new Date().getFullYear()} diff --git a/src/MaksIT.WebUI/src/axiosConfig.ts b/src/MaksIT.WebUI/src/axiosConfig.ts index 3b6ef47..f8207ab 100644 --- a/src/MaksIT.WebUI/src/axiosConfig.ts +++ b/src/MaksIT.WebUI/src/axiosConfig.ts @@ -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()) }, diff --git a/src/MaksIT.WebUI/src/webUiAuthRefresh.ts b/src/MaksIT.WebUI/src/webUiAuthRefresh.ts new file mode 100644 index 0000000..cd0cbae --- /dev/null +++ b/src/MaksIT.WebUI/src/webUiAuthRefresh.ts @@ -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 | null = null + +/** Single in-flight refresh for axios (and SignalR when an app adds hubs). */ +export const refreshWebUiAccessToken = async (): Promise => { + 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 => { + 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 +} diff --git a/src/docker-compose.override.yml b/src/docker-compose.override.yml index cf08a26..bf890de 100644 --- a/src/docker-compose.override.yml +++ b/src/docker-compose.override.yml @@ -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: diff --git a/src/docker-compose.yml b/src/docker-compose.yml index 47468aa..45454c7 100644 --- a/src/docker-compose.yml +++ b/src/docker-compose.yml @@ -1,5 +1,5 @@ -# Naming: maksit-certs-ui- 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 -client|server. +# Naming: maksit-certs-ui- 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 -client|server. name: maksit-certs-ui services: diff --git a/src/MaksIT.CertsUI.Client.Tests/README.E2E.md b/src/e2e-tests/README.md similarity index 69% rename from src/MaksIT.CertsUI.Client.Tests/README.E2E.md rename to src/e2e-tests/README.md index 98c839e..5234a0b 100644 --- a/src/MaksIT.CertsUI.Client.Tests/README.E2E.md +++ b/src/e2e-tests/README.md @@ -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 = "" -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 = '' ``` -**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 `` — 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 `` | +| `CERTSUI_E2E_JWT_CREDENTIALS` | Optional — Base64 `` | +| `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. diff --git a/src/helm/templates/NOTES.txt b/src/helm/templates/NOTES.txt index c68e0ee..607ce3e 100644 --- a/src/helm/templates/NOTES.txt +++ b/src/helm/templates/NOTES.txt @@ -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.`**: **`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.`**: **`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 diff --git a/src/helm/templates/deployments.yaml b/src/helm/templates/deployments.yaml index ae505d6..de83082 100644 --- a/src/helm/templates/deployments.yaml +++ b/src/helm/templates/deployments.yaml @@ -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 }} diff --git a/src/helm/values.yaml b/src/helm/values.yaml index 72c919d..c3ff8fc 100644 --- a/src/helm/values.yaml +++ b/src/helm/values.yaml @@ -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 diff --git a/utils/engines/release/Invoke-ReleasePackage.ps1 b/utils/engines/release/Invoke-ReleasePackage.ps1 new file mode 100644 index 0000000..caf880e --- /dev/null +++ b/utils/engines/release/Invoke-ReleasePackage.ps1 @@ -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 +} diff --git a/utils/engines/release/custom/.gitkeep b/utils/engines/release/custom/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/utils/engines/release/custom/.gitkeep @@ -0,0 +1 @@ + diff --git a/utils/engines/release/scriptSettings.json b/utils/engines/release/scriptSettings.json new file mode 100644 index 0000000..616073c --- /dev/null +++ b/utils/engines/release/scriptSettings.json @@ -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 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" + } + } +} diff --git a/utils/engines/test/scriptSettings.json b/utils/engines/test/scriptSettings.json index 2eb9bcf..c799fc0 100644 --- a/utils/engines/test/scriptSettings.json +++ b/utils/engines/test/scriptSettings.json @@ -12,6 +12,7 @@ "enabled": true, "projects": [ "..\\..\\..\\src\\MaksIT.CertsUI.Engine.Tests", + "..\\..\\..\\src\\MaksIT.CertsUI.Client.Tests", "..\\..\\..\\src\\MaksIT.CertsUI.Tests" ] },