Files
AR-Autopilot/arautopilot/studio/login_window.py
T
alro65 13a2867ef6 sprint-2.5: RBAC 4 roles + Studio bootable + Flash Console
End-to-end implementation per docs/sprint-2.5-plan.md.

New requirement added by user mid-sprint: 4-role RBAC (Super Admin /
Engineer / Owner / User) with dual-auth for Engineer flashing firmware,
plus a "mini Arduino IDE" inside the Studio.

Tests: pytest 231/231 green (129 Sprint 2 + 102 Sprint 2.5 new).

RBAC core (arautopilot/core/):

- rbac.py: 4 roles, 12 capabilities, immutable capability matrix,
  has() / capabilities_of() / require() / requires_dual_auth() helpers.
  Engineer flashing firmware needs SA approval; everything else is
  single-factor.
- user.py: User model with PBKDF2-HMAC-SHA256 PIN hashing (200k iters,
  16-byte salt, self-describing hash format for future migrations).
  4-8 digit numeric PINs enforced.
- user_store.py: JSON-backed user database. seed_demo_users() for
  first-run UX.
- audit.py: append-only JSONL audit log. AuditEvent with timestamp,
  user_id, role, action, target, outcome, reason, secondary_user_id
  for dual-auth, optional extra payload. Crypto signing of lines
  deferred to Sprint 8.

Studio GUI (arautopilot/studio/):

- app.py: real entry point (replaces Sprint 0 stub). --seed-demo
  populates demo users without launching GUI; --data-dir overrides the
  ~/.ar-autopilot/studio/ default.
- session.py: Session + SessionHolder. check() always audits the
  decision; verify_super_admin_pin() + log_dual_auth_grant() for
  dual-auth flows.
- login_window.py: modal login dialog with user picker + PIN field.
  Audits login attempts (success and bad-PIN denials).
- main_window.py: top-level window with sidebar (user + role + caps)
  and tab area (Overview, Flash Console, Project placeholder,
  Telemetry placeholder).
- flash_console.py: the "mini Arduino IDE". Lists serial ports via
  pyserial; picks firmware variant (esp32-dev / esp32-debug); compiles
  via 'pio run'; flashes via 'pio run -t upload --upload-port <port>';
  streams pio output to a dark-themed read-only console; supports
  cancel. For Engineer flashes, asks the Super Admin for their PIN
  inline before invoking pio. Records dual-auth grant + pio exit code
  in the audit log.

Dependencies:

- New [project.optional-dependencies] group 'studio': PySide6>=6.6,
  pyserial>=3.5, platformio>=6.1. Kept optional so the core can be
  installed in lean / CI environments.

Tests (arautopilot/tests/):

- test_rbac.py: 32 tests for capability matrix, dual-auth policy,
  no-privilege-escalation invariants, partial overlap between roles.
- test_user.py: 11 tests for PIN hashing, verification, salting,
  serialisation, field validators.
- test_audit.py: 9 tests for JSONL append, immutability, round-trip,
  corrupt-line detection, dual-auth event shape, blank-line tolerance.
- test_user_store.py: 10 tests for CRUD, persistence, role filtering,
  demo seed idempotency.
- test_session.py: 9 tests for capability checks + audit side effects,
  SA PIN verification, dual-auth recording, SessionHolder lifecycle.
- test_studio_smoke.py: 5 headless tests verifying Studio modules
  import without a display server, --seed-demo works, helpers safe to
  call without hardware.

NOT in Sprint 2.5 (intentional):
  - Crypto signing of audit log lines (hash-chain) -- Sprint 8
  - HWID binding of the user store -- Sprint 8
  - Project configurator + .appack compiler -- Sprint 4
  - Flutter bridge display -- Sprint 4
  - Telemetry dashboard tab -- Sprint 4
  - Serial monitor as a separate tab -- future enhancement

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 18:04:27 -04:00

124 lines
4.0 KiB
Python

"""Studio login window (PySide6).
Lists every active user from the local store, lets the operator pick
themselves and enter their PIN. On success, populates the global
:class:`SessionHolder` and emits :pyattr:`logged_in`.
"""
from __future__ import annotations
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QComboBox,
QDialog,
QDialogButtonBox,
QFormLayout,
QLabel,
QLineEdit,
QMessageBox,
QVBoxLayout,
)
from arautopilot.core.audit import AuditEvent, AuditLog, AuditOutcome
from arautopilot.core.user_store import UserStore
from arautopilot.studio.session import Session, SessionHolder
class LoginDialog(QDialog):
"""Modal login dialog. Emits :pyattr:`logged_in(session)` on success."""
logged_in = Signal(object) # Session
def __init__(
self,
store: UserStore,
audit: AuditLog,
parent: object | None = None,
) -> None:
super().__init__(parent)
self._store = store
self._audit = audit
self.setWindowTitle("AR-Autopilot Studio -- Login")
self.setModal(True)
self.setMinimumWidth(360)
layout = QVBoxLayout(self)
intro = QLabel("Select your user and enter the PIN.")
intro.setWordWrap(True)
layout.addWidget(intro)
form = QFormLayout()
self._user_combo = QComboBox()
for user in self._store.all_users():
if not user.active:
continue
self._user_combo.addItem(
f"{user.display_name} -- {user.role.value}",
userData=user.user_id,
)
form.addRow("User:", self._user_combo)
self._pin_field = QLineEdit()
self._pin_field.setEchoMode(QLineEdit.EchoMode.Password)
self._pin_field.setMaxLength(8)
self._pin_field.setPlaceholderText("4-8 digits")
self._pin_field.setInputMethodHints(Qt.InputMethodHint.ImhDigitsOnly)
form.addRow("PIN:", self._pin_field)
layout.addLayout(form)
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
)
buttons.accepted.connect(self._try_login)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
if self._user_combo.count() == 0:
QMessageBox.critical(
self,
"No users available",
"The local user store is empty. Run the demo seed:\n\n"
" python -m arautopilot.studio.app --seed-demo",
)
# ----- Internal -------------------------------------------------------
def _try_login(self) -> None:
user_id = self._user_combo.currentData()
pin = self._pin_field.text()
user = self._store.get(user_id) if user_id else None
if user is None:
QMessageBox.warning(self, "Login", "Pick a user.")
return
if not user.active:
QMessageBox.warning(self, "Login", "This user is disabled.")
return
if not user.verify_pin(pin):
self._audit.append(
AuditEvent(
user_id=user.user_id,
role=user.role.value,
action="login",
outcome=AuditOutcome.DENIED,
reason="bad PIN",
)
)
QMessageBox.warning(self, "Login", "Incorrect PIN.")
self._pin_field.clear()
self._pin_field.setFocus()
return
touched = user.touch_login()
self._store.replace(touched)
self._audit.append(
AuditEvent(
user_id=touched.user_id,
role=touched.role.value,
action="login",
outcome=AuditOutcome.SUCCESS,
)
)
session = Session(user=touched, store=self._store, audit=self._audit)
SessionHolder.set(session)
self.logged_in.emit(session)
self.accept()