Skip to content

Commit 667ba79

Browse files
authored
Merge branch 'devel' into max_recursion
2 parents b1140f6 + 595937b commit 667ba79

File tree

98 files changed

+2467
-193
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

98 files changed

+2467
-193
lines changed

.github/workflows/ci.yml

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,18 +17,23 @@ jobs:
1717
- env: check
1818
python-version: "3.11"
1919
sonar: false
20+
junit-xml-upload: false
2021
- env: py39
2122
python-version: "3.9"
2223
sonar: false
24+
junit-xml-upload: false
2325
- env: py310
2426
python-version: "3.10"
2527
sonar: false
28+
junit-xml-upload: false
2629
- env: py311
2730
python-version: "3.11"
2831
sonar: true
32+
junit-xml-upload: true
2933
- env: py311sqlite
3034
python-version: "3.11"
3135
sonar: false
36+
junit-xml-upload: false
3237
steps:
3338
- uses: actions/checkout@v4
3439
with:
@@ -43,7 +48,7 @@ jobs:
4348
python-version: ${{ matrix.tests.python-version }}
4449

4550
- name: Install tox
46-
run: pip${{ matrix.tests.python-version }} install tox
51+
run: pip${{ matrix.tests.python-version }} install tox tox-docker
4752

4853
- name: Run tox
4954
run: |
@@ -55,7 +60,7 @@ jobs:
5560
run: sed -i '2i <!-- PR ${{ github.event.number }} -->' coverage.xml
5661

5762
- name: Upload coverage as artifact
58-
uses: actions/upload-artifact@v2
63+
uses: actions/upload-artifact@v4
5964
if: matrix.tests.sonar
6065
with:
6166
name: coverage
@@ -67,3 +72,14 @@ jobs:
6772
env:
6873
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
6974
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
75+
76+
- name: Upload jUnit XML test results
77+
if: matrix.tests.junit-xml-upload && github.event_name == 'push' && github.repository == 'ansible/django-ansible-base' && github.ref_name == 'devel'
78+
continue-on-error: true
79+
run: >-
80+
curl -v --user "${{ vars.PDE_ORG_RESULTS_AGGREGATOR_UPLOAD_USER }}:${{ secrets.PDE_ORG_RESULTS_UPLOAD_PASSWORD }}"
81+
82+
--form "component_name=django-ansible-base"
83+
--form "git_commit_sha=${{ github.sha }}"
84+
--form "git_repository_url=https://github.com/${{ github.repository }}"
85+
"${{ vars.PDE_ORG_RESULTS_AGGREGATOR_UPLOAD_URL }}/api/results/upload/"

.github/workflows/release.yml

Lines changed: 75 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,15 @@ name: Release django-ansible-base
33

44
env:
55
LC_ALL: "C.UTF-8" # prevent ERROR: Ansible could not initialize the preferred locale: unsupported locale setting
6+
PROJECT_NAME: django-ansible-base
67

78
on:
89
workflow_dispatch:
910

1011
jobs:
11-
stage:
12+
build:
1213
runs-on: ubuntu-latest
13-
timeout-minutes: 90
14-
permissions:
15-
packages: write
16-
contents: write
14+
timeout-minutes: 2
1715
steps:
1816
- name: Checkout dab
1917
uses: actions/checkout@v4
@@ -24,12 +22,80 @@ jobs:
2422
run: echo py_version=`make PYTHON_VERSION` >> $GITHUB_ENV
2523

2624
- name: Install python ${{ env.py_version }}
27-
uses: actions/setup-python@v4
25+
uses: actions/setup-python@v5
2826
with:
2927
python-version: ${{ env.py_version }}
3028

31-
- name: Install python deeps
29+
- name: Install python deps
3230
run: pip install -r requirements/requirements_dev.txt
3331

34-
- name: Create release
35-
run: ansible-playbook tools/ansible/release.yml -i localhost -e github_token=${{ secrets.GITHUB_TOKEN }}
32+
- name: Build the dists
33+
run: >-
34+
ansible-playbook
35+
tools/ansible/release.yml
36+
-i localhost
37+
-e github_token=${{ secrets.GITHUB_TOKEN }}
38+
-t build
39+
40+
- name: Store the distribution packages
41+
uses: actions/upload-artifact@v4
42+
with:
43+
name: python-package-distributions
44+
path: |
45+
dist/*.tar.gz
46+
dist/*.whl
47+
retention-days: 90
48+
49+
publish-pypi:
50+
name: Publish to PyPI
51+
needs:
52+
- build
53+
54+
runs-on: ubuntu-latest
55+
56+
timeout-minutes: 1
57+
58+
environment:
59+
name: pypi
60+
url: https://pypi.org/project/${{ env.PROJECT_NAME }}
61+
62+
permissions:
63+
contents: read # This job doesn't need to `git push` anything
64+
id-token: write # PyPI Trusted Publishing (OIDC)
65+
66+
steps:
67+
- name: Download all the dists
68+
uses: actions/download-artifact@v4
69+
with:
70+
name: python-package-distributions
71+
path: dist/
72+
- name: Publish dists to PyPI
73+
uses: pypa/gh-action-pypi-publish@release/v1
74+
75+
post-release-repo-update:
76+
name: Make a GitHub Release
77+
needs:
78+
- publish-pypi
79+
80+
runs-on: ubuntu-latest
81+
82+
timeout-minutes: 2
83+
84+
permissions:
85+
packages: write
86+
contents: write
87+
88+
steps:
89+
- name: Download all the dists
90+
uses: actions/download-artifact@v4
91+
with:
92+
name: python-package-distributions
93+
path: dist/
94+
95+
- name: Create a GitHub Release uploading the dists
96+
run: >-
97+
ansible-playbook
98+
tools/ansible/release.yml
99+
-i localhost
100+
-e github_token=${{ secrets.GITHUB_TOKEN }}
101+
-t github

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ __pycache__
1919
.coverage*
2020
coverage.xml
2121
coverage.json
22+
django-ansible-base-test-results.xml
2223
htmlcov
2324
*.tox
2425
venv/

ansible_base/authentication/authenticator_plugins/_radiusauth.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@
3939
#Handle custom user models
4040
from django.contrib.auth import get_user_model
4141
from django.contrib.auth.models import Group
42+
43+
logger = logging.getLogger('ansible_base.authentication.authenticator_plugins._radiusauth')
44+
4245
User = get_user_model()
4346

4447
DICTIONARY = u"""
@@ -149,23 +152,23 @@ def _perform_radius_auth(self, client, packet):
149152
try:
150153
reply = client.SendPacket(packet)
151154
except Timeout as e:
152-
logging.error("RADIUS timeout occurred contacting %s:%s" % (
155+
logger.error("RADIUS timeout occurred contacting %s:%s" % (
153156
client.server, client.authport))
154157
return None
155158
except Exception as e:
156-
logging.error("RADIUS error: %s" % e)
159+
logger.error("RADIUS error: %s" % e)
157160
return None
158161

159162
if reply.code == AccessReject:
160-
logging.warning("RADIUS access rejected for user '%s'" % (
163+
logger.warning("RADIUS access rejected for user '%s'" % (
161164
packet['User-Name']))
162165
return None
163166
elif reply.code != AccessAccept:
164-
logging.error("RADIUS access error for user '%s' (code %s)" % (
167+
logger.error("RADIUS access error for user '%s' (code %s)" % (
165168
packet['User-Name'], reply.code))
166169
return None
167170

168-
logging.info("RADIUS access granted for user '%s'" % (
171+
logger.info("RADIUS access granted for user '%s'" % (
169172
packet['User-Name']))
170173

171174
if "Class" not in reply.keys():
@@ -190,7 +193,7 @@ def _perform_radius_auth(self, client, packet):
190193
elif role == "superuser":
191194
is_superuser = True
192195
else:
193-
logging.warning("RADIUS Attribute Class contains unknown role '%s'. Only roles 'staff' and 'superuser' are allowed" % cl)
196+
logger.warning("RADIUS Attribute Class contains unknown role '%s'. Only roles 'staff' and 'superuser' are allowed" % cl)
194197
return groups, is_staff, is_superuser
195198

196199
def _radius_auth(self, server, username, password):
@@ -232,7 +235,7 @@ def get_user_groups(self, group_names):
232235
groups = Group.objects.filter(name__in=group_names)
233236
if len(groups) != len(group_names):
234237
local_group_names = [g.name for g in groups]
235-
logging.warning("RADIUS reply contains %d user groups (%s), but only %d (%s) found" % (
238+
logger.warning("RADIUS reply contains %d user groups (%s), but only %d (%s) found" % (
236239
len(group_names), ", ".join(group_names), len(groups), ", ".join(local_group_names)))
237240
return groups
238241

ansible_base/authentication/authenticator_plugins/base.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import logging
22

3+
from django.db import IntegrityError, transaction
34
from django.utils.translation import gettext_lazy as _
45
from rest_framework import serializers
56
from rest_framework.fields import empty
@@ -134,3 +135,61 @@ def add_related_fields(self, request, authenticator):
134135

135136
def validate(self, serializer, data):
136137
return data
138+
139+
def move_authenticator_user_to(self, new_user, old_authenticator_user):
140+
"""
141+
new_user: django User instance. User that we're moving this account to.
142+
old_authenticator_user: AuthenticatorUser instance from this authenticator that is being removed.
143+
"""
144+
exclude_fields = (
145+
"social_auth",
146+
"authenticator_users",
147+
"groups",
148+
"has_roles",
149+
# We're ignoring role assignments for two reasons: 1. this isn't safe to copy right now, as it
150+
# could break the caching layer, 2. roles are intented to come from the authenticator via an
151+
# authenticator map, so when a user is move to a new authenticator, they're old roles should
152+
# be removed.
153+
"role_assignments",
154+
"logentry",
155+
)
156+
157+
old_user = old_authenticator_user.user
158+
159+
# Delete the old authenticator user
160+
old_authenticator_user.delete()
161+
162+
if new_user.pk == old_user.pk:
163+
return
164+
165+
# Copy all of the relationships from the old user to the new one
166+
for field in new_user._meta.get_fields():
167+
if field.many_to_many is True or field.one_to_many is True:
168+
name = field.name
169+
if name in exclude_fields:
170+
continue
171+
if not hasattr(old_user, name) or not hasattr(new_user, name):
172+
continue
173+
for x in getattr(old_user, name).all():
174+
# The only case where this might fail is if the relationship has a uniqueness
175+
# contraint on the user. In this case, all we can do is skip.
176+
try:
177+
# This atomic block is here to prevent a failure that is best described by
178+
# this stack overflow: https://stackoverflow.com/questions/21458387
179+
with transaction.atomic():
180+
getattr(new_user, name).add(x)
181+
except IntegrityError as e:
182+
logger.warning(f"Could not add {name} to {new_user.username}. Error: {e}")
183+
continue
184+
185+
return old_user
186+
187+
def get_alternative_uid(self, **kwargs):
188+
"""
189+
This method can be used to provide an alternative UID for the user in case we need to match
190+
UIDs across different authenticators (as is the case for auto_migrate_users_to). It receives
191+
the kwargs from the social auth pipeline (https://python-social-auth.readthedocs.io/en/latest/pipeline.html).
192+
193+
For a good example of this method in action, check out the keycloak authenticator.
194+
"""
195+
return None

ansible_base/authentication/authenticator_plugins/google_oauth2.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from ansible_base.authentication.social_auth import SocialAuthMixin, SocialAuthValidateCallbackMixin
88
from ansible_base.lib.serializers.fields import BooleanField, CharField, ChoiceField, ListField, URLField
99

10-
logger = logging.getLogger('ansible_base.authentication.authenticator_plugins.oidc')
10+
logger = logging.getLogger('ansible_base.authentication.authenticator_plugins.google_oauth2')
1111

1212

1313
class GoogleOAuth2Configuration(BaseAuthenticatorConfiguration):

ansible_base/authentication/authenticator_plugins/keycloak.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,6 @@ def extra_data(self, user, backend, response, *args, **kwargs):
5858

5959
def get_user_groups(self, extra_groups=[]):
6060
return extra_groups
61+
62+
def get_alternative_uid(self, **kwargs):
63+
return kwargs.get("response", {}).get("sub", None)

ansible_base/authentication/authenticator_plugins/ldap.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -374,7 +374,8 @@ def authenticate(self, request, username=None, password=None, **kwargs) -> (obje
374374
# Ensure USER_SEARCH and GROUP_SEARCH are converted into a search object
375375
for field, search_must_have_user in [('GROUP_SEARCH', False), ('USER_SEARCH', True)]:
376376
data = getattr(self.settings, field, None)
377-
if data is None:
377+
# Ignore None or empty (e.g., [])
378+
if not data:
378379
setattr(self.settings, field, None)
379380
elif not isinstance(data, config.LDAPSearch):
380381
try:

ansible_base/authentication/authenticator_plugins/oidc.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,3 +270,12 @@ def user_data(self, access_token, *args, **kwargs):
270270
logger.error(_(f"Unable to decode user info response JWT: {e}"))
271271
return None
272272
return user_data.json()
273+
274+
def get_alternative_uid(self, **kwargs):
275+
preferred_username = kwargs.get("response", {}).get("preferred_username", None)
276+
uid = kwargs.get("uid", None)
277+
278+
if preferred_username != uid:
279+
return preferred_username
280+
281+
return None

ansible_base/authentication/authenticator_plugins/saml.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,13 @@ def extra_data(self, user, backend, response, *args, **kwargs):
301301
def get_user_groups(self, extra_groups=[]):
302302
return extra_groups
303303

304+
def get_alternative_uid(self, **kwargs):
305+
if uid := kwargs.get("uid", None):
306+
if ":" in uid:
307+
return uid.split(":", maxsplit=1)[1]
308+
309+
return None
310+
304311

305312
class SAMLMetadataView(View):
306313
def get(self, request, pk=None, format=None):

0 commit comments

Comments
 (0)