Skip to content

Commit ce7755f

Browse files
authored
Merge branch 'ansible:devel' into aap-52229-activity-stream
2 parents 7d5432e + a7ec25a commit ce7755f

17 files changed

Lines changed: 543 additions & 84 deletions

File tree

.github/workflows/ci.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ jobs:
1818
python-version: "3.11"
1919
sonar: false
2020
junit-xml-upload: false
21-
- env: py39
22-
python-version: "3.9"
21+
- env: py312
22+
python-version: "3.12"
2323
sonar: false
2424
junit-xml-upload: false
2525
- env: py310

.github/workflows/linting.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ jobs:
2222
command: check_black
2323
- name: api-isort
2424
command: check_isort
25+
- name: pure-python-imports
26+
command: check_pure_python_imports
2527
steps:
2628
- name: Install make
2729
run: sudo apt install make
@@ -38,5 +40,18 @@ jobs:
3840
- name: Install requirments
3941
run: pip3.11 install -r requirements/requirements_dev.txt
4042

43+
- name: Install test dependencies for pure-python-imports
44+
if: matrix.tests.name == 'pure-python-imports'
45+
run: |
46+
pip3.11 install -r requirements/requirements.in
47+
pip3.11 install -r requirements/requirements_testing.txt
48+
pip3.11 install -r requirements/requirements_channels.in
49+
pip3.11 install -r requirements/requirements_redis_client.in
50+
51+
- name: Run pure-python-imports check
52+
if: matrix.tests.name == 'pure-python-imports'
53+
run: python tools/scripts/check_pure_python_imports.py
54+
4155
- name: Run check ${{ matrix.tests.name }}
56+
if: matrix.tests.name != 'pure-python-imports'
4257
run: make ${{ matrix.tests.command }}

Makefile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ check_flake8:
4949
check_isort:
5050
tox -e isort -- --check $(CHECK_SYNTAX_FILES)
5151

52+
5253
## Starts a postgres container in the background if one is not running
5354
# Options:
5455
# -d, --detatch: run the container in background

ansible_base/jwt_consumer/common/util.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
from cryptography.hazmat.primitives import hashes, serialization
88
from cryptography.hazmat.primitives.asymmetric import padding
99

10-
from ansible_base.jwt_consumer.common.cert import JWTCert, JWTCertException
1110
from ansible_base.lib.utils.settings import get_setting
1211

1312
logger = logging.getLogger('ansible_base.jwt_consumer.common.util')
@@ -32,6 +31,8 @@ def generate_x_trusted_proxy_header(key: str) -> str:
3231

3332

3433
def validate_x_trusted_proxy_header(header_value: str, ignore_cache=False) -> bool:
34+
from ansible_base.jwt_consumer.common.cert import JWTCert, JWTCertException
35+
3536
try:
3637
cert = JWTCert()
3738
cert.get_decryption_key(ignore_cache=ignore_cache)

ansible_base/lib/utils/create_system_user.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,22 @@
1-
import logging
2-
from typing import Optional, Tuple, Type
1+
from __future__ import annotations
32

4-
from django.core.exceptions import ImproperlyConfigured
5-
from django.db import models
6-
from django.utils.translation import gettext as _
3+
import logging
4+
from typing import TYPE_CHECKING, Optional, Tuple, Type
75

86
from ansible_base.lib.utils.settings import get_setting
97

8+
if TYPE_CHECKING:
9+
from django.db import models
10+
1011
logger = logging.getLogger('ansible_base.lib.utils.create_system_user')
1112

1213
"""
1314
These functions are in its own file because it is loaded during migrations so it has no access to models.
1415
"""
1516

1617

17-
def create_system_user(user_model: Type[models.Model]) -> models.Model: # Note: We can't load models here so we can typecast to anything better than Model
18+
def create_system_user(user_model: Type[models.Model]) -> models.Model:
19+
# Note: We can't load models here so we can typecast to anything better than Model
1820
from ansible_base.lib.abstract_models.user import AbstractDABUser
1921

2022
#
@@ -63,4 +65,8 @@ def get_system_username() -> Tuple[Optional[str], str]:
6365
return str(value), setting_name
6466

6567
logger.error(f"Expected get_setting to return a string for {setting_name}, got {type(value)}")
68+
69+
from django.core.exceptions import ImproperlyConfigured
70+
from django.utils.translation import gettext as _
71+
6672
raise ImproperlyConfigured(_("Setting %(setting_name)s needs to be a string not a %(type)s") % {'setting_name': setting_name, 'type': type(value)})

ansible_base/lib/utils/requests.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
1-
import logging
2-
from typing import Optional
1+
from __future__ import annotations
32

4-
from crum import get_current_request
5-
from django.http import HttpRequest
3+
import logging
4+
from typing import TYPE_CHECKING, Optional
65

7-
from ansible_base.jwt_consumer.common.util import validate_x_trusted_proxy_header
86
from ansible_base.lib.utils.settings import get_setting
97

8+
if TYPE_CHECKING:
9+
from django.http import HttpRequest
10+
1011
logger = logging.getLogger('ansible_base.lib.uitls.requests')
1112

1213

@@ -43,6 +44,8 @@ def get_remote_hosts(request: HttpRequest, get_first_only: bool = False) -> list
4344
# If we are connected to from a trusted proxy then we can add some additional headers
4445
try:
4546
if 'HTTP_X_TRUSTED_PROXY' in request.META:
47+
from ansible_base.jwt_consumer.common.util import validate_x_trusted_proxy_header
48+
4649
if validate_x_trusted_proxy_header(request.META['HTTP_X_TRUSTED_PROXY']):
4750
# The last entry in x-forwarded-for from envoy can be trusted implicitly
4851
# https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_conn_man/headers#x-forwarded-for
@@ -68,10 +71,14 @@ def get_remote_hosts(request: HttpRequest, get_first_only: bool = False) -> list
6871
def is_proxied_request(request: Optional[HttpRequest] = None) -> bool:
6972
"Return true if request claims to be from a proxy and the header validates as such."
7073
if request is None:
74+
from crum import get_current_request
75+
7176
request = get_current_request()
7277
if request is None:
7378
# e.g. being called by CLI or something
7479
return False
7580
if x_trusted_proxy := request.META.get("HTTP_X_TRUSTED_PROXY"):
81+
from ansible_base.jwt_consumer.common.util import validate_x_trusted_proxy_header
82+
7683
return validate_x_trusted_proxy_header(x_trusted_proxy)
7784
return False
Lines changed: 3 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,62 +1,8 @@
11
# Generated by Django 4.2.23 on 2025-06-30 12:48
22

3-
import logging
4-
53
from django.db import migrations
64

7-
8-
logger = logging.getLogger(__name__)
9-
10-
11-
def create_types_if_needed(apps, schema_editor):
12-
"""Before we can migrate to the new DABContentType, entries in that table must be created.
13-
14-
This method runs what is ordinarily the post_migrate logic, but in the migration case here.
15-
Only needed in the upgrade case, otherwise better to run at true post-migrate.
16-
"""
17-
permission_cls = apps.get_model('dab_rbac', 'DABPermission')
18-
rd_cls = apps.get_model('dab_rbac', 'RoleDefinition')
19-
if permission_cls.objects.exists() or rd_cls.objects.exists():
20-
logger.info('Running DABContentType creation script as part of 0005 migration')
21-
from ansible_base.rbac.management.create_types import create_DAB_contenttypes
22-
23-
create_DAB_contenttypes(apps=apps)
24-
25-
26-
def migrate_content_type(apps, schema_editor):
27-
ct_cls = apps.get_model('dab_rbac', 'DABContentType')
28-
ct_cls.objects.clear_cache()
29-
for model_name in ('dabpermission', 'objectrole', 'roledefinition', 'roleuserassignment', 'roleteamassignment'):
30-
cls = apps.get_model('dab_rbac', model_name)
31-
update_ct = 0
32-
for obj in cls.objects.all():
33-
old_ct = obj.content_type
34-
if old_ct:
35-
try:
36-
# NOTE: could give duplicate normally, but that is impossible in migration path
37-
obj.new_content_type = ct_cls.objects.get_by_natural_key(old_ct.app_label, old_ct.model)
38-
except Exception as e:
39-
raise RuntimeError(
40-
f"Failed to get new content type for a {model_name} pk={obj.pk}, obj={obj.__dict__}"
41-
) from e
42-
obj.save()
43-
update_ct += 1
44-
if update_ct:
45-
logger.info(f'Updated content_type reference to new model for {model_name} for {update_ct} entries')
46-
for model_name in ('roleevaluation', 'roleevaluationuuid'):
47-
cls = apps.get_model('dab_rbac', model_name)
48-
cls.objects.all().delete()
49-
50-
# DABPermission model had api_slug added in last migration
51-
# if records existed before this point, it needs to be filled in
52-
mod_ct = 0
53-
permission_cls = apps.get_model('dab_rbac', 'DABPermission')
54-
for permission in permission_cls.objects.all():
55-
permission.api_slug = f'{permission.new_content_type.service}.{permission.codename}'
56-
permission.save()
57-
mod_ct += 1
58-
if mod_ct:
59-
logger.info(f'Set new field DABPermission.api_slug for {mod_ct} existing permissions')
5+
from . import _utils
606

617

628
class Migration(migrations.Migration):
@@ -66,6 +12,6 @@ class Migration(migrations.Migration):
6612
]
6713

6814
operations = [
69-
migrations.RunPython(create_types_if_needed, migrations.RunPython.noop),
70-
migrations.RunPython(migrate_content_type, migrations.RunPython.noop),
15+
migrations.RunPython(_utils.create_types_if_needed, migrations.RunPython.noop),
16+
migrations.RunPython(_utils.migrate_content_type, migrations.RunPython.noop),
7117
]

ansible_base/rbac/migrations/_utils.py

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
1+
# Generated by Claude Sonnet 4 (claude-sonnet-4@20250514)
2+
import logging
3+
14
from django.db import models
25

6+
logger = logging.getLogger(__name__)
7+
38
# This method has moved, and this is put here temporarily to make branch management easier
49
from ansible_base.rbac.management import create_dab_permissions as create_custom_permissions # noqa
510

@@ -45,3 +50,127 @@ def give_permissions(apps, rd, users=(), teams=(), object_id=None, content_type_
4550
for team_id in teams
4651
]
4752
RoleTeamAssignment.objects.bulk_create(team_assignments, ignore_conflicts=True)
53+
54+
55+
def cleanup_orphaned_permissions(apps):
56+
"""
57+
Delete orphaned DABPermission objects for models no longer in the permission registry.
58+
59+
This is used during migrations to clean up permissions for any previously-registered model
60+
that are no longer tracked by RBAC, but only if they are not referenced by any RoleDefinition.
61+
62+
Args:
63+
apps: Django apps registry (from migration context)
64+
65+
Returns:
66+
int: Number of permissions deleted
67+
"""
68+
# Get model classes from apps registry
69+
permission_cls = apps.get_model('dab_rbac', 'DABPermission')
70+
role_definition_cls = apps.get_model('dab_rbac', 'RoleDefinition')
71+
72+
# Get permission registry to check which models are registered
73+
from ansible_base.rbac import permission_registry
74+
registered_model_keys = set()
75+
for model in permission_registry._registry:
76+
registered_model_keys.add((model._meta.app_label, model._meta.model_name))
77+
78+
# Find orphaned permissions
79+
orphaned_permissions = []
80+
for permission in permission_cls.objects.all():
81+
if permission.content_type:
82+
model_key = (permission.content_type.app_label, permission.content_type.model)
83+
if model_key not in registered_model_keys:
84+
# Check if this permission is referenced by any RoleDefinition
85+
referencing_roles = role_definition_cls.objects.filter(permissions=permission)
86+
if referencing_roles.exists():
87+
# Log warning for unregistered model still referenced by role definitions
88+
role_names = list(referencing_roles.values_list('name', flat=True))
89+
logger.warning(
90+
f'Permission {permission.codename} for unregistered model '
91+
f'{permission.content_type.app_label}.{permission.content_type.model} '
92+
f'is still referenced by role definitions: {role_names}'
93+
)
94+
else:
95+
logger.info(f'Deleting orphaned permission {permission.codename} for unregistered model {model_key}')
96+
orphaned_permissions.append(permission)
97+
98+
# Delete orphaned permissions
99+
deleted_count = 0
100+
if orphaned_permissions:
101+
deleted_count = len(orphaned_permissions)
102+
permission_cls.objects.filter(id__in=[p.id for p in orphaned_permissions]).delete()
103+
logger.info(f'Deleted {deleted_count} orphaned DABPermission objects for unregistered models')
104+
105+
return deleted_count
106+
107+
108+
def migrate_content_type(apps, schema_editor):
109+
"""
110+
Migrate content type references from Django ContentType to DABContentType.
111+
112+
This function handles the migration of content type references across all RBAC models
113+
from the old Django ContentType to the new DABContentType system.
114+
115+
Args:
116+
apps: Django apps registry (from migration context)
117+
schema_editor: Django schema editor (unused but required for migration signature)
118+
"""
119+
# Pre-check: Delete orphaned DABPermission objects before migration
120+
cleanup_orphaned_permissions(apps)
121+
122+
ct_cls = apps.get_model('dab_rbac', 'DABContentType')
123+
ct_cls.objects.clear_cache()
124+
125+
for model_name in ('dabpermission', 'objectrole', 'roledefinition', 'roleuserassignment', 'roleteamassignment'):
126+
cls = apps.get_model('dab_rbac', model_name)
127+
update_ct = 0
128+
for obj in cls.objects.all():
129+
old_ct = obj.content_type
130+
if old_ct:
131+
try:
132+
# NOTE: could give duplicate normally, but that is impossible in migration path
133+
obj.new_content_type = ct_cls.objects.get_by_natural_key(old_ct.app_label, old_ct.model)
134+
except Exception as e:
135+
raise RuntimeError(
136+
f"Failed to get new content type for a {model_name} pk={obj.pk}, obj={obj.__dict__}"
137+
) from e
138+
obj.save()
139+
update_ct += 1
140+
if update_ct:
141+
logger.info(f'Updated content_type reference to new model for {model_name} for {update_ct} entries')
142+
for model_name in ('roleevaluation', 'roleevaluationuuid'):
143+
cls = apps.get_model('dab_rbac', model_name)
144+
cls.objects.all().delete()
145+
146+
# DABPermission model had api_slug added in last migration
147+
# if records existed before this point, it needs to be filled in
148+
mod_ct = 0
149+
permission_cls = apps.get_model('dab_rbac', 'DABPermission')
150+
for permission in permission_cls.objects.all():
151+
permission.api_slug = f'{permission.new_content_type.service}.{permission.codename}'
152+
permission.save()
153+
mod_ct += 1
154+
if mod_ct:
155+
logger.info(f'Set new field DABPermission.api_slug for {mod_ct} existing permissions')
156+
157+
158+
def create_types_if_needed(apps, schema_editor):
159+
"""
160+
Create DABContentType entries if needed before migration.
161+
162+
Before we can migrate to the new DABContentType, entries in that table must be created.
163+
This method runs what is ordinarily the post_migrate logic, but in the migration case here.
164+
Only needed in the upgrade case, otherwise better to run at true post-migrate.
165+
166+
Args:
167+
apps: Django apps registry (from migration context)
168+
schema_editor: Django schema editor (unused but required for migration signature)
169+
"""
170+
permission_cls = apps.get_model('dab_rbac', 'DABPermission')
171+
rd_cls = apps.get_model('dab_rbac', 'RoleDefinition')
172+
if permission_cls.objects.exists() or rd_cls.objects.exists():
173+
logger.info('Running DABContentType creation script as part of 0005 migration')
174+
from ansible_base.rbac.management.create_types import create_DAB_contenttypes
175+
176+
create_DAB_contenttypes(apps=apps)

ansible_base/resource_registry/models/service_identifier.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
import sys
12
import uuid
23

3-
from django.db import models
4+
from django.conf import settings
5+
from django.db import IntegrityError, models, transaction
46

57

68
class ServiceID(models.Model):
@@ -23,5 +25,21 @@ def save(self, *args, **kwargs):
2325
def service_id():
2426
global _service_id
2527
if not _service_id:
26-
_service_id = str(ServiceID.objects.first().pk)
28+
obj = ServiceID.objects.first()
29+
if obj is None:
30+
if settings.DEBUG or "pytest" in sys.argv:
31+
try:
32+
with transaction.atomic():
33+
obj = ServiceID.objects.create()
34+
# Check if another process also created one during the race
35+
if ServiceID.objects.count() > 1:
36+
# We lost the race, delete ours and use the other
37+
obj.delete()
38+
obj = ServiceID.objects.first()
39+
except IntegrityError:
40+
# Another thread/process won the race—read it
41+
obj = ServiceID.objects.first()
42+
else:
43+
raise RuntimeError('Expected ServiceID to be created in data migrations but was not found')
44+
_service_id = str(obj.pk)
2745
return _service_id

0 commit comments

Comments
 (0)