feat: Add sudo authentication screen and update authorization flow
- Introduced a new module for terminal presentation during sudo authentication. - Updated the application to display a temporary authentication notice. - Enhanced tests to cover the new sudo authentication flow and its edge cases.
This commit is contained in:
parent
0b7b52521f
commit
d5a143ee36
5 changed files with 206 additions and 15 deletions
|
|
@ -49,9 +49,10 @@ uvx git+https://git.s1q.dev/phg/hosts.git
|
|||
> limitations before granting sudo access.
|
||||
|
||||
Press `Ctrl+E` to enter Privileged Mode. The application uses an existing sudo
|
||||
authorization or temporarily returns control of the terminal to `sudo` and its
|
||||
configured PAM authentication method. It then creates a Pre-edit Backup before
|
||||
enabling changes. `hosts` never collects or handles authentication credentials.
|
||||
authorization or displays a temporary authentication notice before returning
|
||||
control of the terminal to `sudo` and its configured PAM authentication method.
|
||||
It then creates a Pre-edit Backup before enabling changes. `hosts` never
|
||||
collects or handles authentication credentials.
|
||||
|
||||
Start with the [user guide](docs/user-guide.md) for the complete workflow,
|
||||
including persistence behavior and manual recovery.
|
||||
|
|
|
|||
|
|
@ -186,10 +186,11 @@ reachable through the TUI. Editing them manually is not a supported workflow.
|
|||
|
||||
### Privileged Mode needs authentication
|
||||
|
||||
This is expected when no cached sudo authorization is available. `hosts` never
|
||||
collects credentials; `sudo` and PAM present any configured authentication
|
||||
method, such as Touch ID or a password prompt. Canceling authentication leaves
|
||||
the application in Read-only Mode.
|
||||
This is expected when no cached sudo authorization is available. `hosts` first
|
||||
shows a temporary authentication notice, then `sudo` and PAM present any
|
||||
configured authentication method, such as Touch ID or a password prompt.
|
||||
`hosts` never collects credentials. Canceling authentication leaves the
|
||||
application in Read-only Mode.
|
||||
|
||||
### Privileged Mode cannot be enabled
|
||||
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ from .table_handler import TableHandler
|
|||
from .details_handler import DetailsHandler
|
||||
from .edit_handler import EditHandler
|
||||
from .navigation_handler import NavigationHandler
|
||||
from .privilege_prompt import sudo_authentication_screen
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -722,6 +723,7 @@ class HostsManagerApp(App):
|
|||
interrupted = False
|
||||
try:
|
||||
with self.suspend():
|
||||
with sudo_authentication_screen():
|
||||
try:
|
||||
success, message = self.manager.authorize_interactively()
|
||||
except KeyboardInterrupt:
|
||||
|
|
@ -731,6 +733,8 @@ class HostsManagerApp(App):
|
|||
"Interactive authorization requires terminal suspension; remaining in Read-only Mode."
|
||||
)
|
||||
return
|
||||
except KeyboardInterrupt:
|
||||
interrupted = True
|
||||
|
||||
if interrupted:
|
||||
self.update_status(
|
||||
|
|
|
|||
80
src/hosts/tui/privilege_prompt.py
Normal file
80
src/hosts/tui/privilege_prompt.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
"""Terminal presentation for foreground sudo authentication."""
|
||||
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
import os
|
||||
from shutil import get_terminal_size
|
||||
import signal
|
||||
import sys
|
||||
from typing import TextIO
|
||||
|
||||
|
||||
_ALTERNATE_SCREEN_ENTER = "\x1b[?1049h"
|
||||
_ALTERNATE_SCREEN_EXIT = "\x1b[?1049l"
|
||||
_AMBER_BOLD = "\x1b[1;38;5;214m"
|
||||
_RESET = "\x1b[0m"
|
||||
_FRAMED_NOTICE_MIN_WIDTH = 52
|
||||
|
||||
|
||||
def render_sudo_authentication_notice(*, width: int, color: bool) -> str:
|
||||
"""Return the sudo handoff notice suited to the available terminal width."""
|
||||
instructions = (
|
||||
"\n"
|
||||
"Follow the sudo authentication prompt below.\n"
|
||||
"hosts does not collect or store your credentials.\n"
|
||||
"Press Ctrl+C to cancel and remain in Read-only Mode.\n"
|
||||
)
|
||||
|
||||
if width < _FRAMED_NOTICE_MIN_WIDTH:
|
||||
heading = "PRIVILEGED MODE REQUESTED\nsudo authentication required\n"
|
||||
else:
|
||||
heading = (
|
||||
"+--------------------------------------------------+\n"
|
||||
"| PRIVILEGED MODE REQUESTED |\n"
|
||||
"| sudo authentication required |\n"
|
||||
"+--------------------------------------------------+\n"
|
||||
)
|
||||
|
||||
if color:
|
||||
heading = f"{_AMBER_BOLD}{heading}{_RESET}"
|
||||
return f"{heading}{instructions}"
|
||||
|
||||
|
||||
def _should_use_color(stream: TextIO) -> bool:
|
||||
"""Respect terminal and NO_COLOR conventions for the handoff notice."""
|
||||
return stream.isatty() and "NO_COLOR" not in os.environ
|
||||
|
||||
|
||||
@contextmanager
|
||||
def sudo_authentication_screen(
|
||||
*,
|
||||
stream: TextIO | None = None,
|
||||
width: int | None = None,
|
||||
) -> Iterator[None]:
|
||||
"""Isolate a foreground sudo/PAM conversation from the shell buffer.
|
||||
|
||||
Terminals that support the alternate-screen sequence hide the original
|
||||
shell viewport and scrollback for the duration of the conversation. If a
|
||||
terminal ignores that sequence, the notice is simply written to the
|
||||
existing terminal as the agreed graceful fallback.
|
||||
"""
|
||||
output = stream if stream is not None else sys.stdout
|
||||
columns = width if width is not None else get_terminal_size().columns
|
||||
previous_sigint_handler = signal.getsignal(signal.SIGINT)
|
||||
signal.signal(signal.SIGINT, signal.default_int_handler)
|
||||
try:
|
||||
output.write(_ALTERNATE_SCREEN_ENTER)
|
||||
output.write(
|
||||
render_sudo_authentication_notice(
|
||||
width=columns,
|
||||
color=_should_use_color(output),
|
||||
)
|
||||
)
|
||||
output.flush()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
output.write(f"{_RESET}{_ALTERNATE_SCREEN_EXIT}")
|
||||
output.flush()
|
||||
finally:
|
||||
signal.signal(signal.SIGINT, previous_sigint_handler)
|
||||
|
|
@ -1,6 +1,9 @@
|
|||
"""Tests for application-level authorization flow."""
|
||||
|
||||
from contextlib import contextmanager
|
||||
from io import StringIO
|
||||
import os
|
||||
import signal
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
|
@ -13,6 +16,10 @@ from src.hosts.tui.app import HostsManagerApp
|
|||
from src.hosts.tui.custom_footer import CustomFooter
|
||||
from src.hosts.tui.filter_modal import FilterModal
|
||||
from src.hosts.tui.help_modal import HelpModal
|
||||
from src.hosts.tui.privilege_prompt import (
|
||||
render_sudo_authentication_notice,
|
||||
sudo_authentication_screen,
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
|
|
@ -242,6 +249,53 @@ async def test_small_viewport_blocks_hidden_mutation_shortcuts():
|
|||
class TestPrivilegedModeAuthorization:
|
||||
"""Test the user-visible privileged-mode authorization flow."""
|
||||
|
||||
def test_sudo_notice_has_a_framed_and_a_narrow_terminal_variant(self):
|
||||
"""The notice keeps its safety language when there is no room for a frame."""
|
||||
framed = render_sudo_authentication_notice(width=80, color=False)
|
||||
narrow = render_sudo_authentication_notice(width=40, color=False)
|
||||
|
||||
for notice in (framed, narrow):
|
||||
assert "PRIVILEGED MODE REQUESTED" in notice
|
||||
assert "sudo authentication required" in notice
|
||||
assert "does not collect or store your credentials" in notice
|
||||
assert "Ctrl+C to cancel and remain in Read-only Mode" in notice
|
||||
|
||||
assert "+--------------------------------------------------+" in framed
|
||||
assert "+--------------------------------------------------+" not in narrow
|
||||
|
||||
def test_sudo_notice_uses_the_alternate_screen_for_the_entire_conversation(self):
|
||||
"""The shell viewport is restored after sudo/PAM is finished."""
|
||||
output = StringIO()
|
||||
|
||||
with sudo_authentication_screen(stream=output, width=80):
|
||||
output.write("sudo output\n")
|
||||
|
||||
rendered = output.getvalue()
|
||||
assert rendered.startswith("\x1b[?1049h")
|
||||
assert "PRIVILEGED MODE REQUESTED" in rendered
|
||||
assert "sudo output" in rendered
|
||||
assert rendered.endswith("\x1b[0m\x1b[?1049l")
|
||||
|
||||
def test_sudo_screen_turns_sigint_into_a_catchable_keyboard_interrupt(self):
|
||||
"""Asyncio's SIGINT handler must not cancel the Textual main task."""
|
||||
output = StringIO()
|
||||
previous_handler = signal.getsignal(signal.SIGINT)
|
||||
runner_handler_called = False
|
||||
|
||||
def runner_handler(_signum, _frame):
|
||||
nonlocal runner_handler_called
|
||||
runner_handler_called = True
|
||||
|
||||
signal.signal(signal.SIGINT, runner_handler)
|
||||
try:
|
||||
with pytest.raises(KeyboardInterrupt):
|
||||
with sudo_authentication_screen(stream=output, width=80):
|
||||
os.kill(os.getpid(), signal.SIGINT)
|
||||
finally:
|
||||
signal.signal(signal.SIGINT, previous_handler)
|
||||
|
||||
assert not runner_handler_called
|
||||
|
||||
def test_interactive_authorization_suspends_and_restores_the_tui(self):
|
||||
"""Test foreground PAM authorization runs while Textual is suspended."""
|
||||
events = []
|
||||
|
|
@ -252,13 +306,26 @@ class TestPrivilegedModeAuthorization:
|
|||
yield
|
||||
events.append("restored")
|
||||
|
||||
def authorize_interactively():
|
||||
@contextmanager
|
||||
def authentication_screen():
|
||||
assert events == ["suspended"]
|
||||
events.append("notice shown")
|
||||
yield
|
||||
events.append("notice dismissed")
|
||||
|
||||
def authorize_interactively():
|
||||
assert events == ["suspended", "notice shown"]
|
||||
events.append("authorized")
|
||||
return True, "Sudo access granted"
|
||||
|
||||
def finish_entering_edit_mode():
|
||||
assert events == ["suspended", "authorized", "restored"]
|
||||
assert events == [
|
||||
"suspended",
|
||||
"notice shown",
|
||||
"authorized",
|
||||
"notice dismissed",
|
||||
"restored",
|
||||
]
|
||||
events.append("edit mode enabled")
|
||||
return True, "Edit mode enabled"
|
||||
|
||||
|
|
@ -273,6 +340,9 @@ class TestPrivilegedModeAuthorization:
|
|||
app.suspend = Mock(return_value=suspended_tui())
|
||||
app.update_status = Mock()
|
||||
|
||||
with patch(
|
||||
"src.hosts.tui.app.sudo_authentication_screen", authentication_screen
|
||||
):
|
||||
app.action_toggle_edit_mode()
|
||||
|
||||
assert app.edit_mode
|
||||
|
|
@ -280,7 +350,14 @@ class TestPrivilegedModeAuthorization:
|
|||
app.manager.enter_edit_mode.assert_called_once_with()
|
||||
app.manager.authorize_interactively.assert_called_once_with()
|
||||
app.manager.finish_entering_edit_mode.assert_called_once_with()
|
||||
assert events == ["suspended", "authorized", "restored", "edit mode enabled"]
|
||||
assert events == [
|
||||
"suspended",
|
||||
"notice shown",
|
||||
"authorized",
|
||||
"notice dismissed",
|
||||
"restored",
|
||||
"edit mode enabled",
|
||||
]
|
||||
app.update_status.assert_called_once_with("Edit mode enabled")
|
||||
|
||||
def test_rejected_authorization_keeps_the_app_read_only(self):
|
||||
|
|
@ -333,6 +410,34 @@ class TestPrivilegedModeAuthorization:
|
|||
"Authorization was not granted; remaining in Read-only Mode."
|
||||
)
|
||||
|
||||
def test_interrupt_while_dismissing_the_sudo_notice_keeps_the_app_running(self):
|
||||
"""An interrupt during terminal cleanup must not escape and quit the app."""
|
||||
app = HostsManagerApp()
|
||||
app.manager = Mock()
|
||||
app.manager.enter_edit_mode.return_value = (
|
||||
False,
|
||||
"Interactive authorization required",
|
||||
)
|
||||
app.manager.authorize_interactively.return_value = (
|
||||
False,
|
||||
"Authorization was not granted",
|
||||
)
|
||||
app.suspend = Mock(return_value=suspended_tui())
|
||||
app.update_status = Mock()
|
||||
|
||||
@contextmanager
|
||||
def interrupted_notice():
|
||||
yield
|
||||
raise KeyboardInterrupt
|
||||
|
||||
with patch("src.hosts.tui.app.sudo_authentication_screen", interrupted_notice):
|
||||
app.action_toggle_edit_mode()
|
||||
|
||||
assert not app.edit_mode
|
||||
app.update_status.assert_called_once_with(
|
||||
"Authorization was not granted; remaining in Read-only Mode."
|
||||
)
|
||||
|
||||
def test_unsupported_suspension_keeps_the_app_read_only(self):
|
||||
"""Test the user receives an actionable suspension failure message."""
|
||||
app = HostsManagerApp()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue