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
« 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
7from pypermission.exc import PyPermissionError
9Subject = NewType("Subject", str)
10Role = NewType("Role", str)
11ResourceType = NewType("ResourceType", str)
12ResourceID = NewType("ResourceID", str)
13Action = NewType("Action", str)
15type DefinitionalT = Literal["Subject", "Role", "ResourceType", "ResourceID", "Action"]
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"
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 )
36def _raise_on_colon(*, val: Any, def_id: DefID) -> None:
37 if ":" in val:
38 raise PyPermissionError(f"Invalid character `:` found in `{def_id}`!")
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}`!")
48def _raise_on_empty(*, val: str, def_id: DefID) -> None:
49 if val == "":
50 raise PyPermissionError(f"Argument `{def_id}` cannot be empty!")
53def _raise_on_wildcard(*, val: str, def_id: DefID) -> None:
54 if val == "*":
55 raise PyPermissionError(f"Argument `{def_id}` cannot be the character `*`!")
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 )
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 )
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
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
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
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
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
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
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
156type C[S] = Callable[[Any], TypeIs[S]]
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}
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}
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.
186 Provides the following guardrails:
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)
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 )
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
210 for name in names_to_validate:
211 VALIDATION_RULES[DefID(name)](bound.arguments[name])
213 return func(*args, **kwargs)
215 return wrapper