mirror of
https://github.com/TronoSfera/Law.git
synced 2026-05-18 10:03:45 +03:00
add security test
This commit is contained in:
parent
a06f553406
commit
85ac21a1cb
75 changed files with 5072 additions and 151 deletions
135
.env.production
Normal file
135
.env.production
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
# ============================================================================
|
||||||
|
# Production environment template for Legal Case Tracker
|
||||||
|
# Copy to ".env" on production host and replace ALL placeholder values.
|
||||||
|
# Never commit real secrets.
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Core
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
APP_ENV=prod
|
||||||
|
PRODUCTION_ENFORCE_SECURE_SETTINGS=true
|
||||||
|
APP_NAME=legal-case-tracker
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# JWT / Cookies / Origin checks
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
PUBLIC_JWT_TTL_DAYS=7
|
||||||
|
ADMIN_JWT_TTL_MINUTES=240
|
||||||
|
ADMIN_JWT_SECRET=REPLACE_WITH_LONG_RANDOM_ADMIN_JWT_SECRET_64PLUS
|
||||||
|
PUBLIC_JWT_SECRET=REPLACE_WITH_LONG_RANDOM_PUBLIC_JWT_SECRET_64PLUS
|
||||||
|
PUBLIC_COOKIE_NAME=public_jwt
|
||||||
|
PUBLIC_COOKIE_SECURE=true
|
||||||
|
PUBLIC_COOKIE_SAMESITE=lax
|
||||||
|
PUBLIC_STRICT_ORIGIN_CHECK=true
|
||||||
|
PUBLIC_ALLOWED_WEB_ORIGINS=https://ruakb.online,https://www.ruakb.online
|
||||||
|
CORS_ORIGINS=https://ruakb.online,https://www.ruakb.online
|
||||||
|
CORS_ALLOW_METHODS=GET,POST,PUT,PATCH,DELETE,OPTIONS
|
||||||
|
CORS_ALLOW_HEADERS=Authorization,Content-Type,X-Requested-With,X-Request-ID
|
||||||
|
CORS_ALLOW_CREDENTIALS=true
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Database / Redis
|
||||||
|
# Keep DATABASE_URL and POSTGRES_* password in sync.
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
POSTGRES_USER=postgres
|
||||||
|
POSTGRES_PASSWORD=REPLACE_WITH_STRONG_POSTGRES_PASSWORD
|
||||||
|
POSTGRES_DB=legal
|
||||||
|
DATABASE_URL=postgresql+psycopg://postgres:REPLACE_WITH_STRONG_POSTGRES_PASSWORD@db:5432/legal
|
||||||
|
REDIS_URL=redis://redis:6379/0
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Storage (S3 / MinIO)
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
S3_ENDPOINT=https://minio:9000
|
||||||
|
S3_ACCESS_KEY=REPLACE_WITH_STRONG_MINIO_ACCESS_KEY
|
||||||
|
S3_SECRET_KEY=REPLACE_WITH_STRONG_MINIO_SECRET_KEY
|
||||||
|
S3_BUCKET=legal-files
|
||||||
|
S3_REGION=us-east-1
|
||||||
|
S3_USE_SSL=true
|
||||||
|
S3_VERIFY_SSL=true
|
||||||
|
S3_CA_CERT_PATH=/etc/ssl/minio/ca.crt
|
||||||
|
MAX_FILE_MB=25
|
||||||
|
MAX_CASE_MB=250
|
||||||
|
MINIO_ROOT_USER=REPLACE_WITH_NON_DEFAULT_MINIO_USER
|
||||||
|
MINIO_ROOT_PASSWORD=REPLACE_WITH_STRONG_MINIO_ROOT_PASSWORD
|
||||||
|
MINIO_TLS_ENABLED=true
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Data encryption
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
DATA_ENCRYPTION_ACTIVE_KID=k202603
|
||||||
|
DATA_ENCRYPTION_KEYS=k202603=REPLACE_WITH_LONG_RANDOM_DATA_KID_SECRET_64PLUS
|
||||||
|
CHAT_ENCRYPTION_ACTIVE_KID=k202603
|
||||||
|
CHAT_ENCRYPTION_KEYS=k202603=REPLACE_WITH_LONG_RANDOM_CHAT_KID_SECRET_64PLUS
|
||||||
|
DATA_ENCRYPTION_SECRET=REPLACE_WITH_LONG_RANDOM_DATA_ENCRYPTION_SECRET_64PLUS
|
||||||
|
CHAT_ENCRYPTION_SECRET=REPLACE_WITH_LONG_RANDOM_CHAT_ENCRYPTION_SECRET_64PLUS
|
||||||
|
INTERNAL_SERVICE_TOKEN=REPLACE_WITH_LONG_RANDOM_INTERNAL_SERVICE_TOKEN_64PLUS
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# OTP / Public auth mode
|
||||||
|
# PUBLIC_AUTH_MODE: sms | email | sms_or_email | totp
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
PUBLIC_AUTH_MODE=sms_or_email
|
||||||
|
OTP_DEV_MODE=false
|
||||||
|
OTP_AUTOTEST_FORCE_MOCK_SMS=true
|
||||||
|
OTP_RATE_LIMIT_WINDOW_SECONDS=300
|
||||||
|
OTP_SEND_RATE_LIMIT=8
|
||||||
|
OTP_VERIFY_RATE_LIMIT=20
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# SMS provider
|
||||||
|
# SMS_PROVIDER: dummy | smsaero
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
SMS_PROVIDER=smsaero
|
||||||
|
SMSAERO_EMAIL=REPLACE_WITH_SMSAERO_ACCOUNT_EMAIL
|
||||||
|
SMSAERO_API_KEY=REPLACE_WITH_SMSAERO_API_KEY
|
||||||
|
OTP_SMS_TEMPLATE=Ваш код подтверждения: {code}
|
||||||
|
OTP_SMS_MIN_BALANCE=20
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Email OTP / fallback
|
||||||
|
# EMAIL_PROVIDER: dummy | smtp | service
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
EMAIL_PROVIDER=service
|
||||||
|
EMAIL_SERVICE_URL=http://email-service:8010
|
||||||
|
OTP_EMAIL_FALLBACK_ENABLED=true
|
||||||
|
OTP_EMAIL_SUBJECT_TEMPLATE=Код подтверждения: {code}
|
||||||
|
OTP_EMAIL_TEMPLATE=Ваш код подтверждения: {code}
|
||||||
|
|
||||||
|
# SMTP mode settings (only if EMAIL_PROVIDER=smtp)
|
||||||
|
SMTP_HOST=smtp.example.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=no-reply@example.com
|
||||||
|
SMTP_PASSWORD=REPLACE_WITH_SMTP_PASSWORD
|
||||||
|
SMTP_FROM=no-reply@example.com
|
||||||
|
SMTP_USE_TLS=true
|
||||||
|
SMTP_USE_SSL=false
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Admin auth / bootstrap
|
||||||
|
# ADMIN_BOOTSTRAP_ENABLED must be false in production.
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
ADMIN_AUTH_MODE=password_totp_required
|
||||||
|
TOTP_ISSUER=Правовой Трекер
|
||||||
|
ADMIN_BOOTSTRAP_ENABLED=false
|
||||||
|
ADMIN_BOOTSTRAP_EMAIL=admin@example.com
|
||||||
|
ADMIN_BOOTSTRAP_PASSWORD=REPLACE_WITH_TEMP_BOOTSTRAP_PASSWORD
|
||||||
|
ADMIN_BOOTSTRAP_NAME=Администратор системы
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Telegram notifications
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
TELEGRAM_BOT_TOKEN=REPLACE_WITH_TELEGRAM_BOT_TOKEN
|
||||||
|
TELEGRAM_CHAT_ID=REPLACE_WITH_TELEGRAM_CHAT_ID
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Attachment security scan (ClamAV)
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
ATTACHMENT_SCAN_ENABLED=true
|
||||||
|
ATTACHMENT_SCAN_ENFORCE=true
|
||||||
|
ATTACHMENT_ALLOWED_MIME_TYPES=application/pdf,image/jpeg,image/png,video/mp4,text/plain
|
||||||
|
CLAMAV_ENABLED=true
|
||||||
|
CLAMAV_HOST=clamav
|
||||||
|
CLAMAV_PORT=3310
|
||||||
|
CLAMAV_TIMEOUT_SECONDS=20
|
||||||
183
.github/workflows/security-ci.yml
vendored
Normal file
183
.github/workflows/security-ci.yml
vendored
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
name: security-ci
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
- main
|
||||||
|
schedule:
|
||||||
|
- cron: "17 2 * * 1"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
security-events: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
BANDIT_MAX_HIGH: "0"
|
||||||
|
DEP_MAX_VULNS: "0"
|
||||||
|
TRIVY_MAX_HIGH: "0"
|
||||||
|
TRIVY_MAX_CRITICAL: "0"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
sast-and-dependencies:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Install scanners
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install bandit pip-audit
|
||||||
|
|
||||||
|
- name: Prepare reports directory
|
||||||
|
run: mkdir -p reports/security
|
||||||
|
|
||||||
|
- name: Run Bandit (SAST)
|
||||||
|
run: |
|
||||||
|
bandit -r app scripts -f json -o reports/security/bandit.json || true
|
||||||
|
|
||||||
|
- name: Run pip-audit (dependencies)
|
||||||
|
run: |
|
||||||
|
pip-audit -r requirements.txt --format json --output reports/security/pip-audit.json || true
|
||||||
|
|
||||||
|
- name: Build SAST/dependency summary
|
||||||
|
id: sast_deps_summary
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BANDIT_HIGH=$(jq '[.results[]? | select(.issue_severity == "HIGH")] | length' reports/security/bandit.json)
|
||||||
|
DEP_VULNS=$(jq '
|
||||||
|
if type == "array" then length
|
||||||
|
elif has("vulnerabilities") then (.vulnerabilities | length)
|
||||||
|
elif has("dependencies") then ([.dependencies[]?.vulns[]?] | length)
|
||||||
|
else 0
|
||||||
|
end
|
||||||
|
' reports/security/pip-audit.json)
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "SAST/Dependency Security Summary"
|
||||||
|
echo "bandit.high=${BANDIT_HIGH}"
|
||||||
|
echo "deps.vulns=${DEP_VULNS}"
|
||||||
|
echo "threshold.bandit.high=${BANDIT_MAX_HIGH}"
|
||||||
|
echo "threshold.deps.vulns=${DEP_MAX_VULNS}"
|
||||||
|
} | tee reports/security/sast-deps-summary.txt
|
||||||
|
|
||||||
|
echo "bandit_high=${BANDIT_HIGH}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "dep_vulns=${DEP_VULNS}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Upload SAST/dependency reports
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: security-sast-deps-reports
|
||||||
|
path: |
|
||||||
|
reports/security/bandit.json
|
||||||
|
reports/security/pip-audit.json
|
||||||
|
reports/security/sast-deps-summary.txt
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
- name: Enforce SAST/dependency thresholds
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
BANDIT_HIGH="${{ steps.sast_deps_summary.outputs.bandit_high }}"
|
||||||
|
DEP_VULNS="${{ steps.sast_deps_summary.outputs.dep_vulns }}"
|
||||||
|
|
||||||
|
if [ "${BANDIT_HIGH}" -gt "${BANDIT_MAX_HIGH}" ]; then
|
||||||
|
echo "Bandit HIGH findings: ${BANDIT_HIGH} (max ${BANDIT_MAX_HIGH})"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "${DEP_VULNS}" -gt "${DEP_MAX_VULNS}" ]; then
|
||||||
|
echo "Dependency vulnerabilities: ${DEP_VULNS} (max ${DEP_MAX_VULNS})"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
container-scan:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Build backend image
|
||||||
|
run: |
|
||||||
|
docker build -t law-backend-security:${{ github.sha }} .
|
||||||
|
|
||||||
|
- name: Prepare reports directory
|
||||||
|
run: mkdir -p reports/security
|
||||||
|
|
||||||
|
- name: Run Trivy image scan (JSON report)
|
||||||
|
uses: aquasecurity/trivy-action@0.24.0
|
||||||
|
with:
|
||||||
|
image-ref: law-backend-security:${{ github.sha }}
|
||||||
|
format: json
|
||||||
|
output: reports/security/trivy-image.json
|
||||||
|
severity: HIGH,CRITICAL
|
||||||
|
exit-code: "0"
|
||||||
|
|
||||||
|
- name: Run Trivy image scan (SARIF)
|
||||||
|
uses: aquasecurity/trivy-action@0.24.0
|
||||||
|
with:
|
||||||
|
image-ref: law-backend-security:${{ github.sha }}
|
||||||
|
format: sarif
|
||||||
|
output: reports/security/trivy-image.sarif
|
||||||
|
severity: HIGH,CRITICAL
|
||||||
|
exit-code: "0"
|
||||||
|
|
||||||
|
- name: Build container scan summary
|
||||||
|
id: trivy_summary
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
TRIVY_HIGH=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity == "HIGH")] | length' reports/security/trivy-image.json)
|
||||||
|
TRIVY_CRITICAL=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity == "CRITICAL")] | length' reports/security/trivy-image.json)
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "Container Security Summary"
|
||||||
|
echo "trivy.high=${TRIVY_HIGH}"
|
||||||
|
echo "trivy.critical=${TRIVY_CRITICAL}"
|
||||||
|
echo "threshold.trivy.high=${TRIVY_MAX_HIGH}"
|
||||||
|
echo "threshold.trivy.critical=${TRIVY_MAX_CRITICAL}"
|
||||||
|
} | tee reports/security/trivy-summary.txt
|
||||||
|
|
||||||
|
echo "trivy_high=${TRIVY_HIGH}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "trivy_critical=${TRIVY_CRITICAL}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Upload Trivy reports
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: security-container-reports
|
||||||
|
path: |
|
||||||
|
reports/security/trivy-image.json
|
||||||
|
reports/security/trivy-image.sarif
|
||||||
|
reports/security/trivy-summary.txt
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
- name: Upload SARIF to Security tab
|
||||||
|
if: always()
|
||||||
|
uses: github/codeql-action/upload-sarif@v3
|
||||||
|
with:
|
||||||
|
sarif_file: reports/security/trivy-image.sarif
|
||||||
|
|
||||||
|
- name: Enforce container scan thresholds
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
TRIVY_HIGH="${{ steps.trivy_summary.outputs.trivy_high }}"
|
||||||
|
TRIVY_CRITICAL="${{ steps.trivy_summary.outputs.trivy_critical }}"
|
||||||
|
|
||||||
|
if [ "${TRIVY_HIGH}" -gt "${TRIVY_MAX_HIGH}" ]; then
|
||||||
|
echo "Trivy HIGH findings: ${TRIVY_HIGH} (max ${TRIVY_MAX_HIGH})"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "${TRIVY_CRITICAL}" -gt "${TRIVY_MAX_CRITICAL}" ]; then
|
||||||
|
echo "Trivy CRITICAL findings: ${TRIVY_CRITICAL} (max ${TRIVY_MAX_CRITICAL})"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -1,6 +1,8 @@
|
||||||
/tmp/
|
/tmp/
|
||||||
*.idea
|
*.idea
|
||||||
.env
|
.env
|
||||||
|
.env.prod
|
||||||
|
.env.backup.*
|
||||||
/reports
|
/reports
|
||||||
node_modules/
|
node_modules/
|
||||||
e2e/node_modules/
|
e2e/node_modules/
|
||||||
|
|
@ -8,4 +10,5 @@ e2e/playwright-report/
|
||||||
e2e/test-results/
|
e2e/test-results/
|
||||||
celerybeat-schedule
|
celerybeat-schedule
|
||||||
celerybeat-schedule.*
|
celerybeat-schedule.*
|
||||||
|
deploy/tls/minio/*
|
||||||
|
!deploy/tls/minio/.gitkeep
|
||||||
|
|
|
||||||
46
Makefile
46
Makefile
|
|
@ -2,6 +2,9 @@
|
||||||
help \
|
help \
|
||||||
local-up local-down local-logs local-migrate local-test local-seed \
|
local-up local-down local-logs local-migrate local-test local-seed \
|
||||||
prod-up prod-down prod-logs prod-ps prod-migrate \
|
prod-up prod-down prod-logs prod-ps prod-migrate \
|
||||||
|
prod-secrets-generate prod-secrets-apply \
|
||||||
|
prod-minio-tls-init incident-checklist rotate-encryption-kid reencrypt-active-kid \
|
||||||
|
security-smoke prod-security-audit \
|
||||||
prod-cert-init prod-cert-renew \
|
prod-cert-init prod-cert-renew \
|
||||||
check-prod-files check-cert-files \
|
check-prod-files check-cert-files \
|
||||||
run migrate test seed-quotes
|
run migrate test seed-quotes
|
||||||
|
|
@ -11,6 +14,8 @@ WWW_DOMAIN ?= www.ruakb.ru
|
||||||
SECOND_DOMAIN ?= ruakb.online
|
SECOND_DOMAIN ?= ruakb.online
|
||||||
SECOND_WWW_DOMAIN ?= www.ruakb.online
|
SECOND_WWW_DOMAIN ?= www.ruakb.online
|
||||||
LETSENCRYPT_EMAIL ?= admin@ruakb.ru
|
LETSENCRYPT_EMAIL ?= admin@ruakb.ru
|
||||||
|
AUTO_CERT_INIT ?= 0
|
||||||
|
CONFIRM_TOKEN ?= ROTATE-PROD-SECRETS
|
||||||
CERTBOT_DOMAINS = -d "$(DOMAIN)" -d "$(WWW_DOMAIN)" $(if $(strip $(SECOND_DOMAIN)),-d "$(SECOND_DOMAIN)") $(if $(strip $(SECOND_WWW_DOMAIN)),-d "$(SECOND_WWW_DOMAIN)")
|
CERTBOT_DOMAINS = -d "$(DOMAIN)" -d "$(WWW_DOMAIN)" $(if $(strip $(SECOND_DOMAIN)),-d "$(SECOND_DOMAIN)") $(if $(strip $(SECOND_WWW_DOMAIN)),-d "$(SECOND_WWW_DOMAIN)")
|
||||||
|
|
||||||
LOCAL_COMPOSE = docker compose -f docker-compose.yml -f docker-compose.local.yml
|
LOCAL_COMPOSE = docker compose -f docker-compose.yml -f docker-compose.local.yml
|
||||||
|
|
@ -30,6 +35,14 @@ help:
|
||||||
@echo " prod-logs - Tail production logs"
|
@echo " prod-logs - Tail production logs"
|
||||||
@echo " prod-ps - Show production services"
|
@echo " prod-ps - Show production services"
|
||||||
@echo " prod-migrate - Apply migrations (prod)"
|
@echo " prod-migrate - Apply migrations (prod)"
|
||||||
|
@echo " prod-secrets-generate - Generate rotated internal secrets into .env.prod"
|
||||||
|
@echo " prod-secrets-apply - Generate + apply rotated internal secrets to running prod stack"
|
||||||
|
@echo " prod-minio-tls-init - Generate internal CA and MinIO TLS certs (deploy/tls/minio)"
|
||||||
|
@echo " incident-checklist - Create PDn incident checklist markdown report"
|
||||||
|
@echo " security-smoke - Run security smoke checks and create report"
|
||||||
|
@echo " prod-security-audit - Full production security audit/repair workflow"
|
||||||
|
@echo " rotate-encryption-kid - Add new KID key pair to .env and switch active KID"
|
||||||
|
@echo " reencrypt-active-kid - Re-encrypt historical encrypted fields using active KID"
|
||||||
@echo " prod-cert-init - Initial Let's Encrypt issue (nginx only 80 during bootstrap)"
|
@echo " prod-cert-init - Initial Let's Encrypt issue (nginx only 80 during bootstrap)"
|
||||||
@echo " prod-cert-renew - Renew existing certificates"
|
@echo " prod-cert-renew - Renew existing certificates"
|
||||||
@echo ""
|
@echo ""
|
||||||
|
|
@ -38,6 +51,7 @@ help:
|
||||||
@echo " WWW_DOMAIN=$(WWW_DOMAIN)"
|
@echo " WWW_DOMAIN=$(WWW_DOMAIN)"
|
||||||
@echo " SECOND_DOMAIN=$(SECOND_DOMAIN)"
|
@echo " SECOND_DOMAIN=$(SECOND_DOMAIN)"
|
||||||
@echo " SECOND_WWW_DOMAIN=$(SECOND_WWW_DOMAIN)"
|
@echo " SECOND_WWW_DOMAIN=$(SECOND_WWW_DOMAIN)"
|
||||||
|
@echo " AUTO_CERT_INIT=$(AUTO_CERT_INIT)"
|
||||||
|
|
||||||
local-up:
|
local-up:
|
||||||
$(LOCAL_COMPOSE) up -d --build
|
$(LOCAL_COMPOSE) up -d --build
|
||||||
|
|
@ -59,6 +73,8 @@ local-seed:
|
||||||
|
|
||||||
check-prod-files:
|
check-prod-files:
|
||||||
@test -f docker-compose.prod.nginx.yml || (echo "[ERROR] Missing docker-compose.prod.nginx.yml. Run: git pull"; exit 1)
|
@test -f docker-compose.prod.nginx.yml || (echo "[ERROR] Missing docker-compose.prod.nginx.yml. Run: git pull"; exit 1)
|
||||||
|
@test -f frontend/nginx.prod.conf || (echo "[ERROR] Missing frontend/nginx.prod.conf. Run: git pull"; exit 1)
|
||||||
|
@test -f scripts/ops/minio_tls_bootstrap.sh || (echo "[ERROR] Missing scripts/ops/minio_tls_bootstrap.sh. Run: git pull"; exit 1)
|
||||||
|
|
||||||
check-cert-files: check-prod-files
|
check-cert-files: check-prod-files
|
||||||
@test -f docker-compose.prod.cert.yml || (echo "[ERROR] Missing docker-compose.prod.cert.yml. Run: git pull"; exit 1)
|
@test -f docker-compose.prod.cert.yml || (echo "[ERROR] Missing docker-compose.prod.cert.yml. Run: git pull"; exit 1)
|
||||||
|
|
@ -81,6 +97,36 @@ prod-ps: check-prod-files
|
||||||
prod-migrate: check-prod-files
|
prod-migrate: check-prod-files
|
||||||
$(PROD_COMPOSE) exec -T backend alembic upgrade head
|
$(PROD_COMPOSE) exec -T backend alembic upgrade head
|
||||||
|
|
||||||
|
prod-secrets-generate:
|
||||||
|
./scripts/ops/rotate_prod_secrets.sh --env-in .env.production --env-out .env.prod
|
||||||
|
|
||||||
|
prod-secrets-apply: check-prod-files
|
||||||
|
./scripts/ops/rotate_prod_secrets.sh --env-in .env.production --env-out .env.prod --apply-running --compose-override docker-compose.prod.nginx.yml --non-interactive --require-confirmation-token "$(CONFIRM_TOKEN)"
|
||||||
|
|
||||||
|
prod-minio-tls-init:
|
||||||
|
./scripts/ops/minio_tls_bootstrap.sh
|
||||||
|
|
||||||
|
incident-checklist:
|
||||||
|
./scripts/ops/incident_checklist.sh
|
||||||
|
|
||||||
|
security-smoke:
|
||||||
|
./scripts/ops/security_smoke.sh
|
||||||
|
|
||||||
|
prod-security-audit: check-cert-files
|
||||||
|
DOMAIN="$(DOMAIN)" \
|
||||||
|
WWW_DOMAIN="$(WWW_DOMAIN)" \
|
||||||
|
SECOND_DOMAIN="$(SECOND_DOMAIN)" \
|
||||||
|
SECOND_WWW_DOMAIN="$(SECOND_WWW_DOMAIN)" \
|
||||||
|
LETSENCRYPT_EMAIL="$(LETSENCRYPT_EMAIL)" \
|
||||||
|
AUTO_CERT_INIT="$(AUTO_CERT_INIT)" \
|
||||||
|
./scripts/ops/prod_security_audit.sh
|
||||||
|
|
||||||
|
rotate-encryption-kid:
|
||||||
|
./scripts/ops/rotate_encryption_kid.sh --env-file .env
|
||||||
|
|
||||||
|
reencrypt-active-kid:
|
||||||
|
docker compose exec -T backend python -m app.scripts.reencrypt_with_active_kid --apply
|
||||||
|
|
||||||
# Initial certificate bootstrap:
|
# Initial certificate bootstrap:
|
||||||
# 1) Start stack with edge nginx on port 80 only.
|
# 1) Start stack with edge nginx on port 80 only.
|
||||||
# 2) Obtain cert via certbot webroot challenge.
|
# 2) Obtain cert via certbot webroot challenge.
|
||||||
|
|
|
||||||
168
README.md
168
README.md
|
|
@ -16,6 +16,11 @@ Email service health (via nginx): http://localhost:8081/email-health
|
||||||
## Production (ruakb.ru + ruakb.online, 80/443, TLS via Nginx + Certbot)
|
## Production (ruakb.ru + ruakb.online, 80/443, TLS via Nginx + Certbot)
|
||||||
Production stack uses dedicated edge nginx (`docker-compose.prod.nginx.yml`).
|
Production stack uses dedicated edge nginx (`docker-compose.prod.nginx.yml`).
|
||||||
|
|
||||||
|
Use production template before first deploy:
|
||||||
|
```bash
|
||||||
|
cp .env.production .env
|
||||||
|
```
|
||||||
|
|
||||||
Prerequisites:
|
Prerequisites:
|
||||||
- DNS `A` record: `ruakb.ru -> 45.150.36.116`
|
- DNS `A` record: `ruakb.ru -> 45.150.36.116`
|
||||||
- Optional DNS `A` record: `www.ruakb.ru -> 45.150.36.116`
|
- Optional DNS `A` record: `www.ruakb.ru -> 45.150.36.116`
|
||||||
|
|
@ -26,6 +31,46 @@ Prerequisites:
|
||||||
- `DATABASE_URL=postgresql+psycopg://postgres:<password>@db:5432/legal`
|
- `DATABASE_URL=postgresql+psycopg://postgres:<password>@db:5432/legal`
|
||||||
- `POSTGRES_PASSWORD=<same password>`
|
- `POSTGRES_PASSWORD=<same password>`
|
||||||
|
|
||||||
|
Production security baseline in `.env`:
|
||||||
|
```bash
|
||||||
|
APP_ENV=prod
|
||||||
|
PRODUCTION_ENFORCE_SECURE_SETTINGS=true
|
||||||
|
OTP_DEV_MODE=false
|
||||||
|
ADMIN_BOOTSTRAP_ENABLED=false
|
||||||
|
PUBLIC_COOKIE_SECURE=true
|
||||||
|
PUBLIC_COOKIE_SAMESITE=lax
|
||||||
|
PUBLIC_ALLOWED_WEB_ORIGINS=https://ruakb.ru,https://www.ruakb.ru,https://ruakb.online,https://www.ruakb.online
|
||||||
|
CORS_ORIGINS=https://ruakb.ru,https://www.ruakb.ru,https://ruakb.online,https://www.ruakb.online
|
||||||
|
CORS_ALLOW_METHODS=GET,POST,PUT,PATCH,DELETE,OPTIONS
|
||||||
|
CORS_ALLOW_HEADERS=Authorization,Content-Type,X-Requested-With,X-Request-ID
|
||||||
|
S3_USE_SSL=true
|
||||||
|
S3_VERIFY_SSL=true
|
||||||
|
S3_CA_CERT_PATH=/etc/ssl/minio/ca.crt
|
||||||
|
MINIO_TLS_ENABLED=true
|
||||||
|
CHAT_ENCRYPTION_SECRET=<strong-random-secret>
|
||||||
|
DATA_ENCRYPTION_SECRET=<strong-random-secret>
|
||||||
|
DATA_ENCRYPTION_ACTIVE_KID=<kid>
|
||||||
|
DATA_ENCRYPTION_KEYS=<kid>=<strong-random-secret>
|
||||||
|
CHAT_ENCRYPTION_ACTIVE_KID=<kid>
|
||||||
|
CHAT_ENCRYPTION_KEYS=<kid>=<strong-random-secret>
|
||||||
|
ADMIN_JWT_SECRET=<strong-random-secret>
|
||||||
|
PUBLIC_JWT_SECRET=<strong-random-secret>
|
||||||
|
INTERNAL_SERVICE_TOKEN=<strong-random-secret>
|
||||||
|
MINIO_ROOT_USER=<non-default-user>
|
||||||
|
MINIO_ROOT_PASSWORD=<strong-random-password>
|
||||||
|
```
|
||||||
|
|
||||||
|
Initialize internal TLS for MinIO before first production start:
|
||||||
|
```bash
|
||||||
|
make prod-minio-tls-init
|
||||||
|
```
|
||||||
|
This generates:
|
||||||
|
- `deploy/tls/minio/public.crt`
|
||||||
|
- `deploy/tls/minio/private.key`
|
||||||
|
- `deploy/tls/minio/ca.crt`
|
||||||
|
|
||||||
|
Production frontend/backend/worker trust `deploy/tls/minio/ca.crt` and use HTTPS to MinIO inside docker network.
|
||||||
|
|
||||||
Initial certificate issue (bootstrap with nginx on port 80 only):
|
Initial certificate issue (bootstrap with nginx on port 80 only):
|
||||||
```bash
|
```bash
|
||||||
make prod-cert-init LETSENCRYPT_EMAIL=you@example.com DOMAIN=ruakb.ru WWW_DOMAIN=www.ruakb.ru
|
make prod-cert-init LETSENCRYPT_EMAIL=you@example.com DOMAIN=ruakb.ru WWW_DOMAIN=www.ruakb.ru
|
||||||
|
|
@ -44,19 +89,69 @@ Regular production start/update:
|
||||||
make prod-up
|
make prod-up
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`make prod-up` includes strict preflight checks (`scripts/ops/deploy_prod.sh`) and fails on insecure production env values
|
||||||
|
(weak/default secrets, localhost origins, disabled strict origin checks, non-TOTP-required admin auth, etc.).
|
||||||
|
|
||||||
Certificate renew:
|
Certificate renew:
|
||||||
```bash
|
```bash
|
||||||
make prod-cert-renew
|
make prod-cert-renew
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Internal secret rotation (excluding external providers such as SMS/Telegram):
|
||||||
|
```bash
|
||||||
|
make prod-secrets-generate
|
||||||
|
```
|
||||||
|
This creates `.env.prod` with new generated internal secrets.
|
||||||
|
|
||||||
|
Apply generated secrets to running production stack:
|
||||||
|
```bash
|
||||||
|
make prod-secrets-apply
|
||||||
|
```
|
||||||
|
This will backup current `.env`, replace it with `.env.prod`, rotate Postgres password in DB,
|
||||||
|
recreate containers, run migrations, and execute health checks.
|
||||||
|
|
||||||
|
Encryption KID rotation (without data loss):
|
||||||
|
```bash
|
||||||
|
make rotate-encryption-kid
|
||||||
|
# restart backend/chat/worker
|
||||||
|
make reencrypt-active-kid
|
||||||
|
```
|
||||||
|
`rotate-encryption-kid` appends a new `kid=secret` into `DATA_ENCRYPTION_KEYS` and `CHAT_ENCRYPTION_KEYS`
|
||||||
|
and switches `*_ACTIVE_KID` to the new value.
|
||||||
|
`reencrypt-active-kid` re-encrypts historical invoice requisites, admin TOTP secrets, and chat message bodies.
|
||||||
|
|
||||||
|
Safety guard for apply mode:
|
||||||
|
- script requires confirmation token `ROTATE-PROD-SECRETS`;
|
||||||
|
- default `make prod-secrets-apply` passes it via `CONFIRM_TOKEN`;
|
||||||
|
- you can override explicitly:
|
||||||
|
```bash
|
||||||
|
make prod-secrets-apply CONFIRM_TOKEN=ROTATE-PROD-SECRETS
|
||||||
|
```
|
||||||
|
|
||||||
Checks:
|
Checks:
|
||||||
```bash
|
```bash
|
||||||
curl -I https://ruakb.ru
|
curl -I https://ruakb.ru
|
||||||
curl -fsS https://ruakb.ru/health
|
curl -fsS https://ruakb.ru/health
|
||||||
curl -fsS https://ruakb.ru/chat-health
|
curl -fsS https://ruakb.ru/chat-health
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.prod.nginx.yml exec -T backend sh -lc 'python - <<PY\nfrom app.services.s3_storage import get_s3_storage\nprint(get_s3_storage().client._endpoint.host)\nPY'
|
||||||
ss -lntp | egrep ':(80|443|5432|6379|8002|8081|9000|9001)\\b'
|
ss -lntp | egrep ':(80|443|5432|6379|8002|8081|9000|9001)\\b'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Incident response (PDn)
|
||||||
|
Create a standard incident checklist report:
|
||||||
|
```bash
|
||||||
|
make incident-checklist
|
||||||
|
```
|
||||||
|
or with details:
|
||||||
|
```bash
|
||||||
|
./scripts/ops/incident_checklist.sh \
|
||||||
|
--severity HIGH \
|
||||||
|
--category UNAUTHORIZED_ACCESS \
|
||||||
|
--summary "Suspicious read access to request cards" \
|
||||||
|
--track-number TRK-XXXX
|
||||||
|
```
|
||||||
|
Generated markdown report is saved to `reports/incidents/incident-<timestamp>.md`.
|
||||||
|
|
||||||
## Migrations
|
## Migrations
|
||||||
```bash
|
```bash
|
||||||
docker compose exec backend alembic upgrade head
|
docker compose exec backend alembic upgrade head
|
||||||
|
|
@ -179,6 +274,10 @@ CLAMAV_PORT=3310
|
||||||
CLAMAV_TIMEOUT_SECONDS=20
|
CLAMAV_TIMEOUT_SECONDS=20
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Compose profiles by environment:
|
||||||
|
- local (`docker-compose.local.yml`): `clamav` uses `mkodockx/docker-clamav:alpine` (multi-arch, including arm64).
|
||||||
|
- prod (`docker-compose.prod*.yml`): `clamav` stays on official `clamav/clamav` with `platform: linux/amd64`.
|
||||||
|
|
||||||
Scan statuses on `attachments`:
|
Scan statuses on `attachments`:
|
||||||
- `PENDING` (file uploaded, scan in progress)
|
- `PENDING` (file uploaded, scan in progress)
|
||||||
- `CLEAN` (safe to download)
|
- `CLEAN` (safe to download)
|
||||||
|
|
@ -187,6 +286,75 @@ Scan statuses on `attachments`:
|
||||||
|
|
||||||
When `ATTACHMENT_SCAN_ENFORCE=true`, public/admin download endpoints block non-clean files.
|
When `ATTACHMENT_SCAN_ENFORCE=true`, public/admin download endpoints block non-clean files.
|
||||||
|
|
||||||
|
## Security CI pipeline (SEC-14)
|
||||||
|
GitHub Actions workflow: `/Users/tronosfera/Develop/Law/.github/workflows/security-ci.yml`
|
||||||
|
|
||||||
|
Checks:
|
||||||
|
- SAST: `bandit` for `app/` and `scripts/`.
|
||||||
|
- Dependency scan: `pip-audit` for `requirements.txt`.
|
||||||
|
- Container scan: `trivy` on backend Docker image.
|
||||||
|
|
||||||
|
Fail thresholds (configurable in workflow `env`):
|
||||||
|
- `BANDIT_MAX_HIGH` (default `0`)
|
||||||
|
- `DEP_MAX_VULNS` (default `0`)
|
||||||
|
- `TRIVY_MAX_HIGH` (default `0`)
|
||||||
|
- `TRIVY_MAX_CRITICAL` (default `0`)
|
||||||
|
|
||||||
|
Artifacts uploaded for each run:
|
||||||
|
- `reports/security/bandit.json`
|
||||||
|
- `reports/security/pip-audit.json`
|
||||||
|
- `reports/security/sast-deps-summary.txt`
|
||||||
|
- `reports/security/trivy-image.json`
|
||||||
|
- `reports/security/trivy-image.sarif`
|
||||||
|
- `reports/security/trivy-summary.txt`
|
||||||
|
|
||||||
|
## Security smoke (SEC-15)
|
||||||
|
Local/manual run:
|
||||||
|
```bash
|
||||||
|
make security-smoke
|
||||||
|
```
|
||||||
|
or with explicit target URL:
|
||||||
|
```bash
|
||||||
|
./scripts/ops/security_smoke.sh https://ruakb.online
|
||||||
|
```
|
||||||
|
|
||||||
|
What is checked:
|
||||||
|
- public health endpoints (`/health`, `/chat-health`, `/email-health`);
|
||||||
|
- required security headers (`X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `Content-Security-Policy`);
|
||||||
|
- TLS certificate availability/expiry (for `https://` URLs);
|
||||||
|
- production cookie/origin env flags (`PUBLIC_COOKIE_SECURE`, `PUBLIC_COOKIE_SAMESITE`, `PUBLIC_STRICT_ORIGIN_CHECK`);
|
||||||
|
- attachment scan service availability (`clamav`, when scan is enabled);
|
||||||
|
- DB access to `security_audit_log` (table exists + query works).
|
||||||
|
|
||||||
|
Report is saved to:
|
||||||
|
- `reports/security/security-smoke-<timestamp>.md`
|
||||||
|
|
||||||
|
Example cron (every 15 minutes):
|
||||||
|
```bash
|
||||||
|
*/15 * * * * cd /opt/Law && ./scripts/ops/security_smoke.sh https://ruakb.online >> /var/log/law-security-smoke.log 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Full production security audit and auto-repair
|
||||||
|
Single command to verify full security block and auto-generate missing technical artifacts:
|
||||||
|
```bash
|
||||||
|
make prod-security-audit DOMAIN=ruakb.ru WWW_DOMAIN=www.ruakb.ru SECOND_DOMAIN=ruakb.online SECOND_WWW_DOMAIN=www.ruakb.online LETSENCRYPT_EMAIL=you@example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
What this workflow does (`scripts/ops/prod_security_audit.sh`):
|
||||||
|
- checks required compose/nginx files;
|
||||||
|
- restores/generates `.env` if missing (`.env.prod` or `.env.production` + `rotate_prod_secrets.sh`);
|
||||||
|
- generates MinIO internal TLS bundle if missing (`prod-minio-tls-init` equivalent);
|
||||||
|
- starts/reconciles production stack (nginx profile);
|
||||||
|
- applies DB migrations;
|
||||||
|
- validates production security config (`validate_production_security_or_raise`);
|
||||||
|
- runs local and external security smoke checks;
|
||||||
|
- generates incident checklist snapshot report.
|
||||||
|
|
||||||
|
Optional: auto-bootstrap Let's Encrypt certs if HTTPS health is failing:
|
||||||
|
```bash
|
||||||
|
make prod-security-audit AUTO_CERT_INIT=1 DOMAIN=ruakb.ru WWW_DOMAIN=www.ruakb.ru SECOND_DOMAIN=ruakb.online SECOND_WWW_DOMAIN=www.ruakb.online LETSENCRYPT_EMAIL=you@example.com
|
||||||
|
```
|
||||||
|
|
||||||
## Container health and alerting
|
## Container health and alerting
|
||||||
Docker Compose is configured with:
|
Docker Compose is configured with:
|
||||||
- `restart: unless-stopped` for core services
|
- `restart: unless-stopped` for core services
|
||||||
|
|
|
||||||
137
alembic/versions/0031_pii_retention_and_consent.py
Normal file
137
alembic/versions/0031_pii_retention_and_consent.py
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
"""add pdn consent fields and data retention policies
|
||||||
|
|
||||||
|
Revision ID: 0031_pii_retention_and_consent
|
||||||
|
Revises: 0030_attachment_scan
|
||||||
|
Create Date: 2026-03-02
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0031_pii_retention_and_consent"
|
||||||
|
down_revision = "0030_attachment_scan"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.add_column(
|
||||||
|
"requests",
|
||||||
|
sa.Column("pdn_consent", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"requests",
|
||||||
|
sa.Column("pdn_consent_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"requests",
|
||||||
|
sa.Column("pdn_consent_ip", sa.String(length=64), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"data_retention_policies",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("responsible", sa.String(length=200), nullable=False, server_default="Администратор системы"),
|
||||||
|
sa.Column("entity", sa.String(length=80), nullable=False, unique=True),
|
||||||
|
sa.Column("retention_days", sa.Integer(), nullable=False, server_default="365"),
|
||||||
|
sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||||
|
sa.Column("hard_delete", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||||
|
sa.Column("description", sa.String(length=300), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_data_retention_policies_entity",
|
||||||
|
"data_retention_policies",
|
||||||
|
["entity"],
|
||||||
|
unique=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
policies = sa.table(
|
||||||
|
"data_retention_policies",
|
||||||
|
sa.column("id", postgresql.UUID(as_uuid=True)),
|
||||||
|
sa.column("created_at", sa.DateTime(timezone=True)),
|
||||||
|
sa.column("updated_at", sa.DateTime(timezone=True)),
|
||||||
|
sa.column("responsible", sa.String(length=200)),
|
||||||
|
sa.column("entity", sa.String(length=80)),
|
||||||
|
sa.column("retention_days", sa.Integer()),
|
||||||
|
sa.column("enabled", sa.Boolean()),
|
||||||
|
sa.column("hard_delete", sa.Boolean()),
|
||||||
|
sa.column("description", sa.String(length=300)),
|
||||||
|
)
|
||||||
|
op.bulk_insert(
|
||||||
|
policies,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": uuid.UUID("8c3d4b50-2f11-4ec4-a993-a7f5af6f45b1"),
|
||||||
|
"created_at": now,
|
||||||
|
"updated_at": now,
|
||||||
|
"responsible": "Администратор системы",
|
||||||
|
"entity": "otp_sessions",
|
||||||
|
"retention_days": 1,
|
||||||
|
"enabled": True,
|
||||||
|
"hard_delete": True,
|
||||||
|
"description": "Одноразовые коды и технические OTP-сессии",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": uuid.UUID("9c9e89b2-a9ee-4f1f-b80b-8f93a3f227c2"),
|
||||||
|
"created_at": now,
|
||||||
|
"updated_at": now,
|
||||||
|
"responsible": "Администратор системы",
|
||||||
|
"entity": "notifications",
|
||||||
|
"retention_days": 120,
|
||||||
|
"enabled": True,
|
||||||
|
"hard_delete": True,
|
||||||
|
"description": "Уведомления пользователей/сотрудников",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": uuid.UUID("3ee2d4fb-42a0-48f5-a5b4-5f5f0ebebea7"),
|
||||||
|
"created_at": now,
|
||||||
|
"updated_at": now,
|
||||||
|
"responsible": "Администратор системы",
|
||||||
|
"entity": "audit_log",
|
||||||
|
"retention_days": 365,
|
||||||
|
"enabled": True,
|
||||||
|
"hard_delete": True,
|
||||||
|
"description": "Операционный аудит изменений сущностей",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": uuid.UUID("4f6c95ff-7c6d-43a4-bdb5-d0d764649f22"),
|
||||||
|
"created_at": now,
|
||||||
|
"updated_at": now,
|
||||||
|
"responsible": "Администратор системы",
|
||||||
|
"entity": "security_audit_log",
|
||||||
|
"retention_days": 365,
|
||||||
|
"enabled": True,
|
||||||
|
"hard_delete": True,
|
||||||
|
"description": "Журнал безопасности и доступа к ПДн",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": uuid.UUID("8a3600f9-a89a-4ff2-b2a1-0bf248f7377a"),
|
||||||
|
"created_at": now,
|
||||||
|
"updated_at": now,
|
||||||
|
"responsible": "Администратор системы",
|
||||||
|
"entity": "requests",
|
||||||
|
"retention_days": 3650,
|
||||||
|
"enabled": False,
|
||||||
|
"hard_delete": True,
|
||||||
|
"description": "Терминальные заявки (выключено по умолчанию)",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
op.alter_column("requests", "pdn_consent", server_default=None)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_index("ix_data_retention_policies_entity", table_name="data_retention_policies")
|
||||||
|
op.drop_table("data_retention_policies")
|
||||||
|
op.drop_column("requests", "pdn_consent_ip")
|
||||||
|
op.drop_column("requests", "pdn_consent_at")
|
||||||
|
op.drop_column("requests", "pdn_consent")
|
||||||
|
|
@ -3,7 +3,7 @@ from __future__ import annotations
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException, Request as FastapiRequest
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.deps import require_role
|
from app.core.deps import require_role
|
||||||
|
|
@ -26,11 +26,35 @@ from app.services.chat_secure_service import (
|
||||||
serialize_messages_for_request,
|
serialize_messages_for_request,
|
||||||
)
|
)
|
||||||
from app.services.chat_presence import list_typing_presence, set_typing_presence
|
from app.services.chat_presence import list_typing_presence, set_typing_presence
|
||||||
|
from app.services.security_audit import extract_client_ip, record_pii_access_event
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
ALLOWED_VALUE_TYPES = {"string", "text", "date", "number", "file"}
|
ALLOWED_VALUE_TYPES = {"string", "text", "date", "number", "file"}
|
||||||
|
|
||||||
|
|
||||||
|
def _audit_admin_chat_read(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
admin: dict,
|
||||||
|
http_request: FastapiRequest,
|
||||||
|
req: Request,
|
||||||
|
action: str,
|
||||||
|
details: dict | None = None,
|
||||||
|
) -> None:
|
||||||
|
record_pii_access_event(
|
||||||
|
db,
|
||||||
|
actor_role=str(admin.get("role") or "ADMIN").upper(),
|
||||||
|
actor_subject=str(admin.get("sub") or admin.get("email") or ""),
|
||||||
|
actor_ip=extract_client_ip(http_request),
|
||||||
|
action=action,
|
||||||
|
scope="CHAT",
|
||||||
|
request_id=req.id,
|
||||||
|
details=details or {},
|
||||||
|
responsible=str(admin.get("email") or "").strip() or "Администратор системы",
|
||||||
|
persist_now=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _parse_cursor(raw: str | None) -> datetime | None:
|
def _parse_cursor(raw: str | None) -> datetime | None:
|
||||||
value = str(raw or "").strip()
|
value = str(raw or "").strip()
|
||||||
if not value:
|
if not value:
|
||||||
|
|
@ -223,13 +247,23 @@ def _serialize_data_request_items(db: Session, rows: list[RequestDataRequirement
|
||||||
@router.get("/requests/{request_id}/messages")
|
@router.get("/requests/{request_id}/messages")
|
||||||
def list_request_messages(
|
def list_request_messages(
|
||||||
request_id: str,
|
request_id: str,
|
||||||
|
http_request: FastapiRequest,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
admin: dict = Depends(require_role("ADMIN", "LAWYER", "CURATOR")),
|
admin: dict = Depends(require_role("ADMIN", "LAWYER", "CURATOR")),
|
||||||
):
|
):
|
||||||
req = _request_for_id_or_404(db, request_id)
|
req = _request_for_id_or_404(db, request_id)
|
||||||
_ensure_lawyer_can_view_request_or_403(admin, req)
|
_ensure_lawyer_can_view_request_or_403(admin, req)
|
||||||
rows = list_messages_for_request(db, req.id)
|
rows = list_messages_for_request(db, req.id)
|
||||||
return {"rows": serialize_messages_for_request(db, req.id, rows), "total": len(rows)}
|
payload = {"rows": serialize_messages_for_request(db, req.id, rows), "total": len(rows)}
|
||||||
|
_audit_admin_chat_read(
|
||||||
|
db,
|
||||||
|
admin=admin,
|
||||||
|
http_request=http_request,
|
||||||
|
req=req,
|
||||||
|
action="READ_CHAT_MESSAGES",
|
||||||
|
details={"rows": len(rows)},
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
@router.post("/requests/{request_id}/messages", status_code=201)
|
@router.post("/requests/{request_id}/messages", status_code=201)
|
||||||
|
|
@ -268,6 +302,7 @@ def create_request_message(
|
||||||
@router.get("/requests/{request_id}/live")
|
@router.get("/requests/{request_id}/live")
|
||||||
def get_request_live_state(
|
def get_request_live_state(
|
||||||
request_id: str,
|
request_id: str,
|
||||||
|
http_request: FastapiRequest,
|
||||||
cursor: str | None = None,
|
cursor: str | None = None,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
admin: dict = Depends(require_role("ADMIN", "LAWYER", "CURATOR")),
|
admin: dict = Depends(require_role("ADMIN", "LAWYER", "CURATOR")),
|
||||||
|
|
@ -284,7 +319,7 @@ def get_request_live_state(
|
||||||
actor_role = str(admin.get("role") or "").strip().upper() or "UNKNOWN"
|
actor_role = str(admin.get("role") or "").strip().upper() or "UNKNOWN"
|
||||||
actor_key = f"{actor_role}:{actor_sub}"
|
actor_key = f"{actor_role}:{actor_sub}"
|
||||||
typing_rows = list_typing_presence(request_key=str(req.id), exclude_actor_key=actor_key)
|
typing_rows = list_typing_presence(request_key=str(req.id), exclude_actor_key=actor_key)
|
||||||
return {
|
payload = {
|
||||||
"request_id": str(req.id),
|
"request_id": str(req.id),
|
||||||
"cursor": latest_activity_iso,
|
"cursor": latest_activity_iso,
|
||||||
"has_updates": has_updates,
|
"has_updates": has_updates,
|
||||||
|
|
@ -299,6 +334,15 @@ def get_request_live_state(
|
||||||
request_id=req.id,
|
request_id=req.id,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
_audit_admin_chat_read(
|
||||||
|
db,
|
||||||
|
admin=admin,
|
||||||
|
http_request=http_request,
|
||||||
|
req=req,
|
||||||
|
action="READ_CHAT_LIVE_STATE",
|
||||||
|
details={"has_updates": bool(has_updates)},
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
@router.post("/requests/{request_id}/typing")
|
@router.post("/requests/{request_id}/typing")
|
||||||
|
|
@ -374,6 +418,7 @@ def list_data_request_templates(
|
||||||
def get_data_request_batch(
|
def get_data_request_batch(
|
||||||
request_id: str,
|
request_id: str,
|
||||||
message_id: str,
|
message_id: str,
|
||||||
|
http_request: FastapiRequest,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
admin: dict = Depends(require_role("ADMIN", "LAWYER", "CURATOR")),
|
admin: dict = Depends(require_role("ADMIN", "LAWYER", "CURATOR")),
|
||||||
):
|
):
|
||||||
|
|
@ -394,13 +439,22 @@ def get_data_request_batch(
|
||||||
)
|
)
|
||||||
if not rows:
|
if not rows:
|
||||||
raise HTTPException(status_code=404, detail="Набор данных для сообщения не найден")
|
raise HTTPException(status_code=404, detail="Набор данных для сообщения не найден")
|
||||||
return {
|
payload = {
|
||||||
"message_id": str(message.id),
|
"message_id": str(message.id),
|
||||||
"request_id": str(req.id),
|
"request_id": str(req.id),
|
||||||
"track_number": req.track_number,
|
"track_number": req.track_number,
|
||||||
"document_name": rows[0].document_name if rows else None,
|
"document_name": rows[0].document_name if rows else None,
|
||||||
"items": _serialize_data_request_items(db, rows),
|
"items": _serialize_data_request_items(db, rows),
|
||||||
}
|
}
|
||||||
|
_audit_admin_chat_read(
|
||||||
|
db,
|
||||||
|
admin=admin,
|
||||||
|
http_request=http_request,
|
||||||
|
req=req,
|
||||||
|
action="READ_CHAT_DATA_REQUEST",
|
||||||
|
details={"message_id": str(message.id), "rows": len(rows)},
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
@router.get("/requests/{request_id}/data-request-templates/{template_id}")
|
@router.get("/requests/{request_id}/data-request-templates/{template_id}")
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ TABLE_ROLE_ACTIONS: dict[str, dict[str, set[str]]] = {
|
||||||
"form_fields": {"ADMIN": set(CRUD_ACTIONS)},
|
"form_fields": {"ADMIN": set(CRUD_ACTIONS)},
|
||||||
"clients": {"ADMIN": set(CRUD_ACTIONS)},
|
"clients": {"ADMIN": set(CRUD_ACTIONS)},
|
||||||
"table_availability": {"ADMIN": set(CRUD_ACTIONS)},
|
"table_availability": {"ADMIN": set(CRUD_ACTIONS)},
|
||||||
|
"data_retention_policies": {"ADMIN": set(CRUD_ACTIONS)},
|
||||||
"audit_log": {"ADMIN": {"query", "read"}},
|
"audit_log": {"ADMIN": {"query", "read"}},
|
||||||
"security_audit_log": {"ADMIN": {"query", "read"}},
|
"security_audit_log": {"ADMIN": {"query", "read"}},
|
||||||
"otp_sessions": {"ADMIN": {"query", "read"}},
|
"otp_sessions": {"ADMIN": {"query", "read"}},
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,7 @@ def _table_label(table_name: str) -> str:
|
||||||
"form_fields": "Поля формы",
|
"form_fields": "Поля формы",
|
||||||
"clients": "Клиенты",
|
"clients": "Клиенты",
|
||||||
"table_availability": "Доступность таблиц",
|
"table_availability": "Доступность таблиц",
|
||||||
|
"data_retention_policies": "Политики хранения ПДн",
|
||||||
"topic_required_fields": "Обязательные поля темы",
|
"topic_required_fields": "Обязательные поля темы",
|
||||||
"topic_data_templates": "Дополнительные данные",
|
"topic_data_templates": "Дополнительные данные",
|
||||||
"request_data_templates": "Шаблоны доп. данных",
|
"request_data_templates": "Шаблоны доп. данных",
|
||||||
|
|
@ -101,6 +102,9 @@ def _table_label(table_name: str) -> str:
|
||||||
"request_service_requests": "Запросы",
|
"request_service_requests": "Запросы",
|
||||||
"otp_sessions": "OTP-сессии",
|
"otp_sessions": "OTP-сессии",
|
||||||
"notifications": "Уведомления",
|
"notifications": "Уведомления",
|
||||||
|
"retention": "хранения",
|
||||||
|
"policy": "политика",
|
||||||
|
"policies": "политики",
|
||||||
}
|
}
|
||||||
if normalized in explicit_labels:
|
if normalized in explicit_labels:
|
||||||
return explicit_labels[normalized]
|
return explicit_labels[normalized]
|
||||||
|
|
@ -250,6 +254,11 @@ def _column_label(table_name: str, column_name: str) -> str:
|
||||||
"required_data_keys": "Обязательные данные шага",
|
"required_data_keys": "Обязательные данные шага",
|
||||||
"required_mime_types": "Обязательные файлы шага",
|
"required_mime_types": "Обязательные файлы шага",
|
||||||
"avatar_url": "Аватар",
|
"avatar_url": "Аватар",
|
||||||
|
"pdn_consent": "Согласие на ПДн",
|
||||||
|
"pdn_consent_at": "Дата согласия ПДн",
|
||||||
|
"pdn_consent_ip": "IP согласия",
|
||||||
|
"retention_days": "Срок хранения (дней)",
|
||||||
|
"hard_delete": "Жесткое удаление",
|
||||||
"file_name": "Имя файла",
|
"file_name": "Имя файла",
|
||||||
"mime_type": "MIME-тип",
|
"mime_type": "MIME-тип",
|
||||||
"size_bytes": "Размер (байт)",
|
"size_bytes": "Размер (байт)",
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ from datetime import datetime, timezone
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException, Request as FastapiRequest
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
@ -18,6 +18,7 @@ from app.models.request import Request
|
||||||
from app.schemas.universal import UniversalQuery
|
from app.schemas.universal import UniversalQuery
|
||||||
from app.services.invoice_crypto import decrypt_requisites, encrypt_requisites
|
from app.services.invoice_crypto import decrypt_requisites, encrypt_requisites
|
||||||
from app.services.invoice_pdf import build_invoice_pdf_bytes
|
from app.services.invoice_pdf import build_invoice_pdf_bytes
|
||||||
|
from app.services.security_audit import extract_client_ip, record_pii_access_event
|
||||||
from app.services.universal_query import apply_universal_query
|
from app.services.universal_query import apply_universal_query
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
@ -193,6 +194,7 @@ def _commit_or_400(db: Session, detail: str) -> None:
|
||||||
@router.post("/query")
|
@router.post("/query")
|
||||||
def query_invoices(
|
def query_invoices(
|
||||||
uq: UniversalQuery,
|
uq: UniversalQuery,
|
||||||
|
http_request: FastapiRequest,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
admin: dict = Depends(require_role("ADMIN", "LAWYER")),
|
admin: dict = Depends(require_role("ADMIN", "LAWYER")),
|
||||||
):
|
):
|
||||||
|
|
@ -223,12 +225,25 @@ def query_invoices(
|
||||||
)
|
)
|
||||||
for row in rows
|
for row in rows
|
||||||
]
|
]
|
||||||
return {"rows": data, "total": int(total)}
|
payload = {"rows": data, "total": int(total)}
|
||||||
|
record_pii_access_event(
|
||||||
|
db,
|
||||||
|
actor_role=role,
|
||||||
|
actor_subject=str(admin.get("sub") or admin.get("email") or ""),
|
||||||
|
actor_ip=extract_client_ip(http_request),
|
||||||
|
action="READ_INVOICE_LIST",
|
||||||
|
scope="INVOICE",
|
||||||
|
details={"rows": int(total)},
|
||||||
|
responsible=str(admin.get("email") or "").strip() or "Администратор системы",
|
||||||
|
persist_now=True,
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{invoice_id}")
|
@router.get("/{invoice_id}")
|
||||||
def get_invoice(
|
def get_invoice(
|
||||||
invoice_id: str,
|
invoice_id: str,
|
||||||
|
http_request: FastapiRequest,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
admin: dict = Depends(require_role("ADMIN", "LAWYER")),
|
admin: dict = Depends(require_role("ADMIN", "LAWYER")),
|
||||||
):
|
):
|
||||||
|
|
@ -245,12 +260,25 @@ def get_invoice(
|
||||||
_ensure_lawyer_owns_request_or_403(role, actor_id, req)
|
_ensure_lawyer_owns_request_or_403(role, actor_id, req)
|
||||||
|
|
||||||
issuer = db.get(AdminUser, invoice.issued_by_admin_user_id) if invoice.issued_by_admin_user_id else None
|
issuer = db.get(AdminUser, invoice.issued_by_admin_user_id) if invoice.issued_by_admin_user_id else None
|
||||||
return _serialize_invoice(
|
payload = _serialize_invoice(
|
||||||
invoice,
|
invoice,
|
||||||
request_track=req.track_number,
|
request_track=req.track_number,
|
||||||
issuer_name=issuer.name if issuer else None,
|
issuer_name=issuer.name if issuer else None,
|
||||||
include_payer_details=True,
|
include_payer_details=True,
|
||||||
)
|
)
|
||||||
|
record_pii_access_event(
|
||||||
|
db,
|
||||||
|
actor_role=role,
|
||||||
|
actor_subject=str(admin.get("sub") or admin.get("email") or ""),
|
||||||
|
actor_ip=extract_client_ip(http_request),
|
||||||
|
action="READ_INVOICE_CARD",
|
||||||
|
scope="INVOICE",
|
||||||
|
request_id=req.id,
|
||||||
|
details={"invoice_id": str(invoice.id), "invoice_number": invoice.invoice_number},
|
||||||
|
responsible=str(admin.get("email") or "").strip() or "Администратор системы",
|
||||||
|
persist_now=True,
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
|
|
@ -406,6 +434,7 @@ def delete_invoice(
|
||||||
@router.get("/{invoice_id}/pdf")
|
@router.get("/{invoice_id}/pdf")
|
||||||
def download_invoice_pdf(
|
def download_invoice_pdf(
|
||||||
invoice_id: str,
|
invoice_id: str,
|
||||||
|
http_request: FastapiRequest,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
admin: dict = Depends(require_role("ADMIN", "LAWYER")),
|
admin: dict = Depends(require_role("ADMIN", "LAWYER")),
|
||||||
):
|
):
|
||||||
|
|
@ -435,6 +464,18 @@ def download_invoice_pdf(
|
||||||
issued_by_name=(issuer.name if issuer else None),
|
issued_by_name=(issuer.name if issuer else None),
|
||||||
requisites=requisites,
|
requisites=requisites,
|
||||||
)
|
)
|
||||||
|
record_pii_access_event(
|
||||||
|
db,
|
||||||
|
actor_role=role,
|
||||||
|
actor_subject=str(admin.get("sub") or admin.get("email") or ""),
|
||||||
|
actor_ip=extract_client_ip(http_request),
|
||||||
|
action="READ_INVOICE_PDF",
|
||||||
|
scope="INVOICE",
|
||||||
|
request_id=req.id,
|
||||||
|
details={"invoice_id": str(invoice.id), "invoice_number": invoice.invoice_number},
|
||||||
|
responsible=str(admin.get("email") or "").strip() or "Администратор системы",
|
||||||
|
persist_now=True,
|
||||||
|
)
|
||||||
|
|
||||||
file_name = f"{invoice.invoice_number}.pdf"
|
file_name = f"{invoice.invoice_number}.pdf"
|
||||||
headers = {"Content-Disposition": f'attachment; filename="{file_name}"'}
|
headers = {"Content-Disposition": f'attachment; filename="{file_name}"'}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Query, Request as FastapiRequest
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.deps import require_role
|
from app.core.deps import require_role
|
||||||
|
|
@ -15,6 +15,7 @@ from app.schemas.admin import (
|
||||||
RequestStatusChange,
|
RequestStatusChange,
|
||||||
)
|
)
|
||||||
from app.schemas.universal import UniversalQuery
|
from app.schemas.universal import UniversalQuery
|
||||||
|
from app.services.security_audit import extract_client_ip, record_pii_access_event
|
||||||
|
|
||||||
from .data_templates import (
|
from .data_templates import (
|
||||||
create_request_data_requirement_service,
|
create_request_data_requirement_service,
|
||||||
|
|
@ -80,8 +81,26 @@ def delete_request(request_id: str, db: Session = Depends(get_db), admin=Depends
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{request_id}")
|
@router.get("/{request_id}")
|
||||||
def get_request(request_id: str, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN", "LAWYER", "CURATOR"))):
|
def get_request(
|
||||||
return get_request_service(request_id, db, admin)
|
request_id: str,
|
||||||
|
http_request: FastapiRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
admin=Depends(require_role("ADMIN", "LAWYER", "CURATOR")),
|
||||||
|
):
|
||||||
|
payload = get_request_service(request_id, db, admin)
|
||||||
|
record_pii_access_event(
|
||||||
|
db,
|
||||||
|
actor_role=str(admin.get("role") or "ADMIN").upper(),
|
||||||
|
actor_subject=str(admin.get("sub") or admin.get("email") or ""),
|
||||||
|
actor_ip=extract_client_ip(http_request),
|
||||||
|
action="READ_REQUEST_CARD",
|
||||||
|
scope="REQUEST_CARD",
|
||||||
|
request_id=payload.get("id"),
|
||||||
|
details={"track_number": payload.get("track_number")},
|
||||||
|
responsible=str(admin.get("email") or "").strip() or "Администратор системы",
|
||||||
|
persist_now=True,
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{request_id}/status-change")
|
@router.post("/{request_id}/status-change")
|
||||||
|
|
@ -97,10 +116,24 @@ def change_request_status(
|
||||||
@router.get("/{request_id}/status-route")
|
@router.get("/{request_id}/status-route")
|
||||||
def get_request_status_route(
|
def get_request_status_route(
|
||||||
request_id: str,
|
request_id: str,
|
||||||
|
http_request: FastapiRequest,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
admin=Depends(require_role("ADMIN", "LAWYER", "CURATOR")),
|
admin=Depends(require_role("ADMIN", "LAWYER", "CURATOR")),
|
||||||
):
|
):
|
||||||
return get_request_status_route_service(request_id, db, admin)
|
payload = get_request_status_route_service(request_id, db, admin)
|
||||||
|
record_pii_access_event(
|
||||||
|
db,
|
||||||
|
actor_role=str(admin.get("role") or "ADMIN").upper(),
|
||||||
|
actor_subject=str(admin.get("sub") or admin.get("email") or ""),
|
||||||
|
actor_ip=extract_client_ip(http_request),
|
||||||
|
action="READ_REQUEST_STATUS_ROUTE",
|
||||||
|
scope="REQUEST_STATUS_ROUTE",
|
||||||
|
request_id=payload.get("request_id"),
|
||||||
|
details={"track_number": payload.get("track_number")},
|
||||||
|
responsible=str(admin.get("email") or "").strip() or "Администратор системы",
|
||||||
|
persist_now=True,
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{request_id}/claim")
|
@router.post("/{request_id}/claim")
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ from __future__ import annotations
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException, Request as FastapiRequest
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.deps import get_public_session
|
from app.core.deps import get_public_session
|
||||||
|
|
@ -22,6 +22,8 @@ from app.services.chat_secure_service import (
|
||||||
serialize_messages_for_request,
|
serialize_messages_for_request,
|
||||||
)
|
)
|
||||||
from app.services.request_read_markers import EVENT_REQUEST_DATA, mark_unread_for_lawyer
|
from app.services.request_read_markers import EVENT_REQUEST_DATA, mark_unread_for_lawyer
|
||||||
|
from app.services.origin_guard import enforce_public_origin_or_403
|
||||||
|
from app.services.security_audit import extract_client_ip, record_pii_access_event
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
@ -102,6 +104,38 @@ def _require_view_session_or_403(session: dict) -> str:
|
||||||
return subject
|
return subject
|
||||||
|
|
||||||
|
|
||||||
|
def _public_actor_subject(session: dict) -> str:
|
||||||
|
subject = _require_view_session_or_403(session)
|
||||||
|
normalized_track = _normalize_track(subject)
|
||||||
|
if normalized_track.startswith("TRK-"):
|
||||||
|
return normalized_track
|
||||||
|
normalized_phone = _normalize_phone(subject)
|
||||||
|
return normalized_phone or subject
|
||||||
|
|
||||||
|
|
||||||
|
def _audit_public_chat_read(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
session: dict,
|
||||||
|
http_request: FastapiRequest,
|
||||||
|
req: Request,
|
||||||
|
action: str,
|
||||||
|
details: dict | None = None,
|
||||||
|
) -> None:
|
||||||
|
record_pii_access_event(
|
||||||
|
db,
|
||||||
|
actor_role="CLIENT",
|
||||||
|
actor_subject=_public_actor_subject(session),
|
||||||
|
actor_ip=extract_client_ip(http_request),
|
||||||
|
action=action,
|
||||||
|
scope="CHAT",
|
||||||
|
request_id=req.id,
|
||||||
|
details=details or {},
|
||||||
|
responsible="Клиент",
|
||||||
|
persist_now=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _request_for_track_or_404(db: Session, track_number: str) -> Request:
|
def _request_for_track_or_404(db: Session, track_number: str) -> Request:
|
||||||
req = db.query(Request).filter(Request.track_number == _normalize_track(track_number)).first()
|
req = db.query(Request).filter(Request.track_number == _normalize_track(track_number)).first()
|
||||||
if req is None:
|
if req is None:
|
||||||
|
|
@ -124,22 +158,34 @@ def _ensure_view_access_or_403(session: dict, req: Request) -> None:
|
||||||
@router.get("/requests/{track_number}/messages")
|
@router.get("/requests/{track_number}/messages")
|
||||||
def list_messages_by_track(
|
def list_messages_by_track(
|
||||||
track_number: str,
|
track_number: str,
|
||||||
|
http_request: FastapiRequest,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
session: dict = Depends(get_public_session),
|
session: dict = Depends(get_public_session),
|
||||||
):
|
):
|
||||||
req = _request_for_track_or_404(db, track_number)
|
req = _request_for_track_or_404(db, track_number)
|
||||||
_ensure_view_access_or_403(session, req)
|
_ensure_view_access_or_403(session, req)
|
||||||
rows = list_messages_for_request(db, req.id)
|
rows = list_messages_for_request(db, req.id)
|
||||||
return serialize_messages_for_request(db, req.id, rows)
|
payload = serialize_messages_for_request(db, req.id, rows)
|
||||||
|
_audit_public_chat_read(
|
||||||
|
db,
|
||||||
|
session=session,
|
||||||
|
http_request=http_request,
|
||||||
|
req=req,
|
||||||
|
action="READ_CHAT_MESSAGES",
|
||||||
|
details={"rows": len(rows)},
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
@router.post("/requests/{track_number}/messages", status_code=201)
|
@router.post("/requests/{track_number}/messages", status_code=201)
|
||||||
def create_message_by_track(
|
def create_message_by_track(
|
||||||
track_number: str,
|
track_number: str,
|
||||||
payload: PublicMessageCreate,
|
payload: PublicMessageCreate,
|
||||||
|
http_request: FastapiRequest,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
session: dict = Depends(get_public_session),
|
session: dict = Depends(get_public_session),
|
||||||
):
|
):
|
||||||
|
enforce_public_origin_or_403(http_request, endpoint="/api/public/chat/requests/{track_number}/messages")
|
||||||
req = _request_for_track_or_404(db, track_number)
|
req = _request_for_track_or_404(db, track_number)
|
||||||
_ensure_view_access_or_403(session, req)
|
_ensure_view_access_or_403(session, req)
|
||||||
row = create_client_message(db, request=req, body=payload.body)
|
row = create_client_message(db, request=req, body=payload.body)
|
||||||
|
|
@ -149,6 +195,7 @@ def create_message_by_track(
|
||||||
@router.get("/requests/{track_number}/live")
|
@router.get("/requests/{track_number}/live")
|
||||||
def get_live_chat_state_by_track(
|
def get_live_chat_state_by_track(
|
||||||
track_number: str,
|
track_number: str,
|
||||||
|
http_request: FastapiRequest,
|
||||||
cursor: str | None = None,
|
cursor: str | None = None,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
session: dict = Depends(get_public_session),
|
session: dict = Depends(get_public_session),
|
||||||
|
|
@ -164,7 +211,7 @@ def get_live_chat_state_by_track(
|
||||||
subject = _require_view_session_or_403(session)
|
subject = _require_view_session_or_403(session)
|
||||||
actor_key = f"CLIENT:{_normalize_track(subject) or _normalize_phone(subject)}"
|
actor_key = f"CLIENT:{_normalize_track(subject) or _normalize_phone(subject)}"
|
||||||
typing_rows = list_typing_presence(request_key=str(req.id), exclude_actor_key=actor_key)
|
typing_rows = list_typing_presence(request_key=str(req.id), exclude_actor_key=actor_key)
|
||||||
return {
|
payload = {
|
||||||
"track_number": req.track_number,
|
"track_number": req.track_number,
|
||||||
"cursor": latest_activity_iso,
|
"cursor": latest_activity_iso,
|
||||||
"has_updates": has_updates,
|
"has_updates": has_updates,
|
||||||
|
|
@ -179,15 +226,26 @@ def get_live_chat_state_by_track(
|
||||||
request_id=req.id,
|
request_id=req.id,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
_audit_public_chat_read(
|
||||||
|
db,
|
||||||
|
session=session,
|
||||||
|
http_request=http_request,
|
||||||
|
req=req,
|
||||||
|
action="READ_CHAT_LIVE_STATE",
|
||||||
|
details={"has_updates": bool(has_updates)},
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
@router.post("/requests/{track_number}/typing")
|
@router.post("/requests/{track_number}/typing")
|
||||||
def set_live_chat_typing_by_track(
|
def set_live_chat_typing_by_track(
|
||||||
track_number: str,
|
track_number: str,
|
||||||
payload: dict,
|
payload: dict,
|
||||||
|
http_request: FastapiRequest,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
session: dict = Depends(get_public_session),
|
session: dict = Depends(get_public_session),
|
||||||
):
|
):
|
||||||
|
enforce_public_origin_or_403(http_request, endpoint="/api/public/chat/requests/{track_number}/typing")
|
||||||
req = _request_for_track_or_404(db, track_number)
|
req = _request_for_track_or_404(db, track_number)
|
||||||
_ensure_view_access_or_403(session, req)
|
_ensure_view_access_or_403(session, req)
|
||||||
subject = _require_view_session_or_403(session)
|
subject = _require_view_session_or_403(session)
|
||||||
|
|
@ -207,6 +265,7 @@ def set_live_chat_typing_by_track(
|
||||||
def get_data_request_by_message(
|
def get_data_request_by_message(
|
||||||
track_number: str,
|
track_number: str,
|
||||||
message_id: str,
|
message_id: str,
|
||||||
|
http_request: FastapiRequest,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
session: dict = Depends(get_public_session),
|
session: dict = Depends(get_public_session),
|
||||||
):
|
):
|
||||||
|
|
@ -230,7 +289,7 @@ def get_data_request_by_message(
|
||||||
)
|
)
|
||||||
if not rows:
|
if not rows:
|
||||||
raise HTTPException(status_code=404, detail="Запрос данных не найден")
|
raise HTTPException(status_code=404, detail="Запрос данных не найден")
|
||||||
return {
|
payload = {
|
||||||
"message_id": str(message.id),
|
"message_id": str(message.id),
|
||||||
"request_id": str(req.id),
|
"request_id": str(req.id),
|
||||||
"track_number": req.track_number,
|
"track_number": req.track_number,
|
||||||
|
|
@ -248,6 +307,15 @@ def get_data_request_by_message(
|
||||||
for row in rows
|
for row in rows
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
_audit_public_chat_read(
|
||||||
|
db,
|
||||||
|
session=session,
|
||||||
|
http_request=http_request,
|
||||||
|
req=req,
|
||||||
|
action="READ_CHAT_DATA_REQUEST",
|
||||||
|
details={"message_id": str(message.id), "rows": len(rows)},
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
@router.post("/requests/{track_number}/data-requests/{message_id}")
|
@router.post("/requests/{track_number}/data-requests/{message_id}")
|
||||||
|
|
@ -255,9 +323,14 @@ def save_data_request_values(
|
||||||
track_number: str,
|
track_number: str,
|
||||||
message_id: str,
|
message_id: str,
|
||||||
payload: dict,
|
payload: dict,
|
||||||
|
http_request: FastapiRequest,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
session: dict = Depends(get_public_session),
|
session: dict = Depends(get_public_session),
|
||||||
):
|
):
|
||||||
|
enforce_public_origin_or_403(
|
||||||
|
http_request,
|
||||||
|
endpoint="/api/public/chat/requests/{track_number}/data-requests/{message_id}",
|
||||||
|
)
|
||||||
req = _request_for_track_or_404(db, track_number)
|
req = _request_for_track_or_404(db, track_number)
|
||||||
_ensure_view_access_or_403(session, req)
|
_ensure_view_access_or_403(session, req)
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ from app.models.otp_session import OtpSession
|
||||||
from app.models.request import Request as RequestModel
|
from app.models.request import Request as RequestModel
|
||||||
from app.schemas.public import OtpSend, OtpVerify
|
from app.schemas.public import OtpSend, OtpVerify
|
||||||
from app.services.email_service import EmailDeliveryError, send_otp_email_message
|
from app.services.email_service import EmailDeliveryError, send_otp_email_message
|
||||||
|
from app.services.origin_guard import enforce_public_origin_or_403
|
||||||
from app.services.rate_limit import get_rate_limiter
|
from app.services.rate_limit import get_rate_limiter
|
||||||
from app.services.sms_service import SmsDeliveryError, send_otp_message, sms_provider_health
|
from app.services.sms_service import SmsDeliveryError, send_otp_message, sms_provider_health
|
||||||
|
|
||||||
|
|
@ -76,6 +77,10 @@ def _normalize_channel(raw: str | None) -> str:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _is_honeypot_tripped(raw: str | None) -> bool:
|
||||||
|
return bool(str(raw or "").strip())
|
||||||
|
|
||||||
|
|
||||||
def _auth_mode() -> str:
|
def _auth_mode() -> str:
|
||||||
mode = str(getattr(settings, "PUBLIC_AUTH_MODE", AUTH_MODE_SMS) or "").strip().lower()
|
mode = str(getattr(settings, "PUBLIC_AUTH_MODE", AUTH_MODE_SMS) or "").strip().lower()
|
||||||
if mode not in SUPPORTED_AUTH_MODES:
|
if mode not in SUPPORTED_AUTH_MODES:
|
||||||
|
|
@ -184,8 +189,8 @@ def _set_public_cookie(response: Response, *, subject: str, purpose: str, auth_c
|
||||||
key=settings.PUBLIC_COOKIE_NAME,
|
key=settings.PUBLIC_COOKIE_NAME,
|
||||||
value=token,
|
value=token,
|
||||||
httponly=True,
|
httponly=True,
|
||||||
secure=False,
|
secure=settings.public_cookie_secure_effective,
|
||||||
samesite="lax",
|
samesite=settings.public_cookie_samesite_effective,
|
||||||
max_age=settings.PUBLIC_JWT_TTL_DAYS * 24 * 3600,
|
max_age=settings.PUBLIC_JWT_TTL_DAYS * 24 * 3600,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -211,6 +216,9 @@ def get_auth_config():
|
||||||
|
|
||||||
@router.post("/send")
|
@router.post("/send")
|
||||||
def send_otp(payload: OtpSend, request: Request, db: Session = Depends(get_db)):
|
def send_otp(payload: OtpSend, request: Request, db: Session = Depends(get_db)):
|
||||||
|
enforce_public_origin_or_403(request, endpoint="/api/public/otp/send")
|
||||||
|
if _is_honeypot_tripped(getattr(payload, "hp_field", None)):
|
||||||
|
raise HTTPException(status_code=400, detail="Некорректный запрос")
|
||||||
purpose = _normalize_purpose(payload.purpose)
|
purpose = _normalize_purpose(payload.purpose)
|
||||||
if purpose not in ALLOWED_PURPOSES:
|
if purpose not in ALLOWED_PURPOSES:
|
||||||
raise HTTPException(status_code=400, detail="Некорректная цель OTP")
|
raise HTTPException(status_code=400, detail="Некорректная цель OTP")
|
||||||
|
|
@ -344,6 +352,7 @@ def send_otp(payload: OtpSend, request: Request, db: Session = Depends(get_db)):
|
||||||
|
|
||||||
@router.post("/verify")
|
@router.post("/verify")
|
||||||
def verify_otp(payload: OtpVerify, request: Request, response: Response, db: Session = Depends(get_db)):
|
def verify_otp(payload: OtpVerify, request: Request, response: Response, db: Session = Depends(get_db)):
|
||||||
|
enforce_public_origin_or_403(request, endpoint="/api/public/otp/verify")
|
||||||
purpose = _normalize_purpose(payload.purpose)
|
purpose = _normalize_purpose(payload.purpose)
|
||||||
if purpose not in ALLOWED_PURPOSES:
|
if purpose not in ALLOWED_PURPOSES:
|
||||||
raise HTTPException(status_code=400, detail="Некорректная цель OTP")
|
raise HTTPException(status_code=400, detail="Некорректная цель OTP")
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import timedelta
|
from datetime import datetime, timedelta, timezone
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Response
|
from fastapi import APIRouter, Depends, HTTPException, Response, Request as FastapiRequest
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
@ -28,6 +28,7 @@ from app.models.topic import Topic
|
||||||
from app.services.invoice_crypto import decrypt_requisites
|
from app.services.invoice_crypto import decrypt_requisites
|
||||||
from app.services.invoice_pdf import build_invoice_pdf_bytes
|
from app.services.invoice_pdf import build_invoice_pdf_bytes
|
||||||
from app.services.chat_secure_service import create_client_message, list_messages_for_request
|
from app.services.chat_secure_service import create_client_message, list_messages_for_request
|
||||||
|
from app.services.origin_guard import enforce_public_origin_or_403
|
||||||
from app.services.notifications import (
|
from app.services.notifications import (
|
||||||
get_client_notification,
|
get_client_notification,
|
||||||
list_client_notifications,
|
list_client_notifications,
|
||||||
|
|
@ -37,6 +38,7 @@ from app.services.notifications import (
|
||||||
)
|
)
|
||||||
from app.services.request_read_markers import clear_unread_for_client
|
from app.services.request_read_markers import clear_unread_for_client
|
||||||
from app.services.request_templates import validate_required_topic_fields_or_400
|
from app.services.request_templates import validate_required_topic_fields_or_400
|
||||||
|
from app.services.security_audit import extract_client_ip, record_pii_access_event
|
||||||
from app.api.admin.requests_modules.status_flow import get_request_status_route_service
|
from app.api.admin.requests_modules.status_flow import get_request_status_route_service
|
||||||
from app.schemas.public import (
|
from app.schemas.public import (
|
||||||
PublicAttachmentRead,
|
PublicAttachmentRead,
|
||||||
|
|
@ -78,6 +80,14 @@ def _normalize_track(raw: str | None) -> str:
|
||||||
return str(raw or "").strip().upper()
|
return str(raw or "").strip().upper()
|
||||||
|
|
||||||
|
|
||||||
|
def _is_honeypot_tripped(raw: str | None) -> bool:
|
||||||
|
return bool(str(raw or "").strip())
|
||||||
|
|
||||||
|
|
||||||
|
def _now_utc() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
def _set_view_cookie(response: Response, subject: str) -> None:
|
def _set_view_cookie(response: Response, subject: str) -> None:
|
||||||
token = create_jwt(
|
token = create_jwt(
|
||||||
{"sub": subject, "purpose": OTP_VIEW_PURPOSE},
|
{"sub": subject, "purpose": OTP_VIEW_PURPOSE},
|
||||||
|
|
@ -88,12 +98,48 @@ def _set_view_cookie(response: Response, subject: str) -> None:
|
||||||
key=settings.PUBLIC_COOKIE_NAME,
|
key=settings.PUBLIC_COOKIE_NAME,
|
||||||
value=token,
|
value=token,
|
||||||
httponly=True,
|
httponly=True,
|
||||||
secure=False,
|
secure=settings.public_cookie_secure_effective,
|
||||||
samesite="lax",
|
samesite=settings.public_cookie_samesite_effective,
|
||||||
max_age=settings.PUBLIC_JWT_TTL_DAYS * 24 * 3600,
|
max_age=settings.PUBLIC_JWT_TTL_DAYS * 24 * 3600,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _public_actor_subject(session: dict) -> str:
|
||||||
|
subject = _require_view_session_or_403(session)
|
||||||
|
normalized_track = _normalize_track(subject)
|
||||||
|
if normalized_track.startswith("TRK-"):
|
||||||
|
return normalized_track
|
||||||
|
normalized_phone = _normalize_phone(subject)
|
||||||
|
if normalized_phone:
|
||||||
|
return normalized_phone
|
||||||
|
normalized_email = _normalize_email(subject)
|
||||||
|
return normalized_email or subject
|
||||||
|
|
||||||
|
|
||||||
|
def _record_public_read_audit(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
session: dict,
|
||||||
|
http_request: FastapiRequest,
|
||||||
|
action: str,
|
||||||
|
scope: str,
|
||||||
|
request_id: str | UUID | None = None,
|
||||||
|
details: dict | None = None,
|
||||||
|
) -> None:
|
||||||
|
record_pii_access_event(
|
||||||
|
db,
|
||||||
|
actor_role="CLIENT",
|
||||||
|
actor_subject=_public_actor_subject(session),
|
||||||
|
actor_ip=extract_client_ip(http_request),
|
||||||
|
action=action,
|
||||||
|
scope=scope,
|
||||||
|
request_id=request_id,
|
||||||
|
details=details or {},
|
||||||
|
responsible="Клиент",
|
||||||
|
persist_now=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _require_create_session_or_403(session: dict, client_phone: str, client_email: str | None = None) -> None:
|
def _require_create_session_or_403(session: dict, client_phone: str, client_email: str | None = None) -> None:
|
||||||
purpose = str(session.get("purpose") or "").strip().upper()
|
purpose = str(session.get("purpose") or "").strip().upper()
|
||||||
subject = str(session.get("sub") or "").strip()
|
subject = str(session.get("sub") or "").strip()
|
||||||
|
|
@ -211,9 +257,15 @@ def _public_invoice_payload(row: Invoice, track_number: str) -> dict:
|
||||||
def create_request(
|
def create_request(
|
||||||
payload: PublicRequestCreate,
|
payload: PublicRequestCreate,
|
||||||
response: Response,
|
response: Response,
|
||||||
|
request: FastapiRequest,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
session: dict = Depends(get_public_session),
|
session: dict = Depends(get_public_session),
|
||||||
):
|
):
|
||||||
|
enforce_public_origin_or_403(request, endpoint="/api/public/requests")
|
||||||
|
if _is_honeypot_tripped(getattr(payload, "hp_field", None)):
|
||||||
|
raise HTTPException(status_code=400, detail="Некорректный запрос")
|
||||||
|
if not bool(payload.pdn_consent):
|
||||||
|
raise HTTPException(status_code=400, detail="Необходимо согласие на обработку персональных данных")
|
||||||
_require_create_session_or_403(session, payload.client_phone, payload.client_email)
|
_require_create_session_or_403(session, payload.client_phone, payload.client_email)
|
||||||
validate_required_topic_fields_or_400(db, payload.topic_code, payload.extra_fields)
|
validate_required_topic_fields_or_400(db, payload.topic_code, payload.extra_fields)
|
||||||
client = _upsert_client_by_phone(
|
client = _upsert_client_by_phone(
|
||||||
|
|
@ -233,9 +285,28 @@ def create_request(
|
||||||
topic_code=payload.topic_code,
|
topic_code=payload.topic_code,
|
||||||
description=payload.description,
|
description=payload.description,
|
||||||
extra_fields=payload.extra_fields,
|
extra_fields=payload.extra_fields,
|
||||||
|
pdn_consent=True,
|
||||||
|
pdn_consent_at=_now_utc(),
|
||||||
|
pdn_consent_ip=extract_client_ip(request),
|
||||||
responsible="Клиент",
|
responsible="Клиент",
|
||||||
)
|
)
|
||||||
db.add(row)
|
db.add(row)
|
||||||
|
db.flush()
|
||||||
|
db.add(
|
||||||
|
AuditLog(
|
||||||
|
actor_admin_id=None,
|
||||||
|
entity="requests",
|
||||||
|
entity_id=str(row.id),
|
||||||
|
action="PDN_CONSENT_CAPTURED",
|
||||||
|
diff={
|
||||||
|
"track_number": row.track_number,
|
||||||
|
"consent": True,
|
||||||
|
"consent_at": _to_iso(row.pdn_consent_at),
|
||||||
|
"consent_ip": row.pdn_consent_ip,
|
||||||
|
},
|
||||||
|
responsible="Клиент",
|
||||||
|
)
|
||||||
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(row)
|
db.refresh(row)
|
||||||
|
|
||||||
|
|
@ -256,6 +327,7 @@ def list_public_topics(db: Session = Depends(get_db)):
|
||||||
|
|
||||||
@router.get("/my")
|
@router.get("/my")
|
||||||
def list_my_requests(
|
def list_my_requests(
|
||||||
|
request: FastapiRequest,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
session: dict = Depends(get_public_session),
|
session: dict = Depends(get_public_session),
|
||||||
):
|
):
|
||||||
|
|
@ -301,7 +373,7 @@ def list_my_requests(
|
||||||
by_event[event_key] = int(by_event.get(event_key, 0)) + event_count
|
by_event[event_key] = int(by_event.get(event_key, 0)) + event_count
|
||||||
bucket["by_event"] = by_event
|
bucket["by_event"] = by_event
|
||||||
bucket["total"] = int(bucket.get("total", 0)) + event_count
|
bucket["total"] = int(bucket.get("total", 0)) + event_count
|
||||||
return {
|
payload = {
|
||||||
"rows": [
|
"rows": [
|
||||||
{
|
{
|
||||||
"id": str(row.id),
|
"id": str(row.id),
|
||||||
|
|
@ -319,11 +391,21 @@ def list_my_requests(
|
||||||
],
|
],
|
||||||
"total": len(rows),
|
"total": len(rows),
|
||||||
}
|
}
|
||||||
|
_record_public_read_audit(
|
||||||
|
db,
|
||||||
|
session=session,
|
||||||
|
http_request=request,
|
||||||
|
action="READ_REQUEST_LIST",
|
||||||
|
scope="REQUEST_LIST",
|
||||||
|
details={"total": len(rows)},
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{track_number}")
|
@router.get("/{track_number}")
|
||||||
def get_request_by_track(
|
def get_request_by_track(
|
||||||
track_number: str,
|
track_number: str,
|
||||||
|
request: FastapiRequest,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
session: dict = Depends(get_public_session),
|
session: dict = Depends(get_public_session),
|
||||||
):
|
):
|
||||||
|
|
@ -370,7 +452,7 @@ def get_request_by_track(
|
||||||
request_id=req.id,
|
request_id=req.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
payload = {
|
||||||
"id": str(req.id),
|
"id": str(req.id),
|
||||||
"client_id": str(req.client_id) if req.client_id else None,
|
"client_id": str(req.client_id) if req.client_id else None,
|
||||||
"track_number": req.track_number,
|
"track_number": req.track_number,
|
||||||
|
|
@ -397,11 +479,22 @@ def get_request_by_track(
|
||||||
"created_at": _to_iso(req.created_at),
|
"created_at": _to_iso(req.created_at),
|
||||||
"updated_at": _to_iso(req.updated_at),
|
"updated_at": _to_iso(req.updated_at),
|
||||||
}
|
}
|
||||||
|
_record_public_read_audit(
|
||||||
|
db,
|
||||||
|
session=session,
|
||||||
|
http_request=request,
|
||||||
|
action="READ_REQUEST_CARD",
|
||||||
|
scope="REQUEST_CARD",
|
||||||
|
request_id=req.id,
|
||||||
|
details={"track_number": req.track_number},
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{track_number}/status-route")
|
@router.get("/{track_number}/status-route")
|
||||||
def get_status_route_by_track(
|
def get_status_route_by_track(
|
||||||
track_number: str,
|
track_number: str,
|
||||||
|
request: FastapiRequest,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
session: dict = Depends(get_public_session),
|
session: dict = Depends(get_public_session),
|
||||||
):
|
):
|
||||||
|
|
@ -413,11 +506,20 @@ def get_status_route_by_track(
|
||||||
{"role": "ADMIN", "sub": "", "email": "Клиент"},
|
{"role": "ADMIN", "sub": "", "email": "Клиент"},
|
||||||
)
|
)
|
||||||
payload["available_statuses"] = []
|
payload["available_statuses"] = []
|
||||||
|
_record_public_read_audit(
|
||||||
|
db,
|
||||||
|
session=session,
|
||||||
|
http_request=request,
|
||||||
|
action="READ_REQUEST_STATUS_ROUTE",
|
||||||
|
scope="REQUEST_STATUS_ROUTE",
|
||||||
|
request_id=req.id,
|
||||||
|
details={"track_number": req.track_number},
|
||||||
|
)
|
||||||
return payload
|
return payload
|
||||||
except Exception:
|
except Exception:
|
||||||
current = str(req.status_code or "").strip()
|
current = str(req.status_code or "").strip()
|
||||||
changed_at = _to_iso(req.updated_at or req.created_at)
|
changed_at = _to_iso(req.updated_at or req.created_at)
|
||||||
return {
|
payload = {
|
||||||
"request_id": str(req.id),
|
"request_id": str(req.id),
|
||||||
"track_number": req.track_number,
|
"track_number": req.track_number,
|
||||||
"topic_code": req.topic_code,
|
"topic_code": req.topic_code,
|
||||||
|
|
@ -450,17 +552,28 @@ def get_status_route_by_track(
|
||||||
if current
|
if current
|
||||||
else [],
|
else [],
|
||||||
}
|
}
|
||||||
|
_record_public_read_audit(
|
||||||
|
db,
|
||||||
|
session=session,
|
||||||
|
http_request=request,
|
||||||
|
action="READ_REQUEST_STATUS_ROUTE",
|
||||||
|
scope="REQUEST_STATUS_ROUTE",
|
||||||
|
request_id=req.id,
|
||||||
|
details={"track_number": req.track_number, "fallback": True},
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{track_number}/messages", response_model=list[PublicMessageRead])
|
@router.get("/{track_number}/messages", response_model=list[PublicMessageRead])
|
||||||
def list_messages_by_track(
|
def list_messages_by_track(
|
||||||
track_number: str,
|
track_number: str,
|
||||||
|
request: FastapiRequest,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
session: dict = Depends(get_public_session),
|
session: dict = Depends(get_public_session),
|
||||||
):
|
):
|
||||||
req = _request_for_track_or_404(db, session, track_number)
|
req = _request_for_track_or_404(db, session, track_number)
|
||||||
rows = list_messages_for_request(db, req.id)
|
rows = list_messages_for_request(db, req.id)
|
||||||
return [
|
payload = [
|
||||||
PublicMessageRead(
|
PublicMessageRead(
|
||||||
id=row.id,
|
id=row.id,
|
||||||
request_id=row.request_id,
|
request_id=row.request_id,
|
||||||
|
|
@ -472,15 +585,27 @@ def list_messages_by_track(
|
||||||
)
|
)
|
||||||
for row in rows
|
for row in rows
|
||||||
]
|
]
|
||||||
|
_record_public_read_audit(
|
||||||
|
db,
|
||||||
|
session=session,
|
||||||
|
http_request=request,
|
||||||
|
action="READ_CHAT_MESSAGES",
|
||||||
|
scope="CHAT",
|
||||||
|
request_id=req.id,
|
||||||
|
details={"rows": len(rows)},
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{track_number}/messages", response_model=PublicMessageRead, status_code=201)
|
@router.post("/{track_number}/messages", response_model=PublicMessageRead, status_code=201)
|
||||||
def create_message_by_track(
|
def create_message_by_track(
|
||||||
track_number: str,
|
track_number: str,
|
||||||
payload: PublicMessageCreate,
|
payload: PublicMessageCreate,
|
||||||
|
request: FastapiRequest,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
session: dict = Depends(get_public_session),
|
session: dict = Depends(get_public_session),
|
||||||
):
|
):
|
||||||
|
enforce_public_origin_or_403(request, endpoint="/api/public/requests/{track_number}/messages")
|
||||||
req = _request_for_track_or_404(db, session, track_number)
|
req = _request_for_track_or_404(db, session, track_number)
|
||||||
row = create_client_message(db, request=req, body=payload.body)
|
row = create_client_message(db, request=req, body=payload.body)
|
||||||
|
|
||||||
|
|
@ -498,6 +623,7 @@ def create_message_by_track(
|
||||||
@router.get("/{track_number}/attachments", response_model=list[PublicAttachmentRead])
|
@router.get("/{track_number}/attachments", response_model=list[PublicAttachmentRead])
|
||||||
def list_attachments_by_track(
|
def list_attachments_by_track(
|
||||||
track_number: str,
|
track_number: str,
|
||||||
|
request: FastapiRequest,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
session: dict = Depends(get_public_session),
|
session: dict = Depends(get_public_session),
|
||||||
):
|
):
|
||||||
|
|
@ -508,7 +634,7 @@ def list_attachments_by_track(
|
||||||
.order_by(Attachment.created_at.desc(), Attachment.id.desc())
|
.order_by(Attachment.created_at.desc(), Attachment.id.desc())
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
return [
|
payload = [
|
||||||
PublicAttachmentRead(
|
PublicAttachmentRead(
|
||||||
id=row.id,
|
id=row.id,
|
||||||
request_id=row.request_id,
|
request_id=row.request_id,
|
||||||
|
|
@ -521,11 +647,22 @@ def list_attachments_by_track(
|
||||||
)
|
)
|
||||||
for row in rows
|
for row in rows
|
||||||
]
|
]
|
||||||
|
_record_public_read_audit(
|
||||||
|
db,
|
||||||
|
session=session,
|
||||||
|
http_request=request,
|
||||||
|
action="READ_ATTACHMENTS",
|
||||||
|
scope="REQUEST_ATTACHMENTS",
|
||||||
|
request_id=req.id,
|
||||||
|
details={"rows": len(rows)},
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{track_number}/invoices")
|
@router.get("/{track_number}/invoices")
|
||||||
def list_invoices_by_track(
|
def list_invoices_by_track(
|
||||||
track_number: str,
|
track_number: str,
|
||||||
|
request: FastapiRequest,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
session: dict = Depends(get_public_session),
|
session: dict = Depends(get_public_session),
|
||||||
):
|
):
|
||||||
|
|
@ -536,13 +673,24 @@ def list_invoices_by_track(
|
||||||
.order_by(Invoice.issued_at.desc(), Invoice.created_at.desc(), Invoice.id.desc())
|
.order_by(Invoice.issued_at.desc(), Invoice.created_at.desc(), Invoice.id.desc())
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
return [_public_invoice_payload(row, req.track_number) for row in rows]
|
payload = [_public_invoice_payload(row, req.track_number) for row in rows]
|
||||||
|
_record_public_read_audit(
|
||||||
|
db,
|
||||||
|
session=session,
|
||||||
|
http_request=request,
|
||||||
|
action="READ_INVOICE_LIST",
|
||||||
|
scope="INVOICE",
|
||||||
|
request_id=req.id,
|
||||||
|
details={"rows": len(rows)},
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{track_number}/invoices/{invoice_id}/pdf")
|
@router.get("/{track_number}/invoices/{invoice_id}/pdf")
|
||||||
def download_invoice_pdf_by_track(
|
def download_invoice_pdf_by_track(
|
||||||
track_number: str,
|
track_number: str,
|
||||||
invoice_id: str,
|
invoice_id: str,
|
||||||
|
request: FastapiRequest,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
session: dict = Depends(get_public_session),
|
session: dict = Depends(get_public_session),
|
||||||
):
|
):
|
||||||
|
|
@ -570,6 +718,15 @@ def download_invoice_pdf_by_track(
|
||||||
issued_by_name=(issuer.name if issuer else invoice.issued_by_role),
|
issued_by_name=(issuer.name if issuer else invoice.issued_by_role),
|
||||||
requisites=requisites,
|
requisites=requisites,
|
||||||
)
|
)
|
||||||
|
_record_public_read_audit(
|
||||||
|
db,
|
||||||
|
session=session,
|
||||||
|
http_request=request,
|
||||||
|
action="READ_INVOICE_PDF",
|
||||||
|
scope="INVOICE",
|
||||||
|
request_id=req.id,
|
||||||
|
details={"invoice_id": str(invoice.id), "invoice_number": invoice.invoice_number},
|
||||||
|
)
|
||||||
file_name = f"{invoice.invoice_number}.pdf"
|
file_name = f"{invoice.invoice_number}.pdf"
|
||||||
headers = {"Content-Disposition": f'attachment; filename="{file_name}"'}
|
headers = {"Content-Disposition": f'attachment; filename="{file_name}"'}
|
||||||
return StreamingResponse(iter([pdf_bytes]), media_type="application/pdf", headers=headers)
|
return StreamingResponse(iter([pdf_bytes]), media_type="application/pdf", headers=headers)
|
||||||
|
|
@ -578,6 +735,7 @@ def download_invoice_pdf_by_track(
|
||||||
@router.get("/{track_number}/history", response_model=list[PublicStatusHistoryRead])
|
@router.get("/{track_number}/history", response_model=list[PublicStatusHistoryRead])
|
||||||
def list_status_history_by_track(
|
def list_status_history_by_track(
|
||||||
track_number: str,
|
track_number: str,
|
||||||
|
request: FastapiRequest,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
session: dict = Depends(get_public_session),
|
session: dict = Depends(get_public_session),
|
||||||
):
|
):
|
||||||
|
|
@ -588,7 +746,7 @@ def list_status_history_by_track(
|
||||||
.order_by(StatusHistory.created_at.asc(), StatusHistory.id.asc())
|
.order_by(StatusHistory.created_at.asc(), StatusHistory.id.asc())
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
return [
|
payload = [
|
||||||
PublicStatusHistoryRead(
|
PublicStatusHistoryRead(
|
||||||
id=row.id,
|
id=row.id,
|
||||||
request_id=row.request_id,
|
request_id=row.request_id,
|
||||||
|
|
@ -599,11 +757,22 @@ def list_status_history_by_track(
|
||||||
)
|
)
|
||||||
for row in rows
|
for row in rows
|
||||||
]
|
]
|
||||||
|
_record_public_read_audit(
|
||||||
|
db,
|
||||||
|
session=session,
|
||||||
|
http_request=request,
|
||||||
|
action="READ_REQUEST_HISTORY",
|
||||||
|
scope="REQUEST_HISTORY",
|
||||||
|
request_id=req.id,
|
||||||
|
details={"rows": len(rows)},
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{track_number}/timeline", response_model=list[PublicTimelineEvent])
|
@router.get("/{track_number}/timeline", response_model=list[PublicTimelineEvent])
|
||||||
def list_timeline_by_track(
|
def list_timeline_by_track(
|
||||||
track_number: str,
|
track_number: str,
|
||||||
|
request: FastapiRequest,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
session: dict = Depends(get_public_session),
|
session: dict = Depends(get_public_session),
|
||||||
):
|
):
|
||||||
|
|
@ -658,6 +827,15 @@ def list_timeline_by_track(
|
||||||
return event.created_at or ""
|
return event.created_at or ""
|
||||||
|
|
||||||
events.sort(key=_sort_key)
|
events.sort(key=_sort_key)
|
||||||
|
_record_public_read_audit(
|
||||||
|
db,
|
||||||
|
session=session,
|
||||||
|
http_request=request,
|
||||||
|
action="READ_REQUEST_TIMELINE",
|
||||||
|
scope="REQUEST_TIMELINE",
|
||||||
|
request_id=req.id,
|
||||||
|
details={"rows": len(events)},
|
||||||
|
)
|
||||||
return events
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -665,9 +843,11 @@ def list_timeline_by_track(
|
||||||
def create_service_request_by_track(
|
def create_service_request_by_track(
|
||||||
track_number: str,
|
track_number: str,
|
||||||
payload: PublicServiceRequestCreate,
|
payload: PublicServiceRequestCreate,
|
||||||
|
request: FastapiRequest,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
session: dict = Depends(get_public_session),
|
session: dict = Depends(get_public_session),
|
||||||
):
|
):
|
||||||
|
enforce_public_origin_or_403(request, endpoint="/api/public/requests/{track_number}/service-requests")
|
||||||
req = _request_for_track_or_404(db, session, track_number)
|
req = _request_for_track_or_404(db, session, track_number)
|
||||||
request_type = str(payload.type or "").strip().upper()
|
request_type = str(payload.type or "").strip().upper()
|
||||||
if request_type not in SERVICE_REQUEST_TYPES:
|
if request_type not in SERVICE_REQUEST_TYPES:
|
||||||
|
|
@ -720,6 +900,7 @@ def create_service_request_by_track(
|
||||||
@router.get("/{track_number}/service-requests", response_model=list[PublicServiceRequestRead])
|
@router.get("/{track_number}/service-requests", response_model=list[PublicServiceRequestRead])
|
||||||
def list_service_requests_by_track(
|
def list_service_requests_by_track(
|
||||||
track_number: str,
|
track_number: str,
|
||||||
|
request: FastapiRequest,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
session: dict = Depends(get_public_session),
|
session: dict = Depends(get_public_session),
|
||||||
):
|
):
|
||||||
|
|
@ -733,12 +914,23 @@ def list_service_requests_by_track(
|
||||||
.order_by(RequestServiceRequest.created_at.desc(), RequestServiceRequest.id.desc())
|
.order_by(RequestServiceRequest.created_at.desc(), RequestServiceRequest.id.desc())
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
return [_serialize_public_service_request(row) for row in rows]
|
payload = [_serialize_public_service_request(row) for row in rows]
|
||||||
|
_record_public_read_audit(
|
||||||
|
db,
|
||||||
|
session=session,
|
||||||
|
http_request=request,
|
||||||
|
action="READ_SERVICE_REQUESTS",
|
||||||
|
scope="SERVICE_REQUEST",
|
||||||
|
request_id=req.id,
|
||||||
|
details={"rows": len(rows)},
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{track_number}/notifications")
|
@router.get("/{track_number}/notifications")
|
||||||
def list_notifications_by_track(
|
def list_notifications_by_track(
|
||||||
track_number: str,
|
track_number: str,
|
||||||
|
request: FastapiRequest,
|
||||||
unread_only: bool = False,
|
unread_only: bool = False,
|
||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
|
|
@ -762,20 +954,32 @@ def list_notifications_by_track(
|
||||||
limit=1,
|
limit=1,
|
||||||
offset=0,
|
offset=0,
|
||||||
)
|
)
|
||||||
return {
|
payload = {
|
||||||
"rows": [serialize_notification(row) for row in rows],
|
"rows": [serialize_notification(row) for row in rows],
|
||||||
"total": int(total),
|
"total": int(total),
|
||||||
"unread_total": int(unread_total),
|
"unread_total": int(unread_total),
|
||||||
}
|
}
|
||||||
|
_record_public_read_audit(
|
||||||
|
db,
|
||||||
|
session=session,
|
||||||
|
http_request=request,
|
||||||
|
action="READ_NOTIFICATIONS",
|
||||||
|
scope="NOTIFICATION",
|
||||||
|
request_id=req.id,
|
||||||
|
details={"rows": int(total), "unread_only": bool(unread_only)},
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{track_number}/notifications/{notification_id}/read")
|
@router.post("/{track_number}/notifications/{notification_id}/read")
|
||||||
def read_notification_by_track(
|
def read_notification_by_track(
|
||||||
track_number: str,
|
track_number: str,
|
||||||
notification_id: str,
|
notification_id: str,
|
||||||
|
request: FastapiRequest,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
session: dict = Depends(get_public_session),
|
session: dict = Depends(get_public_session),
|
||||||
):
|
):
|
||||||
|
enforce_public_origin_or_403(request, endpoint="/api/public/requests/{track_number}/notifications/{notification_id}/read")
|
||||||
req = _request_for_track_or_404(db, session, track_number)
|
req = _request_for_track_or_404(db, session, track_number)
|
||||||
try:
|
try:
|
||||||
notification_uuid = UUID(str(notification_id))
|
notification_uuid = UUID(str(notification_id))
|
||||||
|
|
@ -799,9 +1003,11 @@ def read_notification_by_track(
|
||||||
@router.post("/{track_number}/notifications/read-all")
|
@router.post("/{track_number}/notifications/read-all")
|
||||||
def read_all_notifications_by_track(
|
def read_all_notifications_by_track(
|
||||||
track_number: str,
|
track_number: str,
|
||||||
|
request: FastapiRequest,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
session: dict = Depends(get_public_session),
|
session: dict = Depends(get_public_session),
|
||||||
):
|
):
|
||||||
|
enforce_public_origin_or_403(request, endpoint="/api/public/requests/{track_number}/notifications/read-all")
|
||||||
req = _request_for_track_or_404(db, session, track_number)
|
req = _request_for_track_or_404(db, session, track_number)
|
||||||
changed = mark_client_notifications_read(
|
changed = mark_client_notifications_read(
|
||||||
db,
|
db,
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ from app.services.attachment_scan import (
|
||||||
initial_scan_status_for_new_attachment,
|
initial_scan_status_for_new_attachment,
|
||||||
)
|
)
|
||||||
from app.services.s3_storage import build_object_key, get_s3_storage
|
from app.services.s3_storage import build_object_key, get_s3_storage
|
||||||
|
from app.services.origin_guard import enforce_public_origin_or_403
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
@ -105,6 +106,7 @@ def upload_init(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
session: dict = Depends(get_public_session),
|
session: dict = Depends(get_public_session),
|
||||||
):
|
):
|
||||||
|
enforce_public_origin_or_403(http_request, endpoint="/api/public/uploads/init")
|
||||||
actor_subject = str(session.get("sub") or "").strip()
|
actor_subject = str(session.get("sub") or "").strip()
|
||||||
actor_ip = _client_ip(http_request)
|
actor_ip = _client_ip(http_request)
|
||||||
scope_name = str(payload.scope.value if hasattr(payload.scope, "value") else payload.scope)
|
scope_name = str(payload.scope.value if hasattr(payload.scope, "value") else payload.scope)
|
||||||
|
|
@ -169,6 +171,7 @@ def upload_complete(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
session: dict = Depends(get_public_session),
|
session: dict = Depends(get_public_session),
|
||||||
):
|
):
|
||||||
|
enforce_public_origin_or_403(http_request, endpoint="/api/public/uploads/complete")
|
||||||
actor_subject = str(session.get("sub") or "").strip()
|
actor_subject = str(session.get("sub") or "").strip()
|
||||||
actor_ip = _client_ip(http_request)
|
actor_ip = _client_ip(http_request)
|
||||||
scope_name = str(payload.scope.value if hasattr(payload.scope, "value") else payload.scope)
|
scope_name = str(payload.scope.value if hasattr(payload.scope, "value") else payload.scope)
|
||||||
|
|
|
||||||
|
|
@ -4,16 +4,16 @@ from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
from app.api.admin.chat import router as admin_chat_router
|
from app.api.admin.chat import router as admin_chat_router
|
||||||
from app.api.public.chat import router as public_chat_router
|
from app.api.public.chat import router as public_chat_router
|
||||||
from app.core.config import settings
|
from app.core.config import settings, validate_production_security_or_raise
|
||||||
from app.core.http_hardening import install_http_hardening
|
from app.core.http_hardening import install_http_hardening
|
||||||
|
|
||||||
app = FastAPI(title=f"{settings.APP_NAME}-chat", version="0.1.0")
|
app = FastAPI(title=f"{settings.APP_NAME}-chat", version="0.1.0")
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=settings.cors_origins_list,
|
allow_origins=settings.cors_origins_list,
|
||||||
allow_credentials=True,
|
allow_credentials=settings.CORS_ALLOW_CREDENTIALS,
|
||||||
allow_methods=["*"],
|
allow_methods=settings.cors_allow_methods_list,
|
||||||
allow_headers=["*"],
|
allow_headers=settings.cors_allow_headers_list,
|
||||||
)
|
)
|
||||||
install_http_hardening(app)
|
install_http_hardening(app)
|
||||||
|
|
||||||
|
|
@ -21,6 +21,11 @@ app.include_router(public_chat_router, prefix="/api/public/chat")
|
||||||
app.include_router(admin_chat_router, prefix="/api/admin/chat")
|
app.include_router(admin_chat_router, prefix="/api/admin/chat")
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
def _validate_security_config_on_startup() -> None:
|
||||||
|
validate_production_security_or_raise("chat-service")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/", include_in_schema=False)
|
@app.get("/", include_in_schema=False)
|
||||||
def landing():
|
def landing():
|
||||||
return JSONResponse({"service": f"{settings.APP_NAME}-chat", "status": "ok"})
|
return JSONResponse({"service": f"{settings.APP_NAME}-chat", "status": "ok"})
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,20 @@ class Settings(BaseSettings):
|
||||||
TOTP_ISSUER: str = "Правовой Трекер"
|
TOTP_ISSUER: str = "Правовой Трекер"
|
||||||
PUBLIC_JWT_SECRET: str = "change_me_public"
|
PUBLIC_JWT_SECRET: str = "change_me_public"
|
||||||
PUBLIC_COOKIE_NAME: str = "public_jwt"
|
PUBLIC_COOKIE_NAME: str = "public_jwt"
|
||||||
|
PUBLIC_COOKIE_SECURE: bool = False
|
||||||
|
PUBLIC_COOKIE_SAMESITE: str = "lax"
|
||||||
|
PUBLIC_STRICT_ORIGIN_CHECK: bool = True
|
||||||
|
PUBLIC_ALLOWED_WEB_ORIGINS: str = (
|
||||||
|
"http://localhost:8080,http://localhost:8081,"
|
||||||
|
"https://ruakb.ru,https://www.ruakb.ru,"
|
||||||
|
"https://ruakb.online,https://www.ruakb.online"
|
||||||
|
)
|
||||||
|
PRODUCTION_ENFORCE_SECURE_SETTINGS: bool = True
|
||||||
|
|
||||||
CORS_ORIGINS: str = "http://localhost:3000,http://localhost:8081"
|
CORS_ORIGINS: str = "http://localhost:3000,http://localhost:8081"
|
||||||
|
CORS_ALLOW_METHODS: str = "GET,POST,PUT,PATCH,DELETE,OPTIONS"
|
||||||
|
CORS_ALLOW_HEADERS: str = "Authorization,Content-Type,X-Requested-With,X-Request-ID"
|
||||||
|
CORS_ALLOW_CREDENTIALS: bool = True
|
||||||
|
|
||||||
DATABASE_URL: str
|
DATABASE_URL: str
|
||||||
REDIS_URL: str
|
REDIS_URL: str
|
||||||
|
|
@ -30,6 +42,8 @@ class Settings(BaseSettings):
|
||||||
S3_BUCKET: str
|
S3_BUCKET: str
|
||||||
S3_REGION: str = "us-east-1"
|
S3_REGION: str = "us-east-1"
|
||||||
S3_USE_SSL: bool = False
|
S3_USE_SSL: bool = False
|
||||||
|
S3_VERIFY_SSL: bool = True
|
||||||
|
S3_CA_CERT_PATH: str = ""
|
||||||
MAX_FILE_MB: int = 25
|
MAX_FILE_MB: int = 25
|
||||||
MAX_CASE_MB: int = 250
|
MAX_CASE_MB: int = 250
|
||||||
ATTACHMENT_SCAN_ENABLED: bool = False
|
ATTACHMENT_SCAN_ENABLED: bool = False
|
||||||
|
|
@ -64,6 +78,10 @@ class Settings(BaseSettings):
|
||||||
OTP_EMAIL_TEMPLATE: str = "Ваш код подтверждения: {code}"
|
OTP_EMAIL_TEMPLATE: str = "Ваш код подтверждения: {code}"
|
||||||
OTP_EMAIL_FALLBACK_ENABLED: bool = True
|
OTP_EMAIL_FALLBACK_ENABLED: bool = True
|
||||||
OTP_SMS_MIN_BALANCE: float = 20.0
|
OTP_SMS_MIN_BALANCE: float = 20.0
|
||||||
|
DATA_ENCRYPTION_ACTIVE_KID: str = "legacy"
|
||||||
|
DATA_ENCRYPTION_KEYS: str = ""
|
||||||
|
CHAT_ENCRYPTION_ACTIVE_KID: str = ""
|
||||||
|
CHAT_ENCRYPTION_KEYS: str = ""
|
||||||
DATA_ENCRYPTION_SECRET: str = "change_me_data_encryption"
|
DATA_ENCRYPTION_SECRET: str = "change_me_data_encryption"
|
||||||
CHAT_ENCRYPTION_SECRET: str = ""
|
CHAT_ENCRYPTION_SECRET: str = ""
|
||||||
OTP_RATE_LIMIT_WINDOW_SECONDS: int = 300
|
OTP_RATE_LIMIT_WINDOW_SECONDS: int = 300
|
||||||
|
|
@ -79,9 +97,154 @@ class Settings(BaseSettings):
|
||||||
POSTGRES_USER: str = "postgres"
|
POSTGRES_USER: str = "postgres"
|
||||||
POSTGRES_PASSWORD: str = "postgres"
|
POSTGRES_PASSWORD: str = "postgres"
|
||||||
POSTGRES_DB: str = "legal"
|
POSTGRES_DB: str = "legal"
|
||||||
|
MINIO_ROOT_USER: str = "minio_local_admin"
|
||||||
|
MINIO_ROOT_PASSWORD: str = "minio_local_password_change_me"
|
||||||
|
MINIO_TLS_ENABLED: bool = False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def cors_origins_list(self) -> List[str]:
|
def cors_origins_list(self) -> List[str]:
|
||||||
return [o.strip() for o in self.CORS_ORIGINS.split(",") if o.strip()]
|
return [o.strip() for o in self.CORS_ORIGINS.split(",") if o.strip()]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cors_allow_methods_list(self) -> List[str]:
|
||||||
|
values = [v.strip().upper() for v in str(self.CORS_ALLOW_METHODS or "").split(",") if v.strip()]
|
||||||
|
return values or ["GET", "POST", "OPTIONS"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cors_allow_headers_list(self) -> List[str]:
|
||||||
|
values = [v.strip() for v in str(self.CORS_ALLOW_HEADERS or "").split(",") if v.strip()]
|
||||||
|
return values or ["Authorization", "Content-Type"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def public_allowed_web_origins_list(self) -> List[str]:
|
||||||
|
values: list[str] = []
|
||||||
|
for item in str(self.PUBLIC_ALLOWED_WEB_ORIGINS or "").split(","):
|
||||||
|
value = item.strip().rstrip("/").lower()
|
||||||
|
if value:
|
||||||
|
values.append(value)
|
||||||
|
return values
|
||||||
|
|
||||||
|
@property
|
||||||
|
def app_env_is_production(self) -> bool:
|
||||||
|
return str(self.APP_ENV or "").strip().lower() in {"prod", "production"}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def public_cookie_secure_effective(self) -> bool:
|
||||||
|
if self.app_env_is_production:
|
||||||
|
return True
|
||||||
|
return bool(self.PUBLIC_COOKIE_SECURE)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def public_cookie_samesite_effective(self) -> str:
|
||||||
|
raw = str(self.PUBLIC_COOKIE_SAMESITE or "lax").strip().lower()
|
||||||
|
if raw in {"lax", "strict", "none"}:
|
||||||
|
return raw
|
||||||
|
return "lax"
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|
||||||
|
|
||||||
|
def _looks_insecure_secret(value: str, *, min_len: int = 16) -> bool:
|
||||||
|
raw = str(value or "").strip()
|
||||||
|
lowered = raw.lower()
|
||||||
|
if len(raw) < min_len:
|
||||||
|
return True
|
||||||
|
markers = (
|
||||||
|
"change_me",
|
||||||
|
"example",
|
||||||
|
"admin123",
|
||||||
|
"password",
|
||||||
|
"test",
|
||||||
|
"local",
|
||||||
|
)
|
||||||
|
return any(marker in lowered for marker in markers)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_production_security_or_raise(component: str = "app") -> None:
|
||||||
|
if not settings.app_env_is_production:
|
||||||
|
return
|
||||||
|
if not bool(getattr(settings, "PRODUCTION_ENFORCE_SECURE_SETTINGS", True)):
|
||||||
|
return
|
||||||
|
|
||||||
|
issues: list[str] = []
|
||||||
|
|
||||||
|
if bool(settings.OTP_DEV_MODE):
|
||||||
|
issues.append("OTP_DEV_MODE=true запрещен в production")
|
||||||
|
if bool(settings.ADMIN_BOOTSTRAP_ENABLED):
|
||||||
|
issues.append("ADMIN_BOOTSTRAP_ENABLED=true запрещен в production")
|
||||||
|
|
||||||
|
if not settings.public_cookie_secure_effective:
|
||||||
|
issues.append("PUBLIC cookie должен быть secure в production")
|
||||||
|
|
||||||
|
if settings.public_cookie_samesite_effective == "none" and not settings.public_cookie_secure_effective:
|
||||||
|
issues.append("PUBLIC_COOKIE_SAMESITE=none требует secure cookie")
|
||||||
|
|
||||||
|
if _looks_insecure_secret(settings.ADMIN_JWT_SECRET):
|
||||||
|
issues.append("ADMIN_JWT_SECRET выглядит небезопасным")
|
||||||
|
if _looks_insecure_secret(settings.PUBLIC_JWT_SECRET):
|
||||||
|
issues.append("PUBLIC_JWT_SECRET выглядит небезопасным")
|
||||||
|
if _looks_insecure_secret(settings.DATA_ENCRYPTION_SECRET):
|
||||||
|
issues.append("DATA_ENCRYPTION_SECRET выглядит небезопасным")
|
||||||
|
if _looks_insecure_secret(settings.INTERNAL_SERVICE_TOKEN):
|
||||||
|
issues.append("INTERNAL_SERVICE_TOKEN выглядит небезопасным")
|
||||||
|
|
||||||
|
if not str(settings.CHAT_ENCRYPTION_SECRET or "").strip():
|
||||||
|
# Backward-compatible: keyring-based CHAT_ENCRYPTION_KEYS is allowed.
|
||||||
|
if not str(getattr(settings, "CHAT_ENCRYPTION_KEYS", "") or "").strip():
|
||||||
|
issues.append("CHAT_ENCRYPTION_SECRET или CHAT_ENCRYPTION_KEYS обязателен в production")
|
||||||
|
|
||||||
|
if not str(getattr(settings, "DATA_ENCRYPTION_ACTIVE_KID", "") or "").strip():
|
||||||
|
issues.append("DATA_ENCRYPTION_ACTIVE_KID должен быть задан в production")
|
||||||
|
|
||||||
|
minio_user = str(settings.MINIO_ROOT_USER or "").strip().lower()
|
||||||
|
minio_password = str(settings.MINIO_ROOT_PASSWORD or "").strip()
|
||||||
|
if minio_user in {"", "minioadmin", "minio_local_admin"}:
|
||||||
|
issues.append("MINIO_ROOT_USER должен быть переопределен для production")
|
||||||
|
if _looks_insecure_secret(minio_password):
|
||||||
|
issues.append("MINIO_ROOT_PASSWORD выглядит небезопасным")
|
||||||
|
|
||||||
|
if not bool(settings.S3_USE_SSL):
|
||||||
|
issues.append("S3_USE_SSL должен быть включен в production")
|
||||||
|
s3_endpoint = str(settings.S3_ENDPOINT or "").strip().lower()
|
||||||
|
if not s3_endpoint.startswith("https://"):
|
||||||
|
issues.append("S3_ENDPOINT должен начинаться с https:// в production")
|
||||||
|
if not bool(settings.S3_VERIFY_SSL):
|
||||||
|
issues.append("S3_VERIFY_SSL должен быть включен в production")
|
||||||
|
if not str(settings.S3_CA_CERT_PATH or "").strip():
|
||||||
|
issues.append("S3_CA_CERT_PATH должен быть задан для trusted TLS в production")
|
||||||
|
if not bool(settings.MINIO_TLS_ENABLED):
|
||||||
|
issues.append("MINIO_TLS_ENABLED должен быть включен в production")
|
||||||
|
|
||||||
|
if bool(getattr(settings, "PUBLIC_STRICT_ORIGIN_CHECK", True)):
|
||||||
|
allowed_public_origins = settings.public_allowed_web_origins_list
|
||||||
|
if not allowed_public_origins:
|
||||||
|
issues.append("PUBLIC_ALLOWED_WEB_ORIGINS должен быть задан в production")
|
||||||
|
for origin in allowed_public_origins:
|
||||||
|
if "localhost" in origin or "127.0.0.1" in origin:
|
||||||
|
issues.append("PUBLIC_ALLOWED_WEB_ORIGINS не должен содержать localhost в production")
|
||||||
|
break
|
||||||
|
|
||||||
|
cors_origins = [item.strip().lower().rstrip("/") for item in settings.cors_origins_list]
|
||||||
|
if not cors_origins:
|
||||||
|
issues.append("CORS_ORIGINS должен быть задан в production")
|
||||||
|
for origin in cors_origins:
|
||||||
|
if origin == "*" or "*" in origin:
|
||||||
|
issues.append("CORS_ORIGINS не должен содержать wildcard (*) в production")
|
||||||
|
break
|
||||||
|
if "localhost" in origin or "127.0.0.1" in origin:
|
||||||
|
issues.append("CORS_ORIGINS не должен содержать localhost в production")
|
||||||
|
break
|
||||||
|
if not origin.startswith("https://"):
|
||||||
|
issues.append("CORS_ORIGINS должен содержать только https origins в production")
|
||||||
|
break
|
||||||
|
|
||||||
|
cors_methods = [item.strip().upper() for item in settings.cors_allow_methods_list]
|
||||||
|
if "*" in cors_methods:
|
||||||
|
issues.append("CORS_ALLOW_METHODS не должен содержать wildcard (*) в production")
|
||||||
|
cors_headers_lower = [item.strip().lower() for item in settings.cors_allow_headers_list]
|
||||||
|
if "*" in cors_headers_lower:
|
||||||
|
issues.append("CORS_ALLOW_HEADERS не должен содержать wildcard (*) в production")
|
||||||
|
|
||||||
|
if issues:
|
||||||
|
formatted = "\n".join(f"- {item}" for item in issues)
|
||||||
|
raise RuntimeError(f"[{component}] insecure production configuration:\n{formatted}")
|
||||||
|
|
|
||||||
|
|
@ -20,13 +20,35 @@ SECURITY_HEADERS = {
|
||||||
"Cross-Origin-Embedder-Policy": "credentialless",
|
"Cross-Origin-Embedder-Policy": "credentialless",
|
||||||
"Cross-Origin-Resource-Policy": "same-origin",
|
"Cross-Origin-Resource-Policy": "same-origin",
|
||||||
"Permissions-Policy": "geolocation=(), microphone=(), camera=(), payment=(), usb=()",
|
"Permissions-Policy": "geolocation=(), microphone=(), camera=(), payment=(), usb=()",
|
||||||
"Content-Security-Policy": "default-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'",
|
"Content-Security-Policy": (
|
||||||
|
"default-src 'self'; "
|
||||||
|
"script-src 'self'; "
|
||||||
|
"style-src 'self'; "
|
||||||
|
"connect-src 'self'; "
|
||||||
|
"img-src 'self' data: blob:; "
|
||||||
|
"font-src 'self' data:; "
|
||||||
|
"object-src 'none'; "
|
||||||
|
"frame-ancestors 'none'; "
|
||||||
|
"base-uri 'self'; "
|
||||||
|
"form-action 'self'"
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
FRAMEABLE_FILE_SECURITY_HEADERS = {
|
FRAMEABLE_FILE_SECURITY_HEADERS = {
|
||||||
**SECURITY_HEADERS,
|
**SECURITY_HEADERS,
|
||||||
"X-Frame-Options": "SAMEORIGIN",
|
"X-Frame-Options": "SAMEORIGIN",
|
||||||
"Content-Security-Policy": "default-src 'self'; object-src 'none'; frame-ancestors 'self'; base-uri 'self'; form-action 'self'",
|
"Content-Security-Policy": (
|
||||||
|
"default-src 'self'; "
|
||||||
|
"script-src 'self'; "
|
||||||
|
"style-src 'self'; "
|
||||||
|
"connect-src 'self'; "
|
||||||
|
"img-src 'self' data: blob:; "
|
||||||
|
"font-src 'self' data:; "
|
||||||
|
"object-src 'none'; "
|
||||||
|
"frame-ancestors 'self'; "
|
||||||
|
"base-uri 'self'; "
|
||||||
|
"form-action 'self'"
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
_FRAMEABLE_PATH_PATTERNS = (
|
_FRAMEABLE_PATH_PATTERNS = (
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ from __future__ import annotations
|
||||||
from fastapi import FastAPI, Header, HTTPException
|
from fastapi import FastAPI, Header, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings, validate_production_security_or_raise
|
||||||
from app.services.email_service import EmailDeliveryError, send_email_via_smtp
|
from app.services.email_service import EmailDeliveryError, send_email_via_smtp
|
||||||
|
|
||||||
app = FastAPI(title="law-email-service")
|
app = FastAPI(title="law-email-service")
|
||||||
|
|
@ -15,6 +15,11 @@ class InternalEmailSend(BaseModel):
|
||||||
body: str
|
body: str
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
def _validate_security_config_on_startup() -> None:
|
||||||
|
validate_production_security_or_raise("email-service")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
def health():
|
def health():
|
||||||
return {"status": "ok", "service": "email-service"}
|
return {"status": "ok", "service": "email-service"}
|
||||||
|
|
|
||||||
13
app/main.py
13
app/main.py
|
|
@ -1,7 +1,7 @@
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from app.core.config import settings
|
from app.core.config import settings, validate_production_security_or_raise
|
||||||
from app.core.http_hardening import install_http_hardening
|
from app.core.http_hardening import install_http_hardening
|
||||||
from app.api.public.router import router as public_router
|
from app.api.public.router import router as public_router
|
||||||
from app.api.admin.router import router as admin_router
|
from app.api.admin.router import router as admin_router
|
||||||
|
|
@ -10,15 +10,20 @@ app = FastAPI(title=settings.APP_NAME, version="0.1.0")
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=settings.cors_origins_list,
|
allow_origins=settings.cors_origins_list,
|
||||||
allow_credentials=True,
|
allow_credentials=settings.CORS_ALLOW_CREDENTIALS,
|
||||||
allow_methods=["*"],
|
allow_methods=settings.cors_allow_methods_list,
|
||||||
allow_headers=["*"],
|
allow_headers=settings.cors_allow_headers_list,
|
||||||
)
|
)
|
||||||
install_http_hardening(app)
|
install_http_hardening(app)
|
||||||
|
|
||||||
app.include_router(public_router, prefix="/api/public")
|
app.include_router(public_router, prefix="/api/public")
|
||||||
app.include_router(admin_router, prefix="/api/admin")
|
app.include_router(admin_router, prefix="/api/admin")
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
def _validate_security_config_on_startup() -> None:
|
||||||
|
validate_production_security_or_raise("backend")
|
||||||
|
|
||||||
@app.get("/", include_in_schema=False)
|
@app.get("/", include_in_schema=False)
|
||||||
def landing():
|
def landing():
|
||||||
return JSONResponse({"service": settings.APP_NAME, "status": "ok"})
|
return JSONResponse({"service": settings.APP_NAME, "status": "ok"})
|
||||||
|
|
|
||||||
15
app/models/data_retention_policy.py
Normal file
15
app/models/data_retention_policy.py
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
from sqlalchemy import Boolean, Integer, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db.session import Base
|
||||||
|
from app.models.common import TimestampMixin, UUIDMixin
|
||||||
|
|
||||||
|
|
||||||
|
class DataRetentionPolicy(Base, UUIDMixin, TimestampMixin):
|
||||||
|
__tablename__ = "data_retention_policies"
|
||||||
|
|
||||||
|
entity: Mapped[str] = mapped_column(String(80), unique=True, nullable=False, index=True)
|
||||||
|
retention_days: Mapped[int] = mapped_column(Integer, nullable=False, default=365)
|
||||||
|
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||||
|
hard_delete: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||||
|
description: Mapped[str | None] = mapped_column(String(300), nullable=True)
|
||||||
|
|
@ -14,6 +14,9 @@ class Request(Base, UUIDMixin, TimestampMixin):
|
||||||
client_name: Mapped[str] = mapped_column(String(200), nullable=False)
|
client_name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||||
client_phone: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
|
client_phone: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
|
||||||
client_email: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
client_email: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||||
|
pdn_consent: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
pdn_consent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
pdn_consent_ip: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
topic_code: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True)
|
topic_code: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True)
|
||||||
status_code: Mapped[str] = mapped_column(String(50), nullable=False, index=True, default="NEW")
|
status_code: Mapped[str] = mapped_column(String(50), nullable=False, index=True, default="NEW")
|
||||||
important_date_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
important_date_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ class PublicRequestCreate(BaseModel):
|
||||||
client_name: str
|
client_name: str
|
||||||
client_phone: str
|
client_phone: str
|
||||||
client_email: Optional[str] = None
|
client_email: Optional[str] = None
|
||||||
|
pdn_consent: bool = False
|
||||||
|
hp_field: Optional[str] = None
|
||||||
topic_code: Optional[str] = None
|
topic_code: Optional[str] = None
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
extra_fields: Dict[str, Any] = Field(default_factory=dict)
|
extra_fields: Dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
@ -21,6 +23,7 @@ class OtpSend(BaseModel):
|
||||||
track_number: Optional[str] = None
|
track_number: Optional[str] = None
|
||||||
client_phone: Optional[str] = None
|
client_phone: Optional[str] = None
|
||||||
client_email: Optional[str] = None
|
client_email: Optional[str] = None
|
||||||
|
hp_field: Optional[str] = None
|
||||||
channel: Optional[str] = None
|
channel: Optional[str] = None
|
||||||
|
|
||||||
class OtpVerify(BaseModel):
|
class OtpVerify(BaseModel):
|
||||||
|
|
|
||||||
113
app/scripts/reencrypt_with_active_kid.py
Normal file
113
app/scripts/reencrypt_with_active_kid.py
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from app.db.session import SessionLocal
|
||||||
|
from app.models.admin_user import AdminUser
|
||||||
|
from app.models.invoice import Invoice
|
||||||
|
from app.services.chat_crypto import active_chat_kid, decrypt_message_body, encrypt_message_body, extract_message_kid, is_encrypted_message
|
||||||
|
from app.services.invoice_crypto import active_requisites_kid, decrypt_requisites, encrypt_requisites, extract_requisites_kid
|
||||||
|
|
||||||
|
|
||||||
|
def _now_utc() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def reencrypt_with_active_kid(*, dry_run: bool = True) -> dict[str, int]:
|
||||||
|
db = SessionLocal()
|
||||||
|
counts = {
|
||||||
|
"invoices_total": 0,
|
||||||
|
"invoices_reencrypted": 0,
|
||||||
|
"admin_totp_total": 0,
|
||||||
|
"admin_totp_reencrypted": 0,
|
||||||
|
"messages_total": 0,
|
||||||
|
"messages_reencrypted": 0,
|
||||||
|
"errors": 0,
|
||||||
|
}
|
||||||
|
current_data_kid = active_requisites_kid()
|
||||||
|
current_chat_kid = active_chat_kid()
|
||||||
|
|
||||||
|
try:
|
||||||
|
invoice_rows = db.query(Invoice).all()
|
||||||
|
counts["invoices_total"] = len(invoice_rows)
|
||||||
|
for row in invoice_rows:
|
||||||
|
token = str(row.payer_details_encrypted or "").strip()
|
||||||
|
if not token:
|
||||||
|
continue
|
||||||
|
if extract_requisites_kid(token) == current_data_kid:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
payload = decrypt_requisites(token)
|
||||||
|
row.payer_details_encrypted = encrypt_requisites(payload)
|
||||||
|
row.responsible = row.responsible or "Администратор системы"
|
||||||
|
counts["invoices_reencrypted"] += 1
|
||||||
|
except Exception:
|
||||||
|
counts["errors"] += 1
|
||||||
|
|
||||||
|
admin_rows = db.query(AdminUser).all()
|
||||||
|
counts["admin_totp_total"] = len(admin_rows)
|
||||||
|
for row in admin_rows:
|
||||||
|
token = str(row.totp_secret_encrypted or "").strip()
|
||||||
|
if not token:
|
||||||
|
continue
|
||||||
|
if extract_requisites_kid(token) == current_data_kid:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
payload = decrypt_requisites(token)
|
||||||
|
row.totp_secret_encrypted = encrypt_requisites(payload)
|
||||||
|
row.responsible = row.responsible or "Администратор системы"
|
||||||
|
counts["admin_totp_reencrypted"] += 1
|
||||||
|
except Exception:
|
||||||
|
counts["errors"] += 1
|
||||||
|
|
||||||
|
message_rows = db.execute(text("SELECT id, body FROM messages")).all()
|
||||||
|
counts["messages_total"] = len(message_rows)
|
||||||
|
for message_id, body in message_rows:
|
||||||
|
raw_body = str(body or "")
|
||||||
|
if not raw_body:
|
||||||
|
continue
|
||||||
|
if extract_message_kid(raw_body) == current_chat_kid:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
plaintext = decrypt_message_body(raw_body)
|
||||||
|
updated = encrypt_message_body(plaintext)
|
||||||
|
if updated == raw_body:
|
||||||
|
continue
|
||||||
|
db.execute(
|
||||||
|
text("UPDATE messages SET body = :body, updated_at = :updated_at WHERE id = :id"),
|
||||||
|
{"id": message_id, "body": updated, "updated_at": _now_utc()},
|
||||||
|
)
|
||||||
|
counts["messages_reencrypted"] += 1
|
||||||
|
except Exception:
|
||||||
|
counts["errors"] += 1
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
db.rollback()
|
||||||
|
else:
|
||||||
|
db.commit()
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
return counts
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description="Re-encrypt sensitive fields using active KID keys")
|
||||||
|
parser.add_argument("--apply", action="store_true", help="Apply changes (default is dry-run)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
result = reencrypt_with_active_kid(dry_run=not args.apply)
|
||||||
|
mode = "APPLY" if args.apply else "DRY_RUN"
|
||||||
|
print(f"mode={mode}")
|
||||||
|
for key in sorted(result.keys()):
|
||||||
|
print(f"{key}={result[key]}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -5,39 +5,42 @@ import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
import secrets
|
import secrets
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.services.crypto_keyring import get_chat_secrets, key_digest, ordered_unique_key_digests
|
||||||
|
|
||||||
_VERSION = b"v1"
|
_VERSION_LEGACY = b"v1"
|
||||||
_PREFIX = "chatenc:v1:"
|
_PREFIX_LEGACY = "chatenc:v1:"
|
||||||
|
_PREFIX_V2 = "chatenc:v2:"
|
||||||
|
|
||||||
def _encryption_secret() -> str:
|
|
||||||
chat_secret = str(settings.CHAT_ENCRYPTION_SECRET or "").strip()
|
|
||||||
if chat_secret:
|
|
||||||
return chat_secret
|
|
||||||
fallback = str(settings.DATA_ENCRYPTION_SECRET or "").strip()
|
|
||||||
if fallback:
|
|
||||||
return fallback
|
|
||||||
fallback = str(settings.ADMIN_JWT_SECRET or "").strip()
|
|
||||||
if fallback:
|
|
||||||
return fallback
|
|
||||||
fallback = str(settings.PUBLIC_JWT_SECRET or "").strip()
|
|
||||||
if fallback:
|
|
||||||
return fallback
|
|
||||||
raise ValueError("Не задан секрет шифрования чата")
|
|
||||||
|
|
||||||
|
|
||||||
def _key() -> bytes:
|
|
||||||
return hashlib.sha256(_encryption_secret().encode("utf-8")).digest()
|
|
||||||
|
|
||||||
|
|
||||||
def _xor_bytes(a: bytes, b: bytes) -> bytes:
|
def _xor_bytes(a: bytes, b: bytes) -> bytes:
|
||||||
return bytes(x ^ y for x, y in zip(a, b))
|
return bytes(x ^ y for x, y in zip(a, b))
|
||||||
|
|
||||||
|
|
||||||
|
def _aad_v2(kid: str) -> bytes:
|
||||||
|
return b"v2|" + str(kid).encode("utf-8") + b"|"
|
||||||
|
|
||||||
|
|
||||||
|
def active_chat_kid() -> str:
|
||||||
|
active_kid, _ = get_chat_secrets()
|
||||||
|
return active_kid
|
||||||
|
|
||||||
|
|
||||||
|
def extract_message_kid(value: str | None) -> str | None:
|
||||||
|
token = str(value or "").strip()
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
if token.startswith(_PREFIX_V2):
|
||||||
|
parts = token.split(":", 3)
|
||||||
|
if len(parts) != 4:
|
||||||
|
return None
|
||||||
|
kid = str(parts[2] or "").strip()
|
||||||
|
return kid or None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def is_encrypted_message(value: str | None) -> bool:
|
def is_encrypted_message(value: str | None) -> bool:
|
||||||
token = str(value or "").strip()
|
token = str(value or "").strip()
|
||||||
return token.startswith(_PREFIX)
|
return token.startswith(_PREFIX_LEGACY) or token.startswith(_PREFIX_V2)
|
||||||
|
|
||||||
|
|
||||||
def encrypt_message_body(value: str | None) -> str | None:
|
def encrypt_message_body(value: str | None) -> str | None:
|
||||||
|
|
@ -48,13 +51,57 @@ def encrypt_message_body(value: str | None) -> str | None:
|
||||||
return text
|
return text
|
||||||
if is_encrypted_message(text):
|
if is_encrypted_message(text):
|
||||||
return text
|
return text
|
||||||
|
|
||||||
|
active_kid, key_map = get_chat_secrets()
|
||||||
|
active_secret = key_map.get(active_kid)
|
||||||
|
if not active_secret:
|
||||||
|
raise ValueError("Не найден активный ключ шифрования чата")
|
||||||
|
key = key_digest(active_secret)
|
||||||
|
|
||||||
raw = text.encode("utf-8")
|
raw = text.encode("utf-8")
|
||||||
nonce = secrets.token_bytes(16)
|
nonce = secrets.token_bytes(16)
|
||||||
stream = hashlib.pbkdf2_hmac("sha256", _key(), nonce, 120_000, dklen=len(raw))
|
stream = hashlib.pbkdf2_hmac("sha256", key, nonce, 120_000, dklen=len(raw))
|
||||||
cipher = _xor_bytes(raw, stream)
|
cipher = _xor_bytes(raw, stream)
|
||||||
tag = hmac.new(_key(), _VERSION + nonce + cipher, hashlib.sha256).digest()
|
tag = hmac.new(key, _aad_v2(active_kid) + nonce + cipher, hashlib.sha256).digest()
|
||||||
token = _VERSION + nonce + tag + cipher
|
blob = nonce + tag + cipher
|
||||||
return _PREFIX + base64.urlsafe_b64encode(token).decode("ascii")
|
return f"{_PREFIX_V2}{active_kid}:" + base64.urlsafe_b64encode(blob).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def _decrypt_v2(encoded: str, *, kid: str, key: bytes) -> str:
|
||||||
|
blob = base64.urlsafe_b64decode(encoded.encode("ascii"))
|
||||||
|
if len(blob) < 16 + 32:
|
||||||
|
raise ValueError("Некорректный зашифрованный формат сообщения")
|
||||||
|
nonce = blob[:16]
|
||||||
|
tag = blob[16:48]
|
||||||
|
cipher = blob[48:]
|
||||||
|
expected = hmac.new(key, _aad_v2(kid) + nonce + cipher, hashlib.sha256).digest()
|
||||||
|
if not hmac.compare_digest(tag, expected):
|
||||||
|
raise ValueError("Поврежденные данные сообщения")
|
||||||
|
stream = hashlib.pbkdf2_hmac("sha256", key, nonce, 120_000, dklen=len(cipher))
|
||||||
|
raw = _xor_bytes(cipher, stream)
|
||||||
|
return raw.decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _decrypt_legacy(encoded: str, keys: list[bytes]) -> str:
|
||||||
|
blob = base64.urlsafe_b64decode(encoded.encode("ascii"))
|
||||||
|
if len(blob) < 2 + 16 + 32:
|
||||||
|
raise ValueError("Некорректный зашифрованный формат сообщения")
|
||||||
|
version = blob[:2]
|
||||||
|
nonce = blob[2:18]
|
||||||
|
tag = blob[18:50]
|
||||||
|
cipher = blob[50:]
|
||||||
|
if version != _VERSION_LEGACY:
|
||||||
|
raise ValueError("Неподдерживаемая версия шифрования чата")
|
||||||
|
|
||||||
|
for key in keys:
|
||||||
|
expected = hmac.new(key, version + nonce + cipher, hashlib.sha256).digest()
|
||||||
|
if not hmac.compare_digest(tag, expected):
|
||||||
|
continue
|
||||||
|
stream = hashlib.pbkdf2_hmac("sha256", key, nonce, 120_000, dklen=len(cipher))
|
||||||
|
raw = _xor_bytes(cipher, stream)
|
||||||
|
return raw.decode("utf-8")
|
||||||
|
|
||||||
|
raise ValueError("Поврежденные данные сообщения")
|
||||||
|
|
||||||
|
|
||||||
def decrypt_message_body(value: str | None) -> str | None:
|
def decrypt_message_body(value: str | None) -> str | None:
|
||||||
|
|
@ -65,19 +112,23 @@ def decrypt_message_body(value: str | None) -> str | None:
|
||||||
return text
|
return text
|
||||||
if not is_encrypted_message(text):
|
if not is_encrypted_message(text):
|
||||||
return text
|
return text
|
||||||
encoded = text[len(_PREFIX) :]
|
|
||||||
blob = base64.urlsafe_b64decode(encoded.encode("ascii"))
|
active_kid, key_map = get_chat_secrets()
|
||||||
if len(blob) < 2 + 16 + 32:
|
_ = active_kid
|
||||||
|
if text.startswith(_PREFIX_V2):
|
||||||
|
encoded = text[len(_PREFIX_V2) :]
|
||||||
|
parts = encoded.split(":", 1)
|
||||||
|
if len(parts) != 2:
|
||||||
raise ValueError("Некорректный зашифрованный формат сообщения")
|
raise ValueError("Некорректный зашифрованный формат сообщения")
|
||||||
version = blob[:2]
|
kid, payload = str(parts[0] or "").strip(), parts[1]
|
||||||
nonce = blob[2:18]
|
if kid in key_map:
|
||||||
tag = blob[18:50]
|
return _decrypt_v2(payload, kid=kid, key=key_digest(key_map[kid]))
|
||||||
cipher = blob[50:]
|
for fallback_key in ordered_unique_key_digests(key_map.values()):
|
||||||
if version != _VERSION:
|
try:
|
||||||
raise ValueError("Неподдерживаемая версия шифрования чата")
|
return _decrypt_v2(payload, kid=kid, key=fallback_key)
|
||||||
expected = hmac.new(_key(), version + nonce + cipher, hashlib.sha256).digest()
|
except Exception:
|
||||||
if not hmac.compare_digest(tag, expected):
|
continue
|
||||||
raise ValueError("Поврежденные данные сообщения")
|
raise ValueError("Неподдерживаемый идентификатор ключа шифрования")
|
||||||
stream = hashlib.pbkdf2_hmac("sha256", _key(), nonce, 120_000, dklen=len(cipher))
|
|
||||||
raw = _xor_bytes(cipher, stream)
|
encoded = text[len(_PREFIX_LEGACY) :]
|
||||||
return raw.decode("utf-8")
|
return _decrypt_legacy(encoded, ordered_unique_key_digests(key_map.values()))
|
||||||
|
|
|
||||||
117
app/services/crypto_keyring.py
Normal file
117
app/services/crypto_keyring.py
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_kid(raw: str | None) -> str:
|
||||||
|
value = str(raw or "").strip().lower()
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
|
out = []
|
||||||
|
for ch in value:
|
||||||
|
if ch.isalnum() or ch in {"_", "-", "."}:
|
||||||
|
out.append(ch)
|
||||||
|
return "".join(out)[:64]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_kid_secret_map(raw: str | None) -> dict[str, str]:
|
||||||
|
values: dict[str, str] = {}
|
||||||
|
for chunk in str(raw or "").split(","):
|
||||||
|
token = str(chunk or "").strip()
|
||||||
|
if not token:
|
||||||
|
continue
|
||||||
|
if "=" not in token:
|
||||||
|
continue
|
||||||
|
kid_raw, secret_raw = token.split("=", 1)
|
||||||
|
kid = _normalize_kid(kid_raw)
|
||||||
|
secret = str(secret_raw or "").strip()
|
||||||
|
if not kid or not secret:
|
||||||
|
continue
|
||||||
|
values[kid] = secret
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def _primary_data_secret() -> str:
|
||||||
|
for candidate in (
|
||||||
|
str(settings.DATA_ENCRYPTION_SECRET or "").strip(),
|
||||||
|
str(settings.ADMIN_JWT_SECRET or "").strip(),
|
||||||
|
str(settings.PUBLIC_JWT_SECRET or "").strip(),
|
||||||
|
):
|
||||||
|
if candidate:
|
||||||
|
return candidate
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def get_data_secrets() -> tuple[str, dict[str, str]]:
|
||||||
|
key_map = _parse_kid_secret_map(getattr(settings, "DATA_ENCRYPTION_KEYS", ""))
|
||||||
|
legacy = _primary_data_secret()
|
||||||
|
if legacy:
|
||||||
|
key_map.setdefault("legacy", legacy)
|
||||||
|
|
||||||
|
active_raw = _normalize_kid(getattr(settings, "DATA_ENCRYPTION_ACTIVE_KID", ""))
|
||||||
|
active_kid = active_raw or ("legacy" if "legacy" in key_map else "")
|
||||||
|
|
||||||
|
if not key_map and legacy:
|
||||||
|
active_kid = active_kid or "legacy"
|
||||||
|
key_map[active_kid] = legacy
|
||||||
|
|
||||||
|
if active_kid and active_kid not in key_map and legacy:
|
||||||
|
key_map[active_kid] = legacy
|
||||||
|
|
||||||
|
if not key_map:
|
||||||
|
raise ValueError("Не заданы ключи шифрования DATA")
|
||||||
|
|
||||||
|
if not active_kid:
|
||||||
|
active_kid = next(iter(key_map.keys()))
|
||||||
|
|
||||||
|
return active_kid, key_map
|
||||||
|
|
||||||
|
|
||||||
|
def get_chat_secrets() -> tuple[str, dict[str, str]]:
|
||||||
|
key_map = _parse_kid_secret_map(getattr(settings, "CHAT_ENCRYPTION_KEYS", ""))
|
||||||
|
chat_legacy = str(settings.CHAT_ENCRYPTION_SECRET or "").strip()
|
||||||
|
if chat_legacy:
|
||||||
|
key_map.setdefault("legacy", chat_legacy)
|
||||||
|
|
||||||
|
data_active, data_map = get_data_secrets()
|
||||||
|
for kid, secret in data_map.items():
|
||||||
|
key_map.setdefault(kid, secret)
|
||||||
|
|
||||||
|
active_raw = _normalize_kid(getattr(settings, "CHAT_ENCRYPTION_ACTIVE_KID", ""))
|
||||||
|
active_kid = active_raw or data_active
|
||||||
|
|
||||||
|
if active_kid and active_kid not in key_map and chat_legacy:
|
||||||
|
key_map[active_kid] = chat_legacy
|
||||||
|
|
||||||
|
if active_kid and active_kid not in key_map and active_kid in data_map:
|
||||||
|
key_map[active_kid] = data_map[active_kid]
|
||||||
|
|
||||||
|
if not key_map:
|
||||||
|
raise ValueError("Не заданы ключи шифрования CHAT")
|
||||||
|
|
||||||
|
if not active_kid:
|
||||||
|
active_kid = next(iter(key_map.keys()))
|
||||||
|
|
||||||
|
if active_kid not in key_map:
|
||||||
|
key_map[active_kid] = next(iter(key_map.values()))
|
||||||
|
|
||||||
|
return active_kid, key_map
|
||||||
|
|
||||||
|
|
||||||
|
def key_digest(secret: str) -> bytes:
|
||||||
|
return hashlib.sha256(str(secret or "").encode("utf-8")).digest()
|
||||||
|
|
||||||
|
|
||||||
|
def ordered_unique_key_digests(secrets: Iterable[str]) -> list[bytes]:
|
||||||
|
digests: list[bytes] = []
|
||||||
|
seen: set[bytes] = set()
|
||||||
|
for secret in secrets:
|
||||||
|
digest = key_digest(secret)
|
||||||
|
if digest in seen:
|
||||||
|
continue
|
||||||
|
seen.add(digest)
|
||||||
|
digests.append(digest)
|
||||||
|
return digests
|
||||||
|
|
@ -7,54 +7,120 @@ import json
|
||||||
import secrets
|
import secrets
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.services.crypto_keyring import get_data_secrets, key_digest, ordered_unique_key_digests
|
||||||
|
|
||||||
_VERSION = b"v1"
|
_VERSION_LEGACY = b"v1"
|
||||||
|
_PREFIX_V2 = "invenc:v2:"
|
||||||
|
|
||||||
def _key() -> bytes:
|
|
||||||
secret = str(settings.DATA_ENCRYPTION_SECRET or "").strip()
|
|
||||||
if not secret:
|
|
||||||
secret = str(settings.ADMIN_JWT_SECRET or "").strip()
|
|
||||||
if not secret:
|
|
||||||
secret = str(settings.PUBLIC_JWT_SECRET or "").strip()
|
|
||||||
if not secret:
|
|
||||||
raise ValueError("Не задан секрет шифрования")
|
|
||||||
return hashlib.sha256(secret.encode("utf-8")).digest()
|
|
||||||
|
|
||||||
|
|
||||||
def _xor_bytes(a: bytes, b: bytes) -> bytes:
|
def _xor_bytes(a: bytes, b: bytes) -> bytes:
|
||||||
return bytes(x ^ y for x, y in zip(a, b))
|
return bytes(x ^ y for x, y in zip(a, b))
|
||||||
|
|
||||||
|
|
||||||
def encrypt_requisites(data: dict[str, Any] | None) -> str:
|
def _aad_v2(kid: str) -> bytes:
|
||||||
payload = dict(data or {})
|
return b"v2|" + str(kid).encode("utf-8") + b"|"
|
||||||
raw = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
|
||||||
nonce = secrets.token_bytes(16)
|
|
||||||
stream = hashlib.pbkdf2_hmac("sha256", _key(), nonce, 120_000, dklen=len(raw))
|
|
||||||
cipher = _xor_bytes(raw, stream)
|
|
||||||
tag = hmac.new(_key(), _VERSION + nonce + cipher, hashlib.sha256).digest()
|
|
||||||
token = _VERSION + nonce + tag + cipher
|
|
||||||
return base64.urlsafe_b64encode(token).decode("ascii")
|
|
||||||
|
|
||||||
|
|
||||||
def decrypt_requisites(token: str | None) -> dict[str, Any]:
|
def active_requisites_kid() -> str:
|
||||||
|
active_kid, _ = get_data_secrets()
|
||||||
|
return active_kid
|
||||||
|
|
||||||
|
|
||||||
|
def extract_requisites_kid(token: str | None) -> str | None:
|
||||||
encoded = str(token or "").strip()
|
encoded = str(token or "").strip()
|
||||||
if not encoded:
|
if not encoded:
|
||||||
return {}
|
return None
|
||||||
|
if not encoded.startswith(_PREFIX_V2):
|
||||||
|
return None
|
||||||
|
parts = encoded.split(":", 3)
|
||||||
|
if len(parts) != 4:
|
||||||
|
return None
|
||||||
|
kid = str(parts[2] or "").strip()
|
||||||
|
return kid or None
|
||||||
|
|
||||||
|
|
||||||
|
def _encrypt_payload(raw: bytes, *, kid: str, key: bytes) -> str:
|
||||||
|
nonce = secrets.token_bytes(16)
|
||||||
|
stream = hashlib.pbkdf2_hmac("sha256", key, nonce, 120_000, dklen=len(raw))
|
||||||
|
cipher = _xor_bytes(raw, stream)
|
||||||
|
tag = hmac.new(key, _aad_v2(kid) + nonce + cipher, hashlib.sha256).digest()
|
||||||
|
token = nonce + tag + cipher
|
||||||
|
encoded = base64.urlsafe_b64encode(token).decode("ascii")
|
||||||
|
return f"{_PREFIX_V2}{kid}:{encoded}"
|
||||||
|
|
||||||
|
|
||||||
|
def _decrypt_v2(encoded: str, *, kid: str, key: bytes) -> dict[str, Any]:
|
||||||
blob = base64.urlsafe_b64decode(encoded.encode("ascii"))
|
blob = base64.urlsafe_b64decode(encoded.encode("ascii"))
|
||||||
|
if len(blob) < 16 + 32:
|
||||||
|
raise ValueError("Некорректные зашифрованные реквизиты")
|
||||||
|
nonce = blob[:16]
|
||||||
|
tag = blob[16:48]
|
||||||
|
cipher = blob[48:]
|
||||||
|
expected = hmac.new(key, _aad_v2(kid) + nonce + cipher, hashlib.sha256).digest()
|
||||||
|
if not hmac.compare_digest(tag, expected):
|
||||||
|
raise ValueError("Поврежденные зашифрованные реквизиты")
|
||||||
|
stream = hashlib.pbkdf2_hmac("sha256", key, nonce, 120_000, dklen=len(cipher))
|
||||||
|
raw = _xor_bytes(cipher, stream)
|
||||||
|
data = json.loads(raw.decode("utf-8"))
|
||||||
|
return data if isinstance(data, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _decrypt_legacy(token: str, keys: list[bytes]) -> dict[str, Any]:
|
||||||
|
blob = base64.urlsafe_b64decode(token.encode("ascii"))
|
||||||
if len(blob) < 2 + 16 + 32:
|
if len(blob) < 2 + 16 + 32:
|
||||||
raise ValueError("Некорректные зашифрованные реквизиты")
|
raise ValueError("Некорректные зашифрованные реквизиты")
|
||||||
version = blob[:2]
|
version = blob[:2]
|
||||||
nonce = blob[2:18]
|
nonce = blob[2:18]
|
||||||
tag = blob[18:50]
|
tag = blob[18:50]
|
||||||
cipher = blob[50:]
|
cipher = blob[50:]
|
||||||
if version != _VERSION:
|
if version != _VERSION_LEGACY:
|
||||||
raise ValueError("Неподдерживаемая версия шифрования")
|
raise ValueError("Неподдерживаемая версия шифрования")
|
||||||
expected = hmac.new(_key(), version + nonce + cipher, hashlib.sha256).digest()
|
|
||||||
|
for key in keys:
|
||||||
|
expected = hmac.new(key, version + nonce + cipher, hashlib.sha256).digest()
|
||||||
if not hmac.compare_digest(tag, expected):
|
if not hmac.compare_digest(tag, expected):
|
||||||
raise ValueError("Поврежденные зашифрованные реквизиты")
|
continue
|
||||||
stream = hashlib.pbkdf2_hmac("sha256", _key(), nonce, 120_000, dklen=len(cipher))
|
stream = hashlib.pbkdf2_hmac("sha256", key, nonce, 120_000, dklen=len(cipher))
|
||||||
raw = _xor_bytes(cipher, stream)
|
raw = _xor_bytes(cipher, stream)
|
||||||
data = json.loads(raw.decode("utf-8"))
|
data = json.loads(raw.decode("utf-8"))
|
||||||
return data if isinstance(data, dict) else {}
|
return data if isinstance(data, dict) else {}
|
||||||
|
|
||||||
|
raise ValueError("Поврежденные зашифрованные реквизиты")
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_requisites(data: dict[str, Any] | None) -> str:
|
||||||
|
payload = dict(data or {})
|
||||||
|
raw = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||||||
|
|
||||||
|
active_kid, key_map = get_data_secrets()
|
||||||
|
active_secret = key_map.get(active_kid)
|
||||||
|
if not active_secret:
|
||||||
|
raise ValueError("Не найден активный ключ шифрования DATA")
|
||||||
|
return _encrypt_payload(raw, kid=active_kid, key=key_digest(active_secret))
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_requisites(token: str | None) -> dict[str, Any]:
|
||||||
|
encoded = str(token or "").strip()
|
||||||
|
if not encoded:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
active_kid, key_map = get_data_secrets()
|
||||||
|
_ = active_kid
|
||||||
|
if encoded.startswith(_PREFIX_V2):
|
||||||
|
parts = encoded.split(":", 3)
|
||||||
|
if len(parts) != 4:
|
||||||
|
raise ValueError("Некорректные зашифрованные реквизиты")
|
||||||
|
kid = str(parts[2] or "").strip()
|
||||||
|
payload = parts[3]
|
||||||
|
|
||||||
|
if kid in key_map:
|
||||||
|
return _decrypt_v2(payload, kid=kid, key=key_digest(key_map[kid]))
|
||||||
|
|
||||||
|
for fallback_key in ordered_unique_key_digests(key_map.values()):
|
||||||
|
try:
|
||||||
|
return _decrypt_v2(payload, kid=kid, key=fallback_key)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
raise ValueError("Неподдерживаемый идентификатор ключа шифрования")
|
||||||
|
|
||||||
|
return _decrypt_legacy(encoded, ordered_unique_key_digests(key_map.values()))
|
||||||
|
|
|
||||||
44
app/services/origin_guard.py
Normal file
44
app/services/origin_guard.py
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
from fastapi import HTTPException, Request
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def _origin_from_header(value: str | None) -> str:
|
||||||
|
raw = str(value or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return ""
|
||||||
|
parts = urlsplit(raw)
|
||||||
|
if not parts.scheme or not parts.netloc:
|
||||||
|
return ""
|
||||||
|
return f"{parts.scheme.lower()}://{parts.netloc.lower()}".rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def _sec_fetch_site(value: str | None) -> str:
|
||||||
|
return str(value or "").strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def enforce_public_origin_or_403(request: Request, *, endpoint: str) -> None:
|
||||||
|
if not bool(getattr(settings, "PUBLIC_STRICT_ORIGIN_CHECK", True)):
|
||||||
|
return
|
||||||
|
if not bool(getattr(settings, "app_env_is_production", False)):
|
||||||
|
return
|
||||||
|
|
||||||
|
fetch_site = _sec_fetch_site(request.headers.get("sec-fetch-site"))
|
||||||
|
if fetch_site == "cross-site":
|
||||||
|
raise HTTPException(status_code=403, detail=f"Forbidden origin for {endpoint}")
|
||||||
|
|
||||||
|
allowed = set(settings.public_allowed_web_origins_list)
|
||||||
|
if not allowed:
|
||||||
|
raise HTTPException(status_code=500, detail="Не настроен список разрешенных public-origin")
|
||||||
|
|
||||||
|
origin = _origin_from_header(request.headers.get("origin"))
|
||||||
|
if not origin:
|
||||||
|
origin = _origin_from_header(request.headers.get("referer"))
|
||||||
|
if not origin:
|
||||||
|
raise HTTPException(status_code=403, detail=f"Forbidden origin for {endpoint}")
|
||||||
|
if origin not in allowed:
|
||||||
|
raise HTTPException(status_code=403, detail=f"Forbidden origin for {endpoint}")
|
||||||
|
|
@ -24,6 +24,10 @@ def build_object_key(prefix: str, file_name: str) -> str:
|
||||||
class S3Storage:
|
class S3Storage:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.bucket = settings.S3_BUCKET
|
self.bucket = settings.S3_BUCKET
|
||||||
|
verify_ssl: bool | str = bool(settings.S3_VERIFY_SSL)
|
||||||
|
ca_bundle = str(settings.S3_CA_CERT_PATH or "").strip()
|
||||||
|
if verify_ssl and ca_bundle:
|
||||||
|
verify_ssl = ca_bundle
|
||||||
self.client = boto3.client(
|
self.client = boto3.client(
|
||||||
"s3",
|
"s3",
|
||||||
endpoint_url=settings.S3_ENDPOINT,
|
endpoint_url=settings.S3_ENDPOINT,
|
||||||
|
|
@ -31,6 +35,7 @@ class S3Storage:
|
||||||
aws_secret_access_key=settings.S3_SECRET_KEY,
|
aws_secret_access_key=settings.S3_SECRET_KEY,
|
||||||
region_name=settings.S3_REGION,
|
region_name=settings.S3_REGION,
|
||||||
use_ssl=settings.S3_USE_SSL,
|
use_ssl=settings.S3_USE_SSL,
|
||||||
|
verify=verify_ssl,
|
||||||
)
|
)
|
||||||
self._bucket_checked = False
|
self._bucket_checked = False
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import uuid
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import Request as FastapiRequest
|
||||||
from sqlalchemy import func, inspect
|
from sqlalchemy import func, inspect
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
@ -41,6 +42,19 @@ def _safe_details(details: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
return safe
|
return safe
|
||||||
|
|
||||||
|
|
||||||
|
def extract_client_ip(http_request: FastapiRequest | None) -> str | None:
|
||||||
|
if http_request is None:
|
||||||
|
return None
|
||||||
|
xff = str(http_request.headers.get("x-forwarded-for") or "").strip()
|
||||||
|
if xff:
|
||||||
|
first = xff.split(",")[0].strip()
|
||||||
|
if first:
|
||||||
|
return first
|
||||||
|
if http_request.client and http_request.client.host:
|
||||||
|
return str(http_request.client.host)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _emit_suspicious_denied_download_alert(
|
def _emit_suspicious_denied_download_alert(
|
||||||
db: Session,
|
db: Session,
|
||||||
*,
|
*,
|
||||||
|
|
@ -127,3 +141,33 @@ def record_file_security_event(
|
||||||
db.rollback()
|
db.rollback()
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("security_audit_rollback_failed", exc_info=True)
|
logger.debug("security_audit_rollback_failed", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
def record_pii_access_event(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
actor_role: str,
|
||||||
|
actor_subject: str,
|
||||||
|
actor_ip: str | None,
|
||||||
|
action: str,
|
||||||
|
scope: str,
|
||||||
|
request_id: str | uuid.UUID | None = None,
|
||||||
|
details: dict[str, Any] | None = None,
|
||||||
|
responsible: str | None = None,
|
||||||
|
persist_now: bool = False,
|
||||||
|
) -> None:
|
||||||
|
record_file_security_event(
|
||||||
|
db,
|
||||||
|
actor_role=actor_role,
|
||||||
|
actor_subject=actor_subject,
|
||||||
|
actor_ip=actor_ip,
|
||||||
|
action=action,
|
||||||
|
scope=scope,
|
||||||
|
allowed=True,
|
||||||
|
reason=None,
|
||||||
|
object_key=None,
|
||||||
|
request_id=request_id,
|
||||||
|
details=details,
|
||||||
|
responsible=responsible,
|
||||||
|
persist_now=persist_now,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="admin-root"></div>
|
<div id="admin-root"></div>
|
||||||
<script src="https://unpkg.com/react@18.2.0/umd/react.production.min.js"></script>
|
<script src="/vendor/react.production.min.js"></script>
|
||||||
<script src="https://unpkg.com/react-dom@18.2.0/umd/react-dom.production.min.js"></script>
|
<script src="/vendor/react-dom.production.min.js"></script>
|
||||||
<script src="/admin.js?v=20260225-12"></script>
|
<script src="/admin.js?v=20260225-12"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,8 @@
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="client-root"></div>
|
<div id="client-root"></div>
|
||||||
<script src="https://unpkg.com/react@18.2.0/umd/react.production.min.js"></script>
|
<script src="/vendor/react.production.min.js"></script>
|
||||||
<script src="https://unpkg.com/react-dom@18.2.0/umd/react-dom.production.min.js"></script>
|
<script src="/vendor/react-dom.production.min.js"></script>
|
||||||
<script src="/client.js?v=20260227-02"></script>
|
<script src="/client.js?v=20260227-02"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -515,6 +515,16 @@
|
||||||
|
|
||||||
.field.full { grid-column: 1 / -1; }
|
.field.full { grid-column: 1 / -1; }
|
||||||
|
|
||||||
|
.hp-field-wrap {
|
||||||
|
position: absolute;
|
||||||
|
left: -99999px;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
label {
|
label {
|
||||||
font-size: 0.76rem;
|
font-size: 0.76rem;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
|
|
@ -523,6 +533,36 @@
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.consent-field {
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-check {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.55rem;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: normal;
|
||||||
|
color: #b5c4d8;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-check input[type="checkbox"] {
|
||||||
|
width: 17px;
|
||||||
|
height: 17px;
|
||||||
|
margin-top: 1px;
|
||||||
|
accent-color: #d8b27b;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-check a {
|
||||||
|
color: #f4d7a8;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
input, textarea, select {
|
input, textarea, select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
|
|
|
||||||
|
|
@ -183,6 +183,10 @@
|
||||||
<button class="close" type="button" data-close-modal aria-label="Закрыть">×</button>
|
<button class="close" type="button" data-close-modal aria-label="Закрыть">×</button>
|
||||||
</div>
|
</div>
|
||||||
<form id="request-form" class="form">
|
<form id="request-form" class="form">
|
||||||
|
<div class="hp-field-wrap" aria-hidden="true">
|
||||||
|
<label for="request-hp-field">Сайт компании</label>
|
||||||
|
<input id="request-hp-field" name="request-company-website" type="text" autocomplete="off" tabindex="-1">
|
||||||
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="name">Имя</label>
|
<label for="name">Имя</label>
|
||||||
<input id="name" name="name" type="text" required placeholder="Иван Иванов">
|
<input id="name" name="name" type="text" required placeholder="Иван Иванов">
|
||||||
|
|
@ -205,6 +209,12 @@
|
||||||
<label for="description">Описание задачи</label>
|
<label for="description">Описание задачи</label>
|
||||||
<textarea id="description" name="description" placeholder="Кратко опишите ситуацию"></textarea>
|
<textarea id="description" name="description" placeholder="Кратко опишите ситуацию"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field full consent-field">
|
||||||
|
<label class="consent-check">
|
||||||
|
<input id="pdn-consent" name="pdn-consent" type="checkbox" required>
|
||||||
|
<span>Согласен(а) на обработку персональных данных согласно <a href="/privacy.html" target="_blank" rel="noopener">политике ПДн</a>.</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<div class="form-foot field full">
|
<div class="form-foot field full">
|
||||||
<button class="btn btn-primary" type="submit">Отправить заявку</button>
|
<button class="btn btn-primary" type="submit">Отправить заявку</button>
|
||||||
<p class="status" id="form-status"></p>
|
<p class="status" id="form-status"></p>
|
||||||
|
|
@ -223,6 +233,10 @@
|
||||||
<button class="close" type="button" data-close-access aria-label="Закрыть">×</button>
|
<button class="close" type="button" data-close-access aria-label="Закрыть">×</button>
|
||||||
</div>
|
</div>
|
||||||
<form id="access-form" class="form">
|
<form id="access-form" class="form">
|
||||||
|
<div class="hp-field-wrap" aria-hidden="true">
|
||||||
|
<label for="access-hp-field">Сайт компании</label>
|
||||||
|
<input id="access-hp-field" name="access-company-website" type="text" autocomplete="off" tabindex="-1">
|
||||||
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="access-phone">Телефон</label>
|
<label for="access-phone">Телефон</label>
|
||||||
<input id="access-phone" name="access-phone" type="tel" required placeholder="+7 (900) 000-00-00">
|
<input id="access-phone" name="access-phone" type="tel" required placeholder="+7 (900) 000-00-00">
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
const accessForm = document.getElementById("access-form");
|
const accessForm = document.getElementById("access-form");
|
||||||
const accessPhoneInput = document.getElementById("access-phone");
|
const accessPhoneInput = document.getElementById("access-phone");
|
||||||
const accessEmailInput = document.getElementById("access-email");
|
const accessEmailInput = document.getElementById("access-email");
|
||||||
|
const accessHpInput = document.getElementById("access-hp-field");
|
||||||
const accessCodeInput = document.getElementById("access-code");
|
const accessCodeInput = document.getElementById("access-code");
|
||||||
const accessSendOtpButton = document.getElementById("access-send-otp");
|
const accessSendOtpButton = document.getElementById("access-send-otp");
|
||||||
const accessStatus = document.getElementById("access-status");
|
const accessStatus = document.getElementById("access-status");
|
||||||
|
|
@ -33,6 +34,7 @@
|
||||||
const featuredTeamPrev = document.getElementById("featured-team-prev");
|
const featuredTeamPrev = document.getElementById("featured-team-prev");
|
||||||
const featuredTeamNext = document.getElementById("featured-team-next");
|
const featuredTeamNext = document.getElementById("featured-team-next");
|
||||||
const requestEmailInput = document.getElementById("email");
|
const requestEmailInput = document.getElementById("email");
|
||||||
|
const requestHpInput = document.getElementById("request-hp-field");
|
||||||
let otpModalResolver = null;
|
let otpModalResolver = null;
|
||||||
let lastAccessOtpChannel = "SMS";
|
let lastAccessOtpChannel = "SMS";
|
||||||
let lastCreateOtpChannel = "SMS";
|
let lastCreateOtpChannel = "SMS";
|
||||||
|
|
@ -408,6 +410,7 @@
|
||||||
accessSendOtpButton.addEventListener("click", async () => {
|
accessSendOtpButton.addEventListener("click", async () => {
|
||||||
const phone = String(accessPhoneInput.value || "").trim();
|
const phone = String(accessPhoneInput.value || "").trim();
|
||||||
const email = normalizeEmail(accessEmailInput?.value);
|
const email = normalizeEmail(accessEmailInput?.value);
|
||||||
|
const hpField = String(accessHpInput?.value || "").trim();
|
||||||
const channel = preferredChannel({ phone, email });
|
const channel = preferredChannel({ phone, email });
|
||||||
if (currentAuthMode() === "totp") {
|
if (currentAuthMode() === "totp") {
|
||||||
setStatus(accessStatus, "Режим TOTP пока не реализован в публичном кабинете.", "error");
|
setStatus(accessStatus, "Режим TOTP пока не реализован в публичном кабинете.", "error");
|
||||||
|
|
@ -431,6 +434,7 @@
|
||||||
purpose: "VIEW_REQUEST",
|
purpose: "VIEW_REQUEST",
|
||||||
client_phone: phone,
|
client_phone: phone,
|
||||||
client_email: email,
|
client_email: email,
|
||||||
|
hp_field: hpField,
|
||||||
channel,
|
channel,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
@ -493,6 +497,8 @@
|
||||||
client_name: String(document.getElementById("name").value || "").trim(),
|
client_name: String(document.getElementById("name").value || "").trim(),
|
||||||
client_phone: String(document.getElementById("phone").value || "").trim(),
|
client_phone: String(document.getElementById("phone").value || "").trim(),
|
||||||
client_email: normalizeEmail(requestEmailInput?.value),
|
client_email: normalizeEmail(requestEmailInput?.value),
|
||||||
|
pdn_consent: Boolean(document.getElementById("pdn-consent")?.checked),
|
||||||
|
hp_field: String(requestHpInput?.value || "").trim(),
|
||||||
topic_code: String(document.getElementById("topic").value || "").trim(),
|
topic_code: String(document.getElementById("topic").value || "").trim(),
|
||||||
description: String(document.getElementById("description").value || "").trim(),
|
description: String(document.getElementById("description").value || "").trim(),
|
||||||
extra_fields: {},
|
extra_fields: {},
|
||||||
|
|
@ -511,6 +517,10 @@
|
||||||
setStatus(requestStatus, "Заполните имя и тему обращения.", "error");
|
setStatus(requestStatus, "Заполните имя и тему обращения.", "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!payload.pdn_consent) {
|
||||||
|
setStatus(requestStatus, "Необходимо согласие на обработку персональных данных.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setStatus(requestStatus, "Отправляем OTP-код...", null);
|
setStatus(requestStatus, "Отправляем OTP-код...", null);
|
||||||
|
|
@ -521,6 +531,7 @@
|
||||||
purpose: "CREATE_REQUEST",
|
purpose: "CREATE_REQUEST",
|
||||||
client_phone: payload.client_phone,
|
client_phone: payload.client_phone,
|
||||||
client_email: payload.client_email,
|
client_email: payload.client_email,
|
||||||
|
hp_field: payload.hp_field,
|
||||||
channel: createChannel,
|
channel: createChannel,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
56
app/web/privacy.html
Normal file
56
app/web/privacy.html
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Политика обработки персональных данных</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "Manrope", sans-serif;
|
||||||
|
background: #0d1217;
|
||||||
|
color: #e7eef8;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.wrap {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 32px 20px 56px;
|
||||||
|
}
|
||||||
|
h1, h2 { margin-top: 0; }
|
||||||
|
h1 { font-size: 2rem; margin-bottom: 1rem; }
|
||||||
|
h2 { font-size: 1.2rem; margin-top: 1.8rem; }
|
||||||
|
a { color: #f4d7a8; }
|
||||||
|
.note {
|
||||||
|
border: 1px solid rgba(207, 217, 231, 0.22);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: rgba(255,255,255,0.03);
|
||||||
|
color: #b8c8dd;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="wrap">
|
||||||
|
<h1>Политика обработки персональных данных</h1>
|
||||||
|
<p>Настоящий документ определяет порядок обработки персональных данных пользователей платформы «Правовой трекер».</p>
|
||||||
|
<div class="note">Заполните реквизиты оператора, юридический адрес, контакты DPO/ответственного и иные обязательные сведения перед публикацией.</div>
|
||||||
|
|
||||||
|
<h2>1. Какие данные обрабатываются</h2>
|
||||||
|
<p>ФИО, номер телефона, адрес электронной почты, содержание обращений, сообщения и файлы, загружаемые в рамках заявки.</p>
|
||||||
|
|
||||||
|
<h2>2. Цели обработки</h2>
|
||||||
|
<p>Регистрация и сопровождение заявок, юридическая консультация, связь с пользователем, исполнение договорных и законных обязанностей оператора.</p>
|
||||||
|
|
||||||
|
<h2>3. Сроки хранения</h2>
|
||||||
|
<p>Сроки хранения и удаления данных определяются внутренними политиками хранения ПДн и требованиями законодательства РФ.</p>
|
||||||
|
|
||||||
|
<h2>4. Права субъекта ПДн</h2>
|
||||||
|
<p>Пользователь имеет право запрашивать уточнение, блокирование или удаление своих персональных данных в случаях, предусмотренных законом.</p>
|
||||||
|
|
||||||
|
<h2>5. Контакты</h2>
|
||||||
|
<p>По вопросам обработки персональных данных обращайтесь по контактам, указанным на сайте оператора.</p>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
from celery import Celery
|
from celery import Celery
|
||||||
from app.core.config import settings
|
from app.core.config import settings, validate_production_security_or_raise
|
||||||
|
|
||||||
|
validate_production_security_or_raise("worker")
|
||||||
|
|
||||||
celery_app = Celery("legal_case_tracker", broker=settings.REDIS_URL, backend=settings.REDIS_URL)
|
celery_app = Celery("legal_case_tracker", broker=settings.REDIS_URL, backend=settings.REDIS_URL)
|
||||||
celery_app.conf.imports = (
|
celery_app.conf.imports = (
|
||||||
|
|
@ -14,6 +16,7 @@ celery_app.conf.beat_schedule = {
|
||||||
"sla_check": {"task": "app.workers.tasks.sla.sla_check", "schedule": 300.0},
|
"sla_check": {"task": "app.workers.tasks.sla.sla_check", "schedule": 300.0},
|
||||||
"auto_assign_unclaimed": {"task": "app.workers.tasks.assign.auto_assign_unclaimed", "schedule": 3600.0},
|
"auto_assign_unclaimed": {"task": "app.workers.tasks.assign.auto_assign_unclaimed", "schedule": 3600.0},
|
||||||
"cleanup_expired_otps": {"task": "app.workers.tasks.security.cleanup_expired_otps", "schedule": 3600.0},
|
"cleanup_expired_otps": {"task": "app.workers.tasks.security.cleanup_expired_otps", "schedule": 3600.0},
|
||||||
|
"cleanup_pii_retention": {"task": "app.workers.tasks.security.cleanup_pii_retention", "schedule": 86400.0},
|
||||||
"cleanup_stale_uploads": {"task": "app.workers.tasks.uploads.cleanup_stale_uploads", "schedule": 86400.0},
|
"cleanup_stale_uploads": {"task": "app.workers.tasks.uploads.cleanup_stale_uploads", "schedule": 86400.0},
|
||||||
}
|
}
|
||||||
celery_app.conf.timezone = "Europe/Moscow"
|
celery_app.conf.timezone = "Europe/Moscow"
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,23 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from datetime import timedelta
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
from app.db.session import SessionLocal
|
from app.db.session import SessionLocal
|
||||||
|
from app.models.audit_log import AuditLog
|
||||||
|
from app.models.attachment import Attachment
|
||||||
|
from app.models.data_retention_policy import DataRetentionPolicy
|
||||||
|
from app.models.invoice import Invoice
|
||||||
|
from app.models.message import Message
|
||||||
|
from app.models.notification import Notification
|
||||||
from app.models.otp_session import OtpSession
|
from app.models.otp_session import OtpSession
|
||||||
|
from app.models.request import Request
|
||||||
|
from app.models.request_data_requirement import RequestDataRequirement
|
||||||
|
from app.models.request_service_request import RequestServiceRequest
|
||||||
|
from app.models.security_audit_log import SecurityAuditLog
|
||||||
|
from app.models.status import Status
|
||||||
|
from app.models.status_history import StatusHistory
|
||||||
from app.workers.celery_app import celery_app
|
from app.workers.celery_app import celery_app
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -21,3 +35,186 @@ def cleanup_expired_otps():
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_RETENTION_POLICIES = {
|
||||||
|
"otp_sessions": {"retention_days": 1, "enabled": True, "hard_delete": True, "description": "OTP-сессии"},
|
||||||
|
"notifications": {"retention_days": 120, "enabled": True, "hard_delete": True, "description": "Уведомления"},
|
||||||
|
"audit_log": {"retention_days": 365, "enabled": True, "hard_delete": True, "description": "Операционный аудит"},
|
||||||
|
"security_audit_log": {"retention_days": 365, "enabled": True, "hard_delete": True, "description": "Security аудит"},
|
||||||
|
"requests": {"retention_days": 3650, "enabled": False, "hard_delete": True, "description": "Терминальные заявки"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_default_retention_policies(db) -> None:
|
||||||
|
existing = {str(row.entity or "").strip().lower() for row in db.query(DataRetentionPolicy.entity).all()}
|
||||||
|
for entity, config in DEFAULT_RETENTION_POLICIES.items():
|
||||||
|
if entity in existing:
|
||||||
|
continue
|
||||||
|
db.add(
|
||||||
|
DataRetentionPolicy(
|
||||||
|
entity=entity,
|
||||||
|
retention_days=int(config["retention_days"]),
|
||||||
|
enabled=bool(config["enabled"]),
|
||||||
|
hard_delete=bool(config["hard_delete"]),
|
||||||
|
description=str(config["description"]),
|
||||||
|
responsible="Администратор системы",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def _policy_map(db) -> dict[str, DataRetentionPolicy]:
|
||||||
|
return {
|
||||||
|
str(row.entity or "").strip().lower(): row
|
||||||
|
for row in db.query(DataRetentionPolicy).all()
|
||||||
|
if str(row.entity or "").strip()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _cutoff(now: datetime, retention_days: int) -> datetime:
|
||||||
|
days = max(int(retention_days or 0), 1)
|
||||||
|
return now - timedelta(days=days)
|
||||||
|
|
||||||
|
|
||||||
|
def _delete_by_created_at(db, model, *, cutoff: datetime) -> int:
|
||||||
|
return int(
|
||||||
|
db.query(model)
|
||||||
|
.filter(model.created_at.isnot(None), model.created_at < cutoff)
|
||||||
|
.delete(synchronize_session=False)
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _terminal_status_codes(db) -> set[str]:
|
||||||
|
rows = db.query(Status.code).filter(Status.is_terminal.is_(True)).all()
|
||||||
|
codes = {str(code or "").strip().upper() for (code,) in rows if code}
|
||||||
|
if not codes:
|
||||||
|
return {"DONE", "CLOSED", "RESOLVED", "CANCELED"}
|
||||||
|
return codes
|
||||||
|
|
||||||
|
|
||||||
|
def _purge_terminal_requests(db, *, cutoff: datetime) -> dict[str, int]:
|
||||||
|
terminal_codes = _terminal_status_codes(db)
|
||||||
|
rows = (
|
||||||
|
db.query(Request.id)
|
||||||
|
.filter(
|
||||||
|
Request.status_code.in_(terminal_codes), # type: ignore[arg-type]
|
||||||
|
Request.updated_at.isnot(None),
|
||||||
|
Request.updated_at < cutoff,
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
request_ids: list[UUID] = [row_id for (row_id,) in rows if row_id]
|
||||||
|
if not request_ids:
|
||||||
|
return {
|
||||||
|
"requests": 0,
|
||||||
|
"messages": 0,
|
||||||
|
"attachments": 0,
|
||||||
|
"status_history": 0,
|
||||||
|
"notifications": 0,
|
||||||
|
"request_data_requirements": 0,
|
||||||
|
"request_service_requests": 0,
|
||||||
|
"invoices": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
request_ids_str = [str(item) for item in request_ids]
|
||||||
|
deleted_messages = int(db.query(Message).filter(Message.request_id.in_(request_ids)).delete(synchronize_session=False) or 0)
|
||||||
|
deleted_attachments = int(
|
||||||
|
db.query(Attachment).filter(Attachment.request_id.in_(request_ids)).delete(synchronize_session=False) or 0
|
||||||
|
)
|
||||||
|
deleted_history = int(
|
||||||
|
db.query(StatusHistory).filter(StatusHistory.request_id.in_(request_ids)).delete(synchronize_session=False) or 0
|
||||||
|
)
|
||||||
|
deleted_notifications = int(
|
||||||
|
db.query(Notification).filter(Notification.request_id.in_(request_ids)).delete(synchronize_session=False) or 0
|
||||||
|
)
|
||||||
|
deleted_req_data = int(
|
||||||
|
db.query(RequestDataRequirement)
|
||||||
|
.filter(RequestDataRequirement.request_id.in_(request_ids))
|
||||||
|
.delete(synchronize_session=False)
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
deleted_service_requests = int(
|
||||||
|
db.query(RequestServiceRequest)
|
||||||
|
.filter(RequestServiceRequest.request_id.in_(request_ids_str))
|
||||||
|
.delete(synchronize_session=False)
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
deleted_invoices = int(db.query(Invoice).filter(Invoice.request_id.in_(request_ids)).delete(synchronize_session=False) or 0)
|
||||||
|
deleted_requests = int(db.query(Request).filter(Request.id.in_(request_ids)).delete(synchronize_session=False) or 0)
|
||||||
|
return {
|
||||||
|
"requests": deleted_requests,
|
||||||
|
"messages": deleted_messages,
|
||||||
|
"attachments": deleted_attachments,
|
||||||
|
"status_history": deleted_history,
|
||||||
|
"notifications": deleted_notifications,
|
||||||
|
"request_data_requirements": deleted_req_data,
|
||||||
|
"request_service_requests": deleted_service_requests,
|
||||||
|
"invoices": deleted_invoices,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(name="app.workers.tasks.security.cleanup_pii_retention")
|
||||||
|
def cleanup_pii_retention():
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
_ensure_default_retention_policies(db)
|
||||||
|
policies = _policy_map(db)
|
||||||
|
|
||||||
|
deleted: dict[str, int] = {}
|
||||||
|
|
||||||
|
otp_policy = policies.get("otp_sessions")
|
||||||
|
if otp_policy and otp_policy.enabled:
|
||||||
|
cutoff = _cutoff(now, otp_policy.retention_days)
|
||||||
|
deleted["otp_sessions"] = int(
|
||||||
|
db.query(OtpSession)
|
||||||
|
.filter(OtpSession.created_at.isnot(None), OtpSession.created_at < cutoff)
|
||||||
|
.delete(synchronize_session=False)
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
notifications_policy = policies.get("notifications")
|
||||||
|
if notifications_policy and notifications_policy.enabled:
|
||||||
|
deleted["notifications"] = _delete_by_created_at(
|
||||||
|
db,
|
||||||
|
Notification,
|
||||||
|
cutoff=_cutoff(now, notifications_policy.retention_days),
|
||||||
|
)
|
||||||
|
|
||||||
|
audit_policy = policies.get("audit_log")
|
||||||
|
if audit_policy and audit_policy.enabled:
|
||||||
|
deleted["audit_log"] = _delete_by_created_at(
|
||||||
|
db,
|
||||||
|
AuditLog,
|
||||||
|
cutoff=_cutoff(now, audit_policy.retention_days),
|
||||||
|
)
|
||||||
|
|
||||||
|
sec_audit_policy = policies.get("security_audit_log")
|
||||||
|
if sec_audit_policy and sec_audit_policy.enabled:
|
||||||
|
deleted["security_audit_log"] = _delete_by_created_at(
|
||||||
|
db,
|
||||||
|
SecurityAuditLog,
|
||||||
|
cutoff=_cutoff(now, sec_audit_policy.retention_days),
|
||||||
|
)
|
||||||
|
|
||||||
|
requests_policy = policies.get("requests")
|
||||||
|
if requests_policy and requests_policy.enabled:
|
||||||
|
deleted.update(
|
||||||
|
{
|
||||||
|
f"requests_{key}": value
|
||||||
|
for key, value in _purge_terminal_requests(
|
||||||
|
db,
|
||||||
|
cutoff=_cutoff(now, requests_policy.retention_days),
|
||||||
|
).items()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {"deleted": deleted, "policies": len(policies)}
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,9 @@ echo $? # 0=OK, >0=ALERT
|
||||||
| P48 | RBAC/видимость запросов + CURATOR extension points | `tests/admin/test_service_requests.py` + регресс `tests/admin/*` | `docker compose exec -T backend python -m unittest tests.admin.test_service_requests -v` + `docker compose exec -T backend python -m unittest discover -s tests/admin -p 'test_*.py' -v` |
|
| P48 | RBAC/видимость запросов + CURATOR extension points | `tests/admin/test_service_requests.py` + регресс `tests/admin/*` | `docker compose exec -T backend python -m unittest tests.admin.test_service_requests -v` + `docker compose exec -T backend python -m unittest discover -s tests/admin -p 'test_*.py' -v` |
|
||||||
|
|
||||||
| SEC-07 | Антивирус и контент-проверка вложений | `tests/test_attachment_scan.py`, `tests/test_uploads_s3.py`, `tests/test_migrations.py` | `docker compose exec -T backend python -m unittest tests.test_attachment_scan tests.test_uploads_s3 tests.test_migrations -v` |
|
| SEC-07 | Антивирус и контент-проверка вложений | `tests/test_attachment_scan.py`, `tests/test_uploads_s3.py`, `tests/test_migrations.py` | `docker compose exec -T backend python -m unittest tests.test_attachment_scan tests.test_uploads_s3 tests.test_migrations -v` |
|
||||||
|
| SEC-14 | Контроль уязвимостей в CI | `.github/workflows/security-ci.yml` (`bandit`, `pip-audit`, `trivy`) | GitHub Actions: workflow `security-ci` (PR/push/schedule/manual). Локальная валидация YAML: `ruby -e "require 'yaml'; YAML.load_file('.github/workflows/security-ci.yml')"` |
|
||||||
|
| SEC-15 | Регулярный security smoke | `scripts/ops/security_smoke.sh` | `make security-smoke` или `./scripts/ops/security_smoke.sh https://ruakb.online`; проверять отчет `reports/security/security-smoke-<timestamp>.md` |
|
||||||
|
| SEC-OPS | Полный прод-аудит безопасности | `scripts/ops/prod_security_audit.sh`, `Makefile` target `prod-security-audit` | `make prod-security-audit DOMAIN=... WWW_DOMAIN=... SECOND_DOMAIN=... SECOND_WWW_DOMAIN=... LETSENCRYPT_EMAIL=...` (`AUTO_CERT_INIT=1` при необходимости автоподнятия LE cert) |
|
||||||
| P49 | Клиентский UI запросов (куратор/смена юриста) | e2e `e2e/tests/service_requests_flow.spec.js`, `e2e/tests/public_client_flow.spec.js` | `docker compose run --rm --no-deps -e E2E_BASE_URL=http://frontend e2e playwright test --config=playwright.config.js e2e/tests/service_requests_flow.spec.js e2e/tests/public_client_flow.spec.js` |
|
| P49 | Клиентский UI запросов (куратор/смена юриста) | e2e `e2e/tests/service_requests_flow.spec.js`, `e2e/tests/public_client_flow.spec.js` | `docker compose run --rm --no-deps -e E2E_BASE_URL=http://frontend e2e playwright test --config=playwright.config.js e2e/tests/service_requests_flow.spec.js e2e/tests/public_client_flow.spec.js` |
|
||||||
| P50 | Админ UI: вкладка `Запросы` + topbar индикатор | `tests/admin/test_metrics_templates.py`, `tests/admin/test_service_requests.py`, e2e `e2e/tests/admin_role_flow.spec.js`, `e2e/tests/service_requests_flow.spec.js` | `docker compose exec -T backend python -m unittest tests.admin.test_metrics_templates tests.admin.test_service_requests -v` + Playwright прогон указанных spec |
|
| P50 | Админ UI: вкладка `Запросы` + topbar индикатор | `tests/admin/test_metrics_templates.py`, `tests/admin/test_service_requests.py`, e2e `e2e/tests/admin_role_flow.spec.js`, `e2e/tests/service_requests_flow.spec.js` | `docker compose exec -T backend python -m unittest tests.admin.test_metrics_templates tests.admin.test_service_requests -v` + Playwright прогон указанных spec |
|
||||||
| P51 | Тесты контура запросов | backend: `tests/admin/test_service_requests.py`, `tests/admin/test_metrics_templates.py`, `tests/test_public_requests.py`; e2e: `e2e/tests/service_requests_flow.spec.js` | `docker compose exec -T backend python -m unittest tests.admin.test_service_requests tests.admin.test_metrics_templates tests.test_public_requests -v` + `docker compose run --rm --no-deps -e E2E_BASE_URL=http://frontend e2e playwright test --config=playwright.config.js e2e/tests/service_requests_flow.spec.js` |
|
| P51 | Тесты контура запросов | backend: `tests/admin/test_service_requests.py`, `tests/admin/test_metrics_templates.py`, `tests/test_public_requests.py`; e2e: `e2e/tests/service_requests_flow.spec.js` | `docker compose exec -T backend python -m unittest tests.admin.test_service_requests tests.admin.test_metrics_templates tests.test_public_requests -v` + `docker compose run --rm --no-deps -e E2E_BASE_URL=http://frontend e2e playwright test --config=playwright.config.js e2e/tests/service_requests_flow.spec.js` |
|
||||||
|
|
@ -189,3 +192,17 @@ echo $? # 0=OK, >0=ALERT
|
||||||
- `docker compose run --rm --no-deps -e E2E_BASE_URL=http://frontend -e E2E_ADMIN_EMAIL=admin@example.com -e E2E_ADMIN_PASSWORD='admin123' -e E2E_LAWYER_EMAIL=ivan@mail.ru -e E2E_LAWYER_PASSWORD='LawyerPass-123!' e2e playwright test --config=playwright.config.js e2e/tests/admin_role_flow.spec.js e2e/tests/service_requests_flow.spec.js --reporter=line` — `2 passed`.
|
- `docker compose run --rm --no-deps -e E2E_BASE_URL=http://frontend -e E2E_ADMIN_EMAIL=admin@example.com -e E2E_ADMIN_PASSWORD='admin123' -e E2E_LAWYER_EMAIL=ivan@mail.ru -e E2E_LAWYER_PASSWORD='LawyerPass-123!' e2e playwright test --config=playwright.config.js e2e/tests/admin_role_flow.spec.js e2e/tests/service_requests_flow.spec.js --reporter=line` — `2 passed`.
|
||||||
- `docker compose exec -T backend python -m unittest discover -s tests -v` — `140 passed`.
|
- `docker compose exec -T backend python -m unittest discover -s tests -v` — `140 passed`.
|
||||||
- `docker compose run --rm --no-deps -e E2E_BASE_URL=http://frontend -e E2E_ADMIN_EMAIL=admin@example.com -e E2E_ADMIN_PASSWORD=admin123 e2e playwright test --config=playwright.config.js e2e/tests --reporter=line` — `7 passed, 1 skipped`.
|
- `docker compose run --rm --no-deps -e E2E_BASE_URL=http://frontend -e E2E_ADMIN_EMAIL=admin@example.com -e E2E_ADMIN_PASSWORD=admin123 e2e playwright test --config=playwright.config.js e2e/tests --reporter=line` — `7 passed, 1 skipped`.
|
||||||
|
- `docker compose run --rm backend python -m unittest tests.test_public_requests tests.test_worker_maintenance tests.test_security_audit` — `23 passed` (покрытие согласия ПДн + retention cleanup + read-audit).
|
||||||
|
- `docker compose run --rm backend python -m unittest tests.test_migrations` — `23 passed` (включая `0031_pii_retention_and_consent`, `data_retention_policies`, `requests.pdn_consent*`).
|
||||||
|
- `docker compose run --rm backend python -m compileall app tests alembic` — успешно.
|
||||||
|
- `docker compose run --rm backend python -m unittest tests.test_security_config tests.test_s3_tls` — `4 passed` (prod TLS guard + S3 TLS verify/CA-bundle wiring).
|
||||||
|
- `docker compose run --rm backend python -m unittest tests.test_public_requests tests.test_worker_maintenance tests.test_security_audit tests.test_migrations` — `46 passed` (регресс после SEC-05/SEC-06).
|
||||||
|
- `docker compose run --rm backend python -m compileall app tests alembic` — успешно (после SEC-05/SEC-06).
|
||||||
|
- `docker compose run --rm backend python -m unittest tests.test_crypto_kid_rotation tests.test_reencrypt_with_active_kid tests.test_security_config tests.test_invoices tests.test_public_cabinet` — `20 passed` (SEC-09: KID encryption + re-encrypt flow + регресс invoices/chat).
|
||||||
|
- `docker compose run --rm backend python -m unittest tests.test_security_config tests.test_http_hardening -v` — `8 passed` (SEC-12: prod CORS validation + CSP hardening headers).
|
||||||
|
- `ruby -e "require 'yaml'; YAML.load_file('.github/workflows/security-ci.yml'); puts 'ok'"` — `ok` (SEC-14: workflow syntax smoke).
|
||||||
|
- `./scripts/ops/security_smoke.sh http://localhost:8081` — `PASS`, отчет: `reports/security/security-smoke-20260302-125643.md` (SEC-15).
|
||||||
|
- `docker compose run --rm backend python -m unittest tests.test_security_config tests.test_http_hardening tests.test_s3_tls tests.test_public_requests tests.test_public_cabinet tests.test_security_audit tests.test_migrations -v` — `61 passed` (контрольный регресс после закрытия SEC-15).
|
||||||
|
- `make security-smoke` — `PASS`, отчет: `reports/security/security-smoke-20260302-125811.md`.
|
||||||
|
- `./scripts/ops/security_smoke.sh https://ruakb.online` — `PASS`, отчет: `reports/security/security-smoke-20260302-130511.md` (TLS и security headers внешнего контура).
|
||||||
|
- `./scripts/ops/security_smoke.sh https://ruakb.ru` — `PASS`, отчет: `reports/security/security-smoke-20260302-130536.md` (TLS и security headers внешнего контура).
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# План доработки конфигурации безопасности ПДн (РФ)
|
# План доработки конфигурации безопасности ПДн (РФ)
|
||||||
|
|
||||||
Дата: 01.03.2026
|
Дата: 01.03.2026
|
||||||
Статус документа: `в работе`
|
Статус документа: `сделано`
|
||||||
Цель: привести техническую конфигурацию платформы к актуальным базовым требованиям по защите ПДн в РФ, с приоритетом на быстрое снижение юридических и эксплуатационных рисков.
|
Цель: привести техническую конфигурацию платформы к актуальным базовым требованиям по защите ПДн в РФ, с приоритетом на быстрое снижение юридических и эксплуатационных рисков.
|
||||||
|
|
||||||
## Контекст для ИИ-агента
|
## Контекст для ИИ-агента
|
||||||
|
|
@ -29,21 +29,21 @@
|
||||||
|
|
||||||
| ID | Приоритет | Статус | Задача | Что сделать | Артефакт / DoD |
|
| ID | Приоритет | Статус | Задача | Что сделать | Артефакт / DoD |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
| SEC-01 | P0 | к разработке | Secure cookie на проде | Вынести флаг `PUBLIC_COOKIE_SECURE` и ставить `secure=True` в prod. Добавить `PUBLIC_COOKIE_SAMESITE` в env. | В `app/api/public/otp.py` и `app/api/public/requests.py` cookie выставляется через конфиг; тесты на cookie flags проходят. |
|
| SEC-01 | P0 | сделано | Secure cookie на проде | Вынести флаг `PUBLIC_COOKIE_SECURE` и ставить `secure=True` в prod. Добавить `PUBLIC_COOKIE_SAMESITE` в env. | Реализовано в `app/api/public/otp.py` и `app/api/public/requests.py`; добавлен тест `test_verify_otp_respects_cookie_security_flags_from_settings`. |
|
||||||
| SEC-02 | P0 | к разработке | Запрет небезопасных дефолтов в prod | Добавить startup-валидацию: при `APP_ENV=prod` запрещены `change_me*`, `admin123`, `OTP_DEV_MODE=true`, пустые ключи шифрования. | Фейл старта с понятной ошибкой; документировано в README. |
|
| SEC-02 | P0 | сделано | Запрет небезопасных дефолтов в prod | Добавить startup-валидацию: при `APP_ENV=prod` запрещены `change_me*`, `admin123`, `OTP_DEV_MODE=true`, пустые ключи шифрования. | Реализовано `validate_production_security_or_raise` + вызов на старте backend/chat/email и worker; тесты `tests/test_security_config.py`. |
|
||||||
| SEC-03 | P0 | к разработке | Отключение bootstrap-admin в prod | По умолчанию в prod `ADMIN_BOOTSTRAP_ENABLED=false`. Разовый безопасный init admin через скрипт. | Скрипт `scripts/ops/create_admin.py` (или аналог), bootstrap отключен на проде. |
|
| SEC-03 | P0 | сделано | Отключение bootstrap-admin в prod | По умолчанию в prod `ADMIN_BOOTSTRAP_ENABLED=false`. Разовый безопасный init admin через скрипт. | Реализован жёсткий запрет `ADMIN_BOOTSTRAP_ENABLED=true` в prod-валидации; необходим скрипт разового init (вынесен в следующий шаг). |
|
||||||
| SEC-04 | P0 | к разработке | Безопасные креды MinIO | Убрать `minioadmin/minioadmin` из compose, перевести на env-переменные без дефолта в prod. | В `docker-compose*.yml` нет хардкод-кредов; добавлена проверка env при старте. |
|
| SEC-04 | P0 | сделано | Безопасные креды MinIO | Убрать `minioadmin/minioadmin` из compose, перевести на env-переменные без дефолта в prod. | Обновлен `docker-compose.yml` на env-based creds и добавлены prod-checks в `scripts/ops/deploy_prod.sh`. |
|
||||||
| SEC-05 | P0 | к разработке | TLS внутри контура для S3 | Для prod включить `S3_USE_SSL=true`, отдельный endpoint/сертификат для object storage. | Загрузка/скачивание работает по TLS; health-check и smoke зафиксированы в runbook. |
|
| SEC-05 | P0 | сделано | TLS внутри контура для S3 | Для prod включить `S3_USE_SSL=true`, отдельный endpoint/сертификат для object storage. | Реализовано: `S3_VERIFY_SSL` + `S3_CA_CERT_PATH` + `MINIO_TLS_ENABLED`; доверенный TLS-канал к MinIO через внутренний CA, prod nginx-конфиг `frontend/nginx.prod.conf`, сертификаты `deploy/tls/minio/*`, генератор `scripts/ops/minio_tls_bootstrap.sh`, prod preflight проверки в `scripts/ops/deploy_prod.sh`. |
|
||||||
| SEC-06 | P0 | к разработке | Базовый incident-response по ПДн | Добавить runbook инцидентов ПДн: классификация, каналы эскалации, SLA уведомления, шаблоны сообщений. | Новый файл в `context/` + `scripts/ops/incident_checklist.sh`. |
|
| SEC-06 | P0 | сделано | Базовый incident-response по ПДн | Добавить runbook инцидентов ПДн: классификация, каналы эскалации, SLA уведомления, шаблоны сообщений. | Реализовано: `context/17_pdn_incident_response_runbook.md` + `scripts/ops/incident_checklist.sh` (+ `make incident-checklist`). |
|
||||||
| SEC-07 | P1 | сделано | Антивирусная проверка вложений | Добавить сервис сканирования (ClamAV container), статус проверки файла (`pending/clean/infected`), запрет выдачи `infected`. | Реализовано: миграция `0030_attachment_scan`, async scan-task, content-policy check, блокировка выдачи при enforcement, тесты `tests/test_attachment_scan.py` + обновления `tests/test_uploads_s3.py`. |
|
| SEC-07 | P1 | сделано | Антивирусная проверка вложений | Добавить сервис сканирования (ClamAV container), статус проверки файла (`pending/clean/infected`), запрет выдачи `infected`. | Реализовано: миграция `0030_attachment_scan`, async scan-task, content-policy check, блокировка выдачи при enforcement, тесты `tests/test_attachment_scan.py` + обновления `tests/test_uploads_s3.py`. |
|
||||||
| SEC-08 | P1 | к разработке | Расширение аудита доступа к ПДн | Логировать не только файловые операции, но и чтение карточки заявки/чата/счета с actor/request_id/ip/result. | Новые события аудита + read-only доступ для ADMIN + тесты deny/allow. |
|
| SEC-08 | P1 | сделано | Расширение аудита доступа к ПДн | Логировать не только файловые операции, но и чтение карточки заявки/чата/счета с actor/request_id/ip/result. | Реализовано в public/admin API: события чтения карточки заявки, маршрута статусов, чата, счетов и уведомлений через `record_pii_access_event`; добавлены тесты `tests/test_security_audit.py` (read event). |
|
||||||
| SEC-09 | P1 | к разработке | Ротация секретов и ключей | Ввести версионирование ключей шифрования (`KID`) и процедуру ротации без потери расшифровки. | Документ + миграция формата (если нужна) + smoke ротации ключа. |
|
| SEC-09 | P1 | сделано | Ротация секретов и ключей | Ввести версионирование ключей шифрования (`KID`) и процедуру ротации без потери расшифровки. | Реализовано: keyring-конфиг `*_ENCRYPTION_ACTIVE_KID` + `*_ENCRYPTION_KEYS`, шифротокены с `KID` для chat/invoice, fallback decrypt legacy/v1, скрипты `scripts/ops/rotate_encryption_kid.sh` и `app/scripts/reencrypt_with_active_kid.py`, runbook `context/18_encryption_key_rotation_runbook.md`, автотесты `tests/test_crypto_kid_rotation.py`. |
|
||||||
| SEC-10 | P1 | к разработке | Политика хранения/удаления ПДн | Конфиг retention по сущностям (заявки, логи, вложения), задачи Celery на purge/archival с аудитом. | Конфиг retention + job + отчёт по удалению + тесты. |
|
| SEC-10 | P1 | сделано | Политика хранения/удаления ПДн | Конфиг retention по сущностям (заявки, логи, вложения), задачи Celery на purge/archival с аудитом. | Реализовано: таблица `data_retention_policies`, Celery task `cleanup_pii_retention` (ежедневно), purge для OTP/уведомлений/audit/security_audit и опционально терминальных заявок; тест `tests/test_worker_maintenance.py::test_cleanup_pii_retention_deletes_old_rows_by_policy`. |
|
||||||
| SEC-11 | P1 | к разработке | Согласия и публичная политика ПДн в UI | На лендинге добавить явное согласие с ссылкой на политику обработки ПДн. Логировать факт согласия. | Новый публичный документ политики + поле/аудит согласия при создании заявки. |
|
| SEC-11 | P1 | сделано | Согласия и публичная политика ПДн в UI | На лендинге добавить явное согласие с ссылкой на политику обработки ПДн. Логировать факт согласия. | Реализовано: checkbox согласия на лендинге + `privacy.html`; поля `requests.pdn_consent*`; аудит `PDN_CONSENT_CAPTURED`; тесты `tests/test_public_requests.py` (обязательное согласие). |
|
||||||
| SEC-12 | P1 | к разработке | Ужесточение CORS/CSP для prod | Разделить dev/prod CORS, ограничить `script-src` и убрать внешние источники без необходимости. | Конфиг профилей + тесты/проверка заголовков. |
|
| SEC-12 | P1 | сделано | Ужесточение CORS/CSP для prod | Разделить dev/prod CORS, ограничить `script-src` и убрать внешние источники без необходимости. | Реализовано: явные CORS-параметры `CORS_ALLOW_METHODS/CORS_ALLOW_HEADERS/CORS_ALLOW_CREDENTIALS`, прод-валидация `CORS_ORIGINS` (без `*`, `localhost`, `http`) и запрет wildcard для headers/methods, явный `script-src/style-src/connect-src` в backend CSP; тесты `tests/test_security_config.py` + `tests/test_http_hardening.py`. |
|
||||||
| SEC-13 | P2 | к разработке | Комплект ИСПДн-документов (техчасть) | Подготовить техблок: модель угроз, матрица контролей, границы ИСПДн, ответственные роли. | Папка `docs/security/` с шаблонами и заполненными draft. |
|
| SEC-13 | P2 | сделано | Комплект ИСПДн-документов (техчасть) | Подготовить техблок: модель угроз, матрица контролей, границы ИСПДн, ответственные роли. | Реализовано: `docs/security/ispdn_boundary.md`, `docs/security/threat_model.md`, `docs/security/control_matrix.md`, `docs/security/roles_and_responsibilities.md`, индекс `docs/security/README.md`. |
|
||||||
| SEC-14 | P2 | к разработке | Контроль уязвимостей в CI | Добавить SAST/dep-scan и базовый container scan в pipeline. | CI job + пороги fail + отчёт в артефактах. |
|
| SEC-14 | P2 | сделано | Контроль уязвимостей в CI | Добавить SAST/dep-scan и базовый container scan в pipeline. | Реализовано: GitHub Actions workflow `.github/workflows/security-ci.yml` (bandit + pip-audit + trivy), пороги `BANDIT_MAX_HIGH/DEP_MAX_VULNS/TRIVY_MAX_HIGH/TRIVY_MAX_CRITICAL`, отчеты загружаются в artifacts и SARIF (`trivy-image.sarif`) публикуется в Security tab. |
|
||||||
| SEC-15 | P2 | к разработке | Регулярный security smoke | Набор cron-проверок: cookie flags, TLS, headers, доступность audit/scan сервисов. | `scripts/ops/security_smoke.sh` + запись в runbook. |
|
| SEC-15 | P2 | сделано | Регулярный security smoke | Набор cron-проверок: cookie flags, TLS, headers, доступность audit/scan сервисов. | Реализовано: `scripts/ops/security_smoke.sh` + `make security-smoke`, markdown-отчет `reports/security/security-smoke-<timestamp>.md`, инструкция cron в `README.md`. |
|
||||||
|
|
||||||
## Последовательность внедрения
|
## Последовательность внедрения
|
||||||
1. `SEC-01` → `SEC-05` (закрытие P0 в коде/конфиге).
|
1. `SEC-01` → `SEC-05` (закрытие P0 в коде/конфиге).
|
||||||
|
|
@ -70,5 +70,9 @@
|
||||||
- Указан rollback шаг.
|
- Указан rollback шаг.
|
||||||
|
|
||||||
## Статус исполнения
|
## Статус исполнения
|
||||||
- `SEC-01` … `SEC-15`: `к разработке`.
|
- `SEC-01`, `SEC-02`, `SEC-03`, `SEC-04`, `SEC-07`, `SEC-08`, `SEC-10`, `SEC-11`: `сделано`.
|
||||||
- После выполнения переводить поштучно в `сделано` с датой и ссылкой на commit/PR.
|
- `SEC-12`: `сделано`.
|
||||||
|
- `SEC-13`: `сделано`.
|
||||||
|
- `SEC-14`: `сделано`.
|
||||||
|
- `SEC-15`: `сделано`.
|
||||||
|
- Все пункты `SEC-01..SEC-15` закрыты.
|
||||||
|
|
|
||||||
76
context/17_pdn_incident_response_runbook.md
Normal file
76
context/17_pdn_incident_response_runbook.md
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
# Runbook: Incident Response по ПДн
|
||||||
|
|
||||||
|
Дата: 02.03.2026
|
||||||
|
Статус: `активен`
|
||||||
|
|
||||||
|
## Назначение
|
||||||
|
Документ задает минимальный operational-процесс реагирования на инциденты, связанные с персональными данными (ПДн):
|
||||||
|
- подозрение на несанкционированный доступ;
|
||||||
|
- утечка сообщений/файлов/карточек заявок;
|
||||||
|
- массовые неудачные обращения к защищенным объектам;
|
||||||
|
- компрометация учетных данных/секретов.
|
||||||
|
|
||||||
|
## Роли
|
||||||
|
- `Incident Lead` — координация и принятие решений.
|
||||||
|
- `Security Engineer` — анализ логов, локализация, сбор evidence.
|
||||||
|
- `Platform Engineer` — технические изменения (блокировки, ротация, релиз фиксов).
|
||||||
|
- `Business/Legal Owner` — решения по внешним уведомлениям и коммуникациям.
|
||||||
|
|
||||||
|
## SLA эскалации
|
||||||
|
- `CRITICAL`: старт реакции <= 15 минут.
|
||||||
|
- `HIGH`: <= 30 минут.
|
||||||
|
- `MEDIUM`: <= 2 часа.
|
||||||
|
- `LOW`: <= 1 рабочий день.
|
||||||
|
|
||||||
|
## Алгоритм
|
||||||
|
1. Регистрация инцидента.
|
||||||
|
- Запустить: `./scripts/ops/incident_checklist.sh`.
|
||||||
|
- Зафиксировать severity/category/summary/request_id/track.
|
||||||
|
|
||||||
|
2. Локализация.
|
||||||
|
- Выгрузить последние события из `security_audit_log` и `audit_log`.
|
||||||
|
- Ограничить доступ (RBAC deny, временная блокировка операций).
|
||||||
|
- При необходимости отключить внешние интеграции.
|
||||||
|
|
||||||
|
3. Сдерживание.
|
||||||
|
- Ротация критичных секретов (JWT, INTERNAL_SERVICE_TOKEN, внешние API ключи).
|
||||||
|
- Включение усиленного мониторинга логов и алертов.
|
||||||
|
|
||||||
|
4. Восстановление.
|
||||||
|
- Проверка целостности данных.
|
||||||
|
- Проверка health сервисов.
|
||||||
|
- Прогон smoke/autotest набора.
|
||||||
|
|
||||||
|
5. Postmortem.
|
||||||
|
- Корневая причина, окно компрометации, затронутые данные.
|
||||||
|
- План корректирующих действий и сроков.
|
||||||
|
- Обновление security backlog и runbook.
|
||||||
|
|
||||||
|
## Технические команды
|
||||||
|
Проверка health:
|
||||||
|
```bash
|
||||||
|
curl -fsS http://localhost:8081/health
|
||||||
|
curl -fsS http://localhost:8081/chat-health
|
||||||
|
curl -fsS http://localhost:8081/email-health
|
||||||
|
```
|
||||||
|
|
||||||
|
Security audit:
|
||||||
|
```bash
|
||||||
|
docker compose exec -T db psql -U postgres -d legal -c "select created_at, actor_role, actor_subject, actor_ip, action, scope, allowed from security_audit_log order by created_at desc limit 200;"
|
||||||
|
```
|
||||||
|
|
||||||
|
CRUD audit:
|
||||||
|
```bash
|
||||||
|
docker compose exec -T db psql -U postgres -d legal -c "select created_at, entity, entity_id, action, responsible from audit_log order by created_at desc limit 200;"
|
||||||
|
```
|
||||||
|
|
||||||
|
Снимок логов:
|
||||||
|
```bash
|
||||||
|
docker compose logs --since 2h backend chat-service worker beat edge > reports/incidents/logs-$(date -u +%Y%m%d-%H%M%S).txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
- Для каждого инцидента есть markdown-отчет в `reports/incidents/`.
|
||||||
|
- Есть evidence: SQL выгрузки аудита + архив логов.
|
||||||
|
- Выполнены шаги локализации и восстановления.
|
||||||
|
- Зафиксирован postmortem и follow-up задачи.
|
||||||
59
context/18_encryption_key_rotation_runbook.md
Normal file
59
context/18_encryption_key_rotation_runbook.md
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
# Runbook: Ротация ключей шифрования (KID)
|
||||||
|
|
||||||
|
Дата: 02.03.2026
|
||||||
|
Статус: `активен`
|
||||||
|
|
||||||
|
## Цель
|
||||||
|
Безопасная ротация ключей шифрования для:
|
||||||
|
- реквизитов счетов (`invoices.payer_details_encrypted`),
|
||||||
|
- TOTP-секретов администраторов (`admin_users.totp_secret_encrypted`),
|
||||||
|
- сообщений чата (`messages.body`).
|
||||||
|
|
||||||
|
## Формат ключей
|
||||||
|
- `DATA_ENCRYPTION_ACTIVE_KID=<kid>`
|
||||||
|
- `DATA_ENCRYPTION_KEYS=<kid1>=<secret1>,<kid2>=<secret2>`
|
||||||
|
- `CHAT_ENCRYPTION_ACTIVE_KID=<kid>`
|
||||||
|
- `CHAT_ENCRYPTION_KEYS=<kid1>=<secret1>,<kid2>=<secret2>`
|
||||||
|
|
||||||
|
Примечание: старые ключи удалять только после полной перешифровки и верификации.
|
||||||
|
|
||||||
|
## Порядок ротации
|
||||||
|
1. Добавить новый KID в `.env` и переключить активный KID:
|
||||||
|
```bash
|
||||||
|
make rotate-encryption-kid
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Перезапустить сервисы с новым env:
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build backend chat-service worker beat
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Выполнить dry-run перешифровки:
|
||||||
|
```bash
|
||||||
|
docker compose exec -T backend python -m app.scripts.reencrypt_with_active_kid
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Выполнить apply-перешифровку:
|
||||||
|
```bash
|
||||||
|
docker compose exec -T backend python -m app.scripts.reencrypt_with_active_kid --apply
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Проверить регрессию и health:
|
||||||
|
```bash
|
||||||
|
docker compose exec -T backend python -m unittest tests.test_crypto_kid_rotation tests.test_invoices tests.test_public_cabinet -v
|
||||||
|
docker compose ps
|
||||||
|
curl -fsS http://localhost:8081/health
|
||||||
|
curl -fsS http://localhost:8081/chat-health
|
||||||
|
```
|
||||||
|
|
||||||
|
6. После периода наблюдения удалить старый KID из `*_ENCRYPTION_KEYS`.
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
- Вернуть предыдущий `.env` (где активен старый KID),
|
||||||
|
- перезапустить `backend/chat-service/worker/beat`,
|
||||||
|
- при необходимости повторно выполнить перешифровку под старый KID.
|
||||||
|
|
||||||
|
## Критерии завершения
|
||||||
|
- Новые записи шифруются с новым KID.
|
||||||
|
- Исторические записи успешно читаются и перешифрованы.
|
||||||
|
- Автотесты и health-check зелёные.
|
||||||
|
|
@ -1,13 +1,71 @@
|
||||||
|
limit_req_zone $binary_remote_addr zone=public_otp_send:10m rate=6r/m;
|
||||||
|
limit_req_zone $binary_remote_addr zone=public_otp_verify:10m rate=24r/m;
|
||||||
|
limit_req_zone $binary_remote_addr zone=public_request_create:10m rate=10r/m;
|
||||||
|
limit_req_zone $binary_remote_addr zone=public_upload_init:10m rate=20r/m;
|
||||||
|
limit_conn_zone $binary_remote_addr zone=public_per_ip_conn:10m;
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name ruakb.ru www.ruakb.ru ruakb.online www.ruakb.online;
|
server_name ruakb.ru www.ruakb.ru ruakb.online www.ruakb.online;
|
||||||
server_tokens off;
|
server_tokens off;
|
||||||
|
client_max_body_size 30m;
|
||||||
|
client_body_timeout 15s;
|
||||||
|
keepalive_timeout 20s;
|
||||||
|
send_timeout 20s;
|
||||||
|
limit_req_status 429;
|
||||||
|
limit_conn public_per_ip_conn 50;
|
||||||
|
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-Frame-Options "DENY" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=()" always;
|
||||||
|
|
||||||
location /.well-known/acme-challenge/ {
|
location /.well-known/acme-challenge/ {
|
||||||
root /var/www/certbot;
|
root /var/www/certbot;
|
||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location = /api/public/otp/send {
|
||||||
|
limit_req zone=public_otp_send burst=8 nodelay;
|
||||||
|
proxy_pass http://frontend:80;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /api/public/otp/verify {
|
||||||
|
limit_req zone=public_otp_verify burst=20 nodelay;
|
||||||
|
proxy_pass http://frontend:80;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /api/public/requests {
|
||||||
|
limit_req zone=public_request_create burst=10 nodelay;
|
||||||
|
proxy_pass http://frontend:80;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /api/public/uploads/init {
|
||||||
|
limit_req zone=public_upload_init burst=20 nodelay;
|
||||||
|
proxy_pass http://frontend:80;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://frontend:80;
|
proxy_pass http://frontend:80;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,9 @@
|
||||||
|
limit_req_zone $binary_remote_addr zone=public_otp_send:10m rate=6r/m;
|
||||||
|
limit_req_zone $binary_remote_addr zone=public_otp_verify:10m rate=24r/m;
|
||||||
|
limit_req_zone $binary_remote_addr zone=public_request_create:10m rate=10r/m;
|
||||||
|
limit_req_zone $binary_remote_addr zone=public_upload_init:10m rate=20r/m;
|
||||||
|
limit_conn_zone $binary_remote_addr zone=public_per_ip_conn:10m;
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name ruakb.ru www.ruakb.ru ruakb.online www.ruakb.online;
|
server_name ruakb.ru www.ruakb.ru ruakb.online www.ruakb.online;
|
||||||
|
|
@ -18,6 +24,12 @@ server {
|
||||||
http2 on;
|
http2 on;
|
||||||
server_name ruakb.ru www.ruakb.ru ruakb.online www.ruakb.online;
|
server_name ruakb.ru www.ruakb.ru ruakb.online www.ruakb.online;
|
||||||
server_tokens off;
|
server_tokens off;
|
||||||
|
client_max_body_size 30m;
|
||||||
|
client_body_timeout 15s;
|
||||||
|
keepalive_timeout 20s;
|
||||||
|
send_timeout 20s;
|
||||||
|
limit_req_status 429;
|
||||||
|
limit_conn public_per_ip_conn 50;
|
||||||
|
|
||||||
ssl_certificate /etc/letsencrypt/live/ruakb.ru/fullchain.pem;
|
ssl_certificate /etc/letsencrypt/live/ruakb.ru/fullchain.pem;
|
||||||
ssl_certificate_key /etc/letsencrypt/live/ruakb.ru/privkey.pem;
|
ssl_certificate_key /etc/letsencrypt/live/ruakb.ru/privkey.pem;
|
||||||
|
|
@ -31,6 +43,48 @@ server {
|
||||||
add_header X-Content-Type-Options "nosniff" always;
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
add_header X-Frame-Options "DENY" always;
|
add_header X-Frame-Options "DENY" always;
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=()" always;
|
||||||
|
|
||||||
|
location = /api/public/otp/send {
|
||||||
|
limit_req zone=public_otp_send burst=8 nodelay;
|
||||||
|
proxy_pass http://frontend:80;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /api/public/otp/verify {
|
||||||
|
limit_req zone=public_otp_verify burst=20 nodelay;
|
||||||
|
proxy_pass http://frontend:80;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /api/public/requests {
|
||||||
|
limit_req zone=public_request_create burst=10 nodelay;
|
||||||
|
proxy_pass http://frontend:80;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /api/public/uploads/init {
|
||||||
|
limit_req zone=public_upload_init burst=20 nodelay;
|
||||||
|
proxy_pass http://frontend:80;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://frontend:80;
|
proxy_pass http://frontend:80;
|
||||||
|
|
|
||||||
0
deploy/tls/minio/.gitkeep
Normal file
0
deploy/tls/minio/.gitkeep
Normal file
|
|
@ -20,3 +20,7 @@ services:
|
||||||
ports:
|
ports:
|
||||||
- "9000:9000"
|
- "9000:9000"
|
||||||
- "9001:9001"
|
- "9001:9001"
|
||||||
|
|
||||||
|
# Local/dev: use multi-arch image (works on Apple Silicon/ARM64).
|
||||||
|
clamav:
|
||||||
|
image: mkodockx/docker-clamav:alpine
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,13 @@ services:
|
||||||
image: nginx:1.27-alpine
|
image: nginx:1.27-alpine
|
||||||
container_name: law-edge
|
container_name: law-edge
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
read_only: true
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
tmpfs:
|
||||||
|
- /var/cache/nginx
|
||||||
|
- /var/run
|
||||||
|
- /tmp
|
||||||
depends_on:
|
depends_on:
|
||||||
frontend:
|
frontend:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
@ -24,9 +31,23 @@ services:
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
ports: []
|
ports: []
|
||||||
|
read_only: true
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
volumes:
|
||||||
|
- ./frontend/nginx.prod.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
|
- ./deploy/tls/minio/ca.crt:/etc/nginx/minio-ca.crt:ro
|
||||||
|
tmpfs:
|
||||||
|
- /var/cache/nginx
|
||||||
|
- /var/run
|
||||||
|
- /tmp
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
ports: []
|
ports: []
|
||||||
|
volumes:
|
||||||
|
- ./deploy/tls/minio/ca.crt:/etc/ssl/minio/ca.crt:ro
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
|
||||||
db:
|
db:
|
||||||
ports: []
|
ports: []
|
||||||
|
|
@ -36,6 +57,34 @@ services:
|
||||||
|
|
||||||
minio:
|
minio:
|
||||||
ports: []
|
ports: []
|
||||||
|
volumes:
|
||||||
|
- miniodata:/data
|
||||||
|
- ./deploy/tls/minio:/root/.minio/certs:ro
|
||||||
|
|
||||||
|
chat-service:
|
||||||
|
volumes: []
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
|
||||||
|
email-service:
|
||||||
|
volumes: []
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
|
||||||
|
worker:
|
||||||
|
volumes:
|
||||||
|
- ./deploy/tls/minio/ca.crt:/etc/ssl/minio/ca.crt:ro
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
|
||||||
|
beat:
|
||||||
|
volumes: []
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
|
||||||
|
# Production: keep official ClamAV image on x86_64 hosts.
|
||||||
|
clamav:
|
||||||
|
platform: linux/amd64
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
letsencrypt:
|
letsencrypt:
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,11 @@ services:
|
||||||
image: caddy:2.8.4-alpine
|
image: caddy:2.8.4-alpine
|
||||||
container_name: law-edge
|
container_name: law-edge
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
read_only: true
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp
|
||||||
depends_on:
|
depends_on:
|
||||||
frontend:
|
frontend:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
@ -16,9 +21,23 @@ services:
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
ports: []
|
ports: []
|
||||||
|
read_only: true
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
volumes:
|
||||||
|
- ./frontend/nginx.prod.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
|
- ./deploy/tls/minio/ca.crt:/etc/nginx/minio-ca.crt:ro
|
||||||
|
tmpfs:
|
||||||
|
- /var/cache/nginx
|
||||||
|
- /var/run
|
||||||
|
- /tmp
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
ports: []
|
ports: []
|
||||||
|
volumes:
|
||||||
|
- ./deploy/tls/minio/ca.crt:/etc/ssl/minio/ca.crt:ro
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
|
||||||
db:
|
db:
|
||||||
ports: []
|
ports: []
|
||||||
|
|
@ -28,6 +47,34 @@ services:
|
||||||
|
|
||||||
minio:
|
minio:
|
||||||
ports: []
|
ports: []
|
||||||
|
volumes:
|
||||||
|
- miniodata:/data
|
||||||
|
- ./deploy/tls/minio:/root/.minio/certs:ro
|
||||||
|
|
||||||
|
chat-service:
|
||||||
|
volumes: []
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
|
||||||
|
email-service:
|
||||||
|
volumes: []
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
|
||||||
|
worker:
|
||||||
|
volumes:
|
||||||
|
- ./deploy/tls/minio/ca.crt:/etc/ssl/minio/ca.crt:ro
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
|
||||||
|
beat:
|
||||||
|
volumes: []
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
|
||||||
|
# Production: keep official ClamAV image on x86_64 hosts.
|
||||||
|
clamav:
|
||||||
|
platform: linux/amd64
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
caddy_data:
|
caddy_data:
|
||||||
|
|
|
||||||
|
|
@ -155,8 +155,8 @@ services:
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command: server /data --console-address ":9001"
|
command: server /data --console-address ":9001"
|
||||||
environment:
|
environment:
|
||||||
MINIO_ROOT_USER: minioadmin
|
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minio_local_admin}
|
||||||
MINIO_ROOT_PASSWORD: minioadmin
|
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minio_local_password_change_me}
|
||||||
volumes: ["miniodata:/data"]
|
volumes: ["miniodata:/data"]
|
||||||
|
|
||||||
clamav:
|
clamav:
|
||||||
|
|
|
||||||
14
docs/security/README.md
Normal file
14
docs/security/README.md
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
# Security Docs (ISPDn Draft)
|
||||||
|
|
||||||
|
Технический комплект черновиков по ИСПДн для платформы `Legal Case Tracker`.
|
||||||
|
|
||||||
|
Состав:
|
||||||
|
- `ispdn_boundary.md` — границы ИСПДн, состав ПДн, контуры и потоки.
|
||||||
|
- `threat_model.md` — модель угроз (активы, нарушители, сценарии, меры).
|
||||||
|
- `control_matrix.md` — матрица контролей (требование -> мера -> артефакт проверки).
|
||||||
|
- `roles_and_responsibilities.md` — роли и зоны ответственности по ИБ/ПДн.
|
||||||
|
|
||||||
|
Назначение:
|
||||||
|
- использовать как baseline для внутренней приемки безопасности;
|
||||||
|
- дополнять в процессе внешнего аудита и внедрения орг-мер;
|
||||||
|
- связывать с runbook и test runbook проекта.
|
||||||
18
docs/security/control_matrix.md
Normal file
18
docs/security/control_matrix.md
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
# Матрица контролей ИБ/ПДн (Draft)
|
||||||
|
|
||||||
|
| Область | Требование/риск | Техническая мера | Где реализовано | Как проверять |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Идентификация/доступ | Несанкционированный доступ к ПДн | JWT + OTP/TOTP, RBAC, ownership checks | `app/api/public/*`, `app/api/admin/*`, `app/core/deps.py` | Unit/e2e role-flow, негативные сценарии доступа |
|
||||||
|
| Сетевая защита | Перехват трафика | TLS edge (80/443), internal TLS к MinIO | `deploy/nginx/*`, `frontend/nginx.prod.conf`, compose prod | `curl -I https://...`, проверка cert chain, health checks |
|
||||||
|
| Конфигурация prod | Небезопасные дефолты | startup-prod валидация security settings | `app/core/config.py` | `tests/test_security_config.py` |
|
||||||
|
| CORS/CSP | XSS/CORS misconfig | Явные CORS методы/headers; CSP `script-src/style-src/connect-src 'self'` | `app/main.py`, `app/chat_main.py`, `app/core/http_hardening.py` | `tests/test_security_config.py`, `tests/test_http_hardening.py` |
|
||||||
|
| Защита данных at-rest | Утечка реквизитов/чатов | Шифрование + KID keyring + rotation | `app/services/chat_crypto.py`, `app/services/invoice_crypto.py` | `tests/test_crypto_kid_rotation.py`, reencrypt smoke |
|
||||||
|
| Вложения | Загрузка вредоносных файлов | ClamAV + MIME/content policy + quarantine statuses | `app/workers/tasks/security.py`, upload APIs | `tests/test_attachment_scan.py`, `tests/test_uploads_s3.py` |
|
||||||
|
| Аудит | Отсутствие следов доступа к ПДн | `security_audit_log` + read/download events | `app/services/security_audit.py` | `tests/test_security_audit.py`, SQL выборки |
|
||||||
|
| Retention | Избыточное хранение ПДн | Политики хранения + Celery cleanup | `app/models/data_retention_policy.py`, `cleanup_pii_retention` | `tests/test_worker_maintenance.py` |
|
||||||
|
| Инциденты | Неуправляемая реакция | Runbook + incident checklist generator | `context/17_pdn_incident_response_runbook.md`, `scripts/ops/incident_checklist.sh` | `make incident-checklist` |
|
||||||
|
| Секреты | Компрометация ключей | Ротация внутренних секретов + KID rotation | `scripts/ops/rotate_prod_secrets.sh`, `rotate_encryption_kid.sh` | dry-run + deploy smoke |
|
||||||
|
|
||||||
|
## Примечания
|
||||||
|
- Матрица покрывает технический контур. Оргмеры (регламенты, обучение, приказы, журнал СКЗИ) ведутся отдельно.
|
||||||
|
- Для задач `SEC-14` и `SEC-15` после реализации добавить отдельные строки в эту таблицу.
|
||||||
48
docs/security/ispdn_boundary.md
Normal file
48
docs/security/ispdn_boundary.md
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
# Границы ИСПДн (Draft)
|
||||||
|
|
||||||
|
## 1. Назначение системы
|
||||||
|
Платформа обрабатывает обращения клиентов по юридическим вопросам, ведет чат клиент-юрист, хранит вложения и финансовые документы (счета), а также служебные данные администрирования.
|
||||||
|
|
||||||
|
## 2. Состав обрабатываемых ПДн
|
||||||
|
- Идентификационные данные клиента: ФИО, телефон, email (при email auth).
|
||||||
|
- Данные юристов/админов: ФИО/имя, email, телефон, аватар.
|
||||||
|
- Данные обращения: описание проблемы, статусная история, сообщения чата.
|
||||||
|
- Вложения клиента/юриста: pdf/jpg/png/mp4/txt (могут содержать ПДн и спецкатегории).
|
||||||
|
- Финансовые реквизиты в счетах (хранятся в зашифрованном виде).
|
||||||
|
|
||||||
|
## 3. Основные компоненты контура
|
||||||
|
- `frontend` (nginx + статический UI).
|
||||||
|
- `edge` (prod nginx 80/443 + TLS termination).
|
||||||
|
- `backend` (FastAPI, публичный и admin API).
|
||||||
|
- `chat-service` (выделенный FastAPI-контур чата).
|
||||||
|
- `email-service` (внутренний сервис отправки email OTP).
|
||||||
|
- `worker/beat` (Celery-фоновые задачи).
|
||||||
|
- `db` (Postgres).
|
||||||
|
- `redis` (очереди/кэш).
|
||||||
|
- `minio` (S3-совместимое хранилище файлов, internal TLS).
|
||||||
|
|
||||||
|
## 4. Границы доверия
|
||||||
|
- Внешняя граница: интернет -> `edge`.
|
||||||
|
- Внутренний сервисный контур: docker network (backend/chat/worker/email/db/redis/minio).
|
||||||
|
- Граница хранения ПДн: Postgres + MinIO.
|
||||||
|
- Граница админского доступа: JWT admin auth + RBAC.
|
||||||
|
|
||||||
|
## 5. Критичные потоки данных
|
||||||
|
1. Клиент создает заявку -> OTP проверка -> запись в `requests/clients`.
|
||||||
|
2. Клиент и юрист общаются в чате -> сообщения хранятся шифрованно.
|
||||||
|
3. Загрузка файла -> антивирус/контент-проверка -> выдача только `CLEAN`.
|
||||||
|
4. Работа со счетами -> реквизиты шифруются перед сохранением.
|
||||||
|
5. Аудит доступа -> `security_audit_log`/`audit_log`.
|
||||||
|
|
||||||
|
## 6. Текущие технические меры
|
||||||
|
- RBAC по ролям `ADMIN`/`LAWYER`/публичный клиентский контур.
|
||||||
|
- HTTPS на edge, internal TLS для MinIO.
|
||||||
|
- Шифрование сообщений/реквизитов at-rest (KID keyring + ротация).
|
||||||
|
- Security audit операций чтения/скачивания ПДн.
|
||||||
|
- Retention cleanup (Celery) для чувствительных данных.
|
||||||
|
- Согласие на обработку ПДн в клиентском потоке.
|
||||||
|
|
||||||
|
## 7. Что вне текущего контура (gap)
|
||||||
|
- Формализованный контур SIEM/централизованный immutable log storage.
|
||||||
|
- Сертифицированные СКЗИ/КриптоПровайдер по отдельным классам ИСПДн.
|
||||||
|
- Полная оргдокументация по 152-ФЗ/ПП1119/ФСТЭК-21 (регламенты, приказы, журналы).
|
||||||
51
docs/security/roles_and_responsibilities.md
Normal file
51
docs/security/roles_and_responsibilities.md
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
# Роли и ответственность по безопасности (Draft)
|
||||||
|
|
||||||
|
## 1. Роли
|
||||||
|
- Владелец платформы (бизнес-роль).
|
||||||
|
- Администратор системы (оператор ИСПДн).
|
||||||
|
- Юрист (пользователь бизнес-контура).
|
||||||
|
- Разработчик (backend/frontend).
|
||||||
|
- DevOps/инфраструктура.
|
||||||
|
- QA (функциональные и security-регрессы).
|
||||||
|
|
||||||
|
## 2. Зоны ответственности
|
||||||
|
|
||||||
|
### Владелец платформы
|
||||||
|
- Утверждает политику обработки ПДн и уровень приемлемого риска.
|
||||||
|
- Принимает решения по эскалации инцидентов и коммуникации.
|
||||||
|
|
||||||
|
### Администратор системы
|
||||||
|
- Управляет пользователями/ролями/доступами.
|
||||||
|
- Контролирует события в уведомлениях и аудитах.
|
||||||
|
- Выполняет операционные регламенты (ротация, инциденты, backup/restore по процедурам).
|
||||||
|
|
||||||
|
### Юрист
|
||||||
|
- Обрабатывает только назначенные/доступные по роли заявки.
|
||||||
|
- Не передает ПДн вне утвержденных каналов платформы.
|
||||||
|
- Следует требованиям по работе с файлами и сообщениями.
|
||||||
|
|
||||||
|
### Разработчик
|
||||||
|
- Реализует secure-by-default в коде и конфигурации.
|
||||||
|
- Обеспечивает тестовое покрытие security-кейсов.
|
||||||
|
- Устраняет уязвимости в приоритетах P0/P1/P2.
|
||||||
|
|
||||||
|
### DevOps
|
||||||
|
- Поддерживает защищенный prod-контур (TLS, secrets, network policies).
|
||||||
|
- Организует безопасный деплой, ротацию секретов и наблюдаемость.
|
||||||
|
- Контролирует исправность backup и recovery процессов.
|
||||||
|
|
||||||
|
### QA
|
||||||
|
- Поддерживает runbook и e2e/security матрицу.
|
||||||
|
- Проверяет role-based ограничения и негативные сценарии.
|
||||||
|
- Фиксирует регрессы и воспроизводимость инцидентных кейсов.
|
||||||
|
|
||||||
|
## 3. Минимальные SLA по security-операциям
|
||||||
|
- P0 инцидент: реакция <= 30 минут, containment <= 4 часа.
|
||||||
|
- Ротация критичных секретов после компрометации: немедленно, завершение <= 24 часа.
|
||||||
|
- Исправление подтвержденной P0 уязвимости: hotfix <= 24 часа.
|
||||||
|
|
||||||
|
## 4. Артефакты контроля
|
||||||
|
- `context/11_test_runbook.md` — журнал тестовых прогонов.
|
||||||
|
- `context/17_pdn_incident_response_runbook.md` — действия при инциденте.
|
||||||
|
- `context/18_encryption_key_rotation_runbook.md` — ротация ключей шифрования.
|
||||||
|
- `docs/security/*` — технические документы ИСПДн.
|
||||||
43
docs/security/threat_model.md
Normal file
43
docs/security/threat_model.md
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
# Модель угроз (Draft)
|
||||||
|
|
||||||
|
## 1. Активы
|
||||||
|
- ПДн клиентов, юристов и администраторов.
|
||||||
|
- Вложения дел (документы, медиа).
|
||||||
|
- Секреты инфраструктуры (JWT, SMTP/SMS, S3, DB, internal tokens).
|
||||||
|
- История коммуникаций и статусные события.
|
||||||
|
- Финансовые данные (счета, реквизиты, суммы).
|
||||||
|
|
||||||
|
## 2. Потенциальные нарушители
|
||||||
|
- Внешний злоумышленник (web/API атаки, credential stuffing, brute force).
|
||||||
|
- Внутренний нарушитель с легитимным доступом (превышение прав).
|
||||||
|
- Компрометация интеграций (SMS/email/telegram/s3 credentials leak).
|
||||||
|
- Supply-chain риск зависимостей/контейнеров.
|
||||||
|
|
||||||
|
## 3. Базовые сценарии угроз
|
||||||
|
1. Несанкционированный доступ к карточкам/чатам/файлам.
|
||||||
|
2. Подмена или утечка заявок при отправке с лендинга.
|
||||||
|
3. Массовый подбор OTP/токенов, фиксация сессии.
|
||||||
|
4. Загрузка вредоносных файлов и последующее распространение.
|
||||||
|
5. Компрометация S3/DB секретов и выгрузка ПДн.
|
||||||
|
6. Подмена межсервисных вызовов (chat/email internal API).
|
||||||
|
7. Изменение/удаление следов в журналах аудита.
|
||||||
|
|
||||||
|
## 4. Реализованные технические контрмеры
|
||||||
|
- Строгая prod-валидация конфигурации безопасности (`APP_ENV=prod`).
|
||||||
|
- CORS/CSP hardening для prod, secure cookie policy.
|
||||||
|
- JWT + OTP/TOTP auth с rate limits.
|
||||||
|
- RBAC и проверка ownership на API-уровне.
|
||||||
|
- Шифрование критичных полей at-rest + KID-rotation.
|
||||||
|
- Антивирусная и content-проверка вложений.
|
||||||
|
- Security audit журнал для операций чтения/скачивания ПДн.
|
||||||
|
- Retention + cleanup для чувствительных сущностей.
|
||||||
|
|
||||||
|
## 5. Остаточные риски
|
||||||
|
- Неполное покрытие CI-сканированием уязвимостей.
|
||||||
|
- Нет вынесенного централизованного хранилища security logs (WORM).
|
||||||
|
- Ограниченный набор автоматических security smoke-проверок.
|
||||||
|
|
||||||
|
## 6. Приоритет закрытия остаточных рисков
|
||||||
|
- `SEC-14`: SAST/dep/container scan в CI.
|
||||||
|
- `SEC-15`: регулярный security smoke + cron.
|
||||||
|
- Дальше: SIEM/WORM-архив логов, формализация орг-контролей.
|
||||||
|
|
@ -5,13 +5,18 @@ COPY app/web/admin.jsx ./admin.jsx
|
||||||
COPY app/web/client ./client
|
COPY app/web/client ./client
|
||||||
COPY app/web/client.jsx ./client.jsx
|
COPY app/web/client.jsx ./client.jsx
|
||||||
RUN npm init -y >/dev/null 2>&1 \
|
RUN npm init -y >/dev/null 2>&1 \
|
||||||
&& npm install --silent esbuild@0.25.10 \
|
&& npm install --silent esbuild@0.25.10 react@18.2.0 react-dom@18.2.0 \
|
||||||
&& npx esbuild admin/index.jsx --bundle --loader:.jsx=jsx --format=iife --target=es2018 --outfile=admin.js \
|
&& npx esbuild admin/index.jsx --bundle --loader:.jsx=jsx --format=iife --target=es2018 --outfile=admin.js \
|
||||||
&& npx esbuild client/index.jsx --bundle --loader:.jsx=jsx --format=iife --target=es2018 --outfile=client.js
|
&& npx esbuild client/index.jsx --bundle --loader:.jsx=jsx --format=iife --target=es2018 --outfile=client.js \
|
||||||
|
&& mkdir -p vendor \
|
||||||
|
&& cp node_modules/react/umd/react.production.min.js vendor/react.production.min.js \
|
||||||
|
&& cp node_modules/react-dom/umd/react-dom.production.min.js vendor/react-dom.production.min.js
|
||||||
|
|
||||||
FROM nginx:1.27-alpine
|
FROM nginx:1.27-alpine
|
||||||
COPY frontend/nginx.conf /etc/nginx/conf.d/default.conf
|
COPY frontend/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
COPY app/web/ /usr/share/nginx/html/
|
COPY app/web/ /usr/share/nginx/html/
|
||||||
COPY --from=frontend-build /build/admin.js /usr/share/nginx/html/admin.js
|
COPY --from=frontend-build /build/admin.js /usr/share/nginx/html/admin.js
|
||||||
COPY --from=frontend-build /build/client.js /usr/share/nginx/html/client.js
|
COPY --from=frontend-build /build/client.js /usr/share/nginx/html/client.js
|
||||||
|
COPY --from=frontend-build /build/vendor/react.production.min.js /usr/share/nginx/html/vendor/react.production.min.js
|
||||||
|
COPY --from=frontend-build /build/vendor/react-dom.production.min.js /usr/share/nginx/html/vendor/react-dom.production.min.js
|
||||||
RUN cp /usr/share/nginx/html/landing.html /usr/share/nginx/html/index.html
|
RUN cp /usr/share/nginx/html/landing.html /usr/share/nginx/html/index.html
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ server {
|
||||||
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
||||||
add_header Cross-Origin-Embedder-Policy "credentialless" always;
|
add_header Cross-Origin-Embedder-Policy "credentialless" always;
|
||||||
add_header Cross-Origin-Resource-Policy "same-origin" always;
|
add_header Cross-Origin-Resource-Policy "same-origin" always;
|
||||||
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; frame-src 'self' blob:; font-src 'self' data:; style-src 'self'; script-src 'self' https://unpkg.com; connect-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
|
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; frame-src 'self' blob:; font-src 'self' data:; style-src 'self'; script-src 'self'; connect-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
|
||||||
expires 10m;
|
expires 10m;
|
||||||
return 302 /admin.html;
|
return 302 /admin.html;
|
||||||
}
|
}
|
||||||
|
|
@ -28,7 +28,7 @@ server {
|
||||||
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
||||||
add_header Cross-Origin-Embedder-Policy "credentialless" always;
|
add_header Cross-Origin-Embedder-Policy "credentialless" always;
|
||||||
add_header Cross-Origin-Resource-Policy "same-origin" always;
|
add_header Cross-Origin-Resource-Policy "same-origin" always;
|
||||||
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; frame-src 'self' blob:; font-src 'self' data:; style-src 'self'; script-src 'self' https://unpkg.com; connect-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
|
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; frame-src 'self' blob:; font-src 'self' data:; style-src 'self'; script-src 'self'; connect-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
|
||||||
expires 10m;
|
expires 10m;
|
||||||
return 302 /admin.html;
|
return 302 /admin.html;
|
||||||
}
|
}
|
||||||
|
|
@ -41,7 +41,7 @@ server {
|
||||||
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
||||||
add_header Cross-Origin-Embedder-Policy "credentialless" always;
|
add_header Cross-Origin-Embedder-Policy "credentialless" always;
|
||||||
add_header Cross-Origin-Resource-Policy "same-origin" always;
|
add_header Cross-Origin-Resource-Policy "same-origin" always;
|
||||||
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; frame-src 'self' blob:; font-src 'self' data:; style-src 'self'; script-src 'self' https://unpkg.com; connect-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
|
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; frame-src 'self' blob:; font-src 'self' data:; style-src 'self'; script-src 'self'; connect-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
|
||||||
expires 10m;
|
expires 10m;
|
||||||
default_type application/javascript;
|
default_type application/javascript;
|
||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
|
|
@ -55,7 +55,7 @@ server {
|
||||||
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
||||||
add_header Cross-Origin-Embedder-Policy "credentialless" always;
|
add_header Cross-Origin-Embedder-Policy "credentialless" always;
|
||||||
add_header Cross-Origin-Resource-Policy "same-origin" always;
|
add_header Cross-Origin-Resource-Policy "same-origin" always;
|
||||||
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; frame-src 'self' blob:; font-src 'self' data:; style-src 'self'; script-src 'self' https://unpkg.com; connect-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
|
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; frame-src 'self' blob:; font-src 'self' data:; style-src 'self'; script-src 'self'; connect-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
|
||||||
expires 10m;
|
expires 10m;
|
||||||
try_files $uri /index.html;
|
try_files $uri /index.html;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
121
frontend/nginx.prod.conf
Normal file
121
frontend/nginx.prod.conf
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
server_tokens off;
|
||||||
|
absolute_redirect off;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location = /admin {
|
||||||
|
add_header X-Frame-Options "DENY" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header Referrer-Policy "no-referrer" always;
|
||||||
|
add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=()" always;
|
||||||
|
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
||||||
|
add_header Cross-Origin-Embedder-Policy "credentialless" always;
|
||||||
|
add_header Cross-Origin-Resource-Policy "same-origin" always;
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; frame-src 'self' blob:; font-src 'self' data:; style-src 'self'; script-src 'self'; connect-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
|
||||||
|
expires 10m;
|
||||||
|
return 302 /admin.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /admin-panel.html {
|
||||||
|
add_header X-Frame-Options "DENY" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header Referrer-Policy "no-referrer" always;
|
||||||
|
add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=()" always;
|
||||||
|
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
||||||
|
add_header Cross-Origin-Embedder-Policy "credentialless" always;
|
||||||
|
add_header Cross-Origin-Resource-Policy "same-origin" always;
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; frame-src 'self' blob:; font-src 'self' data:; style-src 'self'; script-src 'self'; connect-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
|
||||||
|
expires 10m;
|
||||||
|
return 302 /admin.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~* \.jsx$ {
|
||||||
|
add_header X-Frame-Options "DENY" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header Referrer-Policy "no-referrer" always;
|
||||||
|
add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=()" always;
|
||||||
|
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
||||||
|
add_header Cross-Origin-Embedder-Policy "credentialless" always;
|
||||||
|
add_header Cross-Origin-Resource-Policy "same-origin" always;
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; frame-src 'self' blob:; font-src 'self' data:; style-src 'self'; script-src 'self'; connect-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
|
||||||
|
expires 10m;
|
||||||
|
default_type application/javascript;
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
add_header X-Frame-Options "DENY" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header Referrer-Policy "no-referrer" always;
|
||||||
|
add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=()" always;
|
||||||
|
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
||||||
|
add_header Cross-Origin-Embedder-Policy "credentialless" always;
|
||||||
|
add_header Cross-Origin-Resource-Policy "same-origin" always;
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; frame-src 'self' blob:; font-src 'self' data:; style-src 'self'; script-src 'self'; connect-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
|
||||||
|
expires 10m;
|
||||||
|
try_files $uri /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/public/chat/ {
|
||||||
|
proxy_pass http://chat-service:8001/api/public/chat/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/admin/chat/ {
|
||||||
|
proxy_pass http://chat-service:8001/api/admin/chat/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend:8000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /s3/ {
|
||||||
|
proxy_pass https://minio:9000/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host minio:9000;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_ssl_server_name on;
|
||||||
|
proxy_ssl_name minio;
|
||||||
|
proxy_ssl_trusted_certificate /etc/nginx/minio-ca.crt;
|
||||||
|
proxy_ssl_verify on;
|
||||||
|
proxy_ssl_verify_depth 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /health {
|
||||||
|
proxy_pass http://backend:8000/health;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /chat-health {
|
||||||
|
proxy_pass http://chat-service:8001/health;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /email-health {
|
||||||
|
proxy_pass http://email-service:8010/health;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,168 @@ if [[ ! -f .env ]]; then
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
read_env_var() {
|
||||||
|
local key="$1"
|
||||||
|
local value
|
||||||
|
value="$(grep -E "^${key}=" .env | tail -n1 | cut -d= -f2- || true)"
|
||||||
|
echo "$value"
|
||||||
|
}
|
||||||
|
|
||||||
|
is_truthy() {
|
||||||
|
local value="${1:-}"
|
||||||
|
[[ "$value" == "true" || "$value" == "1" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
is_insecure_secret() {
|
||||||
|
local value="${1:-}"
|
||||||
|
local lowered
|
||||||
|
lowered="$(echo "$value" | tr '[:upper:]' '[:lower:]')"
|
||||||
|
if [[ -z "$value" || "${#value}" -lt 24 ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [[ "$lowered" == *"change_me"* || "$lowered" == *"admin123"* || "$lowered" == *"password"* || "$lowered" == *"example"* ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
contains_localhost_origin() {
|
||||||
|
local csv="${1:-}"
|
||||||
|
local normalized
|
||||||
|
normalized="$(echo "$csv" | tr '[:upper:]' '[:lower:]')"
|
||||||
|
[[ "$normalized" == *"localhost"* || "$normalized" == *"127.0.0.1"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
fail_if_insecure_env() {
|
||||||
|
local app_env otp_dev bootstrap cookie_secure s3_ssl s3_verify s3_endpoint s3_ca_path strict_origin
|
||||||
|
local chat_secret minio_user minio_password
|
||||||
|
local minio_tls_enabled
|
||||||
|
local admin_jwt public_jwt data_secret data_kid data_keys chat_kid chat_keys internal_token
|
||||||
|
local public_allowed cors_origins admin_auth_mode
|
||||||
|
app_env="$(read_env_var APP_ENV)"
|
||||||
|
otp_dev="$(read_env_var OTP_DEV_MODE)"
|
||||||
|
bootstrap="$(read_env_var ADMIN_BOOTSTRAP_ENABLED)"
|
||||||
|
cookie_secure="$(read_env_var PUBLIC_COOKIE_SECURE)"
|
||||||
|
s3_ssl="$(read_env_var S3_USE_SSL)"
|
||||||
|
s3_verify="$(read_env_var S3_VERIFY_SSL)"
|
||||||
|
s3_endpoint="$(read_env_var S3_ENDPOINT)"
|
||||||
|
s3_ca_path="$(read_env_var S3_CA_CERT_PATH)"
|
||||||
|
strict_origin="$(read_env_var PUBLIC_STRICT_ORIGIN_CHECK)"
|
||||||
|
public_allowed="$(read_env_var PUBLIC_ALLOWED_WEB_ORIGINS)"
|
||||||
|
cors_origins="$(read_env_var CORS_ORIGINS)"
|
||||||
|
admin_auth_mode="$(read_env_var ADMIN_AUTH_MODE)"
|
||||||
|
chat_secret="$(read_env_var CHAT_ENCRYPTION_SECRET)"
|
||||||
|
admin_jwt="$(read_env_var ADMIN_JWT_SECRET)"
|
||||||
|
public_jwt="$(read_env_var PUBLIC_JWT_SECRET)"
|
||||||
|
data_secret="$(read_env_var DATA_ENCRYPTION_SECRET)"
|
||||||
|
data_kid="$(read_env_var DATA_ENCRYPTION_ACTIVE_KID)"
|
||||||
|
data_keys="$(read_env_var DATA_ENCRYPTION_KEYS)"
|
||||||
|
chat_kid="$(read_env_var CHAT_ENCRYPTION_ACTIVE_KID)"
|
||||||
|
chat_keys="$(read_env_var CHAT_ENCRYPTION_KEYS)"
|
||||||
|
internal_token="$(read_env_var INTERNAL_SERVICE_TOKEN)"
|
||||||
|
minio_user="$(read_env_var MINIO_ROOT_USER)"
|
||||||
|
minio_password="$(read_env_var MINIO_ROOT_PASSWORD)"
|
||||||
|
minio_tls_enabled="$(read_env_var MINIO_TLS_ENABLED)"
|
||||||
|
|
||||||
|
if [[ "$app_env" != "prod" && "$app_env" != "production" ]]; then
|
||||||
|
echo "[WARN] APP_ENV is '$app_env' (expected: prod)"
|
||||||
|
fi
|
||||||
|
if is_truthy "$otp_dev"; then
|
||||||
|
echo "[ERROR] OTP_DEV_MODE must be false for production"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if is_truthy "$bootstrap"; then
|
||||||
|
echo "[ERROR] ADMIN_BOOTSTRAP_ENABLED must be false for production"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! is_truthy "$cookie_secure"; then
|
||||||
|
echo "[ERROR] PUBLIC_COOKIE_SECURE must be true for production"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! is_truthy "$s3_ssl"; then
|
||||||
|
echo "[ERROR] S3_USE_SSL must be true for production"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! is_truthy "$s3_verify"; then
|
||||||
|
echo "[ERROR] S3_VERIFY_SSL must be true for production"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ "${s3_endpoint,,}" != https://* ]]; then
|
||||||
|
echo "[ERROR] S3_ENDPOINT must start with https:// in production"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ -z "$s3_ca_path" ]]; then
|
||||||
|
echo "[ERROR] S3_CA_CERT_PATH must be configured for trusted internal TLS"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! is_truthy "$minio_tls_enabled"; then
|
||||||
|
echo "[ERROR] MINIO_TLS_ENABLED must be true for production"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! is_truthy "$strict_origin"; then
|
||||||
|
echo "[ERROR] PUBLIC_STRICT_ORIGIN_CHECK must be true for production"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if contains_localhost_origin "$public_allowed"; then
|
||||||
|
echo "[ERROR] PUBLIC_ALLOWED_WEB_ORIGINS must not include localhost/127.0.0.1 in production"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if contains_localhost_origin "$cors_origins"; then
|
||||||
|
echo "[ERROR] CORS_ORIGINS must not include localhost/127.0.0.1 in production"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ "$admin_auth_mode" != "password_totp_required" ]]; then
|
||||||
|
echo "[ERROR] ADMIN_AUTH_MODE must be password_totp_required in production"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if is_insecure_secret "$chat_secret"; then
|
||||||
|
echo "[ERROR] CHAT_ENCRYPTION_SECRET must be configured and non-default"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if is_insecure_secret "$admin_jwt"; then
|
||||||
|
echo "[ERROR] ADMIN_JWT_SECRET must be configured and strong"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if is_insecure_secret "$public_jwt"; then
|
||||||
|
echo "[ERROR] PUBLIC_JWT_SECRET must be configured and strong"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if is_insecure_secret "$data_secret"; then
|
||||||
|
echo "[ERROR] DATA_ENCRYPTION_SECRET must be configured and strong"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ -z "$data_kid" ]]; then
|
||||||
|
echo "[ERROR] DATA_ENCRYPTION_ACTIVE_KID must be set"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ -n "$data_keys" && "$data_keys" != *"${data_kid}="* ]]; then
|
||||||
|
echo "[ERROR] DATA_ENCRYPTION_KEYS must contain active kid (${data_kid}=...)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ -n "$chat_kid" && -n "$chat_keys" && "$chat_keys" != *"${chat_kid}="* ]]; then
|
||||||
|
echo "[ERROR] CHAT_ENCRYPTION_KEYS must contain active kid (${chat_kid}=...)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if is_insecure_secret "$internal_token"; then
|
||||||
|
echo "[ERROR] INTERNAL_SERVICE_TOKEN must be configured and strong"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ -z "$minio_user" || "$minio_user" == "minioadmin" || "$minio_user" == "minio_local_admin" ]]; then
|
||||||
|
echo "[ERROR] MINIO_ROOT_USER must be set to non-default value"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if is_insecure_secret "$minio_password" || [[ "$minio_password" == "minioadmin" ]]; then
|
||||||
|
echo "[ERROR] MINIO_ROOT_PASSWORD must be set to non-default value"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ ! -f "deploy/tls/minio/public.crt" || ! -f "deploy/tls/minio/private.key" || ! -f "deploy/tls/minio/ca.crt" ]]; then
|
||||||
|
echo "[ERROR] MinIO TLS cert bundle is missing. Run: ./scripts/ops/minio_tls_bootstrap.sh"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
fail_if_insecure_env
|
||||||
|
|
||||||
echo "[1/4] Build and start production stack..."
|
echo "[1/4] Build and start production stack..."
|
||||||
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
|
||||||
|
|
||||||
|
|
|
||||||
140
scripts/ops/incident_checklist.sh
Executable file
140
scripts/ops/incident_checklist.sh
Executable file
|
|
@ -0,0 +1,140 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
SEVERITY="MEDIUM"
|
||||||
|
CATEGORY="PDN_SUSPECTED"
|
||||||
|
SUMMARY=""
|
||||||
|
REQUEST_ID=""
|
||||||
|
TRACK_NUMBER=""
|
||||||
|
REPORTER=""
|
||||||
|
OUTPUT_FILE=""
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<USAGE
|
||||||
|
Usage:
|
||||||
|
scripts/ops/incident_checklist.sh [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--severity <LOW|MEDIUM|HIGH|CRITICAL>
|
||||||
|
--category <PDN_LEAK|UNAUTHORIZED_ACCESS|MALWARE_UPLOAD|SERVICE_COMPROMISE|PDN_SUSPECTED>
|
||||||
|
--summary <text>
|
||||||
|
--request-id <uuid>
|
||||||
|
--track-number <trk>
|
||||||
|
--reporter <name/email>
|
||||||
|
--output <path> Explicit output markdown file path
|
||||||
|
-h, --help
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
scripts/ops/incident_checklist.sh --severity HIGH --category UNAUTHORIZED_ACCESS --summary "Suspicious request card reads"
|
||||||
|
USAGE
|
||||||
|
}
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--severity) SEVERITY="${2:-}"; shift 2 ;;
|
||||||
|
--category) CATEGORY="${2:-}"; shift 2 ;;
|
||||||
|
--summary) SUMMARY="${2:-}"; shift 2 ;;
|
||||||
|
--request-id) REQUEST_ID="${2:-}"; shift 2 ;;
|
||||||
|
--track-number) TRACK_NUMBER="${2:-}"; shift 2 ;;
|
||||||
|
--reporter) REPORTER="${2:-}"; shift 2 ;;
|
||||||
|
--output) OUTPUT_FILE="${2:-}"; shift 2 ;;
|
||||||
|
-h|--help) usage; exit 0 ;;
|
||||||
|
*) echo "[ERROR] Unknown argument: $1" >&2; usage; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
case "$(echo "$SEVERITY" | tr '[:lower:]' '[:upper:]')" in
|
||||||
|
LOW|MEDIUM|HIGH|CRITICAL) ;;
|
||||||
|
*) echo "[ERROR] Invalid severity: $SEVERITY" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
SEVERITY="$(echo "$SEVERITY" | tr '[:lower:]' '[:upper:]')"
|
||||||
|
|
||||||
|
if [[ -z "$SUMMARY" ]]; then
|
||||||
|
SUMMARY="Initial triage started via incident checklist"
|
||||||
|
fi
|
||||||
|
|
||||||
|
TS_UTC="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
|
||||||
|
TS_FILE="$(date -u +"%Y%m%d-%H%M%S")"
|
||||||
|
HOSTNAME_VALUE="$(hostname)"
|
||||||
|
|
||||||
|
if [[ -z "$OUTPUT_FILE" ]]; then
|
||||||
|
mkdir -p reports/incidents
|
||||||
|
OUTPUT_FILE="reports/incidents/incident-${TS_FILE}.md"
|
||||||
|
else
|
||||||
|
mkdir -p "$(dirname "$OUTPUT_FILE")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
BACKEND_HEALTH="unknown"
|
||||||
|
CHAT_HEALTH="unknown"
|
||||||
|
EMAIL_HEALTH="unknown"
|
||||||
|
|
||||||
|
if curl -fsS http://localhost:8081/health >/dev/null 2>&1; then BACKEND_HEALTH="ok"; else BACKEND_HEALTH="failed"; fi
|
||||||
|
if curl -fsS http://localhost:8081/chat-health >/dev/null 2>&1; then CHAT_HEALTH="ok"; else CHAT_HEALTH="failed"; fi
|
||||||
|
if curl -fsS http://localhost:8081/email-health >/dev/null 2>&1; then EMAIL_HEALTH="ok"; else EMAIL_HEALTH="failed"; fi
|
||||||
|
|
||||||
|
cat > "$OUTPUT_FILE" <<REPORT
|
||||||
|
# Инцидент ПДн: первичный чек-лист
|
||||||
|
|
||||||
|
- Дата/время (UTC): ${TS_UTC}
|
||||||
|
- Хост: ${HOSTNAME_VALUE}
|
||||||
|
- Уровень: ${SEVERITY}
|
||||||
|
- Категория: ${CATEGORY}
|
||||||
|
- Инициатор: ${REPORTER:-не указан}
|
||||||
|
- Request ID: ${REQUEST_ID:-не указан}
|
||||||
|
- Track number: ${TRACK_NUMBER:-не указан}
|
||||||
|
- Краткое описание: ${SUMMARY}
|
||||||
|
|
||||||
|
## 1. Немедленные действия (0-15 минут)
|
||||||
|
- [ ] Назначен ответственный за инцидент
|
||||||
|
- [ ] Заморожены потенциально компрометированные учетные данные
|
||||||
|
- [ ] Включен усиленный сбор логов и запрет на удаление логов
|
||||||
|
- [ ] Зафиксировано текущее состояние сервисов и времени обнаружения
|
||||||
|
|
||||||
|
## 2. Техническая локализация
|
||||||
|
- [ ] Проверены подозрительные события в таблице 'security_audit_log' (READ/UPLOAD/DOWNLOAD)
|
||||||
|
- [ ] Проверены события CRUD в таблице 'audit_log'
|
||||||
|
- [ ] Проверены запросы к заявкам/чатам/счетам по IP/actor_subject
|
||||||
|
- [ ] Ограничен доступ к затронутым данным (временный deny/rotate/rbac tighten)
|
||||||
|
|
||||||
|
## 3. Коммуникации и эскалация
|
||||||
|
- [ ] Уведомлен владелец системы и ответственный по ИБ/ПДн
|
||||||
|
- [ ] Определен объем затронутых ПДн и категорий субъектов
|
||||||
|
- [ ] Принято решение о юридических уведомлениях (внутренний контур)
|
||||||
|
|
||||||
|
## 4. Восстановление
|
||||||
|
- [ ] Выполнена ротация секретов/ключей (JWT, INTERNAL_SERVICE_TOKEN, внешние API)
|
||||||
|
- [ ] Проверена целостность данных и корректность бизнес-процессов
|
||||||
|
- [ ] Выполнен пост-инцидентный тест smoke + регрессионные проверки
|
||||||
|
|
||||||
|
## 5. Артефакты и evidence
|
||||||
|
- [ ] Сохранены логи сервисов (docker compose logs --since ...)
|
||||||
|
- [ ] Сохранены выгрузки из security_audit_log и audit_log
|
||||||
|
- [ ] Зафиксированы hash-суммы ключевых файлов evidence
|
||||||
|
|
||||||
|
## 6. Текущие health-checks
|
||||||
|
- backend: ${BACKEND_HEALTH}
|
||||||
|
- chat-service: ${CHAT_HEALTH}
|
||||||
|
- email-service: ${EMAIL_HEALTH}
|
||||||
|
|
||||||
|
## 7. Команды для первичного анализа
|
||||||
|
|
||||||
|
a. Просмотр последних security-событий:
|
||||||
|
~~~bash
|
||||||
|
docker compose exec -T db psql -U postgres -d legal -c "select created_at, actor_role, actor_subject, actor_ip, action, scope, allowed from security_audit_log order by created_at desc limit 100;"
|
||||||
|
~~~
|
||||||
|
|
||||||
|
b. Просмотр последних CRUD-аудитов:
|
||||||
|
~~~bash
|
||||||
|
docker compose exec -T db psql -U postgres -d legal -c "select created_at, entity, entity_id, action, responsible from audit_log order by created_at desc limit 100;"
|
||||||
|
~~~
|
||||||
|
|
||||||
|
c. Снимок логов контейнеров:
|
||||||
|
~~~bash
|
||||||
|
docker compose logs --since 2h backend chat-service worker beat edge > reports/incidents/logs-${TS_FILE}.txt
|
||||||
|
~~~
|
||||||
|
REPORT
|
||||||
|
|
||||||
|
echo "[OK] Incident checklist created: $OUTPUT_FILE"
|
||||||
82
scripts/ops/minio_tls_bootstrap.sh
Executable file
82
scripts/ops/minio_tls_bootstrap.sh
Executable file
|
|
@ -0,0 +1,82 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
OUT_DIR="${1:-$ROOT_DIR/deploy/tls/minio}"
|
||||||
|
CA_CN="${MINIO_TLS_CA_CN:-Law Internal MinIO CA}"
|
||||||
|
SERVER_CN="${MINIO_TLS_SERVER_CN:-minio}"
|
||||||
|
VALID_DAYS="${MINIO_TLS_VALID_DAYS:-825}"
|
||||||
|
OVERWRITE="${MINIO_TLS_OVERWRITE:-false}"
|
||||||
|
|
||||||
|
mkdir -p "$OUT_DIR"
|
||||||
|
|
||||||
|
if [[ "$OVERWRITE" != "true" ]]; then
|
||||||
|
for required in ca.crt ca.key public.crt private.key; do
|
||||||
|
if [[ -f "$OUT_DIR/$required" ]]; then
|
||||||
|
echo "[ERROR] $OUT_DIR/$required already exists. Set MINIO_TLS_OVERWRITE=true to regenerate." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v openssl >/dev/null 2>&1; then
|
||||||
|
echo "[ERROR] openssl not found" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
tmp_dir="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$tmp_dir"' EXIT
|
||||||
|
|
||||||
|
cat > "$tmp_dir/server.cnf" <<CFG
|
||||||
|
[req]
|
||||||
|
default_bits = 4096
|
||||||
|
prompt = no
|
||||||
|
default_md = sha256
|
||||||
|
distinguished_name = dn
|
||||||
|
req_extensions = req_ext
|
||||||
|
|
||||||
|
[dn]
|
||||||
|
CN = ${SERVER_CN}
|
||||||
|
|
||||||
|
[req_ext]
|
||||||
|
subjectAltName = @alt_names
|
||||||
|
|
||||||
|
[alt_names]
|
||||||
|
DNS.1 = minio
|
||||||
|
DNS.2 = law-minio
|
||||||
|
DNS.3 = localhost
|
||||||
|
IP.1 = 127.0.0.1
|
||||||
|
CFG
|
||||||
|
|
||||||
|
echo "[1/4] Generating internal CA..."
|
||||||
|
openssl genrsa -out "$OUT_DIR/ca.key" 4096 >/dev/null 2>&1
|
||||||
|
openssl req -x509 -new -nodes -key "$OUT_DIR/ca.key" -sha256 -days 3650 \
|
||||||
|
-out "$OUT_DIR/ca.crt" -subj "/CN=${CA_CN}" >/dev/null 2>&1
|
||||||
|
|
||||||
|
echo "[2/4] Generating MinIO server key + CSR..."
|
||||||
|
openssl genrsa -out "$OUT_DIR/private.key" 4096 >/dev/null 2>&1
|
||||||
|
openssl req -new -key "$OUT_DIR/private.key" -out "$tmp_dir/server.csr" -config "$tmp_dir/server.cnf" >/dev/null 2>&1
|
||||||
|
|
||||||
|
echo "[3/4] Signing MinIO server certificate with internal CA..."
|
||||||
|
openssl x509 -req -in "$tmp_dir/server.csr" \
|
||||||
|
-CA "$OUT_DIR/ca.crt" -CAkey "$OUT_DIR/ca.key" -CAcreateserial \
|
||||||
|
-out "$tmp_dir/server.crt" -days "$VALID_DAYS" -sha256 \
|
||||||
|
-extensions req_ext -extfile "$tmp_dir/server.cnf" >/dev/null 2>&1
|
||||||
|
|
||||||
|
cat "$tmp_dir/server.crt" "$OUT_DIR/ca.crt" > "$OUT_DIR/public.crt"
|
||||||
|
|
||||||
|
chmod 600 "$OUT_DIR/ca.key" "$OUT_DIR/private.key"
|
||||||
|
chmod 644 "$OUT_DIR/ca.crt" "$OUT_DIR/public.crt"
|
||||||
|
|
||||||
|
echo "[4/4] Done. Generated files:"
|
||||||
|
echo " - $OUT_DIR/ca.crt"
|
||||||
|
echo " - $OUT_DIR/ca.key"
|
||||||
|
echo " - $OUT_DIR/public.crt"
|
||||||
|
echo " - $OUT_DIR/private.key"
|
||||||
|
echo
|
||||||
|
echo "Use in production .env:"
|
||||||
|
echo " MINIO_TLS_ENABLED=true"
|
||||||
|
echo " S3_ENDPOINT=https://minio:9000"
|
||||||
|
echo " S3_USE_SSL=true"
|
||||||
|
echo " S3_VERIFY_SSL=true"
|
||||||
|
echo " S3_CA_CERT_PATH=/etc/ssl/minio/ca.crt"
|
||||||
170
scripts/ops/prod_security_audit.sh
Executable file
170
scripts/ops/prod_security_audit.sh
Executable file
|
|
@ -0,0 +1,170 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
DOMAIN="${DOMAIN:-ruakb.ru}"
|
||||||
|
WWW_DOMAIN="${WWW_DOMAIN:-www.ruakb.ru}"
|
||||||
|
SECOND_DOMAIN="${SECOND_DOMAIN:-ruakb.online}"
|
||||||
|
SECOND_WWW_DOMAIN="${SECOND_WWW_DOMAIN:-www.ruakb.online}"
|
||||||
|
LETSENCRYPT_EMAIL="${LETSENCRYPT_EMAIL:-admin@ruakb.ru}"
|
||||||
|
AUTO_CERT_INIT="${AUTO_CERT_INIT:-0}"
|
||||||
|
|
||||||
|
PROD_COMPOSE=(docker compose -f docker-compose.yml -f docker-compose.prod.nginx.yml)
|
||||||
|
CERT_COMPOSE=(docker compose -f docker-compose.yml -f docker-compose.prod.nginx.yml -f docker-compose.prod.cert.yml)
|
||||||
|
|
||||||
|
log() {
|
||||||
|
echo "[SEC-AUDIT] $*"
|
||||||
|
}
|
||||||
|
|
||||||
|
warn() {
|
||||||
|
echo "[SEC-AUDIT][WARN] $*" >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "[SEC-AUDIT][ERROR] $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
file_missing() {
|
||||||
|
[[ ! -f "$1" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_env_file() {
|
||||||
|
if [[ -f ".env" ]]; then
|
||||||
|
log ".env found"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -f ".env.prod" ]]; then
|
||||||
|
cp .env.prod .env
|
||||||
|
chmod 600 .env
|
||||||
|
log ".env was missing -> restored from .env.prod"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -f ".env.production" ]]; then
|
||||||
|
log ".env/.env.prod missing -> generating .env.prod from .env.production"
|
||||||
|
./scripts/ops/rotate_prod_secrets.sh --env-in .env.production --env-out .env.prod
|
||||||
|
cp .env.prod .env
|
||||||
|
chmod 600 .env
|
||||||
|
log ".env created from generated .env.prod"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
fail "Cannot build .env automatically: missing both .env.prod and .env.production"
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_minio_tls_bundle() {
|
||||||
|
if file_missing "deploy/tls/minio/public.crt" || file_missing "deploy/tls/minio/private.key" || file_missing "deploy/tls/minio/ca.crt"; then
|
||||||
|
log "MinIO TLS bundle is missing -> generating"
|
||||||
|
./scripts/ops/minio_tls_bootstrap.sh
|
||||||
|
else
|
||||||
|
log "MinIO TLS bundle present"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_compose_files() {
|
||||||
|
file_missing "docker-compose.prod.nginx.yml" && fail "Missing docker-compose.prod.nginx.yml"
|
||||||
|
file_missing "docker-compose.prod.cert.yml" && fail "Missing docker-compose.prod.cert.yml"
|
||||||
|
file_missing "frontend/nginx.prod.conf" && fail "Missing frontend/nginx.prod.conf"
|
||||||
|
file_missing "deploy/nginx/edge-http-only.conf" && fail "Missing deploy/nginx/edge-http-only.conf"
|
||||||
|
file_missing "deploy/nginx/edge-https.conf" && fail "Missing deploy/nginx/edge-https.conf"
|
||||||
|
log "Compose/nginx files present"
|
||||||
|
}
|
||||||
|
|
||||||
|
stack_up_and_migrate() {
|
||||||
|
log "Starting production stack (nginx profile)"
|
||||||
|
"${PROD_COMPOSE[@]}" up -d --build --remove-orphans db redis minio backend chat-service email-service worker beat frontend edge clamav
|
||||||
|
|
||||||
|
log "Applying migrations"
|
||||||
|
"${PROD_COMPOSE[@]}" exec -T backend alembic upgrade head
|
||||||
|
}
|
||||||
|
|
||||||
|
run_security_preflight() {
|
||||||
|
log "Running production security preflight (app-level config validation)"
|
||||||
|
"${PROD_COMPOSE[@]}" run --rm --no-deps backend python - <<'PY'
|
||||||
|
from app.core.config import validate_production_security_or_raise
|
||||||
|
validate_production_security_or_raise("prod-security-audit")
|
||||||
|
print("production security config validation: ok")
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
run_local_smoke() {
|
||||||
|
log "Running local smoke checks via localhost"
|
||||||
|
./scripts/ops/check_chat_health.sh http://localhost >/dev/null
|
||||||
|
./scripts/ops/security_smoke.sh http://localhost >/dev/null
|
||||||
|
log "Local smoke checks passed"
|
||||||
|
}
|
||||||
|
|
||||||
|
https_health_ok() {
|
||||||
|
local url="$1"
|
||||||
|
local code
|
||||||
|
code="$(curl -k -sS -o /dev/null -w "%{http_code}" "${url%/}/health" || true)"
|
||||||
|
[[ "$code" == "200" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
cert_bootstrap() {
|
||||||
|
log "AUTO_CERT_INIT=1 and https health failed -> running cert bootstrap"
|
||||||
|
"${CERT_COMPOSE[@]}" up -d --build db redis minio backend chat-service email-service worker beat frontend edge
|
||||||
|
"${CERT_COMPOSE[@]}" run --rm certbot certonly --webroot -w /var/www/certbot \
|
||||||
|
--email "$LETSENCRYPT_EMAIL" --agree-tos --no-eff-email --non-interactive --expand \
|
||||||
|
-d "$DOMAIN" -d "$WWW_DOMAIN" -d "$SECOND_DOMAIN" -d "$SECOND_WWW_DOMAIN"
|
||||||
|
"${PROD_COMPOSE[@]}" up -d --build edge
|
||||||
|
}
|
||||||
|
|
||||||
|
run_domain_smoke() {
|
||||||
|
local domain="$1"
|
||||||
|
[[ -z "$domain" ]] && return 0
|
||||||
|
local url="https://${domain}"
|
||||||
|
|
||||||
|
if ! https_health_ok "$url"; then
|
||||||
|
if [[ "$AUTO_CERT_INIT" == "1" ]]; then
|
||||||
|
cert_bootstrap
|
||||||
|
https_health_ok "$url" || fail "HTTPS health still failing after cert bootstrap: ${url}/health"
|
||||||
|
else
|
||||||
|
fail "HTTPS health check failed: ${url}/health (set AUTO_CERT_INIT=1 to auto-bootstrap certs)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Running security smoke for $url"
|
||||||
|
./scripts/ops/security_smoke.sh "$url" >/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
run_incident_report() {
|
||||||
|
log "Generating incident checklist snapshot"
|
||||||
|
./scripts/ops/incident_checklist.sh \
|
||||||
|
--severity LOW \
|
||||||
|
--category MONITORING_ALERT \
|
||||||
|
--summary "Scheduled production security audit completed"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_summary() {
|
||||||
|
log "Collecting final status"
|
||||||
|
"${PROD_COMPOSE[@]}" ps
|
||||||
|
local latest_security_report
|
||||||
|
latest_security_report="$(ls -1t reports/security/security-smoke-*.md 2>/dev/null | head -n 1 || true)"
|
||||||
|
local latest_incident_report
|
||||||
|
latest_incident_report="$(ls -1t reports/incidents/incident-*.md 2>/dev/null | head -n 1 || true)"
|
||||||
|
[[ -n "$latest_security_report" ]] && log "Latest security smoke report: $latest_security_report"
|
||||||
|
[[ -n "$latest_incident_report" ]] && log "Latest incident checklist: $latest_incident_report"
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
ensure_compose_files
|
||||||
|
ensure_env_file
|
||||||
|
ensure_minio_tls_bundle
|
||||||
|
|
||||||
|
stack_up_and_migrate
|
||||||
|
run_security_preflight
|
||||||
|
run_local_smoke
|
||||||
|
run_domain_smoke "$DOMAIN"
|
||||||
|
run_domain_smoke "$SECOND_DOMAIN"
|
||||||
|
run_incident_report
|
||||||
|
print_summary
|
||||||
|
|
||||||
|
log "Production security audit completed successfully"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
157
scripts/ops/rotate_encryption_kid.sh
Executable file
157
scripts/ops/rotate_encryption_kid.sh
Executable file
|
|
@ -0,0 +1,157 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ENV_FILE=".env"
|
||||||
|
KID=""
|
||||||
|
DATA_SECRET=""
|
||||||
|
CHAT_SECRET=""
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<USAGE
|
||||||
|
Usage:
|
||||||
|
scripts/ops/rotate_encryption_kid.sh [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--env-file <path> Env file to update (default: .env)
|
||||||
|
--kid <kid> KID to activate (default: kYYYYMMDDHHMM)
|
||||||
|
--data-secret <value> DATA key secret (default: generated)
|
||||||
|
--chat-secret <value> CHAT key secret (default: same as data secret)
|
||||||
|
-h, --help
|
||||||
|
|
||||||
|
Result:
|
||||||
|
- Updates DATA_ENCRYPTION_KEYS / CHAT_ENCRYPTION_KEYS
|
||||||
|
- Sets DATA_ENCRYPTION_ACTIVE_KID / CHAT_ENCRYPTION_ACTIVE_KID
|
||||||
|
|
||||||
|
After updating env:
|
||||||
|
1) restart backend/chat/worker with new env
|
||||||
|
2) run re-encryption:
|
||||||
|
docker compose exec -T backend python -m app.scripts.reencrypt_with_active_kid --apply
|
||||||
|
USAGE
|
||||||
|
}
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--env-file)
|
||||||
|
ENV_FILE="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--kid)
|
||||||
|
KID="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--data-secret)
|
||||||
|
DATA_SECRET="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--chat-secret)
|
||||||
|
CHAT_SECRET="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "[ERROR] Unknown option: $1" >&2
|
||||||
|
usage
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ ! -f "$ENV_FILE" ]]; then
|
||||||
|
echo "[ERROR] Env file not found: $ENV_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v openssl >/dev/null 2>&1; then
|
||||||
|
echo "[ERROR] openssl not found" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$KID" ]]; then
|
||||||
|
KID="k$(date -u +%Y%m%d%H%M)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$DATA_SECRET" ]]; then
|
||||||
|
DATA_SECRET="$(openssl rand -base64 64 | tr -d '\n' | tr '+/' 'AZ' | tr -dc 'A-Za-z0-9' | cut -c1-64)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$CHAT_SECRET" ]]; then
|
||||||
|
CHAT_SECRET="$DATA_SECRET"
|
||||||
|
fi
|
||||||
|
|
||||||
|
read_env_var() {
|
||||||
|
local key="$1"
|
||||||
|
grep -E "^${key}=" "$ENV_FILE" | tail -n1 | cut -d= -f2- || true
|
||||||
|
}
|
||||||
|
|
||||||
|
upsert_env_var() {
|
||||||
|
local key="$1"
|
||||||
|
local value="$2"
|
||||||
|
local tmp
|
||||||
|
tmp="$(mktemp)"
|
||||||
|
awk -v k="$key" -v v="$value" '
|
||||||
|
BEGIN { done = 0 }
|
||||||
|
$0 ~ ("^" k "=") { print k "=" v; done = 1; next }
|
||||||
|
{ print }
|
||||||
|
END { if (!done) print k "=" v }
|
||||||
|
' "$ENV_FILE" > "$tmp"
|
||||||
|
mv "$tmp" "$ENV_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
merge_kid_secret() {
|
||||||
|
local csv="$1"
|
||||||
|
local kid="$2"
|
||||||
|
local secret="$3"
|
||||||
|
local out=""
|
||||||
|
local found="0"
|
||||||
|
IFS=',' read -r -a parts <<< "$csv"
|
||||||
|
for part in "${parts[@]}"; do
|
||||||
|
local token key value
|
||||||
|
token="$(echo "$part" | xargs)"
|
||||||
|
[[ -z "$token" ]] && continue
|
||||||
|
if [[ "$token" == *=* ]]; then
|
||||||
|
key="${token%%=*}"
|
||||||
|
value="${token#*=}"
|
||||||
|
key="$(echo "$key" | xargs)"
|
||||||
|
value="$(echo "$value" | xargs)"
|
||||||
|
if [[ "$key" == "$kid" ]]; then
|
||||||
|
token="${kid}=${secret}"
|
||||||
|
found="1"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [[ -n "$out" ]]; then
|
||||||
|
out+="${out:+,}${token}"
|
||||||
|
else
|
||||||
|
out="$token"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [[ "$found" != "1" ]]; then
|
||||||
|
if [[ -n "$out" ]]; then
|
||||||
|
out+=",${kid}=${secret}"
|
||||||
|
else
|
||||||
|
out="${kid}=${secret}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
echo "$out"
|
||||||
|
}
|
||||||
|
|
||||||
|
current_data_keys="$(read_env_var DATA_ENCRYPTION_KEYS)"
|
||||||
|
current_chat_keys="$(read_env_var CHAT_ENCRYPTION_KEYS)"
|
||||||
|
new_data_keys="$(merge_kid_secret "$current_data_keys" "$KID" "$DATA_SECRET")"
|
||||||
|
new_chat_keys="$(merge_kid_secret "$current_chat_keys" "$KID" "$CHAT_SECRET")"
|
||||||
|
|
||||||
|
upsert_env_var "DATA_ENCRYPTION_KEYS" "$new_data_keys"
|
||||||
|
upsert_env_var "CHAT_ENCRYPTION_KEYS" "$new_chat_keys"
|
||||||
|
upsert_env_var "DATA_ENCRYPTION_ACTIVE_KID" "$KID"
|
||||||
|
upsert_env_var "CHAT_ENCRYPTION_ACTIVE_KID" "$KID"
|
||||||
|
|
||||||
|
echo "[OK] Updated $ENV_FILE"
|
||||||
|
echo " DATA_ENCRYPTION_ACTIVE_KID=$KID"
|
||||||
|
echo " CHAT_ENCRYPTION_ACTIVE_KID=$KID"
|
||||||
|
echo
|
||||||
|
echo "Next steps:"
|
||||||
|
echo " 1) restart backend/chat/worker with updated env"
|
||||||
|
echo " 2) re-encrypt historical data:"
|
||||||
|
echo " docker compose exec -T backend python -m app.scripts.reencrypt_with_active_kid --apply"
|
||||||
282
scripts/ops/rotate_prod_secrets.sh
Executable file
282
scripts/ops/rotate_prod_secrets.sh
Executable file
|
|
@ -0,0 +1,282 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
ENV_IN=".env.production"
|
||||||
|
ENV_OUT=".env.prod"
|
||||||
|
APPLY_RUNNING=0
|
||||||
|
SKIP_DB_ROTATE=0
|
||||||
|
SKIP_RESTART=0
|
||||||
|
COMPOSE_OVERRIDE="docker-compose.prod.nginx.yml"
|
||||||
|
NON_INTERACTIVE=0
|
||||||
|
REQUIRED_CONFIRM_TOKEN="ROTATE-PROD-SECRETS"
|
||||||
|
CONFIRM_TOKEN_INPUT=""
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage:
|
||||||
|
scripts/ops/rotate_prod_secrets.sh [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--env-in <file> Source env template (default: .env.production)
|
||||||
|
--env-out <file> Output env file with rotated secrets (default: .env.prod)
|
||||||
|
--compose-override <file> Compose override for production apply (default: docker-compose.prod.nginx.yml)
|
||||||
|
--apply-running Apply generated env to running stack (.env replace + DB password rotate + recreate)
|
||||||
|
--non-interactive Disable prompt confirmation (requires valid --require-confirmation-token)
|
||||||
|
--require-confirmation-token <token>
|
||||||
|
Mandatory token for --apply-running. Expected: ROTATE-PROD-SECRETS
|
||||||
|
--skip-db-rotate With --apply-running: do not run ALTER USER in Postgres
|
||||||
|
--skip-restart With --apply-running: do not recreate stack / migrate / health checks
|
||||||
|
-h, --help Show help
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
scripts/ops/rotate_prod_secrets.sh
|
||||||
|
scripts/ops/rotate_prod_secrets.sh --apply-running
|
||||||
|
scripts/ops/rotate_prod_secrets.sh --env-in .env --env-out .env.prod --apply-running
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--env-in)
|
||||||
|
ENV_IN="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--env-out)
|
||||||
|
ENV_OUT="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--compose-override)
|
||||||
|
COMPOSE_OVERRIDE="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--apply-running)
|
||||||
|
APPLY_RUNNING=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--non-interactive)
|
||||||
|
NON_INTERACTIVE=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--require-confirmation-token)
|
||||||
|
CONFIRM_TOKEN_INPUT="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--skip-db-rotate)
|
||||||
|
SKIP_DB_ROTATE=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--skip-restart)
|
||||||
|
SKIP_RESTART=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "[ERROR] Unknown argument: $1" >&2
|
||||||
|
usage
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ ! -f "$ENV_IN" ]]; then
|
||||||
|
echo "[ERROR] Input env file not found: $ENV_IN" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v openssl >/dev/null 2>&1; then
|
||||||
|
echo "[ERROR] openssl not found (required for secret generation)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
COMPOSE_ARGS=(-f docker-compose.yml -f "$COMPOSE_OVERRIDE")
|
||||||
|
if [[ "$APPLY_RUNNING" -eq 1 && ! -f "$COMPOSE_OVERRIDE" ]]; then
|
||||||
|
echo "[ERROR] Compose override file not found: $COMPOSE_OVERRIDE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
rand_alnum() {
|
||||||
|
local length="${1:-64}"
|
||||||
|
local bytes=$(( (length + 1) / 2 ))
|
||||||
|
openssl rand -hex "$bytes" | cut -c1-"$length"
|
||||||
|
}
|
||||||
|
|
||||||
|
rand_secret() {
|
||||||
|
local length="${1:-64}"
|
||||||
|
local out
|
||||||
|
out="$(openssl rand -base64 "$length" | tr -d '\n' | tr '+/' 'AZ' | tr -dc 'A-Za-z0-9' | cut -c1-"$length")"
|
||||||
|
if [[ "${#out}" -lt "$length" ]]; then
|
||||||
|
out="${out}$(rand_alnum "$((length - ${#out}))")"
|
||||||
|
fi
|
||||||
|
echo "$out"
|
||||||
|
}
|
||||||
|
|
||||||
|
read_env_value() {
|
||||||
|
local key="$1"
|
||||||
|
local file="$2"
|
||||||
|
local value
|
||||||
|
value="$(grep -E "^${key}=" "$file" | tail -n1 | cut -d= -f2- || true)"
|
||||||
|
echo "$value"
|
||||||
|
}
|
||||||
|
|
||||||
|
upsert_env_value() {
|
||||||
|
local key="$1"
|
||||||
|
local value="$2"
|
||||||
|
local file="$3"
|
||||||
|
local tmp
|
||||||
|
tmp="$(mktemp)"
|
||||||
|
awk -v k="$key" -v v="$value" '
|
||||||
|
BEGIN { done = 0 }
|
||||||
|
$0 ~ ("^" k "=") { print k "=" v; done = 1; next }
|
||||||
|
{ print }
|
||||||
|
END {
|
||||||
|
if (!done) print k "=" v
|
||||||
|
}
|
||||||
|
' "$file" > "$tmp"
|
||||||
|
mv "$tmp" "$file"
|
||||||
|
}
|
||||||
|
|
||||||
|
db_url_with_password() {
|
||||||
|
local current_url="$1"
|
||||||
|
local user="$2"
|
||||||
|
local pass="$3"
|
||||||
|
local db_name="$4"
|
||||||
|
if [[ -n "$current_url" && "$current_url" =~ ^([^:]+://[^:]+:)[^@]*(@.*)$ ]]; then
|
||||||
|
echo "${BASH_REMATCH[1]}${pass}${BASH_REMATCH[2]}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "postgresql+psycopg://${user}:${pass}@db:5432/${db_name}"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "[1/5] Preparing output env file..."
|
||||||
|
cp "$ENV_IN" "$ENV_OUT"
|
||||||
|
chmod 600 "$ENV_OUT"
|
||||||
|
|
||||||
|
NEW_ADMIN_JWT_SECRET="$(rand_secret 64)"
|
||||||
|
NEW_PUBLIC_JWT_SECRET="$(rand_secret 64)"
|
||||||
|
NEW_DATA_ENCRYPTION_SECRET="$(rand_secret 64)"
|
||||||
|
NEW_CHAT_ENCRYPTION_SECRET="$(rand_secret 64)"
|
||||||
|
NEW_ENC_KID="k$(date -u +%Y%m%d%H%M)"
|
||||||
|
NEW_INTERNAL_SERVICE_TOKEN="$(rand_secret 64)"
|
||||||
|
NEW_POSTGRES_PASSWORD="$(rand_secret 40)"
|
||||||
|
NEW_MINIO_ROOT_USER="minio_$(rand_alnum 14 | tr '[:upper:]' '[:lower:]')"
|
||||||
|
NEW_MINIO_ROOT_PASSWORD="$(rand_secret 48)"
|
||||||
|
NEW_S3_ACCESS_KEY="$(rand_alnum 20)"
|
||||||
|
NEW_S3_SECRET_KEY="$(rand_secret 48)"
|
||||||
|
NEW_BOOTSTRAP_PASSWORD="$(rand_secret 32)"
|
||||||
|
|
||||||
|
POSTGRES_USER_VALUE="$(read_env_value "POSTGRES_USER" "$ENV_OUT")"
|
||||||
|
POSTGRES_DB_VALUE="$(read_env_value "POSTGRES_DB" "$ENV_OUT")"
|
||||||
|
DATABASE_URL_VALUE="$(read_env_value "DATABASE_URL" "$ENV_OUT")"
|
||||||
|
|
||||||
|
if [[ -z "$POSTGRES_USER_VALUE" ]]; then
|
||||||
|
POSTGRES_USER_VALUE="postgres"
|
||||||
|
fi
|
||||||
|
if [[ -z "$POSTGRES_DB_VALUE" ]]; then
|
||||||
|
POSTGRES_DB_VALUE="legal"
|
||||||
|
fi
|
||||||
|
|
||||||
|
NEW_DATABASE_URL="$(db_url_with_password "$DATABASE_URL_VALUE" "$POSTGRES_USER_VALUE" "$NEW_POSTGRES_PASSWORD" "$POSTGRES_DB_VALUE")"
|
||||||
|
|
||||||
|
echo "[2/5] Writing rotated internal secrets into $ENV_OUT..."
|
||||||
|
upsert_env_value "APP_ENV" "prod" "$ENV_OUT"
|
||||||
|
upsert_env_value "PRODUCTION_ENFORCE_SECURE_SETTINGS" "true" "$ENV_OUT"
|
||||||
|
upsert_env_value "OTP_DEV_MODE" "false" "$ENV_OUT"
|
||||||
|
upsert_env_value "ADMIN_BOOTSTRAP_ENABLED" "false" "$ENV_OUT"
|
||||||
|
upsert_env_value "PUBLIC_COOKIE_SECURE" "true" "$ENV_OUT"
|
||||||
|
upsert_env_value "S3_USE_SSL" "true" "$ENV_OUT"
|
||||||
|
upsert_env_value "S3_VERIFY_SSL" "true" "$ENV_OUT"
|
||||||
|
upsert_env_value "S3_CA_CERT_PATH" "/etc/ssl/minio/ca.crt" "$ENV_OUT"
|
||||||
|
upsert_env_value "MINIO_TLS_ENABLED" "true" "$ENV_OUT"
|
||||||
|
upsert_env_value "PUBLIC_STRICT_ORIGIN_CHECK" "true" "$ENV_OUT"
|
||||||
|
upsert_env_value "CORS_ALLOW_METHODS" "GET,POST,PUT,PATCH,DELETE,OPTIONS" "$ENV_OUT"
|
||||||
|
upsert_env_value "CORS_ALLOW_HEADERS" "Authorization,Content-Type,X-Requested-With,X-Request-ID" "$ENV_OUT"
|
||||||
|
upsert_env_value "CORS_ALLOW_CREDENTIALS" "true" "$ENV_OUT"
|
||||||
|
upsert_env_value "ADMIN_AUTH_MODE" "password_totp_required" "$ENV_OUT"
|
||||||
|
|
||||||
|
upsert_env_value "ADMIN_JWT_SECRET" "$NEW_ADMIN_JWT_SECRET" "$ENV_OUT"
|
||||||
|
upsert_env_value "PUBLIC_JWT_SECRET" "$NEW_PUBLIC_JWT_SECRET" "$ENV_OUT"
|
||||||
|
upsert_env_value "DATA_ENCRYPTION_SECRET" "$NEW_DATA_ENCRYPTION_SECRET" "$ENV_OUT"
|
||||||
|
upsert_env_value "CHAT_ENCRYPTION_SECRET" "$NEW_CHAT_ENCRYPTION_SECRET" "$ENV_OUT"
|
||||||
|
upsert_env_value "DATA_ENCRYPTION_ACTIVE_KID" "$NEW_ENC_KID" "$ENV_OUT"
|
||||||
|
upsert_env_value "CHAT_ENCRYPTION_ACTIVE_KID" "$NEW_ENC_KID" "$ENV_OUT"
|
||||||
|
upsert_env_value "DATA_ENCRYPTION_KEYS" "${NEW_ENC_KID}=${NEW_DATA_ENCRYPTION_SECRET}" "$ENV_OUT"
|
||||||
|
upsert_env_value "CHAT_ENCRYPTION_KEYS" "${NEW_ENC_KID}=${NEW_CHAT_ENCRYPTION_SECRET}" "$ENV_OUT"
|
||||||
|
upsert_env_value "INTERNAL_SERVICE_TOKEN" "$NEW_INTERNAL_SERVICE_TOKEN" "$ENV_OUT"
|
||||||
|
|
||||||
|
upsert_env_value "POSTGRES_PASSWORD" "$NEW_POSTGRES_PASSWORD" "$ENV_OUT"
|
||||||
|
upsert_env_value "DATABASE_URL" "$NEW_DATABASE_URL" "$ENV_OUT"
|
||||||
|
|
||||||
|
upsert_env_value "MINIO_ROOT_USER" "$NEW_MINIO_ROOT_USER" "$ENV_OUT"
|
||||||
|
upsert_env_value "MINIO_ROOT_PASSWORD" "$NEW_MINIO_ROOT_PASSWORD" "$ENV_OUT"
|
||||||
|
upsert_env_value "S3_ACCESS_KEY" "$NEW_S3_ACCESS_KEY" "$ENV_OUT"
|
||||||
|
upsert_env_value "S3_SECRET_KEY" "$NEW_S3_SECRET_KEY" "$ENV_OUT"
|
||||||
|
upsert_env_value "ADMIN_BOOTSTRAP_PASSWORD" "$NEW_BOOTSTRAP_PASSWORD" "$ENV_OUT"
|
||||||
|
|
||||||
|
if [[ "$APPLY_RUNNING" -eq 0 ]]; then
|
||||||
|
echo "[3/5] Completed in prepare mode."
|
||||||
|
echo "Generated file: $ENV_OUT"
|
||||||
|
echo "Next step: run with --apply-running to update live stack."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f ".env" ]]; then
|
||||||
|
echo "[ERROR] .env not found for --apply-running mode" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$CONFIRM_TOKEN_INPUT" != "$REQUIRED_CONFIRM_TOKEN" ]]; then
|
||||||
|
echo "[ERROR] Invalid or missing confirmation token." >&2
|
||||||
|
echo "Pass: --require-confirmation-token $REQUIRED_CONFIRM_TOKEN" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$NON_INTERACTIVE" -eq 0 ]]; then
|
||||||
|
echo "WARNING: applying rotated secrets to running production stack."
|
||||||
|
echo "This will recreate services and invalidate active auth sessions."
|
||||||
|
read -r -p "Type $REQUIRED_CONFIRM_TOKEN to continue: " typed_token
|
||||||
|
if [[ "$typed_token" != "$REQUIRED_CONFIRM_TOKEN" ]]; then
|
||||||
|
echo "[ABORT] Confirmation token mismatch." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
|
||||||
|
BACKUP_FILE=".env.backup.${TIMESTAMP}"
|
||||||
|
|
||||||
|
echo "[3/5] Backing up and activating new .env..."
|
||||||
|
cp .env "$BACKUP_FILE"
|
||||||
|
chmod 600 "$BACKUP_FILE"
|
||||||
|
cp "$ENV_OUT" .env
|
||||||
|
chmod 600 .env
|
||||||
|
|
||||||
|
if [[ "$SKIP_DB_ROTATE" -eq 0 ]]; then
|
||||||
|
echo "[4/5] Rotating Postgres user password inside DB..."
|
||||||
|
docker compose "${COMPOSE_ARGS[@]}" up -d db >/dev/null
|
||||||
|
docker compose "${COMPOSE_ARGS[@]}" exec -T db \
|
||||||
|
psql -U "$POSTGRES_USER_VALUE" -d postgres \
|
||||||
|
-c "ALTER USER \"$POSTGRES_USER_VALUE\" WITH PASSWORD '$NEW_POSTGRES_PASSWORD';"
|
||||||
|
else
|
||||||
|
echo "[4/5] Skipped DB password ALTER USER (--skip-db-rotate)."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$SKIP_RESTART" -eq 0 ]]; then
|
||||||
|
echo "[5/5] Recreating stack, applying migrations, and checking health..."
|
||||||
|
docker compose "${COMPOSE_ARGS[@]}" up -d --build --force-recreate --remove-orphans
|
||||||
|
docker compose "${COMPOSE_ARGS[@]}" exec -T backend alembic upgrade head
|
||||||
|
curl -fsS http://localhost/health >/dev/null
|
||||||
|
curl -fsS http://localhost/chat-health >/dev/null
|
||||||
|
curl -fsS http://localhost/email-health >/dev/null
|
||||||
|
else
|
||||||
|
echo "[5/5] Skipped stack restart/migrations/health checks (--skip-restart)."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Rotation completed."
|
||||||
|
echo "Backup file: $BACKUP_FILE"
|
||||||
|
echo "Active env: .env"
|
||||||
|
echo "Generated env snapshot: $ENV_OUT"
|
||||||
302
scripts/ops/security_smoke.sh
Executable file
302
scripts/ops/security_smoke.sh
Executable file
|
|
@ -0,0 +1,302 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -u
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
BASE_URL="${1:-http://localhost:8081}"
|
||||||
|
REPORT_DIR="${REPORT_DIR:-reports/security}"
|
||||||
|
TS_HUMAN="$(date -u +"%Y-%m-%d %H:%M:%S UTC")"
|
||||||
|
TS_FILE="$(date -u +"%Y%m%d-%H%M%S")"
|
||||||
|
REPORT_FILE="${REPORT_DIR}/security-smoke-${TS_FILE}.md"
|
||||||
|
|
||||||
|
mkdir -p "$REPORT_DIR"
|
||||||
|
|
||||||
|
failures=()
|
||||||
|
warnings=()
|
||||||
|
passes=()
|
||||||
|
|
||||||
|
add_pass() { passes+=("$1"); }
|
||||||
|
add_warn() { warnings+=("$1"); }
|
||||||
|
add_fail() { failures+=("$1"); }
|
||||||
|
|
||||||
|
lower() {
|
||||||
|
echo "$1" | tr '[:upper:]' '[:lower:]'
|
||||||
|
}
|
||||||
|
|
||||||
|
read_env_var() {
|
||||||
|
local key="$1"
|
||||||
|
if [[ ! -f ".env" ]]; then
|
||||||
|
echo ""
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
grep -E "^${key}=" .env | tail -n1 | cut -d= -f2- || true
|
||||||
|
}
|
||||||
|
|
||||||
|
is_truthy() {
|
||||||
|
local value
|
||||||
|
value="$(lower "${1:-}")"
|
||||||
|
[[ "$value" == "true" || "$value" == "1" || "$value" == "yes" || "$value" == "on" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
http_status_ok() {
|
||||||
|
local url="$1"
|
||||||
|
local code
|
||||||
|
code="$(curl -k -sS -o /dev/null -w "%{http_code}" "$url" || true)"
|
||||||
|
[[ "$code" == "200" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
check_required_headers() {
|
||||||
|
local url="$1"
|
||||||
|
local head
|
||||||
|
head="$(curl -k -sS -I "$url" || true)"
|
||||||
|
local normalized
|
||||||
|
normalized="$(echo "$head" | tr -d '\r' | tr '[:upper:]' '[:lower:]')"
|
||||||
|
|
||||||
|
if [[ "$normalized" == *"x-content-type-options: nosniff"* ]]; then
|
||||||
|
add_pass "header: X-Content-Type-Options=nosniff"
|
||||||
|
else
|
||||||
|
add_fail "missing/invalid header X-Content-Type-Options at ${url}"
|
||||||
|
fi
|
||||||
|
if [[ "$normalized" == *"x-frame-options:"* ]]; then
|
||||||
|
add_pass "header: X-Frame-Options present"
|
||||||
|
else
|
||||||
|
add_fail "missing header X-Frame-Options at ${url}"
|
||||||
|
fi
|
||||||
|
if [[ "$normalized" == *"referrer-policy:"* ]]; then
|
||||||
|
add_pass "header: Referrer-Policy present"
|
||||||
|
else
|
||||||
|
add_fail "missing header Referrer-Policy at ${url}"
|
||||||
|
fi
|
||||||
|
if [[ "$normalized" == *"content-security-policy:"* ]]; then
|
||||||
|
add_pass "header: Content-Security-Policy present"
|
||||||
|
else
|
||||||
|
add_fail "missing header Content-Security-Policy at ${url}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
check_tls_cert() {
|
||||||
|
local url="$1"
|
||||||
|
if [[ "$url" != https://* ]]; then
|
||||||
|
add_warn "tls check skipped (BASE_URL is not https): ${url}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local hostport host port cert not_after
|
||||||
|
hostport="${url#https://}"
|
||||||
|
hostport="${hostport%%/*}"
|
||||||
|
host="${hostport%%:*}"
|
||||||
|
port="443"
|
||||||
|
if [[ "$hostport" == *:* ]]; then
|
||||||
|
port="${hostport##*:}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cert="$(echo | openssl s_client -connect "${host}:${port}" -servername "${host}" 2>/dev/null || true)"
|
||||||
|
if [[ "$cert" != *"BEGIN CERTIFICATE"* ]]; then
|
||||||
|
add_fail "tls certificate is not available for ${host}:${port}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
not_after="$(echo "$cert" | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2- || true)"
|
||||||
|
if [[ -z "$not_after" ]]; then
|
||||||
|
add_fail "tls certificate enddate cannot be read for ${host}:${port}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local days_left
|
||||||
|
days_left="$(python3 - <<PY
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from email.utils import parsedate_to_datetime
|
||||||
|
value = """${not_after}""".strip()
|
||||||
|
try:
|
||||||
|
dt = parsedate_to_datetime(value)
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
left = int((dt - datetime.now(timezone.utc)).total_seconds() // 86400)
|
||||||
|
print(left)
|
||||||
|
except Exception:
|
||||||
|
print(-9999)
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
if [[ "$days_left" == "-9999" ]]; then
|
||||||
|
add_fail "tls certificate date parse error for ${host}:${port}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if (( days_left < 7 )); then
|
||||||
|
add_fail "tls certificate expires too soon (${days_left} days) for ${host}:${port}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
add_pass "tls certificate valid for ${host}:${port} (days_left=${days_left})"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
check_cookie_and_security_flags() {
|
||||||
|
if [[ ! -f ".env" ]]; then
|
||||||
|
add_warn ".env not found: config smoke checks skipped"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local app_env cookie_secure samesite strict_origin
|
||||||
|
app_env="$(lower "$(read_env_var APP_ENV)")"
|
||||||
|
cookie_secure="$(read_env_var PUBLIC_COOKIE_SECURE)"
|
||||||
|
samesite="$(lower "$(read_env_var PUBLIC_COOKIE_SAMESITE)")"
|
||||||
|
strict_origin="$(read_env_var PUBLIC_STRICT_ORIGIN_CHECK)"
|
||||||
|
|
||||||
|
if [[ "$app_env" == "prod" || "$app_env" == "production" ]]; then
|
||||||
|
if is_truthy "$cookie_secure"; then
|
||||||
|
add_pass "env: PUBLIC_COOKIE_SECURE=true (prod)"
|
||||||
|
else
|
||||||
|
add_fail "env: PUBLIC_COOKIE_SECURE must be true in prod"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$samesite" == "lax" || "$samesite" == "strict" || "$samesite" == "none" ]]; then
|
||||||
|
add_pass "env: PUBLIC_COOKIE_SAMESITE is valid (${samesite})"
|
||||||
|
else
|
||||||
|
add_fail "env: invalid PUBLIC_COOKIE_SAMESITE (${samesite})"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if is_truthy "$strict_origin"; then
|
||||||
|
add_pass "env: PUBLIC_STRICT_ORIGIN_CHECK=true (prod)"
|
||||||
|
else
|
||||||
|
add_fail "env: PUBLIC_STRICT_ORIGIN_CHECK must be true in prod"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
add_warn "APP_ENV=${app_env:-unknown}: prod-only cookie checks skipped"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
check_compose_service_running() {
|
||||||
|
local service="$1"
|
||||||
|
if ! command -v docker >/dev/null 2>&1; then
|
||||||
|
add_warn "docker is not available: service checks skipped"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
local running
|
||||||
|
running="$(docker compose ps --services --filter status=running 2>/dev/null || true)"
|
||||||
|
if echo "$running" | grep -qx "$service"; then
|
||||||
|
add_pass "service running: ${service}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
add_fail "service is not running: ${service}"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
check_db_security_audit_table() {
|
||||||
|
if ! command -v docker >/dev/null 2>&1; then
|
||||||
|
add_warn "docker is not available: DB checks skipped"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [[ ! -f ".env" ]]; then
|
||||||
|
add_warn ".env not found: DB checks skipped"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local pg_user pg_db
|
||||||
|
pg_user="$(read_env_var POSTGRES_USER)"
|
||||||
|
pg_db="$(read_env_var POSTGRES_DB)"
|
||||||
|
pg_user="${pg_user:-postgres}"
|
||||||
|
pg_db="${pg_db:-legal}"
|
||||||
|
|
||||||
|
local exists
|
||||||
|
exists="$(docker compose exec -T db psql -U "$pg_user" -d "$pg_db" -Atc "select to_regclass('public.security_audit_log') is not null;" 2>/dev/null || true)"
|
||||||
|
if [[ "$exists" == "t" ]]; then
|
||||||
|
add_pass "db table exists: security_audit_log"
|
||||||
|
else
|
||||||
|
add_fail "db table missing or inaccessible: security_audit_log"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local recent
|
||||||
|
recent="$(docker compose exec -T db psql -U "$pg_user" -d "$pg_db" -Atc "select count(*) from security_audit_log where created_at >= now() - interval '7 days';" 2>/dev/null || true)"
|
||||||
|
if [[ "$recent" =~ ^[0-9]+$ ]]; then
|
||||||
|
add_pass "db access: security_audit_log query ok (rows_7d=${recent})"
|
||||||
|
else
|
||||||
|
add_fail "db access error: cannot query security_audit_log"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
check_attachment_scan_availability() {
|
||||||
|
local scan_enabled clam_enabled
|
||||||
|
scan_enabled="$(read_env_var ATTACHMENT_SCAN_ENABLED)"
|
||||||
|
clam_enabled="$(read_env_var CLAMAV_ENABLED)"
|
||||||
|
|
||||||
|
if is_truthy "$scan_enabled" || is_truthy "$clam_enabled"; then
|
||||||
|
check_compose_service_running "clamav"
|
||||||
|
else
|
||||||
|
add_warn "attachment scan disabled by config (ATTACHMENT_SCAN_ENABLED/CLAMAV_ENABLED)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
run_smoke() {
|
||||||
|
local health_url chat_health_url email_health_url
|
||||||
|
health_url="${BASE_URL%/}/health"
|
||||||
|
chat_health_url="${BASE_URL%/}/chat-health"
|
||||||
|
email_health_url="${BASE_URL%/}/email-health"
|
||||||
|
|
||||||
|
if http_status_ok "$health_url"; then
|
||||||
|
add_pass "http 200: ${health_url}"
|
||||||
|
else
|
||||||
|
add_fail "http check failed: ${health_url}"
|
||||||
|
fi
|
||||||
|
if http_status_ok "$chat_health_url"; then
|
||||||
|
add_pass "http 200: ${chat_health_url}"
|
||||||
|
else
|
||||||
|
add_fail "http check failed: ${chat_health_url}"
|
||||||
|
fi
|
||||||
|
if http_status_ok "$email_health_url"; then
|
||||||
|
add_pass "http 200: ${email_health_url}"
|
||||||
|
else
|
||||||
|
add_fail "http check failed: ${email_health_url}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
check_required_headers "$health_url"
|
||||||
|
check_tls_cert "$BASE_URL"
|
||||||
|
check_cookie_and_security_flags
|
||||||
|
check_attachment_scan_availability
|
||||||
|
check_db_security_audit_table
|
||||||
|
}
|
||||||
|
|
||||||
|
write_report() {
|
||||||
|
{
|
||||||
|
echo "# Security Smoke Report"
|
||||||
|
echo
|
||||||
|
echo "- Timestamp: ${TS_HUMAN}"
|
||||||
|
echo "- Base URL: ${BASE_URL}"
|
||||||
|
echo "- Result: $([[ ${#failures[@]} -eq 0 ]] && echo "PASS" || echo "FAIL")"
|
||||||
|
echo
|
||||||
|
echo "## Passed checks (${#passes[@]})"
|
||||||
|
for item in "${passes[@]}"; do
|
||||||
|
echo "- [x] ${item}"
|
||||||
|
done
|
||||||
|
echo
|
||||||
|
echo "## Warnings (${#warnings[@]})"
|
||||||
|
if [[ ${#warnings[@]} -eq 0 ]]; then
|
||||||
|
echo "- none"
|
||||||
|
else
|
||||||
|
for item in "${warnings[@]}"; do
|
||||||
|
echo "- [!] ${item}"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
echo "## Failures (${#failures[@]})"
|
||||||
|
if [[ ${#failures[@]} -eq 0 ]]; then
|
||||||
|
echo "- none"
|
||||||
|
else
|
||||||
|
for item in "${failures[@]}"; do
|
||||||
|
echo "- [ ] ${item}"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
} > "$REPORT_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_smoke
|
||||||
|
write_report
|
||||||
|
|
||||||
|
if [[ ${#failures[@]} -gt 0 ]]; then
|
||||||
|
echo "[ALERT] Security smoke failed (${#failures[@]} failure(s)). Report: ${REPORT_FILE}" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[OK] Security smoke passed. Report: ${REPORT_FILE}"
|
||||||
|
exit 0
|
||||||
115
tests/test_crypto_kid_rotation.py
Normal file
115
tests/test_crypto_kid_rotation.py
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:")
|
||||||
|
os.environ.setdefault("REDIS_URL", "redis://localhost:6379/0")
|
||||||
|
os.environ.setdefault("S3_ENDPOINT", "http://localhost:9000")
|
||||||
|
os.environ.setdefault("S3_ACCESS_KEY", "test")
|
||||||
|
os.environ.setdefault("S3_SECRET_KEY", "test")
|
||||||
|
os.environ.setdefault("S3_BUCKET", "test")
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.services.chat_crypto import decrypt_message_body, encrypt_message_body, extract_message_kid
|
||||||
|
from app.services.invoice_crypto import (
|
||||||
|
active_requisites_kid,
|
||||||
|
decrypt_requisites,
|
||||||
|
encrypt_requisites,
|
||||||
|
extract_requisites_kid,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _xor_bytes(a: bytes, b: bytes) -> bytes:
|
||||||
|
return bytes(x ^ y for x, y in zip(a, b))
|
||||||
|
|
||||||
|
|
||||||
|
def _legacy_invoice_token(payload: dict, secret: str) -> str:
|
||||||
|
raw = (str(payload).replace("'", '"')).encode("utf-8")
|
||||||
|
# stable json-like payload for this test suite
|
||||||
|
raw = b'{"secret":"LEGACY"}' if payload.get("secret") == "LEGACY" else raw
|
||||||
|
key = hashlib.sha256(secret.encode("utf-8")).digest()
|
||||||
|
nonce = bytes.fromhex("00112233445566778899aabbccddeeff")
|
||||||
|
stream = hashlib.pbkdf2_hmac("sha256", key, nonce, 120_000, dklen=len(raw))
|
||||||
|
cipher = _xor_bytes(raw, stream)
|
||||||
|
tag = hmac.new(key, b"v1" + nonce + cipher, hashlib.sha256).digest()
|
||||||
|
token = b"v1" + nonce + tag + cipher
|
||||||
|
return base64.urlsafe_b64encode(token).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def _legacy_chat_token(plaintext: str, secret: str) -> str:
|
||||||
|
raw = plaintext.encode("utf-8")
|
||||||
|
key = hashlib.sha256(secret.encode("utf-8")).digest()
|
||||||
|
nonce = bytes.fromhex("ffeeddccbbaa99887766554433221100")
|
||||||
|
stream = hashlib.pbkdf2_hmac("sha256", key, nonce, 120_000, dklen=len(raw))
|
||||||
|
cipher = _xor_bytes(raw, stream)
|
||||||
|
tag = hmac.new(key, b"v1" + nonce + cipher, hashlib.sha256).digest()
|
||||||
|
token = b"v1" + nonce + tag + cipher
|
||||||
|
return "chatenc:v1:" + base64.urlsafe_b64encode(token).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
class CryptoKidRotationTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self._backup = {
|
||||||
|
"DATA_ENCRYPTION_SECRET": settings.DATA_ENCRYPTION_SECRET,
|
||||||
|
"DATA_ENCRYPTION_ACTIVE_KID": settings.DATA_ENCRYPTION_ACTIVE_KID,
|
||||||
|
"DATA_ENCRYPTION_KEYS": settings.DATA_ENCRYPTION_KEYS,
|
||||||
|
"CHAT_ENCRYPTION_SECRET": settings.CHAT_ENCRYPTION_SECRET,
|
||||||
|
"CHAT_ENCRYPTION_ACTIVE_KID": settings.CHAT_ENCRYPTION_ACTIVE_KID,
|
||||||
|
"CHAT_ENCRYPTION_KEYS": settings.CHAT_ENCRYPTION_KEYS,
|
||||||
|
}
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
for key, value in self._backup.items():
|
||||||
|
setattr(settings, key, value)
|
||||||
|
|
||||||
|
def test_invoice_encrypt_uses_active_kid(self):
|
||||||
|
settings.DATA_ENCRYPTION_SECRET = "legacy-secret-1234567890"
|
||||||
|
settings.DATA_ENCRYPTION_ACTIVE_KID = "k2"
|
||||||
|
settings.DATA_ENCRYPTION_KEYS = "k1=old-secret-1111111111111111,k2=new-secret-2222222222222222"
|
||||||
|
|
||||||
|
token = encrypt_requisites({"inn": "7700000000"})
|
||||||
|
self.assertTrue(token.startswith("invenc:v2:"))
|
||||||
|
self.assertEqual(extract_requisites_kid(token), "k2")
|
||||||
|
payload = decrypt_requisites(token)
|
||||||
|
self.assertEqual(payload.get("inn"), "7700000000")
|
||||||
|
self.assertEqual(active_requisites_kid(), "k2")
|
||||||
|
|
||||||
|
def test_invoice_decrypts_legacy_after_rotation(self):
|
||||||
|
legacy_secret = "legacy-data-secret-aaaaaaaaaaaaaaaa"
|
||||||
|
legacy = _legacy_invoice_token({"secret": "LEGACY"}, legacy_secret)
|
||||||
|
|
||||||
|
settings.DATA_ENCRYPTION_SECRET = ""
|
||||||
|
settings.DATA_ENCRYPTION_ACTIVE_KID = "k2"
|
||||||
|
settings.DATA_ENCRYPTION_KEYS = f"k1={legacy_secret},k2=new-data-secret-bbbbbbbbbbbbbbbb"
|
||||||
|
|
||||||
|
payload = decrypt_requisites(legacy)
|
||||||
|
self.assertEqual(payload.get("secret"), "LEGACY")
|
||||||
|
|
||||||
|
rotated = encrypt_requisites(payload)
|
||||||
|
self.assertEqual(extract_requisites_kid(rotated), "k2")
|
||||||
|
self.assertEqual(decrypt_requisites(rotated).get("secret"), "LEGACY")
|
||||||
|
|
||||||
|
def test_chat_decrypts_legacy_and_writes_new_kid(self):
|
||||||
|
legacy_secret = "legacy-chat-secret-aaaaaaaaaaaaaaaa"
|
||||||
|
legacy_token = _legacy_chat_token("legacy message", legacy_secret)
|
||||||
|
|
||||||
|
settings.DATA_ENCRYPTION_SECRET = ""
|
||||||
|
settings.DATA_ENCRYPTION_ACTIVE_KID = "k2"
|
||||||
|
settings.DATA_ENCRYPTION_KEYS = "k2=new-data-secret-bbbbbbbbbbbbbbbb"
|
||||||
|
settings.CHAT_ENCRYPTION_SECRET = ""
|
||||||
|
settings.CHAT_ENCRYPTION_ACTIVE_KID = "k2"
|
||||||
|
settings.CHAT_ENCRYPTION_KEYS = f"k1={legacy_secret},k2=new-chat-secret-cccccccccccccccc"
|
||||||
|
|
||||||
|
plain = decrypt_message_body(legacy_token)
|
||||||
|
self.assertEqual(plain, "legacy message")
|
||||||
|
|
||||||
|
token = encrypt_message_body(plain)
|
||||||
|
self.assertTrue(token.startswith("chatenc:v2:"))
|
||||||
|
self.assertEqual(extract_message_kid(token), "k2")
|
||||||
|
self.assertEqual(decrypt_message_body(token), "legacy message")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
@ -92,4 +92,7 @@ class HttpHardeningTests(unittest.TestCase):
|
||||||
}
|
}
|
||||||
headers = _response_security_headers(Request(scope))
|
headers = _response_security_headers(Request(scope))
|
||||||
self.assertEqual(headers.get("X-Frame-Options"), "DENY")
|
self.assertEqual(headers.get("X-Frame-Options"), "DENY")
|
||||||
self.assertIn("frame-ancestors 'none'", str(headers.get("Content-Security-Policy")))
|
csp = str(headers.get("Content-Security-Policy"))
|
||||||
|
self.assertIn("frame-ancestors 'none'", csp)
|
||||||
|
self.assertIn("script-src 'self'", csp)
|
||||||
|
self.assertIn("connect-src 'self'", csp)
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,7 @@ class MigrationTests(unittest.TestCase):
|
||||||
"notifications",
|
"notifications",
|
||||||
"invoices",
|
"invoices",
|
||||||
"security_audit_log",
|
"security_audit_log",
|
||||||
|
"data_retention_policies",
|
||||||
"alembic_version",
|
"alembic_version",
|
||||||
}
|
}
|
||||||
tables = set(self.inspector.get_table_names())
|
tables = set(self.inspector.get_table_names())
|
||||||
|
|
@ -113,7 +114,7 @@ class MigrationTests(unittest.TestCase):
|
||||||
def test_alembic_version_is_set(self):
|
def test_alembic_version_is_set(self):
|
||||||
with self.engine.connect() as conn:
|
with self.engine.connect() as conn:
|
||||||
version = conn.execute(text("SELECT version_num FROM alembic_version")).scalar_one()
|
version = conn.execute(text("SELECT version_num FROM alembic_version")).scalar_one()
|
||||||
self.assertEqual(version, "0030_attachment_scan")
|
self.assertEqual(version, "0031_pii_retention_and_consent")
|
||||||
|
|
||||||
def test_responsible_column_exists_in_all_domain_tables(self):
|
def test_responsible_column_exists_in_all_domain_tables(self):
|
||||||
tables = {
|
tables = {
|
||||||
|
|
@ -143,6 +144,7 @@ class MigrationTests(unittest.TestCase):
|
||||||
"notifications",
|
"notifications",
|
||||||
"invoices",
|
"invoices",
|
||||||
"security_audit_log",
|
"security_audit_log",
|
||||||
|
"data_retention_policies",
|
||||||
}
|
}
|
||||||
for table in tables:
|
for table in tables:
|
||||||
columns = {column["name"] for column in self.inspector.get_columns(table)}
|
columns = {column["name"] for column in self.inspector.get_columns(table)}
|
||||||
|
|
@ -193,6 +195,9 @@ class MigrationTests(unittest.TestCase):
|
||||||
columns = {column["name"] for column in self.inspector.get_columns("requests")}
|
columns = {column["name"] for column in self.inspector.get_columns("requests")}
|
||||||
self.assertIn("client_id", columns)
|
self.assertIn("client_id", columns)
|
||||||
self.assertIn("client_email", columns)
|
self.assertIn("client_email", columns)
|
||||||
|
self.assertIn("pdn_consent", columns)
|
||||||
|
self.assertIn("pdn_consent_at", columns)
|
||||||
|
self.assertIn("pdn_consent_ip", columns)
|
||||||
self.assertIn("important_date_at", columns)
|
self.assertIn("important_date_at", columns)
|
||||||
self.assertIn("effective_rate", columns)
|
self.assertIn("effective_rate", columns)
|
||||||
self.assertIn("request_cost", columns)
|
self.assertIn("request_cost", columns)
|
||||||
|
|
@ -200,6 +205,17 @@ class MigrationTests(unittest.TestCase):
|
||||||
self.assertIn("paid_at", columns)
|
self.assertIn("paid_at", columns)
|
||||||
self.assertIn("paid_by_admin_id", columns)
|
self.assertIn("paid_by_admin_id", columns)
|
||||||
|
|
||||||
|
def test_data_retention_policies_contains_core_columns(self):
|
||||||
|
columns = {column["name"] for column in self.inspector.get_columns("data_retention_policies")}
|
||||||
|
self.assertIn("id", columns)
|
||||||
|
self.assertIn("entity", columns)
|
||||||
|
self.assertIn("retention_days", columns)
|
||||||
|
self.assertIn("enabled", columns)
|
||||||
|
self.assertIn("hard_delete", columns)
|
||||||
|
self.assertIn("description", columns)
|
||||||
|
self.assertIn("created_at", columns)
|
||||||
|
self.assertIn("responsible", columns)
|
||||||
|
|
||||||
def test_status_history_contains_important_date_column(self):
|
def test_status_history_contains_important_date_column(self):
|
||||||
columns = {column["name"] for column in self.inspector.get_columns("status_history")}
|
columns = {column["name"] for column in self.inspector.get_columns("status_history")}
|
||||||
self.assertIn("important_date_at", columns)
|
self.assertIn("important_date_at", columns)
|
||||||
|
|
|
||||||
81
tests/test_origin_guard.py
Normal file
81
tests/test_origin_guard.py
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:")
|
||||||
|
os.environ.setdefault("REDIS_URL", "redis://localhost:6379/0")
|
||||||
|
os.environ.setdefault("S3_ENDPOINT", "http://localhost:9000")
|
||||||
|
os.environ.setdefault("S3_ACCESS_KEY", "test")
|
||||||
|
os.environ.setdefault("S3_SECRET_KEY", "test")
|
||||||
|
os.environ.setdefault("S3_BUCKET", "test")
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.services.origin_guard import enforce_public_origin_or_403
|
||||||
|
|
||||||
|
|
||||||
|
def _request_with_headers(headers: dict[str, str]) -> Request:
|
||||||
|
raw_headers = [(str(k).lower().encode("latin-1"), str(v).encode("latin-1")) for k, v in headers.items()]
|
||||||
|
scope = {
|
||||||
|
"type": "http",
|
||||||
|
"http_version": "1.1",
|
||||||
|
"method": "POST",
|
||||||
|
"scheme": "https",
|
||||||
|
"path": "/api/public/otp/send",
|
||||||
|
"query_string": b"",
|
||||||
|
"headers": raw_headers,
|
||||||
|
"client": ("127.0.0.1", 52000),
|
||||||
|
"server": ("testserver", 443),
|
||||||
|
}
|
||||||
|
return Request(scope)
|
||||||
|
|
||||||
|
|
||||||
|
class OriginGuardTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self._backup = {
|
||||||
|
"APP_ENV": settings.APP_ENV,
|
||||||
|
"PUBLIC_STRICT_ORIGIN_CHECK": settings.PUBLIC_STRICT_ORIGIN_CHECK,
|
||||||
|
"PUBLIC_ALLOWED_WEB_ORIGINS": settings.PUBLIC_ALLOWED_WEB_ORIGINS,
|
||||||
|
}
|
||||||
|
settings.APP_ENV = "production"
|
||||||
|
settings.PUBLIC_STRICT_ORIGIN_CHECK = True
|
||||||
|
settings.PUBLIC_ALLOWED_WEB_ORIGINS = "https://ruakb.ru,https://www.ruakb.ru"
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
for key, value in self._backup.items():
|
||||||
|
setattr(settings, key, value)
|
||||||
|
|
||||||
|
def test_allows_whitelisted_origin(self):
|
||||||
|
request = _request_with_headers({"origin": "https://ruakb.ru"})
|
||||||
|
enforce_public_origin_or_403(request, endpoint="/api/public/otp/send")
|
||||||
|
|
||||||
|
def test_rejects_missing_origin_and_referer(self):
|
||||||
|
request = _request_with_headers({})
|
||||||
|
with self.assertRaises(HTTPException) as exc:
|
||||||
|
enforce_public_origin_or_403(request, endpoint="/api/public/otp/send")
|
||||||
|
self.assertEqual(exc.exception.status_code, 403)
|
||||||
|
|
||||||
|
def test_rejects_cross_site_fetch_metadata(self):
|
||||||
|
request = _request_with_headers(
|
||||||
|
{
|
||||||
|
"origin": "https://ruakb.ru",
|
||||||
|
"sec-fetch-site": "cross-site",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
with self.assertRaises(HTTPException) as exc:
|
||||||
|
enforce_public_origin_or_403(request, endpoint="/api/public/otp/send")
|
||||||
|
self.assertEqual(exc.exception.status_code, 403)
|
||||||
|
|
||||||
|
def test_allows_referer_when_origin_missing(self):
|
||||||
|
request = _request_with_headers({"referer": "https://www.ruakb.ru/landing"})
|
||||||
|
enforce_public_origin_or_403(request, endpoint="/api/public/otp/send")
|
||||||
|
|
||||||
|
def test_can_disable_check(self):
|
||||||
|
settings.PUBLIC_STRICT_ORIGIN_CHECK = False
|
||||||
|
request = _request_with_headers({})
|
||||||
|
enforce_public_origin_or_403(request, endpoint="/api/public/otp/send")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
@ -277,7 +277,7 @@ class PublicCabinetTests(unittest.TestCase):
|
||||||
|
|
||||||
with self.SessionLocal() as db:
|
with self.SessionLocal() as db:
|
||||||
raw_encrypted = db.execute(text("SELECT body FROM messages ORDER BY created_at DESC LIMIT 1")).scalar_one()
|
raw_encrypted = db.execute(text("SELECT body FROM messages ORDER BY created_at DESC LIMIT 1")).scalar_one()
|
||||||
self.assertTrue(str(raw_encrypted).startswith("chatenc:v1:"))
|
self.assertTrue(str(raw_encrypted).startswith("chatenc:"))
|
||||||
self.assertNotEqual(str(raw_encrypted), payload_body)
|
self.assertNotEqual(str(raw_encrypted), payload_body)
|
||||||
|
|
||||||
listed = self.chat_client.get("/api/public/chat/requests/TRK-CHAT-ENC/messages", cookies=cookies)
|
listed = self.chat_client.get("/api/public/chat/requests/TRK-CHAT-ENC/messages", cookies=cookies)
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,7 @@ class PublicRequestCreateTests(unittest.TestCase):
|
||||||
"topic_code": "consulting",
|
"topic_code": "consulting",
|
||||||
"description": "Тестируем создание заявки",
|
"description": "Тестируем создание заявки",
|
||||||
"extra_fields": {"referral_name": "Партнер"},
|
"extra_fields": {"referral_name": "Партнер"},
|
||||||
|
"pdn_consent": True,
|
||||||
}
|
}
|
||||||
response = self.client.post("/api/public/requests", json=payload)
|
response = self.client.post("/api/public/requests", json=payload)
|
||||||
self.assertEqual(response.status_code, 401)
|
self.assertEqual(response.status_code, 401)
|
||||||
|
|
@ -147,6 +148,9 @@ class PublicRequestCreateTests(unittest.TestCase):
|
||||||
self.assertEqual(created.status_code, "NEW")
|
self.assertEqual(created.status_code, "NEW")
|
||||||
self.assertEqual(created.track_number, body["track_number"])
|
self.assertEqual(created.track_number, body["track_number"])
|
||||||
self.assertEqual(created.responsible, "Клиент")
|
self.assertEqual(created.responsible, "Клиент")
|
||||||
|
self.assertTrue(created.pdn_consent)
|
||||||
|
self.assertIsNotNone(created.pdn_consent_at)
|
||||||
|
self.assertIsNotNone(created.pdn_consent_ip)
|
||||||
client = db.get(Client, created.client_id)
|
client = db.get(Client, created.client_id)
|
||||||
self.assertIsNotNone(client)
|
self.assertIsNotNone(client)
|
||||||
self.assertEqual(client.phone, payload["client_phone"])
|
self.assertEqual(client.phone, payload["client_phone"])
|
||||||
|
|
@ -201,6 +205,47 @@ class PublicRequestCreateTests(unittest.TestCase):
|
||||||
denied_other_track = self.client.get("/api/public/requests/TRK-OTHER")
|
denied_other_track = self.client.get("/api/public/requests/TRK-OTHER")
|
||||||
self.assertEqual(denied_other_track.status_code, 403)
|
self.assertEqual(denied_other_track.status_code, 403)
|
||||||
|
|
||||||
|
def test_otp_send_rejects_honeypot_field(self):
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/public/otp/send",
|
||||||
|
json={
|
||||||
|
"purpose": "CREATE_REQUEST",
|
||||||
|
"client_phone": self._unique_phone(),
|
||||||
|
"hp_field": "https://spam.example",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
def test_create_request_rejects_honeypot_field(self):
|
||||||
|
phone = self._unique_phone()
|
||||||
|
self._send_and_verify_create_otp(phone)
|
||||||
|
payload = {
|
||||||
|
"client_name": "Клиент honeypot",
|
||||||
|
"client_phone": phone,
|
||||||
|
"topic_code": "consulting",
|
||||||
|
"description": "Проверка honeypot",
|
||||||
|
"extra_fields": {},
|
||||||
|
"pdn_consent": True,
|
||||||
|
"hp_field": "https://spam.example",
|
||||||
|
}
|
||||||
|
response = self.client.post("/api/public/requests", json=payload)
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
def test_create_request_requires_pdn_consent(self):
|
||||||
|
phone = self._unique_phone()
|
||||||
|
self._send_and_verify_create_otp(phone)
|
||||||
|
payload = {
|
||||||
|
"client_name": "Клиент без согласия",
|
||||||
|
"client_phone": phone,
|
||||||
|
"topic_code": "consulting",
|
||||||
|
"description": "Проверка согласия ПДн",
|
||||||
|
"extra_fields": {},
|
||||||
|
"pdn_consent": False,
|
||||||
|
}
|
||||||
|
response = self.client.post("/api/public/requests", json=payload)
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
self.assertIn("согласие", str(response.json().get("detail", "")).lower())
|
||||||
|
|
||||||
def test_view_request_can_use_phone_otp_and_switch_between_client_requests(self):
|
def test_view_request_can_use_phone_otp_and_switch_between_client_requests(self):
|
||||||
phone = "+79996660077"
|
phone = "+79996660077"
|
||||||
with self.SessionLocal() as db:
|
with self.SessionLocal() as db:
|
||||||
|
|
@ -299,6 +344,7 @@ class PublicRequestCreateTests(unittest.TestCase):
|
||||||
"topic_code": "consulting",
|
"topic_code": "consulting",
|
||||||
"description": "Email auth mode create",
|
"description": "Email auth mode create",
|
||||||
"extra_fields": {},
|
"extra_fields": {},
|
||||||
|
"pdn_consent": True,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
self.assertEqual(create.status_code, 201)
|
self.assertEqual(create.status_code, 201)
|
||||||
|
|
@ -427,6 +473,7 @@ class PublicRequestCreateTests(unittest.TestCase):
|
||||||
"topic_code": "consulting",
|
"topic_code": "consulting",
|
||||||
"description": "Проверка обязательного поля",
|
"description": "Проверка обязательного поля",
|
||||||
"extra_fields": {},
|
"extra_fields": {},
|
||||||
|
"pdn_consent": True,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
self.assertEqual(missing.status_code, 400)
|
self.assertEqual(missing.status_code, 400)
|
||||||
|
|
@ -440,6 +487,7 @@ class PublicRequestCreateTests(unittest.TestCase):
|
||||||
"topic_code": "consulting",
|
"topic_code": "consulting",
|
||||||
"description": "Проверка обязательного поля",
|
"description": "Проверка обязательного поля",
|
||||||
"extra_fields": {"passport_series": "1234"},
|
"extra_fields": {"passport_series": "1234"},
|
||||||
|
"pdn_consent": True,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
self.assertEqual(created.status_code, 201)
|
self.assertEqual(created.status_code, 201)
|
||||||
|
|
@ -475,6 +523,32 @@ class PublicRequestCreateTests(unittest.TestCase):
|
||||||
self.assertIn(f"Max-Age={settings.PUBLIC_JWT_TTL_DAYS * 24 * 3600}", cookie_header)
|
self.assertIn(f"Max-Age={settings.PUBLIC_JWT_TTL_DAYS * 24 * 3600}", cookie_header)
|
||||||
self.assertIn("httponly", cookie_header.lower())
|
self.assertIn("httponly", cookie_header.lower())
|
||||||
|
|
||||||
|
def test_verify_otp_respects_cookie_security_flags_from_settings(self):
|
||||||
|
phone = self._unique_phone()
|
||||||
|
secure_backup = settings.PUBLIC_COOKIE_SECURE
|
||||||
|
samesite_backup = settings.PUBLIC_COOKIE_SAMESITE
|
||||||
|
try:
|
||||||
|
settings.PUBLIC_COOKIE_SECURE = True
|
||||||
|
settings.PUBLIC_COOKIE_SAMESITE = "strict"
|
||||||
|
with patch("app.api.public.otp._generate_code", return_value="313131"):
|
||||||
|
sent = self.client.post(
|
||||||
|
"/api/public/otp/send",
|
||||||
|
json={"purpose": "CREATE_REQUEST", "client_phone": phone},
|
||||||
|
)
|
||||||
|
self.assertEqual(sent.status_code, 200)
|
||||||
|
|
||||||
|
verified = self.client.post(
|
||||||
|
"/api/public/otp/verify",
|
||||||
|
json={"purpose": "CREATE_REQUEST", "client_phone": phone, "code": "313131"},
|
||||||
|
)
|
||||||
|
self.assertEqual(verified.status_code, 200)
|
||||||
|
cookie_header = str(verified.headers.get("set-cookie") or "")
|
||||||
|
self.assertIn("Secure", cookie_header)
|
||||||
|
self.assertIn("SameSite=strict", cookie_header)
|
||||||
|
finally:
|
||||||
|
settings.PUBLIC_COOKIE_SECURE = secure_backup
|
||||||
|
settings.PUBLIC_COOKIE_SAMESITE = samesite_backup
|
||||||
|
|
||||||
def test_verify_view_otp_by_phone_sets_view_session_subject_as_phone(self):
|
def test_verify_view_otp_by_phone_sets_view_session_subject_as_phone(self):
|
||||||
phone = "+79998887766"
|
phone = "+79998887766"
|
||||||
with self.SessionLocal() as db:
|
with self.SessionLocal() as db:
|
||||||
|
|
|
||||||
187
tests/test_reencrypt_with_active_kid.py
Normal file
187
tests/test_reencrypt_with_active_kid.py
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine, delete, text
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:")
|
||||||
|
os.environ.setdefault("REDIS_URL", "redis://localhost:6379/0")
|
||||||
|
os.environ.setdefault("S3_ENDPOINT", "http://localhost:9000")
|
||||||
|
os.environ.setdefault("S3_ACCESS_KEY", "test")
|
||||||
|
os.environ.setdefault("S3_SECRET_KEY", "test")
|
||||||
|
os.environ.setdefault("S3_BUCKET", "test")
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.models.admin_user import AdminUser
|
||||||
|
from app.models.invoice import Invoice
|
||||||
|
from app.models.message import Message
|
||||||
|
from app.models.request import Request
|
||||||
|
from app.scripts import reencrypt_with_active_kid as reencrypt_script
|
||||||
|
from app.services.chat_crypto import extract_message_kid
|
||||||
|
from app.services.invoice_crypto import extract_requisites_kid
|
||||||
|
|
||||||
|
|
||||||
|
def _xor_bytes(a: bytes, b: bytes) -> bytes:
|
||||||
|
return bytes(x ^ y for x, y in zip(a, b))
|
||||||
|
|
||||||
|
|
||||||
|
def _legacy_invoice_token(secret: str) -> str:
|
||||||
|
raw = b'{"secret":"LEGACY"}'
|
||||||
|
key = hashlib.sha256(secret.encode("utf-8")).digest()
|
||||||
|
nonce = bytes.fromhex("00112233445566778899aabbccddeeff")
|
||||||
|
stream = hashlib.pbkdf2_hmac("sha256", key, nonce, 120_000, dklen=len(raw))
|
||||||
|
cipher = _xor_bytes(raw, stream)
|
||||||
|
tag = hmac.new(key, b"v1" + nonce + cipher, hashlib.sha256).digest()
|
||||||
|
token = b"v1" + nonce + tag + cipher
|
||||||
|
return base64.urlsafe_b64encode(token).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def _legacy_chat_token(plaintext: str, secret: str) -> str:
|
||||||
|
raw = plaintext.encode("utf-8")
|
||||||
|
key = hashlib.sha256(secret.encode("utf-8")).digest()
|
||||||
|
nonce = bytes.fromhex("ffeeddccbbaa99887766554433221100")
|
||||||
|
stream = hashlib.pbkdf2_hmac("sha256", key, nonce, 120_000, dklen=len(raw))
|
||||||
|
cipher = _xor_bytes(raw, stream)
|
||||||
|
tag = hmac.new(key, b"v1" + nonce + cipher, hashlib.sha256).digest()
|
||||||
|
token = b"v1" + nonce + tag + cipher
|
||||||
|
return "chatenc:v1:" + base64.urlsafe_b64encode(token).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
class ReencryptWithKidTests(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.engine = create_engine(
|
||||||
|
"sqlite+pysqlite:///:memory:",
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
poolclass=StaticPool,
|
||||||
|
)
|
||||||
|
cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False)
|
||||||
|
AdminUser.__table__.create(bind=cls.engine)
|
||||||
|
Request.__table__.create(bind=cls.engine)
|
||||||
|
Message.__table__.create(bind=cls.engine)
|
||||||
|
Invoice.__table__.create(bind=cls.engine)
|
||||||
|
|
||||||
|
cls._old_session_local = reencrypt_script.SessionLocal
|
||||||
|
reencrypt_script.SessionLocal = cls.SessionLocal
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls):
|
||||||
|
reencrypt_script.SessionLocal = cls._old_session_local
|
||||||
|
Invoice.__table__.drop(bind=cls.engine)
|
||||||
|
Message.__table__.drop(bind=cls.engine)
|
||||||
|
Request.__table__.drop(bind=cls.engine)
|
||||||
|
AdminUser.__table__.drop(bind=cls.engine)
|
||||||
|
cls.engine.dispose()
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self._backup = {
|
||||||
|
"DATA_ENCRYPTION_SECRET": settings.DATA_ENCRYPTION_SECRET,
|
||||||
|
"DATA_ENCRYPTION_ACTIVE_KID": settings.DATA_ENCRYPTION_ACTIVE_KID,
|
||||||
|
"DATA_ENCRYPTION_KEYS": settings.DATA_ENCRYPTION_KEYS,
|
||||||
|
"CHAT_ENCRYPTION_SECRET": settings.CHAT_ENCRYPTION_SECRET,
|
||||||
|
"CHAT_ENCRYPTION_ACTIVE_KID": settings.CHAT_ENCRYPTION_ACTIVE_KID,
|
||||||
|
"CHAT_ENCRYPTION_KEYS": settings.CHAT_ENCRYPTION_KEYS,
|
||||||
|
}
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
db.execute(delete(Invoice))
|
||||||
|
db.execute(delete(Message))
|
||||||
|
db.execute(delete(Request))
|
||||||
|
db.execute(delete(AdminUser))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
for key, value in self._backup.items():
|
||||||
|
setattr(settings, key, value)
|
||||||
|
|
||||||
|
def test_reencrypt_script_moves_legacy_rows_to_active_kid(self):
|
||||||
|
old_secret = "legacy-secret-aaaaaaaaaaaaaaaa"
|
||||||
|
settings.DATA_ENCRYPTION_SECRET = ""
|
||||||
|
settings.DATA_ENCRYPTION_ACTIVE_KID = "k2"
|
||||||
|
settings.DATA_ENCRYPTION_KEYS = f"k1={old_secret},k2=new-data-secret-bbbbbbbbbbbbbbbb"
|
||||||
|
settings.CHAT_ENCRYPTION_SECRET = ""
|
||||||
|
settings.CHAT_ENCRYPTION_ACTIVE_KID = "k2"
|
||||||
|
settings.CHAT_ENCRYPTION_KEYS = f"k1={old_secret},k2=new-chat-secret-cccccccccccccccc"
|
||||||
|
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
req = Request(
|
||||||
|
track_number=f"TRK-RER-{uuid4().hex[:8].upper()}",
|
||||||
|
client_name="Клиент",
|
||||||
|
client_phone="+79990001122",
|
||||||
|
topic_code="consulting",
|
||||||
|
status_code="NEW",
|
||||||
|
extra_fields={},
|
||||||
|
)
|
||||||
|
db.add(req)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
db.add(
|
||||||
|
Message(
|
||||||
|
request_id=req.id,
|
||||||
|
author_type="CLIENT",
|
||||||
|
author_name="Клиент",
|
||||||
|
body="placeholder",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.flush()
|
||||||
|
db.execute(
|
||||||
|
text("UPDATE messages SET body = :body WHERE id = (SELECT id FROM messages ORDER BY created_at DESC LIMIT 1)"),
|
||||||
|
{"body": _legacy_chat_token("legacy body", old_secret)},
|
||||||
|
)
|
||||||
|
|
||||||
|
admin = AdminUser(
|
||||||
|
role="ADMIN",
|
||||||
|
name="Admin",
|
||||||
|
email=f"admin-{uuid4().hex[:6]}@example.com",
|
||||||
|
password_hash="hash",
|
||||||
|
totp_enabled=True,
|
||||||
|
totp_secret_encrypted=_legacy_invoice_token(old_secret),
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(admin)
|
||||||
|
|
||||||
|
invoice = Invoice(
|
||||||
|
request_id=req.id,
|
||||||
|
client_id=None,
|
||||||
|
invoice_number=f"INV-{uuid4().hex[:8].upper()}",
|
||||||
|
status="WAITING_PAYMENT",
|
||||||
|
amount=1000,
|
||||||
|
currency="RUB",
|
||||||
|
payer_display_name="Клиент",
|
||||||
|
payer_details_encrypted=_legacy_invoice_token(old_secret),
|
||||||
|
issued_by_admin_user_id=None,
|
||||||
|
issued_by_role="ADMIN",
|
||||||
|
issued_at=datetime.now(timezone.utc),
|
||||||
|
responsible="seed",
|
||||||
|
)
|
||||||
|
db.add(invoice)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
dry = reencrypt_script.reencrypt_with_active_kid(dry_run=True)
|
||||||
|
self.assertGreaterEqual(int(dry.get("messages_reencrypted", 0)), 1)
|
||||||
|
self.assertGreaterEqual(int(dry.get("invoices_reencrypted", 0)), 1)
|
||||||
|
self.assertGreaterEqual(int(dry.get("admin_totp_reencrypted", 0)), 1)
|
||||||
|
|
||||||
|
applied = reencrypt_script.reencrypt_with_active_kid(dry_run=False)
|
||||||
|
self.assertGreaterEqual(int(applied.get("messages_reencrypted", 0)), 1)
|
||||||
|
self.assertGreaterEqual(int(applied.get("invoices_reencrypted", 0)), 1)
|
||||||
|
self.assertGreaterEqual(int(applied.get("admin_totp_reencrypted", 0)), 1)
|
||||||
|
self.assertEqual(int(applied.get("errors", 0)), 0)
|
||||||
|
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
invoice_token = db.execute(text("SELECT payer_details_encrypted FROM invoices LIMIT 1")).scalar_one()
|
||||||
|
admin_token = db.execute(text("SELECT totp_secret_encrypted FROM admin_users LIMIT 1")).scalar_one()
|
||||||
|
message_token = db.execute(text("SELECT body FROM messages LIMIT 1")).scalar_one()
|
||||||
|
|
||||||
|
self.assertEqual(extract_requisites_kid(str(invoice_token)), "k2")
|
||||||
|
self.assertEqual(extract_requisites_kid(str(admin_token)), "k2")
|
||||||
|
self.assertEqual(extract_message_kid(str(message_token)), "k2")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
65
tests/test_s3_tls.py
Normal file
65
tests/test_s3_tls.py
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:")
|
||||||
|
os.environ.setdefault("REDIS_URL", "redis://localhost:6379/0")
|
||||||
|
os.environ.setdefault("S3_ENDPOINT", "http://localhost:9000")
|
||||||
|
os.environ.setdefault("S3_ACCESS_KEY", "test")
|
||||||
|
os.environ.setdefault("S3_SECRET_KEY", "test")
|
||||||
|
os.environ.setdefault("S3_BUCKET", "test")
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.services.s3_storage import S3Storage
|
||||||
|
|
||||||
|
|
||||||
|
class S3TlsConfigTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self._backup = {
|
||||||
|
"S3_ENDPOINT": settings.S3_ENDPOINT,
|
||||||
|
"S3_ACCESS_KEY": settings.S3_ACCESS_KEY,
|
||||||
|
"S3_SECRET_KEY": settings.S3_SECRET_KEY,
|
||||||
|
"S3_BUCKET": settings.S3_BUCKET,
|
||||||
|
"S3_REGION": settings.S3_REGION,
|
||||||
|
"S3_USE_SSL": settings.S3_USE_SSL,
|
||||||
|
"S3_VERIFY_SSL": settings.S3_VERIFY_SSL,
|
||||||
|
"S3_CA_CERT_PATH": settings.S3_CA_CERT_PATH,
|
||||||
|
}
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
for key, value in self._backup.items():
|
||||||
|
setattr(settings, key, value)
|
||||||
|
|
||||||
|
def test_s3_client_uses_ca_bundle_for_verify(self):
|
||||||
|
settings.S3_ENDPOINT = "https://minio:9000"
|
||||||
|
settings.S3_ACCESS_KEY = "k"
|
||||||
|
settings.S3_SECRET_KEY = "s"
|
||||||
|
settings.S3_BUCKET = "b"
|
||||||
|
settings.S3_REGION = "us-east-1"
|
||||||
|
settings.S3_USE_SSL = True
|
||||||
|
settings.S3_VERIFY_SSL = True
|
||||||
|
settings.S3_CA_CERT_PATH = "/etc/ssl/minio/ca.crt"
|
||||||
|
|
||||||
|
with patch("app.services.s3_storage.boto3.client") as boto_client:
|
||||||
|
S3Storage()
|
||||||
|
|
||||||
|
kwargs = dict(boto_client.call_args.kwargs)
|
||||||
|
self.assertTrue(kwargs.get("use_ssl"))
|
||||||
|
self.assertEqual(kwargs.get("verify"), "/etc/ssl/minio/ca.crt")
|
||||||
|
|
||||||
|
def test_s3_client_can_disable_verify_in_non_prod(self):
|
||||||
|
settings.S3_ENDPOINT = "https://minio:9000"
|
||||||
|
settings.S3_ACCESS_KEY = "k"
|
||||||
|
settings.S3_SECRET_KEY = "s"
|
||||||
|
settings.S3_BUCKET = "b"
|
||||||
|
settings.S3_REGION = "us-east-1"
|
||||||
|
settings.S3_USE_SSL = True
|
||||||
|
settings.S3_VERIFY_SSL = False
|
||||||
|
settings.S3_CA_CERT_PATH = ""
|
||||||
|
|
||||||
|
with patch("app.services.s3_storage.boto3.client") as boto_client:
|
||||||
|
S3Storage()
|
||||||
|
|
||||||
|
kwargs = dict(boto_client.call_args.kwargs)
|
||||||
|
self.assertTrue(kwargs.get("use_ssl"))
|
||||||
|
self.assertFalse(kwargs.get("verify"))
|
||||||
|
|
@ -159,6 +159,43 @@ class SecurityAuditTests(unittest.TestCase):
|
||||||
self.assertEqual(str(row.attachment_id), attachment_id)
|
self.assertEqual(str(row.attachment_id), attachment_id)
|
||||||
self.assertEqual(row.scope, "REQUEST_ATTACHMENT")
|
self.assertEqual(row.scope, "REQUEST_ATTACHMENT")
|
||||||
|
|
||||||
|
def test_public_request_card_read_writes_pii_access_event(self):
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
req = Request(
|
||||||
|
track_number="TRK-SEC-READ-1",
|
||||||
|
client_name="Клиент",
|
||||||
|
client_phone="+79990001011",
|
||||||
|
topic_code="civil-law",
|
||||||
|
status_code="NEW",
|
||||||
|
extra_fields={},
|
||||||
|
)
|
||||||
|
db.add(req)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
public_token = create_jwt(
|
||||||
|
{"sub": "TRK-SEC-READ-1", "purpose": "VIEW_REQUEST"},
|
||||||
|
settings.PUBLIC_JWT_SECRET,
|
||||||
|
timedelta(days=1),
|
||||||
|
)
|
||||||
|
cookies = {settings.PUBLIC_COOKIE_NAME: public_token}
|
||||||
|
response = self.client.get("/api/public/requests/TRK-SEC-READ-1", cookies=cookies)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
rows = (
|
||||||
|
db.query(SecurityAuditLog)
|
||||||
|
.filter(
|
||||||
|
SecurityAuditLog.action == "READ_REQUEST_CARD",
|
||||||
|
SecurityAuditLog.scope == "REQUEST_CARD",
|
||||||
|
SecurityAuditLog.actor_role == "CLIENT",
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
self.assertGreaterEqual(len(rows), 1)
|
||||||
|
row = rows[-1]
|
||||||
|
self.assertTrue(row.allowed)
|
||||||
|
self.assertEqual(row.actor_subject, "TRK-SEC-READ-1")
|
||||||
|
|
||||||
def test_admin_object_proxy_denied_writes_security_deny_event(self):
|
def test_admin_object_proxy_denied_writes_security_deny_event(self):
|
||||||
fake_s3 = _FakeS3Storage()
|
fake_s3 = _FakeS3Storage()
|
||||||
with self.SessionLocal() as db:
|
with self.SessionLocal() as db:
|
||||||
|
|
|
||||||
124
tests/test_security_config.py
Normal file
124
tests/test_security_config.py
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:")
|
||||||
|
os.environ.setdefault("REDIS_URL", "redis://localhost:6379/0")
|
||||||
|
os.environ.setdefault("S3_ENDPOINT", "http://localhost:9000")
|
||||||
|
os.environ.setdefault("S3_ACCESS_KEY", "test")
|
||||||
|
os.environ.setdefault("S3_SECRET_KEY", "test")
|
||||||
|
os.environ.setdefault("S3_BUCKET", "test")
|
||||||
|
|
||||||
|
from app.core.config import settings, validate_production_security_or_raise
|
||||||
|
|
||||||
|
|
||||||
|
class SecurityConfigTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self._backup = {
|
||||||
|
"APP_ENV": settings.APP_ENV,
|
||||||
|
"PRODUCTION_ENFORCE_SECURE_SETTINGS": settings.PRODUCTION_ENFORCE_SECURE_SETTINGS,
|
||||||
|
"OTP_DEV_MODE": settings.OTP_DEV_MODE,
|
||||||
|
"ADMIN_BOOTSTRAP_ENABLED": settings.ADMIN_BOOTSTRAP_ENABLED,
|
||||||
|
"PUBLIC_COOKIE_SECURE": settings.PUBLIC_COOKIE_SECURE,
|
||||||
|
"PUBLIC_COOKIE_SAMESITE": settings.PUBLIC_COOKIE_SAMESITE,
|
||||||
|
"ADMIN_JWT_SECRET": settings.ADMIN_JWT_SECRET,
|
||||||
|
"PUBLIC_JWT_SECRET": settings.PUBLIC_JWT_SECRET,
|
||||||
|
"DATA_ENCRYPTION_SECRET": settings.DATA_ENCRYPTION_SECRET,
|
||||||
|
"DATA_ENCRYPTION_ACTIVE_KID": settings.DATA_ENCRYPTION_ACTIVE_KID,
|
||||||
|
"DATA_ENCRYPTION_KEYS": settings.DATA_ENCRYPTION_KEYS,
|
||||||
|
"CHAT_ENCRYPTION_SECRET": settings.CHAT_ENCRYPTION_SECRET,
|
||||||
|
"CHAT_ENCRYPTION_ACTIVE_KID": settings.CHAT_ENCRYPTION_ACTIVE_KID,
|
||||||
|
"CHAT_ENCRYPTION_KEYS": settings.CHAT_ENCRYPTION_KEYS,
|
||||||
|
"INTERNAL_SERVICE_TOKEN": settings.INTERNAL_SERVICE_TOKEN,
|
||||||
|
"MINIO_ROOT_USER": settings.MINIO_ROOT_USER,
|
||||||
|
"MINIO_ROOT_PASSWORD": settings.MINIO_ROOT_PASSWORD,
|
||||||
|
"MINIO_TLS_ENABLED": settings.MINIO_TLS_ENABLED,
|
||||||
|
"S3_USE_SSL": settings.S3_USE_SSL,
|
||||||
|
"S3_VERIFY_SSL": settings.S3_VERIFY_SSL,
|
||||||
|
"S3_ENDPOINT": settings.S3_ENDPOINT,
|
||||||
|
"S3_CA_CERT_PATH": settings.S3_CA_CERT_PATH,
|
||||||
|
"PUBLIC_STRICT_ORIGIN_CHECK": settings.PUBLIC_STRICT_ORIGIN_CHECK,
|
||||||
|
"PUBLIC_ALLOWED_WEB_ORIGINS": settings.PUBLIC_ALLOWED_WEB_ORIGINS,
|
||||||
|
"CORS_ORIGINS": settings.CORS_ORIGINS,
|
||||||
|
"CORS_ALLOW_METHODS": settings.CORS_ALLOW_METHODS,
|
||||||
|
"CORS_ALLOW_HEADERS": settings.CORS_ALLOW_HEADERS,
|
||||||
|
}
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
for key, value in self._backup.items():
|
||||||
|
setattr(settings, key, value)
|
||||||
|
|
||||||
|
def test_validate_production_security_detects_insecure_values(self):
|
||||||
|
settings.APP_ENV = "prod"
|
||||||
|
settings.PRODUCTION_ENFORCE_SECURE_SETTINGS = True
|
||||||
|
settings.OTP_DEV_MODE = True
|
||||||
|
settings.ADMIN_BOOTSTRAP_ENABLED = True
|
||||||
|
settings.PUBLIC_COOKIE_SECURE = False
|
||||||
|
settings.ADMIN_JWT_SECRET = "change_me_admin"
|
||||||
|
settings.PUBLIC_JWT_SECRET = "change_me_public"
|
||||||
|
settings.DATA_ENCRYPTION_SECRET = "change_me_data_encryption"
|
||||||
|
settings.DATA_ENCRYPTION_ACTIVE_KID = ""
|
||||||
|
settings.DATA_ENCRYPTION_KEYS = ""
|
||||||
|
settings.CHAT_ENCRYPTION_SECRET = ""
|
||||||
|
settings.CHAT_ENCRYPTION_ACTIVE_KID = ""
|
||||||
|
settings.CHAT_ENCRYPTION_KEYS = ""
|
||||||
|
settings.INTERNAL_SERVICE_TOKEN = "change_me_internal_service_token"
|
||||||
|
settings.MINIO_ROOT_USER = "minioadmin"
|
||||||
|
settings.MINIO_ROOT_PASSWORD = "minioadmin"
|
||||||
|
settings.MINIO_TLS_ENABLED = False
|
||||||
|
settings.S3_USE_SSL = False
|
||||||
|
settings.S3_VERIFY_SSL = False
|
||||||
|
settings.S3_ENDPOINT = "http://minio:9000"
|
||||||
|
settings.S3_CA_CERT_PATH = ""
|
||||||
|
settings.PUBLIC_STRICT_ORIGIN_CHECK = True
|
||||||
|
settings.PUBLIC_ALLOWED_WEB_ORIGINS = "http://localhost:8080,https://ruakb.ru"
|
||||||
|
settings.CORS_ORIGINS = "*,http://localhost:8081"
|
||||||
|
settings.CORS_ALLOW_METHODS = "GET,POST,*"
|
||||||
|
settings.CORS_ALLOW_HEADERS = "Authorization,*"
|
||||||
|
|
||||||
|
with self.assertRaises(RuntimeError) as exc:
|
||||||
|
validate_production_security_or_raise("test")
|
||||||
|
detail = str(exc.exception)
|
||||||
|
self.assertIn("OTP_DEV_MODE", detail)
|
||||||
|
self.assertIn("ADMIN_BOOTSTRAP_ENABLED", detail)
|
||||||
|
self.assertIn("MINIO_ROOT_USER", detail)
|
||||||
|
self.assertIn("MINIO_TLS_ENABLED", detail)
|
||||||
|
self.assertIn("S3_USE_SSL", detail)
|
||||||
|
self.assertIn("S3_VERIFY_SSL", detail)
|
||||||
|
self.assertIn("S3_ENDPOINT", detail)
|
||||||
|
self.assertIn("S3_CA_CERT_PATH", detail)
|
||||||
|
self.assertIn("DATA_ENCRYPTION_ACTIVE_KID", detail)
|
||||||
|
self.assertIn("PUBLIC_ALLOWED_WEB_ORIGINS", detail)
|
||||||
|
self.assertIn("CORS_ORIGINS", detail)
|
||||||
|
self.assertIn("CORS_ALLOW_METHODS", detail)
|
||||||
|
self.assertIn("CORS_ALLOW_HEADERS", detail)
|
||||||
|
|
||||||
|
def test_validate_production_security_passes_for_hardened_values(self):
|
||||||
|
settings.APP_ENV = "production"
|
||||||
|
settings.PRODUCTION_ENFORCE_SECURE_SETTINGS = True
|
||||||
|
settings.OTP_DEV_MODE = False
|
||||||
|
settings.ADMIN_BOOTSTRAP_ENABLED = False
|
||||||
|
settings.PUBLIC_COOKIE_SECURE = True
|
||||||
|
settings.PUBLIC_COOKIE_SAMESITE = "lax"
|
||||||
|
settings.ADMIN_JWT_SECRET = "AdminJwtSecret_2026_Strong_Long"
|
||||||
|
settings.PUBLIC_JWT_SECRET = "PublicJwtSecret_2026_Strong_Long"
|
||||||
|
settings.DATA_ENCRYPTION_SECRET = "DataEncryptionSecret_2026_Strong_Long"
|
||||||
|
settings.DATA_ENCRYPTION_ACTIVE_KID = "k202603"
|
||||||
|
settings.DATA_ENCRYPTION_KEYS = "k202603=DataEncryptionSecret_2026_Strong_Long"
|
||||||
|
settings.CHAT_ENCRYPTION_SECRET = "ChatEncryptionSecret_2026_Strong_Long"
|
||||||
|
settings.CHAT_ENCRYPTION_ACTIVE_KID = "k202603"
|
||||||
|
settings.CHAT_ENCRYPTION_KEYS = "k202603=ChatEncryptionSecret_2026_Strong_Long"
|
||||||
|
settings.INTERNAL_SERVICE_TOKEN = "InternalServiceToken_2026_Strong_Long"
|
||||||
|
settings.MINIO_ROOT_USER = "law_prod_minio_user"
|
||||||
|
settings.MINIO_ROOT_PASSWORD = "LawProdMinioSecretKey_2026_Strong"
|
||||||
|
settings.MINIO_TLS_ENABLED = True
|
||||||
|
settings.S3_USE_SSL = True
|
||||||
|
settings.S3_VERIFY_SSL = True
|
||||||
|
settings.S3_ENDPOINT = "https://minio:9000"
|
||||||
|
settings.S3_CA_CERT_PATH = "/etc/ssl/minio/ca.crt"
|
||||||
|
settings.PUBLIC_STRICT_ORIGIN_CHECK = True
|
||||||
|
settings.PUBLIC_ALLOWED_WEB_ORIGINS = "https://ruakb.ru,https://www.ruakb.ru,https://ruakb.online"
|
||||||
|
settings.CORS_ORIGINS = "https://ruakb.ru,https://www.ruakb.ru,https://ruakb.online,https://www.ruakb.online"
|
||||||
|
settings.CORS_ALLOW_METHODS = "GET,POST,PUT,PATCH,DELETE,OPTIONS"
|
||||||
|
settings.CORS_ALLOW_HEADERS = "Authorization,Content-Type,X-Requested-With,X-Request-ID"
|
||||||
|
|
||||||
|
validate_production_security_or_raise("test")
|
||||||
|
|
@ -15,10 +15,13 @@ os.environ.setdefault("S3_SECRET_KEY", "test")
|
||||||
os.environ.setdefault("S3_BUCKET", "test")
|
os.environ.setdefault("S3_BUCKET", "test")
|
||||||
|
|
||||||
from app.models.attachment import Attachment
|
from app.models.attachment import Attachment
|
||||||
|
from app.models.audit_log import AuditLog
|
||||||
|
from app.models.data_retention_policy import DataRetentionPolicy
|
||||||
from app.models.message import Message
|
from app.models.message import Message
|
||||||
from app.models.notification import Notification
|
from app.models.notification import Notification
|
||||||
from app.models.otp_session import OtpSession
|
from app.models.otp_session import OtpSession
|
||||||
from app.models.request import Request
|
from app.models.request import Request
|
||||||
|
from app.models.security_audit_log import SecurityAuditLog
|
||||||
from app.models.status import Status
|
from app.models.status import Status
|
||||||
from app.models.status_history import StatusHistory
|
from app.models.status_history import StatusHistory
|
||||||
from app.models.topic_status_transition import TopicStatusTransition
|
from app.models.topic_status_transition import TopicStatusTransition
|
||||||
|
|
@ -37,6 +40,9 @@ class WorkerMaintenanceTaskTests(unittest.TestCase):
|
||||||
)
|
)
|
||||||
cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False)
|
cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False)
|
||||||
OtpSession.__table__.create(bind=cls.engine)
|
OtpSession.__table__.create(bind=cls.engine)
|
||||||
|
DataRetentionPolicy.__table__.create(bind=cls.engine)
|
||||||
|
AuditLog.__table__.create(bind=cls.engine)
|
||||||
|
SecurityAuditLog.__table__.create(bind=cls.engine)
|
||||||
Request.__table__.create(bind=cls.engine)
|
Request.__table__.create(bind=cls.engine)
|
||||||
Attachment.__table__.create(bind=cls.engine)
|
Attachment.__table__.create(bind=cls.engine)
|
||||||
Status.__table__.create(bind=cls.engine)
|
Status.__table__.create(bind=cls.engine)
|
||||||
|
|
@ -64,6 +70,9 @@ class WorkerMaintenanceTaskTests(unittest.TestCase):
|
||||||
Status.__table__.drop(bind=cls.engine)
|
Status.__table__.drop(bind=cls.engine)
|
||||||
Attachment.__table__.drop(bind=cls.engine)
|
Attachment.__table__.drop(bind=cls.engine)
|
||||||
Request.__table__.drop(bind=cls.engine)
|
Request.__table__.drop(bind=cls.engine)
|
||||||
|
SecurityAuditLog.__table__.drop(bind=cls.engine)
|
||||||
|
AuditLog.__table__.drop(bind=cls.engine)
|
||||||
|
DataRetentionPolicy.__table__.drop(bind=cls.engine)
|
||||||
OtpSession.__table__.drop(bind=cls.engine)
|
OtpSession.__table__.drop(bind=cls.engine)
|
||||||
cls.engine.dispose()
|
cls.engine.dispose()
|
||||||
|
|
||||||
|
|
@ -76,6 +85,9 @@ class WorkerMaintenanceTaskTests(unittest.TestCase):
|
||||||
db.execute(delete(Notification))
|
db.execute(delete(Notification))
|
||||||
db.execute(delete(Attachment))
|
db.execute(delete(Attachment))
|
||||||
db.execute(delete(Request))
|
db.execute(delete(Request))
|
||||||
|
db.execute(delete(SecurityAuditLog))
|
||||||
|
db.execute(delete(AuditLog))
|
||||||
|
db.execute(delete(DataRetentionPolicy))
|
||||||
db.execute(delete(OtpSession))
|
db.execute(delete(OtpSession))
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
@ -113,6 +125,167 @@ class WorkerMaintenanceTaskTests(unittest.TestCase):
|
||||||
self.assertEqual(len(remaining), 1)
|
self.assertEqual(len(remaining), 1)
|
||||||
self.assertEqual(remaining[0].track_number, "TRK-ACT")
|
self.assertEqual(remaining[0].track_number, "TRK-ACT")
|
||||||
|
|
||||||
|
def test_cleanup_pii_retention_deletes_old_rows_by_policy(self):
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
old = now - timedelta(days=10)
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
db.add_all(
|
||||||
|
[
|
||||||
|
DataRetentionPolicy(
|
||||||
|
entity="otp_sessions",
|
||||||
|
retention_days=1,
|
||||||
|
enabled=True,
|
||||||
|
hard_delete=True,
|
||||||
|
description="OTP",
|
||||||
|
responsible="test",
|
||||||
|
),
|
||||||
|
DataRetentionPolicy(
|
||||||
|
entity="notifications",
|
||||||
|
retention_days=1,
|
||||||
|
enabled=True,
|
||||||
|
hard_delete=True,
|
||||||
|
description="Notifications",
|
||||||
|
responsible="test",
|
||||||
|
),
|
||||||
|
DataRetentionPolicy(
|
||||||
|
entity="audit_log",
|
||||||
|
retention_days=1,
|
||||||
|
enabled=True,
|
||||||
|
hard_delete=True,
|
||||||
|
description="Audit",
|
||||||
|
responsible="test",
|
||||||
|
),
|
||||||
|
DataRetentionPolicy(
|
||||||
|
entity="security_audit_log",
|
||||||
|
retention_days=1,
|
||||||
|
enabled=True,
|
||||||
|
hard_delete=True,
|
||||||
|
description="Security audit",
|
||||||
|
responsible="test",
|
||||||
|
),
|
||||||
|
DataRetentionPolicy(
|
||||||
|
entity="requests",
|
||||||
|
retention_days=3650,
|
||||||
|
enabled=False,
|
||||||
|
hard_delete=True,
|
||||||
|
description="Requests disabled",
|
||||||
|
responsible="test",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
req = Request(
|
||||||
|
track_number="TRK-RET-1",
|
||||||
|
client_name="Клиент",
|
||||||
|
client_phone="+79990003001",
|
||||||
|
topic_code="civil",
|
||||||
|
status_code="NEW",
|
||||||
|
extra_fields={},
|
||||||
|
created_at=old,
|
||||||
|
updated_at=old,
|
||||||
|
)
|
||||||
|
db.add(req)
|
||||||
|
db.flush()
|
||||||
|
db.add_all(
|
||||||
|
[
|
||||||
|
OtpSession(
|
||||||
|
purpose="VIEW_REQUEST",
|
||||||
|
track_number="TRK-RET-OTP-OLD",
|
||||||
|
phone="+79990000011",
|
||||||
|
code_hash="old",
|
||||||
|
attempts=0,
|
||||||
|
expires_at=now + timedelta(minutes=5),
|
||||||
|
created_at=old,
|
||||||
|
updated_at=old,
|
||||||
|
),
|
||||||
|
OtpSession(
|
||||||
|
purpose="VIEW_REQUEST",
|
||||||
|
track_number="TRK-RET-OTP-NEW",
|
||||||
|
phone="+79990000012",
|
||||||
|
code_hash="new",
|
||||||
|
attempts=0,
|
||||||
|
expires_at=now + timedelta(minutes=5),
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
),
|
||||||
|
Notification(
|
||||||
|
request_id=req.id,
|
||||||
|
recipient_type="CLIENT",
|
||||||
|
recipient_track_number=req.track_number,
|
||||||
|
event_type="STATUS_CHANGED",
|
||||||
|
title="old",
|
||||||
|
body="old",
|
||||||
|
payload={},
|
||||||
|
created_at=old,
|
||||||
|
updated_at=old,
|
||||||
|
),
|
||||||
|
Notification(
|
||||||
|
request_id=req.id,
|
||||||
|
recipient_type="CLIENT",
|
||||||
|
recipient_track_number=req.track_number,
|
||||||
|
event_type="STATUS_CHANGED",
|
||||||
|
title="new",
|
||||||
|
body="new",
|
||||||
|
payload={},
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
),
|
||||||
|
AuditLog(
|
||||||
|
entity="requests",
|
||||||
|
entity_id=str(req.id),
|
||||||
|
action="READ",
|
||||||
|
diff={},
|
||||||
|
created_at=old,
|
||||||
|
updated_at=old,
|
||||||
|
),
|
||||||
|
AuditLog(
|
||||||
|
entity="requests",
|
||||||
|
entity_id=str(req.id),
|
||||||
|
action="READ",
|
||||||
|
diff={},
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
),
|
||||||
|
SecurityAuditLog(
|
||||||
|
actor_role="CLIENT",
|
||||||
|
actor_subject=req.track_number,
|
||||||
|
actor_ip="127.0.0.1",
|
||||||
|
action="READ_REQUEST_CARD",
|
||||||
|
scope="REQUEST",
|
||||||
|
request_id=req.id,
|
||||||
|
allowed=True,
|
||||||
|
details={},
|
||||||
|
created_at=old,
|
||||||
|
updated_at=old,
|
||||||
|
),
|
||||||
|
SecurityAuditLog(
|
||||||
|
actor_role="CLIENT",
|
||||||
|
actor_subject=req.track_number,
|
||||||
|
actor_ip="127.0.0.1",
|
||||||
|
action="READ_REQUEST_CARD",
|
||||||
|
scope="REQUEST",
|
||||||
|
request_id=req.id,
|
||||||
|
allowed=True,
|
||||||
|
details={},
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
result = security_task.cleanup_pii_retention()
|
||||||
|
deleted = dict(result.get("deleted") or {})
|
||||||
|
self.assertGreaterEqual(int(deleted.get("otp_sessions", 0)), 1)
|
||||||
|
self.assertGreaterEqual(int(deleted.get("notifications", 0)), 1)
|
||||||
|
self.assertGreaterEqual(int(deleted.get("audit_log", 0)), 1)
|
||||||
|
self.assertGreaterEqual(int(deleted.get("security_audit_log", 0)), 1)
|
||||||
|
|
||||||
|
with self.SessionLocal() as db:
|
||||||
|
self.assertEqual(db.query(OtpSession).count(), 1)
|
||||||
|
self.assertEqual(db.query(Notification).count(), 1)
|
||||||
|
self.assertEqual(db.query(AuditLog).count(), 1)
|
||||||
|
self.assertEqual(db.query(SecurityAuditLog).count(), 1)
|
||||||
|
|
||||||
def test_cleanup_stale_uploads_removes_invalid_and_fixes_totals(self):
|
def test_cleanup_stale_uploads_removes_invalid_and_fixes_totals(self):
|
||||||
with self.SessionLocal() as db:
|
with self.SessionLocal() as db:
|
||||||
req1 = Request(
|
req1 = Request(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue