(docs): consolidate docs into agents.md; fix operator health, ha, and e2e notes for 3.5.2

This commit is contained in:
Maksym Sadovnychyy 2026-06-04 20:07:27 +02:00
parent 4543cfd02b
commit 06ba5e8e2c
20 changed files with 115 additions and 1561 deletions

View File

@ -0,0 +1,17 @@
{
"$schema": "homelab-maksit-skills-manifest-v2",
"sharedSkillsRoot": "E:\\Users\\maksym\\source\\repos\\private\\homelab\\ai\\skills",
"sharedSkills": [
"common/csharp",
"common/maksit-layering",
"common/maksit-auth-rbac",
"common/maksit-identity-tokens",
"common/maksit-patch",
"common/maksit-ha",
"common/maksit-reverse-proxy",
"common/react-typescript",
"common/helm",
"common/maksit-repo-maintenance",
"local-ollama"
]
}

View File

@ -0,0 +1,23 @@
---
description: Load MaksIT agent skills from homelab common/
globs: "**/*.{cs,csproj,slnx,ts,tsx,json,md,ps1,yml,yaml}"
alwaysApply: true
---
# MaksIT skills (maksit-certs-ui)
Complementary skills (no precedence). Read each `SKILL.md` when relevant:
1. `E:\Users\maksym\source\repos\private\homelab\ai\skills\common\csharp\SKILL.md`
2. `E:\Users\maksym\source\repos\private\homelab\ai\skills\common\maksit-layering\SKILL.md`
3. `E:\Users\maksym\source\repos\private\homelab\ai\skills\common\maksit-auth-rbac\SKILL.md`
4. `E:\Users\maksym\source\repos\private\homelab\ai\skills\common\maksit-identity-tokens\SKILL.md`
5. `E:\Users\maksym\source\repos\private\homelab\ai\skills\common\maksit-patch\SKILL.md`
6. `E:\Users\maksym\source\repos\private\homelab\ai\skills\common\maksit-ha\SKILL.md`
7. `E:\Users\maksym\source\repos\private\homelab\ai\skills\common\maksit-reverse-proxy\SKILL.md`
8. `E:\Users\maksym\source\repos\private\homelab\ai\skills\common\react-typescript\SKILL.md`
9. `E:\Users\maksym\source\repos\private\homelab\ai\skills\common\helm\SKILL.md`
10. `E:\Users\maksym\source\repos\private\homelab\ai\skills\common\maksit-repo-maintenance\SKILL.md`
11. `E:\Users\maksym\source\repos\private\homelab\ai\skills\local-ollama\SKILL.md` — local Ollama offload (`@local-ollama`)
Manifest: `.cursor/maksit-skills.json`.

21
AGENTS.md Normal file
View File

@ -0,0 +1,21 @@
# Agent instructions (maksit-certs-ui)
Complementary skills (no precedence). Homelab paths use `sharedSkills` in `.cursor/maksit-skills.json`.
| Skill | Path |
|-------|------|
| csharp | [SKILL.md](E:\Users\maksym\source\repos\private\homelab\ai\skills\common\csharp\SKILL.md) |
| maksit-layering | [SKILL.md](E:\Users\maksym\source\repos\private\homelab\ai\skills\common\maksit-layering\SKILL.md) |
| maksit-auth-rbac | [SKILL.md](E:\Users\maksym\source\repos\private\homelab\ai\skills\common\maksit-auth-rbac\SKILL.md) |
| maksit-identity-tokens | [SKILL.md](E:\Users\maksym\source\repos\private\homelab\ai\skills\common\maksit-identity-tokens\SKILL.md) |
| maksit-patch | [SKILL.md](E:\Users\maksym\source\repos\private\homelab\ai\skills\common\maksit-patch\SKILL.md) |
| maksit-ha | [SKILL.md](E:\Users\maksym\source\repos\private\homelab\ai\skills\common\maksit-ha\SKILL.md) |
| maksit-reverse-proxy | [SKILL.md](E:\Users\maksym\source\repos\private\homelab\ai\skills\common\maksit-reverse-proxy\SKILL.md) |
| react-typescript | [SKILL.md](E:\Users\maksym\source\repos\private\homelab\ai\skills\common\react-typescript\SKILL.md) |
| helm | [SKILL.md](E:\Users\maksym\source\repos\private\homelab\ai\skills\common\helm\SKILL.md) |
| maksit-repo-maintenance | [SKILL.md](E:\Users\maksym\source\repos\private\homelab\ai\skills\common\maksit-repo-maintenance\SKILL.md) |
| local-ollama | [SKILL.md](E:\Users\maksym\source\repos\private\homelab\ai\skills\local-ollama\SKILL.md) |
Manifest: `.cursor/maksit-skills.json`.
**Contributors:** pick the skill that matches the task; **source code** is authoritative.

View File

@ -4,6 +4,16 @@ 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). 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.2] - 2026-06-04
**Release status:** **3.3.4** is the last published release. **3.5.2** is a patch on **3.5.1** (documentation consolidation, dependency bumps).
### Changed
- **Documentation:** Removed **`assets/docs/`** topic guides (layering, HA, auth, RBAC, proxy, patch, PowerShell); README and CONTRIBUTING point contributors at **[AGENTS.md](AGENTS.md)** (homelab `common/maksit-*` skills); operator-facing health and HA notes stay in README and Helm **`NOTES.txt`**.
- **Helm:** **`NOTES.txt`** health probe note is self-contained (no link to removed HA doc).
- **Dependencies:** **MaksIT.Core** **1.6.7**, **MaksIT.Results** **2.0.2** (host, Engine, Client.Tests).
## [3.5.1] - 2026-06-02 ## [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). **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).

View File

@ -14,9 +14,9 @@ Large or architectural changes are best discussed first (see [Contact](#contact)
## Architecture and code layout ## Architecture and code layout
**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. **Canonical (contributors / AI agents):** [AGENTS.md](AGENTS.md) — homelab shared skills via `.cursor/maksit-skills.json`.
**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. **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). Before changing filters or identity/API-key services, read **maksit-auth-rbac** and **maksit-layering** (listed in AGENTS.md).
## Development setup ## Development setup

View File

@ -6,9 +6,9 @@ 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 [Lets Encrypt guidelines](https://letsencrypt.org/docs/) while implementing recommended security and operational best practices. 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 [Lets 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. 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; enforcement is in **`src/MaksIT.CertsUI/`** (authorization filter, identity, and API-key services). Contributors: **AGENTS.md****maksit-auth-rbac**.
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). Contributors and AI agents: architecture skill index in **[AGENTS.md](AGENTS.md)** (homelab `common/maksit-*` skills via `.cursor/maksit-skills.json`). **Source code** is authoritative when docs and behavior differ.
--- ---
@ -26,12 +26,7 @@ If you find this project useful, please consider supporting its development:
- [Table of Contents](#table-of-contents) - [Table of Contents](#table-of-contents)
- [Changelog](#changelog) - [Changelog](#changelog)
- [Contributing](#contributing) - [Contributing](#contributing)
- [User and API key RBAC](#user-and-api-key-rbac) - [Architecture and documentation](#architecture-and-documentation)
- [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)
- [High availability architecture](#high-availability-architecture)
- [Architecture](#architecture) - [Architecture](#architecture)
- [Current Limitations](#current-limitations) - [Current Limitations](#current-limitations)
- [Architecture Scheme](#architecture-scheme) - [Architecture Scheme](#architecture-scheme)
@ -66,81 +61,19 @@ Version history and release notes live in [CHANGELOG.md](CHANGELOG.md).
See [CONTRIBUTING.md](CONTRIBUTING.md) for setup, pull request expectations, and security reporting. See [CONTRIBUTING.md](CONTRIBUTING.md) for setup, pull request expectations, and security reporting.
## User and API key RBAC ## Architecture and documentation
How JWT and **`X-API-KEY`** principals are resolved, how **`CertsUIAuthorizationFilter`** differs from Vaults 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)**. **Contributors / AI agents:** skill index in **[AGENTS.md](AGENTS.md)** (homelab `common/maksit-*` skills — layering, RBAC, HA, Helm, etc.). **Operators:** runbooks and behavior below, [CHANGELOG.md](CHANGELOG.md), and Helm **`NOTES.txt`** after install.
- [1. Two authentication mechanisms](assets/docs/USER_AND_API_KEY_RBAC.md#1-two-authentication-mechanisms) **Server health endpoints:**
- [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 | Path | Use |
|------|-----|
| `GET /health/live` | Liveness — process up |
| `GET /health/ready` | Readiness and load balancers — **503** until bootstrap coordination completes, then PostgreSQL check |
| `GET /health/startup` | Startup diagnostics — JSON phase snapshot (migrations, schema sync, bootstrap) |
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)**. See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup.
- [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.
- [TL;DR (start here)](assets/docs/PATCH_DELTA_REFERENCE.md#tldr-start-here)
- [1. Core contract (MaksIT.Core)](assets/docs/PATCH_DELTA_REFERENCE.md#1-core-contract-maksitcore)
- [2. Backend (BE) rules](assets/docs/PATCH_DELTA_REFERENCE.md#2-backend-be-rules)
- [3. Frontend (FE) rules](assets/docs/PATCH_DELTA_REFERENCE.md#3-frontend-fe-rules)
- [4. Payload examples](assets/docs/PATCH_DELTA_REFERENCE.md#4-payload-examples)
- [5. Quick reference](assets/docs/PATCH_DELTA_REFERENCE.md#5-quick-reference)
- [6. Related docs](assets/docs/PATCH_DELTA_REFERENCE.md#6-related-docs)
- [7. Current implementation vs reference](assets/docs/PATCH_DELTA_REFERENCE.md#7-current-implementation-vs-reference-maksit-certsui)
## Login and refresh token architecture
How login, JWT access tokens, refresh tokens, axios interceptors, and logout interact is documented in **[assets/docs/LOGIN_AND_REFRESH_TOKEN_ARCHITECTURE.md](assets/docs/LOGIN_AND_REFRESH_TOKEN_ARCHITECTURE.md)**. **Certs WebAPI** persists users in PostgreSQL; **2FA** follows whatever this repos backend and WebUI implement (shared models may carry optional fields).
- [1. Overview](assets/docs/LOGIN_AND_REFRESH_TOKEN_ARCHITECTURE.md#1-overview)
- [2. Token model](assets/docs/LOGIN_AND_REFRESH_TOKEN_ARCHITECTURE.md#2-token-model)
- [3. Backend flow](assets/docs/LOGIN_AND_REFRESH_TOKEN_ARCHITECTURE.md#3-backend-flow)
- [4. Frontend flow](assets/docs/LOGIN_AND_REFRESH_TOKEN_ARCHITECTURE.md#4-frontend-flow)
- [5. API summary](assets/docs/LOGIN_AND_REFRESH_TOKEN_ARCHITECTURE.md#5-api-summary)
- [6. Sequence overview](assets/docs/LOGIN_AND_REFRESH_TOKEN_ARCHITECTURE.md#6-sequence-overview)
- [7. Security notes](assets/docs/LOGIN_AND_REFRESH_TOKEN_ARCHITECTURE.md#7-security-notes)
- [8. Key files reference](assets/docs/LOGIN_AND_REFRESH_TOKEN_ARCHITECTURE.md#8-key-files-reference)
## Reverse proxy routing (YARP)
How the **YARP** edge splits **ACME challenge**, **Swagger**, **WebAPI**, and **WebUI** traffic is documented in **[assets/docs/REVERSE_PROXY_ROUTING.md](assets/docs/REVERSE_PROXY_ROUTING.md)**, including **`/.well-known/acme-challenge/`** for HTTP-01.
- [Route table](assets/docs/REVERSE_PROXY_ROUTING.md#route-table)
- [HTTP-01 (Lets Encrypt)](assets/docs/REVERSE_PROXY_ROUTING.md#http-01-lets-encrypt)
- [Kubernetes (Helm)](assets/docs/REVERSE_PROXY_ROUTING.md#kubernetes-helm)
- [Automation and clients](assets/docs/REVERSE_PROXY_ROUTING.md#automation-and-clients)
- [Direct vs proxied ports (local dev)](assets/docs/REVERSE_PROXY_ROUTING.md#direct-vs-proxied-ports-local-dev)
- [Related files](assets/docs/REVERSE_PROXY_ROUTING.md#related-files)
## High availability architecture
High-availability behavior for ACME coordination, challenge coherence, leases, background services, and Kubernetes probes is documented in **[assets/docs/HA_ARCHITECTURE.md](assets/docs/HA_ARCHITECTURE.md)**.
- [Goals and runtime model](assets/docs/HA_ARCHITECTURE.md#goals)
- [Lease design](assets/docs/HA_ARCHITECTURE.md#lease-design)
- [HTTP-01 coherence design](assets/docs/HA_ARCHITECTURE.md#http-01-coherence-design)
- [Kubernetes behavior](assets/docs/HA_ARCHITECTURE.md#kubernetes-behavior)
- [Files involved](assets/docs/HA_ARCHITECTURE.md#files-involved)
--- ---
@ -723,7 +656,7 @@ The Helm chart in [`src/helm`](src/helm) deploys **server**, **client**, and **r
3. **Verify** that pods in the `certs-ui` namespace can reach the database host and port (DNS, network policies, TLS/`SslMode` as required). 3. **Verify** that pods in the `certs-ui` namespace can reach the database host and port (DNS, network policies, TLS/`SslMode` as required).
4. **Configure** `certsServerSecrets.certsEngineConfiguration.connectionString` in your values overlay or Secret (see [step 2](#2-prepare-namespace-secrets-and-configmap) and [`src/helm/values.yaml`](src/helm/values.yaml)). The chart default is an empty placeholder until you set it. 4. **Configure** `certsServerSecrets.certsEngineConfiguration.connectionString` in your values overlay or Secret (see [step 2](#2-prepare-namespace-secrets-and-configmap) and [`src/helm/values.yaml`](src/helm/values.yaml)). The chart default is an empty placeholder until you set it.
For **high availability** (`components.server.replicaCount` > 1), use a **shared** PostgreSQL deployment that every server replica can reach. The application stores users, refresh tokens, ACME sessions, HTTP-01 challenge tokens, and runtime leases in PostgreSQL—not on server PVCs. See [High availability architecture](#high-availability-architecture) and [`assets/docs/HA_ARCHITECTURE.md`](assets/docs/HA_ARCHITECTURE.md). A single PostgreSQL instance is acceptable for development or single-replica clusters if it meets your availability and backup needs. For **high availability** (`components.server.replicaCount` > 1), use a **shared** PostgreSQL deployment that every server replica can reach. The application stores users, refresh tokens, ACME sessions, HTTP-01 challenge tokens, and runtime leases in PostgreSQL—not on server PVCs. Server replicas are **symmetric** (no elected primary); short-lived Postgres leases (`certs-ui-bootstrap`, `certs-ui-renewal-sweep`) coordinate bootstrap and renewal sweeps — see Helm **`NOTES.txt`** after install and [Health endpoints](#architecture-and-documentation) above. A single PostgreSQL instance is acceptable for development or single-replica clusters if it meets your availability and backup needs.
Unlike Docker/Podman Compose in this repo (which includes a `postgres` service in `docker-compose`), the Kubernetes chart expects you to operate the database separately. Unlike Docker/Podman Compose in this repo (which includes a `postgres` service in `docker-compose`), the Kubernetes chart expects you to operate the database separately.

View File

@ -1,358 +0,0 @@
# Architecture layering (Certs UI)
How **MaksIT.CertsUI** (host) and **MaksIT.CertsUI.Engine** (library) split work so HTTP, business rules, and PostgreSQL stay in the right place. Complements `assets/docs/` (HA, auth, proxy).
**Branches, PRs, changelog:** [CONTRIBUTING.md](../../CONTRIBUTING.md).
---
## At a glance
| | **Host** `MaksIT.CertsUI` | **Engine** `MaksIT.CertsUI.Engine` |
|--|---------------------------|-------------------------------------|
| **Owns** | Controllers, app `Services/`, auth, DI, `ToActionResult()`, ProblemDetails | `Domain/`, `DomainServices/`, `Persistence/`, `QueryServices/`, integration `Services/` (e.g. ACME HTTP) |
| **Must not** | Linq2Db, raw SQL, `IPersistenceService` / `IQueryService` in controllers | `IActionResult`, HTTP types, host-only policy |
| **Returns** | HTTP responses | `Result` / `Result<T>` (`MaksIT.Results`) |
**Single spine (request direction):**
```text
Controller → App Service → IDomainService → IPersistenceService OR IQueryService → Linq2Db → PostgreSQL
```
**Shortcut (thin paged search in this repo):** **Controller → App Service → `I*QueryService` → …** with **no** **`IDomainService`** hop—see [Pattern B](#pattern-b-thin-search) (`IdentityService.SearchUsersAsync`, `ApiKeyService.Search…`).
**App Service** is drawn as a **box that contains** **`Mappers/`** on both sides of the Engine call: **Request / wire models → domain or engine inputs** (outbound), then **engine `Result` / `Query/` / domain → Response DTOs** (inbound). Mapping types live under **`MaksIT.CertsUI/Mappers/`**; orchestration and **`Result`** handling live in **`Services/`**.
Details: **[Request flow](#request-flow-to-database)** → **[Response flow](#response-flow-to-client)**. Reads: **[Query flow](#query-flow-reads)** (full stack vs thin search).
ACME HTTP and similar integration run **inside** the `IDomainService` step—not a parallel stack.
**Same layering as sibling MaksIT apps:** thin host `Services/` call **`I*DomainService`** (or **`I*QueryService`** for thin search). Hosted jobs resolve services via **`IServiceScope`** / **`IServiceScopeFactory`** and **Engine `IDomainService`** (and may call a **thin host façade** such as **`ICertsFlowService`**, which forwards to **`ICertsFlowDomainService`**); they **do not** resolve **`IPersistenceService`** / **`IQueryService`** directly from the worker class.
---
**Diagram convention:** **Every** spine figure is **linear**: a **single** **`-->`** chain (**no forks**). **Outbound** uses **`flowchart TB`** (**1** at top → **8 · PostgreSQL** at bottom). **Inbound** (response) uses **`flowchart BT`** (**8 → … → 1** toward HTTP). **Step 3 · App Service** appears as **one or more consecutive nodes** on that chain (labels name the beats).
## Request flow (to database)
```mermaid
%% Spine outbound: request 1 to 8
flowchart TB
o1["1 Controller"] --> o2["2 Request"] --> o_map["3 · Mappers: Request to domain or engine inputs"] --> o_orch["3 · Orchestration, call IDomainService"] --> o5["5 DomainService"] --> o6["6 Persist or Query port"] --> o7["7 Linq2Db + Dto"] --> o8[(8 PostgreSQL)]
```
| Step | Where | Role |
|:----:|-------|------|
| 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. **`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. |
| 8 | DB | PostgreSQL. |
**Shortcuts (still one line, not a fork):**
- **Search / list:** sometimes **3 → 6** with no extra domain logic: app service → **`IQueryService`** → Linq2Db (middle steps **skipped**) — see **[Query flow — Pattern B](#pattern-b-thin-search)**.
- **Hosted:** **`IServiceScopeFactory`** → e.g. **`IRegistrationCacheDomainService`**, **`ICertsFlowDomainService`**, **`ICertsFlowService`** (`AutoRenewal`); never inject **`IPersistenceService`** / **`IQueryService`** on the **hosted** class itself.
**Variants at steps 67 only:**
| | Step 6 | Step 7 |
|--|--------|--------|
| **Write** | `IPersistenceService` | Domain→Dto if needed, then write |
| **Read by key** | `IPersistenceService` | Read Dto, Dto→Domain, return |
| **Read search** | `IQueryService` | Projection → domain or `Query/` type |
---
## Response flow (to client)
Data and **`Result<T>`** unwind **stage by stage** toward HTTP. Nothing “teleports” from Linq2Db to the controller: each layer maps what it owns.
**Spine (database → wire):** **data and `Result<T>`** walk **8→1** (PostgreSQL → … → Controller) in **one line**. Steps **85** are Engine-side (same as outbound). **3 · App Service** is **three beats on that line**: from Engine → unwrap **`Result`** → orchestrate → **`Mappers/`** · domain / **`Query/`** → **`MaksIT.Models`** / response DTOs. The Mermaid figure uses **`flowchart BT`** so arrows run **toward HTTP** (not toward the DB).
```text
PostgreSQL → … → IDomainService → App Service (internal: Mappers · → Response DTOs) → Controller → ToActionResult() / Content / ProblemDetails
```
| Step | Direction | Responsibility |
|:----:|-------------|----------------|
| 8→7 | Engine | **Persist path:** materialize **`Dto/`**; **`Persistence/Mappers`**: **Dto → domain**. **Query path:** materialize **`Dto/`** (or joined Dtos); **`QueryServices/.../Linq2Db`**: **Dto → `Query/`** (e.g. `MapToQueryResult`). |
| 7→6 | Engine | Port returns domain, **`Query/`** read model, or **`Result<T>`** payload. |
| 6→5 | Engine | **`IDomainService`** may enrich, validate, or aggregate before returning **`Result`**. |
| 5→3 | Host | **`App Service`**: orchestration · unwrap **`Result`**; **`MaksIT.CertsUI/Mappers`** · engine outputs / **`Query/`** / domain → response DTOs (**three consecutive nodes for step 3** in diagram). |
| 3→1 | Host | **`Controller`**: **`ToActionResult(result)`** or **`Content(...)`** (ACME). |
```mermaid
%% Spine inbound: response 8 to 1 toward HTTP
flowchart BT
i8[(8 PostgreSQL)] --> i7["7 Linq2Db + Dto"] --> i6["6 Persist or Query port"] --> i5["5 DomainService"] --> i_from["3 · From Engine: Result, domain, Query"] --> i_orch["3 · Orchestration, unwrap Result"] --> i_map["3 · Mappers to Response DTOs"] --> i2["2 Request binding context"] --> i1["1 Controller ToActionResult or Content"]
```
**Thin search shortcut:** if the request skipped **`IDomainService`**, the response still walks **7 → 6 → (skip 5) → 3 → 1**: **`Query/`** (already mapped from **`Dto/`** in step 7) → **`App Service`** (mapper inside step **3**) → **`Result`** → **`ToActionResult()`**.
---
## Query flow (reads)
Reads always hit **PostgreSQL through Linq2Db**; only **who calls the read port** changes.
**Mapping to query results (Engine read model):** inside **`QueryServices/.../Linq2Db`**, Linq2Db materializes **`Dto/`** table rows (or joins). Implementations then **map `Dto` → types under `Query/`** (e.g. `UserQueryResult`, `ApiKeyQueryResult`) before returning **`Result<List<…>>`** from **`Search`**. That **Dto → `Query/`** step is the **query-side read mapper**—not web API mappers and not **`Persistence/Mappers`** (those are for writes / JSON columns / domain load). See e.g. **`UserQueryServiceLinq2Db`** (`MapToQueryResult`).
**Predicates:** **`IUserQueryService`** and **`IApiKeyQueryService`** take **`Expression<Func<TDto, bool>>?`** plus **`skip` / `limit`** and a separate **`Count`** with the same predicate. The **host** builds translatable predicates (today: simple filters such as **`Contains`** on username/description). **`ExpressionCompose`** (`QueryServices/ExpressionCompose.cs`) is available when you need composed predicates through navigation—not required for the current search callers.
**Inside `IQueryService` (Linq2Db implementation):**
```text
PostgreSQL → Linq2Db (materialize Dto / joins) → optional Where(predicate) → map Dto → Query/ types → Result<List<>> to caller; Count uses the same predicate
```
The caller is either **`IDomainService`** (Pattern A) or **app `Service`** (Pattern B).
### Pattern A: Domain-centered read (`IDomainService`)
Use when a use case must go through **one Engine orchestration place** (invariants, multiple ports, ACME side effects, load-by-key).
**In this repo, Identity and API keys use persistence for domain loads, not the query port:** e.g. **`ReadUserByIdAsync`** and **`ReadAPIKeyAsync`** go **App → `I*DomainService` → `I*PersistenceService` → Linq2Db** (**Dto → domain** via **`Persistence/Mappers`**), not **`I*QueryService`**.
**When listing is owned by the domain** (not implemented for Identity/API key search here), the shape is:
```text
Controller → App Service → IDomainService → IQueryService (PostgreSQL → … → Query/) → Result back up
```
**Response:** if the domain sits in the middle, unwind through **`IDomainService`**; if the request used **Pattern B**, **`IDomainService`** is skipped on the return path too (see [Response flow](#response-flow-to-client)).
```mermaid
%% Query Pattern A outbound
flowchart TB
a1["1 Controller"] --> a2["2 Request"] --> a_map["3 · Mappers: Request to domain or engine inputs"] --> a_orch["3 · Orchestration, call IDomainService"] --> a5["5 DomainService"] --> a6["6 Persist or Query port"] --> a7["7 Linq2Db + Dto"] --> a8[(8 PostgreSQL)]
```
### Pattern B: Thin search
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
```
**Response:** **`IQueryService`** returns **`Result<List<QueryType>?>`** from **`Search`** (rows already mapped from **`Dto/`**); **`Count`** supplies **`TotalRecords`**. **App service** maps **`Query/`** → **`MaksIT.Models`** / paged API DTOs → controller → **`ToActionResult()`**.
```mermaid
%% Query Pattern B outbound thin search
flowchart TB
b1["1 Controller"] --> b2["2 Request"] --> b_map["3 · Mappers · predicates"] --> b_qry["3 · IQueryService"] -->|"skip DomainService"| b6["6 Persist or Query port"] --> b7["7 Linq2Db + Dto"] --> b8[(8 PostgreSQL)]
```
**Pick A vs B:** **B** is what **Identity** and **API key** **search** use today. Prefer **A** (domain calls **`IQueryService`**) when list rules must live in Engine; **get-by-id** here stays **domain → `IPersistenceService`**, not **`IQueryService`**.
---
## Layers (detail)
### Host — `MaksIT.CertsUI`
| Layer | Responsibility |
|-------|----------------|
| **Controllers** | Thin: app service + `ToActionResult()`. No business rules, no Linq2Db. |
| **Models** | Often **`MaksIT.Models`** for shared API shapes with **MaksIT.WebUI**. |
| **App `Services/`** | **Only** controller entry for a use case: orchestration · **`IDomainService`** / **`IQueryService`**; **invokes** **`Mappers/`** before Engine (**Request** → domain / engine inputs) and after Engine (**`Result`** / **`Query/`** / domain → **Response** DTOs). |
| **Web `Mappers/`** (`Mappers/`) | Types used **from** app services: **Request** → domain / engine inputs; engine outputs → **Response** DTOs. Not **`Engine/Persistence/Mappers`** (table / JSON payloads). |
### Engine — `MaksIT.CertsUI.Engine`
| Layer | Responsibility |
|-------|----------------|
| **`DomainServices/`** | Use cases: **`IPersistenceService`**, Engine **`Services/`**; may also call **`IQueryService`** when a use case should own search (Identity/API key **search** in this repo is **Pattern B** from the host). No HTTP return types. |
| **`Persistence/`** | Writes (and load-by-key APIs): **`I*PersistenceService`** + Linq2Db, **`Dto/`**, **`Persistence/Mappers`**. |
| **`QueryServices/`** | Reads: **`I*QueryService`** + Linq2Db + **`Query/`**. |
| **`Services/`** (Engine) | Integration **used by** `DomainServices` (e.g. **`ILetsEncryptService`**). Not a second app-service layer. |
| **`Domain/`** | Entities / value objects: no Linq2Db, HTTP, or host types. |
**Engine slice (same idea as the spine):**
| | Purpose | Examples |
|--|---------|----------|
| **DomainServices** | Orchestrate persistence + integration; **`IQueryService`** only when domain owns search (not Identity/API key search here) | `CertsFlowDomainService`, `IdentityDomainService`, `RegistrationCacheDomainService` |
| **QueryServices** | Read port | `IUserQueryService` + `UserQueryServiceLinq2Db` |
| **Persistence** | Write port (+ loads exposed as persistence API) | `IRegistrationCachePersistenceService` |
| **Services** | Outbound HTTP / protocol | `ILetsEncryptService` |
**Guideline:** **`GetTable<>` / SQL** only in **`.../Linq2Db`** under Persistence and QueryServices.
---
## Solution map
| Project | Role |
|---------|------|
| **MaksIT.CertsUI** | ASP.NET: controllers, app services, mappers, DI, pipeline. |
| **MaksIT.CertsUI.Engine** | Domain, domain services, persistence, queries, migrations, ACME integration. |
| **MaksIT.Models** | Shared request/response for WebUI + API. |
| **MaksIT.WebUI** | React SPA. |
| **ReverseProxy** | Optional YARP. |
| **\*Tests** | Unit (Engine) and integration (host + DB). |
**Golden rule:** HTTP and status codes stay in the **host**; PostgreSQL and reusable orchestration stay in the **Engine** behind **`DomainServices`** (and persist/query ports).
---
## Folder layout
### `src/MaksIT.CertsUI/`
Host-only: composition, HTTP, auth, thin façades into Engine.
```
MaksIT.CertsUI/
├── Program.cs # Pipeline, DI (AddCertsEngine, filters, hosted services)
├── Configuration.cs # IOptions settings + secrets
├── Controllers/ # Thin; Result → ToActionResult (or Content for ACME)
├── Services/ # App services → IDomainService / IQueryService (search)
├── Mappers/ # Request/Response ↔ domain or Query/ (not Engine persist mappers)
├── Authorization/ # JWT, API key, filters, HttpContext helpers
├── Abstractions/ # e.g. ServiceBase, BaseAsyncAuthorizationFilter
├── HostedServices/ # Scope + engine domain (+ thin ICertsFlowService, etc.); not IPersistenceService on the worker
├── Infrastructure/ # e.g. IRuntimeInstanceIdProvider for HA
└── Properties/
```
*(Omitted from tree: `bin/`, `obj/`, `appsettings*.json`, Docker.)*
### `src/MaksIT.CertsUI.Engine/` (`Engine/` in prose)
```
Engine/
├── Domain/
├── DomainServices/
├── Dto/
├── Data/ # CertsLinq2DbMapping
├── Persistence/
│ ├── Mappers/
│ └── Services/ … Linq2Db/
├── Query/
├── QueryServices/ # I*QueryService + ExpressionCompose; Linq2Db/… implementations
├── Services/ # LetsEncrypt etc. — called from DomainServices
├── Infrastructure/
├── FluentMigrations/
├── RuntimeCoordination/ # HA — see HA_ARCHITECTURE.md
├── Extensions/ # AddCertsEngine
└── Facades/ …
```
**Spelling:** most paths use **`Persistence`** (historic). A few types say **`Persistence`** — keep per-file consistency; do not mass-rename in small PRs.
**Mapper kinds:**
| Where | Maps |
|-------|------|
| **`MaksIT.CertsUI/Mappers`** | API request/response ↔ domain or **`Query/`** |
| **`Engine/Persistence/Mappers`** | Domain ↔ table row / JSON column (`Dto`) |
| **`Engine/QueryServices/.../Linq2Db`** (inline or private helpers) | **`Dto/`** rows / joins → **`Query/`** read models (e.g. `UserQueryResult`) |
---
## As-built routes (quick map)
Each row fits the **spine** above; only **persist vs query** and **skipped** steps differ.
### API + Engine
| Area | Controller | App service | Engine |
|------|------------|-------------|--------|
| ACME steps | `CertsFlowController` | `CertsFlowService` | `ICertsFlowDomainService` |
| ACME token | `WellKnownController` | `CertsFlowService` | `ICertsFlowDomainService``Content(text/plain)` |
| Identity CRUD / login | `IdentityController` | `IdentityService` | `IIdentityDomainService` |
| Identity search | `IdentityController` | `IdentityService` | `IUserQueryService` → mapper |
| API keys | `APIKeyController` | `ApiKeyService` | `IApiKeyDomainService` / `IApiKeyQueryService` |
| 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 |
|------|-------|------|
| Renewal | `AutoRenewal` | Scoped `IRegistrationCacheDomainService`, `ICertsFlowDomainService`, `ICertsFlowService` (calls `FullFlow` / cache load) |
| Agent | `AgentController``AgentService` | Outbound HTTP only; no Engine domain |
| Debug | `DebugController` | `IRuntimeInstanceId`; bypasses app services |
---
## Hard rules
1. **Controllers** → app **`Services/`** only (except trivial debug). No **`IPersistenceService`**, **`IQueryService`**, **`ILetsEncryptService`** on controllers.
2. **App services****`IDomainService`** for use cases; **`CacheService`** → **`IRegistrationCacheDomainService`**. Search may use **`I*QueryService`** (see Identity / API keys).
3. **`Domain/`** → no Persistence, Linq2Db, `HttpClient`, host types.
4. **`DomainServices/`** → ports + Engine **`Services/`**; no raw SQL / `GetTable<>` (only in Linq2Db types).
5. **Persistence Linq2Db****`ICertsDataConnectionFactory`**, **`Dto/`**, **`Data/CertsLinq2DbMapping`**.
6. **Engine JSON** (DB / zip) → **`MaksIT.Core`** `ToJson()` / `ToObject<T>()` (STJ); no new Newtonsoft in Engine.
7. **HTTP status** → host `ToActionResult()`; Engine stays on **`Result`**.
**Variance:** “DomainService on every read” is not required for **thin paged search**; adding a domain wrapper is optional if shared rules appear.
---
## Host details
- **Controllers:** inject **`Services/*`**; **`WellKnownController`** uses **`Content(..., "text/plain")`** for ACME.
- **App services:** inject Engine ports (**`IDomainService`**, **`IQueryService`** when searching), **`IOptions`**, other app services; **call `Mappers/`** for **Request → engine-shaped inputs** and **`Result` / `Query/` / domain → API response models**—keep mapping logic in **`MaksIT.CertsUI/Mappers/`**, orchestration in **`Services/`**.
---
## Dependency injection (Engine)
Central: **`Extensions/ServiceCollectionExtensions.cs`** (`AddCertsEngine`).
- **`ICertsDataConnectionFactory`** — Scoped
- **Persistence / query Linq2Db** — Scoped
- **`ILetsEncryptService`** — typed `HttpClient`
- **`IRegistrationCacheDomainService`** — Scoped
- **`IRuntimeLeaseService`** — Singleton where HA docs say so
Avoid **Scoped** inside **Singleton** without `IServiceScopeFactory`; use scoped persistence for per-request work.
---
## Tests
- **MaksIT.CertsUI.Engine.Tests** — Engine unit tests (no full host).
- **MaksIT.CertsUI.Tests** — Integration tests + PostgreSQL; mirror production DI where possible.
---
## Contributor checklist
1. New **HTTP use case** → controller → app **`Services/`** → **`IDomainService`** (or **`IQueryService`** for thin paging only).
2. New **DB write****`Persistence/Services/I…`** + **`Linq2Db/`** + register; **`DomainServices`** call persistence—not host **`CacheService`** for engine rules.
3. New **read/report****`QueryServices`** + **`Query/`**; either domain service or app service calls query—**match** Identity / API key search.
4. New **table shape****`Dto/`** + **`CertsLinq2DbMapping`**.
5. **JSON column mapping****`Persistence/Mappers`**, not controllers.
6. **API contract****`MaksIT.Models`** + web mappers, not Engine `Dto` unless it is a real table shape.
7. **Invariants****`Domain/`** or **`DomainServices/`**.
8. **OpenAPI / status codes** → host only.
---
## Related docs
- [USER_AND_API_KEY_RBAC.md](./USER_AND_API_KEY_RBAC.md)
- [RBAC_REFERENCE.md](./RBAC_REFERENCE.md)
- [HA_ARCHITECTURE.md](./HA_ARCHITECTURE.md)
- [LOGIN_AND_REFRESH_TOKEN_ARCHITECTURE.md](./LOGIN_AND_REFRESH_TOKEN_ARCHITECTURE.md)
- [REVERSE_PROXY_ROUTING.md](./REVERSE_PROXY_ROUTING.md)
- [PATCH_DELTA_REFERENCE.md](./PATCH_DELTA_REFERENCE.md)
- [POWERSHELL_CLIENT_MODULE.md](./POWERSHELL_CLIENT_MODULE.md)

View File

@ -1,112 +0,0 @@
# High Availability Architecture
This document explains how HA works in `MaksIT.CertsUI` after moving mutable ACME coordination state to PostgreSQL.
## Goals
- Run multiple `server` replicas without ACME race conditions.
- Keep HTTP-01 challenge tokens coherent across replicas.
- Ensure startup/bootstrap and renewal loops do not run in parallel on every pod.
- Expose health endpoints suitable for Kubernetes probes.
## Runtime model
- **Shared source of truth:** PostgreSQL stores ACME sessions, challenge rows, ToS cache, registration caches, and runtime leases.
- **Per-instance identity:** each running server process gets one canonical `InstanceId` (`IRuntimeInstanceId` singleton).
- **Lease holder:** `NewOrderAsync` acquires **AcmeWriter**; startup uses **BootstrapCoordinator**; each renewal sweep uses **RenewalSweep** (see `RuntimeLeaseNames`). All leases are rows in **`app_runtime_leases`** with TTL semantics—no long-lived leader object in the app.
- **Challenge reads:** `/.well-known/acme-challenge/{token}` returns the token value from PostgreSQL (no local ACME directory).
- **Background coordination:** bootstrap and renewal hosted services use named leases to avoid duplicate work.
## Lease design
- Lease table key: `lease_name`.
- Lease owner: `holder_id` (instance id).
- Acquire semantics:
- insert new row if missing;
- steal only when expired;
- renew when current holder matches.
- Release semantics:
- delete only when `lease_name` and `holder_id` both match.
This is implemented as an optimistic single-statement `INSERT ... ON CONFLICT ... DO UPDATE ... WHERE ...` flow in PostgreSQL.
## HTTP-01 coherence design
- `NewOrderAsync` stores challenge tokens in `acme_http_challenges` via `UpsertAsync`.
- Challenge handler (`AcmeChallengeAsync`) reads the token value from the database and returns it as plain text.
- Cleanup: auto-renewal loop calls `DeleteOlderThanAsync(TimeSpan.FromDays(10))`.
## Kubernetes behavior
- 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
- **Agent remains single-instance** by design near edge proxy.
- **Only HTTP-01** challenge type is supported currently.
- Optional split of ACME worker into a dedicated workload is not implemented yet.
## Files involved
### Core coordination contracts
- `src/MaksIT.CertsUI.Engine/RuntimeCoordination/IRuntimeInstanceId.cs`
- `src/MaksIT.CertsUI.Engine/RuntimeCoordination/RuntimeLeaseNames.cs`
- `src/MaksIT.CertsUI.Engine/Infrastructure/IRuntimeLeaseService.cs`
- `src/MaksIT.CertsUI.Engine/Persistence/Services/IAcmeHttpChallengePersistenceService.cs`
### PostgreSQL implementation
- `src/MaksIT.CertsUI.Engine/Infrastructure/RuntimeLeaseServiceNpgsql.cs`
- `src/MaksIT.CertsUI.Engine/Persistence/Services/Linq2Db/AcmeHttpChallengePersistenceServiceLinq2Db.cs`
- `src/MaksIT.CertsUI.Engine/Data/CertsLinq2DbMapping.cs`
- `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`
- `src/MaksIT.CertsUI/HostedServices/InitializationHostedService.cs`
- `src/MaksIT.CertsUI/HostedServices/AutoRenewal.cs`
- `src/MaksIT.CertsUI/Infrastructure/RuntimeInstanceIdProvider.cs`
- `src/MaksIT.CertsUI/Program.cs`
- `src/MaksIT.CertsUI/Controllers/WellKnownController.cs`
- `src/MaksIT.CertsUI/Services/CertsFlowService.cs`
### Helm and deployment wiring
- `src/helm/values.yaml`
- `src/helm/templates/deployments.yaml`
- `src/helm/templates/poddisruptionbudget.yaml`
### Tests
- `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)

View File

@ -1,214 +0,0 @@
# Login and Refresh Token Architecture
This document describes how authentication (login), token refresh, and logout work across the **MaksIT.CertsUI** WebAPI and WebUI.
**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.
---
## 1. Overview
- **Access token:** Short-lived JWT used in the `Authorization: Bearer <token>` header for API calls.
- **Refresh token:** Opaque string stored with the user in PostgreSQL; used to obtain a new access token (and optionally a new refresh token) when the access token expires.
- **Login** returns both tokens; the client stores them and uses the access token until it expires, then calls **refresh** with the refresh token.
- **Logout** revokes the current session (or all sessions) on the server and clears tokens on the client. On Certs, **logout requires** `Authorization: Bearer` with the current access JWT; the server matches that token when removing the session (see §3.4).
---
## 2. Token model
### 2.1 Backend: `JwtToken` (domain)
**Location:** `src/MaksIT.CertsUI/Domain/JwtToken.cs`
| Property | Type | Description |
|----------|------|-------------|
| `Id` | Guid | Token identifier. |
| `Token` | string | The JWT access token string. |
| `TokenType` | string | Typically `"Bearer"`. |
| `IssuedAt` | DateTime | When the token was issued (UTC). |
| `ExpiresAt` | DateTime | When the access token expires. |
| `RefreshToken` | string | Opaque refresh token. |
| `RefreshTokenExpiresAt` | DateTime | When the refresh token expires. |
| `IsRevoked` | bool | If true, token is treated as unusable (revoked entries are removed when resolving refresh). |
- A **User** holds a list of `JwtToken` instances (multiple devices/sessions).
- New tokens are **upserted** via `User.UpsertJwtToken`.
### 2.2 API response: `LoginResponse`
**Location:** `src/MaksIT.Models/LetsEncryptServer/Identity/Login/LoginResponse.cs`
Returned by **login** and **refresh**:
- `TokenType` (e.g. `"Bearer"`)
- `Token` (access JWT)
- `ExpiresAt` (access token expiry)
- `RefreshToken`
- `RefreshTokenExpiresAt`
There is **no** `Username` field on this model; the WebUI derives display name from **JWT claims** when hydrating identity (`identitySlice`).
---
## 3. Backend flow
### 3.1 Layers
| Layer | Component | Responsibility |
|-------|------------|----------------|
| API | `IdentityController` | Login, refresh, logout, and authenticated `PATCH` user. |
| Service | `IdentityService` | Validates credentials, issues JWTs via `JwtGenerator`, maps domain `JwtToken` to `LoginResponse`, persists users via `IUserStore`. |
| Domain | `User` | Password validation, JWT token list (upsert/remove/revoke). |
| Persistence | `IUserStore` | Users and JWT rows persist in PostgreSQL. |
### 3.2 Login
**Endpoint:** `POST /api/identity/login`
**Controller:** `IdentityController.Login``IdentityService.LoginAsync`
1. **Resolve user** by username (`IUserStore.GetByNameAsync`).
2. **Validate password** (`User.ValidatePassword`) with configured **pepper**.
3. **Optional 2FA fields** on `LoginRequest` are **not** validated by Certs—ignored if sent.
4. **Generate** access JWT via `JwtGenerator.TryGenerateToken` (secret, issuer, audience, expiration from config).
5. **Generate** opaque refresh token and build a domain `JwtToken` with access + refresh expiry (`RefreshExpiration` from config).
6. **Upsert** token on user, **SetLastLogin**, **`IIdentityPersistenceService.WriteAsync`**.
7. Return `LoginResponse` (no username field; client uses JWT claims).
**Request body (`LoginRequest`):** `username`, `password`, optional unused `twoFactorCode` / `twoFactorRecoveryCode` (shared model shape only).
### 3.3 Refresh
**Endpoint:** `POST /api/identity/refresh`
**Controller:** `IdentityController.RefreshToken``IdentityService.RefreshTokenAsync`
**Request body (`RefreshTokenRequest`):** `refreshToken` only (`src/MaksIT.Models/LetsEncryptServer/Identity/Login/RefreshTokenRequest.cs`). The WebUI may send a `force` flag for parity with shared thunk code; the Certs API model does **not** include it (extra properties are typically ignored by the serializer).
1. **Resolve user** by refresh token (`IUserStore.GetByRefreshTokenAsync`).
2. **Remove** revoked JWT rows (`RemoveRevokedJwtTokens`).
3. **Find** the token where `RefreshToken` matches.
4. **Unauthorized** if not found → e.g. “Invalid refresh token.”
5. **If the access token is still valid** (`UtcNow <= token.ExpiresAt`): update last login, **`WriteAsync`**, return the **same** `LoginResponse` (no new JWT). This API does not implement a server-side `force` refresh path.
6. **If access expired** but refresh is still valid (`UtcNow <= RefreshTokenExpiresAt`): issue a **new** access JWT + new refresh token, upsert token, save, return new `LoginResponse`.
7. **If refresh is expired**: remove that token record, return **401** “Refresh token has expired.”
### 3.4 Logout
**Endpoint:** `POST /api/identity/logout` (**requires** `JwtAuthorizationFilter` — send `Authorization: Bearer`)
**Controller:** `IdentityController.Logout``IdentityService.Logout`
1. **Resolve user** by **access JWT** from the validated Bearer token (`GetByAccessTokenAsync` / token string from JWT context).
2. If found: **`LogoutFromAllDevices`** → `RevokeAllJwtTokens()`; else → `RemoveJwtToken(accessToken)` for the current session.
3. **`WriteAsync`** if the user was updated.
4. Return success (implementation may still return OK if the token was unknown—clients should clear local state regardless).
**Request body (`LogoutRequest`):** `token` (access JWT, for shared model parity), `logoutFromAllDevices` — the server uses the **Bearer** access token for lookup.
---
## 4. Frontend flow
### 4.1 Identity state and storage
**Redux:** `identitySlice` (`src/MaksIT.WebUI/src/redux/slices/identitySlice.ts`)
- **State:** `identity: Identity | null`, `hydrated: boolean`, `status`, `showUserOffcanvas`.
- **Persistence:** Login/refresh responses are written to **localStorage** via `writeIdentity`; on load, `setIdentityFromLocalStorage` reads and hydrates state and **enriches** from JWT claims (`userId`, `username`, `roles`, `isGlobalAdmin`, `acls` when those claims exist).
**Identity type** extends `LoginResponse` with client-side fields: `userId`, `username`, `roles`, `isGlobalAdmin`, `acls`.
### 4.2 Login UI
**Component:** `LoginScreen` (`src/MaksIT.WebUI/src/components/LoginScreen.tsx`)
- Form: username and password; **2FA** inputs are **commented out** until the backend supports them.
- On submit: `dispatch(login(loginRequest))`.
- On successful login, `identitySlice` stores the response in state and localStorage; `LoginScreen` redirects when identity is present and refresh token is not expired.
### 4.3 Route protection
**Component:** `Authorization` (`src/MaksIT.WebUI/src/components/Authorization.tsx`)
- Wraps protected routes.
- On mount, if not hydrated, dispatches `setIdentityFromLocalStorage()`.
- **When hydrated:** if `identity` is missing or `refreshTokenExpiresAt` is in the past, redirects to `/login` (with `state.from` for return URL).
- Renders children only when hydrated and refresh token is not expired.
**Refresh token** expiry is what forces re-login; the **access** token may expire while refresh is still valid (handled by axios).
### 4.4 Axios: token attachment and refresh
**File:** `src/MaksIT.WebUI/src/axiosConfig.ts`
- **Excluded URLs** (no Bearer token, no refresh loop): login and refresh routes (`GetApiRoute(ApiRoutes.identityLogin).route`, `GetApiRoute(ApiRoutes.identityRefresh).route`).
- **Request interceptor:** If access token is expired but refresh is still valid by client clock, await a single shared `refreshJwt()`; on success attach new `Authorization`; on failure dispatch `clearIdentity()` and reject (do not send protected calls with an expired access token).
- **Response interceptor:** On **401**, optionally retry once after refresh when refresh is still valid; on refresh failure, `clearIdentity()`.
- **Serialization:** `isRefreshing` / `refreshPromise` so concurrent requests share one refresh.
### 4.5 Async thunks and clearIdentity
- **`login`:** POST login; on success writes identity and enriches from JWT.
- **`refreshJwt(force?)`:** POST refresh with `refreshToken` (and optional `force` in body for shared code paths; server ignores `force` on Certs). On failure, identity cleared.
- **`logout`:** POST logout with access token in body, then clear state/localStorage.
- **`clearIdentity()`:** Clears Redux and localStorage without calling logout API (used when refresh fails).
---
## 5. API summary
| Method | Endpoint | Bearer required | Purpose |
|--------|----------|-----------------|--------|
| POST | `/api/identity/login` | No | Login; returns access + refresh token. |
| POST | `/api/identity/refresh` | No | Exchange refresh token for same or new tokens. |
| POST | `/api/identity/logout` | Yes | Revoke session(s); Bearer identifies the access token to remove. |
Other identity routes (e.g. `PATCH /api/identity/user/{id}`) use `JwtAuthorizationFilter` and require a valid JWT.
Base route: `api/identity` (`IdentityController`, `AppMap`).
---
## 6. Sequence overview
**Login:** User submits credentials → POST `/api/identity/login` → user row updated with new `JwtToken` in PostgreSQL → WebUI stores identity → redirect into app.
**Authenticated request (access token valid):** Interceptor adds `Authorization: Bearer` → API validates JWT.
**Access expired, refresh valid:** Interceptor awaits `refreshJwt()` → POST `/api/identity/refresh` → updated identity → original request retried with new token.
**401 on protected request:** Response interceptor attempts refresh; if refresh returns 401, `clearIdentity()` and redirect to `/login`.
**Logout:** POST `/api/identity/logout` with `Authorization: Bearer` and body `{ token, logoutFromAllDevices }` → server removes token(s) from the user row → client clears storage.
Use Certs resources in examples (e.g. **accounts**, **certificate flows**): no protected API should run after refresh has failed without clearing identity.
---
## 7. Security notes
- **Passwords** use salt + server-side **pepper**; not stored in plain text.
- **Refresh tokens** are stored per user in PostgreSQL; expiry and invalidation are enforced in `IdentityService.RefreshTokenAsync`.
- **2FA** is **not** implemented on the Certs WebAPI; do not enable the WebUI 2FA fields until backend support exists.
- **Login/refresh** do not require Bearer; **logout** and other protected identity routes use `JwtAuthorizationFilter`.
- Frontend keeps **one** identity in localStorage; refresh is serialized to avoid duplicate refresh storms.
---
## 8. Key files reference
| Area | File |
|------|------|
| Domain User | `src/MaksIT.CertsUI/Domain/User.cs` |
| Domain JwtToken | `src/MaksIT.CertsUI/Domain/JwtToken.cs` |
| API service | `src/MaksIT.CertsUI/Services/IdentityService.cs` |
| API controller | `src/MaksIT.CertsUI/Controllers/IdentityController.cs` |
| API models | `src/MaksIT.Models/LetsEncryptServer/Identity/Login/`, `.../Logout/` |
| Frontend state | `src/MaksIT.WebUI/src/redux/slices/identitySlice.ts` |
| Frontend HTTP | `src/MaksIT.WebUI/src/axiosConfig.ts` |
| Frontend routes | `src/MaksIT.WebUI/src/components/Authorization.tsx` |
| Frontend login UI | `src/MaksIT.WebUI/src/components/LoginScreen.tsx` |
| Frontend API map | `src/MaksIT.WebUI/src/AppMap.tsx` |

View File

@ -1,251 +0,0 @@
# PATCH Delta Handling Backend & Frontend Reference
This document is the **single reference** for how PATCH payloads (deltas) are structured and interpreted so backend (BE) and frontend (FE) stay consistent. It follows the **MaksIT.Core** patch contract (same rules as shared **`deepDelta`** helpers). **MaksIT-CertsUI** uses that model with a **hostnames** collection on account PATCH.
**Audience:** Backend (C# / ASP.NET) and Frontend (TypeScript / React) developers.
---
## TL;DR (start here)
- **PATCH** sends only **what changed**, not the full resource. Each change is tagged with an **operation** (set, remove, add item, remove item).
- **Root fields** (e.g. `description`, `contact`): send new value + `operations["fieldName"] = SetField` or `RemoveField`.
- **Collections** (e.g. `hostnames`): **do not** replace the whole array when the API is “patchable collection” semantics. Send **per-item** changes: each added item has `operations.collectionItemOperation = AddToCollection`, each removed item has `RemoveFromCollection`, and changed items send identity and changed fields.
- **Frontend (Certs WebUI):** For **Edit Account**, use
`deepDelta(formState, backupState, { arrays: { hostnames: { identityKey: 'hostname', idFieldKey: 'hostname' } } })`
so hostname rows are itemized (including “add first hostname”) and stay in sync with the backend.
- **Backend:** Use `TryGetOperation(Constants.CollectionItemOperation, out var op)` on each collection item; never treat root `Operations["hostnames"] = SetField` as “replace all” if the API follows per-item patch semantics.
---
## 1. Core contract (MaksIT.Core)
The following come from **MaksIT.Core** and must be respected by all consumers.
### 1.1 PatchOperation enum
| Value | Integer | Meaning |
|-------|---------|--------|
| `SetField` | 0 | Set or replace a scalar or root-level value |
| `RemoveField` | 1 | Set a field to null |
| `AddToCollection` | 2 | Add an item to a collection (used on **collection items**, not root) |
| `RemoveFromCollection` | 3 | Remove an item from a collection (used on **collection items**, not root) |
- **Source:** `MaksIT.Core.Webapi.Models.PatchOperation`
- **FE mirror:** `PatchOperation` enum in WebUI (`src/MaksIT.WebUI/src/models/PatchOperation.ts`) must keep the same numeric values for JSON serialization.
### 1.2 PatchRequestModelBase
- **Operations:** `Dictionary<string, PatchOperation>?` (C#) / `{ [key: string]: PatchOperation }` (TS).
- **Lookup:** Case-insensitive by **property name** (e.g. `"hostnames"`, `"description"`).
- **Usage:**
- **Root level:** `Operations["propertyName"]` describes the operation for that property (e.g. `SetField` for a changed field, `RemoveField` for null).
- **Collection items:** Each element of a collection property is itself a patch model; it uses a **reserved key** (see below) to indicate add/remove/update for that item.
### 1.3 Collection item operation key
For **elements inside a collection property** (e.g. each item in `hostnames`), the operation is stored under a fixed key so the backend can distinguish “add/remove this item” from “update fields of this item”.
- **Key name:** `collectionItemOperation`
- **BE:** `Constants.CollectionItemOperation` (same string across MaksIT services when aligned with Core).
- **FE:** `COLLECTION_ITEM_OPERATION` in `src/MaksIT.WebUI/src/models/PatchOperation.ts`; same string in payloads and in `deepDelta`. Keep in sync with backend.
**Allowed values for collection items:** `AddToCollection` (2), `RemoveFromCollection` (3). For in-place updates (same item, changed fields), the item typically has an `id` and no `collectionItemOperation`, or field-level changes follow your APIs semantics.
---
## 2. Backend (BE) rules
### 2.1 Root-level properties
- Read `request.TryGetOperation(propertyName, out var op)`.
- If `op == SetField`: apply the new value from the request for that property.
- If `op == RemoveField`: set the property to null (or clear it) where applicable.
- If the property is not in `Operations` but is present with a value, treat as optional direct assignment (or ignore), depending on your API convention; for strict PATCH, prefer requiring operations for changed fields.
### 2.2 Collection properties (e.g. hostnames)
- **Rule:** Collection properties are patched **only via per-item operations** when the API follows patchable-collection semantics. The backend does **not** treat root `Operations["hostnames"] = SetField` as “replace the entire collection” unless explicitly documented for that endpoint.
- For each item in the collection payload:
1. Call `item.TryGetOperation(Constants.CollectionItemOperation, out var collectionOp)` (or the agreed constant).
2. If `collectionOp == AddToCollection`: add the item to the collection (merge by id if present, or append).
3. If `collectionOp == RemoveFromCollection`: remove the item (by `item.Id` or by matching key fields such as hostname string).
4. If no `collectionItemOperation` but the item is identifiable: treat as **in-place update**.
5. If no `collectionItemOperation` and the item cannot be identified: **do not add** ambiguous items; the FE must send `AddToCollection` for new rows when required by your rules.
### 2.3 Consistency checklist (BE)
- [ ] Use the same `CollectionItemOperation` key as the FE (see Constants / Core).
- [ ] Do not rely on root-level `SetField` for patchable collections to mean “replace all”; use per-item add/remove/update only (unless documented otherwise).
- [ ] For add: require `TryGetOperation(CollectionItemOperation) == AddToCollection` (or equivalent) for new items where applicable.
- [ ] For remove: use `RemoveFromCollection` and/or identity fields agreed with the FE.
---
## 3. Frontend (FE) rules
### 3.1 Building the delta (deepDelta)
- **Scalar / root fields:** Emit the new value and set `operations[propertyName] = SetField` or `RemoveField` as appropriate.
- **Primitive arrays:** Emit the full array and `operations[propertyName] = SetField` (full replace).
- **Object arrays that are “patchable collections”:** Must always produce **itemized deltas** when configured with an **array policy** (identity key / id field):
- Each added item must have `operations.collectionItemOperation = AddToCollection`.
- Each removed item must have `operations.collectionItemOperation = RemoveFromCollection`.
- Updated items carry changed fields and identity; see `deepDelta` implementation in `src/MaksIT.WebUI/src/functions/deep/deepDelta.ts`.
### 3.2 Patchable collections identity requirement
For the backend to interpret add/remove/update correctly, each collection **item** must be identifiable:
- **Existing items:** Use `id` from the server when present.
- **New items:** May have no server `id`; the FE must pass an **array policy** with `identityKey` / `idFieldKey` so the delta stays itemized (e.g. hostname string as stable key for hostnames).
### 3.3 Shared array policies (this repository)
**MaksIT-CertsUI** does **not** ship a shared `patchCollectionPolicies.ts`; the **Edit Account** form passes an **inline** policy for the `hostnames` collection:
| Collection | Policy (inline) | Used in |
|------------|-----------------|---------|
| `hostnames` | `{ identityKey: 'hostname', idFieldKey: 'hostname' }` | `EditAccount.tsx` |
Example:
```ts
deepDelta(fromFormState, fromBackupState, {
arrays: {
hostnames: {
identityKey: 'hostname',
idFieldKey: 'hostname',
},
},
})
```
### 3.4 Consistency checklist (FE)
- [ ] Use the same `PatchOperation` numeric values as Core (SetField 0, RemoveField 1, AddToCollection 2, RemoveFromCollection 3).
- [ ] Use `COLLECTION_ITEM_OPERATION` from `PatchOperation.ts` (same string as backend `Constants.CollectionItemOperation`).
- [ ] For account `hostnames`, pass the `hostnames` array policy in `deepDelta` so the delta is **itemized**, not a blind full-replace of the array.
- [ ] New items must have `operations.collectionItemOperation = AddToCollection` when the backend expects it.
---
## 4. Payload examples
### 4.1 Root-level SetField (scalar)
```json
{
"description": "Updated",
"operations": {
"description": 0
}
}
```
`0` = SetField.
### 4.2 Root-level RemoveField (clear optional field)
```json
{
"operations": { "someOptionalField": 1 }
}
```
`1` = RemoveField.
### 4.3 Root-level SetField (primitive array full replace)
```json
{
"tags": ["a", "b", "c"],
"operations": {
"tags": 0
}
}
```
### 4.4 Collection property itemized (add items)
Example shape for new hostname rows (numeric ops match `PatchOperation` enum):
```json
{
"hostnames": [
{
"hostname": "api.example.com",
"isDisabled": false,
"operations": { "collectionItemOperation": 2 }
}
]
}
```
`2` = AddToCollection.
### 4.5 Collection property remove item
```json
{
"hostnames": [
{
"hostname": "old.example.com",
"operations": { "collectionItemOperation": 3 }
}
]
}
```
`3` = RemoveFromCollection (identity may be `hostname` or server `id` depending on API).
### 4.6 Collection property in-place update
Item exists; fields change; no `collectionItemOperation` on the item (or only nested field operations per your model).
---
## 5. Quick reference
| Aspect | Backend | Frontend |
|--------|---------|----------|
| Operation enum | `PatchOperation` (Core) | `PatchOperation` (same values 03) |
| Root operations | `TryGetOperation(propertyName, out op)` | `operations[propertyName] = op` |
| Collection item key | `Constants.CollectionItemOperation` (`"collectionItemOperation"`) | `COLLECTION_ITEM_OPERATION` in payload and deepDelta |
| New collection item | Require `AddToCollection` on item when applicable | Send `operations.collectionItemOperation: 2` for new items |
| Certs account hostnames | Per-item patch semantics | `deepDelta` + `hostnames` array policy in `EditAccount.tsx` |
---
## 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)
---
## 7. Current implementation vs reference (MaksIT-CertsUI)
### 7.1 Frontend (FE)
**Checked:** `deepDelta` usage in `src/MaksIT.WebUI/src/forms/EditAccount.tsx` for account PATCH; inline `hostnames` array policy; `COLLECTION_ITEM_OPERATION` in `PatchOperation.ts` and usage in `deepDelta.ts`.
- **Safe:** Hostname rows use `identityKey` / `idFieldKey` so itemized deltas include `AddToCollection` / `RemoveFromCollection` where appropriate.
- **Consistent:** Same `PatchOperation` values and collection-item key string as Core.
**FE summary:** Follows the shared reference; account PATCH covers **account fields and hostnames**.
### 7.2 Backend (BE)
**Maintainers:** Confirm the account PATCH handler in MaksIT.CertsUI WebAPI applies the same per-item rules (`TryGetOperation(CollectionItemOperation, ...)`) for `hostnames` as described in sections 2.2 and 3.
### 7.3 Gaps and maintenance
| Topic | Status | Note |
|-------|--------|------|
| Shared policies file | N/A in Certs | Inline policy in `EditAccount.tsx` for `hostnames`. |
| New forms with patchable collections | **Ongoing** | When adding a form that patches a collection, pass the correct `arrays: { key: policy }` to `deepDelta`. |
---
*Last updated: 2026-04-12*

View File

@ -1,79 +0,0 @@
# PowerShell client module (`MaksIT.CertsUI.Client.PowerShell`)
PowerShell module that exposes the **MaksIT CertsUI API** via custom cmdlets, built on **MaksIT.CertsUI.Client** (C# / .NET).
**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)
---
## Requirements
- **Latest [PowerShell 7](https://github.com/PowerShell/PowerShell/releases)** with a **.NET 10** host. **pwsh 7.4 on .NET 8** cannot load this **`net10.0`** module.
```powershell
[System.Runtime.InteropServices.RuntimeInformation]::FrameworkDescription
# expect: .NET 10.0.x
```
## Installation
1. Build the solution (or the `MaksIT.CertsUI.Client.PowerShell` project).
2. Import from build output:
```powershell
Import-Module .\src\MaksIT.CertsUI.Client.PowerShell\bin\Debug\net10.0\MaksIT.CertsUI.Client.PowerShell.psd1 -Force
```
## Usage
1. **Connect** (base URL is the public ingress or YARP root, e.g. `http://localhost:8080` — no `/api` suffix):
```powershell
Connect-CertsUI -BaseAddress "http://localhost:8080" -ApiKey "your-api-key"
```
2. **Health and accounts:**
```powershell
Test-CertsUIHealth
Get-CertsUIAccounts
Get-CertsUIAccount -AccountId <guid>
```
3. **HA / load-balancer check:**
```powershell
Get-CertsUIRuntimeInstanceId
```
4. **Create / patch / delete account** (create runs the full ACME flow — use staging for tests):
```powershell
Invoke-CertsUICreateAccount -Description "e2e" -Contacts "mailto:a@b" -ChallengeType "http-01" -Hostnames "example.com" -IsStaging -AgreeToS
Invoke-CertsUIPatchAccount -AccountId <guid> -Description "updated"
Invoke-CertsUIDeleteAccount -AccountId <guid>
```
5. **Disconnect:**
```powershell
Disconnect-CertsUI
```
## Cmdlets
| Cmdlet | Description |
|--------|-------------|
| `Connect-CertsUI` | Set base URL and API key for the session |
| `Disconnect-CertsUI` | Clear session |
| `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` |
| `Invoke-CertsUICreateAccount` | `POST /api/account` |
| `Invoke-CertsUIPatchAccount` | `PATCH /api/account/{id}` |
| `Invoke-CertsUIDeleteAccount` | `DELETE /api/account/{id}` |
## E2E scenarios
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).

View File

@ -1,167 +0,0 @@
# 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 | Nonglobal-admin |
|------|--------------|------------------|
| **Search users / user scopes** | All (minus self in user search) | Only users whose org ids ⊆ actors 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 | Nonglobal-admin JWT | Nonglobal-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 | Nonglobal-admin |
|------|--------------|------------------|
| **Search keys / key scopes** | All | Keys whose org ids ⊆ actors 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 **keys** scopes and **`IsGlobalAdmin` on the key** — not the human operators 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) §12, [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 repos RBAC lambdas.
---
*Last updated: May 2026*

View File

@ -1,69 +0,0 @@
# Reverse proxy routing (YARP)
The **`ReverseProxy`** project ([`src/ReverseProxy`](../../src/ReverseProxy)) is an **ASP.NET + YARP** edge that listens on **port 8080** in Docker Compose (`docker-compose.override.yml` maps `8080:8080`).
**Config:** [`src/ReverseProxy/appsettings.json`](../../src/ReverseProxy/appsettings.json)
**Certs routing:** **`/.well-known/acme-challenge/`** is matched **before** the SPA catch-all so HTTP-01 challenges reach the WebAPI on the **same public host** as the UI.
---
## Route table
Routes use explicit **`Order`** (lower = matched first) so the SPA catch-all never wins over `/api`, `/swagger`, or `/.well-known/` when JSON key order varies.
Compose service names are **`server`** (WebAPI) and **`client`** (Vite/WebUI). Cluster IDs **`webapiCluster`** / **`webuiCluster`** reference those upstreams.
| Order | Path match | Cluster | Upstream (Compose) |
|------|------------|---------|---------------------|
| 5 | `/.well-known/acme-challenge/{**catch-all}` | `webapiCluster` | `http://server:5000/` |
| 10 | `/swagger/{**catch-all}` | `webapiCluster` | `http://server:5000/` |
| 20 | `/api/{**catch-all}` | `webapiCluster` | `http://server:5000/` |
| 1000 | `/{**catch-all}` | `webuiCluster` | `http://client:5173/` |
YARP forwards the **same path** to the destination. Example:
- Client: `POST http://localhost:8080/api/identity/login`
- Proxied to: `POST http://server:5000/api/identity/login`
Controllers use the usual **`/api/...`** prefix (e.g. `api/identity`, account and certificate flows)—there is **no** `api/vault`-style segment. Locally, the Web UI uses `public/config.js` / `.env` with `http://localhost:8080/api` so XHR calls go **through** YARP.
### HTTP-01 (Lets Encrypt)
Traffic for **`/.well-known/acme-challenge/*`** must reach **MaksIT.CertsUI** so the HTTP-01 validator can fetch the token body from the API (backed by PostgreSQL). The dedicated route sends that path to the **`server`** service (same `webapiCluster` as `/api`).
### Kubernetes (Helm)
The chart can mount **`config.js`** from a ConfigMap (`certsClientRuntime.apiUrl`). Defaults in `values.yaml` may use a full origin (example host); you can also use a **relative** API base such as **`/api`** so the browser uses the same host and port as the page (ingress / port-forward to **8080** on the reverse-proxy Service) without hard-coding `localhost`. Use a full URL only if the UI and API are served from different origins.
---
## Automation and clients
- **Base URL** for scripts, the Agent, or any HTTP client talking to the **composed** stack should be the **proxy origin**: `http://localhost:8080` when you use Composes published port.
- **Path shape:** Call **`/api/...`** on that origin (either concatenate `BaseAddress` + `api/...` or set `VITE_API_URL` / runtime `API_URL` to `http://localhost:8080/api` so the client already includes `/api`).
- **YARP** forwards request headers to the WebAPI by default (including **`Authorization: Bearer`** for JWT). No special transform is required unless you customize YARP transforms.
---
## Direct vs proxied ports (local dev)
| Scenario | Typical base URL for Certs HTTP API |
|----------|-------------------------------------|
| Docker Compose (this repo) | `http://localhost:8080` (through YARP) |
| Run **MaksIT.CertsUI** only (F5 / `dotnet run`) | See `launchSettings.json` (e.g. `http://localhost:5016`) — **no** YARP |
| Run **ReverseProxy** only (outside Compose) | `launchSettings`: e.g. `http://localhost:5276` — cluster addresses in `appsettings.json` must resolve (Compose service names only work **inside** the Compose network) |
If authentication succeeds but API calls fail, confirm traffic reaches the **same** WebAPI instance and data volume you expect (not a different port or stale container).
---
## 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)
- [`src/ReverseProxy/Program.cs`](../../src/ReverseProxy/Program.cs)
- WebUI runtime API base: [`src/MaksIT.WebUI/public/config.js`](../../src/MaksIT.WebUI/public/config.js), Helm `certsClientRuntime.apiUrl` in [`src/helm/values.yaml`](../../src/helm/values.yaml)

View File

@ -1,149 +0,0 @@
# User (JWT) and API Key RBAC
This document explains how **role-based access control** differs when the caller is a **logged-in user** (JWT) versus an **API key**, how those principals are loaded, and where the rules live in code.
**Audience:** Backend developers, security reviewers, and anyone automating against the CertsUI API (WebUI, PowerShell module, `MaksIT.CertsUI.Client`).
**Related:** [Login and refresh token architecture](./LOGIN_AND_REFRESH_TOKEN_ARCHITECTURE.md) (how JWTs are issued). [RBAC reference](./RBAC_REFERENCE.md) has scope flags and permission matrices. **This** file covers JWT vs API key principals, routes, `GetActingJwtTokenData`, and code references.
---
## 1. Two authentication mechanisms
| Mechanism | Header | Typical use |
|-----------|--------|-------------|
| **User (JWT)** | `Authorization: Bearer <access_token>` | Browser (WebUI), interactive tools, services that can refresh tokens |
| **API key** | `X-API-KEY: <secret>` | Scripts, CI, PowerShell (`Connect-CertsUI`), `MaksIT.CertsUI.Client`; long-lived secret shown once at creation |
**Request resolution:** `CertsUIAuthorizationFilter` (`src/MaksIT.CertsUI/Authorization/Filters/CertsUIAuthorizationFilter.cs`) checks **`X-API-KEY` first**. If that header is absent or invalid, it falls back to **Bearer JWT**. If neither yields a valid principal, the request gets **403 Forbidden** (`Authorization required`).
**Unlike MaksIT.Vault:** Vault uses **`JwtAuthorizationFilter`** on `/api/identity/...` and `/api/apikey/...` (JWT only) and **`VaultAuthorizationFilter`** on `/api/vault/...` (JWT or API key). In **CertsUI**, every protected controller action listed below uses **`CertsUIAuthorizationFilter`** — the same dual path for **identity**, **API keys**, **accounts**, **ACME flow**, and **cache**.
| Route family | Filter | JWT | `X-API-KEY` |
|--------------|--------|-----|-------------|
| `/api/identity/...` (except login / refresh) | `CertsUIAuthorizationFilter` | Yes | Yes |
| `/api/apikey/...` | `CertsUIAuthorizationFilter` | Yes | Yes |
| `/api/account/...`, `/api/certs/...`, `/api/cache/...`, `/api/agent/...`, `/api/debug/...` | `CertsUIAuthorizationFilter` | Yes | Yes |
**Exceptions (no JWT / API key on the action):**
- `POST /api/identity/login` and `POST /api/identity/refresh` — anonymous.
- `GET /.well-known/acme-challenge/{token}` (`WellKnownController`) — no authorization filter; public HTTP-01 challenge responses (see [REVERSE_PROXY_ROUTING.md](./REVERSE_PROXY_ROUTING.md)).
---
## 2. Two principal types (what RBAC sees)
After authentication, the filter stores **`CertsUIAuthorizationData`** on `HttpContext` (`HttpContextValue.CertsUIAuthorizationData`).
| Principal | Type | Where it comes from |
|-----------|------|---------------------|
| **User** | `JwtTokenData` | Validated JWT; includes `UserId`, `IsGlobalAdmin`, and **entity scopes** from the user record |
| **API key** | `ApiKeyData` | Key row by secret + **`ReadApiKeyAuthorization`**; includes `ApiKeyId`, `IsGlobalAdmin` **for that key**, and **entity scopes** stored for the key |
Services that manage users or API keys do not take `CertsUIAuthorizationData` directly on every method. Controllers call **`GetActingJwtTokenData()`**, which:
- Returns the real **`JwtTokenData`** for Bearer sessions.
- Maps an API key to a **synthetic** `JwtTokenData` (`UserId = Guid.Empty`, `Username = apikey:{id}`, scopes and `IsGlobalAdmin` from the key).
So identity and API-key **admin** code paths are written against **`JwtTokenData`**, but the acting principal may be a **user** or an **API key** after mapping.
### 2.1 Global administrator: user vs key (easy to confuse)
- **`JwtTokenData.IsGlobalAdmin`** — the **signed-in user** is a global administrator (or the key was mapped with this flag).
- **`ApiKeyData.IsGlobalAdmin`** — **this API key** was created (or patched) with the global-administrator flag.
These are **independent**. A global-admin **user** who creates an API key does **not** automatically create a global-admin key unless the create request sets **`IsGlobalAdmin: true`** on the key (UI: separate checkbox). Automation that must bypass scoped limits on **accounts / ACME** needs a **global-admin API key**, not merely a key created by an admin user.
**Who may set `IsGlobalAdmin` on a key?** Only a principal that is already a **global administrator** after `GetActingJwtTokenData()` may **create** or **patch** a user or API key with `IsGlobalAdmin` enabled (`RbacHelpers.EnsureActorMayAssignGlobalAdmin` / `EnsureActorMayPatchGlobalAdminFlag` in `IdentityService` / `ApiKeyService`).
### 2.2 Loading API key authorization
For API key requests, `IsGlobalAdmin` and scopes come from persisted authorization data. If **`ReadApiKeyAuthorization`** fails after the key itself was found, the filter **propagates that failure** (it does **not** continue with `IsGlobalAdmin: false`), so misconfiguration or storage errors are not silently downgraded to “non-admin key” behavior.
Expired keys (`ExpiresAt <= UtcNow`) are rejected in the filter before authorization is loaded.
---
## 3. Shared RBAC helpers (`ServiceBase`)
App services inherit from `ServiceBase` (`src/MaksIT.CertsUI/Abstractions/Services/ServiceBase.cs`), which implements:
- **`RBACWrapper`** — If the request used JWT, runs **`RBACWrapperJwtToken`** with `userRules`; if API key, runs **`RBACWrapperApiKey`** with `apiKeyRules`.
- **`RBACWrapperJwtToken` / `RBACWrapperApiKey`** — If `IsGlobalAdmin` is **true** for that principal, return **success immediately** (full access for that wrapper). Otherwise run the supplied rules delegate, or **403** if no rules were supplied.
So: **`userRules` / `apiKeyRules` = null** means **only global administrators** (JWT or API key, respectively) pass. Non-admins always get **forbidden** with no per-scope check.
Variants **`RBACWrapper<T>`** / **`RBACWrapperJwtToken<T>`** / **`RBACWrapperApiKey<T>`** follow the same pattern but carry a resource through the rules.
**Identity / API key services** use **`RBACWrapperJwtToken`** only (after `GetActingJwtTokenData()`), including when the caller used an API key.
---
## 4. Example: accounts and ACME (`AccountService`, `CertsFlowService`)
Illustrates **authentication without scoped RBAC** today.
| Action | RBAC pattern | Nonglobal-admin JWT | Nonglobal-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 keys scopes (or global admin) allow it.
Summary (matrices and enforcement detail: [RBAC reference](./RBAC_REFERENCE.md)):
| Operation | Global-admin actor | Nonglobal-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*

View File

@ -1,5 +1,15 @@
# MaksIT.CertsUI.Client.PowerShell # MaksIT.CertsUI.Client.PowerShell
C# binary module with custom cmdlets for the CertsUI HTTP API. PowerShell module for the CertsUI HTTP API (built on **MaksIT.CertsUI.Client**).
**Documentation:** [PowerShell client module](../../assets/docs/POWERSHELL_CLIENT_MODULE.md) **Requirements:** PowerShell 7 on **.NET 10**. **Cmdlet help:** `Get-Help Connect-CertsUI -Full` (and other exported commands) after `Import-Module`. **E2E:** [src/e2e-tests/README.md](../e2e-tests/README.md).
**Quick start:**
```powershell
Import-Module .\bin\Debug\net10.0\MaksIT.CertsUI.Client.PowerShell.psd1 -Force
Connect-CertsUI -BaseAddress "http://localhost:8080" -ApiKey "<key>"
Test-CertsUIHealth
```
Requires **PowerShell 7** with a **.NET 10** host.

View File

@ -19,7 +19,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="MaksIT.Core" Version="1.6.5" /> <PackageReference Include="MaksIT.Core" Version="1.6.7" />
<ProjectReference Include="..\MaksIT.CertsUI.Client\MaksIT.CertsUI.Client.csproj" /> <ProjectReference Include="..\MaksIT.CertsUI.Client\MaksIT.CertsUI.Client.csproj" />
<ProjectReference Include="..\MaksIT.CertsUI.Contracts\MaksIT.CertsUI.Contracts.csproj" /> <ProjectReference Include="..\MaksIT.CertsUI.Contracts\MaksIT.CertsUI.Contracts.csproj" />
</ItemGroup> </ItemGroup>

View File

@ -14,9 +14,9 @@
<PackageReference Include="FluentMigrator.Runner.Postgres" Version="8.0.1" /> <PackageReference Include="FluentMigrator.Runner.Postgres" Version="8.0.1" />
<PackageReference Include="linq2db" Version="6.3.0" /> <PackageReference Include="linq2db" Version="6.3.0" />
<PackageReference Include="linq2db.PostgreSQL" Version="6.3.0" /> <PackageReference Include="linq2db.PostgreSQL" Version="6.3.0" />
<PackageReference Include="MaksIT.Core" Version="1.6.5" /> <PackageReference Include="MaksIT.Core" Version="1.6.7" />
<PackageReference Include="MaksIT.Dapr" Version="2.0.0" /> <PackageReference Include="MaksIT.Dapr" Version="2.0.0" />
<PackageReference Include="MaksIT.Results" Version="2.0.1" /> <PackageReference Include="MaksIT.Results" Version="2.0.2" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.8" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.8" /> <PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.8" /> <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.8" />

View File

@ -2,7 +2,7 @@
<PropertyGroup> <PropertyGroup>
<DefaultItemExcludes>$(DefaultItemExcludes);Models\obj\**;Models\bin\**</DefaultItemExcludes> <DefaultItemExcludes>$(DefaultItemExcludes);Models\obj\**;Models\bin\**</DefaultItemExcludes>
<Version>3.5.1</Version> <Version>3.5.2</Version>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS> <DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<DockerComposeProjectPath>..\docker-compose.dcproj</DockerComposeProjectPath> <DockerComposeProjectPath>..\docker-compose.dcproj</DockerComposeProjectPath>
@ -10,9 +10,9 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="MaksIT.Core" Version="1.6.5" /> <PackageReference Include="MaksIT.Core" Version="1.6.7" />
<PackageReference Include="MaksIT.Dapr" Version="2.0.0" /> <PackageReference Include="MaksIT.Dapr" Version="2.0.0" />
<PackageReference Include="MaksIT.Results" Version="2.0.1" /> <PackageReference Include="MaksIT.Results" Version="2.0.2" />
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.23.0" /> <PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.23.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.1" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.1" />
</ItemGroup> </ItemGroup>

View File

@ -1,89 +1,28 @@
# CertsUI API key E2E (PowerShell) # CertsUI E2E tests (PowerShell)
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. API-key end-to-end tests against a running CertsUI deployment via **MaksIT.CertsUI.Client.PowerShell**.
Cmdlet reference and `Import-Module` paths: [assets/docs/POWERSHELL_CLIENT_MODULE.md](../../assets/docs/POWERSHELL_CLIENT_MODULE.md). **Module quick start:** [MaksIT.CertsUI.Client.PowerShell/README.md](../MaksIT.CertsUI.Client.PowerShell/README.md).
Requires **latest PowerShell 7** with a **.NET 10** host (install from [PowerShell releases](https://github.com/PowerShell/PowerShell/releases)). **pwsh 7.4 / .NET 8** cannot load the `net10.0` module. Verify: `[System.Runtime.InteropServices.RuntimeInformation]::FrameworkDescription`**.NET 10.x**.
**Docker Compose + YARP on `http://localhost:8080`:** use `http://localhost:8080` as the base URL (no `/api` suffix).
## Credentials ## Credentials
Uses **one** environment variable: **`CERTSUI_E2E_CREDENTIALS`** — UTF-8 text, Base64-encoded. Set **`CERTSUI_E2E_CREDENTIALS`** — Base64 of UTF-8 `<baseUrl><US><apiKey>` (`<US>` = ASCII unit separator, `[char]0x1F`):
The script reads it with **`[Environment]::GetEnvironmentVariable`** in order **Process**, **User**, then **Machine** (same as Vault E2E).
After Base64 decode, the payload is a **single line**: `<baseUrl><US><apiKey>` where **`<US>`** is ASCII Unit Separator, U+001F (`[char]0x1F`).
Encode in pwsh:
```powershell
$us = [char]0x1F # required on its own line before $b64
$b64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes("http://localhost:8080$us" + 'paste-raw-api-key-here'))
$b64
```
Persist for your **user** account:
```powershell
[Environment]::SetEnvironmentVariable('CERTSUI_E2E_CREDENTIALS', '<paste-base64-here>', 'User')
```
Or only for the **current process**:
```powershell
$env:CERTSUI_E2E_CREDENTIALS = '<paste-base64-here>'
```
### JWT credentials (optional)
Identity admin scenarios use the **global admin API key** from `CERTSUI_E2E_CREDENTIALS` (`X-API-KEY`) when the server supports it. Optionally set **`CERTSUI_E2E_JWT_CREDENTIALS`** — same encoding, payload `<adminUsername><US><password>` — for JWT-only probes (e.g. scoped-user login).
```powershell ```powershell
$us = [char]0x1F $us = [char]0x1F
$b64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes("admin$us" + 'your-admin-password')) $b64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes("http://localhost:8080$us" + 'your-api-key'))
[Environment]::SetEnvironmentVariable('CERTSUI_E2E_JWT_CREDENTIALS', $b64, 'User') [Environment]::SetEnvironmentVariable('CERTSUI_E2E_CREDENTIALS', $b64, 'User')
``` ```
Optional **`CERTSUI_E2E_JWT_CREDENTIALS`** — same encoding, payload `<adminUsername><US><password>` — for JWT-only identity probes.
## Run ## Run
```powershell ```powershell
pwsh -File .\src\e2e-tests\Test-CertsUiApiKeyE2E.ps1 pwsh -File .\src\e2e-tests\Test-CertsUiApiKeyE2E.ps1
```
Or run `src\e2e-tests\Test-CertsUiApiKeyE2E.bat` after credentials are set.
Filter scenarios:
```powershell
pwsh -File .\src\e2e-tests\Test-CertsUiApiKeyE2E.ps1 -Scenario Health pwsh -File .\src\e2e-tests\Test-CertsUiApiKeyE2E.ps1 -Scenario Health
pwsh -File .\src\e2e-tests\Test-CertsUiApiKeyE2E.ps1 -Scenario AccountReadPatch
``` ```
## Environment variables Or `src\e2e-tests\Test-CertsUiApiKeyE2E.bat` after credentials are set.
| Variable | Purpose | E2E is **not** run in CI. Requires **PowerShell 7** on **.NET 10** (see script error text if the host is wrong).
|----------|---------|
| `CERTSUI_E2E_CREDENTIALS` | Required — Base64 `<baseUrl><US><apiKey>` |
| `CERTSUI_E2E_JWT_CREDENTIALS` | Optional — Base64 `<adminUser><US><password>` |
| `CERTSUI_E2E_EXPECT_MIN_DISTINCT_INSTANCES` | Optional — `MultiReplica` defaults to **1** (Docker Compose); set **2**+ for HA |
| `CERTSUI_E2E_ACCOUNT_ID` | Optional — `AccountReadPatch` target account (else first account) |
## Registered scenarios
| Id | Cmdlets |
|----|---------|
| `Health` | `Test-CertsUIHealth` |
| `ApiKeyConcurrentReads` | `Get-CertsUIAccounts` (connected session) |
| `MultiReplica` | `Get-CertsUIRuntimeInstanceId` (default **1** instance for Docker Compose; set `CERTSUI_E2E_EXPECT_MIN_DISTINCT_INSTANCES=2` for HA) |
| `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 |
`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'`.
## 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.

View File

@ -48,7 +48,7 @@ Use `existingConfigMap` / `existingSecret` to mount resources created outside th
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). 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`**. Health while starting: **`GET /health/startup`** (JSON phase snapshot); traffic should use **`GET /health/ready`** (503 until bootstrap completes). Server **`startupProbe`** defaults to **`/health/ready`** (see chart **`values.yaml`**).
------------------------------------------------------------ ------------------------------------------------------------
## Uninstall ## Uninstall