File size: 4,627 Bytes
a7ffb22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
806e511
a7ffb22
 
 
b6f5bc4
a7ffb22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
"""Roles and permissions. Every route says which permission it needs, not which role: this file is the one place
that knows what each role may do. Roles are decided by the server on every request (never by the device)."""
from __future__ import annotations

import threading
import time
from typing import Callable, Iterable

from fastapi import Depends, HTTPException, Request

from . import reads
from .auth import current_user

ROLES = ("student", "teacher", "manager", "admin", "developer")
GRANTABLE = ("student", "teacher", "manager", "admin")   # roles an admin can give; developer comes only from the settings

# What each role may do. "*" (developer) is every permission; a permission no role lists (dev.status, checked in status.py) is the developer's alone.
PERMISSIONS: dict[str, frozenset[str]] = {
    "student": frozenset({"path.use"}),
    "teacher": frozenset({"path.use", "class.teach", "class.roster"}),
    "manager": frozenset({"classes.manage", "class.roster", "students.list", "content.manage"}),   # content: subjects and lessons
    "admin": frozenset({"roles.manage"}),
    "developer": frozenset({"*"}),
}
CACHE_SECONDS = 30   # a role change shows within this many seconds (sooner on the server that made it)


def permissions_of(roles: Iterable[str]) -> frozenset[str]:
    return frozenset().union(*(PERMISSIONS.get(role, frozenset()) for role in roles))


def allowed(roles: Iterable[str], permission: str) -> bool:
    permissions = permissions_of(roles)
    return "*" in permissions or permission in permissions


def need(permission: str) -> Callable[..., dict]:
    """A route dependency: the caller is signed in and one of their roles has `permission`; else 403."""
    def dependency(user: dict = Depends(current_user)) -> dict:
        if not allowed(user["roles"], permission):
            raise HTTPException(403, "אין הרשאה לפעולה הזו")
        return user
    return dependency


def learner(request: Request, user: dict = Depends(need("path.use"))) -> dict:
    """Someone who may use the path: a teacher or developer, or a student who is in a class. A person whose
    only role is student and who is in no class is asked to talk to their teacher (403)."""
    if set(user["roles"]) == {"student"} and not reads.of(request).classes_of_student(user["email"]):
        raise HTTPException(403, "ask your teacher to add you to a class")
    return user


class RoleResolver:
    """The roles of an email, from the settings, the roles table and the classes (a class's teacher is a teacher,
    a class's student is a student). Cached per email for a few seconds so a page load costs a few store calls."""

    def __init__(self, settings, store, ttl: float = CACHE_SECONDS, clock: Callable[[], float] = time.monotonic) -> None:
        self.settings, self.store, self.ttl, self.clock = settings, store, ttl, clock
        self._cache: dict[str, tuple[float, frozenset[str]]] = {}
        self._lock = threading.Lock()

    def resolve(self, email: str, source=None) -> frozenset[str]:
        """`source` is the request's reads (so the classes read here are not read again by the handler); the store when None."""
        email = email.strip().lower()
        now = self.clock()
        with self._lock:
            cached = self._cache.get(email)
        if cached and cached[0] > now:
            return cached[1]
        source = source or self.store
        if hasattr(source, "prefetch"):                              # the three reads do not depend on each other: one wait
            source.prefetch(("roles_of", email), ("classes_taught", email), ("classes_of_student", email))
        roles = set(source.roles_of(email)) & set(GRANTABLE)        # a 'developer' row is ignored: it comes only from the settings
        if email in self.settings.developers:
            roles.add("developer")
        if email in self.settings.admins:
            roles.add("admin")
        if email in self.settings.teachers or source.classes_taught(email):
            roles.add("teacher")
        if source.classes_of_student(email):
            roles.add("student")
        if not roles:
            roles.add("student")
        found = frozenset(roles)
        with self._lock:
            self._cache[email] = (now + self.ttl, found)
        return found

    def forget(self, email: str | None = None) -> None:
        """Drop one email's cached roles, or all of them (after a class or role change)."""
        with self._lock:
            if email is None:
                self._cache.clear()
            else:
                self._cache.pop(email.strip().lower(), None)