Files
security-alert-center/backend/app/api/v1/settings.py
T
PapaTramp 154ba3efc2 feat: Windows admin in Settings UI and WinRM host test (0.10.1)
Store domain admin in ui_settings (DB overrides env); qwinsta/logoff use effective config.
Host card: POST /hosts/{id}/actions/winrm-test via pywinrm.
2026-06-20 00:01:17 +10:00

496 lines
16 KiB
Python

from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.auth.jwt_auth import require_admin
from app.database import get_db
from app.services.email_notify import EmailNotConfiguredError, EmailSendError, send_email_test_message
from app.services.notification_policy import (
NotificationPolicyConfig,
get_effective_notification_policy,
upsert_notification_policy,
)
from app.services.notification_settings import (
VALID_SEVERITIES,
EmailConfig,
TelegramConfig,
WebhookConfig,
get_effective_email_config,
get_effective_telegram_config,
get_effective_webhook_config,
upsert_email_channel,
upsert_telegram_channel,
upsert_webhook_channel,
)
from app.services.telegram_notify import TelegramNotConfiguredError, TelegramSendError, send_telegram_test_message
from app.services.webhook_notify import (
WebhookNotConfiguredError,
WebhookSendError,
send_webhook_test_message,
)
from app.services.event_severity_overrides import (
SOURCE_DB,
list_severity_override_items,
replace_severity_overrides,
)
from app.services.ui_settings import (
get_effective_ui_settings,
upsert_ui_settings,
)
from app.services.win_admin_settings import (
get_effective_win_admin_config,
upsert_win_admin_settings,
)
from app.services.winrm_connect import (
HostNotWindowsError,
HostTargetMissingError,
WinAdminNotConfiguredError,
resolve_windows_host_target,
test_winrm_connection,
)
router = APIRouter(prefix="/settings", tags=["settings"])
def _mask_secret(value: str, *, visible_tail: int = 4) -> str | None:
text = value.strip()
if not text:
return None
if len(text) <= visible_tail:
return "*" * len(text)
return f"{'*' * 8}{text[-visible_tail:]}"
def _mask_url(value: str) -> str | None:
text = value.strip()
if not text:
return None
if len(text) <= 20:
return f"{'*' * 6}{text[-4:]}"
return f"{text[:16]}{text[-6:]}"
class TelegramSettingsResponse(BaseModel):
enabled: bool
configured: bool
chat_id_hint: str | None = None
bot_token_hint: str | None = None
min_severity: str
source: str = Field(description="env или db")
class WebhookSettingsResponse(BaseModel):
enabled: bool
configured: bool
url_hint: str | None = None
secret_header: str | None = None
secret_hint: str | None = None
min_severity: str
source: str = Field(description="env или db")
class EmailSettingsResponse(BaseModel):
enabled: bool
configured: bool
smtp_host_hint: str | None = None
smtp_port: int
smtp_user: str | None = None
smtp_password_hint: str | None = None
mail_from: str | None = None
mail_to_hint: str | None = None
smtp_starttls: bool
smtp_ssl: bool
min_severity: str
source: str = Field(description="env или db")
class NotificationPolicyResponse(BaseModel):
min_severity: str
use_telegram: bool
use_webhook: bool
use_email: bool
use_mobile: bool
source: str = Field(description="env или db")
class NotificationSettingsResponse(BaseModel):
policy: NotificationPolicyResponse
telegram: TelegramSettingsResponse
webhook: WebhookSettingsResponse
email: EmailSettingsResponse
class NotificationPolicyUpdate(BaseModel):
min_severity: str
use_telegram: bool
use_webhook: bool
use_email: bool
use_mobile: bool = False
class TelegramSettingsUpdate(BaseModel):
enabled: bool
bot_token: str | None = None
chat_id: str | None = None
class WebhookSettingsUpdate(BaseModel):
enabled: bool
url: str | None = None
secret_header: str | None = None
secret: str | None = None
class EmailSettingsUpdate(BaseModel):
enabled: bool
smtp_host: str | None = None
smtp_port: int | None = None
smtp_user: str | None = None
smtp_password: str | None = None
mail_from: str | None = None
mail_to: str | None = None
smtp_starttls: bool | None = None
smtp_ssl: bool | None = None
class ChannelTestResponse(BaseModel):
ok: bool = True
message: str
def _policy_to_response(cfg: NotificationPolicyConfig) -> NotificationPolicyResponse:
return NotificationPolicyResponse(
min_severity=cfg.min_severity,
use_telegram=cfg.use_telegram,
use_webhook=cfg.use_webhook,
use_email=cfg.use_email,
use_mobile=cfg.use_mobile,
source=cfg.source,
)
def _telegram_to_response(cfg: TelegramConfig, policy: NotificationPolicyConfig) -> TelegramSettingsResponse:
return TelegramSettingsResponse(
enabled=cfg.enabled,
configured=cfg.configured,
chat_id_hint=_mask_secret(cfg.chat_id),
bot_token_hint=_mask_secret(cfg.bot_token),
min_severity=policy.min_severity,
source=cfg.source,
)
def _webhook_to_response(cfg: WebhookConfig, policy: NotificationPolicyConfig) -> WebhookSettingsResponse:
return WebhookSettingsResponse(
enabled=cfg.enabled,
configured=cfg.configured,
url_hint=_mask_url(cfg.url),
secret_header=cfg.secret_header or None,
secret_hint=_mask_secret(cfg.secret),
min_severity=policy.min_severity,
source=cfg.source,
)
def _email_to_response(cfg: EmailConfig, policy: NotificationPolicyConfig) -> EmailSettingsResponse:
return EmailSettingsResponse(
enabled=cfg.enabled,
configured=cfg.configured,
smtp_host_hint=_mask_url(cfg.smtp_host) if cfg.smtp_host else None,
smtp_port=cfg.smtp_port,
smtp_user=cfg.smtp_user or None,
smtp_password_hint=_mask_secret(cfg.smtp_password),
mail_from=cfg.mail_from or None,
mail_to_hint=_mask_secret(cfg.mail_to.replace(";", ",")) if cfg.mail_to else None,
smtp_starttls=cfg.smtp_starttls,
smtp_ssl=cfg.smtp_ssl,
min_severity=policy.min_severity,
source=cfg.source,
)
@router.get("/notifications", response_model=NotificationSettingsResponse)
def get_notification_settings(
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> NotificationSettingsResponse:
policy = get_effective_notification_policy(db)
return NotificationSettingsResponse(
policy=_policy_to_response(policy),
telegram=_telegram_to_response(get_effective_telegram_config(db), policy),
webhook=_webhook_to_response(get_effective_webhook_config(db), policy),
email=_email_to_response(get_effective_email_config(db), policy),
)
@router.put("/notifications/policy", response_model=NotificationPolicyResponse)
def update_notification_policy(
body: NotificationPolicyUpdate,
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> NotificationPolicyResponse:
if body.min_severity not in VALID_SEVERITIES:
raise HTTPException(status_code=422, detail=f"min_severity must be one of: {sorted(VALID_SEVERITIES)}")
if not any((body.use_telegram, body.use_webhook, body.use_email, body.use_mobile)):
raise HTTPException(status_code=422, detail="Выберите хотя бы один канал")
try:
policy = upsert_notification_policy(
db,
min_severity=body.min_severity,
use_telegram=body.use_telegram,
use_webhook=body.use_webhook,
use_email=body.use_email,
use_mobile=body.use_mobile,
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
return _policy_to_response(policy)
@router.put("/notifications/telegram", response_model=TelegramSettingsResponse)
def update_telegram_settings(
body: TelegramSettingsUpdate,
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> TelegramSettingsResponse:
try:
cfg = upsert_telegram_channel(
db,
enabled=body.enabled,
bot_token=body.bot_token,
chat_id=body.chat_id,
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
policy = get_effective_notification_policy(db)
return _telegram_to_response(cfg, policy)
@router.put("/notifications/webhook", response_model=WebhookSettingsResponse)
def update_webhook_settings(
body: WebhookSettingsUpdate,
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> WebhookSettingsResponse:
try:
cfg = upsert_webhook_channel(
db,
enabled=body.enabled,
url=body.url,
secret_header=body.secret_header,
secret=body.secret,
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
policy = get_effective_notification_policy(db)
return _webhook_to_response(cfg, policy)
@router.put("/notifications/email", response_model=EmailSettingsResponse)
def update_email_settings(
body: EmailSettingsUpdate,
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> EmailSettingsResponse:
try:
cfg = upsert_email_channel(
db,
enabled=body.enabled,
smtp_host=body.smtp_host,
smtp_port=body.smtp_port,
smtp_user=body.smtp_user,
smtp_password=body.smtp_password,
mail_from=body.mail_from,
mail_to=body.mail_to,
smtp_starttls=body.smtp_starttls,
smtp_ssl=body.smtp_ssl,
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
policy = get_effective_notification_policy(db)
return _email_to_response(cfg, policy)
@router.post("/notifications/telegram/test", response_model=ChannelTestResponse)
def test_telegram_settings(
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> ChannelTestResponse:
cfg = get_effective_telegram_config(db)
try:
send_telegram_test_message(config=cfg)
except TelegramNotConfiguredError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except TelegramSendError as exc:
code = exc.status_code if exc.status_code and 400 <= exc.status_code < 500 else 502
raise HTTPException(status_code=code, detail=str(exc)) from exc
return ChannelTestResponse(message="Тестовое сообщение отправлено в Telegram")
@router.post("/notifications/webhook/test", response_model=ChannelTestResponse)
def test_webhook_settings(
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> ChannelTestResponse:
cfg = get_effective_webhook_config(db)
try:
send_webhook_test_message(config=cfg)
except WebhookNotConfiguredError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except WebhookSendError as exc:
code = exc.status_code if exc.status_code and 400 <= exc.status_code < 500 else 502
raise HTTPException(status_code=code, detail=str(exc)) from exc
return ChannelTestResponse(message="Тестовый POST отправлен на webhook URL")
@router.post("/notifications/email/test", response_model=ChannelTestResponse)
def test_email_settings(
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> ChannelTestResponse:
cfg = get_effective_email_config(db)
try:
send_email_test_message(config=cfg)
except EmailNotConfiguredError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except EmailSendError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return ChannelTestResponse(message="Тестовое письмо отправлено по SMTP")
class EventSeverityOverrideRow(BaseModel):
event_type: str
default_severity: str
override_severity: str | None = None
class EventSeverityOverridesResponse(BaseModel):
items: list[EventSeverityOverrideRow]
source: str = Field(description="db — overrides хранятся в PostgreSQL")
class EventSeverityOverridesUpdate(BaseModel):
overrides: dict[str, str | None] = Field(
description="event_type → severity; null или пустая строка сбрасывает override",
)
@router.get("/notifications/severity-overrides", response_model=EventSeverityOverridesResponse)
def get_severity_overrides(
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> EventSeverityOverridesResponse:
items = list_severity_override_items(db)
return EventSeverityOverridesResponse(
items=[
EventSeverityOverrideRow(
event_type=i.event_type,
default_severity=i.default_severity,
override_severity=i.override_severity,
)
for i in items
],
source=SOURCE_DB,
)
@router.put("/notifications/severity-overrides", response_model=EventSeverityOverridesResponse)
def update_severity_overrides(
body: EventSeverityOverridesUpdate,
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> EventSeverityOverridesResponse:
try:
items = replace_severity_overrides(db, body.overrides)
db.commit()
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
return EventSeverityOverridesResponse(
items=[
EventSeverityOverrideRow(
event_type=i.event_type,
default_severity=i.default_severity,
override_severity=i.override_severity,
)
for i in items
],
source=SOURCE_DB,
)
class UiSettingsResponse(BaseModel):
show_sidebar_system_stats: bool
source: str = Field(description="db или default")
class UiSettingsUpdate(BaseModel):
show_sidebar_system_stats: bool
@router.get("/ui", response_model=UiSettingsResponse)
def get_ui_settings(
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> UiSettingsResponse:
cfg = get_effective_ui_settings(db)
return UiSettingsResponse(
show_sidebar_system_stats=cfg.show_sidebar_system_stats,
source=cfg.source,
)
@router.put("/ui", response_model=UiSettingsResponse)
def update_ui_settings(
body: UiSettingsUpdate,
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> UiSettingsResponse:
cfg = upsert_ui_settings(db, show_sidebar_system_stats=body.show_sidebar_system_stats)
return UiSettingsResponse(
show_sidebar_system_stats=cfg.show_sidebar_system_stats,
source=cfg.source,
)
class WinAdminSettingsResponse(BaseModel):
configured: bool
user: str | None = None
password_hint: str | None = None
source: str = Field(description="env или db")
class WinAdminSettingsUpdate(BaseModel):
user: str | None = None
password: str | None = None
@router.get("/win-admin", response_model=WinAdminSettingsResponse)
def get_win_admin_settings(
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> WinAdminSettingsResponse:
cfg = get_effective_win_admin_config(db)
return WinAdminSettingsResponse(
configured=cfg.configured,
user=cfg.user or None,
password_hint=_mask_secret(cfg.password),
source=cfg.source,
)
@router.put("/win-admin", response_model=WinAdminSettingsResponse)
def update_win_admin_settings(
body: WinAdminSettingsUpdate,
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> WinAdminSettingsResponse:
if body.user is not None and not body.user.strip():
raise HTTPException(status_code=422, detail="user must not be empty")
cfg = upsert_win_admin_settings(db, user=body.user, password=body.password)
return WinAdminSettingsResponse(
configured=cfg.configured,
user=cfg.user or None,
password_hint=_mask_secret(cfg.password),
source=cfg.source,
)