From 6e30858a9e897b62e730c7b39a2f8ed0857ba077 Mon Sep 17 00:00:00 2001 From: John Westcott IV Date: Thu, 24 Oct 2024 11:53:48 -0400 Subject: [PATCH 1/9] Adding additional baseline features for all applications --- ansible_base/lib/authentication/__init__.py | 0 ansible_base/lib/authentication/basic_auth.py | 23 ++++++ .../lib/drf_templates/rest_framework/api.html | 80 +++++++++++++++++++ .../drf_templates/rest_framework/login.html | 55 +++++++++++++ .../lib/dynamic_config/dynamic_settings.py | 9 +++ ansible_base/lib/managers/__init__.py | 0 ansible_base/lib/managers/user.py | 6 ++ ansible_base/lib/models.py | 27 +++++++ 8 files changed, 200 insertions(+) create mode 100644 ansible_base/lib/authentication/__init__.py create mode 100644 ansible_base/lib/authentication/basic_auth.py create mode 100644 ansible_base/lib/drf_templates/rest_framework/api.html create mode 100644 ansible_base/lib/drf_templates/rest_framework/login.html create mode 100644 ansible_base/lib/managers/__init__.py create mode 100644 ansible_base/lib/managers/user.py create mode 100644 ansible_base/lib/models.py diff --git a/ansible_base/lib/authentication/__init__.py b/ansible_base/lib/authentication/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ansible_base/lib/authentication/basic_auth.py b/ansible_base/lib/authentication/basic_auth.py new file mode 100644 index 000000000..7dcd4115b --- /dev/null +++ b/ansible_base/lib/authentication/basic_auth.py @@ -0,0 +1,23 @@ +import logging + +from ansible_base.lib.utils.settings import get_setting +from django.utils.encoding import smart_str +from rest_framework import authentication + +logger = logging.getLogger('dab.lib.authentication.basic_auth') + + +class LoggedBasicAuthentication(authentication.BasicAuthentication): + def authenticate(self, request): + if not get_setting('ANSIBLE_BASE_BASIC_AUTH_ENABLED', False): + return + ret = super(LoggedBasicAuthentication, self).authenticate(request) + if ret: + username = ret[0].username if ret[0] else '' + logger.info(smart_str(f"User {username} performed a {request.method} to {request.path} through the API via basic auth")) + return ret + + def authenticate_header(self, request): + if not get_setting('ANSIBLE_BASE_BASIC_AUTH_ENABLED'): + return + return super(LoggedBasicAuthentication, self).authenticate_header(request) diff --git a/ansible_base/lib/drf_templates/rest_framework/api.html b/ansible_base/lib/drf_templates/rest_framework/api.html new file mode 100644 index 000000000..f0270697a --- /dev/null +++ b/ansible_base/lib/drf_templates/rest_framework/api.html @@ -0,0 +1,80 @@ +{% extends 'rest_framework/base.html' %} +{% load i18n static %} + +{% block title %}{{ name }} · {% trans 'Django App REST API' %}{% endblock %} + +{% block bootstrap_theme %} + + +{% endblock %} + +{% block style %} + +{{ block.super }} +{% endblock %} + +{% block navbar %} + +{% endblock %} + +{% block content %} + {% if deprecated %} + + {% endif %} +{{ block.super }} +{% endblock content %} + +{% block script %} + + +{{ block.super }} + +
+ {% csrf_token %} +
+{% endblock %} diff --git a/ansible_base/lib/drf_templates/rest_framework/login.html b/ansible_base/lib/drf_templates/rest_framework/login.html new file mode 100644 index 000000000..3f05c6d18 --- /dev/null +++ b/ansible_base/lib/drf_templates/rest_framework/login.html @@ -0,0 +1,55 @@ +{# Partial copy of login_base.html from rest_framework with Django App changes. #} +{% extends 'rest_framework/api.html' %} +{% load i18n static %} + +{% block breadcrumbs %} +{% endblock %} + +{% block content %} + +{% endblock %} diff --git a/ansible_base/lib/dynamic_config/dynamic_settings.py b/ansible_base/lib/dynamic_config/dynamic_settings.py index 08a6a3eed..cb98ab4b5 100644 --- a/ansible_base/lib/dynamic_config/dynamic_settings.py +++ b/ansible_base/lib/dynamic_config/dynamic_settings.py @@ -57,6 +57,15 @@ except NameError: OAUTH2_PROVIDER = {} +try: + TEMPLATES # noqa: F821 + from pathlib import Path + from ansible_base import lib + drf_template_path = Path.joinpath(Path(lib.__file__).parent, 'drf_templates') + TEMPLATES[0]['DIRS'].append(drf_template_path) +except NameError: + pass + for key, value in get_dab_settings( installed_apps=INSTALLED_APPS, rest_framework=REST_FRAMEWORK, diff --git a/ansible_base/lib/managers/__init__.py b/ansible_base/lib/managers/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ansible_base/lib/managers/user.py b/ansible_base/lib/managers/user.py new file mode 100644 index 000000000..b5101df59 --- /dev/null +++ b/ansible_base/lib/managers/user.py @@ -0,0 +1,6 @@ +from django.contrib.auth.models import UserManager + + +class UserUnmanagedManager(UserManager): + def get_queryset(self): + return super().get_queryset().filter(managed=False) diff --git a/ansible_base/lib/models.py b/ansible_base/lib/models.py new file mode 100644 index 000000000..7e1fd39c6 --- /dev/null +++ b/ansible_base/lib/models.py @@ -0,0 +1,27 @@ +def unique_fields_for_model(ModelCls, include_pk=False, flatten_unique_together=True): + """ + Given a model class, determine the names of the unique fields. + + If `include_pk` is True, the primary key field will be included in the set of unique fields. + + If `flatten_unique_together` is True, the unique_together fields will be flattened into the set + of unique fields (otherwise their tuples will be included). + """ + + unique_fields = set() + + # First the concrete fields + for field in ModelCls._meta.fields: + if field.unique and (include_pk or (field != ModelCls._meta.pk)): + unique_fields.add(field.name) + + # But now the unique_together fields + for unique_together in ModelCls._meta.unique_together: + if flatten_unique_together: + for field in unique_together: + if include_pk or (field != ModelCls._meta.pk): + unique_fields.add(field) + else: + unique_fields.add(unique_together) + + return unique_fields From 63559a262b65213348fb5e388bf7546a1a6b126a Mon Sep 17 00:00:00 2001 From: John Westcott IV Date: Wed, 30 Oct 2024 07:57:03 -0400 Subject: [PATCH 2/9] Make the api_*root_view non-changeable --- ansible_base/lib/drf_templates/rest_framework/api.html | 2 +- ansible_base/lib/drf_templates/rest_framework/login.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ansible_base/lib/drf_templates/rest_framework/api.html b/ansible_base/lib/drf_templates/rest_framework/api.html index f0270697a..f31b77bb1 100644 --- a/ansible_base/lib/drf_templates/rest_framework/api.html +++ b/ansible_base/lib/drf_templates/rest_framework/api.html @@ -26,7 +26,7 @@ - + {% trans 'REST API' %} diff --git a/ansible_base/lib/drf_templates/rest_framework/login.html b/ansible_base/lib/drf_templates/rest_framework/login.html index 3f05c6d18..6f21582d0 100644 --- a/ansible_base/lib/drf_templates/rest_framework/login.html +++ b/ansible_base/lib/drf_templates/rest_framework/login.html @@ -12,7 +12,7 @@
{% csrf_token %} - +
From b2fad55ea913c0c205db825c862565782254578c Mon Sep 17 00:00:00 2001 From: John Westcott IV Date: Wed, 30 Oct 2024 16:37:09 -0400 Subject: [PATCH 3/9] Adding standard views --- ansible_base/lib/views/__init__.py | 4 ++ ansible_base/lib/views/local_login.py | 74 +++++++++++++++++++++++++++ ansible_base/lib/views/me.py | 17 ++++++ ansible_base/lib/views/ping.py | 35 +++++++++++++ ansible_base/lib/views/session.py | 36 +++++++++++++ 5 files changed, 166 insertions(+) create mode 100644 ansible_base/lib/views/__init__.py create mode 100644 ansible_base/lib/views/local_login.py create mode 100644 ansible_base/lib/views/me.py create mode 100644 ansible_base/lib/views/ping.py create mode 100644 ansible_base/lib/views/session.py diff --git a/ansible_base/lib/views/__init__.py b/ansible_base/lib/views/__init__.py new file mode 100644 index 000000000..ec4973723 --- /dev/null +++ b/ansible_base/lib/views/__init__.py @@ -0,0 +1,4 @@ +from ansible_base.lib.views.local_login import LoggedLoginView, LoggedLogoutView # noqa: F401 +from ansible_base.lib.views.me import MeViewSet # noqa: F401 +from ansible_base.lib.views.ping import PingView # noqa: F401 +from ansible_base.lib.views.session import SessionView # noqa: F401 diff --git a/ansible_base/lib/views/local_login.py b/ansible_base/lib/views/local_login.py new file mode 100644 index 000000000..2c192cadf --- /dev/null +++ b/ansible_base/lib/views/local_login.py @@ -0,0 +1,74 @@ +import json +import logging +import re + +from ansible_base.lib.utils.requests import get_remote_host +from django.conf import settings +from django.contrib.auth import views +from django.core.exceptions import PermissionDenied +from django.utils.decorators import method_decorator +from django.views.decorators.http import require_http_methods +from rest_framework import status +from rest_framework.exceptions import NotAcceptable +from rest_framework.negotiation import DefaultContentNegotiation +from rest_framework.renderers import StaticHTMLRenderer +from rest_framework.response import Response +from social_core.exceptions import AuthException + +logger = logging.getLogger('aap.templated_app.views.local_login') + + +class LoggedLoginView(views.LoginView): + def get(self, request, *args, **kwargs): + # The django.auth.contrib login form doesn't perform the content + # negotiation we've come to expect from DRF; add in code to catch + # situations where Accept != text/html (or */*) and reply with + # an HTTP 406 + try: + DefaultContentNegotiation().select_renderer(request, [StaticHTMLRenderer], 'html') + except NotAcceptable: + resp = Response(data=json.dumps({"details": "Unacceptable content type"}), status=status.HTTP_406_NOT_ACCEPTABLE) + resp.accepted_renderer = StaticHTMLRenderer() + resp.accepted_media_type = 'text/plain' + resp.content_type = 'application/json' + resp.renderer_context = {} + return resp + return super(LoggedLoginView, self).get(request, *args, **kwargs) + + def post(self, request, *args, **kwargs): + try: + ret = super(LoggedLoginView, self).post(request, *args, **kwargs) + except AuthException as e: + # Log a warning when an exception occurs during login, + # particularly when SYSTEM_USERNAME attempts to log in. + logger.warning("Exception occurred during login.") + raise PermissionDenied from e + + if request.user.is_authenticated: + logger.info(f"User {self.request.user.username} logged in from {get_remote_host(request)}") + return ret + else: + if 'username' in self.request.POST: + username = self.request.POST.get('username') + # Maybe we want to scale this in the future to support additional characters + if not re.match('^[A-Za-z0-9@._-]+$', username): + from base64 import b64encode + + username = f"(base64) {b64encode(username.encode('UTF-8'))}" + logger.warning(f"Login failed for user {username} from {get_remote_host(request)}") + ret.status_code = 401 + return ret + + +@method_decorator(require_http_methods(["POST", "GET"]), name="dispatch") +class LoggedLogoutView(views.LogoutView): + + success_url_allowed_hosts = settings.LOGOUT_ALLOWED_HOSTS + + def dispatch(self, request, *args, **kwargs): + original_user = getattr(request, 'user', None) + ret = super().dispatch(request, *args, **kwargs) + current_user = getattr(request, 'user', None) + if (not current_user or not getattr(current_user, 'pk', True)) and current_user != original_user: + logger.info("User {} logged out.".format(original_user.username)) + return ret diff --git a/ansible_base/lib/views/me.py b/ansible_base/lib/views/me.py new file mode 100644 index 000000000..eacf9ffdb --- /dev/null +++ b/ansible_base/lib/views/me.py @@ -0,0 +1,17 @@ +from django.contrib.auth import get_user_model +from rest_framework import viewsets +from rest_framework.permissions import IsAuthenticated + +from templated_app.serializers import UserSerializer +from templated_app.views.api.v1.common import AnsibleBaseView + +User = get_user_model() + + +class MeViewSet(viewsets.ReadOnlyModelViewSet, AnsibleBaseView): + model = User + serializer_class = UserSerializer + permission_classes = [IsAuthenticated] + + def get_queryset(self): + return User.objects.filter(username=self.request.user.username) diff --git a/ansible_base/lib/views/ping.py b/ansible_base/lib/views/ping.py new file mode 100644 index 000000000..36077f216 --- /dev/null +++ b/ansible_base/lib/views/ping.py @@ -0,0 +1,35 @@ +from datetime import datetime + +from ansible_base.lib.constants import STATUS_DEGRADED, STATUS_GOOD +from django.db import connections +from rest_framework.response import Response + +from templated_app.version import get_aap_version +from templated_app.views.api.v1.common import AnsibleBaseView + + +def _get_db_connection_status(db_conn): + try: + db_conn.cursor() + return {'db_connected': True} + except Exception as e: + # We only log the exception type because the message could contain sensitive information + return {'db_connected': False, 'db_exception': type(e).__name__, 'status': STATUS_DEGRADED} + + +class PingView(AnsibleBaseView): + permission_classes = [] + + def get(self, request): + current_time = datetime.now() + response = { + "version": get_aap_version(), + "pong": str(current_time), + "status": STATUS_GOOD, + } + + # Attempt a db connection + db_info = _get_db_connection_status(connections['default']) + response.update(db_info) + + return Response(response) diff --git a/ansible_base/lib/views/session.py b/ansible_base/lib/views/session.py new file mode 100644 index 000000000..ce5411587 --- /dev/null +++ b/ansible_base/lib/views/session.py @@ -0,0 +1,36 @@ +import logging +from datetime import datetime, timezone + +from ansible_base.lib.utils.views.django_app_api import AnsibleBaseDjangoAppApiView +from django.contrib.sessions.models import Session +from django.utils.translation import gettext as _ +from rest_framework import permissions, status +from rest_framework.response import Response + +logger = logging.getLogger('aap.templated_app.views.api.v1.session') + + +class SessionView(AnsibleBaseDjangoAppApiView): + permission_classes = [permissions.IsAuthenticated] + + def get(self, request, format=None): + try: + session = Session.objects.get(session_key=request.session.session_key) + except Session.DoesNotExist: + return Response({"detail": _("You do not have an associated session")}, status.HTTP_404_NOT_FOUND) + + expires_date = session.expire_date + now = datetime.now(timezone.utc) + delta = expires_date - now + response = { + 'now': now, + 'expires_on': expires_date, + 'expires_in_seconds': delta.seconds, + } + + return Response(response) + + def post(self, request, format=None): + logger.debug(f"Extending session for {request.user} by {request.session.get_expiry_age()}") + request.session.set_expiry(request.session.get_expiry_age()) + return Response({"message": _("Session extended")}) From 0dcb7cac1270579ad277f47372f52a1f9c7920ac Mon Sep 17 00:00:00 2001 From: John Westcott IV Date: Fri, 1 Nov 2024 07:07:13 -0400 Subject: [PATCH 4/9] Fixing default value of ANSIBLE_BASE_BASIC_AUTH_ENABLED in second location --- ansible_base/lib/authentication/basic_auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ansible_base/lib/authentication/basic_auth.py b/ansible_base/lib/authentication/basic_auth.py index 7dcd4115b..a6e789477 100644 --- a/ansible_base/lib/authentication/basic_auth.py +++ b/ansible_base/lib/authentication/basic_auth.py @@ -18,6 +18,6 @@ def authenticate(self, request): return ret def authenticate_header(self, request): - if not get_setting('ANSIBLE_BASE_BASIC_AUTH_ENABLED'): + if not get_setting('ANSIBLE_BASE_BASIC_AUTH_ENABLED', False): return return super(LoggedBasicAuthentication, self).authenticate_header(request) From dbe532cfdbdce215a7ef14a46c5593577fe59106 Mon Sep 17 00:00:00 2001 From: John Westcott IV Date: Fri, 1 Nov 2024 10:54:41 -0400 Subject: [PATCH 5/9] Adding defaut session handler --- ansible_base/lib/authentication/session.py | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 ansible_base/lib/authentication/session.py diff --git a/ansible_base/lib/authentication/session.py b/ansible_base/lib/authentication/session.py new file mode 100644 index 000000000..89d82aa3c --- /dev/null +++ b/ansible_base/lib/authentication/session.py @@ -0,0 +1,6 @@ +from rest_framework import authentication + + +class SessionAuthentication(authentication.SessionAuthentication): + def authenticate_header(self, request): + return 'Session' From 2a1e86c73d1d56c77dec9dd51bd5d73d62b2848a Mon Sep 17 00:00:00 2001 From: John Westcott IV Date: Wed, 6 Nov 2024 09:48:40 -0500 Subject: [PATCH 6/9] Make the rest template logout return you to the same location in the API --- ansible_base/lib/drf_templates/rest_framework/api.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ansible_base/lib/drf_templates/rest_framework/api.html b/ansible_base/lib/drf_templates/rest_framework/api.html index f31b77bb1..7a66c47a7 100644 --- a/ansible_base/lib/drf_templates/rest_framework/api.html +++ b/ansible_base/lib/drf_templates/rest_framework/api.html @@ -74,7 +74,7 @@
{{ block.super }} - + {% csrf_token %} {% endblock %} From d2422506e9055da38d32c696b683b7b2a164f873 Mon Sep 17 00:00:00 2001 From: John Westcott IV Date: Fri, 8 Nov 2024 09:54:42 -0500 Subject: [PATCH 7/9] Create django_template app --- ansible_base/django_template/__init__.py | 0 ansible_base/django_template/apps.py | 21 ++ .../django_template/models/__init__.py | 12 + ansible_base/django_template/models/mixins.py | 47 ++++ .../django_template/models/organization.py | 42 ++++ ansible_base/django_template/models/team.py | 29 +++ ansible_base/django_template/models/user.py | 104 ++++++++ ansible_base/django_template/router.py | 35 +++ .../django_template/serializers/__init__.py | 3 + .../serializers/organization.py | 11 + .../django_template/serializers/team.py | 38 +++ .../django_template/serializers/user.py | 54 +++++ .../django_template/signals/preloaded_data.py | 92 ++++++++ .../django_template/static/api/api.css | 223 ++++++++++++++++++ .../django_template/static/api/api.js | 96 ++++++++ ...ble_Automation_Platform-A-Standard-RGB.svg | 1 + ...ed_Hat-Ansible_Automation_Platform-RGB.png | Bin 0 -> 9585 bytes ansible_base/django_template/urls.py | 30 +++ .../django_template/views/__init__.py | 24 ++ .../django_template/views/api/__init__.py | 0 .../django_template/views/api/v1/__init__.py | 76 ++++++ .../django_template/views/api/v1/common.py | 25 ++ .../views/api/v1}/local_login.py | 9 +- .../views/api/v1}/me.py | 4 +- .../views/api/v1/organization.py | 29 +++ .../views/api/v1}/ping.py | 8 +- .../views/api/v1/related_views.py | 40 ++++ .../views/api/v1}/session.py | 5 +- .../django_template/views/api/v1/team.py | 12 + .../django_template/views/api/v1/user.py | 84 +++++++ ansible_base/lib/utils/auth.py | 18 +- ansible_base/lib/views/__init__.py | 4 - ansible_base/resource_registry/registry.py | 3 +- 33 files changed, 1158 insertions(+), 21 deletions(-) create mode 100644 ansible_base/django_template/__init__.py create mode 100644 ansible_base/django_template/apps.py create mode 100644 ansible_base/django_template/models/__init__.py create mode 100644 ansible_base/django_template/models/mixins.py create mode 100644 ansible_base/django_template/models/organization.py create mode 100644 ansible_base/django_template/models/team.py create mode 100644 ansible_base/django_template/models/user.py create mode 100644 ansible_base/django_template/router.py create mode 100644 ansible_base/django_template/serializers/__init__.py create mode 100644 ansible_base/django_template/serializers/organization.py create mode 100644 ansible_base/django_template/serializers/team.py create mode 100644 ansible_base/django_template/serializers/user.py create mode 100644 ansible_base/django_template/signals/preloaded_data.py create mode 100644 ansible_base/django_template/static/api/api.css create mode 100644 ansible_base/django_template/static/api/api.js create mode 100644 ansible_base/django_template/static/images/Logo-Red_Hat-Ansible_Automation_Platform-A-Standard-RGB.svg create mode 100644 ansible_base/django_template/static/images/Product_icon-Red_Hat-Ansible_Automation_Platform-RGB.png create mode 100644 ansible_base/django_template/urls.py create mode 100644 ansible_base/django_template/views/__init__.py create mode 100644 ansible_base/django_template/views/api/__init__.py create mode 100644 ansible_base/django_template/views/api/v1/__init__.py create mode 100644 ansible_base/django_template/views/api/v1/common.py rename ansible_base/{lib/views => django_template/views/api/v1}/local_login.py (93%) rename ansible_base/{lib/views => django_template/views/api/v1}/me.py (74%) create mode 100644 ansible_base/django_template/views/api/v1/organization.py rename ansible_base/{lib/views => django_template/views/api/v1}/ping.py (84%) create mode 100644 ansible_base/django_template/views/api/v1/related_views.py rename ansible_base/{lib/views => django_template/views/api/v1}/session.py (94%) create mode 100644 ansible_base/django_template/views/api/v1/team.py create mode 100644 ansible_base/django_template/views/api/v1/user.py delete mode 100644 ansible_base/lib/views/__init__.py diff --git a/ansible_base/django_template/__init__.py b/ansible_base/django_template/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ansible_base/django_template/apps.py b/ansible_base/django_template/apps.py new file mode 100644 index 000000000..92e689ca6 --- /dev/null +++ b/ansible_base/django_template/apps.py @@ -0,0 +1,21 @@ +from django.apps import AppConfig +from django.db.models import signals + + +def _initialize_data(sender, **kwargs): + from ansible_base.django_template.signals.preloaded_data import create_preload_data + + create_preload_data(**kwargs) + + +class DjangoTemplateConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'ansible_base.django_template' + label = 'dab_django_template' + verbose_name = 'Django AAP Template' + + def ready(self): + signals.post_migrate.connect(_initialize_data, sender=self, weak=False) + + # Load the signals + import ansible_base.django_template.signals # noqa 401 diff --git a/ansible_base/django_template/models/__init__.py b/ansible_base/django_template/models/__init__.py new file mode 100644 index 000000000..bf2c09d37 --- /dev/null +++ b/ansible_base/django_template/models/__init__.py @@ -0,0 +1,12 @@ +# User must be imported first or else we end up with a circular import +from ansible_base.django_template.models.user import AbstractTemplateUser # noqa: 401 # isort: skip +from ansible_base.django_template.models.organization import AbstractTemplateOrganization # noqa: 401 # isort: skip +from ansible_base.django_template.models.team import AbstractTemplateTeam # noqa: 401 # isort: skip + +from ansible_base.lib.utils.auth import get_organization_model, get_team_model +from ansible_base.rbac import permission_registry + +if get_team_model(return_none_on_error=True) is not None: + permission_registry.register(get_team_model(), parent_field_name='organization') +if get_organization_model(return_none_on_error=True) is not None: + permission_registry.register(get_organization_model(), parent_field_name=None) diff --git a/ansible_base/django_template/models/mixins.py b/ansible_base/django_template/models/mixins.py new file mode 100644 index 000000000..20949ecfa --- /dev/null +++ b/ansible_base/django_template/models/mixins.py @@ -0,0 +1,47 @@ +from django.contrib.auth import get_user_model +from django.db.models import Model + +from ansible_base.lib.utils.response import get_relative_url +from ansible_base.rbac.models import ObjectRole, RoleDefinition + + +class UsersMembersMixin(Model): + class Meta: + abstract = True + + admin_rd_name = None + member_rd_name = None + + def related_fields(self, request): + ret = super().related_fields(request) + for key in ('users', 'admins'): + ret[key] = get_relative_url(f'{self._meta.model_name}-{key}-list', kwargs={'pk': self.id}) + return ret + + @property + def member_rd(self): + return RoleDefinition.objects.get(name=self.member_rd_name) + + @property + def admin_rd(self): + return RoleDefinition.objects.get(name=self.admin_rd_name) + + def add_member(self, user): + self.member_rd.give_permission(user, self) + + def add_admin(self, user): + self.admin_rd.give_permission(user, self) + + def remove_member(self, user): + self.member_rd.remove_permission(user, self) + + def remove_admin(self, user): + self.admin_rd.remove_permission(user, self) + + @property + def admins(self): + return get_user_model().objects.filter(has_roles__in=ObjectRole.objects.filter(object_id=self.pk, role_definition__name=self.admin_rd_name)) + + @property + def users(self): + return get_user_model().objects.filter(has_roles__in=ObjectRole.objects.filter(object_id=self.pk, role_definition__name=self.member_rd_name)) diff --git a/ansible_base/django_template/models/organization.py b/ansible_base/django_template/models/organization.py new file mode 100644 index 000000000..cc650f8b1 --- /dev/null +++ b/ansible_base/django_template/models/organization.py @@ -0,0 +1,42 @@ +from django.contrib.auth import get_user_model +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from ansible_base.activitystream.models import AuditableModel +from ansible_base.django_template.models.mixins import UsersMembersMixin +from ansible_base.lib.abstract_models.organization import AbstractOrganization +from ansible_base.lib.utils.auth import get_team_model +from ansible_base.rbac.managed import OrganizationAdmin, OrganizationMember +from ansible_base.rbac.models import ObjectRole +from ansible_base.resource_registry.fields import AnsibleResourceField + + +class AbstractTemplateOrganization(UsersMembersMixin, AbstractOrganization, AuditableModel): + class Meta: + abstract = True + + admin_rd_name = OrganizationAdmin.name + member_rd_name = OrganizationMember.name + + resource = AnsibleResourceField(primary_key_field="id") + + managed = models.BooleanField( + editable=False, + blank=False, + default=False, + help_text=_("Indicates if this organization is managed by the system. It cannot be modified once created."), + ) + + def get_summary_fields(self): + # TODO: We should probably come up with a more codified and standard + # way to return this kind of info from models. + response = super().get_summary_fields() + response["related_field_counts"] = {} + if get_team_model(return_none_on_error=True) is not None: + response["related_field_counts"]["teams"] = self.teams.count() + + response["related_field_counts"]["users"] = get_user_model().objects.filter( + has_roles__in=ObjectRole.objects.filter(object_id=self.pk, role_definition__name=self.member_rd_name) + ).count() + + return response diff --git a/ansible_base/django_template/models/team.py b/ansible_base/django_template/models/team.py new file mode 100644 index 000000000..efe301326 --- /dev/null +++ b/ansible_base/django_template/models/team.py @@ -0,0 +1,29 @@ +from django.conf import settings +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from ansible_base.activitystream.models import AuditableModel +from ansible_base.django_template.models.mixins import UsersMembersMixin +from ansible_base.lib.abstract_models import AbstractTeam +from ansible_base.rbac.managed import TeamAdmin, TeamMember +from ansible_base.resource_registry.fields import AnsibleResourceField + + +class AbstractTemplateTeam(UsersMembersMixin, AbstractTeam, AuditableModel): + class Meta(AbstractTeam.Meta): + abstract = True + + admin_rd_name = TeamAdmin.name + member_rd_name = TeamMember.name + + resource = AnsibleResourceField(primary_key_field="id") + + ignore_relations = ['parents', 'teams'] + + # If we remove this in the future, you can also remove the ignore_relations + parents = models.ManyToManyField( + settings.ANSIBLE_BASE_TEAM_MODEL, + blank=True, + symmetrical=False, + help_text=_("The list of teams that are parents of this team"), + ) diff --git a/ansible_base/django_template/models/user.py b/ansible_base/django_template/models/user.py new file mode 100644 index 000000000..3190cca85 --- /dev/null +++ b/ansible_base/django_template/models/user.py @@ -0,0 +1,104 @@ +import logging + +from django.contrib.auth.hashers import UNUSABLE_PASSWORD_PREFIX, UNUSABLE_PASSWORD_SUFFIX_LENGTH, get_hashers_by_algorithm, identify_hasher, make_password +from django.contrib.auth.models import AbstractUser +from django.db import models +from django.utils.translation import gettext as _ + +from ansible_base.activitystream.models import AuditableModel +from ansible_base.lib.abstract_models.common import CommonModel +from ansible_base.lib.abstract_models.user import AbstractDABUser +from ansible_base.lib.managers.user import UserUnmanagedManager +from ansible_base.lib.utils.models import user_summary_fields +from ansible_base.resource_registry.fields import AnsibleResourceField + +logger = logging.getLogger('ansible_base.django_template.models.user') + + +def password_is_hashed(password): + """ + Returns a boolean of whether password is hashed with loaded algorithms + """ + if password is None: + return False + try: + hasher = identify_hasher(password) + except ValueError: + # hasher can't be identified or is not loaded + return False + return hasher.algorithm in get_hashers_by_algorithm().keys() + + +def password_is_usable(password): + """ + Returns True if password is None or wasn't generated by django.contrib.auth.hashers.make_password(None) + """ + unusable_password_len = len(UNUSABLE_PASSWORD_PREFIX) + UNUSABLE_PASSWORD_SUFFIX_LENGTH + + # what are the odds that a user password starts with unusable prefix and the same length :-? + return password is None or not (password.startswith(UNUSABLE_PASSWORD_PREFIX) and len(password) == unusable_password_len) + + +class AbstractTemplateUser(AbstractDABUser, CommonModel, AuditableModel): + class Meta(AbstractUser.Meta): + abstract = True + + ignore_relations = [ + 'groups', # not using the auth app stuff, see Team model + 'user_permissions', # not using auth app permissions + 'logentry', # used for Django admin pages, not the API + 'social_auth', # Social auth endpoint + 'organizations_administered', # We are going to merge [teams|orgs] the user is an admin in with [teams|orgs] the user is a member of + 'teams_administered', + ] + activity_stream_excluded_field_names = ['last_login'] + + encrypted_fields = () # handed as special case by UserSerializer + PASSWORD_FIELDS = ["password"] # Mark password fields so ansible_base.lib.rest_filters can properly block attempts to filter over password + + resource = AnsibleResourceField(primary_key_field="id") + + managed = models.BooleanField( + editable=False, + blank=False, + default=False, + help_text=_("Indicates if this user is managed by the system. It cannot be modified once created."), + ) + + # By default, skip managed users (use all_objects for all users queryset) + objects = UserUnmanagedManager() + + def __init__(self, *args, is_platform_auditor=False, **kwargs): + super().__init__(*args, **kwargs) + if is_platform_auditor: + self._is_platform_auditor = True + # Store the original value of the fields to check for field changes later + self._original_fields = self._get_fields() + + def _get_fields(self): + """ + Return a dictionary of the model's instance fields and their current values. + """ + return {field.name: self.__dict__.get(field.name) for field in self._meta.fields} + + def save(self, *args, **kwargs): + # If the password is empty string lets make it None, this will get turned into an unusable password by make_password + if self.password == '': + self.password = None + + if password_is_usable(self.password) and not password_is_hashed(self.password): + self.password = make_password(self.password) + + super().save(*args, **kwargs) + + def summary_fields(self): + return user_summary_fields(self) + + @property + def organizations(self): + + from ansible_base.lib.utils.auth import get_organization_model + if get_organization_model(return_none_on_error=True) is None: + raise AttributeError("Property not available") + else: + return get_organization_model().access_qs(self, 'member') diff --git a/ansible_base/django_template/router.py b/ansible_base/django_template/router.py new file mode 100644 index 000000000..5ef246a22 --- /dev/null +++ b/ansible_base/django_template/router.py @@ -0,0 +1,35 @@ +from ansible_base.django_template import views +from ansible_base.django_template.views.api.v1.user import OrganizationRelatedUserViewSet, TeamRelatedUserViewSet +from ansible_base.lib.routers import AssociationResourceRouter +from ansible_base.lib.utils.auth import get_organization_model, get_team_model + +router = AssociationResourceRouter() +router.register( + r'users', + views.UserViewSet, + related_views={}, +) +if get_organization_model(return_none_on_error=True) is not None: + related_views = { + 'users': (OrganizationRelatedUserViewSet, 'users'), + 'admins': (OrganizationRelatedUserViewSet, 'admins'), + } + if get_team_model(return_none_on_error=True) is not None: + related_views['teams'] = (views.TeamViewSet, 'teams') + router.register( + r'organizations', + views.OrganizationViewSet, + related_views=related_views, + basename="organization", + ) + +if get_team_model(return_none_on_error=True) is not None: + router.register( + r'teams', + views.TeamViewSet, + related_views={ + 'users': (TeamRelatedUserViewSet, 'users'), + 'admins': (TeamRelatedUserViewSet, 'admins'), + }, + basename='team', +) diff --git a/ansible_base/django_template/serializers/__init__.py b/ansible_base/django_template/serializers/__init__.py new file mode 100644 index 000000000..7345f50d6 --- /dev/null +++ b/ansible_base/django_template/serializers/__init__.py @@ -0,0 +1,3 @@ +from ansible_base.django_template.serializers.organization import OrganizationSerializer # noqa: 401 +from ansible_base.django_template.serializers.team import TeamSerializer # noqa: 401 +from ansible_base.django_template.serializers.user import UserSerializer # noqa: 401 diff --git a/ansible_base/django_template/serializers/organization.py b/ansible_base/django_template/serializers/organization.py new file mode 100644 index 000000000..84a637a9e --- /dev/null +++ b/ansible_base/django_template/serializers/organization.py @@ -0,0 +1,11 @@ +from ansible_base.lib.serializers.common import NamedCommonModelSerializer +from ansible_base.lib.utils.auth import get_organization_model + + +class OrganizationSerializer(NamedCommonModelSerializer): + class Meta: + model = get_organization_model() + fields = NamedCommonModelSerializer.Meta.fields + [ + 'description', + 'managed', + ] diff --git a/ansible_base/django_template/serializers/team.py b/ansible_base/django_template/serializers/team.py new file mode 100644 index 000000000..6643181f3 --- /dev/null +++ b/ansible_base/django_template/serializers/team.py @@ -0,0 +1,38 @@ +# from rest_framework import serializers +from ansible_base.lib.serializers.common import NamedCommonModelSerializer +from ansible_base.lib.utils.auth import get_organization_model, get_team_model +from ansible_base.rbac.api.related import RelatedAccessMixin + + +class TeamSerializer(RelatedAccessMixin, NamedCommonModelSerializer): + lookup_field = 'users' + + class Meta: + model = get_team_model() + fields = NamedCommonModelSerializer.Meta.fields + [ + 'organization', + 'description', + ] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + request = self.context.get('request') + if request: + self.fields['organization'].queryset = get_organization_model().access_qs(request.user) + + def get_extra_kwargs(self): + extra_kwargs = super().get_extra_kwargs() + request = self.context.get('request') + if request and request.user.is_superuser: + return extra_kwargs + + view = self.context.get('view') + if view: + action = view.action + + if action in ['create', 'update', 'partial_update']: + kwargs = extra_kwargs.get('organization') + kwargs['read_only'] = action in ['update', 'partial_update'] + extra_kwargs['organization'] = kwargs + + return extra_kwargs diff --git a/ansible_base/django_template/serializers/user.py b/ansible_base/django_template/serializers/user.py new file mode 100644 index 000000000..112f41e60 --- /dev/null +++ b/ansible_base/django_template/serializers/user.py @@ -0,0 +1,54 @@ +import logging + +from django.contrib.auth import get_user_model +from rest_framework import serializers +from rest_framework.fields import empty + +from ansible_base.django_template.models.user import password_is_usable +from ansible_base.lib.serializers.common import CommonUserSerializer +from ansible_base.lib.utils.encryption import ENCRYPTED_STRING + +logger = logging.getLogger('aap.gateway.serializer.user') + +PASSWORD_DISABLED = 'Password Disabled' # signal unusable passwords + + +class UserSerializer(CommonUserSerializer): + password = serializers.CharField(required=False, max_length=128, allow_blank=True) + + def __init__(self, instance=None, data=empty, **kwargs): + super().__init__(instance, data, **kwargs) + + class Meta(CommonUserSerializer.Meta): + model = get_user_model() + fields = CommonUserSerializer.Meta.fields + [ + 'username', + 'email', + 'first_name', + 'last_name', + 'last_login', + 'password', + 'is_superuser', + 'managed', + ] + read_only_fields = ["last_login"] + + def update(self, instance, validated_data): + # We don't want the $encrypted$ password going back to the model + if validated_data.get('password', "") in [ENCRYPTED_STRING, PASSWORD_DISABLED]: + validated_data.pop('password', None) + + instance = super().update(instance, validated_data) + + return instance + + def to_representation(self, obj): + ret = super(UserSerializer, self).to_representation(obj) + if password_is_usable(ret['password']): + # If its an internal account lets assume there is a password and return a masked value to the user + ret['password'] = ENCRYPTED_STRING + else: + # User does not have a local password, or password is unusable/ disabled + ret['password'] = PASSWORD_DISABLED + + return ret diff --git a/ansible_base/django_template/signals/preloaded_data.py b/ansible_base/django_template/signals/preloaded_data.py new file mode 100644 index 000000000..f3f9111b2 --- /dev/null +++ b/ansible_base/django_template/signals/preloaded_data.py @@ -0,0 +1,92 @@ +import logging + +from django.apps import apps as global_apps + +from ansible_base.lib.utils.auth import get_organization_model +from ansible_base.lib.utils.models import get_system_user +from ansible_base.rbac.permission_registry import permission_registry + +logger = logging.getLogger('ansible_base.django_template.signals.preloaded_data') + + +def create_preload_data(**kwargs) -> None: + """ + Run functions in a given order to create pre-loaded data + All functions in this code should take no arguments and be idempotent + If they fail, an exception (of any type) should be raised + If an exception is raised the message "Failed to " is outputted in the logs + """ + + function_order = [ + create_default_organization, + create_managed_roles, + # set_system_user_password, + # set_system_user_managed_flag, + ] + + # Verbosity comes from the signal see https://docs.djangoproject.com/en/5.0/ref/signals/#post-migrate + verbosity = kwargs.get('verbosity', 1) + + # Plan comes from the signal as well. + # If this got called outside of a signal or presumably from a flush it may not exist + if 'plan' not in kwargs: + # If we are are being called via a flush instead of a migrate then we can just return + return + + for migration, rolled_back in kwargs['plan']: + if rolled_back: + if verbosity > 0: + logger.debug(f"We are rolling back migration {migration}, no need to create objects") + return + + if verbosity > 0: + logger.info("Building preloaded data") + + for function in function_order: + name = function.__name__ + try: + if verbosity > 1: + logger.info(f"Running {name}") + created = function() + if verbosity > 0 and created: + action = 'Created' + if name.startswith('set'): + action = 'Set' + logger.debug(f"{action} {' '.join(name.split('_')[1:])}") + except Exception as e: + # raise e + if verbosity in [0, 1]: + logger.error(f"Failed to {name.replace('_', ' ')} {e}") + elif verbosity > 1: + logger.exception(f"Failed to {name.replace('_', ' ')}") + + +def create_default_organization() -> bool: + _org, created = get_organization_model().objects.get_or_create( + name='Default', defaults={'managed': True, 'description': 'The default organization for Ansible Automation Platform'} + ) + return created + + +def create_managed_roles() -> None: + permission_registry.create_managed_roles(global_apps) + + +def set_system_user_password() -> bool: + # When the system user is created by a migration it can't call set_usable_password because is of class <__fake__.User> + system_user = get_system_user() + if system_user.has_usable_password: + system_user.set_unusable_password() + system_user.save() + return True + else: + return False + + +def set_system_user_managed_flag() -> None: + system_user = get_system_user() + if system_user.managed: + return False + system_user.managed = True + system_user.save() + return True diff --git a/ansible_base/django_template/static/api/api.css b/ansible_base/django_template/static/api/api.css new file mode 100644 index 000000000..7b81c7322 --- /dev/null +++ b/ansible_base/django_template/static/api/api.css @@ -0,0 +1,223 @@ +/************************************************* + * Copyright (c) 2016 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +@layer theme { + :root { + --gray-10: #fafafa; + --gray-15: #f5f5f5; + --gray-20: #f0f0f0; + --gray-30: #d2d2d2; + --gray-40: #b8bbbe; + --gray-50: #8a8d90; + --gray-60: #6a6e73; + --gray-70: #4f5255; + --gray-80: #3c3f42; + --gray-85: #212427; + --gray-90: #151515; + --gray-100: #030303; + --blue-5: #e7f1fa; + --blue-10: #bee1f4; + --blue-20: #73bcf7; + --blue-30: #2b9af3; + --blue-40: #06c; + --blue-50: #004080; + --blue-60: #002952; + --blue-70: #001223; + + --blue-primary: var(--blue-40); + --blue-secondary: var(--blue-60); + + --border-radius: 3px; + --background-color: var(--gray-20); + } + + @font-face { + font-family: 'Overpass'; + font-style: normal; + font-weight: 400; + font-display: fallback; + src: url(/static/fonts/overpass-regular.woff2) format('woff2'); + } + @font-face { + font-family: 'Overpass'; + font-style: normal; + font-weight: 700; + font-display: fallback; + src: url(/static/fonts/overpass-bold.woff2) format('woff2'); + } + @font-face { + font-family: 'Overpass Mono'; + font-style: normal; + font-weight: 400; + font-display: fallback; + src: url(/static/fonts/overpass-mono.woff2) format('woff2'); + } + @font-face { + font-family: 'Overpass Mono'; + font-style: normal; + font-weight: 700; + font-display: fallback; + src: url(/static/fonts/overpass-mono-bold.woff2) format('woff2'); + } +} + +@layer global { + html { + font-size: 0.9375em; + } + + body { + margin-block-start: 72px; + font-family: Overpass, Helvetica, Arial, sans-serif; + background-color: var(--background-color); + font-size: unset; + } + + pre { + font-family: 'Overpass Mono', monospace; + border-radius: unset; + background-color: var(--gray-10); + } + + a:any-link { + color: var(--blue-40); + } + a:hover { + color: var(--blue-50); + text-decoration: underline; + } +} + +@layer layout { + .l-login { + display: flex; + min-block-size: calc(100svb - 100px); + align-items: center; + } +} + +@layer modules { + .container { + inline-size: unset; + max-inline-size: 80rem; + margin-inline: auto; + } + + .navbar { + background-color: var(--gray-90); + color: var(--gray-10); + padding-block: 10px; + } + .navbar a:any-link { + color: inherit; + } + + .navbar > .container { + display: flex; + } + .navbar-header, + .navbar-brand { + display: flex; + gap: 1rem; + } + .navbar-brand { + white-space: nowrap; + } + .navbar-collapse { + margin-inline-start: auto; + } + .navbar-title { + display: none; + } + + .nav > li > a:hover { + background-color: unset; + color: var(--blue-30); + } + + .breadcrumb { + margin-block-start: 1.5em; + margin-inline: -15px; + } + + .nav-tabs > li.active > a { + color: inherit; + text-decoration: none; + } + + .btn { + background-color: var(--blue-40); + color: var(--gray-10); + border: unset; + border-radius: var(--border-radius); + font: inherit; + } + .btn:hover { + background-color: var(--blue-50); + } + + .btn-group > .btn:first-child { + border-start-end-radius: 0; + border-end-end-radius: 0; + } + .btn-group > .btn:not(:first-child) { + margin-inline-start: 1px; + border-start-start-radius: 0; + border-end-start-radius: 0; + } + + .form-control { + border-radius: unset; + border: 1px solid var(--gray-30); + width: 100% !important; + } + .form-control:hover { + border-block-end: 1px solid var(--blue-40); + } + .form-control:focus { + box-shadow: unset; + border-block-end: 2px solid var(--blue-40); + } + .form-control:focus-visible { + outline: 2px solid var(--blue-40); + } + textarea.form-control { + font-family: 'Overpass Mono', monospace; + } + + .dropdown-menu a:any-link { + color: inherit; + } + .dropdown-menu a:hover { + text-decoration: none; + } + + .well { + background-color: var(--gray-10); + border: 0; + border-block-end: 1px solid var(--gray-40); + border-radius: unset; + box-shadow: unset; + -webkit-box-shadow: unset; + } + + .footer-copyright { + text-align: center; + color: var(--gray-50); + } + .footer-copyright a:any-link { + color: var(--gray-70); + text-decoration: underline; + } + + .prettyprint { + font-size: inherit !important; + } +} + +.content-main > .request-info { + clear: both; +} diff --git a/ansible_base/django_template/static/api/api.js b/ansible_base/django_template/static/api/api.js new file mode 100644 index 000000000..67053ae2f --- /dev/null +++ b/ansible_base/django_template/static/api/api.js @@ -0,0 +1,96 @@ +/************************************************* + * Copyright (c) 2016 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +$(function() { + + // Add syntax highlighting to examples in description. + $('.description pre').addClass('prettyprint'); + prettyPrint(); + + // Make links from relative URLs to resources. + $('span.str').each(function() { + var s = $(this).html(); + if (s.match(/^\"\/.+\/\"$/) || s.match(/^\"\/.+\/\?.*\"$/)) { + $(this).html('"' + s.replace(/\"/g, '') + '"'); + } + }); + + // Make links for all inventory script hosts. + $('.request-info .pln').filter(function() { + return $(this).text() === 'script'; + }).each(function() { + $('.response-info span.str').filter(function() { + return $(this).text() === '"hosts"'; + }).each(function() { + $(this).nextUntil('span.pun:contains("]")').filter('span.str').each(function() { + if ($(this).text().match(/^\".+\"$/)) { + var s = $(this).text().replace(/\"/g, ''); + $(this).html('"' + s + '"'); + } + else if ($(this).text() !== '"') { + var s = $(this).text(); + $(this).html('' + s + ''); + } + }); + }); + }); + + // Add classes/icons for dynamically showing/hiding help. + if ($('.description').html()) { + $('.description').addClass('prettyprint').parent().css('float', 'none'); + $('.hidden a.hide-description').prependTo('.description'); + $('a.hide-description').click(function() { + $(this).tooltip('hide'); + $('.description').slideUp('fast'); + return false; + }); + $('.hidden a.toggle-description').appendTo('.page-header h1'); + $('a.toggle-description').click(function() { + $(this).tooltip('hide'); + $('.description').slideToggle('fast'); + return false; + }); + } + + $('[data-toggle="tooltip"]').tooltip(); + + if ($(window).scrollTop() >= 115) { + $('body').addClass('show-title'); + } + $(window).scroll(function() { + if ($(window).scrollTop() >= 115) { + $('body').addClass('show-title'); + } + else { + $('body').removeClass('show-title'); + } + }); + + $('a.resize').click(function() { + $(this).tooltip('hide'); + if ($(this).find('span.glyphicon-resize-full').size()) { + $(this).find('span.glyphicon').addClass('glyphicon-resize-small').removeClass('glyphicon-resize-full'); + $('.container').addClass('container-fluid').removeClass('container'); + document.cookie = 'api_width=wide; path=/api/'; + } + else { + $(this).find('span.glyphicon').addClass('glyphicon-resize-full').removeClass('glyphicon-resize-small'); + $('.container-fluid').addClass('container').removeClass('container-fluid'); + document.cookie = 'api_width=fixed; path=/api/'; + } + return false; + }); + + function getCookie(name) { + var value = "; " + document.cookie; + var parts = value.split("; " + name + "="); + if (parts.length == 2) return parts.pop().split(";").shift(); + } + if (getCookie('api_width') == 'wide') { + $('a.resize').click(); + } + +}); diff --git a/ansible_base/django_template/static/images/Logo-Red_Hat-Ansible_Automation_Platform-A-Standard-RGB.svg b/ansible_base/django_template/static/images/Logo-Red_Hat-Ansible_Automation_Platform-A-Standard-RGB.svg new file mode 100644 index 000000000..5a08171f3 --- /dev/null +++ b/ansible_base/django_template/static/images/Logo-Red_Hat-Ansible_Automation_Platform-A-Standard-RGB.svg @@ -0,0 +1 @@ +Logo-Red_Hat-Ansible_Automation_Platform-A-Reverse-RGB diff --git a/ansible_base/django_template/static/images/Product_icon-Red_Hat-Ansible_Automation_Platform-RGB.png b/ansible_base/django_template/static/images/Product_icon-Red_Hat-Ansible_Automation_Platform-RGB.png new file mode 100644 index 0000000000000000000000000000000000000000..9b160ad3bae66cccaf0642b39bec287e52763a0e GIT binary patch literal 9585 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
Vx`}S|Jp*@u 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 nZvwcm|Htsr|38m(f-vg(tKge[0-9]+)/teams/', views.UserTeamViewSet.as_view({'get': 'list'}), name='user-teams-list')) +if get_organization_model(return_none_on_error=True) is not None: + api_version_urls.append(re_path('users/(?P[0-9]+)/organizations/', views.UserOrganizationViewSet.as_view({'get': 'list'}), name='user-organizations-list')) diff --git a/ansible_base/django_template/views/__init__.py b/ansible_base/django_template/views/__init__.py new file mode 100644 index 000000000..d389d77e8 --- /dev/null +++ b/ansible_base/django_template/views/__init__.py @@ -0,0 +1,24 @@ +from ansible_base.django_template.views.api.v1 import V1RootView # noqa: F401 +from ansible_base.django_template.views.api.v1.local_login import LoggedLoginView, LoggedLogoutView # noqa: F401 +from ansible_base.django_template.views.api.v1.me import MeViewSet # noqa: F401 +from ansible_base.django_template.views.api.v1.organization import OrganizationViewSet # noqa: F401 +from ansible_base.django_template.views.api.v1.ping import PingView # noqa: F401 +from ansible_base.django_template.views.api.v1.related_views import UserOrganizationViewSet, UserTeamViewSet # noqa: F401 +from ansible_base.django_template.views.api.v1.session import SessionView # noqa: F401 +from ansible_base.django_template.views.api.v1.team import TeamViewSet # noqa: F401 +from ansible_base.django_template.views.api.v1.user import UserViewSet # noqa: F401 + +from django.core.exceptions import FieldError +from django.db import IntegrityError +from rest_framework.exceptions import ParseError +from rest_framework.views import exception_handler + +def api_exception_handler(exc, context): + """ + Override default API exception handler to catch IntegrityError exceptions. + """ + if isinstance(exc, IntegrityError): + exc = ParseError(exc.args[0]) + if isinstance(exc, FieldError): + exc = ParseError(exc.args[0]) + return exception_handler(exc, context) diff --git a/ansible_base/django_template/views/api/__init__.py b/ansible_base/django_template/views/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ansible_base/django_template/views/api/v1/__init__.py b/ansible_base/django_template/views/api/v1/__init__.py new file mode 100644 index 000000000..f212713f6 --- /dev/null +++ b/ansible_base/django_template/views/api/v1/__init__.py @@ -0,0 +1,76 @@ +import logging +import re +from collections import OrderedDict + +from django.urls import get_resolver +from django.urls.exceptions import NoReverseMatch +from django.utils.decorators import method_decorator +from django.utils.translation import gettext_lazy as _ +from django.views.decorators.csrf import ensure_csrf_cookie +from rest_framework.permissions import AllowAny +from rest_framework.response import Response +from rest_framework.schemas.generators import EndpointEnumerator + +from ansible_base.lib.utils.response import get_relative_url +from ansible_base.lib.utils.views.ansible_base import AnsibleBaseView + +logger = logging.getLogger('aap.templated_app.views') + + +ignore_endpoints = ['docs', 'login', 'logout'] +api_endpoint_re = re.compile('^/api/[^/]*/v1/(?P[^/]+)') + + +def get_all_endpoints(): + url_patterns = get_resolver().url_patterns + endpoints = [] + + for pattern in url_patterns: + if hasattr(pattern, 'url_patterns'): + endpoints.extend(get_all_endpoints_from_pattern(pattern)) + else: + endpoints.append(pattern.name) + + return endpoints + + +def get_all_endpoints_from_pattern(pattern): + endpoints = [] + for subpattern in pattern.url_patterns: + if hasattr(subpattern, 'url_patterns'): + endpoints.extend(get_all_endpoints_from_pattern(subpattern)) + else: + endpoints.append(subpattern.name) + return endpoints + + +class V1RootView(AnsibleBaseView): + permission_classes = (AllowAny,) + name = _('v1') + versioning_class = None + + @method_decorator(ensure_csrf_cookie) + def get(self, request, format=None): + # Get all of the endpoints we want to know about from the URLs in Django + data = {} + for endpoint_name in get_all_endpoints(): + try: + relative_url = get_relative_url(endpoint_name) + except NoReverseMatch: + continue + + matches = api_endpoint_re.match(relative_url) + if matches is None: + logger.debug(f"Endpoint {relative_url} was not a v1 endpoint, skipping") + continue + + data[matches.group('endpoint')] = relative_url + + sorted_data = OrderedDict() + for sorted_endpoint in sorted(data.keys()): + if sorted_endpoint in ignore_endpoints: + continue + + sorted_data[sorted_endpoint] = data[sorted_endpoint] + + return Response(sorted_data) diff --git a/ansible_base/django_template/views/api/v1/common.py b/ansible_base/django_template/views/api/v1/common.py new file mode 100644 index 000000000..3b697ed09 --- /dev/null +++ b/ansible_base/django_template/views/api/v1/common.py @@ -0,0 +1,25 @@ +from rest_framework import viewsets + +from ansible_base.lib.utils.views.ansible_base import AnsibleBaseView +from ansible_base.lib.utils.views.permissions import IsSuperuserOrAuditor +from ansible_base.rbac.api.permissions import AnsibleBaseObjectPermissions + + +class TemplatedAppReadOnlyModelViewSet(viewsets.ReadOnlyModelViewSet, AnsibleBaseView): + permission_classes = [IsSuperuserOrAuditor] + + +class TemplatedAppModelViewSet(viewsets.ModelViewSet, AnsibleBaseView): + permission_classes = [IsSuperuserOrAuditor] + + +class RoleModelViewSet(TemplatedAppModelViewSet): + "Use for models registered in the DAB RBAC permission registry" + permission_classes = [AnsibleBaseObjectPermissions] + + def filter_queryset(self, qs): + if hasattr(qs, 'model'): + cls = qs.model + qs = cls.access_qs(self.request.user, queryset=qs) + + return super().filter_queryset(qs) diff --git a/ansible_base/lib/views/local_login.py b/ansible_base/django_template/views/api/v1/local_login.py similarity index 93% rename from ansible_base/lib/views/local_login.py rename to ansible_base/django_template/views/api/v1/local_login.py index 2c192cadf..e8d03d3e3 100644 --- a/ansible_base/lib/views/local_login.py +++ b/ansible_base/django_template/views/api/v1/local_login.py @@ -2,8 +2,6 @@ import logging import re -from ansible_base.lib.utils.requests import get_remote_host -from django.conf import settings from django.contrib.auth import views from django.core.exceptions import PermissionDenied from django.utils.decorators import method_decorator @@ -15,7 +13,10 @@ from rest_framework.response import Response from social_core.exceptions import AuthException -logger = logging.getLogger('aap.templated_app.views.local_login') +from ansible_base.lib.utils.requests import get_remote_host +from ansible_base.lib.utils.settings import get_setting + +logger = logging.getLogger('ansible_base.django_template.views.local_login') class LoggedLoginView(views.LoginView): @@ -63,7 +64,7 @@ def post(self, request, *args, **kwargs): @method_decorator(require_http_methods(["POST", "GET"]), name="dispatch") class LoggedLogoutView(views.LogoutView): - success_url_allowed_hosts = settings.LOGOUT_ALLOWED_HOSTS + success_url_allowed_hosts = get_setting('LOGOUT_ALLOWED_HOSTS', []) def dispatch(self, request, *args, **kwargs): original_user = getattr(request, 'user', None) diff --git a/ansible_base/lib/views/me.py b/ansible_base/django_template/views/api/v1/me.py similarity index 74% rename from ansible_base/lib/views/me.py rename to ansible_base/django_template/views/api/v1/me.py index eacf9ffdb..a58696221 100644 --- a/ansible_base/lib/views/me.py +++ b/ansible_base/django_template/views/api/v1/me.py @@ -2,8 +2,8 @@ from rest_framework import viewsets from rest_framework.permissions import IsAuthenticated -from templated_app.serializers import UserSerializer -from templated_app.views.api.v1.common import AnsibleBaseView +from ansible_base.django_template.serializers import UserSerializer +from ansible_base.django_template.views.api.v1.common import AnsibleBaseView User = get_user_model() diff --git a/ansible_base/django_template/views/api/v1/organization.py b/ansible_base/django_template/views/api/v1/organization.py new file mode 100644 index 000000000..06c35f42c --- /dev/null +++ b/ansible_base/django_template/views/api/v1/organization.py @@ -0,0 +1,29 @@ +import logging + +from django.utils.translation import gettext_lazy as _ +from rest_framework import status +from rest_framework.response import Response + +from ansible_base.django_template.serializers import OrganizationSerializer +from ansible_base.django_template.views.api.v1.common import RoleModelViewSet +from ansible_base.lib.utils.auth import get_organization_model + +logger = logging.getLogger('aap.templated_app.views.organization') + + +class OrganizationViewSet(RoleModelViewSet): + """ + API endpoint that allows organizations to be viewed or edited. + """ + + queryset = get_organization_model().objects.select_related("resource").all() + serializer_class = OrganizationSerializer + + # Don't allow the deletion of any managed organizations + def destroy(self, request, *args, **kwargs): + instance = self.get_object() + if instance.managed: + logger.info("Managed organizations cannot be deleted.") + return Response(status=status.HTTP_400_BAD_REQUEST, data={"details": _("Managed organizations cannot be deleted.")}) + else: + return super().destroy(request, *args, **kwargs) diff --git a/ansible_base/lib/views/ping.py b/ansible_base/django_template/views/api/v1/ping.py similarity index 84% rename from ansible_base/lib/views/ping.py rename to ansible_base/django_template/views/api/v1/ping.py index 36077f216..cae047de0 100644 --- a/ansible_base/lib/views/ping.py +++ b/ansible_base/django_template/views/api/v1/ping.py @@ -1,11 +1,11 @@ from datetime import datetime -from ansible_base.lib.constants import STATUS_DEGRADED, STATUS_GOOD from django.db import connections from rest_framework.response import Response -from templated_app.version import get_aap_version -from templated_app.views.api.v1.common import AnsibleBaseView +#from templated_app.version import get_aap_version +from ansible_base.django_template.views.api.v1.common import AnsibleBaseView +from ansible_base.lib.constants import STATUS_DEGRADED, STATUS_GOOD def _get_db_connection_status(db_conn): @@ -23,7 +23,7 @@ class PingView(AnsibleBaseView): def get(self, request): current_time = datetime.now() response = { - "version": get_aap_version(), +# "version": get_aap_version(), "pong": str(current_time), "status": STATUS_GOOD, } diff --git a/ansible_base/django_template/views/api/v1/related_views.py b/ansible_base/django_template/views/api/v1/related_views.py new file mode 100644 index 000000000..cefd9b62e --- /dev/null +++ b/ansible_base/django_template/views/api/v1/related_views.py @@ -0,0 +1,40 @@ +from django.contrib.auth import get_user_model +from django.db.models.functions import Cast +from django.http import Http404 + +from ansible_base.django_template.serializers import OrganizationSerializer, TeamSerializer +from ansible_base.django_template.views.api.v1.common import TemplatedAppModelViewSet +from ansible_base.lib.utils.auth import get_organization_model, get_team_model +from ansible_base.rbac.api.permissions import AnsibleBaseUserPermissions +from ansible_base.rbac.models import ObjectRole +from ansible_base.rbac.policies import visible_users + + +class UserTeamViewSet(TemplatedAppModelViewSet): + model = get_team_model + serializer_class = TeamSerializer + permission_classes = [AnsibleBaseUserPermissions] + + def get_queryset(self): + try: + user = visible_users(self.request.user).get(pk=self.kwargs['pk']) + return get_team_model().access_qs(user, 'member') + except get_user_model.DoesNotExist: + raise Http404("No User matches the given query") + + +class UserOrganizationViewSet(TemplatedAppModelViewSet): + model = get_organization_model() + serializer_class = OrganizationSerializer + permission_classes = [AnsibleBaseUserPermissions] + + def get_queryset(self): + try: + user = visible_users(self.request.user).get(pk=self.kwargs['pk']) + return get_organization_model().objects.filter( + id__in=ObjectRole.objects.filter( + role_definition__name__in=(get_organization_model().member_rd_name, get_organization_model().admin_rd_name), users=user.id + ).values_list(Cast('object_id', output_field=get_organization_model()._meta.pk)) + ) + except get_user_model.DoesNotExist: + raise Http404("No User matches the given query") diff --git a/ansible_base/lib/views/session.py b/ansible_base/django_template/views/api/v1/session.py similarity index 94% rename from ansible_base/lib/views/session.py rename to ansible_base/django_template/views/api/v1/session.py index ce5411587..8613d37aa 100644 --- a/ansible_base/lib/views/session.py +++ b/ansible_base/django_template/views/api/v1/session.py @@ -1,13 +1,14 @@ import logging from datetime import datetime, timezone -from ansible_base.lib.utils.views.django_app_api import AnsibleBaseDjangoAppApiView from django.contrib.sessions.models import Session from django.utils.translation import gettext as _ from rest_framework import permissions, status from rest_framework.response import Response -logger = logging.getLogger('aap.templated_app.views.api.v1.session') +from ansible_base.lib.utils.views.django_app_api import AnsibleBaseDjangoAppApiView + +logger = logging.getLogger('ansible_base.django_template.views.api.v1.session') class SessionView(AnsibleBaseDjangoAppApiView): diff --git a/ansible_base/django_template/views/api/v1/team.py b/ansible_base/django_template/views/api/v1/team.py new file mode 100644 index 000000000..a225e3ed1 --- /dev/null +++ b/ansible_base/django_template/views/api/v1/team.py @@ -0,0 +1,12 @@ +from ansible_base.django_template.serializers import TeamSerializer +from ansible_base.django_template.views.api.v1.common import RoleModelViewSet +from ansible_base.lib.utils.auth import get_team_model + + +class TeamViewSet(RoleModelViewSet): + """ + API endpoint that allows groups to be viewed or edited. + """ + + queryset = get_team_model().objects.select_related("resource").all() + serializer_class = TeamSerializer diff --git a/ansible_base/django_template/views/api/v1/user.py b/ansible_base/django_template/views/api/v1/user.py new file mode 100644 index 000000000..5559d9071 --- /dev/null +++ b/ansible_base/django_template/views/api/v1/user.py @@ -0,0 +1,84 @@ +from django.contrib.auth import get_user_model + +from ansible_base.django_template.serializers import UserSerializer +from ansible_base.django_template.views.api.v1.common import TemplatedAppModelViewSet +from ansible_base.rbac.api.permissions import AnsibleBaseUserPermissions +from ansible_base.rbac.policies import can_view_all_users, visible_users + + +class UserViewSet(TemplatedAppModelViewSet): + """ + API endpoint that allows users to be viewed or edited. + """ + + model = get_user_model() + queryset = get_user_model().objects.select_related("resource").all() + serializer_class = UserSerializer + permission_classes = [AnsibleBaseUserPermissions] + + def filter_queryset(self, qs): + qs = visible_users(self.request.user, queryset=qs) + return super().filter_queryset(qs) + + def get_queryset(self): + if self.detail: + return get_user_model().all_objects.select_related("resource").all() + return super().get_queryset() + + +class DeprecatedRelatedUserViewSet(TemplatedAppModelViewSet): + """ + Shows all users for sublists like /api/v1/organizations/5/users/ + the related view still checks organization view permission + """ + + deprecated = True + model = get_user_model() + queryset = get_user_model().objects.select_related("resource").all() + serializer_class = UserSerializer + permission_classes = [AnsibleBaseUserPermissions] + + # Methods for compatibility with the old users and admins endpoints + def get_association_role_definition(self, parent_instance): + rd = None + if self.association_fk == 'users': + rd = parent_instance.member_rd + elif self.association_fk == 'admins': + rd = parent_instance.admin_rd + return rd + + def get_sublist_queryset(self, parent_instance): + rd = self.get_association_role_definition(parent_instance) + object_roles = rd.object_roles.filter(object_id=parent_instance.pk) + return self.queryset.filter(has_roles__in=object_roles) + + def perform_associate(self, parent_instance, related_instances): + rd = self.get_association_role_definition(parent_instance) + for user in related_instances: + rd.give_permission(user, parent_instance) + + def perform_disassociate(self, parent_instance, related_instances): + rd = self.get_association_role_definition(parent_instance) + for user in related_instances: + rd.remove_permission(user, parent_instance) + + +class OrganizationRelatedUserViewSet(DeprecatedRelatedUserViewSet): + def filter_queryset(self, qs): + qs = visible_users(self.request.user, queryset=qs, always_show_superusers=False, always_show_self=False) + return super().filter_queryset(qs) + + +class TeamRelatedUserViewSet(DeprecatedRelatedUserViewSet): + def filter_associate_queryset(self, qs): + qs = visible_users(self.request.user, queryset=qs, always_show_superusers=False, always_show_self=False) + return super().filter_queryset(qs) + + def is_team_admin(self, parent_instance): + return self.request.user.has_obj_perm(parent_instance, 'change') + + def get_sublist_queryset(self, parent_instance): + queryset = super().get_sublist_queryset(parent_instance) + if can_view_all_users(self.request.user) or self.is_team_admin(parent_instance): + return queryset + return queryset & self.queryset.filter(pk=self.request.user.id) diff --git a/ansible_base/lib/utils/auth.py b/ansible_base/lib/utils/auth.py index ffb9610bf..0cdf335d0 100644 --- a/ansible_base/lib/utils/auth.py +++ b/ansible_base/lib/utils/auth.py @@ -29,12 +29,22 @@ def get_model_from_settings(setting_name: str) -> Any: raise ImproperlyConfigured(f"{setting_name} refers to model '{setting}' that has not been installed") -def get_team_model() -> Type[AbstractTeam]: - return get_model_from_settings('ANSIBLE_BASE_TEAM_MODEL') +def get_team_model(return_none_on_error: bool = False) -> Type[AbstractTeam]: + try: + return get_model_from_settings('ANSIBLE_BASE_TEAM_MODEL') + except ImproperlyConfigured: + if return_none_on_error: + return None + raise -def get_organization_model() -> Type[AbstractOrganization]: - return get_model_from_settings('ANSIBLE_BASE_ORGANIZATION_MODEL') +def get_organization_model(return_none_on_error: bool = False) -> Type[AbstractOrganization]: + try: + return get_model_from_settings('ANSIBLE_BASE_ORGANIZATION_MODEL') + except ImproperlyConfigured: + if return_none_on_error: + return None + raise def get_object_by_ansible_id(qs: QuerySet, ansible_id: Union[str, UUID], annotate_as: str = 'ansible_id_for_filter') -> Model: diff --git a/ansible_base/lib/views/__init__.py b/ansible_base/lib/views/__init__.py deleted file mode 100644 index ec4973723..000000000 --- a/ansible_base/lib/views/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from ansible_base.lib.views.local_login import LoggedLoginView, LoggedLogoutView # noqa: F401 -from ansible_base.lib.views.me import MeViewSet # noqa: F401 -from ansible_base.lib.views.ping import PingView # noqa: F401 -from ansible_base.lib.views.session import SessionView # noqa: F401 diff --git a/ansible_base/resource_registry/registry.py b/ansible_base/resource_registry/registry.py index a55a9cccc..1e0acd0f8 100644 --- a/ansible_base/resource_registry/registry.py +++ b/ansible_base/resource_registry/registry.py @@ -97,7 +97,8 @@ def _validate_api_config(self, config): - Viewsets have the correct serializer, pagination and filter classes - Service type is set to one of awx, galaxy, eda or aap """ - assert config.service_type in ["aap", "awx", "galaxy", "eda"] + service_types = ["aap", "awx", "galaxy", "eda", "templated_app"] + assert config.service_type in service_types, f"Expected a service_type in {service_types} got {config.service_type}" def get_resources(self): return self.registry From d316e39f1530b08b95ab12824a339d7d2d12f34c Mon Sep 17 00:00:00 2001 From: Bryan Havenstein Date: Mon, 11 Nov 2024 11:36:49 -0700 Subject: [PATCH 8/9] Add common JWT auth with permissions enabled --- ansible_base/jwt_consumer/common/auth.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ansible_base/jwt_consumer/common/auth.py b/ansible_base/jwt_consumer/common/auth.py index 1d901af6c..8d9c740cc 100644 --- a/ansible_base/jwt_consumer/common/auth.py +++ b/ansible_base/jwt_consumer/common/auth.py @@ -342,3 +342,7 @@ def process_permissions(self): self.common_auth.process_rbac_permissions() else: logger.info("process_permissions was not overridden for JWTAuthentication") + + +class JWTAuthenticationWithPermissions(JWTAuthentication): + use_rbac_permissions = True From 62f13aafb14ac2d7579b0098362ac666c8f6fd57 Mon Sep 17 00:00:00 2001 From: Bryan Havenstein Date: Tue, 12 Nov 2024 14:47:57 -0700 Subject: [PATCH 9/9] (TODO) remove social auth import This is included to make things work for now, but probably not the proper way to go. --- ansible_base/django_template/views/api/v1/local_login.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ansible_base/django_template/views/api/v1/local_login.py b/ansible_base/django_template/views/api/v1/local_login.py index e8d03d3e3..179e8780f 100644 --- a/ansible_base/django_template/views/api/v1/local_login.py +++ b/ansible_base/django_template/views/api/v1/local_login.py @@ -11,7 +11,7 @@ from rest_framework.negotiation import DefaultContentNegotiation from rest_framework.renderers import StaticHTMLRenderer from rest_framework.response import Response -from social_core.exceptions import AuthException +#from social_core.exceptions import AuthException TODO This does not work from ansible_base.lib.utils.requests import get_remote_host from ansible_base.lib.utils.settings import get_setting @@ -39,7 +39,7 @@ def get(self, request, *args, **kwargs): def post(self, request, *args, **kwargs): try: ret = super(LoggedLoginView, self).post(request, *args, **kwargs) - except AuthException as e: + except ValueError as e: # TODO What exception should be caught? Common denominator between social auth and django auth? # Log a warning when an exception occurs during login, # particularly when SYSTEM_USERNAME attempts to log in. logger.warning("Exception occurred during login.")