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 000000000..9b160ad3b Binary files /dev/null and b/ansible_base/django_template/static/images/Product_icon-Red_Hat-Ansible_Automation_Platform-RGB.png differ diff --git a/ansible_base/django_template/urls.py b/ansible_base/django_template/urls.py new file mode 100644 index 000000000..9af4c04f7 --- /dev/null +++ b/ansible_base/django_template/urls.py @@ -0,0 +1,30 @@ +from django.urls import include, path, re_path + +from ansible_base.django_template import views +from ansible_base.django_template.router import router +from ansible_base.lib.utils.auth import get_organization_model, get_team_model + +root_urls = [] + +api_urls = [ + path('v1/', views.V1RootView.as_view(), name='api_app_v1_root_view'), +] + +api_version_urls = [ + path('', include(router.urls)), + # Default views from ansible_base + path( + 'login/', + views.LoggedLoginView.as_view(template_name='rest_framework/login.html', extra_context={'inside_login_context': True}), + name='login', + ), + path('logout/', views.LoggedLogoutView.as_view(next_page='/api/', redirect_field_name='next'), name='logout'), + path('me/', views.MeViewSet.as_view({'get': 'list'}), name='me-list'), + path('ping/', views.PingView.as_view(), name='ping-view'), + path('session/', views.SessionView.as_view(), name='session-view'), +] + +if get_team_model(return_none_on_error=True) is not None: + api_version_urls.append(re_path('users/(?P[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/django_template/views/api/v1/local_login.py b/ansible_base/django_template/views/api/v1/local_login.py new file mode 100644 index 000000000..179e8780f --- /dev/null +++ b/ansible_base/django_template/views/api/v1/local_login.py @@ -0,0 +1,75 @@ +import json +import logging +import re + +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 TODO This does not work + +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): + 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 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.") + 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 = get_setting('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/django_template/views/api/v1/me.py b/ansible_base/django_template/views/api/v1/me.py new file mode 100644 index 000000000..a58696221 --- /dev/null +++ b/ansible_base/django_template/views/api/v1/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 ansible_base.django_template.serializers import UserSerializer +from ansible_base.django_template.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/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/django_template/views/api/v1/ping.py b/ansible_base/django_template/views/api/v1/ping.py new file mode 100644 index 000000000..cae047de0 --- /dev/null +++ b/ansible_base/django_template/views/api/v1/ping.py @@ -0,0 +1,35 @@ +from datetime import datetime + +from django.db import connections +from rest_framework.response import Response + +#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): + 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/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/django_template/views/api/v1/session.py b/ansible_base/django_template/views/api/v1/session.py new file mode 100644 index 000000000..8613d37aa --- /dev/null +++ b/ansible_base/django_template/views/api/v1/session.py @@ -0,0 +1,37 @@ +import logging +from datetime import datetime, timezone + +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 + +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): + 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")}) 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/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 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..a6e789477 --- /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', False): + return + return super(LoggedBasicAuthentication, self).authenticate_header(request) 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' 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..7a66c47a7 --- /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..6f21582d0 --- /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 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/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