Skip to content

Commit 69d52e2

Browse files
authored
Merge branch 'devel' into add_service_id
2 parents 01744b1 + 31c95bb commit 69d52e2

File tree

101 files changed

+2886
-198
lines changed

Some content is hidden

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

101 files changed

+2886
-198
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ jobs:
4848
python-version: ${{ matrix.tests.python-version }}
4949

5050
- name: Install tox
51-
run: pip${{ matrix.tests.python-version }} install tox
51+
run: pip${{ matrix.tests.python-version }} install tox tox-docker
5252

5353
- name: Run tox
5454
run: |

.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

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: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,14 @@ class OpenIdConnectConfiguration(BaseAuthenticatorConfiguration):
201201
ui_field_label=_("Username Key"),
202202
)
203203

204+
GROUPS_CLAIM = CharField(
205+
help_text=_("The JSON key used to extract the user's groups from the ID token or userinfo endpoint."),
206+
required=False,
207+
allow_null=True,
208+
default="Group",
209+
ui_field_label=_("Groups Claim"),
210+
)
211+
204212

205213
class AuthenticatorPlugin(SocialAuthMixin, OpenIdConnectAuth, AbstractAuthenticatorPlugin):
206214
configuration_class = OpenIdConnectConfiguration
@@ -209,6 +217,10 @@ class AuthenticatorPlugin(SocialAuthMixin, OpenIdConnectAuth, AbstractAuthentica
209217
category = "sso"
210218
configuration_encrypted_fields = ['SECRET']
211219

220+
@property
221+
def groups_claim(self):
222+
return self.setting('GROUPS_CLAIM')
223+
212224
def extra_data(self, user, backend, response, *args, **kwargs):
213225
for perm in ["is_superuser", get_setting('ANSIBLE_BASE_SOCIAL_AUDITOR_FLAG')]:
214226
if perm in response:
@@ -270,3 +282,12 @@ def user_data(self, access_token, *args, **kwargs):
270282
logger.error(_(f"Unable to decode user info response JWT: {e}"))
271283
return None
272284
return user_data.json()
285+
286+
def get_alternative_uid(self, **kwargs):
287+
preferred_username = kwargs.get("response", {}).get("preferred_username", None)
288+
uid = kwargs.get("uid", None)
289+
290+
if preferred_username != uid:
291+
return preferred_username
292+
293+
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):
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Generated by Django 4.2.11 on 2024-09-10 14:32
2+
3+
from django.db import migrations, models
4+
import django.db.models.deletion
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
('dab_authentication', '0013_alter_authenticator_order'),
11+
]
12+
13+
operations = [
14+
migrations.AddField(
15+
model_name='authenticator',
16+
name='auto_migrate_users_to',
17+
field=models.ForeignKey(help_text='Automatically move users from this authenticator to the target authenticator when a matching user logs in via the target authenticator. For this to work, the field used for the user ID on both authenticators needs to have the same value. This should only be used when migrating users between two authentication mechanisms that share the same user database (such as when both IDPs share the same LDAP user directory).', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='auto_migrate_users_from', to='dab_authentication.authenticator'),
18+
),
19+
]

0 commit comments

Comments
 (0)