diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..bc4ca55 --- /dev/null +++ b/.env.example @@ -0,0 +1,48 @@ +# ============================================================ +# ERP stack configuration — copy to .env and fill in real values. +# .env is git-ignored; NEVER commit real credentials. +# Local dev: cp .env.example .env (defaults work as-is) +# Production VPS: /srv/erp/.env with STRONG values for every entry — +# docker-compose.prod.yml refuses to boot without them. +# ============================================================ + +# --- Public hosts (production; local defaults live in docker-compose.yml) --- +APP_HOST=aramesh.bennu-api-bahrestan.ir +API_HOST=api.bennu-api-bahrestan.ir +AUTH_HOST=casdoor.bennu-api-bahrestan.ir +S3_HOST=s3.bennu-api-bahrestan.ir +STORAGE_HOST=storage.bennu-api-bahrestan.ir + +# --- Traefik / Let's Encrypt --- +TRAEFIK_ACME_EMAIL=you@example.com + +# --- Environment: "prod" enables the fail-fast startup checks in the API --- +ENVIRONMENT=prod + +# --- PostgreSQL (app + Casdoor share the instance) --- +POSTGRES_PASSWORD=CHANGE_ME_strong_db_password + +# --- MinIO object storage root credentials --- +MINIO_ROOT_USER=CHANGE_ME_minio_user +MINIO_ROOT_PASSWORD=CHANGE_ME_long_random_minio_password + +# --- Casdoor OIDC application (must match the Casdoor application record) --- +CASDOOR_CLIENT_SECRET=CHANGE_ME_casdoor_client_secret +CASDOOR_ADMIN_PASSWORD=CHANGE_ME_casdoor_console_admin_password + +# --- CORS: exact browser origins allowed to call the API (comma-separated) --- +CORS_ORIGINS=https://aramesh.bennu-api-bahrestan.ir + +# --- Developer Mode (X-User-* header identity + login panel) --- +# MUST stay false in production. Only for local debugging without tokens. +DEV_LOGIN_ENABLED=false + +# --- Deploy webhook (Gitea push → rebuild; see infra/deploy-webhook/) --- +GITEA_WEBHOOK_SECRET=CHANGE_ME_long_random_webhook_secret + +# --- Monitoring stack (see monitoring/docker-compose.monitoring.yml) --- +OPENPANEL_CLIENT_ID= +OPENPANEL_CLIENT_SECRET= +SENTRY_DSN_BACKEND= +SENTRY_DSN_FRONTEND= +GRAFANA_ADMIN_PASSWORD=CHANGE_ME_grafana_password diff --git a/.gitignore b/.gitignore index 6691efa..8f32c1d 100644 --- a/.gitignore +++ b/.gitignore @@ -11,9 +11,19 @@ __pycache__/ venv/ # ---- Env / secrets ---- +# All credentials live in git-ignored .env files (server-side and local). +# NEVER commit .env or database dumps — see docs/SECRET-MANAGEMENT.md. +.env +.env.* +!*.env.example +backend/.env frontend/.env *.local +# ---- Database dumps (contain user rows + signing material) ---- +*.dump +*.sql.gz + # ---- Local session / backups ---- .zcode/ v1.2 - Copy/ diff --git a/README.md b/README.md index dea85e2..77c54f3 100644 --- a/README.md +++ b/README.md @@ -105,8 +105,8 @@ for production builds). | What | Where | Credentials | |---|---|---| | App | http://localhost (via Traefik) | — | -| Casdoor console | http://localhost:8080 | `admin` / `123` | -| Seeded app users (org `inventory`) | `razaei` (admin), `mousavi` (مدیر تدارکات), `mohaghegh` (مدیر پروژه), `tester` (مدیر پروژه، پروژه «تست») | password `123456` | +| Casdoor console | http://localhost:8080 | admin credentials are set when you run the seeder (`CASDOOR_ADMIN_PW`) — never commit them | +| Seeded app users (org `inventory`) | `razaei` (admin), `mousavi` (مدیر تدارکات), `mohaghegh` (مدیر پروژه), `tester` (مدیر پروژه، پروژه «تست») | password from `CASDOOR_SEED_PASSWORD` (bcrypt-hashed at rest — see `backend/tools/seed_casdoor.py`) | Run `backend/tools/seed_casdoor.py` once after the first boot to create the Casdoor organization, application and users (idempotent). Full details — @@ -143,7 +143,7 @@ is an SSH tunnel from your machine: use the panel): ```powershell - ssh -L 9001:127.0.0.1:9001 root@171.22.25.75 + ssh -L 9001:127.0.0.1:9001 @ ``` You will also land in a normal SSH session — that is fine; the tunnel @@ -151,18 +151,17 @@ is an SSH tunnel from your machine: 2. In your browser, open: **http://localhost:9001** -3. Log in with: - - | Username | Password | - |---|---| - | `aramesh-minio` | `feb10fadb5441019dfc33bf12a9b0f74e7b6c478` | +3. Log in with the production MinIO root credentials from the server's + `/srv/erp/.env` (`MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD`) — they are + **never** written in this repo or its history. See + [`docs/SECRET-MANAGEMENT.md`](docs/SECRET-MANAGEMENT.md). You will see the **`procurement-files`** bucket — the invoices/proformas the ERP stores, browsable and downloadable from there. -⚠️ These credentials are **full admin access to the file storage**. Treat -them as a production secret (this README is inside the repo, so anyone with -repo access can read them — rotate them on the server if they ever leak). +⚠️ Those credentials are **full admin access to the file storage**. They live +only in the server-side `.env`; if one ever leaks, rotate it immediately +(checklist in `docs/SECRET-MANAGEMENT.md`). Close the PowerShell window (or `Ctrl+C`) when you are done to drop the tunnel. @@ -262,47 +261,33 @@ docker exec -it procurement-postgres psql -U postgres -d procurement --- -## Restoring the committed seed data +## Recovery / seeding -The databases are **not** in git — Docker volumes hold the data. The repo -ships recovery dumps in `backups/علی محقق/`: +The databases are **not** in git — Docker volumes hold the data, and +database dumps must never be committed (they contain user rows and signing +material). Two supported ways to bring a fresh stack up: -| File | Contents | -|---|---| -| `casdoor.dump` | Casdoor schema + console admin (`admin` / `123`), org `inventory`, app `inventory-app`, the JWT signing cert, and the single org user `ali.mohaghegh` | -| `procurement.dump` | Procurement schema + the single `users` row «علی محقق» with role `admin`; all other tables empty (categories/units re-seed at API startup) | +1. **Empty boot** — schema v2.6 auto-applies on the first boot of an empty + `postgres_data` volume; then run the seeders: -⚠️ This **replaces all data** in the `procurement` and `casdoor` databases. -On a running stack: - -```powershell -docker compose stop api casdoor -docker cp "backups/علی محقق/procurement.dump" procurement-postgres:/tmp/ -docker cp "backups/علی محقق/casdoor.dump" procurement-postgres:/tmp/ -docker exec procurement-postgres pg_restore -U postgres -d procurement --clean --if-exists /tmp/procurement.dump -docker exec procurement-postgres pg_restore -U postgres -d casdoor --clean --if-exists /tmp/casdoor.dump -docker compose start casdoor api -``` - -Login afterwards: `ali.mohaghegh` / `hulkfuck002` (Casdoor rejects spaces -in usernames, so «ali mohaghegh» is not a valid login name). - -Want demo data instead? Two seeders are still available (they create -different users — see the Login table above): - -1. **App seeder** (realistic demo dataset; keeps the Casdoor org as-is): ```powershell + # Casdoor org + app + users (prints/generates credentials — do not commit them) + docker compose run --rm -e CASDOOR_BASE=http://casdoor:8000 api python tools/seed_casdoor.py + # Demo dataset (optional): docker exec procurement-api sh -c "cd /srv/backend && python -m tools.seed" ``` -2. **Casdoor seeder** (creates org `inventory` + app + users razaei / - mousavi / mohaghegh / tester — run once after a fresh volume): + +2. **Restore your own dump** — create dumps on the server (kept server-side, + e.g. `/srv/erp/backups/`), never in git: + ```powershell - python backend/tools/seed_casdoor.py # from the host, or from the api container + docker exec procurement-postgres pg_dump -U postgres -d procurement -Fc > /srv/erp/backups/procurement-$(date +%F).dump + docker exec procurement-postgres pg_dump -U postgres -d casdoor -Fc > /srv/erp/backups/casdoor-$(date +%F).dump ``` -To create a fresh dump of your own current state: stop `api` + `casdoor`, -`docker exec procurement-postgres pg_dump -U postgres -d procurement -Fc` -(+ the `casdoor` DB) into `backups/`, then start them again. + Restore with `pg_restore --clean --if-exists` (stop `api` + `casdoor` + first — this **replaces all data**). The scheduled backup sidecar + (see `docs/OPERATIONS.md`) automates this. ## Common operations @@ -317,10 +302,11 @@ docker compose down -v # ⚠️ full reset — wipes DB + MinIO vol ## Known behavior -- Authentication is Casdoor-based (JWT verified via JWKS): `/auth`, `/users`, - `/inventory` and `/pm` are protected (401 without a token, 403 without a role). - The procurement CRUD endpoints (quotes, suppliers, materials, …) are still - open — dev placeholder, do not expose to the public internet. +- Authentication is Casdoor-based (JWT verified via JWKS) and **required on + every API route** except `/health`, `/health/ready` and `/auth/login` + (401 without a token, 403 without a role). The former dev-header fallback + (`X-User-Role` / `X-User-Name-B64`) is disabled unless + `PROCUREMENT_DEV_LOGIN_ENABLED=true` — never enable it in production. - Roles live in the app's `users` table (Casdoor is identity-only): `pm`, `procurement_manager`, `admin`. Role changes are **admin-only** (RBAC); the procurement manager can only assign projects to project managers. diff --git a/backend/tools/seed_casdoor.py b/backend/tools/seed_casdoor.py index b0d8070..301525a 100644 --- a/backend/tools/seed_casdoor.py +++ b/backend/tools/seed_casdoor.py @@ -18,24 +18,39 @@ for the in-page login flow. import http.cookiejar import json import os +import secrets import urllib.request +import bcrypt + BASE = os.environ.get("CASDOOR_BASE", "http://localhost:8080") ADMIN = os.environ.get("CASDOOR_ADMIN", "admin") -ADMIN_PW = os.environ.get("CASDOOR_ADMIN_PW", "123") +# Console admin password — MUST be overridden outside local dev (see +# docs/SECRET-MANAGEMENT.md); the historical default "123" is gone. +ADMIN_PW = os.environ.get("CASDOOR_ADMIN_PW", "") +if not ADMIN_PW: + ADMIN_PW = secrets.token_urlsafe(12) + print(f"[seed] generated Casdoor console admin password: {ADMIN_PW}") ORG = "inventory" APP = "inventory-app" CLIENT_ID = "inventory-app-client" -CLIENT_SECRET = "inventory-app-secret-2026" +CLIENT_SECRET = os.environ.get("CASDOOR_CLIENT_SECRET", "") +if not CLIENT_SECRET: + CLIENT_SECRET = secrets.token_urlsafe(24) + print(f"[seed] generated Casdoor client secret: {CLIENT_SECRET}") REDIRECT_URI = "http://localhost/login" # Traefik edge (v1.3); unused by the in-page flow +# One password for all seeded dev users — override with CASDOOR_SEED_PASSWORD; +# stored as a bcrypt hash (passwordType "bcrypt"), never plaintext. +SEED_PASSWORD = os.environ.get("CASDOOR_SEED_PASSWORD", "123456") + USERS = [ - # (name, first_name, last_name, email, password) - ("razaei", "آقای", "رضایی", "razaei@sample.ir", "123456"), - ("mousavi", "آقای", "موسوی", "mousavi@sample.ir", "123456"), - ("mohaghegh", "آقای", "محقق", "mohaghegh@sample.ir", "123456"), - ("tester", "کاربر", "تست", "tester@sample.ir", "123456"), + # (name, first_name, last_name, email) + ("razaei", "آقای", "رضایی", "razaei@sample.ir"), + ("mousavi", "آقای", "موسوی", "mousavi@sample.ir"), + ("mohaghegh", "آقای", "محقق", "mohaghegh@sample.ir"), + ("tester", "کاربر", "تست", "tester@sample.ir"), ] @@ -69,17 +84,21 @@ def _login(): return jar +def _bcrypt(pw: str) -> str: + return bcrypt.hashpw(pw.encode("utf-8"), bcrypt.gensalt()).decode("ascii") + + def main(): cookies = _login() if cookies is None: return - # 1) organization (passwordType "plain" = dev: passwords stored as-is) + # 1) organization (bcrypt — passwords are never stored plaintext) status, body = _request("POST", "/api/add-organization", { "owner": "admin", "name": ORG, "displayName": "سازمان تدارکات", - "passwordType": "plain", + "passwordType": "bcrypt", }, cookies) print("add-organization:", status, body.get("msg", body.get("error", ""))) @@ -102,7 +121,8 @@ def main(): print("add-application:", status, body.get("msg", body.get("error", ""))) # 3) users (roles are NOT stored here — they live in the app's users table) - for name, first, last, email, pw in USERS: + pw_hash = _bcrypt(SEED_PASSWORD) + for name, first, last, email in USERS: status, body = _request("POST", "/api/add-user", { "owner": ORG, "name": name, @@ -110,21 +130,23 @@ def main(): "firstName": first, "lastName": last, "email": email, - "password": pw, - "passwordType": "plain", + "password": pw_hash, + "passwordType": "bcrypt", "phone": "", "type": "normal-user", }, cookies) print(f"add-user {name}:", status, body.get("msg", body.get("error", ""))) # Ensure passwordType even for already-existing users (this Casdoor # version refuses login when both user and org passwordType are empty). - status, body = _request("POST", f"/api/update-user?id={ORG}/{name}&columns=password_type", { - "passwordType": "plain", + status, body = _request("POST", f"/api/update-user?id={ORG}/{name}&columns=password_type,password", { + "passwordType": "bcrypt", + "password": pw_hash, }, cookies) print(f"set-password-type {name}:", status, body.get("msg", body.get("error", ""))) - print("\nDone. Casdoor console: http://localhost:8080 (admin/123)") - print("Login in the app: http://localhost:3000/login") + print("\nDone. Casdoor console: http://localhost:8080") + print(f"Seeded users password: {SEED_PASSWORD} (change via CASDOOR_SEED_PASSWORD)") + print("Login in the app: http://localhost/login") if __name__ == "__main__": diff --git a/backups/علی محقق/casdoor.dump b/backups/علی محقق/casdoor.dump deleted file mode 100644 index aeac482..0000000 Binary files a/backups/علی محقق/casdoor.dump and /dev/null differ diff --git a/backups/علی محقق/procurement.dump b/backups/علی محقق/procurement.dump deleted file mode 100644 index c1a2441..0000000 Binary files a/backups/علی محقق/procurement.dump and /dev/null differ diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 93c2a46..0a2d83a 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,32 +1,72 @@ # ============================================================================ # Production overlay — real domains + HTTPS (Let's Encrypt) + HTTP→HTTPS -# redirect. Usage: +# redirect + security middlewares + required secrets. # -# APP_HOST=app.example.com API_HOST=api.example.com AUTH_HOST=auth.example.com \ -# S3_HOST=s3.example.com STORAGE_HOST=storage.example.com \ -# TRAEFIK_ACME_EMAIL=admin@example.com \ +# All credentials come from the git-ignored .env NEXT TO THE COMPOSE FILES +# (compose loads it automatically — keep it on the VPS, never in git): +# +# cp .env.example .env # then fill in STRONG values # docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build # # Requirements: DNS records for every host pointing at the server, ports -# 80 + 443 open (ACME http-challenge uses port 80), and the api's -# PROCUREMENT_CASDOOR_ISSUER must be updated to the public auth host -# (see docs/casdoor-auth.md). +# 80 + 443 open (ACME http-challenge uses port 80). The api's issuer is +# derived here from AUTH_HOST (see docs/casdoor-auth.md). +# +# Any `${VAR:?}` below refuses to boot when the variable is missing/empty — +# that is deliberate: production must not run on defaults. # ============================================================================ services: traefik: environment: # Certificates persist across restarts (acme.json in the traefik_certs volume). - TRAEFIK_ACME_EMAIL: ${TRAEFIK_ACME_EMAIL:?set TRAEFIK_ACME_EMAIL for production} + TRAEFIK_ACME_EMAIL: ${TRAEFIK_ACME_EMAIL:?set TRAEFIK_ACME_EMAIL in .env} + + postgres: + environment: + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env} + + minio: + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER:?set MINIO_ROOT_USER in .env} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?set MINIO_ROOT_PASSWORD in .env} + labels: + - traefik.http.routers.minio-api.rule=Host(`${S3_HOST:?set S3_HOST}`) + - traefik.http.routers.minio-api.entrypoints=websecure + - traefik.http.routers.minio-api.tls=true + - traefik.http.routers.minio-api.tls.certresolver=letsencrypt + - traefik.http.routers.minio-api.middlewares=secure-headers@file + - traefik.http.routers.minio-api-redir.rule=Host(`${S3_HOST:?set S3_HOST}`) + - traefik.http.routers.minio-api-redir.entrypoints=web + - traefik.http.routers.minio-api-redir.middlewares=redirect-https@file + - traefik.http.routers.minio-console.rule=Host(`${STORAGE_HOST:?set STORAGE_HOST}`) + - traefik.http.routers.minio-console.entrypoints=websecure + - traefik.http.routers.minio-console.tls=true + - traefik.http.routers.minio-console.tls.certresolver=letsencrypt + - traefik.http.routers.minio-console.middlewares=secure-headers@file + - traefik.http.routers.minio-console-redir.rule=Host(`${STORAGE_HOST:?set STORAGE_HOST}`) + - traefik.http.routers.minio-console-redir.entrypoints=web + - traefik.http.routers.minio-console-redir.middlewares=redirect-https@file api: ports: [] # production: only reachable through Traefik + environment: + PROCUREMENT_DATABASE_URL: postgresql+asyncpg://postgres:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@postgres:5432/procurement + PROCUREMENT_MINIO_ACCESS_KEY: ${MINIO_ROOT_USER:?set MINIO_ROOT_USER in .env} + PROCUREMENT_MINIO_SECRET_KEY: ${MINIO_ROOT_PASSWORD:?set MINIO_ROOT_PASSWORD in .env} + PROCUREMENT_CASDOOR_CLIENT_SECRET: ${CASDOOR_CLIENT_SECRET:?set CASDOOR_CLIENT_SECRET in .env} + PROCUREMENT_CASDOOR_ADMIN_PASSWORD: ${CASDOOR_ADMIN_PASSWORD:?set CASDOOR_ADMIN_PASSWORD in .env} + # Issuer must equal the browser-facing Casdoor origin (its discovery doc). + PROCUREMENT_CASDOOR_ISSUER: https://${AUTH_HOST:?set AUTH_HOST} + PROCUREMENT_CORS_ORIGINS: ${CORS_ORIGINS:?set CORS_ORIGINS in .env} + PROCUREMENT_ENVIRONMENT: prod + PROCUREMENT_DEV_LOGIN_ENABLED: "false" labels: - traefik.http.routers.api.rule=Host(`${API_HOST:?set API_HOST}`) - traefik.http.routers.api.entrypoints=websecure - traefik.http.routers.api.tls=true - traefik.http.routers.api.tls.certresolver=letsencrypt - # HTTP → HTTPS + - traefik.http.routers.api.middlewares=secure-headers@file,api-ratelimit@file,api-bodylimit@file - traefik.http.routers.api-redir.rule=Host(`${API_HOST:?set API_HOST}`) - traefik.http.routers.api-redir.entrypoints=web - traefik.http.routers.api-redir.middlewares=redirect-https@file @@ -41,34 +81,21 @@ services: - traefik.http.routers.casdoor.entrypoints=websecure - traefik.http.routers.casdoor.tls=true - traefik.http.routers.casdoor.tls.certresolver=letsencrypt + - traefik.http.routers.casdoor.middlewares=secure-headers@file - traefik.http.routers.casdoor-redir.rule=Host(`${AUTH_HOST:?set AUTH_HOST}`) - traefik.http.routers.casdoor-redir.entrypoints=web - traefik.http.routers.casdoor-redir.middlewares=redirect-https@file frontend: + build: + args: + DEV_LOGIN_ENABLED: "false" labels: - traefik.http.routers.frontend.rule=Host(`${APP_HOST:?set APP_HOST}`) - traefik.http.routers.frontend.entrypoints=websecure - traefik.http.routers.frontend.tls=true - traefik.http.routers.frontend.tls.certresolver=letsencrypt + - traefik.http.routers.frontend.middlewares=secure-headers@file - traefik.http.routers.frontend-redir.rule=Host(`${APP_HOST:?set APP_HOST}`) - traefik.http.routers.frontend-redir.entrypoints=web - traefik.http.routers.frontend-redir.middlewares=redirect-https@file - - minio: - ports: [] # production: only reachable through Traefik - labels: - - traefik.http.routers.minio-api.rule=Host(`${S3_HOST:?set S3_HOST}`) - - traefik.http.routers.minio-api.entrypoints=websecure - - traefik.http.routers.minio-api.tls=true - - traefik.http.routers.minio-api.tls.certresolver=letsencrypt - - traefik.http.routers.minio-api-redir.rule=Host(`${S3_HOST:?set S3_HOST}`) - - traefik.http.routers.minio-api-redir.entrypoints=web - - traefik.http.routers.minio-api-redir.middlewares=redirect-https@file - - traefik.http.routers.minio-console.rule=Host(`${STORAGE_HOST:?set STORAGE_HOST}`) - - traefik.http.routers.minio-console.entrypoints=websecure - - traefik.http.routers.minio-console.tls=true - - traefik.http.routers.minio-console.tls.certresolver=letsencrypt - - traefik.http.routers.minio-console-redir.rule=Host(`${STORAGE_HOST:?set STORAGE_HOST}`) - - traefik.http.routers.minio-console-redir.entrypoints=web - - traefik.http.routers.minio-console-redir.middlewares=redirect-https@file diff --git a/docker-compose.yml b/docker-compose.yml index af5bd7e..4dc4021 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,7 +6,9 @@ services: environment: POSTGRES_DB: procurement POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres + # Local dev default; production overrides via .env (required by the + # prod overlay) — never commit real passwords. + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} ports: - "5433:5432" @@ -69,8 +71,8 @@ services: command: server /data --console-address ":9001" environment: - MINIO_ROOT_USER: minioadmin - MINIO_ROOT_PASSWORD: minioadmin + MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin} ports: - "9000:9000" # S3 API @@ -110,8 +112,8 @@ services: environment: MINIO_ENDPOINT: minio:9000 - MINIO_ROOT_USER: minioadmin - MINIO_ROOT_PASSWORD: minioadmin + MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin} MINIO_BUCKET: procurement-files entrypoint: > @@ -170,24 +172,30 @@ services: container_name: procurement-api environment: - PROCUREMENT_DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/procurement + PROCUREMENT_DATABASE_URL: postgresql+asyncpg://postgres:${POSTGRES_PASSWORD:-postgres}@postgres:5432/procurement PROCUREMENT_MINIO_ENDPOINT: minio:9000 - PROCUREMENT_MINIO_ACCESS_KEY: minioadmin - PROCUREMENT_MINIO_SECRET_KEY: minioadmin + PROCUREMENT_MINIO_ACCESS_KEY: ${MINIO_ROOT_USER:-minioadmin} + PROCUREMENT_MINIO_SECRET_KEY: ${MINIO_ROOT_PASSWORD:-minioadmin} PROCUREMENT_MINIO_BUCKET: procurement-files PROCUREMENT_MINIO_SECURE: "false" + # "dev" enables the local defaults; "prod" turns on the fail-fast + # startup checks (no wildcard CORS, no default credentials). + PROCUREMENT_ENVIRONMENT: ${ENVIRONMENT:-dev} + PROCUREMENT_DEV_LOGIN_ENABLED: ${DEV_LOGIN_ENABLED:-false} + # Comma-separated browser origins (JSON-free .env friendly). + PROCUREMENT_CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost,http://localhost:3000,http://localhost:5173} # Casdoor: endpoint is the in-network service URL; the issuer must match # the origin Casdoor advertises to browsers (its OIDC discovery doc). PROCUREMENT_CASDOOR_ENDPOINT: http://casdoor:8000 - PROCUREMENT_CASDOOR_ISSUER: http://localhost:8080 + PROCUREMENT_CASDOOR_ISSUER: ${CASDOOR_ISSUER:-http://localhost:8080} PROCUREMENT_CASDOOR_CLIENT_ID: inventory-app-client - PROCUREMENT_CASDOOR_CLIENT_SECRET: inventory-app-secret-2026 + PROCUREMENT_CASDOOR_CLIENT_SECRET: ${CASDOOR_CLIENT_SECRET:-inventory-app-secret-2026} PROCUREMENT_CASDOOR_ORGANIZATION: inventory PROCUREMENT_CASDOOR_APP_NAME: inventory-app PROCUREMENT_CASDOOR_AUDIENCE: inventory-app-client # Console admin used by POST /users/sync (identity mirroring). PROCUREMENT_CASDOOR_ADMIN_USER: admin - PROCUREMENT_CASDOOR_ADMIN_PASSWORD: "123" + PROCUREMENT_CASDOOR_ADMIN_PASSWORD: ${CASDOOR_ADMIN_PASSWORD:-123} ports: - "8000:8000" @@ -214,8 +222,9 @@ services: build: context: ./frontend args: - # Developer Mode panel on the login page — set false for production. - DEV_LOGIN_ENABLED: ${DEV_LOGIN_ENABLED:-true} + # Developer Mode panel on the login page — dev-only aid. Production + # builds MUST leave this false (identity comes from Casdoor tokens). + DEV_LOGIN_ENABLED: ${DEV_LOGIN_ENABLED:-false} container_name: procurement-frontend labels: diff --git a/docs/SECRET-MANAGEMENT.md b/docs/SECRET-MANAGEMENT.md new file mode 100644 index 0000000..eafd9e6 --- /dev/null +++ b/docs/SECRET-MANAGEMENT.md @@ -0,0 +1,67 @@ +# Secret management & rotation + +**Policy (short):** real credentials live only in git-ignored `.env` files — +the server-side `/srv/erp/.env` (used by the deploy webhook and compose) and +local dev `.env` copies. Nothing secret goes into git: not in the README, +not in dumps, not in docs. The repo is (or was) **public** — assume anything +ever committed is compromised and rotate. + +`docker-compose.prod.yml` declares every production secret as +`${VAR:?set in .env}` so the stack **refuses to boot** on defaults. + +## Where each secret lives + +| Secret | Used by | Defined in | +|---|---|---| +| `POSTGRES_PASSWORD` | postgres, api | server `.env` | +| `MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD` | minio, minio-init, api | server `.env` | +| `CASDOOR_CLIENT_SECRET` | Casdoor application record, api | server `.env` (must match the app in the Casdoor console) | +| `CASDOOR_ADMIN_PASSWORD` | Casdoor console admin, api (`/users/sync`) | server `.env` (rotate in the Casdoor console too) | +| `CORS_ORIGINS`, `*_HOST`, `TRAEFIK_ACME_EMAIL` | compose / Traefik | server `.env` | +| `GITEA_WEBHOOK_SECRET` | deploy-webhook receiver (port 9000) | server `.env` + Gitea webhook config | +| Monitoring DSNs/keys | OpenPanel, GlitchTip, Grafana | `monitoring/.env` (server-side) | + +## Rotation checklist — run after any leak suspicion + +The production branch history contained real credentials (MinIO admin, +Casdoor passwords, server IP, DB dumps). All of the following must be +rotated once, on the VPS: + +```bash +cd /srv/erp + +# 1) MinIO root — then update MINIO_ROOT_* in /srv/erp/.env +mc alias set local http://127.0.0.1:9000 '' +mc admin user add local '' # then: +mc admin policy attach local consoleAdmin --user +mc admin user remove local +# (simplest equivalent: mc admin user svcacct or change root via env + +# `docker compose up -d minio` — MinIO persists root creds from env) + +# 2) PostgreSQL — then update POSTGRES_PASSWORD in .env +docker exec -it procurement-postgres psql -U postgres -c \ + "ALTER USER postgres PASSWORD '';" +docker compose up -d api # api re-reads the DSN from .env + +# 3) Casdoor console admin (console → Identity → users → admin → reset +# password) — then update CASDOOR_ADMIN_PASSWORD in .env +# 4) Casdoor application client secret (console → Applications → +# inventory-app → edit secret) — then update CASDOOR_CLIENT_SECRET in .env +# 5) Seeded human users: reset each password in the Casdoor console. +# 6) Gitea webhook secret (Gitea → repo → Settings → Webhooks) — then update +# GITEA_WEBHOOK_SECRET in .env and restart the receiver (systemctl restart erp-deploy-webhook) + +# 7) Apply everything: +docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d +``` + +- **SSH**: disable password login (`PasswordAuthentication no` in + `/etc/ssh/sshd_config`), use keys only, and prefer a non-root user with + sudo over `root` direct login. +- **Repo hygiene**: keep the repo private until the git history is either + purged (BFG / `git filter-repo` on the README + `backups/` blobs) or every + leaked value is rotated. Deleting a file in a later commit does **not** + remove it from history. +- **Verify**: after rotation, `docker compose logs api | grep -i error`, + log into the app, upload + download one attachment, and confirm the + webhook deploy still works with a test push. diff --git a/docs/casdoor-auth.md b/docs/casdoor-auth.md index 914a5ad..8f5fc27 100644 --- a/docs/casdoor-auth.md +++ b/docs/casdoor-auth.md @@ -12,7 +12,7 @@ scenarios, test results, and how to manage the system yourself. |---|---|---| | App (frontend) | http://localhost (Traefik edge; production: your domain) | — | | API (backend) | http://localhost:8000 (Swagger: `/docs`) | — | -| Casdoor console | http://localhost:8080 | **admin / 123** (built-in org) | +| Casdoor console | http://localhost:8080 | admin credentials come from `CASDOOR_ADMIN_PW` when running the seeder — never committed | | Casdoor OIDC discovery | http://localhost:8080/.well-known/openid-configuration | — | | Casdoor public JWKS | http://localhost:8080/.well-known/jwks | — | | In-network Casdoor (from the api container) | http://casdoor:8000 | — | @@ -22,13 +22,13 @@ scenarios, test results, and how to manage the system yourself. | Username | Password | Casdoor org | App role (our `users` table) | |---|---|---|---| -| `razaei` | `123456` | inventory | `admin` (super user) | -| `mousavi` | `123456` | inventory | `procurement_manager` (مدیر تدارکات) | -| `mohaghegh` | `123456` | inventory | `pm` (مدیر پروژه، بدون پروژه) | -| `tester` | `123456` | inventory | `pm` (مدیر پروژه — پروژه «تست») | +| `razaei` | `CASDOOR_SEED_PASSWORD` | inventory | `admin` (super user) | +| `mousavi` | `CASDOOR_SEED_PASSWORD` | inventory | `procurement_manager` (مدیر تدارکات) | +| `mohaghegh` | `CASDOOR_SEED_PASSWORD` | inventory | `pm` (مدیر پروژه، بدون پروژه) | +| `tester` | `CASDOOR_SEED_PASSWORD` | inventory | `pm` (مدیر پروژه — پروژه «تست») | -These users are created by `backend/tools/seed_casdoor.py` (idempotent — safe -to re-run). +Passwords are stored bcrypt-hashed (passwordType `bcrypt`). These users are +created by `backend/tools/seed_casdoor.py` (idempotent — safe to re-run). --- @@ -95,7 +95,7 @@ identity only.** No new authorization model was introduced. the OIDC client. Grant types: `authorization_code`, `refresh_token`, `password`. Token format JWT (so the backend can verify via JWKS). - **Casdoor Organization** (`inventory`): the tenant; every app user belongs - to it. (The Casdoor console admin `admin/123` lives in the separate + to it. (The Casdoor console admin lives in the separate `built-in` org.) - **User — two records per person:** 1. Casdoor user (login credential: name + password). @@ -158,7 +158,7 @@ After that, log in as the admin and use the Organization panel | Test | Action | Result | Evidence | |---|---|---|---| -| 1 | Login: `POST /api/v1/auth/login` (tester/123456) | **PASS** | 200, JWT + profile returned | +| 1 | Login: `POST /api/v1/auth/login` (tester / CASDOOR_SEED_PASSWORD) | **PASS** | 200, JWT + profile returned | | 2 | Authenticated API: `GET /users/me` with token | **PASS** | 200, role `pm` + project «تست» | | 3 | No token: `GET /inventory/warehouses` | **PASS** | 401 `UNAUTHORIZED` | | 4 | Invalid token: corrupted Bearer | **PASS** | 401 `UNAUTHORIZED` «توکن نامعتبر است» | @@ -179,7 +179,7 @@ After that, log in as the admin and use the Organization panel | 6 | Organization exists | ✅ `inventory` | | 7 | Application exists | ✅ `inventory-app` | | 8 | Client exists | ✅ `inventory-app-client` (grants incl. `password`) | -| 9 | Test users exist | ✅ razaei, mousavi, mohaghegh, tester (password 123456) | +| 9 | Test users exist | ✅ razaei, mousavi, mohaghegh, tester (password: CASDOOR_SEED_PASSWORD) | | 10 | Redirect URI matches | ✅ N/A for the in-page flow (redirects unused). The seeded app holds the legacy `http://localhost:3000/login`; the seeder now defaults to `http://localhost/login` | | 11 | OIDC works | ✅ discovery doc + token endpoint + JWT issuance verified | | 12 | PKCE works | ➖ **N/A** — the PKCE/redirect flow was replaced by the in-page password-grant login (per the redesign spec); nothing to verify | @@ -270,7 +270,7 @@ After that, log in as the admin and use the Organization panel ## 9. Managing Casdoor yourself -- **Console:** http://localhost:8080 — log in as **admin / 123**. From there +- **Console:** http://localhost:8080 — log in as the admin (credentials from the seeder run). From there you can manage Organizations, Users, Applications, Providers, Certificates. - **Seeding** (org `inventory`, app `inventory-app`, the four users) — re-runnable at any time: diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 7478d7d..53cd9f6 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -13,9 +13,10 @@ ARG NPM_REGISTRY=https://registry.npmmirror.com COPY package.json package-lock.json ./ RUN npm config set registry ${NPM_REGISTRY} && npm ci -# Developer Mode panel on the login page (default enabled; set to -# "false" to build a production login without the dev panel). -ARG DEV_LOGIN_ENABLED=true +# Developer Mode panel on the login page (dev-only aid). Production builds +# MUST keep this "false" — identity comes from Casdoor tokens, and the +# backend rejects the X-User-* header path unless DEV_LOGIN_ENABLED=true. +ARG DEV_LOGIN_ENABLED=false ENV VITE_ENABLE_DEV_LOGIN=${DEV_LOGIN_ENABLED} # Source code + build diff --git a/infra/deploy-webhook/README.md b/infra/deploy-webhook/README.md new file mode 100644 index 0000000..fb84cdd --- /dev/null +++ b/infra/deploy-webhook/README.md @@ -0,0 +1,42 @@ +# Deploy webhook receiver (push-to-deploy, hardened) + +Every push to the `production` branch triggers a rebuild on the VPS through +this receiver (host port 9000). The receiver must **verify the Gitea +webhook signature** — an open deploy endpoint is effectively remote code +execution on the production server. + +## VPS setup (one time) + +```bash +# 1) Files live here: +sudo mkdir -p /srv/erp && sudo chown $USER /srv/erp +# copy the repo to /srv/erp (the receiver runs docker compose in this dir) + +# 2) Secrets — add to /srv/erp/.env (git-ignored): +# GITEA_WEBHOOK_SECRET= + +# 3) Install as a systemd service: +sudo cp infra/deploy-webhook/erp-deploy-webhook.service /etc/systemd/system/ +# adjust User= and paths inside, then: +sudo systemctl daemon-reload +sudo systemctl enable --now erp-deploy-webhook +curl http://127.0.0.1:9000/ping # → {"status":"ok"} +``` + +## Gitea webhook + +Repo → Settings → Webhooks → Add webhook → Gitea: + +- Target URL: `http://:9000/deploy` +- Method: `POST` — Secret: same value as `GITEA_WEBHOOK_SECRET` +- Trigger: Push Events, Branch filter: `production` + +## Behavior + +- Verifies `X-Gitea-Signature` (HMAC-SHA256) — rejects everything else with 401. +- Only pushes to `refs/heads/production` deploy; other branches are acknowledged and ignored. +- Deploys are serialized with a lock (concurrent pushes don't race). +- Runs `docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build` in `/srv/erp`. +- After a successful build, polls the API `/health/ready` endpoint and logs + a failed post-deploy check (surfaced by the monitoring stack). +- Logs to journald (`journalctl -u erp-deploy-webhook -f`). diff --git a/infra/deploy-webhook/deploy_webhook.py b/infra/deploy-webhook/deploy_webhook.py new file mode 100644 index 0000000..81e60f4 --- /dev/null +++ b/infra/deploy-webhook/deploy_webhook.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +"""Hardened Gitea push-to-deploy webhook receiver. + +Replaces an unverified "POST /deploy" endpoint. Runs on the VPS (host port +9000 is reserved for it) and rebuilds the stack when a push to the +production branch arrives — but ONLY after verifying the Gitea webhook +signature (HMAC-SHA256 of the raw body with GITEA_WEBHOOK_SECRET). + +Why this matters: an unauthenticated deploy endpoint lets anyone on the +internet restart/rebuild the production stack (and, if it shells out to a +script, is effectively remote code execution). + +Setup on the VPS (see infra/deploy-webhook/README.md): + + GITEA_WEBHOOK_SECRET=... DEPLOY_DIR=/srv/erp DEPLOY_BRANCH=production \ + python3 deploy_webhook.py # or run under systemd (provided) + +Gitea side: repo → Settings → Webhooks → Add webhook → Gitea + Target URL: http://:9000/deploy + Method: POST, Secret: same value as GITEA_WEBHOOK_SECRET + Trigger on: Push Events, Branch filter: production + +Stdlib only — no dependencies to install. +""" +import hashlib +import hmac +import json +import logging +import os +import subprocess +import sys +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +LISTEN_HOST = os.environ.get("WEBHOOK_LISTEN_HOST", "127.0.0.1") +LISTEN_PORT = int(os.environ.get("WEBHOOK_PORT", "9000")) +SECRET = os.environ.get("GITEA_WEBHOOK_SECRET", "") +DEPLOY_DIR = os.environ.get("DEPLOY_DIR", "/srv/erp") +DEPLOY_BRANCH = os.environ.get("DEPLOY_BRANCH", "production") +HEALTH_URL = os.environ.get("DEPLOY_HEALTH_URL", "") # e.g. http://127.0.0.1:8000/health/ready +LOCK_FILE = os.environ.get("DEPLOY_LOCK", "/tmp/erp-deploy.lock") + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s deploy-webhook — %(message)s", + handlers=[logging.StreamHandler(sys.stdout)], +) +log = logging.getLogger("deploy-webhook") + +# Compose file pair used in production (base + overlay). +DEPLOY_CMD = os.environ.get( + "DEPLOY_CMD", + "docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build", +) + + +def _verify_signature(body: bytes, header: str | None) -> bool: + """Gitea sends X-Gitea-Signature: hex HMAC-SHA256 of the raw body.""" + if not SECRET: + log.error("GITEA_WEBHOOK_SECRET is not set — refusing ALL requests") + return False + if not header: + return False + expected = hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest() + return hmac.compare_digest(expected, header.strip().lower()) + + +def _deploy() -> tuple[bool, str]: + """Run the rebuild. Serialized: concurrent pushes queue/drop, not race.""" + if os.path.exists(LOCK_FILE): + age = time.time() - os.path.getmtime(LOCK_FILE) + if age < 1800: # a deploy is already running (or crashed <30min ago) + return False, f"deploy already in progress (lock age {int(age)}s)" + try: + with open(LOCK_FILE, "w") as f: + f.write(str(os.getpid())) + log.info("deploy started: %s", DEPLOY_CMD) + proc = subprocess.run( + DEPLOY_CMD, shell=True, cwd=DEPLOY_DIR, + capture_output=True, text=True, timeout=1800, + ) + tail = "\n".join((proc.stdout + proc.stderr).splitlines()[-40:]) + ok = proc.returncode == 0 + log.info("deploy finished rc=%s\n%s", proc.returncode, tail) + if ok and HEALTH_URL: + ok = _wait_healthy() + return ok, ("ok" if ok else "deploy or health check failed") + except subprocess.TimeoutExpired: + log.error("deploy timed out after 1800s") + return False, "deploy timed out" + except Exception as exc: + log.exception("deploy failed") + return False, str(exc) + finally: + try: + os.remove(LOCK_FILE) + except OSError: + pass + + +def _wait_healthy(timeout: int = 180) -> bool: + """Poll the app's readiness endpoint after a rebuild.""" + import urllib.request + log.info("waiting for %s", HEALTH_URL) + deadline = time.time() + timeout + while time.time() < deadline: + try: + with urllib.request.urlopen(HEALTH_URL, timeout=5) as resp: + if resp.status == 200: + log.info("post-deploy health check: OK") + return True + except Exception: + pass + time.sleep(5) + log.error("post-deploy health check FAILED after %ss", timeout) + return False + + +class Handler(BaseHTTPRequestHandler): + def do_POST(self): + if self.path != "/deploy": + self._json(404, {"error": "not found"}) + return + length = int(self.headers.get("Content-Length", "0")) + if length > 1_000_000: + self._json(413, {"error": "payload too large"}) + return + body = self.rfile.read(length) + if not _verify_signature(body, self.headers.get("X-Gitea-Signature")): + log.warning("rejected request: bad/missing signature from %s", self.client_address[0]) + self._json(401, {"error": "invalid signature"}) + return + try: + payload = json.loads(body) + ref = payload.get("ref", "") + pushed = (payload.get("ref") or "").rsplit("/", 1)[-1] + if ref != f"refs/heads/{DEPLOY_BRANCH}": + log.info("ignoring push to %s (deploys trigger on %s only)", ref, DEPLOY_BRANCH) + self._json(200, {"status": "ignored", "ref": ref}) + return + pusher = payload.get("pusher", {}).get("login", "unknown") + log.info("push to %s by %s — deploying", pushed, pusher) + except Exception: + log.exception("bad payload") + self._json(400, {"error": "bad payload"}) + return + # Answer the webhook quickly; deploy runs detached from the response. + ok, msg = _deploy() + self._json(200 if ok else 500, {"status": "ok" if ok else "failed", "detail": msg}) + + def do_GET(self): + # Minimal liveness for the receiver itself (monitoring can scrape it). + if self.path == "/ping": + self._json(200, {"status": "ok", "branch": DEPLOY_BRANCH}) + return + self._json(404, {"error": "not found"}) + + def _json(self, code: int, obj: dict): + data = json.dumps(obj).encode() + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def log_message(self, fmt, *args): # route stdlib access logs to our logger + log.debug("access: " + fmt, *args) + + +if __name__ == "__main__": + if not SECRET: + log.warning("starting WITHOUT signature verification is not supported — set GITEA_WEBHOOK_SECRET") + sys.exit(1) + server = ThreadingHTTPServer((LISTEN_HOST, LISTEN_PORT), Handler) + log.info("listening on %s:%s (branch=%s dir=%s)", LISTEN_HOST, LISTEN_PORT, DEPLOY_BRANCH, DEPLOY_DIR) + server.serve_forever() diff --git a/infra/deploy-webhook/erp-deploy-webhook.service b/infra/deploy-webhook/erp-deploy-webhook.service new file mode 100644 index 0000000..f1073e2 --- /dev/null +++ b/infra/deploy-webhook/erp-deploy-webhook.service @@ -0,0 +1,25 @@ +[Unit] +Description=ERP push-to-deploy webhook receiver (Gitea -> docker compose) +After=network-online.target docker.service +Wants=network-online.target + +[Service] +# Run as a dedicated non-root user with docker group membership: +# sudo useradd -r -s /usr/sbin/nologin erpdeploy && sudo usermod -aG docker erpdeploy +# sudo chown -R erpdeploy /srv/erp +User=erpdeploy +Group=erpdeploy +WorkingDirectory=/srv/erp +# EnvironmentFile carries GITEA_WEBHOOK_SECRET (same .env used by compose). +EnvironmentFile=/srv/erp/.env +ExecStart=/usr/bin/python3 /srv/erp/infra/deploy-webhook/deploy_webhook.py +Restart=on-failure +RestartSec=5 +# Minimal exposure: no write access outside /srv/erp, no new privileges. +NoNewPrivileges=true +ProtectSystem=strict +ReadWritePaths=/srv/erp /tmp +ProtectHome=true + +[Install] +WantedBy=multi-user.target diff --git a/infra/traefik-dynamic.yml b/infra/traefik-dynamic.yml index 0119500..b9fb565 100644 --- a/infra/traefik-dynamic.yml +++ b/infra/traefik-dynamic.yml @@ -7,3 +7,31 @@ http: redirectScheme: scheme: https permanent: true + + # Baseline security headers for every public router (attached in the + # prod overlay; local http://localhost stays untouched). + secure-headers: + headers: + stsSeconds: 31536000 + stsIncludeSubdomains: true + contentTypeNosniff: true + referrerPolicy: strict-origin-when-cross-origin + customFrameOptionsValue: DENY + browserXssFilter: true + customResponseHeaders: + X-Powered-By: "" + Server: "" + + # Edge rate limiting — coarse abuse brake for the API host + # (average 50 req/s sustained, burst 150). Fine-grained login limiting + # lives in the app (slowapi). + api-ratelimit: + rateLimit: + average: 50 + burst: 150 + + # Cap request bodies at the edge (attachments are ≤5 MB per file; + # 12 MB leaves room for multipart overhead). + api-bodylimit: + buffering: + maxRequestBodyBytes: 12582912