Coverage for app/services/authorization.py: 100%
55 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-28 23:33 +0000
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-28 23:33 +0000
1"""
2AuthorizationService — Phase 23 permission resolution.
4Resolution order for effective_mask(user, aircraft, tenant):
5 1. Admin bypass → ALL bits
6 2. all_planes row for (user, tenant) → use its mask (or role default if NULL)
7 3. Per-aircraft row for (user, aircraft) → use its mask (or role default if NULL)
8 4. Profile-type default mask for the user's role
10The `can(user_id, action, aircraft_id, tenant_id)` wrapper maps the
11plain action string to a PermissionBit and calls effective_mask.
13view_only flag suppresses all write bits regardless of the resolved mask.
14"""
16from __future__ import annotations
19class AuthorizationService:
20 @staticmethod
21 def effective_mask(user_id: int, aircraft_id: int | None, tenant_id: int) -> int:
22 """Return the resolved PermissionBit mask for user on a specific aircraft.
24 Pass aircraft_id=None to get the tenant-level mask (e.g. for list queries).
25 """
26 from models import (
27 PermissionBit,
28 Role,
29 TenantUser,
30 User,
31 UserAircraftAccess,
32 UserAllAircraftAccess,
33 db,
34 )
36 tu = TenantUser.query.filter_by(user_id=user_id, tenant_id=tenant_id).first()
37 if not tu:
38 return 0
40 role = tu.role
42 # 1. Admin bypass
43 if role == Role.ADMIN:
44 return PermissionBit.ALL
46 user = db.session.get(User, user_id)
47 if not user:
48 return 0
50 # Owner always gets ALL (unless view_only — see below)
51 if role == Role.OWNER:
52 mask = PermissionBit.ALL
53 else:
54 # Role default is the starting point
55 default_mask = PermissionBit.ROLE_DEFAULTS.get(role.value, 0)
57 # 2. all_planes row overrides the default
58 all_row = UserAllAircraftAccess.query.filter_by(
59 user_id=user_id, tenant_id=tenant_id
60 ).first()
62 if all_row is not None:
63 mask = (
64 all_row.permissions_mask
65 if all_row.permissions_mask is not None
66 else default_mask
67 )
68 elif aircraft_id is not None:
69 # 3. Per-aircraft row overrides the default
70 ac_row = UserAircraftAccess.query.filter_by(
71 user_id=user_id, aircraft_id=aircraft_id
72 ).first()
73 if ac_row is not None:
74 mask = (
75 ac_row.permissions_mask
76 if ac_row.permissions_mask is not None
77 else default_mask
78 )
79 else:
80 mask = 0 # no access row → no access
81 else:
82 # No all_planes row and no aircraft_id: use role default for list-level checks
83 mask = default_mask
85 # view_only suppresses all write bits
86 if user.view_only:
87 write_bits = (
88 PermissionBit.EDIT_AIRCRAFT
89 | PermissionBit.WRITE_MAINTENANCE
90 | PermissionBit.EDIT_COMPONENTS
91 | PermissionBit.WRITE_LOGBOOK
92 | PermissionBit.RESERVE_AIRCRAFT
93 )
94 mask &= ~write_bits
96 return mask
98 @staticmethod
99 def can(
100 user_id: int,
101 action: str,
102 aircraft_id: int | None = None,
103 tenant_id: int | None = None,
104 ) -> bool:
105 """Return True if the user holds the bit for *action* on the aircraft.
107 When tenant_id is not supplied it is resolved from the user's TenantUser row.
108 """
109 from models import PermissionBit, TenantUser
111 if tenant_id is None:
112 tu = TenantUser.query.filter_by(user_id=user_id).first()
113 if not tu:
114 return False
115 tenant_id = tu.tenant_id
117 action_bits = {
118 "view_aircraft": PermissionBit.VIEW_AIRCRAFT,
119 "edit_aircraft": PermissionBit.EDIT_AIRCRAFT,
120 "view_maintenance": PermissionBit.READ_MAINT_FULL
121 | PermissionBit.READ_MAINT_LIMITED,
122 "edit_maintenance": PermissionBit.WRITE_MAINTENANCE,
123 "log_flight": PermissionBit.WRITE_LOGBOOK,
124 "reserve_aircraft": PermissionBit.RESERVE_AIRCRAFT,
125 "edit_components": PermissionBit.EDIT_COMPONENTS,
126 }
127 required = action_bits.get(action, 0)
128 if required == 0:
129 return False
130 mask = AuthorizationService.effective_mask(user_id, aircraft_id, tenant_id)
131 # For view_maintenance any of the two read bits is enough
132 if action == "view_maintenance":
133 return bool(mask & required)
134 return (mask & required) == required
136 @staticmethod
137 def maintenance_view_level(user_id: int, aircraft_id: int, tenant_id: int) -> str:
138 """Return 'full', 'limited', or 'none' for the user's maintenance read access."""
139 from models import PermissionBit
141 mask = AuthorizationService.effective_mask(user_id, aircraft_id, tenant_id)
142 if mask & PermissionBit.READ_MAINT_FULL:
143 return "full"
144 if mask & PermissionBit.READ_MAINT_LIMITED:
145 return "limited"
146 return "none"