From 06ba5e8e2c4fdfe9dab243c5692c1f2451a46344 Mon Sep 17 00:00:00 2001 From: Maksym Sadovnychyy Date: Thu, 4 Jun 2026 20:07:27 +0200 Subject: [PATCH] (docs): consolidate docs into agents.md; fix operator health, ha, and e2e notes for 3.5.2 --- .cursor/maksit-skills.json | 17 + .cursor/rules/maksit-skills.mdc | 23 ++ AGENTS.md | 21 + CHANGELOG.md | 10 + CONTRIBUTING.md | 4 +- README.md | 93 +---- assets/docs/ARCHITECTURE_LAYERING.md | 358 ------------------ assets/docs/HA_ARCHITECTURE.md | 112 ------ .../LOGIN_AND_REFRESH_TOKEN_ARCHITECTURE.md | 214 ----------- assets/docs/PATCH_DELTA_REFERENCE.md | 251 ------------ assets/docs/POWERSHELL_CLIENT_MODULE.md | 79 ---- assets/docs/RBAC_REFERENCE.md | 167 -------- assets/docs/REVERSE_PROXY_ROUTING.md | 69 ---- assets/docs/USER_AND_API_KEY_RBAC.md | 149 -------- .../README.md | 14 +- .../MaksIT.CertsUI.Client.Tests.csproj | 2 +- .../MaksIT.CertsUI.Engine.csproj | 4 +- src/MaksIT.CertsUI/MaksIT.CertsUI.csproj | 6 +- src/e2e-tests/README.md | 81 +--- src/helm/templates/NOTES.txt | 2 +- 20 files changed, 115 insertions(+), 1561 deletions(-) create mode 100644 .cursor/maksit-skills.json create mode 100644 .cursor/rules/maksit-skills.mdc create mode 100644 AGENTS.md delete mode 100644 assets/docs/ARCHITECTURE_LAYERING.md delete mode 100644 assets/docs/HA_ARCHITECTURE.md delete mode 100644 assets/docs/LOGIN_AND_REFRESH_TOKEN_ARCHITECTURE.md delete mode 100644 assets/docs/PATCH_DELTA_REFERENCE.md delete mode 100644 assets/docs/POWERSHELL_CLIENT_MODULE.md delete mode 100644 assets/docs/RBAC_REFERENCE.md delete mode 100644 assets/docs/REVERSE_PROXY_ROUTING.md delete mode 100644 assets/docs/USER_AND_API_KEY_RBAC.md diff --git a/.cursor/maksit-skills.json b/.cursor/maksit-skills.json new file mode 100644 index 0000000..f028587 --- /dev/null +++ b/.cursor/maksit-skills.json @@ -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" + ] +} diff --git a/.cursor/rules/maksit-skills.mdc b/.cursor/rules/maksit-skills.mdc new file mode 100644 index 0000000..a733e4c --- /dev/null +++ b/.cursor/rules/maksit-skills.mdc @@ -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`. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d31393e --- /dev/null +++ b/AGENTS.md @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md index d0c05f3..8016988 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). +## [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 **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). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9da2b0c..58659b0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,9 +14,9 @@ Large or architectural changes are best discussed first (see [Contact](#contact) ## 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 diff --git a/README.md b/README.md index 7ddc43b..9b90df9 100644 --- a/README.md +++ b/README.md @@ -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 [Let’s Encrypt guidelines](https://letsencrypt.org/docs/) while implementing recommended security and operational best practices. -Authorization is **scope-based RBAC** for **users** and **API keys** (organization-scoped **Identity** / **ApiKey** flags). **Global administrator** on a signed-in user (JWT) and on an API key are evaluated **separately**—a user being admin does not automatically grant the same to a key they create. Certificate and account endpoints today accept **any authenticated** principal; see the matrices for detail. +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) - [Changelog](#changelog) - [Contributing](#contributing) - - [User and API key RBAC](#user-and-api-key-rbac) - - [RBAC reference](#rbac-reference) - - [Patch and delta reference](#patch-and-delta-reference) - - [Login and refresh token architecture](#login-and-refresh-token-architecture) - - [Reverse proxy routing (YARP)](#reverse-proxy-routing-yarp) - - [High availability architecture](#high-availability-architecture) + - [Architecture and documentation](#architecture-and-documentation) - [Architecture](#architecture) - [Current Limitations](#current-limitations) - [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. -## User and API key RBAC +## Architecture and documentation -How JWT and **`X-API-KEY`** principals are resolved, how **`CertsUIAuthorizationFilter`** differs from Vault’s route split, **`GetActingJwtTokenData`**, and where rules live in code: **[assets/docs/USER_AND_API_KEY_RBAC.md](assets/docs/USER_AND_API_KEY_RBAC.md)**. +**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) -- [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) +**Server health endpoints:** -## 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)**. - -- [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 repo’s 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 (Let’s 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) +See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup. --- @@ -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). 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. diff --git a/assets/docs/ARCHITECTURE_LAYERING.md b/assets/docs/ARCHITECTURE_LAYERING.md deleted file mode 100644 index ee86d3b..0000000 --- a/assets/docs/ARCHITECTURE_LAYERING.md +++ /dev/null @@ -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` (`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 6–7 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`** 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`** walk **8→1** (PostgreSQL → … → Controller) in **one line**. Steps **8–5** 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`** 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>`** 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>?`** 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> 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?>`** 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()` (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) diff --git a/assets/docs/HA_ARCHITECTURE.md b/assets/docs/HA_ARCHITECTURE.md deleted file mode 100644 index 948b672..0000000 --- a/assets/docs/HA_ARCHITECTURE.md +++ /dev/null @@ -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) diff --git a/assets/docs/LOGIN_AND_REFRESH_TOKEN_ARCHITECTURE.md b/assets/docs/LOGIN_AND_REFRESH_TOKEN_ARCHITECTURE.md deleted file mode 100644 index dfdbcdf..0000000 --- a/assets/docs/LOGIN_AND_REFRESH_TOKEN_ARCHITECTURE.md +++ /dev/null @@ -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 ` 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` | diff --git a/assets/docs/PATCH_DELTA_REFERENCE.md b/assets/docs/PATCH_DELTA_REFERENCE.md deleted file mode 100644 index ef26b8d..0000000 --- a/assets/docs/PATCH_DELTA_REFERENCE.md +++ /dev/null @@ -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?` (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 API’s 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 0–3) | -| 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* diff --git a/assets/docs/POWERSHELL_CLIENT_MODULE.md b/assets/docs/POWERSHELL_CLIENT_MODULE.md deleted file mode 100644 index b615051..0000000 --- a/assets/docs/POWERSHELL_CLIENT_MODULE.md +++ /dev/null @@ -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 - ``` - -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 -Description "updated" - Invoke-CertsUIDeleteAccount -AccountId - ``` - -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). diff --git a/assets/docs/RBAC_REFERENCE.md b/assets/docs/RBAC_REFERENCE.md deleted file mode 100644 index 8833425..0000000 --- a/assets/docs/RBAC_REFERENCE.md +++ /dev/null @@ -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 | Non–global-admin | -|------|--------------|------------------| -| **Search users / user scopes** | All (minus self in user search) | Only users whose org ids ⊆ actor’s org ids with **`Identity` + `Read`** | -| **Create / patch `IsGlobalAdmin`** | Allowed | **Forbidden** if request touches `IsGlobalAdmin` | -| **Read / create / patch / delete user by id** | Allowed | **RBAC wrapper returns success without org checks** — scoped enforcement **not** implemented in `ReadUserRBAC` / `CreateUserRBAC` / `PatchUserRBAC` / `DeleteUserRBAC` lambdas yet (XML comments describe the **intended** Vault-aligned rules) | - -If shorthand tables below disagree with §4.1, **§4.1 wins** until CRUD wrappers are brought in line with Vault (`GetEntityIdsWithScope` checks like `maksit-vault` `IdentityService`). - -### 4.2 Intended policy (target behavior; align CRUD with this) - -| Action | Admin | Identity manager | Identity reader | -|--------|-------|------------------|-----------------| -| Read user | Yes (any) | Yes if `Read` on **Identity** for **all** target orgs | Yes if `Read` on **all** target orgs | -| Create user | Yes (any) | Yes if `Create` on **Identity** for **all** orgs in create request | No | -| Patch user | Yes (any) | Yes if `Write` on **all** target orgs (and touched orgs on scope patch) | Self only for profile fields; no role/org changes | -| Delete user | Yes (any, not self) | Yes if `Delete` on **all** target orgs; not self | No | - -**Self:** Vault allows self read/patch with restrictions; Certs **search** excludes self; self-service rules for patch should match Vault when CRUD is completed. - ---- - -## 5. ACME, accounts, cache, and agent - -There is **no** per-organization scope on certificate accounts or ACME sessions in the current schema. - -### 5.1 Enforced permissions (from code) - -| Resource / area | Routes | Non–global-admin JWT | Non–global-admin API key | -|-----------------|--------|----------------------|---------------------------| -| Accounts | `/api/account/...` | Any authenticated principal | Any authenticated principal | -| ACME flow | `/api/certs/...` | Any authenticated principal | Any authenticated principal | -| Registration cache | `/api/cache/...` | Any authenticated principal | Any authenticated principal | -| Agent hello | `/api/agent/...` | Any authenticated principal | Any authenticated principal | -| HTTP-01 challenge | `GET /.well-known/acme-challenge/...` | **Anonymous** (no filter) | **Anonymous** | - -All of the above use `RBACWrapper(..., _ => Result.Ok(), _ => Result.Ok())` except Well-Known. **Treat every valid API key like a full automation principal** for certificate operations until account-scoped RBAC exists. - -### 5.2 Operational guidance - -| Goal | Recommendation | -|------|----------------| -| Least privilege for **cert automation** | Issue keys only to trusted pipelines; rotate and expire keys; network-restrict the API. Do not rely on org scopes for ACME yet. | -| Least privilege for **user/key admin** | Use scoped **Identity** / **ApiKey** grants; use **search** for operators without global admin. | -| Full platform control | Global-admin JWT or global-admin API key. | - ---- - -## 6. Managing API keys - -Routes: `/api/apikey/...`. Callers use **`GetActingJwtTokenData()`** — JWT **or** API key (see [User vs API key RBAC](./USER_AND_API_KEY_RBAC.md) §5). - -### 6.1 Enforced in code today - -Same pattern as §4.1: - -| Area | Global admin | Non–global-admin | -|------|--------------|------------------| -| **Search keys / key scopes** | All | Keys whose org ids ⊆ actor’s org ids with **`ApiKey` + `Read`** | -| **Patch `IsGlobalAdmin` on a key** | Allowed | **Forbidden** | -| **Read / create / patch / delete key by id** | Allowed | **No org scope check in RBAC lambdas yet** | - -### 6.2 Intended policy (target behavior) - -| Action | Admin | API key manager | API key reader | -|--------|-------|-----------------|---------------| -| Read API key | Yes (any) | Yes if `Read` on **ApiKey** for **all** key orgs | Yes if `Read` on **all** key orgs | -| Create API key | Yes (any); may set key `IsGlobalAdmin` | Yes if `Create` on **all** orgs in request; cannot set key `IsGlobalAdmin` | No | -| Patch API key | Yes (any) | Yes if `Write` on **all** key orgs | No | -| Delete API key | Yes (any) | Yes if `Delete` on **all** key orgs | No | - ---- - -## 7. Calling the API with an API key - -Use **`X-API-KEY`** on any protected route that uses **`CertsUIAuthorizationFilter`**. RBAC uses the **key’s** scopes and **`IsGlobalAdmin` on the key** — not the human operator’s JWT. - -| Use case | Key type | -|----------|----------| -| PowerShell / `MaksIT.CertsUI.Client` automation (accounts, ACME) | Any valid key (prefer dedicated non-global keys only if you accept §5.1) | -| Create accounts, run `FullFlow`, cache | Authenticated key (global admin not required today) | -| Manage users or API keys via API | Key needs appropriate **Identity** / **ApiKey** scopes or global admin | -| Match WebUI “full admin” for certs + identity | **`IsGlobalAdmin: true`** on the key | - -Details: [User vs API key RBAC](./USER_AND_API_KEY_RBAC.md) §1–2, [POWERSHELL_CLIENT_MODULE.md](./POWERSHELL_CLIENT_MODULE.md). - ---- - -## 8. Comparison with MaksIT.Vault - -| Topic | Vault | CertsUI | -|-------|-------|---------| -| Dual auth filter | `VaultAuthorizationFilter` on `/api/vault/...` only | `CertsUIAuthorizationFilter` on all protected API controllers | -| Identity / API key routes | JWT only | JWT **or** API key via `GetActingJwtTokenData()` | -| Resource scopes | Organization, Application, secrets | **None** for ACME; Identity/ApiKey admin only | -| Scoped CRUD on identity | Enforced in `IdentityService` | **Search enforced**; CRUD wrappers **stub** (May 2026) | - -When porting fixes from Vault, copy **`GetEntityIdsWithScope`** patterns from `maksit-vault` `IdentityService` / `APIKeyService` into this repo’s RBAC lambdas. - ---- - -*Last updated: May 2026* diff --git a/assets/docs/REVERSE_PROXY_ROUTING.md b/assets/docs/REVERSE_PROXY_ROUTING.md deleted file mode 100644 index 11e1a77..0000000 --- a/assets/docs/REVERSE_PROXY_ROUTING.md +++ /dev/null @@ -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 (Let’s 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 Compose’s 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) diff --git a/assets/docs/USER_AND_API_KEY_RBAC.md b/assets/docs/USER_AND_API_KEY_RBAC.md deleted file mode 100644 index 8b8329c..0000000 --- a/assets/docs/USER_AND_API_KEY_RBAC.md +++ /dev/null @@ -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 ` | Browser (WebUI), interactive tools, services that can refresh tokens | -| **API key** | `X-API-KEY: ` | Scripts, CI, PowerShell (`Connect-CertsUI`), `MaksIT.CertsUI.Client`; long-lived secret shown once at creation | - -**Request resolution:** `CertsUIAuthorizationFilter` (`src/MaksIT.CertsUI/Authorization/Filters/CertsUIAuthorizationFilter.cs`) checks **`X-API-KEY` first**. If that header is absent or invalid, it falls back to **Bearer JWT**. If neither yields a valid principal, the request gets **403 Forbidden** (`Authorization required`). - -**Unlike MaksIT.Vault:** Vault uses **`JwtAuthorizationFilter`** on `/api/identity/...` and `/api/apikey/...` (JWT only) and **`VaultAuthorizationFilter`** on `/api/vault/...` (JWT or API key). In **CertsUI**, every protected controller action listed below uses **`CertsUIAuthorizationFilter`** — the same dual path for **identity**, **API keys**, **accounts**, **ACME flow**, and **cache**. - -| Route family | Filter | JWT | `X-API-KEY` | -|--------------|--------|-----|-------------| -| `/api/identity/...` (except login / refresh) | `CertsUIAuthorizationFilter` | Yes | Yes | -| `/api/apikey/...` | `CertsUIAuthorizationFilter` | Yes | Yes | -| `/api/account/...`, `/api/certs/...`, `/api/cache/...`, `/api/agent/...`, `/api/debug/...` | `CertsUIAuthorizationFilter` | Yes | Yes | - -**Exceptions (no JWT / API key on the action):** - -- `POST /api/identity/login` and `POST /api/identity/refresh` — anonymous. -- `GET /.well-known/acme-challenge/{token}` (`WellKnownController`) — no authorization filter; public HTTP-01 challenge responses (see [REVERSE_PROXY_ROUTING.md](./REVERSE_PROXY_ROUTING.md)). - ---- - -## 2. Two principal types (what RBAC sees) - -After authentication, the filter stores **`CertsUIAuthorizationData`** on `HttpContext` (`HttpContextValue.CertsUIAuthorizationData`). - -| Principal | Type | Where it comes from | -|-----------|------|---------------------| -| **User** | `JwtTokenData` | Validated JWT; includes `UserId`, `IsGlobalAdmin`, and **entity scopes** from the user record | -| **API key** | `ApiKeyData` | Key row by secret + **`ReadApiKeyAuthorization`**; includes `ApiKeyId`, `IsGlobalAdmin` **for that key**, and **entity scopes** stored for the key | - -Services that manage users or API keys do not take `CertsUIAuthorizationData` directly on every method. Controllers call **`GetActingJwtTokenData()`**, which: - -- Returns the real **`JwtTokenData`** for Bearer sessions. -- Maps an API key to a **synthetic** `JwtTokenData` (`UserId = Guid.Empty`, `Username = apikey:{id}`, scopes and `IsGlobalAdmin` from the key). - -So identity and API-key **admin** code paths are written against **`JwtTokenData`**, but the acting principal may be a **user** or an **API key** after mapping. - -### 2.1 Global administrator: user vs key (easy to confuse) - -- **`JwtTokenData.IsGlobalAdmin`** — the **signed-in user** is a global administrator (or the key was mapped with this flag). -- **`ApiKeyData.IsGlobalAdmin`** — **this API key** was created (or patched) with the global-administrator flag. - -These are **independent**. A global-admin **user** who creates an API key does **not** automatically create a global-admin key unless the create request sets **`IsGlobalAdmin: true`** on the key (UI: separate checkbox). Automation that must bypass scoped limits on **accounts / ACME** needs a **global-admin API key**, not merely a key created by an admin user. - -**Who may set `IsGlobalAdmin` on a key?** Only a principal that is already a **global administrator** after `GetActingJwtTokenData()` may **create** or **patch** a user or API key with `IsGlobalAdmin` enabled (`RbacHelpers.EnsureActorMayAssignGlobalAdmin` / `EnsureActorMayPatchGlobalAdminFlag` in `IdentityService` / `ApiKeyService`). - -### 2.2 Loading API key authorization - -For API key requests, `IsGlobalAdmin` and scopes come from persisted authorization data. If **`ReadApiKeyAuthorization`** fails after the key itself was found, the filter **propagates that failure** (it does **not** continue with `IsGlobalAdmin: false`), so misconfiguration or storage errors are not silently downgraded to “non-admin key” behavior. - -Expired keys (`ExpiresAt <= UtcNow`) are rejected in the filter before authorization is loaded. - ---- - -## 3. Shared RBAC helpers (`ServiceBase`) - -App services inherit from `ServiceBase` (`src/MaksIT.CertsUI/Abstractions/Services/ServiceBase.cs`), which implements: - -- **`RBACWrapper`** — If the request used JWT, runs **`RBACWrapperJwtToken`** with `userRules`; if API key, runs **`RBACWrapperApiKey`** with `apiKeyRules`. -- **`RBACWrapperJwtToken` / `RBACWrapperApiKey`** — If `IsGlobalAdmin` is **true** for that principal, return **success immediately** (full access for that wrapper). Otherwise run the supplied rules delegate, or **403** if no rules were supplied. - -So: **`userRules` / `apiKeyRules` = null** means **only global administrators** (JWT or API key, respectively) pass. Non-admins always get **forbidden** with no per-scope check. - -Variants **`RBACWrapper`** / **`RBACWrapperJwtToken`** / **`RBACWrapperApiKey`** follow the same pattern but carry a resource through the rules. - -**Identity / API key services** use **`RBACWrapperJwtToken`** only (after `GetActingJwtTokenData()`), including when the caller used an API key. - ---- - -## 4. Example: accounts and ACME (`AccountService`, `CertsFlowService`) - -Illustrates **authentication without scoped RBAC** today. - -| Action | RBAC pattern | Non–global-admin JWT | Non–global-admin API key | -|--------|----------------|----------------------|---------------------------| -| **List / read / create / patch / delete account** | `RBACWrapper(..., _ => Ok(), _ => Ok())` | Allowed if authenticated | Allowed if authenticated | -| **Certs flow steps** (`CertsFlowService`) | Same open rules | Allowed if authenticated | Allowed if authenticated | -| **Registration cache** (`CacheService`) | Same | Allowed if authenticated | Allowed if authenticated | - -There is **no** organization- or account-level scope check on certificate operations yet. **Do not** treat API keys as least-privilege for ACME unless you issue **non-global** keys and accept that they can still drive any account operation once authenticated. Scoped **Identity** / **ApiKey** flags apply to **user and key administration** (see [RBAC reference](./RBAC_REFERENCE.md) §4–§6 and §5.1 for search vs CRUD). - ---- - -## 5. Identity and API key administration (`GetActingJwtTokenData`) - -Operations in **`IIdentityService`** / **`IApiKeyService`** and their controllers take **`JwtTokenData`** from **`GetActingJwtTokenData()`** — so **both** Bearer users and **`X-API-KEY`** callers can hit `/api/identity/...` and `/api/apikey/...` when the key’s scopes (or global admin) allow it. - -Summary (matrices and enforcement detail: [RBAC reference](./RBAC_REFERENCE.md)): - -| Operation | Global-admin actor | Non–global-admin actor | -|-----------|-------------------|-------------------------| -| **Search users / keys / scopes** | Full list (plus request filters) | Results limited to targets whose **organization** ids are covered by actor **`Identity`** / **`ApiKey`** **`Read`** scopes (enforced in query predicates) | -| **Read / patch / delete by id** | Full access | **Intended:** same org coverage as Vault-style identity rules; **current code:** non-admin CRUD wrappers return success without scope checks — see [RBAC reference](./RBAC_REFERENCE.md) §4.1 | -| **Create** | Any user/key; may set **`IsGlobalAdmin`** on new principal | **Intended:** `Create` on scopes for all orgs in request; **current code:** non-admin create wrapper allows any authenticated actor | -| **Patch `IsGlobalAdmin`** | Allowed | **Forbidden** (`RbacHelpers`) | - -E2E regression tests in `src/e2e-tests/scenarios/Scenario-05-IdentityConfigurations.ps1` assert non-global users cannot create global-admin users or keys. - ---- - -## 6. Troubleshooting - -| Symptom | Likely cause | -|---------|----------------| -| **403** `Authorization required` | Neither valid `X-API-KEY` nor Bearer JWT. | -| **403** `Invalid API Key` / `API Key expired` | Wrong secret, revoked row, or past `ExpiresAt`. | -| **403** `User does not have access to resource.` | JWT user is not global-admin and the service used `RBACWrapper` with **null** rules (not used on account/certs today). | -| **403** `ApiKey does not have access to resource.` | Same for API key + null rules. | -| **403** on **`IsGlobalAdmin`** patch/create | Acting principal is not global-admin (`RbacHelpers`). | -| User/key **visible in UI search** but **403 on GET by id** | Unlikely today (CRUD is permissive for non-admins); if scope checks are added to match Vault, search and GET will align — until then, prefer **search** for scoped operators. | -| Automation works in UI but **403** with API key | Key lacks **`IsGlobalAdmin`** or scopes; UI user may be global-admin while the key is not. | -| **403** / unexpected status right after API key validation | Authorization row read failed; check logs and DB for that `ApiKeyId`. | - ---- - -## 7. Code map - -| Topic | Location | -|-------|----------| -| API key vs JWT resolution | `MaksIT.CertsUI/Authorization/Filters/CertsUIAuthorizationFilter.cs` | -| Acting principal for identity/API key | `MaksIT.CertsUI/Authorization/Extensions/HttpContextExtension.cs` (`GetActingJwtTokenData`, `ToActingJwtTokenData`) | -| Identity (dual auth) | `MaksIT.CertsUI/Controllers/IdentityController.cs` | -| API keys (dual auth) | `MaksIT.CertsUI/Controllers/APIKeyController.cs` | -| Accounts / certs / cache (dual auth) | `AccountController`, `CertsFlowController`, `CacheController` | -| HTTP-01 (no RBAC) | `MaksIT.CertsUI/Controllers/WellKnownController.cs` | -| `RBACWrapper*` helpers | `MaksIT.CertsUI/Abstractions/Services/ServiceBase.cs` | -| Scope helpers | `MaksIT.CertsUI/Services/Helpers/RbacHelpers.cs` | -| Identity RBAC | `MaksIT.CertsUI/Services/IdentityService.cs` | -| API key RBAC | `MaksIT.CertsUI/Services/ApiKeyService.cs` | -| Open certs RBAC | `AccountService`, `CertsFlowService`, `CacheService`, `AgentService` | -| Principal types | `MaksIT.CertsUI/Authorization/` (`JwtTokenData`, `ApiKeyData`, `CertsUIAuthorizationData`, `IdentityScopeData`) | -| Scope enums | `MaksIT.CertsUI.Engine/ScopeEntityType.cs`, `ScopePermission.cs` | - ---- - -*Last updated: May 2026* diff --git a/src/MaksIT.CertsUI.Client.PowerShell/README.md b/src/MaksIT.CertsUI.Client.PowerShell/README.md index ccd3156..93e04a3 100644 --- a/src/MaksIT.CertsUI.Client.PowerShell/README.md +++ b/src/MaksIT.CertsUI.Client.PowerShell/README.md @@ -1,5 +1,15 @@ # 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 "" +Test-CertsUIHealth +``` + +Requires **PowerShell 7** with a **.NET 10** host. diff --git a/src/MaksIT.CertsUI.Client.Tests/MaksIT.CertsUI.Client.Tests.csproj b/src/MaksIT.CertsUI.Client.Tests/MaksIT.CertsUI.Client.Tests.csproj index 4003a13..cdd3198 100644 --- a/src/MaksIT.CertsUI.Client.Tests/MaksIT.CertsUI.Client.Tests.csproj +++ b/src/MaksIT.CertsUI.Client.Tests/MaksIT.CertsUI.Client.Tests.csproj @@ -19,7 +19,7 @@ - + diff --git a/src/MaksIT.CertsUI.Engine/MaksIT.CertsUI.Engine.csproj b/src/MaksIT.CertsUI.Engine/MaksIT.CertsUI.Engine.csproj index 2cf4425..a1ee1ac 100644 --- a/src/MaksIT.CertsUI.Engine/MaksIT.CertsUI.Engine.csproj +++ b/src/MaksIT.CertsUI.Engine/MaksIT.CertsUI.Engine.csproj @@ -14,9 +14,9 @@ - + - + diff --git a/src/MaksIT.CertsUI/MaksIT.CertsUI.csproj b/src/MaksIT.CertsUI/MaksIT.CertsUI.csproj index 9fd27d5..d53a088 100644 --- a/src/MaksIT.CertsUI/MaksIT.CertsUI.csproj +++ b/src/MaksIT.CertsUI/MaksIT.CertsUI.csproj @@ -2,7 +2,7 @@ $(DefaultItemExcludes);Models\obj\**;Models\bin\** - 3.5.1 + 3.5.2 net10.0 Linux ..\docker-compose.dcproj @@ -10,9 +10,9 @@ - + - + diff --git a/src/e2e-tests/README.md b/src/e2e-tests/README.md index 5234a0b..d6b17a4 100644 --- a/src/e2e-tests/README.md +++ b/src/e2e-tests/README.md @@ -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). - -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). +**Module quick start:** [MaksIT.CertsUI.Client.PowerShell/README.md](../MaksIT.CertsUI.Client.PowerShell/README.md). ## Credentials -Uses **one** environment variable: **`CERTSUI_E2E_CREDENTIALS`** — UTF-8 text, Base64-encoded. - -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**: `` where **``** 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', '', 'User') -``` - -Or only for the **current process**: - -```powershell -$env:CERTSUI_E2E_CREDENTIALS = '' -``` - -### JWT credentials (optional) - -Identity admin scenarios use the **global admin API key** from `CERTSUI_E2E_CREDENTIALS` (`X-API-KEY`) when the server supports it. Optionally set **`CERTSUI_E2E_JWT_CREDENTIALS`** — same encoding, payload `` — for JWT-only probes (e.g. scoped-user login). +Set **`CERTSUI_E2E_CREDENTIALS`** — Base64 of UTF-8 `` (`` = ASCII unit separator, `[char]0x1F`): ```powershell $us = [char]0x1F -$b64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes("admin$us" + 'your-admin-password')) -[Environment]::SetEnvironmentVariable('CERTSUI_E2E_JWT_CREDENTIALS', $b64, 'User') +$b64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes("http://localhost:8080$us" + 'your-api-key')) +[Environment]::SetEnvironmentVariable('CERTSUI_E2E_CREDENTIALS', $b64, 'User') ``` +Optional **`CERTSUI_E2E_JWT_CREDENTIALS`** — same encoding, payload `` — for JWT-only identity probes. + ## Run ```powershell 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 AccountReadPatch ``` -## Environment variables +Or `src\e2e-tests\Test-CertsUiApiKeyE2E.bat` after credentials are set. -| Variable | Purpose | -|----------|---------| -| `CERTSUI_E2E_CREDENTIALS` | Required — Base64 `` | -| `CERTSUI_E2E_JWT_CREDENTIALS` | Optional — Base64 `` | -| `CERTSUI_E2E_EXPECT_MIN_DISTINCT_INSTANCES` | Optional — `MultiReplica` defaults to **1** (Docker Compose); set **2**+ for HA | -| `CERTSUI_E2E_ACCOUNT_ID` | Optional — `AccountReadPatch` target account (else first account) | - -## Registered scenarios - -| Id | Cmdlets | -|----|---------| -| `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. +E2E is **not** run in CI. Requires **PowerShell 7** on **.NET 10** (see script error text if the host is wrong). diff --git a/src/helm/templates/NOTES.txt b/src/helm/templates/NOTES.txt index 607ce3e..9bfd87f 100644 --- a/src/helm/templates/NOTES.txt +++ b/src/helm/templates/NOTES.txt @@ -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). -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