Skip to content

Commit f1ec423

Browse files
[AAP-48713] Alter authenticator maps to work with attribute expansion (ansible#768)
## Description <!-- Mandatory: Provide a clear, concise description of the changes and their purpose --> - What is being changed? If you add the syntax `{% for_attr_value(<attribute name>) %}` to an authenticator map we will expand that value based on the values returned from an attribute on login. - Why is this change needed? This will make gateway backwards compatible with an obscure SAML setting from AWX. - How does this change address the issue? This allows authenticator maps to behave like the SAML authenticator can. ## Type of Change <!-- Mandatory: Check one or more boxes that apply --> - [ ] Bug fix (non-breaking change which fixes an issue) - [X] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] Documentation update - [ ] Test update - [X] Refactoring (no functional changes) - [ ] Development environment change - [ ] Configuration change ## Self-Review Checklist <!-- These items help ensure quality - they complement our automated CI checks --> - [X] I have performed a self-review of my code - [X] I have added relevant comments to complex code sections - [X] I have updated documentation where needed - [X] I have considered the security impact of these changes - [X] I have considered performance implications - [X] I have thought about error handling and edge cases - [X] I have tested the changes in my local environment ## Testing Instructions <!-- Optional for test-only changes. Mandatory for all other changes --> <!-- Must be detailed enough for reviewers to reproduce --> ### Prerequisites <!-- List any specific setup required --> ### Steps to Test 1. Setup an authenticator which return multiple attribute values 2. Create authenticator maps that expand values 3. Login through the authenticator 4. Assert that the values were expanded as expected ### Expected Results <!-- Describe what should happen after following the steps --> ## Additional Context <!-- Optional but helpful information --> ### Required Actions <!-- Check if changes require work in other areas --> <!-- Remove section if no external actions needed --> - [ ] Requires documentation updates <!-- API docs, feature docs, deployment guides --> - [ ] Requires downstream repository changes <!-- Specify repos: django-ansible-base, eda-server, etc. --> - [ ] Requires infrastructure/deployment changes <!-- CI/CD, installer updates, new services --> - [ ] Requires coordination with other teams <!-- UI team, platform services, infrastructure --> - [ ] Blocked by PR/MR: #XXX <!-- Reference blocking PRs/MRs with brief context --> ### Screenshots/Logs <!-- Add if relevant to demonstrate the changes -->
1 parent fae96e3 commit f1ec423

File tree

10 files changed

+1732
-102
lines changed

10 files changed

+1732
-102
lines changed

ansible_base/authentication/serializers/authenticator_map.py

Lines changed: 7 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,15 @@
11
import logging
2+
from typing import Optional
23

3-
from django.conf import settings
4-
from django.core.exceptions import ObjectDoesNotExist
54
from django.utils.translation import gettext_lazy as _
65
from rest_framework.serializers import ValidationError
76

87
from ansible_base.authentication.models import AuthenticatorMap
9-
from ansible_base.authentication.utils.authenticator_map import _EXPANSION_FIELDS, check_expansion_syntax, has_expansion
8+
from ansible_base.authentication.utils.authenticator_map import _EXPANSION_FIELDS, check_expansion_syntax, check_role_type, has_expansion
109
from ansible_base.authentication.utils.trigger_definition import TRIGGER_DEFINITION
1110
from ansible_base.lib.serializers.common import NamedCommonModelSerializer
12-
from ansible_base.lib.utils.auth import get_organization_model, get_team_model
1311
from ansible_base.lib.utils.string import is_empty
12+
from ansible_base.lib.utils.typing import TranslatedString
1413

1514
logger = logging.getLogger('ansible_base.authentication.serializers.authenticator_map')
1615

@@ -51,57 +50,12 @@ def validate(self, data) -> dict:
5150
raise ValidationError(errors)
5251
return data
5352

54-
@staticmethod
55-
def _is_rbac_installed():
56-
return 'ansible_base.rbac' in settings.INSTALLED_APPS
57-
58-
def validate_role_data(self, map_type, role, org, team):
59-
errors = {}
60-
61-
# Validation is possible only if RBAC is installed
62-
if not self._is_rbac_installed():
63-
logger.warning(_("You specified a role without RBAC installed "))
64-
return errors
65-
66-
from ansible_base.rbac.models import RoleDefinition
67-
68-
# If this role is dynamically expanded we can't check it now, only at run time.
53+
def validate_role_data(self, map_type: Optional[str], role: Optional[str], org: Optional[str], team: Optional[str]) -> dict[str, TranslatedString]:
54+
# If the role field has an expansion in it we can only check this role at runtime
6955
if has_expansion(role):
70-
return errors
71-
72-
try:
73-
rbac_role = RoleDefinition.objects.get(name=role)
74-
is_system_role = rbac_role.content_type is None
75-
76-
# system role is allowed for map type == role without further conditions
77-
if is_system_role and map_type == 'role':
78-
return errors
56+
return {}
7957

80-
is_org_role, is_team_role = False, False
81-
if not is_system_role:
82-
model_class = rbac_role.content_type.model_class()
83-
is_org_role = issubclass(model_class, get_organization_model())
84-
is_team_role = issubclass(model_class, get_team_model())
85-
86-
# role type and map type must correspond
87-
if map_type == 'organization' and not is_org_role:
88-
errors['role'] = _("For an organization map type you must specify an organization based role")
89-
90-
if map_type == 'team' and not is_team_role:
91-
errors['role'] = _("For a team map type you must specify a team based role")
92-
93-
# org/team role needs organization field
94-
if (is_org_role or is_team_role) and is_empty(org):
95-
errors["organization"] = _("You must specify an organization with the selected role")
96-
97-
# team role needs team field
98-
if is_team_role and is_empty(team):
99-
errors["team"] = _("You must specify a team with the selected role")
100-
101-
except ObjectDoesNotExist:
102-
errors['role'] = _("RoleDefinition {role} doesn't exist").format(role=role)
103-
104-
return errors
58+
return check_role_type(map_type, role, org, team)
10559

10660
def validate_trigger_data(self, data):
10761
errors = {}

ansible_base/authentication/utils/authenticator_map.py

Lines changed: 206 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,21 @@
1+
import logging
12
import re
2-
from typing import Any, Optional
3+
from typing import Any, Dict, Optional
34

5+
from django.conf import settings
6+
from django.core.exceptions import ObjectDoesNotExist
47
from django.utils.translation import gettext_lazy as _
58

9+
from ansible_base.authentication.models import AuthenticatorMap
10+
from ansible_base.lib.utils.auth import get_organization_model, get_team_model
11+
from ansible_base.lib.utils.collection import dict_cartesian_product
12+
from ansible_base.lib.utils.string import is_empty
13+
from ansible_base.lib.utils.typing import TranslatedString
14+
15+
logger = logging.getLogger('ansible_base.authentication.utils.authenticator_map')
16+
617
_EXPANSION_FIELDS = ['organization', 'role', 'team']
18+
_EXPANSION_RE = re.compile(r'{%\s*for_attr_value\(([^(%})]+)\)\s*%}')
719

820

921
def has_expansion(value: Optional[str]) -> bool:
@@ -18,12 +30,201 @@ def has_expansion(value: Optional[str]) -> bool:
1830
return False
1931

2032

21-
def check_expansion_syntax(value: Optional[str]) -> Optional[Any]:
33+
def check_expansion_syntax(value: Optional[str]) -> Optional[TranslatedString]:
2234
"""
2335
Check a given field to see if it contains the proper syntax for {% for_attr_value(user_orgs) %}
24-
25-
Raises a ValidationError if its incorrect
2636
"""
2737

28-
if has_expansion(value) and not re.search(r'{%\s*for_attr_value\(.+\)\s*%}', value):
38+
if has_expansion(value) and not _EXPANSION_RE.search(value):
2939
return _("Expansion only supports the format {% for_attr_value(attribute) %}")
40+
41+
42+
def expand_syntax(attributes: dict, auth_map: AuthenticatorMap) -> list[Dict[str, str]]:
43+
"""
44+
Given attributes and a map, look for the fields that can be expanded and do the expansion
45+
If no fields require expansion, do nothing
46+
47+
returns:
48+
list of named tuples of expanded value
49+
"""
50+
expanded_strings = {}
51+
for field in _EXPANSION_FIELDS:
52+
field_value = getattr(auth_map, field, None)
53+
54+
if field_value is None:
55+
# If this particular field is None we can move on.
56+
# This might be true for the team value of an authenticator map type of organization for example.
57+
continue
58+
59+
# Get a list of all of the attributes specified in {% for_attr_value(something) %}
60+
# In the above example, we would get a list like ['something'].
61+
# If there were more than one in a given string we would have an array like ['something', 'else']
62+
attrs_used = _EXPANSION_RE.findall(field_value)
63+
64+
if has_expansion(field_value) and attrs_used == []:
65+
# This field had an invalid expansion so we can't expand anything, just return []
66+
logger.info(
67+
f"Authenticator Map {auth_map.name} on {auth_map.authenticator.name} has a bad expansion in {field} unable to process map ({field_value})"
68+
)
69+
continue
70+
71+
# Make sure that the attributes we got contain all the ones we want to expand
72+
if missing_attributes := set(attrs_used).difference(set(attributes.keys())):
73+
logger.info(
74+
f"Authenticator Map {auth_map.name} on {auth_map.authenticator.name} tried to expand attribute(s) "
75+
f"{', '.join(missing_attributes)} but they were not in the users attributes!"
76+
)
77+
continue
78+
79+
# Normalize and check the attribute values we are going to use
80+
try:
81+
normalized_attributes = _normalize_attributes(auth_map, attrs_used, attributes)
82+
except ValueError:
83+
# If we had issues normalizing the data we can just move on because if can't replace
84+
# even one of the items in the string there is no point in trying at all
85+
continue
86+
87+
# Create an entry in our expanded_string dictionary with the initial value of the field
88+
expanded_strings[field] = [field_value]
89+
90+
# For each value in the attribute, expand the strings
91+
# Lets say we have a field like "Organization {% for_attr_value(org) %} - Department {% for attr_value(dept) %}"
92+
# And in our attributes we had:
93+
# org = ["a", "b"]
94+
# dept = ['Z', 'Y']
95+
# First our string list will be:
96+
# ['Organization {% for_attr_value(org) %} - Department {% for attr_value(dept) %}']
97+
# After the first pass of this loop, our string list will be:
98+
# ['Organization a - Department {% for attr_value(dept) %}', 'Organization b Department {% for attr_value(dept) %}']
99+
# After the second pass the string list will be:
100+
# [
101+
# ['Organization a - Department Z'
102+
# ['Organization a - Department Y'
103+
# ['Organization b - Department Z'
104+
# ['Organization b - Department Y'
105+
# ]
106+
expanded_strings[field] = _expand_strings(attrs_used, normalized_attributes, expanded_strings[field])
107+
108+
# Return the cartesian product of the expanded strings
109+
return dict_cartesian_product(expanded_strings)
110+
111+
112+
def _expand_strings(attrs_used: list[str], normalized_attributes: dict[str, list[str]], expanded_strings: list[str]) -> list[str]:
113+
for attr_name in attrs_used:
114+
# Make a list of new strings.
115+
new_strings = []
116+
117+
for value in normalized_attributes[attr_name]:
118+
for string in expanded_strings:
119+
new_strings.append(_EXPANSION_RE.sub(value, string, 1))
120+
121+
expanded_strings = new_strings
122+
123+
return expanded_strings
124+
125+
126+
def _normalize_attributes(auth_map: AuthenticatorMap, attrs_used: list[str], attributes: dict) -> dict[str, list[str]]:
127+
"""
128+
Normalize the attributes we are going to use
129+
130+
Given a list of attributes that are used (i.e. ["first_name", "last_name"]) create and return a dictionary of attributes lke:
131+
{
132+
"first_name": ["all", "values", "of", "first_name"],
133+
"last_name": ["value"],
134+
}
135+
136+
If the list contains non-strings we will raise errors
137+
138+
Raises ValueError if we got one or more issues when normalizing values
139+
"""
140+
normalized_attributes = {}
141+
errors = []
142+
for attr_name in attrs_used:
143+
attr_errors = []
144+
# Make sure the attribute is in the list of attributes we have.
145+
attr_value = _make_list(attributes.get(attr_name, []))
146+
147+
if len(attr_value) == 0:
148+
attr_errors.append(
149+
f"Authenticator Map {auth_map.name} on {auth_map.authenticator.name} tried to expand attribute {attr_name} "
150+
f"but there were not values in that attribute, ignoring"
151+
)
152+
153+
# If any of the values are not a string then we can't expand it
154+
for value in attr_value:
155+
if type(value) is not str:
156+
attr_errors.append(
157+
f"Authenticator Map {auth_map.name} on {auth_map.authenticator.name} tried to expand attribute {attr_name} "
158+
f"but that was not a list of string, instead got {value}, ignoring"
159+
)
160+
161+
if attr_errors == []:
162+
normalized_attributes[attr_name] = attr_value
163+
else:
164+
# If we got errors for this item add them to the overall errors for logging
165+
errors.extend(attr_errors)
166+
for error in attr_errors:
167+
logger.info(error)
168+
169+
if errors:
170+
raise ValueError()
171+
172+
return normalized_attributes
173+
174+
175+
def _make_list(value: Any) -> list[Any]:
176+
if type(value) is list:
177+
return value
178+
return [value]
179+
180+
181+
def _is_rbac_installed():
182+
"""
183+
Determine if RBAC is installed.
184+
Separated out for easy mocking.
185+
"""
186+
return 'ansible_base.rbac' in settings.INSTALLED_APPS
187+
188+
189+
def check_role_type(map_type: Optional[str], role: Optional[str], org: Optional[str], team: Optional[str]) -> dict[str, TranslatedString]:
190+
errors = {}
191+
192+
if not _is_rbac_installed():
193+
errors['role'] = _("You specified a role without RBAC installed ")
194+
return errors
195+
196+
from ansible_base.rbac.models import RoleDefinition
197+
198+
try:
199+
rbac_role = RoleDefinition.objects.get(name=role)
200+
is_system_role = rbac_role.content_type is None
201+
202+
# system role is allowed for map type == role without further conditions
203+
if is_system_role and map_type == 'role':
204+
return errors
205+
206+
is_org_role, is_team_role = False, False
207+
if not is_system_role:
208+
model_class = rbac_role.content_type.model_class()
209+
is_org_role = issubclass(model_class, get_organization_model())
210+
is_team_role = issubclass(model_class, get_team_model())
211+
212+
# role type and map type must correspond
213+
if map_type == 'organization' and not is_org_role:
214+
errors['role'] = _("For an organization map type you must specify an organization based role")
215+
216+
if map_type == 'team' and not is_team_role:
217+
errors['role'] = _("For a team map type you must specify a team based role")
218+
219+
# org/team role needs organization field
220+
if (is_org_role or is_team_role) and is_empty(org):
221+
errors["organization"] = _("You must specify an organization with the selected role")
222+
223+
# team role needs team field
224+
if is_team_role and is_empty(team):
225+
errors["team"] = _("You must specify a team with the selected role")
226+
227+
except ObjectDoesNotExist:
228+
errors['role'] = _("RoleDefinition {role} doesn't exist").format(role=role)
229+
230+
return errors

ansible_base/authentication/utils/claims.py

Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from rest_framework.serializers import DateTimeField
1818

1919
from ansible_base.authentication.models import Authenticator, AuthenticatorMap, AuthenticatorUser
20+
from ansible_base.authentication.utils.authenticator_map import check_role_type, expand_syntax
2021
from ansible_base.lib.abstract_models import AbstractOrganization, AbstractTeam, CommonModel
2122
from ansible_base.lib.utils.auth import get_organization_model, get_team_model
2223
from ansible_base.lib.utils.string import is_empty
@@ -58,15 +59,12 @@ def create_claims(authenticator: Authenticator, username: str, attrs: dict, grou
5859

5960
# load the maps
6061
logger.debug(f"Authenticator ID: {authenticator.id}")
61-
maps = AuthenticatorMap.objects.order_by("order")
62-
logger.debug(maps)
6362
maps = AuthenticatorMap.objects.filter(authenticator=authenticator.id).order_by("order")
6463
logger.debug("==============================================================")
65-
logger.debug(maps)
64+
logger.debug("Processing {maps.count()} map(s) for this authenticator")
6665

6766
for auth_map in maps:
68-
logger.debug(auth_map)
69-
logger.debug("++++")
67+
logger.debug(f"Processing map {auth_map.name} {auth_map.id}")
7068
has_permission = None
7169
trigger_result = TriggerResult.SKIP
7270
allowed_keys = TRIGGER_DEFINITION.keys()
@@ -109,22 +107,43 @@ def create_claims(authenticator: Authenticator, username: str, attrs: dict, grou
109107

110108
rule_responses.append({auth_map.id: has_permission, 'enabled': auth_map.enabled})
111109

110+
understood_map = False
112111
if auth_map.map_type == 'allow' and not has_permission:
113112
# If any rule does not allow we don't want to return this to true
114113
access_allowed = False
114+
understood_map = True
115115
elif auth_map.map_type == 'is_superuser':
116116
is_superuser = has_permission
117-
elif auth_map.map_type in ['team', 'role'] and not is_empty(auth_map.organization) and not is_empty(auth_map.team) and not is_empty(auth_map.role):
118-
if auth_map.organization not in org_team_mapping:
119-
org_team_mapping[auth_map.organization] = {}
120-
org_team_mapping[auth_map.organization][auth_map.team] = has_permission
121-
_add_rbac_role_mapping(has_permission, rbac_role_mapping, auth_map.role, auth_map.organization, auth_map.team)
122-
elif auth_map.map_type in ['organization', 'role'] and not is_empty(auth_map.organization) and not is_empty(auth_map.role):
123-
organization_membership[auth_map.organization] = has_permission
124-
_add_rbac_role_mapping(has_permission, rbac_role_mapping, auth_map.role, auth_map.organization)
125-
elif auth_map.map_type == 'role' and not is_empty(auth_map.role) and is_empty(auth_map.organization) and is_empty(auth_map.team):
126-
_add_rbac_role_mapping(has_permission, rbac_role_mapping, auth_map.role)
127-
else:
117+
understood_map = True
118+
elif auth_map.map_type in ['team', 'organization', 'role']:
119+
# These types of maps can have expansions
120+
for expanded_values in expand_syntax(attrs, auth_map):
121+
expanded_organization = expanded_values.get('organization', None)
122+
expanded_team = expanded_values.get('team', None)
123+
expanded_role = expanded_values.get('role', None)
124+
125+
if (role_errors := check_role_type(map_type=auth_map.map_type, role=expanded_role, team=expanded_team, org=expanded_organization)) != {}:
126+
logger.info(f"Map type {auth_map.map_type} of rule {auth_map.name} had an invalid role type and will be skipped {role_errors}")
127+
elif (
128+
auth_map.map_type in ['team', 'role']
129+
and not is_empty(expanded_organization)
130+
and not is_empty(expanded_team)
131+
and not is_empty(expanded_role)
132+
):
133+
if expanded_organization not in org_team_mapping:
134+
org_team_mapping[expanded_organization] = {}
135+
org_team_mapping[expanded_organization][expanded_team] = has_permission
136+
_add_rbac_role_mapping(has_permission, rbac_role_mapping, expanded_role, expanded_organization, expanded_team)
137+
understood_map = True
138+
elif auth_map.map_type in ['organization', 'role'] and not is_empty(expanded_organization) and not is_empty(expanded_role):
139+
organization_membership[expanded_organization] = has_permission
140+
_add_rbac_role_mapping(has_permission, rbac_role_mapping, expanded_role, expanded_organization)
141+
understood_map = True
142+
elif auth_map.map_type == 'role' and not is_empty(expanded_role) and is_empty(expanded_organization) and is_empty(expanded_team):
143+
_add_rbac_role_mapping(has_permission, rbac_role_mapping, expanded_role)
144+
understood_map = True
145+
146+
if not understood_map:
128147
logger.error(f"Map type {auth_map.map_type} of rule {auth_map.name} does not know how to be processed")
129148

130149
return {

0 commit comments

Comments
 (0)