diff --git a/.gitea/workflows/ci-cd.yml b/.gitea/workflows/ci-cd.yml index 95d6354..0e3dba6 100644 --- a/.gitea/workflows/ci-cd.yml +++ b/.gitea/workflows/ci-cd.yml @@ -36,7 +36,7 @@ on: workflow_dispatch: inputs: deploy_backend: - description: "Deploy the backend (after editing /etc/erpcore/erpcore.env, or after a failed deploy)" + description: "Deploy the backend (after changing the config in this file's env: block, or after a failed deploy)" type: boolean default: false deploy_frontend: @@ -236,14 +236,28 @@ jobs: PUBLISH_DIR: /home/erp/core/publish BUILD_DIR: /tmp/erp-deploy SERVICE: erpcore.service - SERVICE_USER: erp + SERVICE_USER: erpuser BACKEND_PORT: "5220" - ENV_FILE: /etc/erpcore/erpcore.env DB_NAME: ERPCore + DB_USER: erpcore BACKUP_DIR: /var/backups/erpcore WEB_IMAGE: erp-web:latest WEB_CONTAINER: erp-web WEB_PORT: "3020" + # ── Backend runtime config ──────────────────────────────────────────────────────────── + # There is no /etc/erpcore/erpcore.env. These values are the single source of truth: the + # "Install unit + write config" step writes them into a systemd drop-in on every backend + # deploy. Change a setting here, then merge or use "Run workflow" with deploy_backend. + # The two secrets (DB password, GL API key) come from Gitea secrets, never from this file. + AUTHHEX_BASE_URL: "http://127.0.0.1:5433" + GL_BASE_URL: "http://127.0.0.1:5433/api/v1/" + STORAGE_ROOT: /var/lib/erpcore/hr-documents + LOG_PATH: /var/log/erpcore/erpcore-.log + SEED_DEMO_DATA: "false" + # Door policy: blank = any valid AuthHex token is admitted. Set the real AuthHex codes to + # restrict ERP to one user type / role (Infra/Auth/JwtAuthExtensions.cs). + AUTH_REQUIRED_USER_TYPE: "" + AUTH_REQUIRED_ROLE: "" steps: - name: Checkout code @@ -324,25 +338,60 @@ jobs: echo "Backup: $file" find "$BACKUP_DIR" -maxdepth 1 -name '*.dump' -mtime +30 -delete - - name: Guard data paths + - name: Install unit + write config if: steps.changes.outputs.backend == 'true' + env: + DB_PASSWORD: ${{ secrets.ERP_DB_PASSWORD }} + GL_API_KEY: ${{ secrets.ERP_GL_API_KEY }} run: | + # Config lives in this workflow's env: block, not in a file on the server. It is written + # to a systemd drop-in here, before the service is stopped, so a bad value fails the job + # while the OLD release is still serving. + case "$AUTHHEX_BASE_URL$GL_BASE_URL" in + *CHANGE_ME*) echo "❌ AUTHHEX_BASE_URL / GL_BASE_URL still hold CHANGE_ME — set the real ports in ci-cd.yml (run 'ss -ltnp' on the VPS)"; exit 1 ;; + esac + [ -n "$DB_PASSWORD" ] || { echo "❌ Gitea secret ERP_DB_PASSWORD is not set"; exit 1; } + [ -n "$GL_API_KEY" ] || { echo "❌ Gitea secret ERP_GL_API_KEY is not set"; exit 1; } + case "$DB_PASSWORD" in + *";"*) echo "❌ ERP_DB_PASSWORD contains ';' — Npgsql would truncate the connection string there. Use a password without ';'"; exit 1 ;; + esac + # "Copy to deploy folder" empties the publish folder, so uploads and logs must live elsewhere. - [ -r "$ENV_FILE" ] || { echo "❌ $ENV_FILE not found"; exit 1; } publish="$(realpath -m "$PUBLISH_DIR")" - for key in FileStorage__RootPath LogFile__Path; do - value="$(sed -n "s/^$key=//p" "$ENV_FILE" | tail -n 1 | tr -d '"')" - [ -n "$value" ] || { echo "❌ $key is not set in $ENV_FILE"; exit 1; } - case "$value" in + for path in "$STORAGE_ROOT" "$LOG_PATH"; do + case "$path" in /*) ;; - *) echo "❌ $key=$value is relative — it would resolve inside $PUBLISH_DIR"; exit 1 ;; + *) echo "❌ $path is relative — it would resolve inside $PUBLISH_DIR"; exit 1 ;; esac - case "$(realpath -m "$value")/" in - "$publish"/*) echo "❌ $key=$value is inside $PUBLISH_DIR, which every deploy wipes"; exit 1 ;; + case "$(realpath -m "$path")/" in + "$publish"/*) echo "❌ $path is inside $PUBLISH_DIR, which every deploy wipes"; exit 1 ;; esac - echo "ok $key=$value" + echo "ok $path" done + # The unit is installed from the repo, so editing deploy/systemd/erpcore.service and + # deploying is enough — no manual copy step on the server. + install -m 644 deploy/systemd/erpcore.service /etc/systemd/system/erpcore.service + install -d -m 755 /etc/systemd/system/erpcore.service.d + conf=/etc/systemd/system/erpcore.service.d/10-erpcore-env.conf + # Values are quoted: systemd would otherwise split a password containing spaces. + umask 077 + # Only the listening URL. Everything else is app configuration and lands in + # appsettings.Production.json (written after the copy step) — ASPNETCORE_URLS is host + # configuration, the one setting appsettings cannot reliably provide. + cat > "$conf" < "$conf" <<'PY' + import json, os + e = os.environ + print(json.dumps({ + "ConnectionStrings": { + "DefaultConnection": + "Host=127.0.0.1;Port=5432;Database=%s;Username=%s;Password=%s" + % (e["DB_NAME"], e["DB_USER"], e["DB_PASSWORD"]), + }, + "AuthHex": {"BaseUrl": e["AUTHHEX_BASE_URL"]}, + "GeneralLedgerService": {"BaseUrl": e["GL_BASE_URL"], "ApiKey": e["GL_API_KEY"]}, + "Auth": { + "RequiredUserTypeCode": e["AUTH_REQUIRED_USER_TYPE"], + "RequiredRoleCode": e["AUTH_REQUIRED_ROLE"], + }, + "FileStorage": {"RootPath": e["STORAGE_ROOT"]}, + "LogFile": {"Path": e["LOG_PATH"]}, + "Seed": {"DemoData": e["SEED_DEMO_DATA"] == "true"}, + }, indent=2)) + PY + chown "root:$SERVICE_USER" "$conf" + chmod 640 "$conf" + python3 -c "import json,sys; json.load(open(sys.argv[1]))" "$conf" + echo "✅ appsettings.Production.json written ($(wc -c < "$conf") bytes, mode 640, values not printed)" + - name: Start service if: steps.changes.outputs.backend == 'true' run: | diff --git a/README.md b/README.md index 20cd2b9..ce1f9a2 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ npm run dev ## Configuration & secrets - `appsettings.json` is committed and holds **no secrets**; environment-specific values are blank there. -- Local: `appsettings.Development.json` (git-ignored, from the `.example`). Deployed (production): environment variables in `/etc/erpcore/erpcore.env` on the server — outside Development the API refuses to start when a required key is missing. +- Local: `appsettings.Development.json` (git-ignored, from the `.example`). Deployed (production): set in the `env:` block of `.gitea/workflows/ci-cd.yml`; the deploy generates `appsettings.Production.json` from it on each run, with the DB password and GL API key coming from Gitea secrets — outside Development the API refuses to start when a required key is missing. - Details and the full key list: `docs/40-DEPLOYMENT.md §5`. Credentials committed in the past must be rotated (`docs/02-SECURITY.md` AR-06). ## Deployment diff --git a/deploy/systemd/erpcore.service b/deploy/systemd/erpcore.service index 5f679fa..34872e3 100644 --- a/deploy/systemd/erpcore.service +++ b/deploy/systemd/erpcore.service @@ -1,32 +1,38 @@ # ERP-Core API — production (erp.hexdive.com). # -# Install once (docs/40-DEPLOYMENT.md, "Active" A.2): -# cp deploy/systemd/erpcore.service /etc/systemd/system/ -# systemctl daemon-reload && systemctl enable erpcore.service -# .gitea/workflows/ci-cd.yml stops it, replaces /home/erp/core/publish, then starts it again. +# This unit holds only what never changes. Everything configurable — listening URL, connection +# string, AuthHex/GL URLs, storage paths — is written by .gitea/workflows/ci-cd.yml into +# /etc/systemd/system/erpcore.service.d/10-erpcore-env.conf +# on every backend deploy, from the values in that workflow's `env:` block plus Gitea secrets. +# There is no /etc/erpcore/erpcore.env any more. To change a setting, edit the workflow and +# redeploy (docs/40-DEPLOYMENT.md, "Active" A.2/A.3). +# +# The workflow installs this file itself, so editing it here and deploying is enough. [Unit] -Description=ERP-Core API (production) +Description=ERP core service After=network-online.target postgresql-16.service Wants=network-online.target -# Stop hammering the database if a release crash-loops. +# Stop hammering the database if a release crash-loops: give up after 5 starts in 5 minutes +# (systemd then reports "failed", which is what wait-healthy.sh watches for). StartLimitIntervalSec=300 StartLimitBurst=5 [Service] Type=simple -User=erp -Group=erp +User=erpuser +Group=erpuser WorkingDirectory=/home/erp/core/publish ExecStart=/home/erp/core/publish/ERPCore -# Secrets + settings (template: deploy/env/erpcore.env.example). -EnvironmentFile=/etc/erpcore/erpcore.env +SyslogIdentifier=erpcore +Environment=ASPNETCORE_ENVIRONMENT=Production +Environment=DOTNET_PRINT_TELEMETRY_MESSAGE=false Environment=DOTNET_CLI_TELEMETRY_OPTOUT=1 -Restart=on-failure -RestartSec=5 +Restart=always +RestartSec=10 TimeoutStopSec=30 -# Shared 8 GB VPS: a runaway process must not take Gitea/Postgres down with it. +# Shared 8 GB VPS: a runaway process must not take Gitea/Postgres/AuthHex down with it. MemoryMax=768M # Hardening. No ProtectHome: the app itself lives under /home/erp. diff --git a/docs/00-CORE.md b/docs/00-CORE.md index cb3ab94..2954fe1 100644 --- a/docs/00-CORE.md +++ b/docs/00-CORE.md @@ -247,7 +247,7 @@ Resulting `ERPCore.csproj`: "Jwt": { "SigningKey": "dev-only-signing-key-please-change-me-0123456789" } } ``` -The deployed environment (production; a test environment is on hold) has **no** `appsettings..json`: every environment-specific value and secret is an environment variable in `/etc/erpcore/erpcore.env`, and outside Development the API refuses to start when a required key is blank — see `40-DEPLOYMENT.md §5`. +The deployed environment (production; a test environment is on hold) has **no** `appsettings..json`: every environment-specific value is set in the `env:` block of `.gitea/workflows/ci-cd.yml`, and the deploy generates `appsettings.Production.json` from it (the two secrets come from Gitea secrets, and the file is never committed), and outside Development the API refuses to start when a required key is blank — see `40-DEPLOYMENT.md §5`. > **Secrets note:** `appsettings.Development.json` is git-ignored (start from `appsettings.Development.json.example`) and the base `appsettings.json` holds no secrets. Credentials committed before this change stay readable in git history and must be rotated (`02-SECURITY.md` AR-06). ### 5.6 Minimal `Program.cs` wiring (outline) diff --git a/docs/02-SECURITY.md b/docs/02-SECURITY.md index 792163d..25b0c86 100644 --- a/docs/02-SECURITY.md +++ b/docs/02-SECURITY.md @@ -17,7 +17,7 @@ These are known, deliberately-accepted Phase-1 exposures. Each has a compensatin | **AR-03** | **IDOR** — sequential IDs + no RBAC → a user can access another's document by guessing the ID. | Consequence of AR-01. | Audit trail. | Closed with RBAC. | | **AR-04** | **GRN-without-PO** — receive (and value) goods with no order; a fraud vector. | Emergency/direct receipt is useful (FR-GRN-02). | Permission-gated (when RBAC on); flagged for review; audit; cost entered here gets extra scrutiny (see C.3). | With RBAC / policy on direct receipts. | | **AR-05** | **In-transit loss window** — dispatched-but-not-received stock is untracked shrinkage risk. | Inherent to in-transit transfers (FR-STK-05). | In-transit aging monitoring report. | Add stuck-transfer alert (Part D). | -| **AR-06** | **Committed secrets (historical).** `appsettings.Development.json` (DB password), `appsettings.json` (GL API key) and `Backend/smoke/smoke_common.py` (AuthHex login) were committed while the repo was publicly readable. | Local-dev convenience in early phases. | **Now closed going forward:** env-specific appsettings untracked, base `appsettings.json` and smoke defaults hold no secrets, deployed secrets live only in `/etc/erpcore/erpcore.env` on the server (`40-DEPLOYMENT.md` A.2 / §5), non-Development startup fails on missing keys. | The exposed values stay readable in git history → **rotate** the GL API key, Postgres password and the AuthHex account password, and keep the repo private. | +| **AR-06** | **Committed secrets (historical).** `appsettings.Development.json` (DB password), `appsettings.json` (GL API key) and `Backend/smoke/smoke_common.py` (AuthHex login) were committed while the repo was publicly readable. | Local-dev convenience in early phases. | **Now closed going forward:** env-specific appsettings untracked, base `appsettings.json` and smoke defaults hold no secrets, deployed secrets live only in Gitea secrets (`ERP_DB_PASSWORD`, `ERP_GL_API_KEY`) and are written at deploy time into `appsettings.Production.json` (mode 640, never committed — `.gitignore` ignores `appsettings.*.json`; `40-DEPLOYMENT.md` A.1 step 6b); non-secret deploy config is in `ci-cd.yml`, non-Development startup fails on missing keys. | The exposed values stay readable in git history → **rotate** the GL API key, Postgres password and the AuthHex account password, and keep the repo private. | | **AR-07** | **`getUserDetails` / `LogoutUser` callable without a bearer token** — `GET /api/v1/auth/users/{userId}` and `POST /api/v1/auth/logout` resolve the target user from the URL/payload, not the caller's session, so any anonymous caller can fetch a profile or log out an arbitrary user's sessions by GUID. | Carried over verbatim from AuthHex's own dispatcher contract (API_REFERENCE.md §3) — ERPCore's `AuthController` proxies it as-is rather than silently tightening a contract it doesn't own. | GUIDs are not enumerable; every call is written to `AuthEventLogs` upstream in AuthHex. | Revisit once AuthHex exposes a token-scoped variant, or add ERPCore-side rate limiting / auth requirement ahead of AuthHex. | | **AR-08** | **No rate limiting on `AuthController`'s anonymous endpoints** (login, register, refresh, recovery, OTP send/verify) — brute-force and account-enumeration exposure. | Not built in this pass (docs/11 §2.0, added 2026-07-16); AuthHex may rate-limit server-side but ERPCore does not add its own layer yet. | AuthHex's own lockout/backoff (per docs/10 NFR-03), immutable audit trail. | Add ASP.NET Core rate limiting middleware to `AuthController` before any non-local deployment. | | **AR-09** | **HRM inherits AR-01 for salary/PII data** — any door-admitted authenticated user can currently view/download any employee's salary figures, payslip, or uploaded documents (NIC scans, contracts). This is a **explicit, flagged decision, not a silent inheritance**: salary/PII is categorically more sensitive than Phase-1 inventory data, and this was called out to the business owner before HRM build started (see `12-BACKEND-HRM.md` A.1). | RBAC still deferred repo-wide; a coarse HR-role door-gate was not made a blocking prerequisite for HRM go-live. | Immutable audit trail (as AR-01); sidebar-visibility hiding of Employees/Attendance/Payroll sections for non-HR roles via the existing `NavItem`/`RolePermission` mechanism (UI-level only, not a server-enforced gate). | **HRM should be the forcing function that enables per-endpoint RBAC ahead of the rest of the system** (see Part D) — salary-data exposure is a materially worse blast radius than inventory data. | diff --git a/docs/40-DEPLOYMENT.md b/docs/40-DEPLOYMENT.md index 2a50839..4264426 100644 --- a/docs/40-DEPLOYMENT.md +++ b/docs/40-DEPLOYMENT.md @@ -2,7 +2,7 @@ > **What this is:** how code gets from a pull request to `erp.hexdive.com` (the `test-erp.hexdive.com` environment is on hold), how the server is set up, and how to operate it (config changes, rollback, backups). > **Navigation:** you arrived from `00-CORE.md` or `01-DOC-GUIDE.md`. Security posture: `02-SECURITY.md` (AR-06 covers the secrets history). -> **Files (active):** `.gitea/workflows/ci-cd.yml` (PR checks + production deploy, one file) · `deploy/scripts/{common,ci-detect,ci-backend,migration-scan,wait-healthy}.sh` · `deploy/systemd/erpcore.service` · `deploy/apache/erp.hexdive.com.conf` · `deploy/env/erpcore.env.example`. +> **Files (active):** `.gitea/workflows/ci-cd.yml` (PR checks + production deploy, one file) · `deploy/scripts/{common,ci-detect,ci-backend,migration-scan,wait-healthy}.sh` · `deploy/systemd/erpcore.service` · `deploy/apache/erp.hexdive.com.conf`. Runtime config lives in the workflow's `env:` block — there is no env file on the server. > **Files (on hold):** `deploy/on-hold/` — the test + release pipeline, see its README. --- @@ -36,8 +36,9 @@ The deploy job checks `pull_request.base.ref == 'production'` itself, because th | 2 | Detect changes | always | compare folder **tree hashes** base → merge for `Backend/ERPCore`, `Frontend/erp-system`, `Backend/ERPCore/Migrations`. Works for merge, squash and rebase merges. Manual runs use the `deploy_backend` / `deploy_frontend` checkboxes. | | 3 | Restore, Publish | backend changed | `dotnet restore` + `dotnet publish -r linux-x64 --self-contained` into `/tmp/erp-deploy` (on the host, like Fitness) | | 4 | Backup database | backend **and** migrations changed | `pg_dump -Fc ERPCore` → `/var/backups/erpcore/`, dumps older than 30 days deleted | -| 5 | Guard data paths | backend changed | fail if `FileStorage__RootPath` / `LogFile__Path` in `/etc/erpcore/erpcore.env` are missing, relative, or inside the publish folder | -| 6 | Stop → Copy → Fix ownership → Start | backend changed | `systemctl stop erpcore.service` · empty `/home/erp/core/publish` and copy the build · `chown erp:erp`, 755/644, `+x ERPCore` · `systemctl start` | +| 5 | Install unit + write config | backend changed | install `deploy/systemd/erpcore.service`, write the `ASPNETCORE_URLS` drop-in, `daemon-reload` + `enable`. Validates first, while the **old** release is still serving: fails on a `CHANGE_ME` URL, a missing secret, a `;` in the DB password, or a storage/log path inside the publish folder | +| 6 | Stop → Copy → Fix ownership → Start | backend changed | `systemctl stop erpcore.service` · empty `/home/erp/core/publish` and copy the build · `chown erpuser:erpuser`, 755/644, `+x ERPCore` · `systemctl start` | +| 6b | Write `appsettings.Production.json` | backend changed | generate it into the publish folder from the `env:` block + Gitea secrets (mode 640 `root:erpuser`), after the chmod pass so it is not world-readable. It **merges over** the committed `appsettings.json`, which keeps `Auth:RsaPublicKeyXml`, `Issuer`, `Audience` and `FileStorage:MaxSizeBytes`. Not in git — `.gitignore` ignores `appsettings.*.json` | | 7 | Verify backend | backend changed | `deploy/scripts/wait-healthy.sh`: waits for `/health` 200. A crash (unit failed / auto-restarted) fails within seconds; otherwise it waits up to 300 s, because migrations run at startup. Failure prints the journal and turns the job **red**. | | 8 | Build frontend image → Replace container | frontend changed | `docker build -t erp-web:latest Frontend/erp-system` (build inside the image, like Client_New_Fe) · recreate `erp-web` on `127.0.0.1:3020`, 512 MB, `--restart unless-stopped` | | 9 | Verify frontend → Remove old images | frontend changed | `GET /login` within 30 s or print container logs and fail · `docker image prune -f`, `docker builder prune` older than 7 days | @@ -68,33 +69,31 @@ dotnet --list-sdks # needs a 10.0.x SDK; install it next # (Fitness/AuthHex have no global.json, so they will build with SDK 10 too once it is the newest — normally fine) # Service user + folders. Uploads, logs and backups live OUTSIDE the publish folder, which every deploy empties. -useradd --create-home --home-dir /home/erp --shell /sbin/nologin erp -install -d -m 755 -o erp -g erp /home/erp/core /home/erp/core/publish -install -d -m 750 -o erp -g erp /var/lib/erpcore/hr-documents /var/log/erpcore +# (The deploy creates the data dirs too, but make them now so a first run has nothing to fix.) +useradd --create-home --home-dir /home/erp --shell /sbin/nologin erpuser +install -d -m 755 -o erpuser -g erpuser /home/erp/core /home/erp/core/publish +install -d -m 750 -o erpuser -g erpuser /var/lib/erpcore/hr-documents /var/log/erpcore install -d -m 700 /var/backups/erpcore -install -d -m 750 -o root -g erp /etc/erpcore -# Database (Postgres 16, localhost only) +# Database (Postgres 16, localhost only). This password goes into the Gitea secret ERP_DB_PASSWORD. sudo -u postgres psql -c "CREATE ROLE erpcore LOGIN PASSWORD 'CHANGE_ME';" -c 'CREATE DATABASE "ERPCore" OWNER erpcore;' -# Settings + secrets (fill in CHANGE_ME / ports — keys in §5) -install -m 640 -o root -g erp deploy/env/erpcore.env.example /etc/erpcore/erpcore.env - -# Service -cp deploy/systemd/erpcore.service /etc/systemd/system/ && systemctl daemon-reload && systemctl enable erpcore.service +# Service: only `enable` by hand. The deploy installs the unit and writes the config drop-in itself, +# so there is NO file to create under /etc/erpcore — that directory is not used any more. +# nothing to do here — the deploy installs the unit, writes the config and runs `systemctl enable` # Apache + TLS (needs SELinux httpd_can_network_connect=on) cp deploy/apache/erp.hexdive.com.conf /etc/httpd/conf.d/ && apachectl configtest && systemctl reload httpd certbot --apache -d erp.hexdive.com ``` -**Gitea:** Actions enabled for the repo · the `linux` runner available to `New_REP_SYSTEM` · secret **`ERPSECRET`** (a token that can read the repo) · branch protection on `production`: require PR, 1 approval, required check `ci-ok` (it appears as `CI/CD - ERP Core / ci-ok …` after the first PR run; a pattern like `CI/CD - ERP Core / ci-ok*` matches it), no direct push. `Dev`: require PR + the same check. +**Gitea:** Actions enabled for the repo · the `linux` runner available to `New_REP_SYSTEM`, with its label mapped to **`host`** (§A.4) · secrets **`ERPSECRET`** (repo-read token), **`ERP_DB_PASSWORD`** (the `erpcore` role's password) and **`ERP_GL_API_KEY`** · branch protection on `production`: require PR, 1 approval, required check `ci-ok` (it appears as `CI/CD - ERP Core / ci-ok …` after the first PR run; a pattern like `CI/CD - ERP Core / ci-ok*` matches it), no direct push. `Dev`: require PR + the same check. **First deploy:** merge a PR that touches both folders, or use the manual run with both boxes ticked. If `erpcore.service` fails with `status=203/EXEC`, run `restorecon -Rv /home/erp/core/publish` (SELinux label). ## A.3 Operations | Task | How | |---|---| -| Config change | edit `/etc/erpcore/erpcore.env` → Run workflow with **deploy_backend** (or `systemctl restart erpcore` + `bash deploy/scripts/wait-healthy.sh`) | +| Config change | edit the `env:` block of `.gitea/workflows/ci-cd.yml` (or the Gitea secret, for the two credentials) → merge → Run workflow with **deploy_backend**. `appsettings.Production.json` is regenerated on every backend deploy. | | Redeploy after a failure | fix the cause → Run workflow with the failed side ticked | | Roll back | merge a revert PR into `production` (it deploys like any change); see the limits above | | Logs | `journalctl -u erpcore -f` · `/var/log/erpcore/erpcore-*.log` · `docker logs -f erp-web` · `/var/log/httpd/erp.hexdive.com-error.log` | @@ -107,7 +106,7 @@ certbot --apache -d erp.hexdive.com | Job stays "Waiting" | runner not available to this repo/org, or offline | | **Every job red, `Set up job` took minutes, every later step 0 s** | **the `linux` label resolves to a container, not the host** — see below | | "Guard data paths" fails | `FileStorage__RootPath` / `LogFile__Path` missing, relative or inside `/home/erp/core/publish` | -| Verify backend fails in seconds with `Missing required configuration …` | a blank key in `/etc/erpcore/erpcore.env` | +| Verify backend fails in seconds with `Missing required configuration …` | a key is empty in the generated config — `jq 'paths(scalars) | join(":")' /home/erp/core/publish/appsettings.Production.json` lists the keys it actually got (keys only, no values) | | Verify backend times out after 300 s | DB unreachable (`/health` 503) or a very long migration. **Not** AuthHex/GL: `/health` is `AddDbContextCheck` only, so it depends on Postgres alone (§A.5) | | Login loops back to `/login` | not on HTTPS (cookies are Secure) | | Nothing deployed after a merge | the PR changed neither `Backend/ERPCore` nor `Frontend/erp-system` (the detect step prints the flags) | @@ -122,7 +121,7 @@ certbot --apache -d erp.hexdive.com | `no space left on device` | disk full | `docker system prune`, clear old dumps in `/var/backups/erpcore` | | `failed to create container` / dial or TLS errors | docker daemon or registry unreachable | fix the daemon / egress | -A docker-mapped runner is wrong here regardless of whether the pull succeeds: the deploy job calls `systemctl`, `runuser -u postgres pg_dump`, `chown erp:erp` and `docker build` **on the host**. Same requirement as the Fitness workflows, which is why their `linux` label is `host`. +A docker-mapped runner is wrong here regardless of whether the pull succeeds: the deploy job calls `systemctl`, `runuser -u postgres pg_dump`, `chown erpuser:erpuser` and `docker build` **on the host**. Same requirement as the Fitness workflows, which is why their `linux` label is `host`. ```bash systemctl cat act_runner | grep -i config # find the --config path @@ -141,7 +140,7 @@ Worth knowing before blaming a red deploy on an unwired dependency: - `MapHealthChecks("/health")` has **no fallback authorization policy** — it is anonymous, so `wait-healthy.sh` never gets a 401. - The only registered check is `AddDbContextCheck()`, so `/health` proves **Postgres** is reachable and nothing else. - The AuthHex and GL typed clients are configured in their `AddHttpClient` delegates, which run **per request**, not at startup. An unreachable AuthHex or GL breaks `/api/v1/auth/*` at runtime but does **not** stop the service or turn `/health` red. -- Startup hard-fails on five blank keys (`Program.cs`, `!IsDevelopment()`). Four come from `/etc/erpcore/erpcore.env`; the fifth, `Auth:RsaPublicKeyXml`, ships with a real value in `appsettings.json`. Blank `Auth__RequiredUserTypeCode` / `RequiredRoleCode` is deliberate — the door policy then requires only a valid ERP token. +- Startup hard-fails on five blank keys (`Program.cs`, `!IsDevelopment()`). Four come from the generated `appsettings.Production.json`; the fifth, `Auth:RsaPublicKeyXml`, ships with a real value in `appsettings.json`. Blank `Auth__RequiredUserTypeCode` / `RequiredRoleCode` is deliberate — the door policy then requires only a valid ERP token. So a backend deploy is expected to go **green with AuthHex still unwired**. `/health` 200 while `/api/v1/auth/login` returns 502/504 is the correct intermediate state, not a failed deploy. @@ -205,7 +204,7 @@ Git stores every folder as a **tree object** whose id is a hash of its exact con ## 4. Server bootstrap (one time) -> **On hold — do not follow this to set up production.** This bootstraps the two-environment layout: service user **`erpcore`** under `/opt/erpcore`. The active production deploy needs user **`erp`** under `/home/erp/core/publish` (`chown erp:erp` in the workflow, `User=erp` in `erpcore.service`) — use **§A.2** instead. §4.0 (Gitea prerequisites), §4.1 (runner, host tools, swap), §4.3 (Postgres) and §4.7 (branch protection) apply to both; §4.2 does not. +> **On hold — do not follow this to set up production.** This bootstraps the two-environment layout: service user **`erpcore`** under `/opt/erpcore`. The active production deploy needs user **`erpuser`** under `/home/erp/core/publish` (`chown erpuser:erpuser` in the workflow, `User=erpuser` in `erpcore.service`) — use **§A.2** instead. §4.0 (Gitea prerequisites), §4.1 (runner, host tools, swap), §4.3 (Postgres) and §4.7 (branch protection) apply to both; §4.2 does not. Run as root on the VPS. Keep names/ports in sync with §2. @@ -256,6 +255,9 @@ SQL Each role **owns** its database (migrations run DDL at startup) and cannot touch the other. Postgres stays localhost-only; `pg_hba.conf` already allows `127.0.0.1/32` with scram-sha-256. ### 4.4 Environment files → §5 +> `deploy/env/erpcore.env.example` was **deleted** when active production moved its config into the +> `ci-cd.yml` `env:` block + a systemd drop-in (§A.1 step 5). Restoring this two-environment design means +> recreating that template (it is in git history) or adopting the drop-in approach per environment. ```bash install -m 640 -o root -g erpcore deploy/env/erpcore.env.example /etc/erpcore/test.env install -m 640 -o root -g erpcore deploy/env/erpcore.env.example /etc/erpcore/release.env