diff --git a/usr/lib/python3/dist-packages/linuxmusterApi/pytests/test_devices.py b/usr/lib/python3/dist-packages/linuxmusterApi/pytests/test_devices.py index e047eb0..23bea22 100644 --- a/usr/lib/python3/dist-packages/linuxmusterApi/pytests/test_devices.py +++ b/usr/lib/python3/dist-packages/linuxmusterApi/pytests/test_devices.py @@ -89,3 +89,9 @@ def test_import_devices_denied(self, user): def test_import_devices_sa_other_school_forbidden(self): r = client.get(f"{BASE_URL}/devices/list/other-school/import-devices", headers={"X-API-KEY": SCHOOLADMIN.jwt}) assert r.status_code == 403 + + @pytest.mark.parametrize("user", USERS[2:]) + def test_get_device_roles_denied(self, user): + r = client.get(f"{BASE_URL}/devices/roles", headers={"X-API-KEY": user.jwt}) + assert r.status_code == 401 + assert 'Permission denied' in r.json()["detail"] diff --git a/usr/lib/python3/dist-packages/linuxmusterApi/pytests/test_devices_roles.py b/usr/lib/python3/dist-packages/linuxmusterApi/pytests/test_devices_roles.py new file mode 100644 index 0000000..e910269 --- /dev/null +++ b/usr/lib/python3/dist-packages/linuxmusterApi/pytests/test_devices_roles.py @@ -0,0 +1,168 @@ +import configparser +from unittest.mock import Mock + +import pytest +from fastapi import FastAPI, HTTPException +from fastapi.testclient import TestClient + +from routers_v1 import devices +from security import AuthenticatedUser, RoleChecker, check_authentication_header + + +ROLES = [ + "addc", + "byod", + "classroom-studentcomputer", + "classroom-teachercomputer", + "faculty-teachercomputer", + "iponly", + "mobile", + "printer", + "router", + "server", + "staffcomputer", + "switch", + "thinclient", + "voip", + "wlan", +] + + +@pytest.fixture +def sophomorix_ini(monkeypatch): + ini = Mock() + ini.computerrole = list(reversed(ROLES)) + monkeypatch.setattr(devices, "SophomorixIni", lambda: ini) + return ini + + +def make_client(role="globaladministrator"): + """The devices router alone, with authentication resolved to one role. + + Calling the handler directly cannot show which route a request reaches, and + that is the property worth pinning here — /devices/{device} would answer for + /devices/roles if the two were registered the other way round. + + Only check_authentication_header is overridden, so RoleChecker itself still + runs. That is what makes the denial cases below real: the equivalent tests + in test_devices.py go through the live server and never get past + authentication on a machine without matching credentials. + """ + + app = FastAPI() + app.include_router(devices.router, prefix="/v1") + app.dependency_overrides[check_authentication_header] = lambda: AuthenticatedUser( + dn="", + user=role, + role=role, + ) + return TestClient(app) + + +@pytest.fixture +def client(): + return make_client() + + +def test_a_get_on_the_roles_path_reaches_the_roles_handler(sophomorix_ini, client): + response = client.get("/v1/devices/roles") + + assert response.status_code == 200 + assert response.json() == ROLES + + +def test_roles_are_returned_sorted(sophomorix_ini): + assert devices.get_computer_roles(None) == ROLES + + +def test_roles_follow_the_installation_rather_than_a_fixed_list(sophomorix_ini): + # The point of the endpoint: a server that defines its own roles gets them, + # which a hardcoded list in a client could not. Also the assertion that fails + # if the handler ever stops reading the ini. + sophomorix_ini.computerrole = ["custom-lab-pc", "addc"] + + assert devices.get_computer_roles(None) == ["addc", "custom-lab-pc"] + + +def test_roles_are_read_per_request(monkeypatch): + # Instantiated in the handler, so editing sophomorix.ini does not need an API + # restart to take effect. + calls = [] + + def _make(): + calls.append(1) + ini = Mock() + ini.computerrole = ROLES + return ini + + monkeypatch.setattr(devices, "SophomorixIni", _make) + + devices.get_computer_roles(None) + devices.get_computer_roles(None) + + assert len(calls) == 2 + + +@pytest.mark.parametrize( + "error", + [ + # ConfigParser.read() ignores a missing or unreadable file; the constructor + # then reads a section this endpoint never asked for. + KeyError("ROLE_USER"), + configparser.MissingSectionHeaderError("sophomorix.ini", 1, "stray = line"), + ], +) +def test_an_unreadable_sophomorix_ini_names_the_file(monkeypatch, error): + def _raise(): + raise error + + monkeypatch.setattr(devices, "SophomorixIni", _raise) + + with pytest.raises(HTTPException) as raised: + devices.get_computer_roles(None) + + assert raised.value.status_code == 500 + assert "sophomorix" in raised.value.detail.lower() + # The original error is kept: KeyError('ROLE_USER') on its own would send an + # admin looking at user roles. + assert repr(error) in raised.value.detail + + +def test_roles_route_is_registered_before_the_device_path_parameter(): + # Cheap structural companion to the routing test above; on its own it is only + # a proxy, since it compares path strings and is blind to the method. + paths = [route.path for route in devices.router.routes] + + assert paths.index("/devices/roles") < paths.index("/devices/{device}") + + +@pytest.mark.parametrize("role", ["globaladministrator", "schooladministrator"]) +def test_roles_are_served_to_administrators(sophomorix_ini, role): + response = make_client(role).get("/v1/devices/roles") + + assert response.status_code == 200 + assert response.json() == ROLES + + +@pytest.mark.parametrize("role", ["teacher", "student", "parent", "staff"]) +def test_roles_are_denied_to_everyone_else(sophomorix_ini, role): + response = make_client(role).get("/v1/devices/roles") + + assert response.status_code == 401 + assert response.json()["detail"] == "Permission denied" + + +def test_roles_route_is_open_to_school_administrators(): + route = next( + r + for r in devices.router.routes + if r.path == "/devices/roles" and "GET" in r.methods + ) + checkers = [ + dependency.call + for dependency in route.dependant.dependencies + if isinstance(dependency.call, RoleChecker) + ] + + assert len(checkers) == 1 + assert checkers[0].roles == ["globaladministrator", "schooladministrator"] diff --git a/usr/lib/python3/dist-packages/linuxmusterApi/routers_v1/devices.py b/usr/lib/python3/dist-packages/linuxmusterApi/routers_v1/devices.py index fe0d68f..1e3f8e4 100644 --- a/usr/lib/python3/dist-packages/linuxmusterApi/routers_v1/devices.py +++ b/usr/lib/python3/dist-packages/linuxmusterApi/routers_v1/devices.py @@ -1,9 +1,11 @@ +import configparser import os import subprocess from fastapi import APIRouter, Depends, HTTPException from security import RoleChecker, AuthenticatedUser from linuxmusterTools.ldapconnector import LMNLdapReader as lr, LMNDevice +from linuxmusterTools.lmnconfig import SophomorixIni from linuxmusterTools.lmnfile import LMNFile from linuxmusterTools.samba_util import DeviceManager from utils.checks import get_printer_or_404 @@ -71,6 +73,40 @@ def get_all_devices(school: str, who: AuthenticatedUser = Depends(RoleChecker("G return devices_data +@router.get("/roles", name="List the computer roles a device may have") +def get_computer_roles(who: AuthenticatedUser = Depends(RoleChecker("GS"))): + """ + ## List the roles that are valid in the sophomorixRole column of devices.csv. + + Read from sophomorix.ini, so an installation that defines its own roles gets + them. Unlike GET /roles, which reports the roles currently present in LDAP, + this is the set a device may be assigned — the two differ on any server where + a valid role is not in use yet. + + ### Access + - global-administrators + - school-administrators + + \f + :param who: User requesting the data, read from API Token + :type who: AuthenticatedUser + :return: Valid computer roles, sorted + :rtype: list + """ + + + try: + return sorted(SophomorixIni().computerrole) + except (KeyError, configparser.Error) as e: + # ConfigParser.read() ignores a missing or unreadable file, and the + # constructor then reads a section this endpoint never asked for, so an + # unreadable ini surfaces as KeyError('ROLE_USER') — an error about user + # roles from the computer roles endpoint. + raise HTTPException( + status_code=500, + detail=f"Unable to read the computer roles from the sophomorix ini: {e!r}", + ) + @router.get("/{device}", name="Get device details") def get_device_details(device: str, credentials: bool = False, who: AuthenticatedUser = Depends(RoleChecker("GS"))): """