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

1""" 

2AuthorizationService — Phase 23 permission resolution. 

3 

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 

9 

10The `can(user_id, action, aircraft_id, tenant_id)` wrapper maps the 

11plain action string to a PermissionBit and calls effective_mask. 

12 

13view_only flag suppresses all write bits regardless of the resolved mask. 

14""" 

15 

16from __future__ import annotations 

17 

18 

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. 

23 

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 ) 

35 

36 tu = TenantUser.query.filter_by(user_id=user_id, tenant_id=tenant_id).first() 

37 if not tu: 

38 return 0 

39 

40 role = tu.role 

41 

42 # 1. Admin bypass 

43 if role == Role.ADMIN: 

44 return PermissionBit.ALL 

45 

46 user = db.session.get(User, user_id) 

47 if not user: 

48 return 0 

49 

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) 

56 

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() 

61 

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 

84 

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 

95 

96 return mask 

97 

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. 

106 

107 When tenant_id is not supplied it is resolved from the user's TenantUser row. 

108 """ 

109 from models import PermissionBit, TenantUser 

110 

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 

116 

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 

135 

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 

140 

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"