messagefoundry 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- messagefoundry/__init__.py +108 -0
- messagefoundry/__main__.py +1155 -0
- messagefoundry/api/__init__.py +27 -0
- messagefoundry/api/app.py +1581 -0
- messagefoundry/api/approvals.py +184 -0
- messagefoundry/api/auth_models.py +211 -0
- messagefoundry/api/auth_routes.py +655 -0
- messagefoundry/api/field_authz.py +96 -0
- messagefoundry/api/models.py +374 -0
- messagefoundry/api/security.py +247 -0
- messagefoundry/api/tls.py +47 -0
- messagefoundry/auth/__init__.py +39 -0
- messagefoundry/auth/data/common_passwords.NOTICE +13 -0
- messagefoundry/auth/data/common_passwords.txt +10000 -0
- messagefoundry/auth/identity.py +71 -0
- messagefoundry/auth/ldap.py +264 -0
- messagefoundry/auth/notifications.py +68 -0
- messagefoundry/auth/passwords.py +53 -0
- messagefoundry/auth/permissions.py +120 -0
- messagefoundry/auth/policy.py +153 -0
- messagefoundry/auth/ratelimit.py +55 -0
- messagefoundry/auth/service.py +1323 -0
- messagefoundry/auth/tokens.py +26 -0
- messagefoundry/auth/totp.py +174 -0
- messagefoundry/checks.py +174 -0
- messagefoundry/config/__init__.py +30 -0
- messagefoundry/config/active_environment.py +80 -0
- messagefoundry/config/ai_policy.py +140 -0
- messagefoundry/config/code_sets.py +260 -0
- messagefoundry/config/connections_edit.py +200 -0
- messagefoundry/config/connections_file.py +287 -0
- messagefoundry/config/db_lookup.py +117 -0
- messagefoundry/config/environments.py +116 -0
- messagefoundry/config/ingest_time.py +83 -0
- messagefoundry/config/models.py +240 -0
- messagefoundry/config/reference.py +158 -0
- messagefoundry/config/response.py +83 -0
- messagefoundry/config/run_context.py +153 -0
- messagefoundry/config/settings.py +1311 -0
- messagefoundry/config/state.py +99 -0
- messagefoundry/config/tls_policy.py +110 -0
- messagefoundry/config/wiring.py +1918 -0
- messagefoundry/console/__init__.py +20 -0
- messagefoundry/console/__main__.py +274 -0
- messagefoundry/console/_async.py +107 -0
- messagefoundry/console/change_password.py +111 -0
- messagefoundry/console/client.py +552 -0
- messagefoundry/console/connections.py +324 -0
- messagefoundry/console/login.py +107 -0
- messagefoundry/console/mfa.py +205 -0
- messagefoundry/console/reauth.py +94 -0
- messagefoundry/console/search.py +57 -0
- messagefoundry/console/service_control.py +137 -0
- messagefoundry/console/sessions.py +122 -0
- messagefoundry/console/shell.py +410 -0
- messagefoundry/console/status.py +377 -0
- messagefoundry/console/users_page.py +282 -0
- messagefoundry/console/widgets.py +553 -0
- messagefoundry/generators/README.md +27 -0
- messagefoundry/generators/__init__.py +15 -0
- messagefoundry/generators/_core.py +589 -0
- messagefoundry/generators/_hl7data.py +428 -0
- messagefoundry/generators/adt.py +286 -0
- messagefoundry/generators/all_types.py +24 -0
- messagefoundry/generators/bar.py +28 -0
- messagefoundry/generators/dft.py +20 -0
- messagefoundry/generators/mdm.py +39 -0
- messagefoundry/generators/mfn.py +46 -0
- messagefoundry/generators/oml.py +32 -0
- messagefoundry/generators/orl.py +30 -0
- messagefoundry/generators/orm.py +23 -0
- messagefoundry/generators/oru.py +21 -0
- messagefoundry/generators/ras.py +20 -0
- messagefoundry/generators/rde.py +54 -0
- messagefoundry/generators/siu.py +64 -0
- messagefoundry/generators/vxu.py +20 -0
- messagefoundry/hl7schema.py +75 -0
- messagefoundry/last_resort.py +55 -0
- messagefoundry/logging_setup.py +332 -0
- messagefoundry/parsing/__init__.py +64 -0
- messagefoundry/parsing/consistency.py +166 -0
- messagefoundry/parsing/groups.py +228 -0
- messagefoundry/parsing/message.py +453 -0
- messagefoundry/parsing/peek.py +237 -0
- messagefoundry/parsing/split.py +120 -0
- messagefoundry/parsing/summary.py +46 -0
- messagefoundry/parsing/tree.py +128 -0
- messagefoundry/parsing/validate.py +95 -0
- messagefoundry/parsing/x12/__init__.py +46 -0
- messagefoundry/parsing/x12/delimiters.py +140 -0
- messagefoundry/parsing/x12/errors.py +30 -0
- messagefoundry/parsing/x12/interchange.py +232 -0
- messagefoundry/parsing/x12/message.py +200 -0
- messagefoundry/parsing/x12/peek.py +207 -0
- messagefoundry/pipeline/__init__.py +21 -0
- messagefoundry/pipeline/alert_sinks.py +486 -0
- messagefoundry/pipeline/alerts.py +100 -0
- messagefoundry/pipeline/cert_expiry.py +219 -0
- messagefoundry/pipeline/cluster.py +955 -0
- messagefoundry/pipeline/cluster_sqlserver.py +444 -0
- messagefoundry/pipeline/config_convergence.py +137 -0
- messagefoundry/pipeline/dryrun.py +450 -0
- messagefoundry/pipeline/engine.py +756 -0
- messagefoundry/pipeline/leader_tasks.py +158 -0
- messagefoundry/pipeline/reference_sync.py +369 -0
- messagefoundry/pipeline/retention.py +289 -0
- messagefoundry/pipeline/security_notify.py +168 -0
- messagefoundry/pipeline/state_convergence.py +143 -0
- messagefoundry/pipeline/wiring_runner.py +1722 -0
- messagefoundry/py.typed +0 -0
- messagefoundry/redaction.py +71 -0
- messagefoundry/scaffold.py +321 -0
- messagefoundry/secrets_dpapi.py +129 -0
- messagefoundry/store/__init__.py +46 -0
- messagefoundry/store/audit_tee.py +67 -0
- messagefoundry/store/base.py +758 -0
- messagefoundry/store/crypto.py +166 -0
- messagefoundry/store/keyprovider.py +192 -0
- messagefoundry/store/postgres.py +3447 -0
- messagefoundry/store/sqlserver.py +3014 -0
- messagefoundry/store/store.py +3790 -0
- messagefoundry/timezone.py +207 -0
- messagefoundry/transports/__init__.py +50 -0
- messagefoundry/transports/base.py +269 -0
- messagefoundry/transports/database.py +693 -0
- messagefoundry/transports/file.py +551 -0
- messagefoundry/transports/framing.py +164 -0
- messagefoundry/transports/loopback.py +53 -0
- messagefoundry/transports/mllp.py +644 -0
- messagefoundry/transports/remotefile.py +664 -0
- messagefoundry/transports/rest.py +281 -0
- messagefoundry/transports/signing.py +321 -0
- messagefoundry/transports/soap.py +507 -0
- messagefoundry/transports/tcp.py +307 -0
- messagefoundry/transports/timer.py +146 -0
- messagefoundry/transports/x12.py +323 -0
- messagefoundry-0.1.0.dist-info/METADATA +212 -0
- messagefoundry-0.1.0.dist-info/RECORD +142 -0
- messagefoundry-0.1.0.dist-info/WHEEL +4 -0
- messagefoundry-0.1.0.dist-info/entry_points.txt +2 -0
- messagefoundry-0.1.0.dist-info/licenses/LICENSE +662 -0
- messagefoundry-0.1.0.dist-info/licenses/NOTICE +27 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Organization and contributors
|
|
3
|
+
"""Step-up re-verification dialog (ASVS 7.5.3, WP-L3-16 — console side).
|
|
4
|
+
|
|
5
|
+
The engine refuses a highly sensitive operation (user admin, replay, purge, config reload) with
|
|
6
|
+
**403 + ``X-Step-Up-Required``** when the session hasn't re-proved its credential within
|
|
7
|
+
``[auth].step_up_max_age_seconds``. :class:`ReauthDialog` collects the password and calls
|
|
8
|
+
``POST /me/reauth`` (local password re-verify, or an AD re-bind); :func:`make_step_up_handler` wires it
|
|
9
|
+
into :class:`~messagefoundry.console.client.EngineClient` so any sensitive action transparently
|
|
10
|
+
prompts-and-retries instead of surfacing a raw 403.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
|
|
17
|
+
from PySide6.QtWidgets import (
|
|
18
|
+
QApplication,
|
|
19
|
+
QDialog,
|
|
20
|
+
QFormLayout,
|
|
21
|
+
QLabel,
|
|
22
|
+
QLineEdit,
|
|
23
|
+
QPushButton,
|
|
24
|
+
QVBoxLayout,
|
|
25
|
+
QWidget,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
from messagefoundry.console.client import ApiError, EngineClient
|
|
29
|
+
from messagefoundry.console.widgets import ERROR_COLOR
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ReauthDialog(QDialog):
|
|
33
|
+
"""Collect the caller's current password and call :meth:`EngineClient.reauth` to refresh the
|
|
34
|
+
step-up window. Accepts on success; surfaces a wrong password (403) inline."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, client: EngineClient, *, parent: QWidget | None = None) -> None:
|
|
37
|
+
super().__init__(parent)
|
|
38
|
+
self.setWindowTitle("Re-verify — MessageFoundry")
|
|
39
|
+
self._client = client
|
|
40
|
+
|
|
41
|
+
user = client.current_user
|
|
42
|
+
is_ad = user is not None and user.auth_provider == "ad"
|
|
43
|
+
prompt = QLabel(
|
|
44
|
+
"This action needs you to re-verify. Re-enter your "
|
|
45
|
+
+ ("Active Directory password" if is_ad else "password")
|
|
46
|
+
+ " to continue."
|
|
47
|
+
)
|
|
48
|
+
prompt.setWordWrap(True)
|
|
49
|
+
|
|
50
|
+
self._password = QLineEdit()
|
|
51
|
+
self._password.setEchoMode(QLineEdit.EchoMode.Password)
|
|
52
|
+
form = QFormLayout()
|
|
53
|
+
form.addRow("Password", self._password)
|
|
54
|
+
|
|
55
|
+
self._error = QLabel("")
|
|
56
|
+
self._error.setStyleSheet(f"color: {ERROR_COLOR};")
|
|
57
|
+
self._error.setWordWrap(True)
|
|
58
|
+
|
|
59
|
+
confirm = QPushButton("Re-verify")
|
|
60
|
+
confirm.setDefault(True)
|
|
61
|
+
confirm.clicked.connect(self._attempt)
|
|
62
|
+
|
|
63
|
+
layout = QVBoxLayout(self)
|
|
64
|
+
layout.addWidget(prompt)
|
|
65
|
+
layout.addLayout(form)
|
|
66
|
+
layout.addWidget(self._error)
|
|
67
|
+
layout.addWidget(confirm)
|
|
68
|
+
|
|
69
|
+
self._password.returnPressed.connect(self._attempt)
|
|
70
|
+
|
|
71
|
+
def _attempt(self) -> None:
|
|
72
|
+
password = self._password.text()
|
|
73
|
+
if not password:
|
|
74
|
+
self._error.setText("Enter your password.")
|
|
75
|
+
return
|
|
76
|
+
try:
|
|
77
|
+
self._client.reauth(password)
|
|
78
|
+
except ApiError as exc:
|
|
79
|
+
self._error.setText("Password is incorrect." if exc.status == 403 else str(exc))
|
|
80
|
+
return
|
|
81
|
+
self._password.clear() # don't leave plaintext in the field (Qt doesn't zero buffers)
|
|
82
|
+
self.accept()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def make_step_up_handler(client: EngineClient) -> Callable[[], bool]:
|
|
86
|
+
"""A handler for :meth:`EngineClient.set_step_up_handler`: prompt for re-verification on the active
|
|
87
|
+
window and return ``True`` iff the user re-verified. Runs on the calling thread — the console's
|
|
88
|
+
sensitive actions run on the Qt main thread, so the modal dialog shows directly."""
|
|
89
|
+
|
|
90
|
+
def handler() -> bool:
|
|
91
|
+
dialog = ReauthDialog(client, parent=QApplication.activeWindow())
|
|
92
|
+
return dialog.exec() == QDialog.DialogCode.Accepted
|
|
93
|
+
|
|
94
|
+
return handler
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Organization and contributors
|
|
3
|
+
"""Log Search page: search options on top, the message list below, and the single-message
|
|
4
|
+
detail (parse tree / raw / deliveries / audit) at the bottom. Composes the existing
|
|
5
|
+
``MessagesPanel`` and ``MessageDetailPanel``, stacked vertically.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from PySide6.QtCore import Qt, Signal
|
|
11
|
+
from PySide6.QtWidgets import QSplitter, QVBoxLayout, QWidget
|
|
12
|
+
|
|
13
|
+
from messagefoundry.console.client import EngineClient
|
|
14
|
+
from messagefoundry.console.widgets import MessageDetailPanel, MessagesPanel
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class LogSearchPage(QWidget):
|
|
18
|
+
"""Message browser + detail, the relocated home of the old console's lower half."""
|
|
19
|
+
|
|
20
|
+
error = Signal(str)
|
|
21
|
+
|
|
22
|
+
def __init__(self, client: EngineClient, *, poll_client: EngineClient | None = None) -> None:
|
|
23
|
+
super().__init__()
|
|
24
|
+
self.messages = MessagesPanel(client, poll_client=poll_client)
|
|
25
|
+
self.detail = MessageDetailPanel(client, poll_client=poll_client)
|
|
26
|
+
|
|
27
|
+
self.messages.message_selected.connect(self.detail.load)
|
|
28
|
+
self.messages.selection_cleared.connect(self.detail.clear)
|
|
29
|
+
self.detail.changed.connect(self.messages.refresh)
|
|
30
|
+
self.messages.error.connect(self.error.emit)
|
|
31
|
+
self.detail.error.connect(self.error.emit)
|
|
32
|
+
|
|
33
|
+
split = QSplitter(Qt.Orientation.Vertical)
|
|
34
|
+
split.addWidget(self.messages)
|
|
35
|
+
split.addWidget(self.detail)
|
|
36
|
+
split.setStretchFactor(0, 1)
|
|
37
|
+
split.setStretchFactor(1, 2)
|
|
38
|
+
|
|
39
|
+
layout = QVBoxLayout(self)
|
|
40
|
+
layout.addWidget(split)
|
|
41
|
+
|
|
42
|
+
def refresh(self) -> None:
|
|
43
|
+
"""Silent refresh — used by the auto-refresh timer (no PHI audit)."""
|
|
44
|
+
self.messages.refresh()
|
|
45
|
+
|
|
46
|
+
def reload(self) -> None:
|
|
47
|
+
"""User-initiated load (opening the page) — audits summary display."""
|
|
48
|
+
self.messages.refresh(audit=True)
|
|
49
|
+
|
|
50
|
+
def set_channel(self, channel_id: str) -> None:
|
|
51
|
+
"""Filter the list to one channel (used by the Connections 'Logs' link)."""
|
|
52
|
+
self.messages.set_channel_filter(channel_id)
|
|
53
|
+
|
|
54
|
+
def stop(self) -> None:
|
|
55
|
+
"""Stop the message-list + detail background runners (call on window close)."""
|
|
56
|
+
self.messages.stop()
|
|
57
|
+
self.detail.stop()
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Organization and contributors
|
|
3
|
+
"""Windows service control for the Engine Status page.
|
|
4
|
+
|
|
5
|
+
The engine can't control its *own* hosting service through the API (stopping it kills the API;
|
|
6
|
+
once stopped there's no API to start it), and service control needs admin rights — so this is
|
|
7
|
+
done locally via the Windows SCM: ``sc query`` for state (no elevation) and an elevated
|
|
8
|
+
``net start/stop`` for actions (one UAC prompt each). Same-machine, Windows-only; on other
|
|
9
|
+
platforms / when ``sc`` is absent, state is ``"unavailable"`` and actions are no-ops.
|
|
10
|
+
|
|
11
|
+
Elevated actions are fire-and-forget (the elevated process is detached, so output isn't
|
|
12
|
+
captured) — poll :func:`service_state` to observe the result.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import ctypes
|
|
18
|
+
import re
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
import messagefoundry
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"service_state",
|
|
27
|
+
"control_service",
|
|
28
|
+
"parse_service_state",
|
|
29
|
+
"install_script_path",
|
|
30
|
+
"install_service",
|
|
31
|
+
"is_safe_environment",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
_ACTIONS = {
|
|
35
|
+
"start": '{0} start "{1}"',
|
|
36
|
+
"stop": '{0} stop "{1}"',
|
|
37
|
+
"restart": '{0} stop "{1}" & {0} start "{1}"',
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
# The restart action chains two `net` calls with `&`, so control_service can't avoid cmd.exe — and
|
|
41
|
+
# that line runs ELEVATED. A service name with a quote/`&`/`|` could break out of its quoted argument
|
|
42
|
+
# and run arbitrary commands as admin. Windows service names need none of those, so allow only a
|
|
43
|
+
# conservative set and reject the rest (review low-16).
|
|
44
|
+
_SAFE_SERVICE_NAME = re.compile(r"^[A-Za-z0-9 ._-]+$")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _is_safe_service_name(name: str) -> bool:
|
|
48
|
+
return bool(_SAFE_SERVICE_NAME.match(name))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# The active-environment name is passed to install-service.ps1 as `-Environment <name>`, interpolated
|
|
52
|
+
# into an ELEVATED PowerShell command line (ShellExecuteW "runas"). It also becomes a filename segment
|
|
53
|
+
# (environments/<name>.toml), so constrain it to the same token charset the engine validates
|
|
54
|
+
# ([ai].environment) and reject anything else — an unsafe value could break out of the quoted arg and
|
|
55
|
+
# run as admin (cf. the service-name guard above).
|
|
56
|
+
_SAFE_ENV_NAME = re.compile(r"^[A-Za-z0-9._-]+$")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def is_safe_environment(name: str) -> bool:
|
|
60
|
+
"""True if ``name`` is a valid active-environment name (letters, digits, ``.``, ``_``, ``-``)."""
|
|
61
|
+
return bool(_SAFE_ENV_NAME.match(name))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def parse_service_state(sc_output: str) -> str:
|
|
65
|
+
"""Map ``sc query`` output to ``running`` / ``stopped`` / ``unknown``."""
|
|
66
|
+
text = sc_output.upper()
|
|
67
|
+
if "RUNNING" in text:
|
|
68
|
+
return "running"
|
|
69
|
+
if "STOP" in text: # STOPPED or STOP_PENDING
|
|
70
|
+
return "stopped"
|
|
71
|
+
return "unknown"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def service_state(name: str) -> str:
|
|
75
|
+
"""``running`` | ``stopped`` | ``not installed`` | ``unavailable`` (non-Windows / no ``sc``)."""
|
|
76
|
+
if sys.platform != "win32":
|
|
77
|
+
return "unavailable"
|
|
78
|
+
if not _is_safe_service_name(name):
|
|
79
|
+
return "unavailable" # never let an unsafe name enable the (elevated) control buttons
|
|
80
|
+
try:
|
|
81
|
+
# nosec: fixed system tool (sc), no shell; `name` is validated above (low-16).
|
|
82
|
+
proc = subprocess.run(["sc", "query", name], capture_output=True, text=True, timeout=5) # nosec B603 B607
|
|
83
|
+
except (OSError, subprocess.SubprocessError):
|
|
84
|
+
return "unavailable"
|
|
85
|
+
if proc.returncode != 0:
|
|
86
|
+
return "not installed" # e.g. error 1060: service does not exist
|
|
87
|
+
return parse_service_state(proc.stdout)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def control_service(action: str, name: str) -> bool:
|
|
91
|
+
"""Start/stop/restart ``name`` with a one-time UAC elevation. Returns False off Windows.
|
|
92
|
+
|
|
93
|
+
Uses ``net`` (synchronous) under an elevated, hidden ``cmd``. Output isn't captured;
|
|
94
|
+
call :func:`service_state` afterwards to see the new state.
|
|
95
|
+
|
|
96
|
+
Raises :class:`ValueError` for a service name with shell metacharacters — it would be
|
|
97
|
+
interpolated into an elevated cmd.exe line (review low-16)."""
|
|
98
|
+
if not _is_safe_service_name(name):
|
|
99
|
+
raise ValueError(f"unsafe service name {name!r}")
|
|
100
|
+
if sys.platform != "win32":
|
|
101
|
+
return False
|
|
102
|
+
command = _ACTIONS[action].format("net", name)
|
|
103
|
+
# ShellExecuteW with the "runas" verb raises the UAC prompt; SW_HIDE (0) hides the console.
|
|
104
|
+
ctypes.windll.shell32.ShellExecuteW(None, "runas", "cmd.exe", f"/c {command}", None, 0)
|
|
105
|
+
return True
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def install_script_path() -> Path | None:
|
|
109
|
+
"""Locate ``scripts/service/install-service.ps1`` in the (editable-installed) repo."""
|
|
110
|
+
pkg = messagefoundry.__file__
|
|
111
|
+
if pkg is None:
|
|
112
|
+
return None
|
|
113
|
+
script = Path(pkg).resolve().parents[1] / "scripts" / "service" / "install-service.ps1"
|
|
114
|
+
return script if script.exists() else None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _install_params(script_path: str, environment: str) -> str:
|
|
118
|
+
"""Build the argument string for the elevated installer launch.
|
|
119
|
+
|
|
120
|
+
``environment`` is the active environment the service will run as (``serve --env``); it is
|
|
121
|
+
interpolated into an elevated command line, so reject anything that isn't a plain environment
|
|
122
|
+
name (raises :class:`ValueError`). Validation runs on every platform so it is unit-testable."""
|
|
123
|
+
if not is_safe_environment(environment):
|
|
124
|
+
raise ValueError(f"unsafe environment name {environment!r}")
|
|
125
|
+
return f'-NoExit -ExecutionPolicy Bypass -File "{script_path}" -Environment "{environment}"'
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def install_service(script_path: str, environment: str) -> bool:
|
|
129
|
+
"""Run the install script elevated in a *visible* PowerShell window (one-time setup, so the
|
|
130
|
+
operator can read the output / 'next steps' and any errors). ``environment`` is the active
|
|
131
|
+
environment the service runs as (ADR 0017 — install-service.ps1 requires it). Returns False off
|
|
132
|
+
Windows. Raises :class:`ValueError` for an unsafe environment name (it runs elevated)."""
|
|
133
|
+
params = _install_params(script_path, environment) # validates env on every platform
|
|
134
|
+
if sys.platform != "win32":
|
|
135
|
+
return False
|
|
136
|
+
ctypes.windll.shell32.ShellExecuteW(None, "runas", "powershell.exe", params, None, 1)
|
|
137
|
+
return True
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Organization and contributors
|
|
3
|
+
"""Self-service active-session inventory dialog (the WP-10 console view).
|
|
4
|
+
|
|
5
|
+
Lists the signed-in user's active sessions (``GET /me/sessions``) and lets them revoke an individual
|
|
6
|
+
session or sign out everywhere else. The **current** session is flagged and cannot be revoked here —
|
|
7
|
+
that is what "Sign out" does — so this dialog never logs the user out. Calls are synchronous like the
|
|
8
|
+
other console dialogs (localhost, modal; see :mod:`messagefoundry.console.client`).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from PySide6.QtWidgets import (
|
|
14
|
+
QDialog,
|
|
15
|
+
QHBoxLayout,
|
|
16
|
+
QHeaderView,
|
|
17
|
+
QLabel,
|
|
18
|
+
QMessageBox,
|
|
19
|
+
QPushButton,
|
|
20
|
+
QTableWidget,
|
|
21
|
+
QTableWidgetItem,
|
|
22
|
+
QVBoxLayout,
|
|
23
|
+
QWidget,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
from messagefoundry.api.auth_models import SessionInfo
|
|
27
|
+
from messagefoundry.console.client import ApiError, EngineClient
|
|
28
|
+
from messagefoundry.console.widgets import ERROR_COLOR, fmt_ts
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SessionsDialog(QDialog):
|
|
32
|
+
"""The user's active sessions with per-row revoke + "sign out everywhere else"."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, client: EngineClient, *, parent: QWidget | None = None) -> None:
|
|
35
|
+
super().__init__(parent)
|
|
36
|
+
self.setWindowTitle("Active sessions — MessageFoundry")
|
|
37
|
+
self._client = client
|
|
38
|
+
self._sessions: list[SessionInfo] = []
|
|
39
|
+
|
|
40
|
+
self._table = QTableWidget(0, 5)
|
|
41
|
+
self._table.setHorizontalHeaderLabels(["Client", "Created", "Last used", "Expires", ""])
|
|
42
|
+
header = self._table.horizontalHeader()
|
|
43
|
+
if header is not None:
|
|
44
|
+
header.setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
|
|
45
|
+
self._table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
|
|
46
|
+
self._table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
|
|
47
|
+
|
|
48
|
+
self._error = QLabel("")
|
|
49
|
+
self._error.setStyleSheet(f"color: {ERROR_COLOR};")
|
|
50
|
+
self._error.setWordWrap(True)
|
|
51
|
+
|
|
52
|
+
self._others_btn = QPushButton("Sign out everywhere else")
|
|
53
|
+
self._others_btn.clicked.connect(self._sign_out_others)
|
|
54
|
+
refresh_btn = QPushButton("Refresh")
|
|
55
|
+
refresh_btn.clicked.connect(self._reload)
|
|
56
|
+
close_btn = QPushButton("Close")
|
|
57
|
+
close_btn.clicked.connect(self.accept)
|
|
58
|
+
|
|
59
|
+
buttons = QHBoxLayout()
|
|
60
|
+
buttons.addWidget(self._others_btn)
|
|
61
|
+
buttons.addStretch(1)
|
|
62
|
+
buttons.addWidget(refresh_btn)
|
|
63
|
+
buttons.addWidget(close_btn)
|
|
64
|
+
|
|
65
|
+
layout = QVBoxLayout(self)
|
|
66
|
+
layout.addWidget(self._table)
|
|
67
|
+
layout.addWidget(self._error)
|
|
68
|
+
layout.addLayout(buttons)
|
|
69
|
+
|
|
70
|
+
self._reload()
|
|
71
|
+
|
|
72
|
+
def _reload(self) -> None:
|
|
73
|
+
self._error.clear()
|
|
74
|
+
try:
|
|
75
|
+
self._sessions = self._client.list_sessions()
|
|
76
|
+
except ApiError as exc:
|
|
77
|
+
self._error.setText(str(exc))
|
|
78
|
+
return
|
|
79
|
+
self._table.setRowCount(len(self._sessions))
|
|
80
|
+
for row, session in enumerate(self._sessions):
|
|
81
|
+
label = (session.client or "unknown") + (" (this device)" if session.current else "")
|
|
82
|
+
cells = [
|
|
83
|
+
label,
|
|
84
|
+
fmt_ts(session.created_at),
|
|
85
|
+
fmt_ts(session.last_used_at),
|
|
86
|
+
fmt_ts(session.expires_at),
|
|
87
|
+
]
|
|
88
|
+
for col, text in enumerate(cells):
|
|
89
|
+
self._table.setItem(row, col, QTableWidgetItem(text))
|
|
90
|
+
# The current session is revoked via "Sign out", not here, so it gets a label not a button.
|
|
91
|
+
if session.current:
|
|
92
|
+
self._table.setCellWidget(row, 4, QLabel("current"))
|
|
93
|
+
else:
|
|
94
|
+
revoke = QPushButton("Revoke")
|
|
95
|
+
revoke.clicked.connect(lambda _=False, sid=session.id: self._revoke(sid))
|
|
96
|
+
self._table.setCellWidget(row, 4, revoke)
|
|
97
|
+
# Nothing else to sign out when only this session exists.
|
|
98
|
+
self._others_btn.setEnabled(any(not s.current for s in self._sessions))
|
|
99
|
+
|
|
100
|
+
def _revoke(self, session_id: str) -> None:
|
|
101
|
+
try:
|
|
102
|
+
self._client.revoke_session(session_id)
|
|
103
|
+
except ApiError as exc:
|
|
104
|
+
self._error.setText(str(exc))
|
|
105
|
+
return
|
|
106
|
+
self._reload()
|
|
107
|
+
|
|
108
|
+
def _sign_out_others(self) -> None:
|
|
109
|
+
confirm = QMessageBox.question(
|
|
110
|
+
self,
|
|
111
|
+
"Sign out other sessions",
|
|
112
|
+
"Revoke every other active session (all devices except this one)?",
|
|
113
|
+
)
|
|
114
|
+
if confirm != QMessageBox.StandardButton.Yes:
|
|
115
|
+
return
|
|
116
|
+
try:
|
|
117
|
+
detail = self._client.revoke_other_sessions()
|
|
118
|
+
except ApiError as exc:
|
|
119
|
+
self._error.setText(str(exc))
|
|
120
|
+
return
|
|
121
|
+
self._reload()
|
|
122
|
+
QMessageBox.information(self, "Sessions revoked", detail)
|