Безопасность довёл до ума — Cursor-генерацию переписал руками. IDOR закрыл, CSRF задушил, refresh rotation теперь как надо. HSTS на staging, ENABLE_DOCS=false, install.env recovery протестил. Backend: - jwt_denylist + auth_epoch: мгновенный revoke access JWT (logout/block/reset) - auth/admin/users: bump epoch, logout с Bearer, forgot_password skip для blocked - install_secrets: путь всегда apps/api/data/secrets/ (bootstrap из корня не ломает Docker) - seed: SEED_DEMO_USERS=false на prod/staging - тесты: jwt revoke, integration, coverage gate 90% Frontend: - logout шлёт Bearer, обработка TOKEN_REVOKED - guards TypeScript fix - E2E: blocked user → 401 сразу после block Infra: - staging/prod compose, TLS nginx, deploy-скрипты - k6 §17.2, backup/health/smoke scripts Docs: - docs/ на русском: project, security, deploy, release (старые md слили) - README короткий + план ТЗ + стандартные логины dev Код готов к плаванию. Капитан может идти писать фронт.
193 lines
6.4 KiB
Python
193 lines
6.4 KiB
Python
from __future__ import annotations
|
|
|
|
import os
|
|
from dataclasses import dataclass
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
from urllib.parse import ParseResult, urlparse, urlunparse
|
|
from uuid import uuid4
|
|
|
|
from app.core.crypto import generate_install_bundle
|
|
|
|
_API_ROOT = Path(__file__).resolve().parents[2]
|
|
INSTALL_SECRETS_DIR = _API_ROOT / "data" / "secrets"
|
|
INSTALL_SECRETS_FILE = INSTALL_SECRETS_DIR / "install.env"
|
|
INSTALL_SECRETS_META_FILE = INSTALL_SECRETS_DIR / "install.meta.json"
|
|
REQUIRED_KEYS = (
|
|
"POSTGRES_USER",
|
|
"POSTGRES_PASSWORD",
|
|
"POSTGRES_DB",
|
|
"DATABASE_URL",
|
|
"JWT_ACCESS_SECRET",
|
|
"JWT_REFRESH_PEPPER",
|
|
"S3_ACCESS_KEY",
|
|
"S3_SECRET_KEY",
|
|
"MINIO_ROOT_USER",
|
|
"MINIO_ROOT_PASSWORD",
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class InstallSecretsStatus:
|
|
initialized: bool
|
|
locked: bool
|
|
path: str
|
|
created: bool
|
|
|
|
|
|
def _parse_env_text(raw: str) -> dict[str, str]:
|
|
values: dict[str, str] = {}
|
|
for line in raw.splitlines():
|
|
line = line.strip()
|
|
if not line or line.startswith("#") or "=" not in line:
|
|
continue
|
|
key, value = line.split("=", 1)
|
|
values[key.strip()] = value.strip()
|
|
return values
|
|
|
|
|
|
def _render_env(values: dict[str, str]) -> str:
|
|
ordered = [f"{key}={values[key]}" for key in sorted(values.keys())]
|
|
return "\n".join(ordered) + "\n"
|
|
|
|
|
|
def read_install_secrets() -> dict[str, str]:
|
|
if not INSTALL_SECRETS_FILE.exists():
|
|
return {}
|
|
return _parse_env_text(INSTALL_SECRETS_FILE.read_text(encoding="utf-8"))
|
|
|
|
|
|
def _write_install_secrets(values: dict[str, str]) -> None:
|
|
INSTALL_SECRETS_DIR.mkdir(parents=True, exist_ok=True)
|
|
INSTALL_SECRETS_FILE.write_text(_render_env(values), encoding="utf-8")
|
|
|
|
|
|
def _write_meta() -> None:
|
|
payload = (
|
|
"{\n"
|
|
f' "install_id": "{uuid4()}",\n'
|
|
f' "locked_at": "{datetime.now(UTC).isoformat()}"\n'
|
|
"}\n"
|
|
)
|
|
INSTALL_SECRETS_META_FILE.write_text(payload, encoding="utf-8")
|
|
|
|
|
|
def _adopt_from_environment() -> dict[str, str]:
|
|
env_values = {key: os.getenv(key, "") for key in REQUIRED_KEYS}
|
|
db_url = os.getenv("DATABASE_URL", "")
|
|
if db_url:
|
|
parsed = urlparse(db_url)
|
|
if parsed.username:
|
|
env_values["POSTGRES_USER"] = parsed.username
|
|
if parsed.password:
|
|
env_values["POSTGRES_PASSWORD"] = parsed.password
|
|
if parsed.path and parsed.path != "/":
|
|
env_values["POSTGRES_DB"] = parsed.path.lstrip("/")
|
|
return {key: value for key, value in env_values.items() if value}
|
|
|
|
|
|
def _sync_minio_s3_secrets(values: dict[str, str]) -> dict[str, str]:
|
|
"""MinIO root credentials are the S3 access key pair — keep them aligned."""
|
|
if (
|
|
values.get("S3_ACCESS_KEY") == values.get("MINIO_ROOT_USER")
|
|
and values.get("MINIO_ROOT_PASSWORD")
|
|
and values.get("S3_SECRET_KEY") != values["MINIO_ROOT_PASSWORD"]
|
|
):
|
|
values = dict(values)
|
|
values["S3_SECRET_KEY"] = values["MINIO_ROOT_PASSWORD"]
|
|
return values
|
|
|
|
|
|
def load_install_secrets_to_env() -> None:
|
|
values = _sync_minio_s3_secrets(read_install_secrets())
|
|
for key, value in values.items():
|
|
os.environ[key] = value
|
|
|
|
|
|
def ensure_install_secrets() -> InstallSecretsStatus:
|
|
existing = read_install_secrets()
|
|
if existing:
|
|
synced = _sync_minio_s3_secrets(existing)
|
|
if synced != existing:
|
|
synced["SECRETS_LOCKED"] = existing.get("SECRETS_LOCKED", "true")
|
|
_write_install_secrets(synced)
|
|
existing = synced
|
|
load_install_secrets_to_env()
|
|
return InstallSecretsStatus(True, existing.get("SECRETS_LOCKED", "false") == "true", str(INSTALL_SECRETS_FILE), False)
|
|
|
|
adopted = _adopt_from_environment()
|
|
generated = generate_install_bundle()
|
|
values = generated | adopted
|
|
values["SECRETS_LOCKED"] = "true"
|
|
_write_install_secrets(values)
|
|
_write_meta()
|
|
load_install_secrets_to_env()
|
|
return InstallSecretsStatus(True, True, str(INSTALL_SECRETS_FILE), True)
|
|
|
|
|
|
def masked_database_url(database_url: str) -> str:
|
|
parsed = urlparse(database_url)
|
|
if not parsed.username:
|
|
return database_url
|
|
password = "***" if parsed.password else ""
|
|
credentials = f"{parsed.username}:{password}" if password else parsed.username
|
|
host = parsed.hostname or ""
|
|
if parsed.port:
|
|
host = f"{host}:{parsed.port}"
|
|
netloc = f"{credentials}@{host}"
|
|
sanitized = ParseResult(
|
|
scheme=parsed.scheme,
|
|
netloc=netloc,
|
|
path=parsed.path,
|
|
params=parsed.params,
|
|
query=parsed.query,
|
|
fragment=parsed.fragment,
|
|
)
|
|
return urlunparse(sanitized)
|
|
|
|
|
|
def install_secrets_payload() -> dict:
|
|
values = read_install_secrets()
|
|
db = urlparse(values.get("DATABASE_URL", ""))
|
|
return {
|
|
"initialized": bool(values),
|
|
"locked": values.get("SECRETS_LOCKED") == "true",
|
|
"secrets_path": str(INSTALL_SECRETS_FILE),
|
|
"database": {
|
|
"host": db.hostname,
|
|
"port": db.port,
|
|
"database": db.path.lstrip("/") if db.path else "",
|
|
"user": db.username,
|
|
"password_configured": bool(values.get("POSTGRES_PASSWORD")),
|
|
},
|
|
"connection_string_masked": masked_database_url(values.get("DATABASE_URL", "")),
|
|
"secrets_status": {
|
|
"jwt_access_secret": "configured" if bool(values.get("JWT_ACCESS_SECRET")) else "missing",
|
|
"jwt_refresh_pepper": "configured" if bool(values.get("JWT_REFRESH_PEPPER")) else "missing",
|
|
"postgres_password": "configured" if bool(values.get("POSTGRES_PASSWORD")) else "missing",
|
|
"s3_secret_key": "configured" if bool(values.get("S3_SECRET_KEY")) else "missing",
|
|
"password_bcrypt_salt": "per_user_in_db",
|
|
},
|
|
}
|
|
|
|
|
|
def reveal_install_secret(key: str) -> str:
|
|
mapping = {
|
|
"database_password": "POSTGRES_PASSWORD",
|
|
"jwt_access_secret": "JWT_ACCESS_SECRET",
|
|
"jwt_refresh_pepper": "JWT_REFRESH_PEPPER",
|
|
"s3_secret_key": "S3_SECRET_KEY",
|
|
}
|
|
env_key = mapping.get(key)
|
|
if not env_key:
|
|
raise ValueError("UNSUPPORTED_SECRET_KEY")
|
|
values = read_install_secrets()
|
|
if env_key in values:
|
|
return values[env_key]
|
|
if env_key == "POSTGRES_PASSWORD":
|
|
database_url = values.get("DATABASE_URL") or os.getenv("DATABASE_URL", "")
|
|
parsed = urlparse(database_url)
|
|
return parsed.password or ""
|
|
return os.getenv(env_key, "")
|
|
|