chore(security): remove committed secrets/dumps, .env-based config, hardened deploy webhook
- Delete backups/*.dump and all real credentials from README/docs
(prod MinIO creds, Casdoor passwords, server IP, admin password)
- All stack secrets now come from git-ignored .env (.env.example added);
docker-compose.prod.yml refuses to boot without them (${VAR:?})
- seed_casdoor.py: bcrypt password hashing, generated console/client secrets
- Harden infra/deploy-webhook: HMAC-verified Gitea webhook receiver
+ systemd unit + README (replaces unauthenticated deploy endpoint)
- docs/SECRET-MANAGEMENT.md: secret inventory + rotation checklist
This commit is contained in:
@@ -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
|
||||||
+10
@@ -11,9 +11,19 @@ __pycache__/
|
|||||||
venv/
|
venv/
|
||||||
|
|
||||||
# ---- Env / secrets ----
|
# ---- 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
|
frontend/.env
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
|
# ---- Database dumps (contain user rows + signing material) ----
|
||||||
|
*.dump
|
||||||
|
*.sql.gz
|
||||||
|
|
||||||
# ---- Local session / backups ----
|
# ---- Local session / backups ----
|
||||||
.zcode/
|
.zcode/
|
||||||
v1.2 - Copy/
|
v1.2 - Copy/
|
||||||
|
|||||||
@@ -105,8 +105,8 @@ for production builds).
|
|||||||
| What | Where | Credentials |
|
| What | Where | Credentials |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| App | http://localhost (via Traefik) | — |
|
| App | http://localhost (via Traefik) | — |
|
||||||
| Casdoor console | http://localhost:8080 | `admin` / `123` |
|
| 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 `123456` |
|
| 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
|
Run `backend/tools/seed_casdoor.py` once after the first boot to create the
|
||||||
Casdoor organization, application and users (idempotent). Full details —
|
Casdoor organization, application and users (idempotent). Full details —
|
||||||
@@ -143,7 +143,7 @@ is an SSH tunnel from your machine:
|
|||||||
use the panel):
|
use the panel):
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
ssh -L 9001:127.0.0.1:9001 root@171.22.25.75
|
ssh -L 9001:127.0.0.1:9001 <user>@<server-host>
|
||||||
```
|
```
|
||||||
|
|
||||||
You will also land in a normal SSH session — that is fine; the tunnel
|
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**
|
2. In your browser, open: **http://localhost:9001**
|
||||||
|
|
||||||
3. Log in with:
|
3. Log in with the production MinIO root credentials from the server's
|
||||||
|
`/srv/erp/.env` (`MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD`) — they are
|
||||||
| Username | Password |
|
**never** written in this repo or its history. See
|
||||||
|---|---|
|
[`docs/SECRET-MANAGEMENT.md`](docs/SECRET-MANAGEMENT.md).
|
||||||
| `aramesh-minio` | `feb10fadb5441019dfc33bf12a9b0f74e7b6c478` |
|
|
||||||
|
|
||||||
You will see the **`procurement-files`** bucket — the invoices/proformas the
|
You will see the **`procurement-files`** bucket — the invoices/proformas the
|
||||||
ERP stores, browsable and downloadable from there.
|
ERP stores, browsable and downloadable from there.
|
||||||
|
|
||||||
⚠️ These credentials are **full admin access to the file storage**. Treat
|
⚠️ Those credentials are **full admin access to the file storage**. They live
|
||||||
them as a production secret (this README is inside the repo, so anyone with
|
only in the server-side `.env`; if one ever leaks, rotate it immediately
|
||||||
repo access can read them — rotate them on the server if they ever leak).
|
(checklist in `docs/SECRET-MANAGEMENT.md`).
|
||||||
|
|
||||||
Close the PowerShell window (or `Ctrl+C`) when you are done to drop the
|
Close the PowerShell window (or `Ctrl+C`) when you are done to drop the
|
||||||
tunnel.
|
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
|
The databases are **not** in git — Docker volumes hold the data, and
|
||||||
ships recovery dumps in `backups/علی محقق/`:
|
database dumps must never be committed (they contain user rows and signing
|
||||||
|
material). Two supported ways to bring a fresh stack up:
|
||||||
|
|
||||||
| File | Contents |
|
1. **Empty boot** — schema v2.6 auto-applies on the first boot of an empty
|
||||||
|---|---|
|
`postgres_data` volume; then run the seeders:
|
||||||
| `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) |
|
|
||||||
|
|
||||||
⚠️ 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
|
```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"
|
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
|
```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`,
|
Restore with `pg_restore --clean --if-exists` (stop `api` + `casdoor`
|
||||||
`docker exec procurement-postgres pg_dump -U postgres -d procurement -Fc`
|
first — this **replaces all data**). The scheduled backup sidecar
|
||||||
(+ the `casdoor` DB) into `backups/`, then start them again.
|
(see `docs/OPERATIONS.md`) automates this.
|
||||||
|
|
||||||
## Common operations
|
## Common operations
|
||||||
|
|
||||||
@@ -317,10 +302,11 @@ docker compose down -v # ⚠️ full reset — wipes DB + MinIO vol
|
|||||||
|
|
||||||
## Known behavior
|
## Known behavior
|
||||||
|
|
||||||
- Authentication is Casdoor-based (JWT verified via JWKS): `/auth`, `/users`,
|
- Authentication is Casdoor-based (JWT verified via JWKS) and **required on
|
||||||
`/inventory` and `/pm` are protected (401 without a token, 403 without a role).
|
every API route** except `/health`, `/health/ready` and `/auth/login`
|
||||||
The procurement CRUD endpoints (quotes, suppliers, materials, …) are still
|
(401 without a token, 403 without a role). The former dev-header fallback
|
||||||
open — dev placeholder, do not expose to the public internet.
|
(`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`,
|
- Roles live in the app's `users` table (Casdoor is identity-only): `pm`,
|
||||||
`procurement_manager`, `admin`. Role changes are **admin-only** (RBAC);
|
`procurement_manager`, `admin`. Role changes are **admin-only** (RBAC);
|
||||||
the procurement manager can only assign projects to project managers.
|
the procurement manager can only assign projects to project managers.
|
||||||
|
|||||||
@@ -18,24 +18,39 @@ for the in-page login flow.
|
|||||||
import http.cookiejar
|
import http.cookiejar
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import secrets
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
|
import bcrypt
|
||||||
|
|
||||||
BASE = os.environ.get("CASDOOR_BASE", "http://localhost:8080")
|
BASE = os.environ.get("CASDOOR_BASE", "http://localhost:8080")
|
||||||
ADMIN = os.environ.get("CASDOOR_ADMIN", "admin")
|
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"
|
ORG = "inventory"
|
||||||
APP = "inventory-app"
|
APP = "inventory-app"
|
||||||
CLIENT_ID = "inventory-app-client"
|
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
|
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 = [
|
USERS = [
|
||||||
# (name, first_name, last_name, email, password)
|
# (name, first_name, last_name, email)
|
||||||
("razaei", "آقای", "رضایی", "razaei@sample.ir", "123456"),
|
("razaei", "آقای", "رضایی", "razaei@sample.ir"),
|
||||||
("mousavi", "آقای", "موسوی", "mousavi@sample.ir", "123456"),
|
("mousavi", "آقای", "موسوی", "mousavi@sample.ir"),
|
||||||
("mohaghegh", "آقای", "محقق", "mohaghegh@sample.ir", "123456"),
|
("mohaghegh", "آقای", "محقق", "mohaghegh@sample.ir"),
|
||||||
("tester", "کاربر", "تست", "tester@sample.ir", "123456"),
|
("tester", "کاربر", "تست", "tester@sample.ir"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -69,17 +84,21 @@ def _login():
|
|||||||
return jar
|
return jar
|
||||||
|
|
||||||
|
|
||||||
|
def _bcrypt(pw: str) -> str:
|
||||||
|
return bcrypt.hashpw(pw.encode("utf-8"), bcrypt.gensalt()).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
cookies = _login()
|
cookies = _login()
|
||||||
if cookies is None:
|
if cookies is None:
|
||||||
return
|
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", {
|
status, body = _request("POST", "/api/add-organization", {
|
||||||
"owner": "admin",
|
"owner": "admin",
|
||||||
"name": ORG,
|
"name": ORG,
|
||||||
"displayName": "سازمان تدارکات",
|
"displayName": "سازمان تدارکات",
|
||||||
"passwordType": "plain",
|
"passwordType": "bcrypt",
|
||||||
}, cookies)
|
}, cookies)
|
||||||
print("add-organization:", status, body.get("msg", body.get("error", "")))
|
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", "")))
|
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)
|
# 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", {
|
status, body = _request("POST", "/api/add-user", {
|
||||||
"owner": ORG,
|
"owner": ORG,
|
||||||
"name": name,
|
"name": name,
|
||||||
@@ -110,21 +130,23 @@ def main():
|
|||||||
"firstName": first,
|
"firstName": first,
|
||||||
"lastName": last,
|
"lastName": last,
|
||||||
"email": email,
|
"email": email,
|
||||||
"password": pw,
|
"password": pw_hash,
|
||||||
"passwordType": "plain",
|
"passwordType": "bcrypt",
|
||||||
"phone": "",
|
"phone": "",
|
||||||
"type": "normal-user",
|
"type": "normal-user",
|
||||||
}, cookies)
|
}, cookies)
|
||||||
print(f"add-user {name}:", status, body.get("msg", body.get("error", "")))
|
print(f"add-user {name}:", status, body.get("msg", body.get("error", "")))
|
||||||
# Ensure passwordType even for already-existing users (this Casdoor
|
# Ensure passwordType even for already-existing users (this Casdoor
|
||||||
# version refuses login when both user and org passwordType are empty).
|
# 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", {
|
status, body = _request("POST", f"/api/update-user?id={ORG}/{name}&columns=password_type,password", {
|
||||||
"passwordType": "plain",
|
"passwordType": "bcrypt",
|
||||||
|
"password": pw_hash,
|
||||||
}, cookies)
|
}, cookies)
|
||||||
print(f"set-password-type {name}:", status, body.get("msg", body.get("error", "")))
|
print(f"set-password-type {name}:", status, body.get("msg", body.get("error", "")))
|
||||||
|
|
||||||
print("\nDone. Casdoor console: http://localhost:8080 (admin/123)")
|
print("\nDone. Casdoor console: http://localhost:8080")
|
||||||
print("Login in the app: http://localhost:3000/login")
|
print(f"Seeded users password: {SEED_PASSWORD} (change via CASDOOR_SEED_PASSWORD)")
|
||||||
|
print("Login in the app: http://localhost/login")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
+54
-27
@@ -1,32 +1,72 @@
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Production overlay — real domains + HTTPS (Let's Encrypt) + HTTP→HTTPS
|
# 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 \
|
# All credentials come from the git-ignored .env NEXT TO THE COMPOSE FILES
|
||||||
# S3_HOST=s3.example.com STORAGE_HOST=storage.example.com \
|
# (compose loads it automatically — keep it on the VPS, never in git):
|
||||||
# TRAEFIK_ACME_EMAIL=admin@example.com \
|
#
|
||||||
|
# cp .env.example .env # then fill in STRONG values
|
||||||
# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
|
# 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
|
# Requirements: DNS records for every host pointing at the server, ports
|
||||||
# 80 + 443 open (ACME http-challenge uses port 80), and the api's
|
# 80 + 443 open (ACME http-challenge uses port 80). The api's issuer is
|
||||||
# PROCUREMENT_CASDOOR_ISSUER must be updated to the public auth host
|
# derived here from AUTH_HOST (see docs/casdoor-auth.md).
|
||||||
# (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:
|
services:
|
||||||
traefik:
|
traefik:
|
||||||
environment:
|
environment:
|
||||||
# Certificates persist across restarts (acme.json in the traefik_certs volume).
|
# 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:
|
api:
|
||||||
ports: [] # production: only reachable through Traefik
|
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:
|
labels:
|
||||||
- traefik.http.routers.api.rule=Host(`${API_HOST:?set API_HOST}`)
|
- traefik.http.routers.api.rule=Host(`${API_HOST:?set API_HOST}`)
|
||||||
- traefik.http.routers.api.entrypoints=websecure
|
- traefik.http.routers.api.entrypoints=websecure
|
||||||
- traefik.http.routers.api.tls=true
|
- traefik.http.routers.api.tls=true
|
||||||
- traefik.http.routers.api.tls.certresolver=letsencrypt
|
- 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.rule=Host(`${API_HOST:?set API_HOST}`)
|
||||||
- traefik.http.routers.api-redir.entrypoints=web
|
- traefik.http.routers.api-redir.entrypoints=web
|
||||||
- traefik.http.routers.api-redir.middlewares=redirect-https@file
|
- traefik.http.routers.api-redir.middlewares=redirect-https@file
|
||||||
@@ -41,34 +81,21 @@ services:
|
|||||||
- traefik.http.routers.casdoor.entrypoints=websecure
|
- traefik.http.routers.casdoor.entrypoints=websecure
|
||||||
- traefik.http.routers.casdoor.tls=true
|
- traefik.http.routers.casdoor.tls=true
|
||||||
- traefik.http.routers.casdoor.tls.certresolver=letsencrypt
|
- 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.rule=Host(`${AUTH_HOST:?set AUTH_HOST}`)
|
||||||
- traefik.http.routers.casdoor-redir.entrypoints=web
|
- traefik.http.routers.casdoor-redir.entrypoints=web
|
||||||
- traefik.http.routers.casdoor-redir.middlewares=redirect-https@file
|
- traefik.http.routers.casdoor-redir.middlewares=redirect-https@file
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
|
build:
|
||||||
|
args:
|
||||||
|
DEV_LOGIN_ENABLED: "false"
|
||||||
labels:
|
labels:
|
||||||
- traefik.http.routers.frontend.rule=Host(`${APP_HOST:?set APP_HOST}`)
|
- traefik.http.routers.frontend.rule=Host(`${APP_HOST:?set APP_HOST}`)
|
||||||
- traefik.http.routers.frontend.entrypoints=websecure
|
- traefik.http.routers.frontend.entrypoints=websecure
|
||||||
- traefik.http.routers.frontend.tls=true
|
- traefik.http.routers.frontend.tls=true
|
||||||
- traefik.http.routers.frontend.tls.certresolver=letsencrypt
|
- 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.rule=Host(`${APP_HOST:?set APP_HOST}`)
|
||||||
- traefik.http.routers.frontend-redir.entrypoints=web
|
- traefik.http.routers.frontend-redir.entrypoints=web
|
||||||
- traefik.http.routers.frontend-redir.middlewares=redirect-https@file
|
- 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
|
|
||||||
|
|||||||
+22
-13
@@ -6,7 +6,9 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: procurement
|
POSTGRES_DB: procurement
|
||||||
POSTGRES_USER: postgres
|
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:
|
ports:
|
||||||
- "5433:5432"
|
- "5433:5432"
|
||||||
@@ -69,8 +71,8 @@ services:
|
|||||||
command: server /data --console-address ":9001"
|
command: server /data --console-address ":9001"
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
MINIO_ROOT_USER: minioadmin
|
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
|
||||||
MINIO_ROOT_PASSWORD: minioadmin
|
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
- "9000:9000" # S3 API
|
- "9000:9000" # S3 API
|
||||||
@@ -110,8 +112,8 @@ services:
|
|||||||
|
|
||||||
environment:
|
environment:
|
||||||
MINIO_ENDPOINT: minio:9000
|
MINIO_ENDPOINT: minio:9000
|
||||||
MINIO_ROOT_USER: minioadmin
|
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
|
||||||
MINIO_ROOT_PASSWORD: minioadmin
|
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
|
||||||
MINIO_BUCKET: procurement-files
|
MINIO_BUCKET: procurement-files
|
||||||
|
|
||||||
entrypoint: >
|
entrypoint: >
|
||||||
@@ -170,24 +172,30 @@ services:
|
|||||||
container_name: procurement-api
|
container_name: procurement-api
|
||||||
|
|
||||||
environment:
|
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_ENDPOINT: minio:9000
|
||||||
PROCUREMENT_MINIO_ACCESS_KEY: minioadmin
|
PROCUREMENT_MINIO_ACCESS_KEY: ${MINIO_ROOT_USER:-minioadmin}
|
||||||
PROCUREMENT_MINIO_SECRET_KEY: minioadmin
|
PROCUREMENT_MINIO_SECRET_KEY: ${MINIO_ROOT_PASSWORD:-minioadmin}
|
||||||
PROCUREMENT_MINIO_BUCKET: procurement-files
|
PROCUREMENT_MINIO_BUCKET: procurement-files
|
||||||
PROCUREMENT_MINIO_SECURE: "false"
|
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
|
# Casdoor: endpoint is the in-network service URL; the issuer must match
|
||||||
# the origin Casdoor advertises to browsers (its OIDC discovery doc).
|
# the origin Casdoor advertises to browsers (its OIDC discovery doc).
|
||||||
PROCUREMENT_CASDOOR_ENDPOINT: http://casdoor:8000
|
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_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_ORGANIZATION: inventory
|
||||||
PROCUREMENT_CASDOOR_APP_NAME: inventory-app
|
PROCUREMENT_CASDOOR_APP_NAME: inventory-app
|
||||||
PROCUREMENT_CASDOOR_AUDIENCE: inventory-app-client
|
PROCUREMENT_CASDOOR_AUDIENCE: inventory-app-client
|
||||||
# Console admin used by POST /users/sync (identity mirroring).
|
# Console admin used by POST /users/sync (identity mirroring).
|
||||||
PROCUREMENT_CASDOOR_ADMIN_USER: admin
|
PROCUREMENT_CASDOOR_ADMIN_USER: admin
|
||||||
PROCUREMENT_CASDOOR_ADMIN_PASSWORD: "123"
|
PROCUREMENT_CASDOOR_ADMIN_PASSWORD: ${CASDOOR_ADMIN_PASSWORD:-123}
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
@@ -214,8 +222,9 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
args:
|
args:
|
||||||
# Developer Mode panel on the login page — set false for production.
|
# Developer Mode panel on the login page — dev-only aid. Production
|
||||||
DEV_LOGIN_ENABLED: ${DEV_LOGIN_ENABLED:-true}
|
# builds MUST leave this false (identity comes from Casdoor tokens).
|
||||||
|
DEV_LOGIN_ENABLED: ${DEV_LOGIN_ENABLED:-false}
|
||||||
container_name: procurement-frontend
|
container_name: procurement-frontend
|
||||||
|
|
||||||
labels:
|
labels:
|
||||||
|
|||||||
@@ -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 <old-user> '<old-pass>'
|
||||||
|
mc admin user add local <new-user> '<new-pass>' # then:
|
||||||
|
mc admin policy attach local consoleAdmin --user <new-user>
|
||||||
|
mc admin user remove local <old-user>
|
||||||
|
# (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 '<new-strong-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.
|
||||||
+11
-11
@@ -12,7 +12,7 @@ scenarios, test results, and how to manage the system yourself.
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| App (frontend) | http://localhost (Traefik edge; production: your domain) | — |
|
| App (frontend) | http://localhost (Traefik edge; production: your domain) | — |
|
||||||
| API (backend) | http://localhost:8000 (Swagger: `/docs`) | — |
|
| 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 OIDC discovery | http://localhost:8080/.well-known/openid-configuration | — |
|
||||||
| Casdoor public JWKS | http://localhost:8080/.well-known/jwks | — |
|
| Casdoor public JWKS | http://localhost:8080/.well-known/jwks | — |
|
||||||
| In-network Casdoor (from the api container) | http://casdoor:8000 | — |
|
| 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) |
|
| Username | Password | Casdoor org | App role (our `users` table) |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `razaei` | `123456` | inventory | `admin` (super user) |
|
| `razaei` | `CASDOOR_SEED_PASSWORD` | inventory | `admin` (super user) |
|
||||||
| `mousavi` | `123456` | inventory | `procurement_manager` (مدیر تدارکات) |
|
| `mousavi` | `CASDOOR_SEED_PASSWORD` | inventory | `procurement_manager` (مدیر تدارکات) |
|
||||||
| `mohaghegh` | `123456` | inventory | `pm` (مدیر پروژه، بدون پروژه) |
|
| `mohaghegh` | `CASDOOR_SEED_PASSWORD` | inventory | `pm` (مدیر پروژه، بدون پروژه) |
|
||||||
| `tester` | `123456` | inventory | `pm` (مدیر پروژه — پروژه «تست») |
|
| `tester` | `CASDOOR_SEED_PASSWORD` | inventory | `pm` (مدیر پروژه — پروژه «تست») |
|
||||||
|
|
||||||
These users are created by `backend/tools/seed_casdoor.py` (idempotent — safe
|
Passwords are stored bcrypt-hashed (passwordType `bcrypt`). These users are
|
||||||
to re-run).
|
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`,
|
the OIDC client. Grant types: `authorization_code`, `refresh_token`,
|
||||||
`password`. Token format JWT (so the backend can verify via JWKS).
|
`password`. Token format JWT (so the backend can verify via JWKS).
|
||||||
- **Casdoor Organization** (`inventory`): the tenant; every app user belongs
|
- **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.)
|
`built-in` org.)
|
||||||
- **User — two records per person:**
|
- **User — two records per person:**
|
||||||
1. Casdoor user (login credential: name + password).
|
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 |
|
| 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 «تست» |
|
| 2 | Authenticated API: `GET /users/me` with token | **PASS** | 200, role `pm` + project «تست» |
|
||||||
| 3 | No token: `GET /inventory/warehouses` | **PASS** | 401 `UNAUTHORIZED` |
|
| 3 | No token: `GET /inventory/warehouses` | **PASS** | 401 `UNAUTHORIZED` |
|
||||||
| 4 | Invalid token: corrupted Bearer | **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` |
|
| 6 | Organization exists | ✅ `inventory` |
|
||||||
| 7 | Application exists | ✅ `inventory-app` |
|
| 7 | Application exists | ✅ `inventory-app` |
|
||||||
| 8 | Client exists | ✅ `inventory-app-client` (grants incl. `password`) |
|
| 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` |
|
| 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 |
|
| 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 |
|
| 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
|
## 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.
|
you can manage Organizations, Users, Applications, Providers, Certificates.
|
||||||
- **Seeding** (org `inventory`, app `inventory-app`, the four users) —
|
- **Seeding** (org `inventory`, app `inventory-app`, the four users) —
|
||||||
re-runnable at any time:
|
re-runnable at any time:
|
||||||
|
|||||||
+4
-3
@@ -13,9 +13,10 @@ ARG NPM_REGISTRY=https://registry.npmmirror.com
|
|||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm config set registry ${NPM_REGISTRY} && npm ci
|
RUN npm config set registry ${NPM_REGISTRY} && npm ci
|
||||||
|
|
||||||
# Developer Mode panel on the login page (default enabled; set to
|
# Developer Mode panel on the login page (dev-only aid). Production builds
|
||||||
# "false" to build a production login without the dev panel).
|
# MUST keep this "false" — identity comes from Casdoor tokens, and the
|
||||||
ARG DEV_LOGIN_ENABLED=true
|
# 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}
|
ENV VITE_ENABLE_DEV_LOGIN=${DEV_LOGIN_ENABLED}
|
||||||
|
|
||||||
# Source code + build
|
# Source code + build
|
||||||
|
|||||||
@@ -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=<long random string>
|
||||||
|
|
||||||
|
# 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://<server-ip>: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`).
|
||||||
@@ -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://<server>: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()
|
||||||
@@ -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
|
||||||
@@ -7,3 +7,31 @@ http:
|
|||||||
redirectScheme:
|
redirectScheme:
|
||||||
scheme: https
|
scheme: https
|
||||||
permanent: true
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user