Skip to content

Commit b7a577a

Browse files
committed
Regex operator for JWT role extraction
When we originally added the JWT role extraction operators, we made some basic ones which thought would be enough, but we found that there's too much complexity and variance in the various fields we find in Red Hat SSO tokens, so we realized we're going to need a regex operator to handle more complex matching scenarios This commit simply adds a new `MATCH` operator to the `JsonPathOperator` enum, and implements the logic to handle it in the `JwtRolesResolver`. `_evaluate_operator` had to be modified to accept the entire `JwtRoleRule` so that it can access the pre-compiled regex pattern for better performance.
1 parent b0ae4ea commit b7a577a

File tree

3 files changed

+163
-12
lines changed

3 files changed

+163
-12
lines changed

src/authorization/resolvers.py

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -72,10 +72,8 @@ def evaluate_role_rules(rule: JwtRoleRule, jwt_claims: dict[str, Any]) -> UserRo
7272
return (
7373
set(rule.roles)
7474
if JwtRolesResolver._evaluate_operator(
75-
rule.negate,
75+
rule,
7676
[match.value for match in parse(rule.jsonpath).find(jwt_claims)],
77-
rule.operator,
78-
rule.value,
7977
)
8078
else set()
8179
)
@@ -99,19 +97,26 @@ def _get_claims(auth: AuthTuple) -> dict[str, Any]:
9997

10098
@staticmethod
10199
def _evaluate_operator(
102-
negate: bool, match: Any, operator: JsonPathOperator, value: Any
100+
rule: JwtRoleRule, match: Any
103101
) -> bool: # pylint: disable=too-many-branches
104-
"""Evaluate an operator against a match and value."""
102+
"""Evaluate an operator against a match and rule."""
105103
result = False
106-
match operator:
104+
match rule.operator:
107105
case JsonPathOperator.EQUALS:
108-
result = match == value
106+
result = match == rule.value
109107
case JsonPathOperator.CONTAINS:
110-
result = value in match
108+
result = rule.value in match
111109
case JsonPathOperator.IN:
112-
result = match in value
113-
114-
if negate:
110+
result = match in rule.value
111+
case JsonPathOperator.MATCH:
112+
# Use the pre-compiled regex pattern for better performance
113+
if rule.compiled_regex is not None:
114+
result = any(
115+
isinstance(item, str) and bool(rule.compiled_regex.search(item))
116+
for item in match
117+
)
118+
119+
if rule.negate:
115120
result = not result
116121

117122
return result

src/models/config.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
"""Model with service configuration."""
22

33
from pathlib import Path
4-
from typing import Optional, Any
4+
from typing import Optional, Any, Pattern
55
from enum import Enum
6+
import re
67

78
import jsonpath_ng
89
from jsonpath_ng.exceptions import JSONPathError
@@ -11,6 +12,7 @@
1112
ConfigDict,
1213
Field,
1314
model_validator,
15+
computed_field,
1416
FilePath,
1517
AnyHttpUrl,
1618
PositiveInt,
@@ -233,6 +235,7 @@ class JsonPathOperator(str, Enum):
233235
EQUALS = "equals"
234236
CONTAINS = "contains"
235237
IN = "in"
238+
MATCH = "match"
236239

237240

238241
class JwtRoleRule(ConfigurationBase):
@@ -272,6 +275,29 @@ def check_roles(self) -> Self:
272275

273276
return self
274277

278+
@model_validator(mode="after")
279+
def check_regex_pattern(self) -> Self:
280+
"""Verify that regex patterns are valid for MATCH operator."""
281+
if self.operator == JsonPathOperator.MATCH:
282+
if not isinstance(self.value, str):
283+
raise ValueError(
284+
f"MATCH operator requires a string regex pattern, got {type(self.value).__name__}"
285+
)
286+
try:
287+
re.compile(self.value)
288+
except re.error as e:
289+
raise ValueError(
290+
f"Invalid regex pattern for MATCH operator: {self.value}: {e}"
291+
) from e
292+
return self
293+
294+
@computed_field
295+
def compiled_regex(self) -> Optional[Pattern[str]]:
296+
"""Return compiled regex pattern for MATCH operator, None otherwise."""
297+
if self.operator == JsonPathOperator.MATCH and isinstance(self.value, str):
298+
return re.compile(self.value)
299+
return None
300+
275301

276302
class Action(str, Enum):
277303
"""Available actions in the system."""

tests/unit/authorization/test_resolvers.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,126 @@ async def test_resolve_roles_no_match(self):
7575
roles = await jwt_resolver.resolve_roles(auth)
7676
assert len(roles) == 0
7777

78+
async def test_resolve_roles_match_operator_email_domain(self):
79+
"""Test role extraction using MATCH operator with email domain regex."""
80+
role_rules = [
81+
JwtRoleRule(
82+
jsonpath="$.email",
83+
operator=JsonPathOperator.MATCH,
84+
value=r"@redhat\.com$",
85+
roles=["redhat_employee"],
86+
)
87+
]
88+
jwt_resolver = JwtRolesResolver(role_rules)
89+
90+
jwt_claims = {
91+
"exp": 1754489339,
92+
"iat": 1754488439,
93+
"sub": "f:123:employee@redhat.com",
94+
"email": "employee@redhat.com",
95+
}
96+
97+
auth = ("user", "token", claims_to_token(jwt_claims))
98+
roles = await jwt_resolver.resolve_roles(auth)
99+
assert "redhat_employee" in roles
100+
101+
async def test_resolve_roles_match_operator_no_match(self):
102+
"""Test role extraction using MATCH operator with no match."""
103+
role_rules = [
104+
JwtRoleRule(
105+
jsonpath="$.email",
106+
operator=JsonPathOperator.MATCH,
107+
value=r"@redhat\.com$",
108+
roles=["redhat_employee"],
109+
)
110+
]
111+
jwt_resolver = JwtRolesResolver(role_rules)
112+
113+
jwt_claims = {
114+
"exp": 1754489339,
115+
"iat": 1754488439,
116+
"sub": "f:123:user@example.com",
117+
"email": "user@example.com",
118+
}
119+
120+
auth = ("user", "token", claims_to_token(jwt_claims))
121+
roles = await jwt_resolver.resolve_roles(auth)
122+
assert len(roles) == 0
123+
124+
async def test_resolve_roles_match_operator_invalid_regex(self):
125+
"""Test that invalid regex patterns are rejected at rule creation time."""
126+
import pytest
127+
128+
with pytest.raises(
129+
ValueError, match="Invalid regex pattern for MATCH operator"
130+
):
131+
JwtRoleRule(
132+
jsonpath="$.email",
133+
operator=JsonPathOperator.MATCH,
134+
value="[invalid regex(", # Invalid regex pattern
135+
roles=["test_role"],
136+
)
137+
138+
async def test_resolve_roles_match_operator_non_string_pattern(self):
139+
"""Test that non-string regex patterns are rejected at rule creation time."""
140+
import pytest
141+
142+
with pytest.raises(
143+
ValueError, match="MATCH operator requires a string regex pattern"
144+
):
145+
JwtRoleRule(
146+
jsonpath="$.user_id",
147+
operator=JsonPathOperator.MATCH,
148+
value=123, # Non-string pattern
149+
roles=["test_role"],
150+
)
151+
152+
async def test_resolve_roles_match_operator_non_string_value(self):
153+
"""Test role extraction using MATCH operator with non-string match value."""
154+
role_rules = [
155+
JwtRoleRule(
156+
jsonpath="$.user_id",
157+
operator=JsonPathOperator.MATCH,
158+
value=r"\d+", # Number pattern
159+
roles=["numeric_user"],
160+
)
161+
]
162+
jwt_resolver = JwtRolesResolver(role_rules)
163+
164+
jwt_claims = {
165+
"exp": 1754489339,
166+
"iat": 1754488439,
167+
"user_id": 12345, # Non-string value
168+
}
169+
170+
auth = ("user", "token", claims_to_token(jwt_claims))
171+
roles = await jwt_resolver.resolve_roles(auth)
172+
assert len(roles) == 0 # Non-string values don't match regex
173+
174+
async def test_compiled_regex_property(self):
175+
"""Test that compiled regex pattern is properly created for MATCH operator."""
176+
import re
177+
178+
# Test MATCH operator creates compiled regex
179+
match_rule = JwtRoleRule(
180+
jsonpath="$.email",
181+
operator=JsonPathOperator.MATCH,
182+
value=r"@example\.com$",
183+
roles=["example_user"],
184+
)
185+
assert match_rule.compiled_regex is not None
186+
assert isinstance(match_rule.compiled_regex, re.Pattern)
187+
assert match_rule.compiled_regex.pattern == r"@example\.com$"
188+
189+
# Test non-MATCH operator returns None
190+
equals_rule = JwtRoleRule(
191+
jsonpath="$.email",
192+
operator=JsonPathOperator.EQUALS,
193+
value="test@example.com",
194+
roles=["example_user"],
195+
)
196+
assert equals_rule.compiled_regex is None
197+
78198

79199
class TestGenericAccessResolver:
80200
"""Test cases for GenericAccessResolver."""

0 commit comments

Comments
 (0)