diff --git a/backend/alembic/versions/027_host_mgmt_credentials.py b/backend/alembic/versions/027_host_mgmt_credentials.py new file mode 100644 index 0000000..ec16f33 --- /dev/null +++ b/backend/alembic/versions/027_host_mgmt_credentials.py @@ -0,0 +1,25 @@ +"""per-host management credentials (SSH / WinRM override) + +Revision ID: 027_host_mgmt_credentials +Revises: 026_host_silence_dedupe +Create Date: 2026-07-14 + +""" + +from alembic import op +import sqlalchemy as sa + +revision = "027_host_mgmt_credentials" +down_revision = "026_host_silence_dedupe" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("hosts", sa.Column("mgmt_user", sa.String(length=256), nullable=True)) + op.add_column("hosts", sa.Column("mgmt_password", sa.Text(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("hosts", "mgmt_password") + op.drop_column("hosts", "mgmt_user") diff --git a/backend/app/api/v1/events.py b/backend/app/api/v1/events.py index 2114686..dcdad07 100644 --- a/backend/app/api/v1/events.py +++ b/backend/app/api/v1/events.py @@ -28,12 +28,12 @@ from app.services.host_sessions import ( mark_event_session_terminated, terminate_session_for_event, ) -from app.services.linux_admin_settings import get_effective_linux_admin_config +from app.services.linux_admin_settings import get_effective_linux_admin_for_host from app.services.ssh_connect import ( HostNotLinuxError as SshHostNotLinuxError, HostTargetMissingError as SshHostTargetMissingError, ) -from app.services.win_admin_settings import get_effective_win_admin_config +from app.services.win_admin_settings import get_effective_win_admin_for_host from app.services.winrm_connect import HostNotWindowsError, HostTargetMissingError from app.services.ingest import ingest_event from app.services.event_summary import event_to_summary @@ -284,8 +284,10 @@ def post_event_terminate_session( if event_session_terminated(event, db=db): raise HTTPException(status_code=409, detail="Session already terminated for this event") - linux_cfg = get_effective_linux_admin_config(db) - win_cfg = get_effective_win_admin_config(db) + if event.host is None: + raise HTTPException(status_code=400, detail="Event has no host") + linux_cfg = get_effective_linux_admin_for_host(db, event.host) + win_cfg = get_effective_win_admin_for_host(db, event.host) sid = (body.session_id if body else None) or event_session_id(event) try: diff --git a/backend/app/api/v1/hosts.py b/backend/app/api/v1/hosts.py index ce112a7..1cd70a7 100644 --- a/backend/app/api/v1/hosts.py +++ b/backend/app/api/v1/hosts.py @@ -46,8 +46,12 @@ from app.services.host_sessions import ( terminate_windows_session, ) from app.services.host_delete import delete_host_and_related -from app.services.linux_admin_settings import get_effective_linux_admin_config -from app.services.win_admin_settings import get_effective_win_admin_config +from app.services.host_mgmt_credentials import ( + get_host_mgmt_access_view, + upsert_host_mgmt_credentials, +) +from app.services.linux_admin_settings import get_effective_linux_admin_for_host +from app.services.win_admin_settings import get_effective_win_admin_for_host from app.services.winrm_connect import ( HostNotWindowsError, HostTargetMissingError, @@ -299,6 +303,23 @@ class HostAgentConfigResponse(BaseModel): settings: dict[str, object] +class HostMgmtAccessResponse(BaseModel): + host_id: int + has_override: bool + user: str | None = None + password_set: bool = False + password_hint: str | None = None + effective_source: str + effective_configured: bool + effective_user: str | None = None + + +class HostMgmtAccessUpdate(BaseModel): + user: str | None = None + password: str | None = None + clear: bool = False + + class HostRemoteActionStartResponse(BaseModel): status: str host_id: int @@ -469,9 +490,12 @@ def test_host_winrm( if host is None: raise HTTPException(status_code=404, detail="Host not found") - cfg = get_effective_win_admin_config(db) + cfg = get_effective_win_admin_for_host(db, host) if not cfg.configured: - raise HTTPException(status_code=400, detail="Windows domain admin is not configured") + raise HTTPException( + status_code=400, + detail="Windows admin is not configured (host override or Settings → Windows)", + ) try: targets = iter_winrm_targets(host) @@ -532,9 +556,12 @@ def test_host_ssh( if host is None: raise HTTPException(status_code=404, detail="Host not found") - cfg = get_effective_linux_admin_config(db) + cfg = get_effective_linux_admin_for_host(db, host) if not cfg.configured: - raise HTTPException(status_code=400, detail="Linux SSH admin is not configured") + raise HTTPException( + status_code=400, + detail="Linux SSH admin is not configured (host override or Settings → Linux)", + ) response = _run_ssh_action_on_host(host, cfg, action=test_ssh_connection) _set_ssh_admin_status(host, response.ok) @@ -556,9 +583,12 @@ def update_host_agent_via_ssh( if host is None: raise HTTPException(status_code=404, detail="Host not found") - cfg = get_effective_linux_admin_config(db) + cfg = get_effective_linux_admin_for_host(db, host) if not cfg.configured: - raise HTTPException(status_code=400, detail="Linux SSH admin is not configured") + raise HTTPException( + status_code=400, + detail="Linux SSH admin is not configured (host override or Settings → Linux)", + ) title = "Обновление ssh-monitor (SSH)" try: @@ -686,9 +716,12 @@ def list_host_sessions( from app.services.winrm_connect import is_windows_host if is_linux_host(host): - cfg = get_effective_linux_admin_config(db) + cfg = get_effective_linux_admin_for_host(db, host) if not cfg.configured: - raise HTTPException(status_code=400, detail="Linux SSH admin is not configured") + raise HTTPException( + status_code=400, + detail="Linux SSH admin is not configured (host override or Settings → Linux)", + ) try: sessions, result = list_linux_sessions(host, cfg) except SshHostNotLinuxError as exc: @@ -703,9 +736,12 @@ def list_host_sessions( ) if is_windows_host(host): - cfg = get_effective_win_admin_config(db) + cfg = get_effective_win_admin_for_host(db, host) if not cfg.configured: - raise HTTPException(status_code=400, detail="Windows domain admin is not configured") + raise HTTPException( + status_code=400, + detail="Windows admin is not configured (host override or Settings → Windows)", + ) try: sessions, result = list_windows_sessions(host, cfg) except HostNotWindowsError as exc: @@ -743,9 +779,12 @@ def terminate_host_session( raise HTTPException(status_code=422, detail="session_id is required") if is_linux_host(host): - cfg = get_effective_linux_admin_config(db) + cfg = get_effective_linux_admin_for_host(db, host) if not cfg.configured: - raise HTTPException(status_code=400, detail="Linux SSH admin is not configured") + raise HTTPException( + status_code=400, + detail="Linux SSH admin is not configured (host override or Settings → Linux)", + ) try: result = terminate_linux_session(host, cfg, sid) except SshHostNotLinuxError as exc: @@ -761,9 +800,12 @@ def terminate_host_session( ) if is_windows_host(host): - cfg = get_effective_win_admin_config(db) + cfg = get_effective_win_admin_for_host(db, host) if not cfg.configured: - raise HTTPException(status_code=400, detail="Windows domain admin is not configured") + raise HTTPException( + status_code=400, + detail="Windows admin is not configured (host override or Settings → Windows)", + ) try: result = terminate_windows_session(host, cfg, sid) except HostNotWindowsError as exc: @@ -803,6 +845,57 @@ def patch_host_agent_config( ) +@router.get("/{host_id}/access", response_model=HostMgmtAccessResponse) +def get_host_access( + host_id: int, + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> HostMgmtAccessResponse: + host = db.get(Host, host_id) + if host is None: + raise HTTPException(status_code=404, detail="Host not found") + view = get_host_mgmt_access_view(db, host) + return HostMgmtAccessResponse( + host_id=view.host_id, + has_override=view.has_override, + user=view.user, + password_set=view.password_set, + password_hint=view.password_hint, + effective_source=view.effective_source, + effective_configured=view.effective_configured, + effective_user=view.effective_user, + ) + + +@router.put("/{host_id}/access", response_model=HostMgmtAccessResponse) +def put_host_access( + host_id: int, + body: HostMgmtAccessUpdate, + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> HostMgmtAccessResponse: + host = db.get(Host, host_id) + if host is None: + raise HTTPException(status_code=404, detail="Host not found") + view = upsert_host_mgmt_credentials( + db, + host, + user=body.user, + password=body.password, + clear=body.clear, + ) + return HostMgmtAccessResponse( + host_id=view.host_id, + has_override=view.has_override, + user=view.user, + password_set=view.password_set, + password_hint=view.password_hint, + effective_source=view.effective_source, + effective_configured=view.effective_configured, + effective_user=view.effective_user, + ) + + @router.get("/{host_id}/agent-config", response_model=HostAgentConfigResponse) def get_host_agent_config( host_id: int, diff --git a/backend/app/models/host.py b/backend/app/models/host.py index f65c391..7e27bed 100644 --- a/backend/app/models/host.py +++ b/backend/app/models/host.py @@ -26,6 +26,9 @@ class Host(Base): use_sac_mode: Mapped[str | None] = mapped_column(String(32)) ssh_admin_ok: Mapped[bool | None] = mapped_column(nullable=True) ssh_admin_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + # Optional override for SSH/WinRM (home PCs, unique local admin). Empty → global Settings. + mgmt_user: Mapped[str | None] = mapped_column(String(256)) + mgmt_password: Mapped[str | None] = mapped_column(Text) pending_agent_update: Mapped[bool] = mapped_column(default=False, server_default="false") pending_update_requested_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) pending_update_target_version: Mapped[str | None] = mapped_column(String(64)) diff --git a/backend/app/services/agent_commands.py b/backend/app/services/agent_commands.py index 362f23e..e53037d 100644 --- a/backend/app/services/agent_commands.py +++ b/backend/app/services/agent_commands.py @@ -7,17 +7,22 @@ from datetime import datetime, timezone from sqlalchemy import select from sqlalchemy.orm import Session -from app.config import get_settings from app.models import AgentCommand, Host -from app.services.win_admin_settings import get_effective_win_admin_config +from app.services.win_admin_settings import ( + get_effective_win_admin_config, + get_effective_win_admin_for_host, +) def _win_admin_configured() -> bool: return get_effective_win_admin_config().configured -def win_admin_run_as() -> dict[str, str] | None: - cfg = get_effective_win_admin_config() +def win_admin_run_as(db: Session | None = None, host: Host | None = None) -> dict[str, str] | None: + if host is not None and db is not None: + cfg = get_effective_win_admin_for_host(db, host) + else: + cfg = get_effective_win_admin_config(db) if not cfg.configured: return None return { @@ -26,7 +31,13 @@ def win_admin_run_as() -> dict[str, str] | None: } -def command_to_dict(cmd: AgentCommand, *, include_run_as: bool = False) -> dict: +def command_to_dict( + cmd: AgentCommand, + *, + include_run_as: bool = False, + db: Session | None = None, + host: Host | None = None, +) -> dict: out = { "id": cmd.command_uuid, "type": cmd.command_type, @@ -38,7 +49,7 @@ def command_to_dict(cmd: AgentCommand, *, include_run_as: bool = False) -> dict: "completed_at": cmd.completed_at.isoformat() if cmd.completed_at else None, } if include_run_as and cmd.status == "pending": - run_as = win_admin_run_as() + run_as = win_admin_run_as(db, host) if run_as: out["run_as"] = run_as return out diff --git a/backend/app/services/agent_poll.py b/backend/app/services/agent_poll.py index f412806..505b061 100644 --- a/backend/app/services/agent_poll.py +++ b/backend/app/services/agent_poll.py @@ -27,5 +27,5 @@ def build_agent_poll_payload(db: Session, host: Host) -> dict[str, Any]: "config_revision": int(host.agent_config_revision or 0), "config": config_payload, "update": update_block, - "commands": [command_to_dict(c, include_run_as=True) for c in pending], + "commands": [command_to_dict(c, include_run_as=True, db=db, host=host) for c in pending], } diff --git a/backend/app/services/agent_update.py b/backend/app/services/agent_update.py index 51a2e77..e939742 100644 --- a/backend/app/services/agent_update.py +++ b/backend/app/services/agent_update.py @@ -20,7 +20,7 @@ from app.services.agent_version import ( host_version_outdated, latest_agent_versions_by_product, ) -from app.services.linux_admin_settings import get_effective_linux_admin_config +from app.services.linux_admin_settings import get_effective_linux_admin_for_host from app.services.ssh_connect import ( HostNotLinuxError, HostTargetMissingError, @@ -28,7 +28,7 @@ from app.services.ssh_connect import ( iter_ssh_targets, run_ssh_monitor_update, ) -from app.services.win_admin_settings import get_effective_win_admin_config +from app.services.win_admin_settings import get_effective_win_admin_for_host from app.services.winrm_connect import ( HostNotWindowsError, HostTargetMissingError as WinRmHostTargetMissingError, @@ -249,7 +249,7 @@ def execute_agent_update_fallback(db: Session, host: Host) -> AgentUpdateFallbac product = (host.product or "").strip() if product == PRODUCT_SSH or is_linux_host(host): - linux_cfg = get_effective_linux_admin_config(db) + linux_cfg = get_effective_linux_admin_for_host(db, host) agent_cfg = get_effective_agent_update_config(db) repo_url = (agent_cfg.ssh_git_repo_url or "").strip() or None branch = (agent_cfg.git_branch or "main").strip() or "main" @@ -260,7 +260,7 @@ def execute_agent_update_fallback(db: Session, host: Host) -> AgentUpdateFallbac git_branch=branch, ) elif product == PRODUCT_RDP or is_windows_host(host): - win_cfg = get_effective_win_admin_config(db) + win_cfg = get_effective_win_admin_for_host(db, host) result = run_windows_agent_update_fallback( host, win_cfg, diff --git a/backend/app/services/host_mgmt_credentials.py b/backend/app/services/host_mgmt_credentials.py new file mode 100644 index 0000000..ae5d865 --- /dev/null +++ b/backend/app/services/host_mgmt_credentials.py @@ -0,0 +1,116 @@ +"""Per-host management credentials (override global Win/Linux admin).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from sqlalchemy.orm import Session + +from app.models.host import Host +from app.services.linux_admin_settings import ( + LinuxAdminConfig, + get_effective_linux_admin_for_host, +) +from app.services.win_admin_settings import ( + WinAdminConfig, + get_effective_win_admin_for_host, + normalize_win_admin_user, +) + + +def mask_secret(value: str | None) -> str | None: + if not value: + return None + text = value.strip() + if not text: + return None + if len(text) <= 4: + return "****" + return f"{text[:2]}…{text[-2:]}" + + +@dataclass(frozen=True) +class HostMgmtAccessView: + host_id: int + has_override: bool + user: str | None + password_set: bool + password_hint: str | None + effective_source: str + effective_configured: bool + effective_user: str | None + + +def _effective_for_host(db: Session, host: Host) -> WinAdminConfig | LinuxAdminConfig: + os_family = (host.os_family or "").strip().lower() + if os_family == "windows" or (host.product or "").strip().lower() in {"rdp-login-monitor", "rdp"}: + return get_effective_win_admin_for_host(db, host) + if os_family == "linux" or (host.product or "").strip().lower() in {"ssh-monitor", "ssh"}: + return get_effective_linux_admin_for_host(db, host) + # Fallback: try host override first via win helper (same columns), then global win, then linux. + win_cfg = get_effective_win_admin_for_host(db, host) + if win_cfg.configured: + return win_cfg + return get_effective_linux_admin_for_host(db, host) + + +def get_host_mgmt_access_view(db: Session, host: Host) -> HostMgmtAccessView: + host_user = (host.mgmt_user or "").strip() or None + host_password = (host.mgmt_password or "").strip() or None + has_override = bool(host_user or host_password) + effective = _effective_for_host(db, host) + return HostMgmtAccessView( + host_id=host.id, + has_override=has_override, + user=host_user, + password_set=bool(host_password), + password_hint=mask_secret(host_password), + effective_source=effective.source, + effective_configured=effective.configured, + effective_user=effective.user or None, + ) + + +def upsert_host_mgmt_credentials( + db: Session, + host: Host, + *, + user: str | None = None, + password: str | None = None, + clear: bool = False, +) -> HostMgmtAccessView: + """Update per-host credentials. + + - clear=True → wipe host override (use global Settings). + - user="" → clear username. + - password omitted (None) → keep existing password. + - password="" → clear password. + """ + if clear: + host.mgmt_user = None + host.mgmt_password = None + db.commit() + db.refresh(host) + return get_host_mgmt_access_view(db, host) + + if user is not None: + cleaned = user.strip() + if not cleaned: + host.mgmt_user = None + else: + # Preserve DOMAIN\\user form for Windows; for Linux leave as-is after strip. + os_family = (host.os_family or "").strip().lower() + if os_family == "windows" or "\\" in cleaned or "@" in cleaned: + host.mgmt_user = normalize_win_admin_user(cleaned) + else: + host.mgmt_user = cleaned + + if password is not None: + if not password.strip(): + host.mgmt_password = None + else: + host.mgmt_password = password + + db.commit() + db.refresh(host) + return get_host_mgmt_access_view(db, host) diff --git a/backend/app/services/host_remote_actions.py b/backend/app/services/host_remote_actions.py index 7f023fc..8f1ed9d 100644 --- a/backend/app/services/host_remote_actions.py +++ b/backend/app/services/host_remote_actions.py @@ -14,7 +14,7 @@ from app.database import SessionLocal from app.models import Host from app.services.agent_update import execute_agent_update_fallback from app.services.agent_update_types import AgentUpdateFallbackResult -from app.services.linux_admin_settings import get_effective_linux_admin_config +from app.services.linux_admin_settings import get_effective_linux_admin_for_host from app.services.agent_update_settings import get_effective_agent_update_config from app.services.ssh_connect import ( iter_ssh_targets, @@ -97,7 +97,7 @@ def _completion_message(result: SshCommandResult) -> str: def _fetch_ssh_update_log_tail(db: Session, host: Host, *, lines: int = 200) -> str: - cfg = get_effective_linux_admin_config(db) + cfg = get_effective_linux_admin_for_host(db, host) if not cfg.configured: return "" try: @@ -191,7 +191,7 @@ def _poll_ssh_update_log_loop( payload = dict(row.remote_action or {}) if (payload.get("status") or "").strip().lower() != "running": continue - cfg = get_effective_linux_admin_config(session) + cfg = get_effective_linux_admin_for_host(session, row) if not cfg.configured: continue try: @@ -322,7 +322,7 @@ def start_host_remote_action( def run_ssh_monitor_update_action(db: Session, host: Host) -> SshCommandResult: - cfg = get_effective_linux_admin_config(db) + cfg = get_effective_linux_admin_for_host(db, host) if not cfg.configured: return SshCommandResult( ok=False, diff --git a/backend/app/services/linux_admin_settings.py b/backend/app/services/linux_admin_settings.py index f1306dd..ad0fd77 100644 --- a/backend/app/services/linux_admin_settings.py +++ b/backend/app/services/linux_admin_settings.py @@ -1,4 +1,4 @@ -"""Effective Linux SSH admin credentials (DB overrides env).""" +"""Effective Linux SSH admin credentials (host → DB → env).""" from __future__ import annotations @@ -7,6 +7,7 @@ from dataclasses import dataclass from sqlalchemy.orm import Session from app.config import get_settings +from app.models.host import Host from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings @@ -14,7 +15,7 @@ from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings class LinuxAdminConfig: user: str password: str - source: str # env | db + source: str # host | env | db @property def configured(self) -> bool: @@ -80,3 +81,16 @@ def upsert_linux_admin_settings( db.commit() db.refresh(row) return get_effective_linux_admin_config(db) + + +def get_effective_linux_admin_for_host(db: Session, host: Host) -> LinuxAdminConfig: + """Prefer per-host mgmt_* when both user and password are set; else global Settings/env.""" + host_user = (host.mgmt_user or "").strip() + host_password = (host.mgmt_password or "").strip() + if host_user and host_password: + return LinuxAdminConfig(user=host_user, password=host_password, source="host") + + global_cfg = get_effective_linux_admin_config(db) + if host_user and global_cfg.password.strip(): + return LinuxAdminConfig(user=host_user, password=global_cfg.password, source="host") + return global_cfg diff --git a/backend/app/services/rdg_winrm_actions.py b/backend/app/services/rdg_winrm_actions.py index 048d28f..dd38b66 100644 --- a/backend/app/services/rdg_winrm_actions.py +++ b/backend/app/services/rdg_winrm_actions.py @@ -12,7 +12,7 @@ from app.models import AgentCommand, Event, Host from app.services.rdg_client_host import ClientWorkstationNotFoundError, resolve_client_workstation from app.services.rdg_display import event_supports_rdg_client_qwinsta from app.services.rdg_session_flap import event_internal_ip -from app.services.win_admin_settings import get_effective_win_admin_config +from app.services.win_admin_settings import get_effective_win_admin_for_host from app.services.winrm_connect import ( WinRmCmdResult, run_winrm_logoff, @@ -21,12 +21,17 @@ from app.services.winrm_connect import ( ) -def _require_win_admin(db: Session): - cfg = get_effective_win_admin_config(db) +def _require_win_admin(db: Session, host: Host | None = None): + if host is not None: + cfg = get_effective_win_admin_for_host(db, host) + else: + from app.services.win_admin_settings import get_effective_win_admin_config + + cfg = get_effective_win_admin_config(db) if not cfg.configured: raise HTTPException( status_code=503, - detail="Windows domain admin is not configured (Settings or SAC_WIN_ADMIN_*)", + detail="Windows admin is not configured (host override or Settings → Windows)", ) return cfg @@ -93,8 +98,8 @@ def _persist_command( def execute_qwinsta_via_winrm(db: Session, event: Event, *, requested_by: str) -> AgentCommand: _require_rdg_client_qwinsta(db, event) - cfg = _require_win_admin(db) client_host = _resolve_client(db, event) + cfg = _require_win_admin(db, client_host) details = event.details if isinstance(event.details, dict) else {} user = details.get("user") @@ -136,8 +141,8 @@ def execute_logoff_via_winrm( requested_by: str, ) -> AgentCommand: _require_rdg_client_qwinsta(db, event) - cfg = _require_win_admin(db) client_host = _resolve_client(db, event) + cfg = _require_win_admin(db, client_host) details = event.details if isinstance(event.details, dict) else {} user = details.get("user") diff --git a/backend/app/services/rdp_flap_auto_disconnect.py b/backend/app/services/rdp_flap_auto_disconnect.py index 85af63e..f624382 100644 --- a/backend/app/services/rdp_flap_auto_disconnect.py +++ b/backend/app/services/rdp_flap_auto_disconnect.py @@ -35,7 +35,7 @@ from app.services.rdg_workstation_session import ( users_match_rdg, ) from app.services.rdp_flap_settings import get_effective_rdp_flap_settings -from app.services.win_admin_settings import get_effective_win_admin_config +from app.services.win_admin_settings import get_effective_win_admin_for_host logger = logging.getLogger("sac.rdp_flap_auto_disconnect") @@ -143,7 +143,7 @@ def _disconnect_on_workstation( user: str, login_event: Event | None, ) -> AutoDisconnectResult: - win_cfg = get_effective_win_admin_config(db) + win_cfg = get_effective_win_admin_for_host(db, workstation) sessions, qwinsta = list_windows_sessions(workstation, win_cfg) if qwinsta is None or not qwinsta.ok: message = qwinsta.message if qwinsta else "qwinsta failed" @@ -291,14 +291,6 @@ def maybe_auto_disconnect_stuck_rdp_session(db: Session, event: Event) -> AutoDi if not get_effective_rdp_flap_settings(db).auto_disconnect: return None - win_cfg = get_effective_win_admin_config(db) - if not win_cfg.configured: - logger.warning( - "auto rdp flap disconnect skipped: win admin not configured (event_id=%s)", - event.event_id, - ) - return None - result = _auto_disconnect_rdg_flap(db, event) if result is not None: if result.ok: diff --git a/backend/app/services/win_admin_settings.py b/backend/app/services/win_admin_settings.py index c6980fc..a7d3096 100644 --- a/backend/app/services/win_admin_settings.py +++ b/backend/app/services/win_admin_settings.py @@ -1,4 +1,4 @@ -"""Effective Windows domain admin credentials (DB overrides env).""" +"""Effective Windows domain admin credentials (host → DB → env).""" from __future__ import annotations @@ -7,6 +7,7 @@ from dataclasses import dataclass from sqlalchemy.orm import Session from app.config import get_settings +from app.models.host import Host from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings @@ -14,7 +15,7 @@ from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings class WinAdminConfig: user: str password: str - source: str # env | db + source: str # host | env | db @property def configured(self) -> bool: @@ -92,3 +93,17 @@ def upsert_win_admin_settings( db.commit() db.refresh(row) return get_effective_win_admin_config(db) + + +def get_effective_win_admin_for_host(db: Session, host: Host) -> WinAdminConfig: + """Prefer per-host mgmt_* when both user and password are set; else global Settings/env.""" + host_user = normalize_win_admin_user((host.mgmt_user or "").strip()) + host_password = (host.mgmt_password or "").strip() + if host_user and host_password: + return WinAdminConfig(user=host_user, password=host_password, source="host") + + global_cfg = get_effective_win_admin_config(db) + # Allow host to override only the username, still using global password (rare). + if host_user and global_cfg.password.strip(): + return WinAdminConfig(user=host_user, password=global_cfg.password, source="host") + return global_cfg diff --git a/backend/app/version.py b/backend/app/version.py index 8fd3b02..79933b3 100644 --- a/backend/app/version.py +++ b/backend/app/version.py @@ -1,5 +1,5 @@ """Единый источник версии SAC (API, health, логи, OpenAPI).""" APP_NAME = "Security Alert Center" -APP_VERSION = "0.5.16" +APP_VERSION = "0.5.17" APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}" diff --git a/backend/tests/test_host_mgmt_credentials.py b/backend/tests/test_host_mgmt_credentials.py new file mode 100644 index 0000000..247234e --- /dev/null +++ b/backend/tests/test_host_mgmt_credentials.py @@ -0,0 +1,64 @@ +"""Per-host management credential override.""" + +from app.models.host import Host +from app.services.linux_admin_settings import get_effective_linux_admin_for_host +from app.services.win_admin_settings import get_effective_win_admin_for_host +from app.services.host_mgmt_credentials import get_host_mgmt_access_view, upsert_host_mgmt_credentials + + +def test_win_admin_for_host_prefers_override(db_session): + host = Host( + hostname="HOME-PC", + os_family="windows", + product="rdp-login-monitor", + mgmt_user=".\\Admin", + mgmt_password="local-secret", + ) + db_session.add(host) + db_session.commit() + db_session.refresh(host) + + cfg = get_effective_win_admin_for_host(db_session, host) + assert cfg.configured is True + assert cfg.source == "host" + assert cfg.user == ".\\Admin" + assert cfg.password == "local-secret" + + +def test_linux_admin_for_host_prefers_override(db_session): + host = Host( + hostname="homeserver", + os_family="linux", + product="ssh-monitor", + mgmt_user="deploy", + mgmt_password="ssh-pass", + ) + db_session.add(host) + db_session.commit() + db_session.refresh(host) + + cfg = get_effective_linux_admin_for_host(db_session, host) + assert cfg.source == "host" + assert cfg.user == "deploy" + + +def test_upsert_and_clear_host_access(db_session): + host = Host(hostname="box", os_family="windows", product="rdp-login-monitor") + db_session.add(host) + db_session.commit() + db_session.refresh(host) + + view = upsert_host_mgmt_credentials( + db_session, + host, + user="HOME\\user", + password="secret123", + ) + assert view.has_override is True + assert view.password_set is True + assert view.user == "HOME\\user" + + cleared = upsert_host_mgmt_credentials(db_session, host, clear=True) + assert cleared.has_override is False + assert cleared.password_set is False + assert get_host_mgmt_access_view(db_session, host).user is None diff --git a/docs/agent-control-plane.md b/docs/agent-control-plane.md index ae0be58..bb0a418 100644 --- a/docs/agent-control-plane.md +++ b/docs/agent-control-plane.md @@ -183,11 +183,13 @@ sequenceDiagram ## 5. П.2C — Доступ SAC к хостам -### 5.1. Windows — WinRM +### 5.1. Windows — WinRM / учётки -- **Один доменный admin** в **Настройки → Управление хостами → Windows**: `DOMAIN\user` + password (encrypted). -- Override на карточке хоста — опционально позже. -- Используется для fallback-обновления и (при необходимости) remote ops; для qwinsta/logoff MVP — **через агента** с теми же creds, переданными в command poll (TLS + API key, не пишутся на диск агента). +- **Глобальный доменный admin** в **Настройки → Windows**: `DOMAIN\user` + password (encrypted). Аналогично **Linux admin** для SSH. +- **Override на карточке хоста** (**Хосты → WinRM/SSH / доступ к хосту**): `COMPUTER\user` / `.\Administrator` + password (encrypted в `hosts.mgmt_*`). Если пусто — берутся глобальные. +- Effective creds: `get_effective_win_admin_for_host` / `get_effective_linux_admin_for_host` (source: `host` | `db` | `env`). +- API: `GET` / `PUT /api/v1/hosts/{id}/access` (`user`, `password`, `clear`). +- Используются для WinRM/SSH-test, fallback-update, qwinsta/logoff, remote ops; для run_as через агента — те же creds в command poll (TLS + API key, не пишутся на диск агента). ### 5.2. Linux — bootstrap password → SSH key → удалить password @@ -266,7 +268,7 @@ Authorization: Bearer sac_xxx | POST | `/api/v1/events/{id}/actions/logoff` | logoff `{ "session_id": 5 }` | | GET | `/api/v1/events/{id}/actions/{cmd_id}` | Статус / результат | | PATCH | `/api/v1/hosts/{id}/config` | Desired config | -| PATCH | `/api/v1/hosts/{id}/access` | Bootstrap / WinRM creds | +| GET/PUT | `/api/v1/hosts/{id}/access` | Per-host WinRM/SSH override (mgmt_user/password) | | GET/PATCH | `/api/v1/settings/agent-updates` | Режим A/B, версии, источники | | GET/PATCH | `/api/v1/settings/host-management` | Доменный admin Windows | diff --git a/frontend/src/api.ts b/frontend/src/api.ts index ff23332..c5aa494 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -532,6 +532,31 @@ export function updateLinuxAdminSettings(payload: { }); } +export interface HostMgmtAccess { + host_id: number; + has_override: boolean; + user: string | null; + password_set: boolean; + password_hint: string | null; + effective_source: string; + effective_configured: boolean; + effective_user: string | null; +} + +export function fetchHostAccess(hostId: number): Promise { + return apiFetch(`/api/v1/hosts/${hostId}/access`); +} + +export function updateHostAccess( + hostId: number, + payload: { user?: string | null; password?: string | null; clear?: boolean }, +): Promise { + return apiFetch(`/api/v1/hosts/${hostId}/access`, { + method: "PUT", + body: JSON.stringify(payload), + }); +} + export interface HostSshActionResult { ok: boolean; message: string; diff --git a/frontend/src/version.ts b/frontend/src/version.ts index 8b21f6a..4168b16 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1,4 +1,4 @@ /** Fallback до загрузки /health; при релизе держите в sync с backend/app/version.py */ export const APP_NAME = "Security Alert Center"; -export const APP_VERSION = "0.5.16"; +export const APP_VERSION = "0.5.17"; export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`; diff --git a/frontend/src/views/HostDetailView.vue b/frontend/src/views/HostDetailView.vue index 829156f..0a043bb 100644 --- a/frontend/src/views/HostDetailView.vue +++ b/frontend/src/views/HostDetailView.vue @@ -69,6 +69,52 @@
{{ host.event_count ?? 0 }}
+ +
+

{{ isWindowsHost ? "WinRM / доступ к хосту" : "SSH / доступ к хосту" }}

+

+ Переопределение для этого ПК. Если пусто — используются глобальные учётные данные из + Настройки + ({{ isWindowsHost ? "Windows admin" : "Linux admin" }}). + Сейчас effective: + {{ accessEffectiveLabel }} +

+
+ + +
+ + +
+

{{ accessMessage }}

+
+
+