Coverage for src/pypermission/util/input_validation.py: 97%

127 statements  

« prev     ^ index     » next       coverage.py v7.11.3, created at 2025-12-01 18:06 +0000

1import inspect 

2from collections.abc import Callable 

3from enum import StrEnum 

4from functools import wraps 

5from typing import Any, Literal, NewType, TypeIs 

6 

7from pypermission.exc import PyPermissionError 

8 

9Subject = NewType("Subject", str) 

10Role = NewType("Role", str) 

11ResourceType = NewType("ResourceType", str) 

12ResourceID = NewType("ResourceID", str) 

13Action = NewType("Action", str) 

14 

15type DefinitionalT = Literal["Subject", "Role", "ResourceType", "ResourceID", "Action"] 

16 

17 

18class DefID(StrEnum): 

19 SUBJECT = "subject" 

20 ROLE = "role" 

21 CHILD_ROLE = "child_role" 

22 PARENT_ROLE = "parent_role" 

23 RESOURCE_TYPE = "resource_type" 

24 RESOURCE_ID = "resource_id" 

25 ACTION = "action" 

26 

27 

28def _raise_on_isinstance_str_fail(*, val: Any, def_id: DefID) -> None: 

29 if not isinstance(val, str): 29 ↛ 30line 29 didn't jump to line 30 because the condition on line 29 was never true

30 raise PyPermissionError( 

31 f"All `{def_id}` identifiers must be subclass of string. " 

32 f"Got {type(val).__name__}." 

33 ) 

34 

35 

36def _raise_on_colon(*, val: Any, def_id: DefID) -> None: 

37 if ":" in val: 

38 raise PyPermissionError(f"Invalid character `:` found in `{def_id}`!") 

39 

40 

41def _raise_on_bracket(*, val: str, def_id: DefID) -> None: 

42 if "[" in val: 

43 raise PyPermissionError(f"Invalid character `[` found in `{def_id}`!") 

44 if "]" in val: 

45 raise PyPermissionError(f"Invalid character `]` found in `{def_id}`!") 

46 

47 

48def _raise_on_empty(*, val: str, def_id: DefID) -> None: 

49 if val == "": 

50 raise PyPermissionError(f"Argument `{def_id}` cannot be empty!") 

51 

52 

53def _raise_on_wildcard(*, val: str, def_id: DefID) -> None: 

54 if val == "*": 

55 raise PyPermissionError(f"Argument `{def_id}` cannot be the character `*`!") 

56 

57 

58def _raise_on_lr_whitespaces(*, val: str, def_id: DefID) -> None: 

59 if val != val.strip(): 

60 raise PyPermissionError( 

61 f"Argument `{def_id}` cannot have leading or trailing spaces!" 

62 ) 

63 

64 

65def _raise_on_bracket_imbalance(*, value: str, def_id: DefID) -> None: 

66 depth = 0 

67 last = "" 

68 for ch in value: 

69 if ch == "[": 

70 depth += 1 

71 elif ch == "]": 

72 if last == "[": 

73 raise PyPermissionError( 

74 f"Invalid `{def_id}`: closing ']' used prematurely." 

75 ) 

76 depth -= 1 

77 if depth < 0: 

78 raise PyPermissionError( 

79 f"Invalid `{def_id}`: unmatched closing ']' in {value}." 

80 ) 

81 last = ch 

82 if depth != 0: 

83 raise PyPermissionError( 

84 f"Invalid `{def_id}`: unmatched opening '[' in {value}." 

85 ) 

86 

87 

88def assert_subject(subject: Any) -> TypeIs[Subject]: 

89 _raise_on_isinstance_str_fail(val=subject, def_id=DefID.SUBJECT) 

90 _raise_on_empty(val=subject, def_id=DefID.SUBJECT) 

91 _raise_on_lr_whitespaces(val=subject, def_id=DefID.SUBJECT) 

92 _raise_on_colon(val=subject, def_id=DefID.SUBJECT) 

93 _raise_on_wildcard(val=subject, def_id=DefID.SUBJECT) 

94 _raise_on_bracket_imbalance(value=subject, def_id=DefID.SUBJECT) 

95 return True 

96 

97 

98def assert_role(role: Any) -> TypeIs[Role]: 

99 _raise_on_isinstance_str_fail(val=role, def_id=DefID.ROLE) 

100 _raise_on_empty(val=role, def_id=DefID.ROLE) 

101 _raise_on_lr_whitespaces(val=role, def_id=DefID.ROLE) 

102 _raise_on_colon(val=role, def_id=DefID.ROLE) 

103 _raise_on_wildcard(val=role, def_id=DefID.ROLE) 

104 _raise_on_bracket_imbalance(value=role, def_id=DefID.ROLE) 

105 return True 

106 

107 

108def assert_parent_role(parent_role: Any) -> TypeIs[Role]: 

109 _raise_on_isinstance_str_fail(val=parent_role, def_id=DefID.PARENT_ROLE) 

110 _raise_on_empty(val=parent_role, def_id=DefID.PARENT_ROLE) 

111 _raise_on_lr_whitespaces(val=parent_role, def_id=DefID.PARENT_ROLE) 

112 _raise_on_colon(val=parent_role, def_id=DefID.PARENT_ROLE) 

113 _raise_on_wildcard(val=parent_role, def_id=DefID.PARENT_ROLE) 

114 _raise_on_bracket_imbalance(value=parent_role, def_id=DefID.PARENT_ROLE) 

115 return True 

116 

117 

118def assert_child_role(child_role: Any) -> TypeIs[Role]: 

119 _raise_on_isinstance_str_fail(val=child_role, def_id=DefID.CHILD_ROLE) 

120 _raise_on_empty(val=child_role, def_id=DefID.CHILD_ROLE) 

121 _raise_on_lr_whitespaces(val=child_role, def_id=DefID.CHILD_ROLE) 

122 _raise_on_colon(val=child_role, def_id=DefID.CHILD_ROLE) 

123 _raise_on_wildcard(val=child_role, def_id=DefID.CHILD_ROLE) 

124 _raise_on_bracket_imbalance(value=child_role, def_id=DefID.CHILD_ROLE) 

125 return True 

126 

127 

128def assert_resource_type(resource_type: Any) -> TypeIs[ResourceType]: 

129 _raise_on_isinstance_str_fail(val=resource_type, def_id=DefID.RESOURCE_TYPE) 

130 _raise_on_empty(val=resource_type, def_id=DefID.RESOURCE_TYPE) 

131 _raise_on_lr_whitespaces(val=resource_type, def_id=DefID.RESOURCE_TYPE) 

132 _raise_on_colon(val=resource_type, def_id=DefID.RESOURCE_TYPE) 

133 _raise_on_wildcard(val=resource_type, def_id=DefID.RESOURCE_TYPE) 

134 _raise_on_bracket(val=resource_type, def_id=DefID.RESOURCE_TYPE) 

135 return True 

136 

137 

138def assert_resource_id(resource_id: Any) -> TypeIs[ResourceID]: 

139 _raise_on_isinstance_str_fail(val=resource_id, def_id=DefID.RESOURCE_ID) 

140 _raise_on_lr_whitespaces(val=resource_id, def_id=DefID.RESOURCE_ID) 

141 _raise_on_colon(val=resource_id, def_id=DefID.RESOURCE_ID) 

142 _raise_on_bracket_imbalance(value=resource_id, def_id=DefID.RESOURCE_ID) 

143 return True 

144 

145 

146def assert_action(action: Any) -> TypeIs[Action]: 

147 _raise_on_isinstance_str_fail(val=action, def_id=DefID.ACTION) 

148 _raise_on_empty(val=action, def_id=DefID.ACTION) 

149 _raise_on_lr_whitespaces(val=action, def_id=DefID.ACTION) 

150 _raise_on_colon(val=action, def_id=DefID.ACTION) 

151 _raise_on_wildcard(val=action, def_id=DefID.ACTION) 

152 _raise_on_bracket_imbalance(value=action, def_id=DefID.ACTION) 

153 return True 

154 

155 

156type C[S] = Callable[[Any], TypeIs[S]] 

157 

158 

159VALIDATION_RULES: dict[ 

160 DefID, C[Subject] | C[Role] | C[ResourceType] | C[ResourceID] | C[Action] 

161] = { 

162 DefID.SUBJECT: assert_subject, 

163 DefID.ROLE: assert_role, 

164 DefID.CHILD_ROLE: assert_child_role, 

165 DefID.PARENT_ROLE: assert_parent_role, 

166 DefID.RESOURCE_TYPE: assert_resource_type, 

167 DefID.RESOURCE_ID: assert_resource_id, 

168 DefID.ACTION: assert_action, 

169} 

170 

171SKIP_IDENTIFIERS = { 

172 "db", 

173 "inherited", 

174 "cls", 

175 "self", 

176 "permission", # no check needed, as `Permission.__init__` already validates parameters 

177 "include_descendant_subjects", 

178 "include_ascendant_roles", 

179} 

180 

181 

182def validate_rbac_parameters[**P, T](func: Callable[P, T]) -> Callable[P, T]: 

183 """ 

184 Apply input validation to a method of the RBAC API. 

185 

186 Provides the following guardrails: 

187 

188 * The characters `[` and `]` are not allowed anywhere inside ResourceType 

189 * Strings `"*"` and `""` are only allowed in ResourceID 

190 * `:` can never be used within any string 

191 * Leading & trailing spaces are never allowed 

192 * Decorator will fail to apply, if trying to wrap a function with unexpected signature 

193 """ 

194 sig = inspect.signature(func) 

195 

196 param_names = set(sig.parameters) 

197 validateable_names = set(VALIDATION_RULES) 

198 names_to_validate = param_names & validateable_names 

199 if unexpected_args := param_names - (SKIP_IDENTIFIERS | validateable_names): 199 ↛ 200line 199 didn't jump to line 200 because the condition on line 199 was never true

200 raise PyPermissionError( 

201 f"Found unexpected args when applying the decorator: {unexpected_args}" 

202 ) 

203 

204 @wraps(func) 

205 def wrapper(*args: P.args, **kwargs: P.kwargs) -> T: 

206 # Bind arguments to parameter names (see if really necessary) 

207 bound = sig.bind(*args, **kwargs) # <- here 

208 bound.apply_defaults() # <- here 

209 

210 for name in names_to_validate: 

211 VALIDATION_RULES[DefID(name)](bound.arguments[name]) 

212 

213 return func(*args, **kwargs) 

214 

215 return wrapper