"""Role-based access control + dual-auth policy for the AR Suite. The brief originally specified three RBAC tiers (Operator / Technician / Integrator). The product evolved in Sprint 2.5 to four roles with stricter guarantees: - ``SUPER_ADMIN`` -- the integrator (Álvaro). Unrestricted. - ``ENGINEER`` -- an authorised technician of the integrator. May edit ESP32 firmware and flash boards, but flashing requires a Super Admin PIN as a second factor (2-of-N approval). - ``OWNER`` -- the vessel owner. May manage Users on their own vessel and edit operational preferences. May NOT touch engineering parameters. - ``USER`` -- a crew member. May operate the pilot (engage, acknowledge alarms, change setpoints) but cannot create users or change config. Every gateable action is enumerated in :class:`Capability`. The matrix ``_CAPABILITIES_BY_ROLE`` is the **only** place where the role -> capability mapping lives; everything else queries via :func:`has`. """ from __future__ import annotations from enum import StrEnum class Role(StrEnum): """Top-level role of an authenticated user.""" SUPER_ADMIN = "super_admin" """The integrator (Álvaro). No restrictions.""" ENGINEER = "engineer" """Authorised technician of the integrator. Flashing needs SA approval.""" OWNER = "owner" """Vessel owner. Manages users on their vessel + operational preferences.""" USER = "user" """Crew member. Operates the pilot, no config rights.""" class Capability(StrEnum): """Every action that requires a permission check. Adding an entry here is a deliberate, reviewed change -- the UI and every call site must opt into the new gate. """ # ----- Code & firmware (integrator IP) --------------------------------- EDIT_PYTHON_PROJECT = "edit_python_project" """Edit the arautopilot package, tools, scripts. Super Admin only.""" EDIT_FIRMWARE_SOURCE = "edit_firmware_source" """Edit ESP32 C++ sources under firmware/**. SA + Engineer.""" FLASH_FIRMWARE = "flash_firmware" """Flash a .bin to an ESP32. SA direct; Engineer with SA PIN.""" BUILD_FIRMWARE = "build_firmware" """Compile firmware locally via pio. SA + Engineer.""" # ----- Tuning ---------------------------------------------------------- EDIT_BASE_GAINS = "edit_base_gains" """Edit the integrator's base PID gains (IP). Super Admin only.""" EDIT_COMMISSIONING = "edit_commissioning" """Edit field-commissioning parameters (rudder limits, calibration). SA + Engineer.""" EDIT_OPERATIONAL = "edit_operational" """Edit operational preferences (favourite headings, alarm volume, profile Soft/Normal/Sport). SA + Engineer + Owner.""" # ----- User management ------------------------------------------------- MANAGE_USERS = "manage_users" """Create / delete / edit Users on this vessel. SA + Owner.""" # ----- Runtime operation ---------------------------------------------- ENGAGE_PILOT = "engage_pilot" """Engage or disengage the autopilot. All roles.""" READ_TELEMETRY = "read_telemetry" """Read live telemetry from the firmware. All roles.""" ACK_ALARMS = "ack_alarms" """Acknowledge active alarms. All roles.""" # ----- Audit ----------------------------------------------------------- VIEW_AUDIT_LOG_FULL = "view_audit_log_full" """Read the full immutable audit log (all vessels). SA + Engineer.""" VIEW_AUDIT_LOG_VESSEL = "view_audit_log_vessel" """Read the audit log for this vessel only. SA + Engineer + Owner.""" # The single source of truth for who can do what. Order inside each set # is irrelevant; we use frozenset so accidental mutation is impossible. _CAPABILITIES_BY_ROLE: dict[Role, frozenset[Capability]] = { Role.SUPER_ADMIN: frozenset(Capability), # everything Role.ENGINEER: frozenset( { Capability.EDIT_FIRMWARE_SOURCE, Capability.FLASH_FIRMWARE, Capability.BUILD_FIRMWARE, Capability.EDIT_COMMISSIONING, Capability.EDIT_OPERATIONAL, Capability.ENGAGE_PILOT, Capability.READ_TELEMETRY, Capability.ACK_ALARMS, Capability.VIEW_AUDIT_LOG_FULL, Capability.VIEW_AUDIT_LOG_VESSEL, } ), Role.OWNER: frozenset( { Capability.EDIT_OPERATIONAL, Capability.MANAGE_USERS, Capability.ENGAGE_PILOT, Capability.READ_TELEMETRY, Capability.ACK_ALARMS, Capability.VIEW_AUDIT_LOG_VESSEL, } ), Role.USER: frozenset( { Capability.ENGAGE_PILOT, Capability.READ_TELEMETRY, Capability.ACK_ALARMS, } ), } def has(role: Role, capability: Capability) -> bool: """Return True iff ``role`` has ``capability``.""" return capability in _CAPABILITIES_BY_ROLE[role] def capabilities_of(role: Role) -> frozenset[Capability]: """Return the full capability set granted to ``role``.""" return _CAPABILITIES_BY_ROLE[role] def requires_dual_auth(actor_role: Role, capability: Capability) -> bool: """Return True if the given (actor, capability) pair requires a Super Admin approval in addition to the actor's own credentials. Sprint 2.5 policy: an ``ENGINEER`` flashing firmware needs the Super Admin's PIN as a second factor. Everything else is single-factor. Reasoning: flashing the wrong firmware to a customer board is a high-impact action with real-world consequences (rudder mis-driven, safety interlocks bypassed). The Super Admin retains a checkpoint. """ if actor_role == Role.ENGINEER and capability == Capability.FLASH_FIRMWARE: return True return False class PermissionError(Exception): """Raised when a capability check fails or a dual-auth second factor is missing.""" def require(role: Role, capability: Capability) -> None: """Raise :class:`PermissionError` if ``role`` lacks ``capability``. Use this at the entry point of every gated function. Combine with the audit log to record both grants and denials. """ if not has(role, capability): raise PermissionError( f"role {role.value!r} does not have capability {capability.value!r}" )