mirror of
https://github.com/ansible/awx.git
synced 2026-02-06 03:54:44 -03:30
Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
918d5b3565 | ||
|
|
158314af50 | ||
|
|
4754819a09 | ||
|
|
78fc23138a | ||
|
|
014534bfa5 | ||
|
|
2502e7c7d8 | ||
|
|
fb237e3834 | ||
|
|
e4646ae611 | ||
|
|
7dc77546f4 | ||
|
|
f5f85666c8 | ||
|
|
47a061eb39 | ||
|
|
c760577855 | ||
|
|
814ceb0d06 | ||
|
|
f178c84728 | ||
|
|
c0f71801f6 | ||
|
|
4e8e1398d7 | ||
|
|
3d6a8fd4ef | ||
|
|
e873bb1304 | ||
|
|
672f1eb745 | ||
|
|
199507c6f1 | ||
|
|
a176c04c14 | ||
|
|
e3af658f82 | ||
|
|
e8a3b96482 | ||
|
|
c015e8413e | ||
|
|
390c2d8907 | ||
|
|
97605c5f19 | ||
|
|
818c326160 | ||
|
|
c98727d83e | ||
|
|
a138a92e67 | ||
|
|
7aed19ffda | ||
|
|
3bb559dd09 | ||
|
|
389a729b75 | ||
|
|
2f3c9122fd | ||
|
|
733478ee19 | ||
|
|
41c6337fc1 | ||
|
|
7446da1c2f | ||
|
|
c79fca5ceb | ||
|
|
dc5f43927a | ||
|
|
35a5a81e19 | ||
|
|
9dcc11d54c | ||
|
|
74ce21fa54 | ||
|
|
eb93660b36 | ||
|
|
f50e597548 | ||
|
|
817c3b36b9 | ||
|
|
1859a6ae69 | ||
|
|
0645d342dd | ||
|
|
61ec03e540 | ||
|
|
09f0a366bf | ||
|
|
778961d31e | ||
|
|
f962c88df3 | ||
|
|
8db3ffe719 | ||
|
|
cc5d4dd119 |
11
.github/workflows/ci.yml
vendored
11
.github/workflows/ci.yml
vendored
@@ -66,6 +66,8 @@ jobs:
|
||||
awx-operator:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
DEBUG_OUTPUT_DIR: /tmp/awx_operator_molecule_test
|
||||
steps:
|
||||
- name: Checkout awx
|
||||
uses: actions/checkout@v3
|
||||
@@ -111,6 +113,15 @@ jobs:
|
||||
env:
|
||||
AWX_TEST_IMAGE: local/awx
|
||||
AWX_TEST_VERSION: ci
|
||||
AWX_EE_TEST_IMAGE: quay.io/ansible/awx-ee:latest
|
||||
STORE_DEBUG_OUTPUT: true
|
||||
|
||||
- name: Upload debug output
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: awx-operator-debug-output
|
||||
path: ${{ env.DEBUG_OUTPUT_DIR }}
|
||||
|
||||
collection-sanity:
|
||||
name: awx_collection sanity
|
||||
|
||||
75
.github/workflows/e2e_test.yml
vendored
75
.github/workflows/e2e_test.yml
vendored
@@ -1,75 +0,0 @@
|
||||
---
|
||||
name: E2E Tests
|
||||
env:
|
||||
LC_ALL: "C.UTF-8" # prevent ERROR: Ansible could not initialize the preferred locale: unsupported locale setting
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [labeled]
|
||||
jobs:
|
||||
e2e-test:
|
||||
if: contains(github.event.pull_request.labels.*.name, 'qe:e2e')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 40
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
strategy:
|
||||
matrix:
|
||||
job: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- uses: ./.github/actions/run_awx_devel
|
||||
id: awx
|
||||
with:
|
||||
build-ui: true
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Pull awx_cypress_base image
|
||||
run: |
|
||||
docker pull quay.io/awx/awx_cypress_base:latest
|
||||
|
||||
- name: Checkout test project
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
repository: ${{ github.repository_owner }}/tower-qa
|
||||
ssh-key: ${{ secrets.QA_REPO_KEY }}
|
||||
path: tower-qa
|
||||
ref: devel
|
||||
|
||||
- name: Build cypress
|
||||
run: |
|
||||
cd ${{ secrets.E2E_PROJECT }}/ui-tests/awx-pf-tests
|
||||
docker build -t awx-pf-tests .
|
||||
|
||||
- name: Run E2E tests
|
||||
env:
|
||||
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
|
||||
run: |
|
||||
export COMMIT_INFO_BRANCH=$GITHUB_HEAD_REF
|
||||
export COMMIT_INFO_AUTHOR=$GITHUB_ACTOR
|
||||
export COMMIT_INFO_SHA=$GITHUB_SHA
|
||||
export COMMIT_INFO_REMOTE=$GITHUB_REPOSITORY_OWNER
|
||||
cd ${{ secrets.E2E_PROJECT }}/ui-tests/awx-pf-tests
|
||||
AWX_IP=${{ steps.awx.outputs.ip }}
|
||||
printenv > .env
|
||||
echo "Executing tests:"
|
||||
docker run \
|
||||
--network '_sources_default' \
|
||||
--ipc=host \
|
||||
--env-file=.env \
|
||||
-e CYPRESS_baseUrl="https://$AWX_IP:8043" \
|
||||
-e CYPRESS_AWX_E2E_USERNAME=admin \
|
||||
-e CYPRESS_AWX_E2E_PASSWORD='password' \
|
||||
-e COMMAND="npm run cypress-concurrently-gha" \
|
||||
-v /dev/shm:/dev/shm \
|
||||
-v $PWD:/e2e \
|
||||
-w /e2e \
|
||||
awx-pf-tests run --project .
|
||||
|
||||
- uses: ./.github/actions/upload_awx_devel_logs
|
||||
if: always()
|
||||
with:
|
||||
log-filename: e2e-${{ matrix.job }}.log
|
||||
40
.github/workflows/promote.yml
vendored
40
.github/workflows/promote.yml
vendored
@@ -7,7 +7,11 @@ env:
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag_name:
|
||||
description: 'Name for the tag of the release.'
|
||||
required: true
|
||||
permissions:
|
||||
contents: read # to fetch code (actions/checkout)
|
||||
|
||||
@@ -17,6 +21,16 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- name: Set GitHub Env vars for workflow_dispatch event
|
||||
if: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
run: |
|
||||
echo "TAG_NAME=${{ github.event.inputs.tag_name }}" >> $GITHUB_ENV
|
||||
|
||||
- name: Set GitHub Env vars if release event
|
||||
if: ${{ github.event_name == 'release' }}
|
||||
run: |
|
||||
echo "TAG_NAME=${{ env.TAG_NAME }}" >> $GITHUB_ENV
|
||||
|
||||
- name: Checkout awx
|
||||
uses: actions/checkout@v3
|
||||
|
||||
@@ -43,16 +57,18 @@ jobs:
|
||||
- name: Build collection and publish to galaxy
|
||||
env:
|
||||
COLLECTION_NAMESPACE: ${{ env.collection_namespace }}
|
||||
COLLECTION_VERSION: ${{ github.event.release.tag_name }}
|
||||
COLLECTION_VERSION: ${{ env.TAG_NAME }}
|
||||
COLLECTION_TEMPLATE_VERSION: true
|
||||
run: |
|
||||
make build_collection
|
||||
if [ "$(curl -L --head -sw '%{http_code}' https://galaxy.ansible.com/download/${{ env.collection_namespace }}-awx-${{ github.event.release.tag_name }}.tar.gz | tail -1)" == "302" ] ; then \
|
||||
echo "Galaxy release already done"; \
|
||||
else \
|
||||
curl_with_redirects=$(curl --head -sLw '%{http_code}' https://galaxy.ansible.com/download/${{ env.collection_namespace }}-awx-${{ env.TAG_NAME }}.tar.gz | tail -1)
|
||||
curl_without_redirects=$(curl --head -sw '%{http_code}' https://galaxy.ansible.com/download/${{ env.collection_namespace }}-awx-${{ env.TAG_NAME }}.tar.gz | tail -1)
|
||||
if [[ "$curl_with_redirects" == "302" ]] || [[ "$curl_without_redirects" == "302" ]]; then
|
||||
echo "Galaxy release already done";
|
||||
else
|
||||
ansible-galaxy collection publish \
|
||||
--token=${{ secrets.GALAXY_TOKEN }} \
|
||||
awx_collection_build/${{ env.collection_namespace }}-awx-${{ github.event.release.tag_name }}.tar.gz; \
|
||||
awx_collection_build/${{ env.collection_namespace }}-awx-${{ env.TAG_NAME }}.tar.gz;
|
||||
fi
|
||||
|
||||
- name: Set official pypi info
|
||||
@@ -64,6 +80,8 @@ jobs:
|
||||
if: ${{ github.repository_owner != 'ansible' }}
|
||||
|
||||
- name: Build awxkit and upload to pypi
|
||||
env:
|
||||
SETUPTOOLS_SCM_PRETEND_VERSION: ${{ env.TAG_NAME }}
|
||||
run: |
|
||||
git reset --hard
|
||||
cd awxkit && python3 setup.py sdist bdist_wheel
|
||||
@@ -84,14 +102,14 @@ jobs:
|
||||
- name: Re-tag and promote awx image
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
ghcr.io/${{ github.repository }}:${{ github.event.release.tag_name }} \
|
||||
--tag quay.io/${{ github.repository }}:${{ github.event.release.tag_name }}
|
||||
ghcr.io/${{ github.repository }}:${{ env.TAG_NAME }} \
|
||||
--tag quay.io/${{ github.repository }}:${{ env.TAG_NAME }}
|
||||
docker buildx imagetools create \
|
||||
ghcr.io/${{ github.repository }}:${{ github.event.release.tag_name }} \
|
||||
ghcr.io/${{ github.repository }}:${{ env.TAG_NAME }} \
|
||||
--tag quay.io/${{ github.repository }}:latest
|
||||
|
||||
- name: Re-tag and promote awx-ee image
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
ghcr.io/${{ github.repository_owner }}/awx-ee:${{ github.event.release.tag_name }} \
|
||||
--tag quay.io/${{ github.repository_owner }}/awx-ee:${{ github.event.release.tag_name }}
|
||||
ghcr.io/${{ github.repository_owner }}/awx-ee:${{ env.TAG_NAME }} \
|
||||
--tag quay.io/${{ github.repository_owner }}/awx-ee:${{ env.TAG_NAME }}
|
||||
|
||||
@@ -80,7 +80,7 @@ If any of those items are missing your pull request will still get the `needs_tr
|
||||
Currently you can expect awxbot to add common labels such as `state:needs_triage`, `type:bug`, `component:docs`, etc...
|
||||
These labels are determined by the template data. Please use the template and fill it out as accurately as possible.
|
||||
|
||||
The `state:needs_triage` label will will remain on your pull request until a person has looked at it.
|
||||
The `state:needs_triage` label will remain on your pull request until a person has looked at it.
|
||||
|
||||
You can also expect the bot to CC maintainers of specific areas of the code, this will notify them that there is a pull request by placing a comment on the pull request.
|
||||
The comment will look something like `CC @matburt @wwitzel3 ...`.
|
||||
|
||||
2
Makefile
2
Makefile
@@ -616,7 +616,7 @@ docker-clean:
|
||||
-$(foreach image_id,$(shell docker images --filter=reference='*/*/*awx_devel*' --filter=reference='*/*awx_devel*' --filter=reference='*awx_devel*' -aq),docker rmi --force $(image_id);)
|
||||
|
||||
docker-clean-volumes: docker-compose-clean docker-compose-container-group-clean
|
||||
docker volume rm -f tools_var_lib_awx tools_awx_db tools_vault_1 tools_ldap_1 tools_grafana_storage tools_prometheus_storage $(docker volume ls --filter name=tools_redis_socket_ -q)
|
||||
docker volume rm -f tools_var_lib_awx tools_awx_db tools_awx_db_15 tools_vault_1 tools_ldap_1 tools_grafana_storage tools_prometheus_storage $(shell docker volume ls --filter name=tools_redis_socket_ -q)
|
||||
|
||||
docker-refresh: docker-clean docker-compose
|
||||
|
||||
|
||||
@@ -30,11 +30,15 @@ from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.renderers import StaticHTMLRenderer
|
||||
from rest_framework.negotiation import DefaultContentNegotiation
|
||||
|
||||
# django-ansible-base
|
||||
from ansible_base.rest_filters.rest_framework.field_lookup_backend import FieldLookupBackend
|
||||
from ansible_base.lib.utils.models import get_all_field_names
|
||||
from ansible_base.rbac.models import RoleEvaluation, RoleDefinition
|
||||
from ansible_base.rbac.permission_registry import permission_registry
|
||||
|
||||
# AWX
|
||||
from awx.main.models import UnifiedJob, UnifiedJobTemplate, User, Role, Credential, WorkflowJobTemplateNode, WorkflowApprovalTemplate
|
||||
from awx.main.models.rbac import give_creator_permissions
|
||||
from awx.main.access import optimize_queryset
|
||||
from awx.main.utils import camelcase_to_underscore, get_search_fields, getattrd, get_object_or_400, decrypt_field, get_awx_version
|
||||
from awx.main.utils.licensing import server_product_name
|
||||
@@ -91,7 +95,9 @@ class LoggedLoginView(auth_views.LoginView):
|
||||
ret = super(LoggedLoginView, self).post(request, *args, **kwargs)
|
||||
if request.user.is_authenticated:
|
||||
logger.info(smart_str(u"User {} logged in from {}".format(self.request.user.username, request.META.get('REMOTE_ADDR', None))))
|
||||
ret.set_cookie('userLoggedIn', 'true', secure=getattr(settings, 'SESSION_COOKIE_SECURE', False))
|
||||
ret.set_cookie(
|
||||
'userLoggedIn', 'true', secure=getattr(settings, 'SESSION_COOKIE_SECURE', False), samesite=getattr(settings, 'USER_COOKIE_SAMESITE', 'Lax')
|
||||
)
|
||||
ret.setdefault('X-API-Session-Cookie-Name', getattr(settings, 'SESSION_COOKIE_NAME', 'awx_sessionid'))
|
||||
|
||||
return ret
|
||||
@@ -472,7 +478,11 @@ class ListAPIView(generics.ListAPIView, GenericAPIView):
|
||||
|
||||
class ListCreateAPIView(ListAPIView, generics.ListCreateAPIView):
|
||||
# Base class for a list view that allows creating new objects.
|
||||
pass
|
||||
def perform_create(self, serializer):
|
||||
super().perform_create(serializer)
|
||||
if serializer.Meta.model in permission_registry.all_registered_models:
|
||||
if self.request and self.request.user:
|
||||
give_creator_permissions(self.request.user, serializer.instance)
|
||||
|
||||
|
||||
class ParentMixin(object):
|
||||
@@ -792,6 +802,7 @@ class RetrieveUpdateDestroyAPIView(RetrieveUpdateAPIView, DestroyAPIView):
|
||||
|
||||
|
||||
class ResourceAccessList(ParentMixin, ListAPIView):
|
||||
deprecated = True
|
||||
serializer_class = ResourceAccessListElementSerializer
|
||||
ordering = ('username',)
|
||||
|
||||
@@ -799,6 +810,15 @@ class ResourceAccessList(ParentMixin, ListAPIView):
|
||||
obj = self.get_parent_object()
|
||||
|
||||
content_type = ContentType.objects.get_for_model(obj)
|
||||
|
||||
if settings.ANSIBLE_BASE_ROLE_SYSTEM_ACTIVATED:
|
||||
ancestors = set(RoleEvaluation.objects.filter(content_type_id=content_type.id, object_id=obj.id).values_list('role_id', flat=True))
|
||||
qs = User.objects.filter(has_roles__in=ancestors) | User.objects.filter(is_superuser=True)
|
||||
auditor_role = RoleDefinition.objects.filter(name="System Auditor").first()
|
||||
if auditor_role:
|
||||
qs |= User.objects.filter(role_assignments__role_definition=auditor_role)
|
||||
return qs.distinct()
|
||||
|
||||
roles = set(Role.objects.filter(content_type=content_type, object_id=obj.id))
|
||||
|
||||
ancestors = set()
|
||||
@@ -958,7 +978,7 @@ class CopyAPIView(GenericAPIView):
|
||||
None, None, self.model, obj, request.user, create_kwargs=create_kwargs, copy_name=serializer.validated_data.get('name', '')
|
||||
)
|
||||
if hasattr(new_obj, 'admin_role') and request.user not in new_obj.admin_role.members.all():
|
||||
new_obj.admin_role.members.add(request.user)
|
||||
give_creator_permissions(request.user, new_obj)
|
||||
if sub_objs:
|
||||
permission_check_func = None
|
||||
if hasattr(type(self), 'deep_copy_permission_check_func'):
|
||||
|
||||
@@ -43,11 +43,14 @@ from rest_framework.utils.serializer_helpers import ReturnList
|
||||
# Django-Polymorphic
|
||||
from polymorphic.models import PolymorphicModel
|
||||
|
||||
# django-ansible-base
|
||||
from ansible_base.lib.utils.models import get_type_for_model
|
||||
from ansible_base.rbac.models import RoleEvaluation, ObjectRole
|
||||
from ansible_base.rbac import permission_registry
|
||||
|
||||
# AWX
|
||||
from awx.main.access import get_user_capabilities
|
||||
from awx.main.constants import ACTIVE_STATES, CENSOR_VALUE
|
||||
from awx.main.constants import ACTIVE_STATES, CENSOR_VALUE, org_role_to_permission
|
||||
from awx.main.models import (
|
||||
ActivityStream,
|
||||
AdHocCommand,
|
||||
@@ -102,7 +105,7 @@ from awx.main.models import (
|
||||
CLOUD_INVENTORY_SOURCES,
|
||||
)
|
||||
from awx.main.models.base import VERBOSITY_CHOICES, NEW_JOB_TYPE_CHOICES
|
||||
from awx.main.models.rbac import role_summary_fields_generator, RoleAncestorEntry
|
||||
from awx.main.models.rbac import role_summary_fields_generator, give_creator_permissions, get_role_codenames, to_permissions, get_role_from_object_role
|
||||
from awx.main.fields import ImplicitRoleField
|
||||
from awx.main.utils import (
|
||||
get_model_for_type,
|
||||
@@ -2763,13 +2766,26 @@ class ResourceAccessListElementSerializer(UserSerializer):
|
||||
team_content_type = ContentType.objects.get_for_model(Team)
|
||||
content_type = ContentType.objects.get_for_model(obj)
|
||||
|
||||
def get_roles_on_resource(parent_role):
|
||||
"Returns a string list of the roles a parent_role has for current obj."
|
||||
return list(
|
||||
RoleAncestorEntry.objects.filter(ancestor=parent_role, content_type_id=content_type.id, object_id=obj.id)
|
||||
.values_list('role_field', flat=True)
|
||||
.distinct()
|
||||
)
|
||||
reversed_org_map = {}
|
||||
for k, v in org_role_to_permission.items():
|
||||
reversed_org_map[v] = k
|
||||
reversed_role_map = {}
|
||||
for k, v in to_permissions.items():
|
||||
reversed_role_map[v] = k
|
||||
|
||||
def get_roles_from_perms(perm_list):
|
||||
"""given a list of permission codenames return a list of role names"""
|
||||
role_names = set()
|
||||
for codename in perm_list:
|
||||
action = codename.split('_', 1)[0]
|
||||
if action in reversed_role_map:
|
||||
role_names.add(reversed_role_map[action])
|
||||
elif codename in reversed_org_map:
|
||||
if isinstance(obj, Organization):
|
||||
role_names.add(reversed_org_map[codename])
|
||||
if 'view_organization' not in role_names:
|
||||
role_names.add('read_role')
|
||||
return list(role_names)
|
||||
|
||||
def format_role_perm(role):
|
||||
role_dict = {'id': role.id, 'name': role.name, 'description': role.description}
|
||||
@@ -2786,13 +2802,21 @@ class ResourceAccessListElementSerializer(UserSerializer):
|
||||
else:
|
||||
# Singleton roles should not be managed from this view, as per copy/edit rework spec
|
||||
role_dict['user_capabilities'] = {'unattach': False}
|
||||
return {'role': role_dict, 'descendant_roles': get_roles_on_resource(role)}
|
||||
|
||||
model_name = content_type.model
|
||||
if isinstance(obj, Organization):
|
||||
descendant_perms = [codename for codename in get_role_codenames(role) if codename.endswith(model_name) or codename.startswith('add_')]
|
||||
else:
|
||||
descendant_perms = [codename for codename in get_role_codenames(role) if codename.endswith(model_name)]
|
||||
|
||||
return {'role': role_dict, 'descendant_roles': get_roles_from_perms(descendant_perms)}
|
||||
|
||||
def format_team_role_perm(naive_team_role, permissive_role_ids):
|
||||
ret = []
|
||||
team = naive_team_role.content_object
|
||||
team_role = naive_team_role
|
||||
if naive_team_role.role_field == 'admin_role':
|
||||
team_role = naive_team_role.content_object.member_role
|
||||
team_role = team.member_role
|
||||
for role in team_role.children.filter(id__in=permissive_role_ids).all():
|
||||
role_dict = {
|
||||
'id': role.id,
|
||||
@@ -2812,10 +2836,87 @@ class ResourceAccessListElementSerializer(UserSerializer):
|
||||
else:
|
||||
# Singleton roles should not be managed from this view, as per copy/edit rework spec
|
||||
role_dict['user_capabilities'] = {'unattach': False}
|
||||
ret.append({'role': role_dict, 'descendant_roles': get_roles_on_resource(team_role)})
|
||||
|
||||
descendant_perms = list(
|
||||
RoleEvaluation.objects.filter(role__in=team.has_roles.all(), object_id=obj.id, content_type_id=content_type.id)
|
||||
.values_list('codename', flat=True)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
ret.append({'role': role_dict, 'descendant_roles': get_roles_from_perms(descendant_perms)})
|
||||
return ret
|
||||
|
||||
gfk_kwargs = dict(content_type_id=content_type.id, object_id=obj.id)
|
||||
direct_permissive_role_ids = Role.objects.filter(**gfk_kwargs).values_list('id', flat=True)
|
||||
|
||||
if settings.ANSIBLE_BASE_ROLE_SYSTEM_ACTIVATED:
|
||||
ret['summary_fields']['direct_access'] = []
|
||||
ret['summary_fields']['indirect_access'] = []
|
||||
|
||||
new_roles_seen = set()
|
||||
all_team_roles = set()
|
||||
all_permissive_role_ids = set()
|
||||
for evaluation in RoleEvaluation.objects.filter(role__in=user.has_roles.all(), **gfk_kwargs).prefetch_related('role'):
|
||||
new_role = evaluation.role
|
||||
if new_role.id in new_roles_seen:
|
||||
continue
|
||||
new_roles_seen.add(new_role.id)
|
||||
old_role = get_role_from_object_role(new_role)
|
||||
all_permissive_role_ids.add(old_role.id)
|
||||
|
||||
if int(new_role.object_id) == obj.id and new_role.content_type_id == content_type.id:
|
||||
ret['summary_fields']['direct_access'].append(format_role_perm(old_role))
|
||||
elif new_role.content_type_id == team_content_type.id:
|
||||
all_team_roles.add(old_role)
|
||||
else:
|
||||
ret['summary_fields']['indirect_access'].append(format_role_perm(old_role))
|
||||
|
||||
# Lazy role creation gives us a big problem, where some intermediate roles are not easy to find
|
||||
# like when a team has indirect permission, so here we get all roles the users teams have
|
||||
# these contribute to all potential permission-granting roles of the object
|
||||
user_teams_qs = permission_registry.team_model.objects.filter(member_roles__in=ObjectRole.objects.filter(users=user))
|
||||
team_obj_roles = ObjectRole.objects.filter(teams__in=user_teams_qs)
|
||||
for evaluation in RoleEvaluation.objects.filter(role__in=team_obj_roles, **gfk_kwargs).prefetch_related('role'):
|
||||
new_role = evaluation.role
|
||||
if new_role.id in new_roles_seen:
|
||||
continue
|
||||
new_roles_seen.add(new_role.id)
|
||||
old_role = get_role_from_object_role(new_role)
|
||||
all_permissive_role_ids.add(old_role.id)
|
||||
|
||||
# In DAB RBAC, superuser is strictly a user flag, and global roles are not in the RoleEvaluation table
|
||||
if user.is_superuser:
|
||||
ret['summary_fields'].setdefault('indirect_access', [])
|
||||
all_role_names = [field.name for field in obj._meta.get_fields() if isinstance(field, ImplicitRoleField)]
|
||||
ret['summary_fields']['indirect_access'].append(
|
||||
{
|
||||
"role": {
|
||||
"id": None,
|
||||
"name": _("System Administrator"),
|
||||
"description": _("Can manage all aspects of the system"),
|
||||
"user_capabilities": {"unattach": False},
|
||||
},
|
||||
"descendant_roles": all_role_names,
|
||||
}
|
||||
)
|
||||
elif user.is_system_auditor:
|
||||
ret['summary_fields'].setdefault('indirect_access', [])
|
||||
ret['summary_fields']['indirect_access'].append(
|
||||
{
|
||||
"role": {
|
||||
"id": None,
|
||||
"name": _("System Auditor"),
|
||||
"description": _("Can view all aspects of the system"),
|
||||
"user_capabilities": {"unattach": False},
|
||||
},
|
||||
"descendant_roles": ["read_role"],
|
||||
}
|
||||
)
|
||||
|
||||
ret['summary_fields']['direct_access'].extend([y for x in (format_team_role_perm(r, all_permissive_role_ids) for r in all_team_roles) for y in x])
|
||||
|
||||
return ret
|
||||
|
||||
direct_permissive_role_ids = Role.objects.filter(content_type=content_type, object_id=obj.id).values_list('id', flat=True)
|
||||
all_permissive_role_ids = Role.objects.filter(content_type=content_type, object_id=obj.id).values_list('ancestors__id', flat=True)
|
||||
|
||||
direct_access_roles = user.roles.filter(id__in=direct_permissive_role_ids).all()
|
||||
@@ -3084,7 +3185,7 @@ class CredentialSerializerCreate(CredentialSerializer):
|
||||
credential = super(CredentialSerializerCreate, self).create(validated_data)
|
||||
|
||||
if user:
|
||||
credential.admin_role.members.add(user)
|
||||
give_creator_permissions(user, credential)
|
||||
if team:
|
||||
if not credential.organization or team.organization.id != credential.organization.id:
|
||||
raise serializers.ValidationError({"detail": _("Credential organization must be set and match before assigning to a team")})
|
||||
|
||||
@@ -29,9 +29,7 @@ def reverse(viewname, args=None, kwargs=None, request=None, format=None, **extra
|
||||
kwargs = {}
|
||||
if 'version' not in kwargs:
|
||||
kwargs['version'] = settings.REST_FRAMEWORK['DEFAULT_VERSION']
|
||||
url = drf_reverse(viewname, args, kwargs, request, format, **extra)
|
||||
|
||||
return transform_optional_api_urlpattern_prefix_url(request, url)
|
||||
return drf_reverse(viewname, args, kwargs, request, format, **extra)
|
||||
|
||||
|
||||
class URLPathVersioning(BaseVersioning):
|
||||
|
||||
@@ -60,6 +60,9 @@ from oauth2_provider.models import get_access_token_model
|
||||
import pytz
|
||||
from wsgiref.util import FileWrapper
|
||||
|
||||
# django-ansible-base
|
||||
from ansible_base.rbac.models import RoleEvaluation, ObjectRole
|
||||
|
||||
# AWX
|
||||
from awx.main.tasks.system import send_notifications, update_inventory_computed_fields
|
||||
from awx.main.access import get_user_queryset
|
||||
@@ -87,6 +90,7 @@ from awx.api.generics import (
|
||||
from awx.api.views.labels import LabelSubListCreateAttachDetachView
|
||||
from awx.api.versioning import reverse
|
||||
from awx.main import models
|
||||
from awx.main.models.rbac import get_role_definition
|
||||
from awx.main.utils import (
|
||||
camelcase_to_underscore,
|
||||
extract_ansible_vars,
|
||||
@@ -536,6 +540,7 @@ class InstanceGroupAccessList(ResourceAccessList):
|
||||
|
||||
|
||||
class InstanceGroupObjectRolesList(SubListAPIView):
|
||||
deprecated = True
|
||||
model = models.Role
|
||||
serializer_class = serializers.RoleSerializer
|
||||
parent_model = models.InstanceGroup
|
||||
@@ -724,6 +729,7 @@ class TeamUsersList(BaseUsersList):
|
||||
|
||||
|
||||
class TeamRolesList(SubListAttachDetachAPIView):
|
||||
deprecated = True
|
||||
model = models.Role
|
||||
serializer_class = serializers.RoleSerializerWithParentAccess
|
||||
metadata_class = RoleMetadata
|
||||
@@ -763,10 +769,12 @@ class TeamRolesList(SubListAttachDetachAPIView):
|
||||
|
||||
|
||||
class TeamObjectRolesList(SubListAPIView):
|
||||
deprecated = True
|
||||
model = models.Role
|
||||
serializer_class = serializers.RoleSerializer
|
||||
parent_model = models.Team
|
||||
search_fields = ('role_field', 'content_type__model')
|
||||
deprecated = True
|
||||
|
||||
def get_queryset(self):
|
||||
po = self.get_parent_object()
|
||||
@@ -784,8 +792,15 @@ class TeamProjectsList(SubListAPIView):
|
||||
self.check_parent_access(team)
|
||||
model_ct = ContentType.objects.get_for_model(self.model)
|
||||
parent_ct = ContentType.objects.get_for_model(self.parent_model)
|
||||
proj_roles = models.Role.objects.filter(Q(ancestors__content_type=parent_ct) & Q(ancestors__object_id=team.pk), content_type=model_ct)
|
||||
return self.model.accessible_objects(self.request.user, 'read_role').filter(pk__in=[t.content_object.pk for t in proj_roles])
|
||||
|
||||
rd = get_role_definition(team.member_role)
|
||||
role = ObjectRole.objects.filter(object_id=team.id, content_type=parent_ct, role_definition=rd).first()
|
||||
if role is None:
|
||||
# Team has no permissions, therefore team has no projects
|
||||
return self.model.objects.none()
|
||||
else:
|
||||
project_qs = self.model.accessible_objects(self.request.user, 'read_role')
|
||||
return project_qs.filter(id__in=RoleEvaluation.objects.filter(content_type_id=model_ct.id, role=role).values_list('object_id'))
|
||||
|
||||
|
||||
class TeamActivityStreamList(SubListAPIView):
|
||||
@@ -800,10 +815,23 @@ class TeamActivityStreamList(SubListAPIView):
|
||||
self.check_parent_access(parent)
|
||||
|
||||
qs = self.request.user.get_queryset(self.model)
|
||||
|
||||
return qs.filter(
|
||||
Q(team=parent)
|
||||
| Q(project__in=models.Project.accessible_objects(parent.member_role, 'read_role'))
|
||||
| Q(credential__in=models.Credential.accessible_objects(parent.member_role, 'read_role'))
|
||||
| Q(
|
||||
project__in=RoleEvaluation.objects.filter(
|
||||
role__in=parent.has_roles.all(), content_type_id=ContentType.objects.get_for_model(models.Project).id, codename='view_project'
|
||||
)
|
||||
.values_list('object_id')
|
||||
.distinct()
|
||||
)
|
||||
| Q(
|
||||
credential__in=RoleEvaluation.objects.filter(
|
||||
role__in=parent.has_roles.all(), content_type_id=ContentType.objects.get_for_model(models.Credential).id, codename='view_credential'
|
||||
)
|
||||
.values_list('object_id')
|
||||
.distinct()
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -1055,10 +1083,12 @@ class ProjectAccessList(ResourceAccessList):
|
||||
|
||||
|
||||
class ProjectObjectRolesList(SubListAPIView):
|
||||
deprecated = True
|
||||
model = models.Role
|
||||
serializer_class = serializers.RoleSerializer
|
||||
parent_model = models.Project
|
||||
search_fields = ('role_field', 'content_type__model')
|
||||
deprecated = True
|
||||
|
||||
def get_queryset(self):
|
||||
po = self.get_parent_object()
|
||||
@@ -1216,6 +1246,7 @@ class UserTeamsList(SubListAPIView):
|
||||
|
||||
|
||||
class UserRolesList(SubListAttachDetachAPIView):
|
||||
deprecated = True
|
||||
model = models.Role
|
||||
serializer_class = serializers.RoleSerializerWithParentAccess
|
||||
metadata_class = RoleMetadata
|
||||
@@ -1490,10 +1521,12 @@ class CredentialAccessList(ResourceAccessList):
|
||||
|
||||
|
||||
class CredentialObjectRolesList(SubListAPIView):
|
||||
deprecated = True
|
||||
model = models.Role
|
||||
serializer_class = serializers.RoleSerializer
|
||||
parent_model = models.Credential
|
||||
search_fields = ('role_field', 'content_type__model')
|
||||
deprecated = True
|
||||
|
||||
def get_queryset(self):
|
||||
po = self.get_parent_object()
|
||||
@@ -2280,13 +2313,6 @@ class JobTemplateList(ListCreateAPIView):
|
||||
serializer_class = serializers.JobTemplateSerializer
|
||||
always_allow_superuser = False
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
ret = super(JobTemplateList, self).post(request, *args, **kwargs)
|
||||
if ret.status_code == 201:
|
||||
job_template = models.JobTemplate.objects.get(id=ret.data['id'])
|
||||
job_template.admin_role.members.add(request.user)
|
||||
return ret
|
||||
|
||||
|
||||
class JobTemplateDetail(RelatedJobsPreventDeleteMixin, RetrieveUpdateDestroyAPIView):
|
||||
model = models.JobTemplate
|
||||
@@ -2832,10 +2858,12 @@ class JobTemplateAccessList(ResourceAccessList):
|
||||
|
||||
|
||||
class JobTemplateObjectRolesList(SubListAPIView):
|
||||
deprecated = True
|
||||
model = models.Role
|
||||
serializer_class = serializers.RoleSerializer
|
||||
parent_model = models.JobTemplate
|
||||
search_fields = ('role_field', 'content_type__model')
|
||||
deprecated = True
|
||||
|
||||
def get_queryset(self):
|
||||
po = self.get_parent_object()
|
||||
@@ -3218,10 +3246,12 @@ class WorkflowJobTemplateAccessList(ResourceAccessList):
|
||||
|
||||
|
||||
class WorkflowJobTemplateObjectRolesList(SubListAPIView):
|
||||
deprecated = True
|
||||
model = models.Role
|
||||
serializer_class = serializers.RoleSerializer
|
||||
parent_model = models.WorkflowJobTemplate
|
||||
search_fields = ('role_field', 'content_type__model')
|
||||
deprecated = True
|
||||
|
||||
def get_queryset(self):
|
||||
po = self.get_parent_object()
|
||||
@@ -4230,6 +4260,7 @@ class ActivityStreamDetail(RetrieveAPIView):
|
||||
|
||||
|
||||
class RoleList(ListAPIView):
|
||||
deprecated = True
|
||||
model = models.Role
|
||||
serializer_class = serializers.RoleSerializer
|
||||
permission_classes = (IsAuthenticated,)
|
||||
@@ -4237,11 +4268,13 @@ class RoleList(ListAPIView):
|
||||
|
||||
|
||||
class RoleDetail(RetrieveAPIView):
|
||||
deprecated = True
|
||||
model = models.Role
|
||||
serializer_class = serializers.RoleSerializer
|
||||
|
||||
|
||||
class RoleUsersList(SubListAttachDetachAPIView):
|
||||
deprecated = True
|
||||
model = models.User
|
||||
serializer_class = serializers.UserSerializer
|
||||
parent_model = models.Role
|
||||
@@ -4276,6 +4309,7 @@ class RoleUsersList(SubListAttachDetachAPIView):
|
||||
|
||||
|
||||
class RoleTeamsList(SubListAttachDetachAPIView):
|
||||
deprecated = True
|
||||
model = models.Team
|
||||
serializer_class = serializers.TeamSerializer
|
||||
parent_model = models.Role
|
||||
@@ -4320,10 +4354,12 @@ class RoleTeamsList(SubListAttachDetachAPIView):
|
||||
team.member_role.children.remove(role)
|
||||
else:
|
||||
team.member_role.children.add(role)
|
||||
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class RoleParentsList(SubListAPIView):
|
||||
deprecated = True
|
||||
model = models.Role
|
||||
serializer_class = serializers.RoleSerializer
|
||||
parent_model = models.Role
|
||||
@@ -4337,6 +4373,7 @@ class RoleParentsList(SubListAPIView):
|
||||
|
||||
|
||||
class RoleChildrenList(SubListAPIView):
|
||||
deprecated = True
|
||||
model = models.Role
|
||||
serializer_class = serializers.RoleSerializer
|
||||
parent_model = models.Role
|
||||
|
||||
@@ -152,6 +152,7 @@ class InventoryObjectRolesList(SubListAPIView):
|
||||
serializer_class = RoleSerializer
|
||||
parent_model = Inventory
|
||||
search_fields = ('role_field', 'content_type__model')
|
||||
deprecated = True
|
||||
|
||||
def get_queryset(self):
|
||||
po = self.get_parent_object()
|
||||
|
||||
@@ -226,6 +226,7 @@ class OrganizationObjectRolesList(SubListAPIView):
|
||||
serializer_class = RoleSerializer
|
||||
parent_model = Organization
|
||||
search_fields = ('role_field', 'content_type__model')
|
||||
deprecated = True
|
||||
|
||||
def get_queryset(self):
|
||||
po = self.get_parent_object()
|
||||
|
||||
@@ -132,6 +132,9 @@ class ApiVersionRootView(APIView):
|
||||
data['bulk'] = reverse('api:bulk', request=request)
|
||||
data['analytics'] = reverse('api:analytics_root_view', request=request)
|
||||
data['service_index'] = django_reverse('service-index-root')
|
||||
data['role_definitions'] = django_reverse('roledefinition-list')
|
||||
data['role_user_assignments'] = django_reverse('roleuserassignment-list')
|
||||
data['role_team_assignments'] = django_reverse('roleteamassignment-list')
|
||||
return Response(data)
|
||||
|
||||
|
||||
|
||||
@@ -61,6 +61,10 @@ class StringListBooleanField(ListField):
|
||||
|
||||
def to_representation(self, value):
|
||||
try:
|
||||
if isinstance(value, str):
|
||||
# https://github.com/encode/django-rest-framework/commit/a180bde0fd965915718b070932418cabc831cee1
|
||||
# DRF changed truthy and falsy lists to be capitalized
|
||||
value = value.lower()
|
||||
if isinstance(value, (list, tuple)):
|
||||
return super(StringListBooleanField, self).to_representation(value)
|
||||
elif value in BooleanField.TRUE_VALUES:
|
||||
@@ -78,6 +82,8 @@ class StringListBooleanField(ListField):
|
||||
|
||||
def to_internal_value(self, data):
|
||||
try:
|
||||
if isinstance(data, str):
|
||||
data = data.lower()
|
||||
if isinstance(data, (list, tuple)):
|
||||
return super(StringListBooleanField, self).to_internal_value(data)
|
||||
elif data in BooleanField.TRUE_VALUES:
|
||||
|
||||
@@ -20,7 +20,10 @@ from rest_framework.exceptions import ParseError, PermissionDenied
|
||||
# Django OAuth Toolkit
|
||||
from awx.main.models.oauth import OAuth2Application, OAuth2AccessToken
|
||||
|
||||
# django-ansible-base
|
||||
from ansible_base.lib.utils.validation import to_python_boolean
|
||||
from ansible_base.rbac.models import RoleEvaluation
|
||||
from ansible_base.rbac import permission_registry
|
||||
|
||||
# AWX
|
||||
from awx.main.utils import (
|
||||
@@ -72,8 +75,6 @@ from awx.main.models import (
|
||||
WorkflowJobTemplateNode,
|
||||
WorkflowApproval,
|
||||
WorkflowApprovalTemplate,
|
||||
ROLE_SINGLETON_SYSTEM_ADMINISTRATOR,
|
||||
ROLE_SINGLETON_SYSTEM_AUDITOR,
|
||||
)
|
||||
from awx.main.models.mixins import ResourceMixin
|
||||
|
||||
@@ -264,7 +265,11 @@ class BaseAccess(object):
|
||||
return self.can_change(obj, data)
|
||||
|
||||
def can_delete(self, obj):
|
||||
return self.user.is_superuser
|
||||
if self.user.is_superuser:
|
||||
return True
|
||||
if obj._meta.model_name in [cls._meta.model_name for cls in permission_registry.all_registered_models]:
|
||||
return self.user.has_obj_perm(obj, 'delete')
|
||||
return False
|
||||
|
||||
def can_copy(self, obj):
|
||||
return self.can_add({'reference_obj': obj})
|
||||
@@ -651,9 +656,7 @@ class UserAccess(BaseAccess):
|
||||
qs = (
|
||||
User.objects.filter(pk__in=Organization.accessible_objects(self.user, 'read_role').values('member_role__members'))
|
||||
| User.objects.filter(pk=self.user.id)
|
||||
| User.objects.filter(
|
||||
pk__in=Role.objects.filter(singleton_name__in=[ROLE_SINGLETON_SYSTEM_ADMINISTRATOR, ROLE_SINGLETON_SYSTEM_AUDITOR]).values('members')
|
||||
)
|
||||
| User.objects.filter(is_superuser=True)
|
||||
).distinct()
|
||||
return qs
|
||||
|
||||
@@ -711,6 +714,15 @@ class UserAccess(BaseAccess):
|
||||
if not allow_orphans:
|
||||
# in these cases only superusers can modify orphan users
|
||||
return False
|
||||
if settings.ANSIBLE_BASE_ROLE_SYSTEM_ACTIVATED:
|
||||
# Permission granted if the user has all permissions that the target user has
|
||||
target_perms = set(
|
||||
RoleEvaluation.objects.filter(role__in=obj.has_roles.all()).values_list('object_id', 'content_type_id', 'codename').distinct()
|
||||
)
|
||||
user_perms = set(
|
||||
RoleEvaluation.objects.filter(role__in=self.user.has_roles.all()).values_list('object_id', 'content_type_id', 'codename').distinct()
|
||||
)
|
||||
return not (target_perms - user_perms)
|
||||
return not obj.roles.all().exclude(ancestors__in=self.user.roles.all()).exists()
|
||||
else:
|
||||
return self.is_all_org_admin(obj)
|
||||
@@ -949,9 +961,6 @@ class InventoryAccess(BaseAccess):
|
||||
def can_update(self, obj):
|
||||
return self.user in obj.update_role
|
||||
|
||||
def can_delete(self, obj):
|
||||
return self.can_admin(obj, None)
|
||||
|
||||
def can_run_ad_hoc_commands(self, obj):
|
||||
return self.user in obj.adhoc_role
|
||||
|
||||
@@ -1405,8 +1414,12 @@ class ExecutionEnvironmentAccess(BaseAccess):
|
||||
def can_change(self, obj, data):
|
||||
if obj and obj.organization_id is None:
|
||||
raise PermissionDenied
|
||||
if self.user not in obj.organization.execution_environment_admin_role:
|
||||
raise PermissionDenied
|
||||
if settings.ANSIBLE_BASE_ROLE_SYSTEM_ACTIVATED:
|
||||
if not self.user.has_obj_perm(obj, 'change'):
|
||||
raise PermissionDenied
|
||||
else:
|
||||
if self.user not in obj.organization.execution_environment_admin_role:
|
||||
raise PermissionDenied
|
||||
if data and 'organization' in data:
|
||||
new_org = get_object_from_data('organization', Organization, data, obj=obj)
|
||||
if not new_org or self.user not in new_org.execution_environment_admin_role:
|
||||
@@ -2592,6 +2605,8 @@ class ScheduleAccess(UnifiedCredentialsMixin, BaseAccess):
|
||||
if not JobLaunchConfigAccess(self.user).can_add(data):
|
||||
return False
|
||||
if not data:
|
||||
if settings.ANSIBLE_BASE_ROLE_SYSTEM_ACTIVATED:
|
||||
return self.user.has_roles.filter(permission_partials__codename__in=['execute_jobtemplate', 'update_project', 'update_inventory']).exists()
|
||||
return Role.objects.filter(role_field__in=['update_role', 'execute_role'], ancestors__in=self.user.roles.all()).exists()
|
||||
|
||||
return self.check_related('unified_job_template', UnifiedJobTemplate, data, role_field='execute_role', mandatory=True)
|
||||
@@ -2620,6 +2635,8 @@ class NotificationTemplateAccess(BaseAccess):
|
||||
prefetch_related = ('created_by', 'modified_by', 'organization')
|
||||
|
||||
def filtered_queryset(self):
|
||||
if settings.ANSIBLE_BASE_ROLE_SYSTEM_ACTIVATED:
|
||||
return self.model.access_qs(self.user, 'view')
|
||||
return self.model.objects.filter(
|
||||
Q(organization__in=Organization.accessible_objects(self.user, 'notification_admin_role')) | Q(organization__in=self.user.auditor_of_organizations)
|
||||
).distinct()
|
||||
@@ -2788,7 +2805,7 @@ class ActivityStreamAccess(BaseAccess):
|
||||
| Q(notification_template__organization__in=auditing_orgs)
|
||||
| Q(notification__notification_template__organization__in=auditing_orgs)
|
||||
| Q(label__organization__in=auditing_orgs)
|
||||
| Q(role__in=Role.objects.filter(ancestors__in=self.user.roles.all()) if auditing_orgs else [])
|
||||
| Q(role__in=Role.visible_roles(self.user) if auditing_orgs else [])
|
||||
)
|
||||
|
||||
project_set = Project.accessible_pk_qs(self.user, 'read_role')
|
||||
@@ -2845,13 +2862,10 @@ class RoleAccess(BaseAccess):
|
||||
|
||||
def filtered_queryset(self):
|
||||
result = Role.visible_roles(self.user)
|
||||
# Sanity check: is the requesting user an orphaned non-admin/auditor?
|
||||
# if yes, make system admin/auditor mandatorily visible.
|
||||
if not self.user.is_superuser and not self.user.is_system_auditor and not self.user.organizations.exists():
|
||||
mandatories = ('system_administrator', 'system_auditor')
|
||||
super_qs = Role.objects.filter(singleton_name__in=mandatories)
|
||||
result = result | super_qs
|
||||
return result
|
||||
# Make system admin/auditor mandatorily visible.
|
||||
mandatories = ('system_administrator', 'system_auditor')
|
||||
super_qs = Role.objects.filter(singleton_name__in=mandatories)
|
||||
return result | super_qs
|
||||
|
||||
def can_add(self, obj, data):
|
||||
# Unsupported for now
|
||||
|
||||
@@ -1,7 +1,40 @@
|
||||
from django.apps import AppConfig
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from awx.main.utils.named_url_graph import _customize_graph, generate_graph
|
||||
from awx.conf import register, fields
|
||||
|
||||
|
||||
class MainConfig(AppConfig):
|
||||
name = 'awx.main'
|
||||
verbose_name = _('Main')
|
||||
|
||||
def load_named_url_feature(self):
|
||||
models = [m for m in self.get_models() if hasattr(m, 'get_absolute_url')]
|
||||
generate_graph(models)
|
||||
_customize_graph()
|
||||
register(
|
||||
'NAMED_URL_FORMATS',
|
||||
field_class=fields.DictField,
|
||||
read_only=True,
|
||||
label=_('Formats of all available named urls'),
|
||||
help_text=_('Read-only list of key-value pairs that shows the standard format of all available named URLs.'),
|
||||
category=_('Named URL'),
|
||||
category_slug='named-url',
|
||||
)
|
||||
register(
|
||||
'NAMED_URL_GRAPH_NODES',
|
||||
field_class=fields.DictField,
|
||||
read_only=True,
|
||||
label=_('List of all named url graph nodes.'),
|
||||
help_text=_(
|
||||
'Read-only list of key-value pairs that exposes named URL graph topology.'
|
||||
' Use this list to programmatically generate named URLs for resources'
|
||||
),
|
||||
category=_('Named URL'),
|
||||
category_slug='named-url',
|
||||
)
|
||||
|
||||
def ready(self):
|
||||
super().ready()
|
||||
|
||||
self.load_named_url_feature()
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import logging
|
||||
|
||||
# Django
|
||||
from django.core.checks import Error
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
# Django REST Framework
|
||||
@@ -954,3 +955,27 @@ def logging_validate(serializer, attrs):
|
||||
|
||||
|
||||
register_validate('logging', logging_validate)
|
||||
|
||||
|
||||
def csrf_trusted_origins_validate(serializer, attrs):
|
||||
if not serializer.instance or not hasattr(serializer.instance, 'CSRF_TRUSTED_ORIGINS'):
|
||||
return attrs
|
||||
if 'CSRF_TRUSTED_ORIGINS' not in attrs:
|
||||
return attrs
|
||||
errors = []
|
||||
for origin in attrs['CSRF_TRUSTED_ORIGINS']:
|
||||
if "://" not in origin:
|
||||
errors.append(
|
||||
Error(
|
||||
"As of Django 4.0, the values in the CSRF_TRUSTED_ORIGINS "
|
||||
"setting must start with a scheme (usually http:// or "
|
||||
"https://) but found %s. See the release notes for details." % origin,
|
||||
)
|
||||
)
|
||||
if errors:
|
||||
error_messages = [error.msg for error in errors]
|
||||
raise serializers.ValidationError(_('\n'.join(error_messages)))
|
||||
return attrs
|
||||
|
||||
|
||||
register_validate('system', csrf_trusted_origins_validate)
|
||||
|
||||
@@ -114,3 +114,28 @@ SUBSCRIPTION_USAGE_MODEL_UNIQUE_HOSTS = 'unique_managed_hosts'
|
||||
|
||||
# Shared prefetch to use for creating a queryset for the purpose of writing or saving facts
|
||||
HOST_FACTS_FIELDS = ('name', 'ansible_facts', 'ansible_facts_modified', 'modified', 'inventory_id')
|
||||
|
||||
# Data for RBAC compatibility layer
|
||||
role_name_to_perm_mapping = {
|
||||
'adhoc_role': ['adhoc_'],
|
||||
'approval_role': ['approve_'],
|
||||
'auditor_role': ['audit_'],
|
||||
'admin_role': ['change_', 'add_', 'delete_'],
|
||||
'execute_role': ['execute_'],
|
||||
'read_role': ['view_'],
|
||||
'update_role': ['update_'],
|
||||
'member_role': ['member_'],
|
||||
'use_role': ['use_'],
|
||||
}
|
||||
|
||||
org_role_to_permission = {
|
||||
'notification_admin_role': 'add_notificationtemplate',
|
||||
'project_admin_role': 'add_project',
|
||||
'execute_role': 'execute_jobtemplate',
|
||||
'inventory_admin_role': 'add_inventory',
|
||||
'credential_admin_role': 'add_credential',
|
||||
'workflow_admin_role': 'add_workflowjobtemplate',
|
||||
'job_template_admin_role': 'change_jobtemplate', # TODO: this doesnt really work, solution not clear
|
||||
'execution_environment_admin_role': 'add_executionenvironment',
|
||||
'auditor_role': 'view_project', # TODO: also doesnt really work
|
||||
}
|
||||
|
||||
@@ -2,10 +2,11 @@ import json
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
|
||||
from typing import Any
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.conf import settings
|
||||
|
||||
from awx.conf import settings_registry
|
||||
|
||||
|
||||
@@ -40,6 +41,15 @@ class Command(BaseCommand):
|
||||
"USER_SEARCH": False,
|
||||
}
|
||||
|
||||
def is_enabled(self, settings, keys):
|
||||
missing_fields = []
|
||||
for key, required in keys.items():
|
||||
if required and not settings.get(key):
|
||||
missing_fields.append(key)
|
||||
if missing_fields:
|
||||
return False, missing_fields
|
||||
return True, None
|
||||
|
||||
def get_awx_ldap_settings(self) -> dict[str, dict[str, Any]]:
|
||||
awx_ldap_settings = {}
|
||||
|
||||
@@ -64,15 +74,17 @@ class Command(BaseCommand):
|
||||
|
||||
if new_key == "SERVER_URI" and value:
|
||||
value = value.split(", ")
|
||||
grouped_settings[index][new_key] = value
|
||||
|
||||
if type(value).__name__ == "LDAPSearch":
|
||||
data = []
|
||||
data.append(value.base_dn)
|
||||
data.append("SCOPE_SUBTREE")
|
||||
data.append(value.filterstr)
|
||||
grouped_settings[index][new_key] = data
|
||||
|
||||
return grouped_settings
|
||||
|
||||
def is_enabled(self, settings, keys):
|
||||
for key, required in keys.items():
|
||||
if required and not settings.get(key):
|
||||
return False
|
||||
return True
|
||||
|
||||
def get_awx_saml_settings(self) -> dict[str, Any]:
|
||||
awx_saml_settings = {}
|
||||
for awx_saml_setting in settings_registry.get_registered_settings(category_slug='saml'):
|
||||
@@ -82,7 +94,7 @@ class Command(BaseCommand):
|
||||
|
||||
def format_config_data(self, enabled, awx_settings, type, keys, name):
|
||||
config = {
|
||||
"type": f"awx.authentication.authenticator_plugins.{type}",
|
||||
"type": f"ansible_base.authentication.authenticator_plugins.{type}",
|
||||
"name": name,
|
||||
"enabled": enabled,
|
||||
"create_objects": True,
|
||||
@@ -130,7 +142,7 @@ class Command(BaseCommand):
|
||||
|
||||
# dump SAML settings
|
||||
awx_saml_settings = self.get_awx_saml_settings()
|
||||
awx_saml_enabled = self.is_enabled(awx_saml_settings, self.DAB_SAML_AUTHENTICATOR_KEYS)
|
||||
awx_saml_enabled, saml_missing_fields = self.is_enabled(awx_saml_settings, self.DAB_SAML_AUTHENTICATOR_KEYS)
|
||||
if awx_saml_enabled:
|
||||
awx_saml_name = awx_saml_settings["ENABLED_IDPS"]
|
||||
data.append(
|
||||
@@ -142,21 +154,25 @@ class Command(BaseCommand):
|
||||
awx_saml_name,
|
||||
)
|
||||
)
|
||||
else:
|
||||
data.append({"SAML_missing_fields": saml_missing_fields})
|
||||
|
||||
# dump LDAP settings
|
||||
awx_ldap_group_settings = self.get_awx_ldap_settings()
|
||||
for awx_ldap_name, awx_ldap_settings in enumerate(awx_ldap_group_settings.values()):
|
||||
enabled = self.is_enabled(awx_ldap_settings, self.DAB_LDAP_AUTHENTICATOR_KEYS)
|
||||
if enabled:
|
||||
for awx_ldap_name, awx_ldap_settings in awx_ldap_group_settings.items():
|
||||
awx_ldap_enabled, ldap_missing_fields = self.is_enabled(awx_ldap_settings, self.DAB_LDAP_AUTHENTICATOR_KEYS)
|
||||
if awx_ldap_enabled:
|
||||
data.append(
|
||||
self.format_config_data(
|
||||
enabled,
|
||||
awx_ldap_enabled,
|
||||
awx_ldap_settings,
|
||||
"ldap",
|
||||
self.DAB_LDAP_AUTHENTICATOR_KEYS,
|
||||
str(awx_ldap_name),
|
||||
f"LDAP_{awx_ldap_name}",
|
||||
)
|
||||
)
|
||||
else:
|
||||
data.append({f"LDAP_{awx_ldap_name}_missing_fields": ldap_missing_fields})
|
||||
|
||||
# write to file if requested
|
||||
if options["output_file"]:
|
||||
|
||||
@@ -165,11 +165,10 @@ class Command(BaseCommand):
|
||||
return
|
||||
|
||||
WebsocketsMetricsServer().start()
|
||||
websocket_relay_manager = WebSocketRelayManager()
|
||||
|
||||
while True:
|
||||
try:
|
||||
asyncio.run(websocket_relay_manager.run())
|
||||
asyncio.run(WebSocketRelayManager().run())
|
||||
except KeyboardInterrupt:
|
||||
logger.info('Shutting down Websocket Relayer')
|
||||
break
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Copyright (c) 2015 Ansible, Inc.
|
||||
# All Rights Reserved.
|
||||
|
||||
import functools
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
@@ -9,20 +10,16 @@ from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import logout
|
||||
from django.contrib.auth.models import User
|
||||
from django.db.migrations.recorder import MigrationRecorder
|
||||
from django.db import connection
|
||||
from django.shortcuts import redirect
|
||||
from django.apps import apps
|
||||
from django.utils.deprecation import MiddlewareMixin
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.urls import reverse, resolve
|
||||
|
||||
from awx.main import migrations
|
||||
from awx.main.utils.named_url_graph import generate_graph, GraphNode
|
||||
from awx.conf import fields, register
|
||||
from awx.main.utils.profiling import AWXProfiler
|
||||
from awx.main.utils.common import memoize
|
||||
from awx.urls import get_urlpatterns
|
||||
|
||||
|
||||
logger = logging.getLogger('awx.main.middleware')
|
||||
@@ -100,49 +97,7 @@ class DisableLocalAuthMiddleware(MiddlewareMixin):
|
||||
logout(request)
|
||||
|
||||
|
||||
def _customize_graph():
|
||||
from awx.main.models import Instance, Schedule, UnifiedJobTemplate
|
||||
|
||||
for model in [Schedule, UnifiedJobTemplate]:
|
||||
if model in settings.NAMED_URL_GRAPH:
|
||||
settings.NAMED_URL_GRAPH[model].remove_bindings()
|
||||
settings.NAMED_URL_GRAPH.pop(model)
|
||||
if User not in settings.NAMED_URL_GRAPH:
|
||||
settings.NAMED_URL_GRAPH[User] = GraphNode(User, ['username'], [])
|
||||
settings.NAMED_URL_GRAPH[User].add_bindings()
|
||||
if Instance not in settings.NAMED_URL_GRAPH:
|
||||
settings.NAMED_URL_GRAPH[Instance] = GraphNode(Instance, ['hostname'], [])
|
||||
settings.NAMED_URL_GRAPH[Instance].add_bindings()
|
||||
|
||||
|
||||
class URLModificationMiddleware(MiddlewareMixin):
|
||||
def __init__(self, get_response):
|
||||
models = [m for m in apps.get_app_config('main').get_models() if hasattr(m, 'get_absolute_url')]
|
||||
generate_graph(models)
|
||||
_customize_graph()
|
||||
register(
|
||||
'NAMED_URL_FORMATS',
|
||||
field_class=fields.DictField,
|
||||
read_only=True,
|
||||
label=_('Formats of all available named urls'),
|
||||
help_text=_('Read-only list of key-value pairs that shows the standard format of all available named URLs.'),
|
||||
category=_('Named URL'),
|
||||
category_slug='named-url',
|
||||
)
|
||||
register(
|
||||
'NAMED_URL_GRAPH_NODES',
|
||||
field_class=fields.DictField,
|
||||
read_only=True,
|
||||
label=_('List of all named url graph nodes.'),
|
||||
help_text=_(
|
||||
'Read-only list of key-value pairs that exposes named URL graph topology.'
|
||||
' Use this list to programmatically generate named URLs for resources'
|
||||
),
|
||||
category=_('Named URL'),
|
||||
category_slug='named-url',
|
||||
)
|
||||
super().__init__(get_response)
|
||||
|
||||
@staticmethod
|
||||
def _hijack_for_old_jt_name(node, kwargs, named_url):
|
||||
try:
|
||||
@@ -220,3 +175,27 @@ class MigrationRanCheckMiddleware(MiddlewareMixin):
|
||||
def process_request(self, request):
|
||||
if is_migrating() and getattr(resolve(request.path), 'url_name', '') != 'migrations_notran':
|
||||
return redirect(reverse("ui:migrations_notran"))
|
||||
|
||||
|
||||
class OptionalURLPrefixPath(MiddlewareMixin):
|
||||
@functools.lru_cache
|
||||
def _url_optional(self, prefix):
|
||||
# Relavant Django code path https://github.com/django/django/blob/stable/4.2.x/django/core/handlers/base.py#L300
|
||||
#
|
||||
# resolve_request(request)
|
||||
# get_resolver(request.urlconf)
|
||||
# _get_cached_resolver(request.urlconf) <-- cached via @functools.cache
|
||||
#
|
||||
# Django will attempt to cache the value(s) of request.urlconf
|
||||
# Being hashable is a prerequisit for being cachable.
|
||||
# tuple() is hashable list() is not.
|
||||
# Hence the tuple(list()) wrap.
|
||||
return tuple(get_urlpatterns(prefix=prefix))
|
||||
|
||||
def process_request(self, request):
|
||||
prefix = settings.OPTIONAL_API_URLPATTERN_PREFIX
|
||||
|
||||
if request.path.startswith(f"/api/{prefix}"):
|
||||
request.urlconf = self._url_optional(prefix)
|
||||
else:
|
||||
request.urlconf = 'awx.urls'
|
||||
|
||||
@@ -4,7 +4,6 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('main', '0189_inbound_hop_nodes'),
|
||||
]
|
||||
|
||||
85
awx/main/migrations/0191_add_django_permissions.py
Normal file
85
awx/main/migrations/0191_add_django_permissions.py
Normal file
@@ -0,0 +1,85 @@
|
||||
# Generated by Django 4.2.6 on 2023-11-13 20:10
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('main', '0190_alter_inventorysource_source_and_more'),
|
||||
('dab_rbac', '__first__'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# Add custom permissions for all special actions, like update, use, adhoc, and so on
|
||||
migrations.AlterModelOptions(
|
||||
name='credential',
|
||||
options={'ordering': ('name',), 'permissions': [('use_credential', 'Can use credential in a job or related resource')]},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='instancegroup',
|
||||
options={'permissions': [('use_instancegroup', 'Can use instance group in a preference list of a resource')]},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='inventory',
|
||||
options={
|
||||
'ordering': ('name',),
|
||||
'permissions': [
|
||||
('use_inventory', 'Can use inventory in a job template'),
|
||||
('adhoc_inventory', 'Can run ad hoc commands'),
|
||||
('update_inventory', 'Can update inventory sources in inventory'),
|
||||
],
|
||||
'verbose_name_plural': 'inventories',
|
||||
},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='jobtemplate',
|
||||
options={'ordering': ('name',), 'permissions': [('execute_jobtemplate', 'Can run this job template')]},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='project',
|
||||
options={
|
||||
'ordering': ('id',),
|
||||
'permissions': [('update_project', 'Can run a project update'), ('use_project', 'Can use project in a job template')],
|
||||
},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='workflowjobtemplate',
|
||||
options={
|
||||
'permissions': [
|
||||
('execute_workflowjobtemplate', 'Can run this workflow job template'),
|
||||
('approve_workflowjobtemplate', 'Can approve steps in this workflow job template'),
|
||||
]
|
||||
},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='organization',
|
||||
options={
|
||||
'default_permissions': ('change', 'delete', 'view'),
|
||||
'ordering': ('name',),
|
||||
'permissions': [
|
||||
('member_organization', 'Basic participation permissions for organization'),
|
||||
('audit_organization', 'Audit everything inside the organization'),
|
||||
],
|
||||
},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='team',
|
||||
options={'ordering': ('organization__name', 'name'), 'permissions': [('member_team', 'Inherit all roles assigned to this team')]},
|
||||
),
|
||||
# Remove add default permission for a few models
|
||||
migrations.AlterModelOptions(
|
||||
name='jobtemplate',
|
||||
options={
|
||||
'default_permissions': ('change', 'delete', 'view'),
|
||||
'ordering': ('name',),
|
||||
'permissions': [('execute_jobtemplate', 'Can run this job template')],
|
||||
},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='instancegroup',
|
||||
options={
|
||||
'default_permissions': ('change', 'delete', 'view'),
|
||||
'permissions': [('use_instancegroup', 'Can use instance group in a preference list of a resource')],
|
||||
},
|
||||
),
|
||||
]
|
||||
20
awx/main/migrations/0192_custom_roles.py
Normal file
20
awx/main/migrations/0192_custom_roles.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 4.2.6 on 2023-11-21 02:06
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
from awx.main.migrations._dab_rbac import migrate_to_new_rbac, create_permissions_as_operation, setup_managed_role_definitions
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('main', '0191_add_django_permissions'),
|
||||
('dab_rbac', '__first__'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# make sure permissions and content types have been created by now
|
||||
# these normally run in a post_migrate signal but we need them for our logic
|
||||
migrations.RunPython(create_permissions_as_operation, migrations.RunPython.noop),
|
||||
migrations.RunPython(setup_managed_role_definitions, migrations.RunPython.noop),
|
||||
migrations.RunPython(migrate_to_new_rbac, migrations.RunPython.noop),
|
||||
]
|
||||
359
awx/main/migrations/_dab_rbac.py
Normal file
359
awx/main/migrations/_dab_rbac.py
Normal file
@@ -0,0 +1,359 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
from django.apps import apps as global_apps
|
||||
from django.db.models import ForeignKey
|
||||
from django.conf import settings
|
||||
from ansible_base.rbac.migrations._utils import give_permissions
|
||||
from ansible_base.rbac.management import create_dab_permissions
|
||||
|
||||
from awx.main.fields import ImplicitRoleField
|
||||
from awx.main.constants import role_name_to_perm_mapping
|
||||
|
||||
from ansible_base.rbac.permission_registry import permission_registry
|
||||
|
||||
|
||||
logger = logging.getLogger('awx.main.migrations._dab_rbac')
|
||||
|
||||
|
||||
def create_permissions_as_operation(apps, schema_editor):
|
||||
create_dab_permissions(global_apps.get_app_config("main"), apps=apps)
|
||||
|
||||
|
||||
"""
|
||||
Data structures and methods for the migration of old Role model to ObjectRole
|
||||
"""
|
||||
|
||||
system_admin = ImplicitRoleField(name='system_administrator')
|
||||
system_auditor = ImplicitRoleField(name='system_auditor')
|
||||
system_admin.model = None
|
||||
system_auditor.model = None
|
||||
|
||||
|
||||
def resolve_parent_role(f, role_path):
|
||||
"""
|
||||
Given a field and a path declared in parent_role from the field definition, like
|
||||
execute_role = ImplicitRoleField(parent_role='admin_role')
|
||||
This expects to be passed in (execute_role object, "admin_role")
|
||||
It hould return the admin_role from that object
|
||||
"""
|
||||
if role_path == 'singleton:system_administrator':
|
||||
return system_admin
|
||||
elif role_path == 'singleton:system_auditor':
|
||||
return system_auditor
|
||||
else:
|
||||
related_field = f
|
||||
current_model = f.model
|
||||
for related_field_name in role_path.split('.'):
|
||||
related_field = current_model._meta.get_field(related_field_name)
|
||||
if isinstance(related_field, ForeignKey) and not isinstance(related_field, ImplicitRoleField):
|
||||
current_model = related_field.related_model
|
||||
return related_field
|
||||
|
||||
|
||||
def build_role_map(apps):
|
||||
"""
|
||||
For the old Role model, this builds and returns dictionaries (children, parents)
|
||||
which give a global mapping of the ImplicitRoleField instances according to the graph
|
||||
"""
|
||||
models = set(apps.get_app_config('main').get_models())
|
||||
|
||||
all_fields = set()
|
||||
parents = {}
|
||||
children = {}
|
||||
|
||||
all_fields.add(system_admin)
|
||||
all_fields.add(system_auditor)
|
||||
|
||||
for cls in models:
|
||||
for f in cls._meta.get_fields():
|
||||
if isinstance(f, ImplicitRoleField):
|
||||
all_fields.add(f)
|
||||
|
||||
for f in all_fields:
|
||||
if f.parent_role is not None:
|
||||
if isinstance(f.parent_role, str):
|
||||
parent_roles = [f.parent_role]
|
||||
else:
|
||||
parent_roles = f.parent_role
|
||||
|
||||
# SPECIAL CASE: organization auditor_role is not a child of admin_role
|
||||
# this makes no practical sense and conflicts with expected managed role
|
||||
# so we put it in as a hack here
|
||||
if f.name == 'auditor_role' and f.model._meta.model_name == 'organization':
|
||||
parent_roles.append('admin_role')
|
||||
|
||||
parent_list = []
|
||||
for rel_name in parent_roles:
|
||||
parent_list.append(resolve_parent_role(f, rel_name))
|
||||
|
||||
parents[f] = parent_list
|
||||
|
||||
# build children lookup from parents lookup
|
||||
for child_field, parent_list in parents.items():
|
||||
for parent_field in parent_list:
|
||||
children.setdefault(parent_field, [])
|
||||
children[parent_field].append(child_field)
|
||||
|
||||
return (parents, children)
|
||||
|
||||
|
||||
def get_descendents(f, children_map):
|
||||
"""
|
||||
Given ImplicitRoleField F and the children mapping, returns all descendents
|
||||
of that field, as a set of other fields, including itself
|
||||
"""
|
||||
ret = {f}
|
||||
if f in children_map:
|
||||
for child_field in children_map[f]:
|
||||
ret.update(get_descendents(child_field, children_map))
|
||||
return ret
|
||||
|
||||
|
||||
def get_permissions_for_role(role_field, children_map, apps):
|
||||
Permission = apps.get_model('dab_rbac', 'DABPermission')
|
||||
ContentType = apps.get_model('contenttypes', 'ContentType')
|
||||
|
||||
perm_list = []
|
||||
for child_field in get_descendents(role_field, children_map):
|
||||
if child_field.name in role_name_to_perm_mapping:
|
||||
for perm_name in role_name_to_perm_mapping[child_field.name]:
|
||||
if perm_name == 'add_' and role_field.model._meta.model_name != 'organization':
|
||||
continue # only organizations can contain add permissions
|
||||
perm = Permission.objects.filter(content_type=ContentType.objects.get_for_model(child_field.model), codename__startswith=perm_name).first()
|
||||
if perm is not None and perm not in perm_list:
|
||||
perm_list.append(perm)
|
||||
|
||||
# special case for two models that have object roles but no organization roles in old system
|
||||
if role_field.name == 'notification_admin_role' or (role_field.name == 'admin_role' and role_field.model._meta.model_name == 'organization'):
|
||||
ct = ContentType.objects.get_for_model(apps.get_model('main', 'NotificationTemplate'))
|
||||
perm_list.extend(list(Permission.objects.filter(content_type=ct)))
|
||||
if role_field.name == 'execution_environment_admin_role' or (role_field.name == 'admin_role' and role_field.model._meta.model_name == 'organization'):
|
||||
ct = ContentType.objects.get_for_model(apps.get_model('main', 'ExecutionEnvironment'))
|
||||
perm_list.extend(list(Permission.objects.filter(content_type=ct)))
|
||||
|
||||
# more special cases for those same above special org-level roles
|
||||
if role_field.name == 'auditor_role':
|
||||
for codename in ('view_notificationtemplate', 'view_executionenvironment'):
|
||||
perm_list.append(Permission.objects.get(codename=codename))
|
||||
|
||||
return perm_list
|
||||
|
||||
|
||||
def model_class(ct, apps):
|
||||
"""
|
||||
You can not use model methods in migrations, so this duplicates
|
||||
what ContentType.model_class does, using current apps
|
||||
"""
|
||||
try:
|
||||
return apps.get_model(ct.app_label, ct.model)
|
||||
except LookupError:
|
||||
return None
|
||||
|
||||
|
||||
def migrate_to_new_rbac(apps, schema_editor):
|
||||
"""
|
||||
This method moves the assigned permissions from the old rbac.py models
|
||||
to the new RoleDefinition and ObjectRole models
|
||||
"""
|
||||
Role = apps.get_model('main', 'Role')
|
||||
RoleDefinition = apps.get_model('dab_rbac', 'RoleDefinition')
|
||||
RoleUserAssignment = apps.get_model('dab_rbac', 'RoleUserAssignment')
|
||||
Permission = apps.get_model('dab_rbac', 'DABPermission')
|
||||
|
||||
# remove add premissions that are not valid for migrations from old versions
|
||||
for perm_str in ('add_organization', 'add_jobtemplate'):
|
||||
perm = Permission.objects.filter(codename=perm_str).first()
|
||||
if perm:
|
||||
perm.delete()
|
||||
|
||||
managed_definitions = dict()
|
||||
for role_definition in RoleDefinition.objects.filter(managed=True):
|
||||
permissions = frozenset(role_definition.permissions.values_list('id', flat=True))
|
||||
managed_definitions[permissions] = role_definition
|
||||
|
||||
# Build map of old role model
|
||||
parents, children = build_role_map(apps)
|
||||
|
||||
# NOTE: this import is expected to break at some point, and then just move the data here
|
||||
from awx.main.models.rbac import role_descriptions
|
||||
|
||||
for role in Role.objects.prefetch_related('members', 'parents').iterator():
|
||||
if role.singleton_name:
|
||||
continue # only bothering to migrate object roles
|
||||
|
||||
team_roles = []
|
||||
for parent in role.parents.all():
|
||||
if parent.id not in json.loads(role.implicit_parents):
|
||||
team_roles.append(parent)
|
||||
|
||||
# we will not create any roles that do not have any users or teams
|
||||
if not (role.members.all() or team_roles):
|
||||
logger.debug(f'Skipping role {role.role_field} for {role.content_type.model}-{role.object_id} due to no members')
|
||||
continue
|
||||
|
||||
# get a list of permissions that the old role would grant
|
||||
object_cls = apps.get_model(f'main.{role.content_type.model}')
|
||||
object = object_cls.objects.get(pk=role.object_id) # WORKAROUND, role.content_object does not work in migrations
|
||||
f = object._meta.get_field(role.role_field) # should be ImplicitRoleField
|
||||
perm_list = get_permissions_for_role(f, children, apps)
|
||||
|
||||
permissions = frozenset(perm.id for perm in perm_list)
|
||||
|
||||
# With the needed permissions established, obtain the RoleDefinition this will need, priorities:
|
||||
# 1. If it exists as a managed RoleDefinition then obviously use that
|
||||
# 2. If we already created this for a prior role, use that
|
||||
# 3. Create a new RoleDefinition that lists those permissions
|
||||
if permissions in managed_definitions:
|
||||
role_definition = managed_definitions[permissions]
|
||||
else:
|
||||
action = role.role_field.rsplit('_', 1)[0] # remove the _field ending of the name
|
||||
role_definition_name = f'{model_class(role.content_type, apps).__name__} {action.title()}'
|
||||
|
||||
description = role_descriptions[role.role_field]
|
||||
if type(description) == dict:
|
||||
if role.content_type.model in description:
|
||||
description = description.get(role.content_type.model)
|
||||
else:
|
||||
description = description.get('default')
|
||||
if '%s' in description:
|
||||
description = description % role.content_type.model
|
||||
|
||||
role_definition, created = RoleDefinition.objects.get_or_create(
|
||||
name=role_definition_name,
|
||||
defaults={'description': description, 'content_type_id': role.content_type_id},
|
||||
)
|
||||
|
||||
if created:
|
||||
logger.info(f'Created custom Role Definition {role_definition_name}, pk={role_definition.pk}')
|
||||
role_definition.permissions.set(perm_list)
|
||||
|
||||
# Create the object role and add users to it
|
||||
give_permissions(
|
||||
apps,
|
||||
role_definition,
|
||||
users=role.members.all(),
|
||||
teams=[tr.object_id for tr in team_roles],
|
||||
object_id=role.object_id,
|
||||
content_type_id=role.content_type_id,
|
||||
)
|
||||
|
||||
# Create new replacement system auditor role
|
||||
new_system_auditor, created = RoleDefinition.objects.get_or_create(
|
||||
name='System Auditor',
|
||||
defaults={'description': 'Migrated singleton role giving read permission to everything', 'managed': True},
|
||||
)
|
||||
new_system_auditor.permissions.add(*list(Permission.objects.filter(codename__startswith='view')))
|
||||
|
||||
# migrate is_system_auditor flag, because it is no longer handled by a system role
|
||||
old_system_auditor = Role.objects.filter(singleton_name='system_auditor').first()
|
||||
if old_system_auditor:
|
||||
# if the system auditor role is not present, this is a new install and no users should exist
|
||||
ct = 0
|
||||
for user in role.members.all():
|
||||
RoleUserAssignment.objects.create(user=user, role_definition=new_system_auditor)
|
||||
ct += 1
|
||||
if ct:
|
||||
logger.info(f'Migrated {ct} users to new system auditor flag')
|
||||
|
||||
|
||||
def get_or_create_managed(name, description, ct, permissions, RoleDefinition):
|
||||
role_definition, created = RoleDefinition.objects.get_or_create(name=name, defaults={'managed': True, 'description': description, 'content_type': ct})
|
||||
role_definition.permissions.set(list(permissions))
|
||||
|
||||
if not role_definition.managed:
|
||||
role_definition.managed = True
|
||||
role_definition.save(update_fields=['managed'])
|
||||
|
||||
if created:
|
||||
logger.info(f'Created RoleDefinition {role_definition.name} pk={role_definition} with {len(permissions)} permissions')
|
||||
|
||||
return role_definition
|
||||
|
||||
|
||||
def setup_managed_role_definitions(apps, schema_editor):
|
||||
"""
|
||||
Idepotent method to create or sync the managed role definitions
|
||||
"""
|
||||
to_create = {
|
||||
'object_admin': '{cls.__name__} Admin',
|
||||
'org_admin': 'Organization Admin',
|
||||
'org_children': 'Organization {cls.__name__} Admin',
|
||||
'special': '{cls.__name__} {action}',
|
||||
}
|
||||
|
||||
ContentType = apps.get_model('contenttypes', 'ContentType')
|
||||
Permission = apps.get_model('dab_rbac', 'DABPermission')
|
||||
RoleDefinition = apps.get_model('dab_rbac', 'RoleDefinition')
|
||||
Organization = apps.get_model(settings.ANSIBLE_BASE_ORGANIZATION_MODEL)
|
||||
org_ct = ContentType.objects.get_for_model(Organization)
|
||||
managed_role_definitions = []
|
||||
|
||||
org_perms = set()
|
||||
for cls in permission_registry._registry:
|
||||
ct = ContentType.objects.get_for_model(cls)
|
||||
object_perms = set(Permission.objects.filter(content_type=ct))
|
||||
# Special case for InstanceGroup which has an organiation field, but is not an organization child object
|
||||
if cls._meta.model_name != 'instancegroup':
|
||||
org_perms.update(object_perms)
|
||||
|
||||
if 'object_admin' in to_create and cls != Organization:
|
||||
indiv_perms = object_perms.copy()
|
||||
add_perms = [perm for perm in indiv_perms if perm.codename.startswith('add_')]
|
||||
if add_perms:
|
||||
for perm in add_perms:
|
||||
indiv_perms.remove(perm)
|
||||
|
||||
managed_role_definitions.append(
|
||||
get_or_create_managed(
|
||||
to_create['object_admin'].format(cls=cls), f'Has all permissions to a single {cls._meta.verbose_name}', ct, indiv_perms, RoleDefinition
|
||||
)
|
||||
)
|
||||
|
||||
if 'org_children' in to_create and cls != Organization:
|
||||
org_child_perms = object_perms.copy()
|
||||
org_child_perms.add(Permission.objects.get(codename='view_organization'))
|
||||
|
||||
managed_role_definitions.append(
|
||||
get_or_create_managed(
|
||||
to_create['org_children'].format(cls=cls),
|
||||
f'Has all permissions to {cls._meta.verbose_name_plural} within an organization',
|
||||
org_ct,
|
||||
org_child_perms,
|
||||
RoleDefinition,
|
||||
)
|
||||
)
|
||||
|
||||
if 'special' in to_create:
|
||||
special_perms = []
|
||||
for perm in object_perms:
|
||||
if perm.codename.split('_')[0] not in ('add', 'change', 'update', 'delete', 'view'):
|
||||
special_perms.append(perm)
|
||||
for perm in special_perms:
|
||||
action = perm.codename.split('_')[0]
|
||||
view_perm = Permission.objects.get(content_type=ct, codename__startswith='view_')
|
||||
managed_role_definitions.append(
|
||||
get_or_create_managed(
|
||||
to_create['special'].format(cls=cls, action=action.title()),
|
||||
f'Has {action} permissions to a single {cls._meta.verbose_name}',
|
||||
ct,
|
||||
[perm, view_perm],
|
||||
RoleDefinition,
|
||||
)
|
||||
)
|
||||
|
||||
if 'org_admin' in to_create:
|
||||
managed_role_definitions.append(
|
||||
get_or_create_managed(
|
||||
to_create['org_admin'].format(cls=Organization),
|
||||
'Has all permissions to a single organization and all objects inside of it',
|
||||
org_ct,
|
||||
org_perms,
|
||||
RoleDefinition,
|
||||
)
|
||||
)
|
||||
|
||||
unexpected_role_definitions = RoleDefinition.objects.filter(managed=True).exclude(pk__in=[rd.pk for rd in managed_role_definitions])
|
||||
for role_definition in unexpected_role_definitions:
|
||||
logger.info(f'Deleting old managed role definition {role_definition.name}, pk={role_definition.pk}')
|
||||
role_definition.delete()
|
||||
@@ -1,6 +1,8 @@
|
||||
# Copyright (c) 2015 Ansible, Inc.
|
||||
# All Rights Reserved.
|
||||
|
||||
import json
|
||||
|
||||
# Django
|
||||
from django.conf import settings # noqa
|
||||
from django.db import connection
|
||||
@@ -8,7 +10,10 @@ from django.db.models.signals import pre_delete # noqa
|
||||
|
||||
# django-ansible-base
|
||||
from ansible_base.resource_registry.fields import AnsibleResourceField
|
||||
from ansible_base.rbac import permission_registry
|
||||
from ansible_base.rbac.models import RoleDefinition, RoleUserAssignment
|
||||
from ansible_base.lib.utils.models import prevent_search
|
||||
from ansible_base.lib.utils.models import user_summary_fields
|
||||
|
||||
# AWX
|
||||
from awx.main.models.base import BaseModel, PrimordialModel, accepts_json, CLOUD_INVENTORY_SOURCES, VERBOSITY_CHOICES # noqa
|
||||
@@ -102,6 +107,7 @@ User.add_to_class('get_queryset', get_user_queryset)
|
||||
User.add_to_class('can_access', check_user_access)
|
||||
User.add_to_class('can_access_with_errors', check_user_access_with_errors)
|
||||
User.add_to_class('resource', AnsibleResourceField(primary_key_field="id"))
|
||||
User.add_to_class('summary_fields', user_summary_fields)
|
||||
|
||||
|
||||
def convert_jsonfields():
|
||||
@@ -194,11 +200,21 @@ User.add_to_class('auditor_of_organizations', user_get_auditor_of_organizations)
|
||||
User.add_to_class('created', created)
|
||||
|
||||
|
||||
def get_system_auditor_role():
|
||||
rd, created = RoleDefinition.objects.get_or_create(
|
||||
name='System Auditor', defaults={'description': 'Migrated singleton role giving read permission to everything'}
|
||||
)
|
||||
if created:
|
||||
rd.permissions.add(*list(permission_registry.permission_qs.filter(codename__startswith='view')))
|
||||
return rd
|
||||
|
||||
|
||||
@property
|
||||
def user_is_system_auditor(user):
|
||||
if not hasattr(user, '_is_system_auditor'):
|
||||
if user.pk:
|
||||
user._is_system_auditor = user.roles.filter(singleton_name='system_auditor', role_field='system_auditor').exists()
|
||||
rd = get_system_auditor_role()
|
||||
user._is_system_auditor = RoleUserAssignment.objects.filter(user=user, role_definition=rd).exists()
|
||||
else:
|
||||
# Odd case where user is unsaved, this should never be relied on
|
||||
return False
|
||||
@@ -212,17 +228,17 @@ def user_is_system_auditor(user, tf):
|
||||
# time they've logged in, and we've just created the new User in this
|
||||
# request), we need one to set up the system auditor role
|
||||
user.save()
|
||||
if tf:
|
||||
role = Role.singleton('system_auditor')
|
||||
# must check if member to not duplicate activity stream
|
||||
if user not in role.members.all():
|
||||
role.members.add(user)
|
||||
user._is_system_auditor = True
|
||||
else:
|
||||
role = Role.singleton('system_auditor')
|
||||
if user in role.members.all():
|
||||
role.members.remove(user)
|
||||
user._is_system_auditor = False
|
||||
rd = get_system_auditor_role()
|
||||
assignment = RoleUserAssignment.objects.filter(user=user, role_definition=rd).first()
|
||||
prior_value = bool(assignment)
|
||||
if prior_value != bool(tf):
|
||||
if assignment:
|
||||
assignment.delete()
|
||||
else:
|
||||
rd.give_global_permission(user)
|
||||
user._is_system_auditor = bool(tf)
|
||||
entry = ActivityStream.objects.create(changes=json.dumps({"is_system_auditor": [prior_value, bool(tf)]}), object1='user', operation='update')
|
||||
entry.user.add(user)
|
||||
|
||||
|
||||
User.add_to_class('is_system_auditor', user_is_system_auditor)
|
||||
@@ -290,6 +306,10 @@ activity_stream_registrar.connect(WorkflowApprovalTemplate)
|
||||
activity_stream_registrar.connect(OAuth2Application)
|
||||
activity_stream_registrar.connect(OAuth2AccessToken)
|
||||
|
||||
# Register models
|
||||
permission_registry.register(Project, Team, WorkflowJobTemplate, JobTemplate, Inventory, Organization, Credential, NotificationTemplate, ExecutionEnvironment)
|
||||
permission_registry.register(InstanceGroup, parent_field_name=None) # Not part of an organization
|
||||
|
||||
# prevent API filtering on certain Django-supplied sensitive fields
|
||||
prevent_search(User._meta.get_field('password'))
|
||||
prevent_search(OAuth2AccessToken._meta.get_field('token'))
|
||||
|
||||
@@ -7,6 +7,9 @@ from django.core.exceptions import ValidationError, ObjectDoesNotExist
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.timezone import now
|
||||
|
||||
# django-ansible-base
|
||||
from ansible_base.lib.utils.models import get_type_for_model
|
||||
|
||||
# Django-CRUM
|
||||
from crum import get_current_user
|
||||
|
||||
@@ -139,6 +142,23 @@ class BaseModel(models.Model):
|
||||
self.save(update_fields=update_fields)
|
||||
return update_fields
|
||||
|
||||
def summary_fields(self):
|
||||
"""
|
||||
This exists for use by django-ansible-base,
|
||||
which has standard patterns that differ from AWX, but we enable views from DAB
|
||||
for those views to list summary_fields for AWX models, those models need to provide this
|
||||
"""
|
||||
from awx.api.serializers import SUMMARIZABLE_FK_FIELDS
|
||||
|
||||
model_name = get_type_for_model(self)
|
||||
related_fields = SUMMARIZABLE_FK_FIELDS.get(model_name, {})
|
||||
summary_data = {}
|
||||
for field_name in related_fields:
|
||||
fval = getattr(self, field_name, None)
|
||||
if fval is not None:
|
||||
summary_data[field_name] = fval
|
||||
return summary_data
|
||||
|
||||
|
||||
class CreatedModifiedModel(BaseModel):
|
||||
"""
|
||||
|
||||
@@ -83,6 +83,7 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique, ResourceMixin):
|
||||
app_label = 'main'
|
||||
ordering = ('name',)
|
||||
unique_together = ('organization', 'name', 'credential_type')
|
||||
permissions = [('use_credential', 'Can use credential in a job or related resource')]
|
||||
|
||||
PASSWORD_FIELDS = ['inputs']
|
||||
FIELDS_TO_PRESERVE_AT_COPY = ['input_sources']
|
||||
@@ -1231,6 +1232,14 @@ ManagedCredentialType(
|
||||
'multiline': True,
|
||||
'help_text': gettext_noop('Terraform backend config as Hashicorp configuration language.'),
|
||||
},
|
||||
{
|
||||
'id': 'gce_credentials',
|
||||
'label': gettext_noop('Google Cloud Platform account credentials'),
|
||||
'type': 'string',
|
||||
'secret': True,
|
||||
'multiline': True,
|
||||
'help_text': gettext_noop('Google Cloud Platform account credentials in JSON format.'),
|
||||
},
|
||||
],
|
||||
'required': ['configuration'],
|
||||
},
|
||||
|
||||
@@ -130,3 +130,10 @@ def terraform(cred, env, private_data_dir):
|
||||
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
|
||||
f.write(cred.get_input('configuration'))
|
||||
env['TF_BACKEND_CONFIG_FILE'] = to_container_path(path, private_data_dir)
|
||||
# Handle env variables for GCP account credentials
|
||||
if 'gce_credentials' in cred.inputs:
|
||||
handle, path = tempfile.mkstemp(dir=os.path.join(private_data_dir, 'env'))
|
||||
with os.fdopen(handle, 'w') as f:
|
||||
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
|
||||
f.write(cred.get_input('gce_credentials'))
|
||||
env['GOOGLE_BACKEND_CREDENTIALS'] = to_container_path(path, private_data_dir)
|
||||
|
||||
@@ -485,6 +485,9 @@ class InstanceGroup(HasPolicyEditsMixin, BaseModel, RelatedJobsMixin, ResourceMi
|
||||
|
||||
class Meta:
|
||||
app_label = 'main'
|
||||
permissions = [('use_instancegroup', 'Can use instance group in a preference list of a resource')]
|
||||
# Since this has no direct organization field only superuser can add, so remove add permission
|
||||
default_permissions = ('change', 'delete', 'view')
|
||||
|
||||
def set_default_policy_fields(self):
|
||||
self.policy_instance_list = []
|
||||
|
||||
@@ -11,6 +11,8 @@ import os.path
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import yaml
|
||||
import tempfile
|
||||
import stat
|
||||
|
||||
# Django
|
||||
from django.conf import settings
|
||||
@@ -89,6 +91,11 @@ class Inventory(CommonModelNameNotUnique, ResourceMixin, RelatedJobsMixin):
|
||||
verbose_name_plural = _('inventories')
|
||||
unique_together = [('name', 'organization')]
|
||||
ordering = ('name',)
|
||||
permissions = [
|
||||
('use_inventory', 'Can use inventory in a job template'),
|
||||
('adhoc_inventory', 'Can run ad hoc commands'),
|
||||
('update_inventory', 'Can update inventory sources in inventory'),
|
||||
]
|
||||
|
||||
organization = models.ForeignKey(
|
||||
'Organization',
|
||||
@@ -1400,7 +1407,7 @@ class InventoryUpdate(UnifiedJob, InventorySourceOptions, JobNotificationMixin,
|
||||
return selected_groups
|
||||
|
||||
|
||||
class CustomInventoryScript(CommonModelNameNotUnique, ResourceMixin):
|
||||
class CustomInventoryScript(CommonModelNameNotUnique):
|
||||
class Meta:
|
||||
app_label = 'main'
|
||||
ordering = ('name',)
|
||||
@@ -1633,17 +1640,39 @@ class satellite6(PluginFileInjector):
|
||||
|
||||
class terraform(PluginFileInjector):
|
||||
plugin_name = 'terraform_state'
|
||||
base_injector = 'managed'
|
||||
namespace = 'cloud'
|
||||
collection = 'terraform'
|
||||
use_fqcn = True
|
||||
|
||||
def inventory_as_dict(self, inventory_update, private_data_dir):
|
||||
env = super(terraform, self).get_plugin_env(inventory_update, private_data_dir, None)
|
||||
ret = super().inventory_as_dict(inventory_update, private_data_dir)
|
||||
ret['backend_config_files'] = env["TF_BACKEND_CONFIG_FILE"]
|
||||
credential = inventory_update.get_cloud_credential()
|
||||
config_cred = credential.get_input('configuration')
|
||||
if config_cred:
|
||||
handle, path = tempfile.mkstemp(dir=os.path.join(private_data_dir, 'env'))
|
||||
with os.fdopen(handle, 'w') as f:
|
||||
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
|
||||
f.write(config_cred)
|
||||
ret['backend_config_files'] = to_container_path(path, private_data_dir)
|
||||
return ret
|
||||
|
||||
def build_plugin_private_data(self, inventory_update, private_data_dir):
|
||||
credential = inventory_update.get_cloud_credential()
|
||||
|
||||
private_data = {'credentials': {}}
|
||||
gce_cred = credential.get_input('gce_credentials')
|
||||
if gce_cred:
|
||||
private_data['credentials'][credential] = gce_cred
|
||||
return private_data
|
||||
|
||||
def get_plugin_env(self, inventory_update, private_data_dir, private_data_files):
|
||||
env = super(terraform, self).get_plugin_env(inventory_update, private_data_dir, private_data_files)
|
||||
credential = inventory_update.get_cloud_credential()
|
||||
cred_data = private_data_files['credentials']
|
||||
if cred_data[credential]:
|
||||
env['GOOGLE_BACKEND_CREDENTIALS'] = to_container_path(cred_data[credential], private_data_dir)
|
||||
return env
|
||||
|
||||
|
||||
class controller(PluginFileInjector):
|
||||
plugin_name = 'tower' # TODO: relying on routing for now, update after EEs pick up revised collection
|
||||
|
||||
@@ -205,6 +205,9 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, SurveyJobTemplateMixin, Resour
|
||||
class Meta:
|
||||
app_label = 'main'
|
||||
ordering = ('name',)
|
||||
permissions = [('execute_jobtemplate', 'Can run this job template')]
|
||||
# Remove add permission, ability to add comes from use permission for inventory, project, credentials
|
||||
default_permissions = ('change', 'delete', 'view')
|
||||
|
||||
job_type = models.CharField(
|
||||
max_length=64,
|
||||
|
||||
@@ -19,13 +19,14 @@ from django.utils.translation import gettext_lazy as _
|
||||
from ansible_base.lib.utils.models import prevent_search
|
||||
|
||||
# AWX
|
||||
from awx.main.models.rbac import Role, RoleAncestorEntry
|
||||
|
||||
from awx.main.models.rbac import Role, RoleAncestorEntry, to_permissions
|
||||
from awx.main.utils import parse_yaml_or_json, get_custom_venv_choices, get_licenser, polymorphic
|
||||
from awx.main.utils.execution_environments import get_default_execution_environment
|
||||
from awx.main.utils.encryption import decrypt_value, get_encryption_key, is_encrypted
|
||||
from awx.main.utils.polymorphic import build_polymorphic_ctypes_map
|
||||
from awx.main.fields import AskForField
|
||||
from awx.main.constants import ACTIVE_STATES
|
||||
from awx.main.constants import ACTIVE_STATES, org_role_to_permission
|
||||
|
||||
|
||||
logger = logging.getLogger('awx.main.models.mixins')
|
||||
@@ -64,6 +65,18 @@ class ResourceMixin(models.Model):
|
||||
|
||||
@staticmethod
|
||||
def _accessible_pk_qs(cls, accessor, role_field, content_types=None):
|
||||
if settings.ANSIBLE_BASE_ROLE_SYSTEM_ACTIVATED:
|
||||
if cls._meta.model_name == 'organization' and role_field in org_role_to_permission:
|
||||
# Organization roles can not use the DAB RBAC shortcuts
|
||||
# like Organization.access_qs(user, 'change_jobtemplate') is needed
|
||||
# not just Organization.access_qs(user, 'change') is needed
|
||||
if accessor.is_superuser:
|
||||
return cls.objects.values_list('id')
|
||||
|
||||
codename = org_role_to_permission[role_field]
|
||||
|
||||
return cls.access_ids_qs(accessor, codename, content_types=content_types)
|
||||
return cls.access_ids_qs(accessor, to_permissions[role_field], content_types=content_types)
|
||||
if accessor._meta.model_name == 'user':
|
||||
ancestor_roles = accessor.roles.all()
|
||||
elif type(accessor) == Role:
|
||||
|
||||
@@ -35,6 +35,12 @@ class Organization(CommonModel, NotificationFieldsModel, ResourceMixin, CustomVi
|
||||
class Meta:
|
||||
app_label = 'main'
|
||||
ordering = ('name',)
|
||||
permissions = [
|
||||
('member_organization', 'Basic participation permissions for organization'),
|
||||
('audit_organization', 'Audit everything inside the organization'),
|
||||
]
|
||||
# Remove add permission, only superuser can add
|
||||
default_permissions = ('change', 'delete', 'view')
|
||||
|
||||
instance_groups = OrderedManyToManyField('InstanceGroup', blank=True, through='OrganizationInstanceGroupMembership')
|
||||
galaxy_credentials = OrderedManyToManyField(
|
||||
@@ -137,6 +143,7 @@ class Team(CommonModelNameNotUnique, ResourceMixin):
|
||||
app_label = 'main'
|
||||
unique_together = [('organization', 'name')]
|
||||
ordering = ('organization__name', 'name')
|
||||
permissions = [('member_team', 'Inherit all roles assigned to this team')]
|
||||
|
||||
organization = models.ForeignKey(
|
||||
'Organization',
|
||||
|
||||
@@ -259,6 +259,7 @@ class Project(UnifiedJobTemplate, ProjectOptions, ResourceMixin, CustomVirtualEn
|
||||
class Meta:
|
||||
app_label = 'main'
|
||||
ordering = ('id',)
|
||||
permissions = [('update_project', 'Can run a project update'), ('use_project', 'Can use project in a job template')]
|
||||
|
||||
default_environment = models.ForeignKey(
|
||||
'ExecutionEnvironment',
|
||||
|
||||
@@ -7,14 +7,30 @@ import threading
|
||||
import contextlib
|
||||
import re
|
||||
|
||||
# django-rest-framework
|
||||
from rest_framework.serializers import ValidationError
|
||||
|
||||
# crum to impersonate users
|
||||
from crum import impersonate
|
||||
|
||||
# Django
|
||||
from django.db import models, transaction, connection
|
||||
from django.db.models.signals import m2m_changed
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
|
||||
# Ansible_base app
|
||||
from ansible_base.rbac.models import RoleDefinition
|
||||
from ansible_base.lib.utils.models import get_type_for_model
|
||||
|
||||
# AWX
|
||||
from awx.api.versioning import reverse
|
||||
from awx.main.migrations._dab_rbac import build_role_map, get_permissions_for_role
|
||||
from awx.main.constants import role_name_to_perm_mapping, org_role_to_permission
|
||||
|
||||
__all__ = [
|
||||
'Role',
|
||||
@@ -75,6 +91,11 @@ role_descriptions = {
|
||||
}
|
||||
|
||||
|
||||
to_permissions = {}
|
||||
for k, v in role_name_to_perm_mapping.items():
|
||||
to_permissions[k] = v[0].strip('_')
|
||||
|
||||
|
||||
tls = threading.local() # thread local storage
|
||||
|
||||
|
||||
@@ -86,10 +107,8 @@ def check_singleton(func):
|
||||
"""
|
||||
|
||||
def wrapper(*args, **kwargs):
|
||||
sys_admin = Role.singleton(ROLE_SINGLETON_SYSTEM_ADMINISTRATOR)
|
||||
sys_audit = Role.singleton(ROLE_SINGLETON_SYSTEM_AUDITOR)
|
||||
user = args[0]
|
||||
if user in sys_admin or user in sys_audit:
|
||||
if user.is_superuser or user.is_system_auditor:
|
||||
if len(args) == 2:
|
||||
return args[1]
|
||||
return Role.objects.all()
|
||||
@@ -169,6 +188,24 @@ class Role(models.Model):
|
||||
|
||||
def __contains__(self, accessor):
|
||||
if accessor._meta.model_name == 'user':
|
||||
if accessor.is_superuser:
|
||||
return True
|
||||
if self.role_field == 'system_administrator':
|
||||
return accessor.is_superuser
|
||||
elif self.role_field == 'system_auditor':
|
||||
return accessor.is_system_auditor
|
||||
elif self.role_field in ('read_role', 'auditor_role') and accessor.is_system_auditor:
|
||||
return True
|
||||
|
||||
if settings.ANSIBLE_BASE_ROLE_SYSTEM_ACTIVATED:
|
||||
if self.content_object and self.content_object._meta.model_name == 'organization' and self.role_field in org_role_to_permission:
|
||||
codename = org_role_to_permission[self.role_field]
|
||||
|
||||
return accessor.has_obj_perm(self.content_object, codename)
|
||||
|
||||
if self.role_field not in to_permissions:
|
||||
raise Exception(f'{self.role_field} evaluated but not a translatable permission')
|
||||
return accessor.has_obj_perm(self.content_object, to_permissions[self.role_field])
|
||||
return self.ancestors.filter(members=accessor).exists()
|
||||
else:
|
||||
raise RuntimeError(f'Role evaluations only valid for users, received {accessor}')
|
||||
@@ -280,6 +317,9 @@ class Role(models.Model):
|
||||
#
|
||||
#
|
||||
|
||||
if settings.ANSIBLE_BASE_ROLE_SYSTEM_ACTIVATED:
|
||||
return
|
||||
|
||||
if len(additions) == 0 and len(removals) == 0:
|
||||
return
|
||||
|
||||
@@ -412,6 +452,12 @@ class Role(models.Model):
|
||||
in their organization, but some of those roles descend from
|
||||
organization admin_role, but not auditor_role.
|
||||
"""
|
||||
if settings.ANSIBLE_BASE_ROLE_SYSTEM_ACTIVATED:
|
||||
from ansible_base.rbac.models import RoleEvaluation
|
||||
|
||||
q = RoleEvaluation.objects.filter(role__in=user.has_roles.all()).values_list('object_id', 'content_type_id').query
|
||||
return roles_qs.extra(where=[f'(object_id,content_type_id) in ({q})'])
|
||||
|
||||
return roles_qs.filter(
|
||||
id__in=RoleAncestorEntry.objects.filter(
|
||||
descendent__in=RoleAncestorEntry.objects.filter(ancestor_id__in=list(user.roles.values_list('id', flat=True))).values_list(
|
||||
@@ -434,6 +480,13 @@ class Role(models.Model):
|
||||
return self.singleton_name in [ROLE_SINGLETON_SYSTEM_ADMINISTRATOR, ROLE_SINGLETON_SYSTEM_AUDITOR]
|
||||
|
||||
|
||||
class AncestorManager(models.Manager):
|
||||
def get_queryset(self):
|
||||
if settings.ANSIBLE_BASE_ROLE_SYSTEM_ACTIVATED:
|
||||
raise RuntimeError('The old RBAC system has been disabled, this should never be called')
|
||||
return super(AncestorManager, self).get_queryset()
|
||||
|
||||
|
||||
class RoleAncestorEntry(models.Model):
|
||||
class Meta:
|
||||
app_label = 'main'
|
||||
@@ -451,6 +504,8 @@ class RoleAncestorEntry(models.Model):
|
||||
content_type_id = models.PositiveIntegerField(null=False)
|
||||
object_id = models.PositiveIntegerField(null=False)
|
||||
|
||||
objects = AncestorManager()
|
||||
|
||||
|
||||
def role_summary_fields_generator(content_object, role_field):
|
||||
global role_descriptions
|
||||
@@ -479,3 +534,173 @@ def role_summary_fields_generator(content_object, role_field):
|
||||
summary['name'] = role_names[role_field]
|
||||
summary['id'] = getattr(content_object, '{}_id'.format(role_field))
|
||||
return summary
|
||||
|
||||
|
||||
# ----------------- Custom Role Compatibility -------------------------
|
||||
# The following are methods to connect this (old) RBAC system to the new
|
||||
# system which allows custom roles
|
||||
# this follows the ORM interface layer documented in docs/rbac.md
|
||||
def get_role_codenames(role):
|
||||
obj = role.content_object
|
||||
if obj is None:
|
||||
return
|
||||
f = obj._meta.get_field(role.role_field)
|
||||
parents, children = build_role_map(apps)
|
||||
return [perm.codename for perm in get_permissions_for_role(f, children, apps)]
|
||||
|
||||
|
||||
def get_role_definition(role):
|
||||
"""Given a old-style role, this gives a role definition in the new RBAC system for it"""
|
||||
obj = role.content_object
|
||||
if obj is None:
|
||||
return
|
||||
f = obj._meta.get_field(role.role_field)
|
||||
action_name = f.name.rsplit("_", 1)[0]
|
||||
model_print = type(obj).__name__
|
||||
rd_name = f'{model_print} {action_name.title()} Compat'
|
||||
perm_list = get_role_codenames(role)
|
||||
defaults = {
|
||||
'content_type_id': role.content_type_id,
|
||||
'description': f'Has {action_name.title()} permission to {model_print} for backwards API compatibility',
|
||||
}
|
||||
with impersonate(None):
|
||||
try:
|
||||
rd, created = RoleDefinition.objects.get_or_create(name=rd_name, permissions=perm_list, defaults=defaults)
|
||||
except ValidationError:
|
||||
# This is a tricky case - practically speaking, users should not be allowed to create team roles
|
||||
# or roles that include the team member permission.
|
||||
# If we need to create this for compatibility purposes then we will create it as a managed non-editable role
|
||||
defaults['managed'] = True
|
||||
rd, created = RoleDefinition.objects.get_or_create(name=rd_name, permissions=perm_list, defaults=defaults)
|
||||
return rd
|
||||
|
||||
|
||||
def get_role_from_object_role(object_role):
|
||||
"""
|
||||
Given an object role from the new system, return the corresponding role from the old system
|
||||
reverses naming from get_role_definition, and the ANSIBLE_BASE_ROLE_PRECREATE setting.
|
||||
"""
|
||||
rd = object_role.role_definition
|
||||
if rd.name.endswith(' Compat'):
|
||||
model_name, role_name, _ = rd.name.split()
|
||||
role_name = role_name.lower()
|
||||
role_name += '_role'
|
||||
elif rd.name.endswith(' Admin') and rd.name.count(' ') == 2:
|
||||
# cases like "Organization Project Admin"
|
||||
model_name, target_model_name, role_name = rd.name.split()
|
||||
role_name = role_name.lower()
|
||||
model_cls = apps.get_model('main', target_model_name)
|
||||
target_model_name = get_type_for_model(model_cls)
|
||||
if target_model_name == 'notification_template':
|
||||
target_model_name = 'notification' # total exception
|
||||
role_name = f'{target_model_name}_admin_role'
|
||||
elif rd.name.endswith(' Admin'):
|
||||
# cases like "project-admin"
|
||||
role_name = 'admin_role'
|
||||
else:
|
||||
print(rd.name)
|
||||
model_name, role_name = rd.name.split()
|
||||
role_name = role_name.lower()
|
||||
role_name += '_role'
|
||||
return getattr(object_role.content_object, role_name)
|
||||
|
||||
|
||||
def give_or_remove_permission(role, actor, giving=True):
|
||||
obj = role.content_object
|
||||
if obj is None:
|
||||
return
|
||||
rd = get_role_definition(role)
|
||||
rd.give_or_remove_permission(actor, obj, giving=giving)
|
||||
|
||||
|
||||
class SyncEnabled(threading.local):
|
||||
def __init__(self):
|
||||
self.enabled = True
|
||||
|
||||
|
||||
rbac_sync_enabled = SyncEnabled()
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def disable_rbac_sync():
|
||||
try:
|
||||
previous_value = rbac_sync_enabled.enabled
|
||||
rbac_sync_enabled.enabled = False
|
||||
yield
|
||||
finally:
|
||||
rbac_sync_enabled.enabled = previous_value
|
||||
|
||||
|
||||
def give_creator_permissions(user, obj):
|
||||
assignment = RoleDefinition.objects.give_creator_permissions(user, obj)
|
||||
if assignment:
|
||||
with disable_rbac_sync():
|
||||
old_role = get_role_from_object_role(assignment.object_role)
|
||||
old_role.members.add(user)
|
||||
|
||||
|
||||
def sync_members_to_new_rbac(instance, action, model, pk_set, reverse, **kwargs):
|
||||
if action.startswith('pre_'):
|
||||
return
|
||||
if not rbac_sync_enabled.enabled:
|
||||
return
|
||||
|
||||
if action == 'post_add':
|
||||
is_giving = True
|
||||
elif action == 'post_remove':
|
||||
is_giving = False
|
||||
elif action == 'post_clear':
|
||||
raise RuntimeError('Clearing of role members not supported')
|
||||
|
||||
if reverse:
|
||||
user = instance
|
||||
else:
|
||||
role = instance
|
||||
|
||||
for user_or_role_id in pk_set:
|
||||
if reverse:
|
||||
role = Role.objects.get(pk=user_or_role_id)
|
||||
else:
|
||||
user = get_user_model().objects.get(pk=user_or_role_id)
|
||||
give_or_remove_permission(role, user, giving=is_giving)
|
||||
|
||||
|
||||
def sync_parents_to_new_rbac(instance, action, model, pk_set, reverse, **kwargs):
|
||||
if action.startswith('pre_'):
|
||||
return
|
||||
|
||||
if action == 'post_add':
|
||||
is_giving = True
|
||||
elif action == 'post_remove':
|
||||
is_giving = False
|
||||
elif action == 'post_clear':
|
||||
raise RuntimeError('Clearing of role members not supported')
|
||||
|
||||
if reverse:
|
||||
parent_role = instance
|
||||
else:
|
||||
child_role = instance
|
||||
|
||||
for role_id in pk_set:
|
||||
if reverse:
|
||||
child_role = Role.objects.get(id=role_id)
|
||||
else:
|
||||
parent_role = Role.objects.get(id=role_id)
|
||||
|
||||
# To a fault, we want to avoid running this if triggered from implicit_parents management
|
||||
# we only want to do anything if we know for sure this is a non-implicit team role
|
||||
if parent_role.role_field == 'member_role' and parent_role.content_type.model == 'team':
|
||||
# Team internal parents are member_role->read_role and admin_role->member_role
|
||||
# for the same object, this parenting will also be implicit_parents management
|
||||
# do nothing for internal parents, but OTHER teams may still be assigned permissions to a team
|
||||
if (child_role.content_type_id == parent_role.content_type_id) and (child_role.object_id == parent_role.object_id):
|
||||
return
|
||||
|
||||
from awx.main.models.organization import Team
|
||||
|
||||
team = Team.objects.get(pk=parent_role.object_id)
|
||||
give_or_remove_permission(child_role, team, giving=is_giving)
|
||||
|
||||
|
||||
m2m_changed.connect(sync_members_to_new_rbac, Role.members.through)
|
||||
m2m_changed.connect(sync_parents_to_new_rbac, Role.parents.through)
|
||||
|
||||
@@ -37,7 +37,8 @@ from awx.main.models.base import CommonModelNameNotUnique, PasswordFieldsModel,
|
||||
from awx.main.dispatch import get_task_queuename
|
||||
from awx.main.dispatch.control import Control as ControlDispatcher
|
||||
from awx.main.registrar import activity_stream_registrar
|
||||
from awx.main.models.mixins import ResourceMixin, TaskManagerUnifiedJobMixin, ExecutionEnvironmentMixin
|
||||
from awx.main.models.mixins import TaskManagerUnifiedJobMixin, ExecutionEnvironmentMixin
|
||||
from awx.main.models.rbac import to_permissions
|
||||
from awx.main.utils.common import (
|
||||
camelcase_to_underscore,
|
||||
get_model_for_type,
|
||||
@@ -210,7 +211,15 @@ class UnifiedJobTemplate(PolymorphicModel, CommonModelNameNotUnique, ExecutionEn
|
||||
# do not use this if in a subclass
|
||||
if cls != UnifiedJobTemplate:
|
||||
return super(UnifiedJobTemplate, cls).accessible_pk_qs(accessor, role_field)
|
||||
return ResourceMixin._accessible_pk_qs(cls, accessor, role_field, content_types=cls._submodels_with_roles())
|
||||
from ansible_base.rbac.models import RoleEvaluation
|
||||
|
||||
action = to_permissions[role_field]
|
||||
|
||||
return (
|
||||
RoleEvaluation.objects.filter(role__in=accessor.has_roles.all(), codename__startswith=action, content_type_id__in=cls._submodels_with_roles())
|
||||
.values_list('object_id')
|
||||
.distinct()
|
||||
)
|
||||
|
||||
def _perform_unique_checks(self, unique_checks):
|
||||
# Handle the list of unique fields returned above. Replace with an
|
||||
|
||||
@@ -467,6 +467,10 @@ class WorkflowJobTemplate(UnifiedJobTemplate, WorkflowJobOptions, SurveyJobTempl
|
||||
|
||||
class Meta:
|
||||
app_label = 'main'
|
||||
permissions = [
|
||||
('execute_workflowjobtemplate', 'Can run this workflow job template'),
|
||||
('approve_workflowjobtemplate', 'Can approve steps in this workflow job template'),
|
||||
]
|
||||
|
||||
notification_templates_approvals = models.ManyToManyField(
|
||||
"NotificationTemplate",
|
||||
|
||||
@@ -126,6 +126,8 @@ def rebuild_role_ancestor_list(reverse, model, instance, pk_set, action, **kwarg
|
||||
|
||||
def sync_superuser_status_to_rbac(instance, **kwargs):
|
||||
'When the is_superuser flag is changed on a user, reflect that in the membership of the System Admnistrator role'
|
||||
if settings.ANSIBLE_BASE_ROLE_SYSTEM_ACTIVATED:
|
||||
return
|
||||
update_fields = kwargs.get('update_fields', None)
|
||||
if update_fields and 'is_superuser' not in update_fields:
|
||||
return
|
||||
@@ -137,6 +139,8 @@ def sync_superuser_status_to_rbac(instance, **kwargs):
|
||||
|
||||
def sync_rbac_to_superuser_status(instance, sender, **kwargs):
|
||||
'When the is_superuser flag is false but a user has the System Admin role, update the database to reflect that'
|
||||
if settings.ANSIBLE_BASE_ROLE_SYSTEM_ACTIVATED:
|
||||
return
|
||||
if kwargs['action'] in ['post_add', 'post_remove', 'post_clear']:
|
||||
new_status_value = bool(kwargs['action'] == 'post_add')
|
||||
if hasattr(instance, 'singleton_name'): # duck typing, role.members.add() vs user.roles.add()
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"TF_BACKEND_CONFIG_FILE": "{{ file_reference }}"
|
||||
"GOOGLE_BACKEND_CREDENTIALS": "{{ file_reference }}"
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ class TestSwaggerGeneration:
|
||||
# The number of API endpoints changes over time, but let's just check
|
||||
# for a reasonable number here; if this test starts failing, raise/lower the bounds
|
||||
paths = JSON['paths']
|
||||
assert 250 < len(paths) < 375
|
||||
assert 250 < len(paths) < 400
|
||||
assert set(list(paths['/api/'].keys())) == set(['get', 'parameters'])
|
||||
assert set(list(paths['/api/v2/'].keys())) == set(['get', 'parameters'])
|
||||
assert set(list(sorted(paths['/api/v2/credentials/'].keys()))) == set(['get', 'post', 'parameters'])
|
||||
|
||||
@@ -4,7 +4,6 @@ from prometheus_client.parser import text_string_to_metric_families
|
||||
from awx.main import models
|
||||
from awx.main.analytics.metrics import metrics
|
||||
from awx.api.versioning import reverse
|
||||
from awx.main.models.rbac import Role
|
||||
|
||||
EXPECTED_VALUES = {
|
||||
'awx_system_info': 1.0,
|
||||
@@ -66,7 +65,6 @@ def test_metrics_permissions(get, admin, org_admin, alice, bob, organization):
|
||||
organization.auditor_role.members.add(bob)
|
||||
assert get(get_metrics_view_db_only(), user=bob).status_code == 403
|
||||
|
||||
Role.singleton('system_auditor').members.add(bob)
|
||||
bob.is_system_auditor = True
|
||||
assert get(get_metrics_view_db_only(), user=bob).status_code == 200
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ def test_idempotent_credential_type_setup():
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_user_credential_via_credentials_list(post, get, alice, credentialtype_ssh):
|
||||
def test_create_user_credential_via_credentials_list(post, get, alice, credentialtype_ssh, setup_managed_roles):
|
||||
params = {
|
||||
'credential_type': 1,
|
||||
'inputs': {'username': 'someusername'},
|
||||
@@ -81,7 +81,7 @@ def test_credential_validation_error_with_multiple_owner_fields(post, admin, ali
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_user_credential_via_user_credentials_list(post, get, alice, credentialtype_ssh):
|
||||
def test_create_user_credential_via_user_credentials_list(post, get, alice, credentialtype_ssh, setup_managed_roles):
|
||||
params = {
|
||||
'credential_type': 1,
|
||||
'inputs': {'username': 'someusername'},
|
||||
@@ -385,10 +385,9 @@ def test_list_created_org_credentials(post, get, organization, org_admin, org_me
|
||||
@pytest.mark.django_db
|
||||
def test_list_cannot_order_by_encrypted_field(post, get, organization, org_admin, credentialtype_ssh, order_by):
|
||||
for i, password in enumerate(('abc', 'def', 'xyz')):
|
||||
response = post(reverse('api:credential_list'), {'organization': organization.id, 'name': 'C%d' % i, 'password': password}, org_admin)
|
||||
post(reverse('api:credential_list'), {'organization': organization.id, 'name': 'C%d' % i, 'password': password}, org_admin, expect=400)
|
||||
|
||||
response = get(reverse('api:credential_list'), org_admin, QUERY_STRING='order_by=%s' % order_by, status=400)
|
||||
assert response.status_code == 400
|
||||
get(reverse('api:credential_list'), org_admin, QUERY_STRING='order_by=%s' % order_by, expect=400)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -399,8 +398,7 @@ def test_inputs_cannot_contain_extra_fields(get, post, organization, admin, cred
|
||||
'credential_type': credentialtype_ssh.pk,
|
||||
'inputs': {'invalid_field': 'foo'},
|
||||
}
|
||||
response = post(reverse('api:credential_list'), params, admin)
|
||||
assert response.status_code == 400
|
||||
response = post(reverse('api:credential_list'), params, admin, expect=400)
|
||||
assert "'invalid_field' was unexpected" in response.data['inputs'][0]
|
||||
|
||||
|
||||
|
||||
@@ -3,17 +3,6 @@ import pytest
|
||||
from awx.api.versioning import reverse
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_admin_visible_to_orphaned_users(get, alice):
|
||||
names = set()
|
||||
|
||||
response = get(reverse('api:role_list'), user=alice)
|
||||
for item in response.data['results']:
|
||||
names.add(item['name'])
|
||||
assert 'System Auditor' in names
|
||||
assert 'System Administrator' in names
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize('role,code', [('member_role', 400), ('admin_role', 400), ('inventory_admin_role', 204)])
|
||||
@pytest.mark.parametrize('reversed', [True, False])
|
||||
|
||||
@@ -16,6 +16,8 @@ from django.db.backends.sqlite3.base import SQLiteCursorWrapper
|
||||
|
||||
from django.db.models.signals import post_migrate
|
||||
|
||||
from awx.main.migrations._dab_rbac import setup_managed_role_definitions
|
||||
|
||||
# AWX
|
||||
from awx.main.models.projects import Project
|
||||
from awx.main.models.ha import Instance
|
||||
@@ -32,7 +34,6 @@ from awx.main.models.organization import (
|
||||
Organization,
|
||||
Team,
|
||||
)
|
||||
from awx.main.models.rbac import Role
|
||||
from awx.main.models.notifications import NotificationTemplate, Notification
|
||||
from awx.main.models.events import (
|
||||
JobEvent,
|
||||
@@ -91,6 +92,12 @@ def deploy_jobtemplate(project, inventory, credential):
|
||||
return jt
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def setup_managed_roles():
|
||||
"Run the migration script to pre-create managed role definitions"
|
||||
setup_managed_role_definitions(apps, None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def team(organization):
|
||||
return organization.teams.create(name='test-team')
|
||||
@@ -434,7 +441,7 @@ def admin(user):
|
||||
@pytest.fixture
|
||||
def system_auditor(user):
|
||||
u = user('an-auditor', False)
|
||||
Role.singleton('system_auditor').members.add(u)
|
||||
u.is_system_auditor = True
|
||||
return u
|
||||
|
||||
|
||||
|
||||
111
awx/main/tests/functional/dab_rbac/test_access_list.py
Normal file
111
awx/main/tests/functional/dab_rbac/test_access_list.py
Normal file
@@ -0,0 +1,111 @@
|
||||
import pytest
|
||||
|
||||
from awx.main.models import User
|
||||
from awx.api.versioning import reverse
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_access_list_superuser(get, admin_user, inventory):
|
||||
url = reverse('api:inventory_access_list', kwargs={'pk': inventory.id})
|
||||
|
||||
response = get(url, user=admin_user, expect=200)
|
||||
by_username = {}
|
||||
for entry in response.data['results']:
|
||||
by_username[entry['username']] = entry
|
||||
assert 'admin' in by_username
|
||||
|
||||
assert len(by_username['admin']['summary_fields']['indirect_access']) == 1
|
||||
assert len(by_username['admin']['summary_fields']['direct_access']) == 0
|
||||
access_entry = by_username['admin']['summary_fields']['indirect_access'][0]
|
||||
assert sorted(access_entry['descendant_roles']) == sorted(['adhoc_role', 'use_role', 'update_role', 'read_role', 'admin_role'])
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_access_list_system_auditor(get, admin_user, inventory):
|
||||
sys_auditor = User.objects.create(username='sys-aud')
|
||||
sys_auditor.is_system_auditor = True
|
||||
assert sys_auditor.is_system_auditor
|
||||
url = reverse('api:inventory_access_list', kwargs={'pk': inventory.id})
|
||||
|
||||
response = get(url, user=admin_user, expect=200)
|
||||
by_username = {}
|
||||
for entry in response.data['results']:
|
||||
by_username[entry['username']] = entry
|
||||
assert 'sys-aud' in by_username
|
||||
|
||||
assert len(by_username['sys-aud']['summary_fields']['indirect_access']) == 1
|
||||
assert len(by_username['sys-aud']['summary_fields']['direct_access']) == 0
|
||||
access_entry = by_username['sys-aud']['summary_fields']['indirect_access'][0]
|
||||
assert access_entry['descendant_roles'] == ['read_role']
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_access_list_direct_access(get, admin_user, inventory):
|
||||
u1 = User.objects.create(username='u1')
|
||||
|
||||
inventory.admin_role.members.add(u1)
|
||||
|
||||
url = reverse('api:inventory_access_list', kwargs={'pk': inventory.id})
|
||||
response = get(url, user=admin_user, expect=200)
|
||||
by_username = {}
|
||||
for entry in response.data['results']:
|
||||
by_username[entry['username']] = entry
|
||||
assert 'u1' in by_username
|
||||
|
||||
assert len(by_username['u1']['summary_fields']['direct_access']) == 1
|
||||
assert len(by_username['u1']['summary_fields']['indirect_access']) == 0
|
||||
access_entry = by_username['u1']['summary_fields']['direct_access'][0]
|
||||
assert sorted(access_entry['descendant_roles']) == sorted(['adhoc_role', 'use_role', 'update_role', 'read_role', 'admin_role'])
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_access_list_organization_access(get, admin_user, inventory):
|
||||
u2 = User.objects.create(username='u2')
|
||||
|
||||
inventory.organization.inventory_admin_role.members.add(u2)
|
||||
|
||||
# User has indirect access to the inventory
|
||||
url = reverse('api:inventory_access_list', kwargs={'pk': inventory.id})
|
||||
response = get(url, user=admin_user, expect=200)
|
||||
by_username = {}
|
||||
for entry in response.data['results']:
|
||||
by_username[entry['username']] = entry
|
||||
assert 'u2' in by_username
|
||||
|
||||
assert len(by_username['u2']['summary_fields']['indirect_access']) == 1
|
||||
assert len(by_username['u2']['summary_fields']['direct_access']) == 0
|
||||
access_entry = by_username['u2']['summary_fields']['indirect_access'][0]
|
||||
assert sorted(access_entry['descendant_roles']) == sorted(['adhoc_role', 'use_role', 'update_role', 'read_role', 'admin_role'])
|
||||
|
||||
# Test that user shows up in the organization access list with direct access of expected roles
|
||||
url = reverse('api:organization_access_list', kwargs={'pk': inventory.organization_id})
|
||||
response = get(url, user=admin_user, expect=200)
|
||||
by_username = {}
|
||||
for entry in response.data['results']:
|
||||
by_username[entry['username']] = entry
|
||||
assert 'u2' in by_username
|
||||
|
||||
assert len(by_username['u2']['summary_fields']['direct_access']) == 1
|
||||
assert len(by_username['u2']['summary_fields']['indirect_access']) == 0
|
||||
access_entry = by_username['u2']['summary_fields']['direct_access'][0]
|
||||
assert sorted(access_entry['descendant_roles']) == sorted(['inventory_admin_role', 'read_role'])
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_team_indirect_access(get, team, admin_user, inventory):
|
||||
u1 = User.objects.create(username='u1')
|
||||
team.member_role.members.add(u1)
|
||||
|
||||
inventory.organization.inventory_admin_role.parents.add(team.member_role)
|
||||
|
||||
url = reverse('api:inventory_access_list', kwargs={'pk': inventory.id})
|
||||
response = get(url, user=admin_user, expect=200)
|
||||
by_username = {}
|
||||
for entry in response.data['results']:
|
||||
by_username[entry['username']] = entry
|
||||
assert 'u1' in by_username
|
||||
|
||||
assert len(by_username['u1']['summary_fields']['direct_access']) == 1
|
||||
assert len(by_username['u1']['summary_fields']['indirect_access']) == 0
|
||||
access_entry = by_username['u1']['summary_fields']['direct_access'][0]
|
||||
assert sorted(access_entry['descendant_roles']) == sorted(['adhoc_role', 'use_role', 'update_role', 'read_role', 'admin_role'])
|
||||
90
awx/main/tests/functional/dab_rbac/test_dab_rbac_api.py
Normal file
90
awx/main/tests/functional/dab_rbac/test_dab_rbac_api.py
Normal file
@@ -0,0 +1,90 @@
|
||||
import pytest
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.urls import reverse as django_reverse
|
||||
|
||||
from awx.api.versioning import reverse
|
||||
from awx.main.models import JobTemplate, Inventory, Organization
|
||||
|
||||
from ansible_base.rbac.models import RoleDefinition
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_managed_roles_created(setup_managed_roles):
|
||||
"Managed RoleDefinitions are created in post_migration signal, we expect to see them here"
|
||||
for cls in (JobTemplate, Inventory):
|
||||
ct = ContentType.objects.get_for_model(cls)
|
||||
rds = list(RoleDefinition.objects.filter(content_type=ct))
|
||||
assert len(rds) > 1
|
||||
assert f'{cls.__name__} Admin' in [rd.name for rd in rds]
|
||||
for rd in rds:
|
||||
assert rd.managed is True
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_custom_read_role(admin_user, post, setup_managed_roles):
|
||||
rd_url = django_reverse('roledefinition-list')
|
||||
resp = post(
|
||||
url=rd_url, data={"name": "read role made for test", "content_type": "awx.inventory", "permissions": ['view_inventory']}, user=admin_user, expect=201
|
||||
)
|
||||
rd_id = resp.data['id']
|
||||
rd = RoleDefinition.objects.get(id=rd_id)
|
||||
assert rd.content_type == ContentType.objects.get_for_model(Inventory)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_custom_system_roles_prohibited(admin_user, post):
|
||||
rd_url = django_reverse('roledefinition-list')
|
||||
resp = post(url=rd_url, data={"name": "read role made for test", "content_type": None, "permissions": ['view_inventory']}, user=admin_user, expect=400)
|
||||
assert 'System-wide roles are not enabled' in str(resp.data)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_assignment_to_invisible_user(admin_user, alice, rando, inventory, post, setup_managed_roles):
|
||||
"Alice can not see rando, and so can not give them a role assignment"
|
||||
rd = RoleDefinition.objects.get(name='Inventory Admin')
|
||||
rd.give_permission(alice, inventory)
|
||||
url = django_reverse('roleuserassignment-list')
|
||||
r = post(url=url, data={"user": rando.id, "role_definition": rd.id, "object_id": inventory.id}, user=alice, expect=400)
|
||||
assert 'does not exist' in str(r.data)
|
||||
assert not rando.has_obj_perm(inventory, 'change')
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_assign_managed_role(admin_user, alice, rando, inventory, post, setup_managed_roles, organization):
|
||||
rd = RoleDefinition.objects.get(name='Inventory Admin')
|
||||
rd.give_permission(alice, inventory)
|
||||
# When alice and rando are members of the same org, they can see each other
|
||||
member_rd = RoleDefinition.objects.get(name='Organization Member')
|
||||
for u in (alice, rando):
|
||||
member_rd.give_permission(u, organization)
|
||||
# Now that alice has full permissions to the inventory, and can see rando, she will give rando permission
|
||||
url = django_reverse('roleuserassignment-list')
|
||||
post(url=url, data={"user": rando.id, "role_definition": rd.id, "object_id": inventory.id}, user=alice, expect=201)
|
||||
assert rando.has_obj_perm(inventory, 'change') is True
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_assign_custom_delete_role(admin_user, rando, inventory, delete, patch):
|
||||
rd, _ = RoleDefinition.objects.get_or_create(
|
||||
name='inventory-delete', permissions=['delete_inventory', 'view_inventory'], content_type=ContentType.objects.get_for_model(Inventory)
|
||||
)
|
||||
rd.give_permission(rando, inventory)
|
||||
inv_id = inventory.pk
|
||||
inv_url = reverse('api:inventory_detail', kwargs={'pk': inv_id})
|
||||
patch(url=inv_url, data={"description": "new"}, user=rando, expect=403)
|
||||
delete(url=inv_url, user=rando, expect=202)
|
||||
assert Inventory.objects.get(id=inv_id).pending_deletion
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_assign_custom_add_role(admin_user, rando, organization, post, setup_managed_roles):
|
||||
rd, _ = RoleDefinition.objects.get_or_create(
|
||||
name='inventory-add', permissions=['add_inventory', 'view_organization'], content_type=ContentType.objects.get_for_model(Organization)
|
||||
)
|
||||
rd.give_permission(rando, organization)
|
||||
url = reverse('api:inventory_list')
|
||||
r = post(url=url, data={'name': 'abc', 'organization': organization.id}, user=rando, expect=201)
|
||||
inv_id = r.data['id']
|
||||
inventory = Inventory.objects.get(id=inv_id)
|
||||
assert rando.has_obj_perm(inventory, 'change')
|
||||
143
awx/main/tests/functional/dab_rbac/test_translation_layer.py
Normal file
143
awx/main/tests/functional/dab_rbac/test_translation_layer.py
Normal file
@@ -0,0 +1,143 @@
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
from crum import impersonate
|
||||
|
||||
from awx.main.models.rbac import get_role_from_object_role, give_creator_permissions
|
||||
from awx.main.models import User, Organization, WorkflowJobTemplate, WorkflowJobTemplateNode, Team
|
||||
from awx.api.versioning import reverse
|
||||
|
||||
from ansible_base.rbac.models import RoleUserAssignment, RoleDefinition
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize(
|
||||
'role_name',
|
||||
['execution_environment_admin_role', 'project_admin_role', 'admin_role', 'auditor_role', 'read_role', 'execute_role', 'notification_admin_role'],
|
||||
)
|
||||
def test_round_trip_roles(organization, rando, role_name, setup_managed_roles):
|
||||
"""
|
||||
Make an assignment with the old-style role,
|
||||
get the equivelent new role
|
||||
get the old role again
|
||||
"""
|
||||
getattr(organization, role_name).members.add(rando)
|
||||
assignment = RoleUserAssignment.objects.get(user=rando)
|
||||
print(assignment.role_definition.name)
|
||||
old_role = get_role_from_object_role(assignment.object_role)
|
||||
assert old_role.id == getattr(organization, role_name).id
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_role_naming(setup_managed_roles):
|
||||
qs = RoleDefinition.objects.filter(content_type=ContentType.objects.get(model='jobtemplate'), name__endswith='dmin')
|
||||
assert qs.count() == 1 # sanity
|
||||
rd = qs.first()
|
||||
assert rd.name == 'JobTemplate Admin'
|
||||
assert rd.description
|
||||
assert rd.created_by is None
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_action_role_naming(setup_managed_roles):
|
||||
qs = RoleDefinition.objects.filter(content_type=ContentType.objects.get(model='jobtemplate'), name__endswith='ecute')
|
||||
assert qs.count() == 1 # sanity
|
||||
rd = qs.first()
|
||||
assert rd.name == 'JobTemplate Execute'
|
||||
assert rd.description
|
||||
assert rd.created_by is None
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_compat_role_naming(setup_managed_roles, job_template, rando, alice):
|
||||
with impersonate(alice):
|
||||
job_template.read_role.members.add(rando)
|
||||
qs = RoleDefinition.objects.filter(content_type=ContentType.objects.get(model='jobtemplate'), name__endswith='ompat')
|
||||
assert qs.count() == 1 # sanity
|
||||
rd = qs.first()
|
||||
assert rd.name == 'JobTemplate Read Compat'
|
||||
assert rd.description
|
||||
assert rd.created_by is None
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_organization_level_permissions(organization, inventory, setup_managed_roles):
|
||||
u1 = User.objects.create(username='alice')
|
||||
u2 = User.objects.create(username='bob')
|
||||
|
||||
organization.inventory_admin_role.members.add(u1)
|
||||
organization.workflow_admin_role.members.add(u2)
|
||||
|
||||
assert u1 in inventory.admin_role
|
||||
assert u1 in organization.inventory_admin_role
|
||||
assert u2 in organization.workflow_admin_role
|
||||
|
||||
assert u2 not in organization.inventory_admin_role
|
||||
assert u1 not in organization.workflow_admin_role
|
||||
assert not (set(u1.has_roles.all()) & set(u2.has_roles.all())) # user have no roles in common
|
||||
|
||||
# Old style
|
||||
assert set(Organization.accessible_objects(u1, 'inventory_admin_role')) == set([organization])
|
||||
assert set(Organization.accessible_objects(u2, 'inventory_admin_role')) == set()
|
||||
assert set(Organization.accessible_objects(u1, 'workflow_admin_role')) == set()
|
||||
assert set(Organization.accessible_objects(u2, 'workflow_admin_role')) == set([organization])
|
||||
|
||||
# New style
|
||||
assert set(Organization.access_qs(u1, 'add_inventory')) == set([organization])
|
||||
assert set(Organization.access_qs(u1, 'change_inventory')) == set([organization])
|
||||
assert set(Organization.access_qs(u2, 'add_inventory')) == set()
|
||||
assert set(Organization.access_qs(u1, 'add_workflowjobtemplate')) == set()
|
||||
assert set(Organization.access_qs(u2, 'add_workflowjobtemplate')) == set([organization])
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_organization_execute_role(organization, rando, setup_managed_roles):
|
||||
organization.execute_role.members.add(rando)
|
||||
assert rando in organization.execute_role
|
||||
assert set(Organization.accessible_objects(rando, 'execute_role')) == set([organization])
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_workflow_approval_list(get, post, admin_user, setup_managed_roles):
|
||||
workflow_job_template = WorkflowJobTemplate.objects.create()
|
||||
approval_node = WorkflowJobTemplateNode.objects.create(workflow_job_template=workflow_job_template)
|
||||
url = reverse('api:workflow_job_template_node_create_approval', kwargs={'pk': approval_node.pk, 'version': 'v2'})
|
||||
post(url, {'name': 'URL Test', 'description': 'An approval', 'timeout': 0}, user=admin_user)
|
||||
approval_node.refresh_from_db()
|
||||
approval_jt = approval_node.unified_job_template
|
||||
approval_jt.create_unified_job()
|
||||
|
||||
r = get(url=reverse('api:workflow_approval_list'), user=admin_user, expect=200)
|
||||
assert r.data['count'] >= 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_creator_permission(rando, admin_user, inventory, setup_managed_roles):
|
||||
give_creator_permissions(rando, inventory)
|
||||
assert rando in inventory.admin_role
|
||||
assert rando in inventory.admin_role.members.all()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_team_team_read_role(rando, team, admin_user, post, setup_managed_roles):
|
||||
orgs = [Organization.objects.create(name=f'foo-{i}') for i in range(2)]
|
||||
teams = [Team.objects.create(name=f'foo-{i}', organization=orgs[i]) for i in range(2)]
|
||||
teams[1].member_role.members.add(rando)
|
||||
|
||||
# give second team read permission to first team through the API for regression testing
|
||||
url = reverse('api:role_teams_list', kwargs={'pk': teams[0].read_role.pk, 'version': 'v2'})
|
||||
post(url, {'id': teams[1].id}, user=admin_user)
|
||||
|
||||
# user should be able to view the first team
|
||||
assert rando in teams[0].read_role
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_implicit_parents_no_assignments(organization):
|
||||
"""Through the normal course of creating models, we should not be changing DAB RBAC permissions"""
|
||||
with mock.patch('awx.main.models.rbac.give_or_remove_permission') as mck:
|
||||
Team.objects.create(name='random team', organization=organization)
|
||||
mck.assert_not_called()
|
||||
@@ -104,11 +104,13 @@ class TestRolesAssociationEntries:
|
||||
else:
|
||||
assert len(entry_qs) == 1
|
||||
# unfortunate, the original creation does _not_ set a real is_auditor field
|
||||
assert 'is_system_auditor' not in json.loads(entry_qs[0].changes)
|
||||
assert 'is_system_auditor' not in json.loads(entry_qs[0].changes) # NOTE: if this fails, see special note
|
||||
# special note - if system auditor flag is moved to user model then we expect this assertion to be changed
|
||||
# make sure that an extra entry is not created, expectation for count would change to 1
|
||||
if value:
|
||||
auditor_changes = json.loads(entry_qs[1].changes)
|
||||
assert auditor_changes['object2'] == 'user'
|
||||
assert auditor_changes['object2_pk'] == u.pk
|
||||
entry = entry_qs[1]
|
||||
assert json.loads(entry.changes) == {'is_system_auditor': [False, True]}
|
||||
assert entry.object1 == 'user'
|
||||
|
||||
def test_user_no_op_api(self, system_auditor):
|
||||
as_ct = ActivityStream.objects.count()
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import pytest
|
||||
|
||||
# AWX context managers for testing
|
||||
from awx.main.models.rbac import batch_role_ancestor_rebuilding
|
||||
from awx.main.signals import disable_activity_stream, disable_computed_fields, update_inventory_computed_fields
|
||||
|
||||
# AWX models
|
||||
@@ -10,15 +9,6 @@ from awx.main.models import ActivityStream, Job
|
||||
from awx.main.tests.functional import immediate_on_commit
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_rbac_batch_rebuilding(rando, organization):
|
||||
with batch_role_ancestor_rebuilding():
|
||||
organization.admin_role.members.add(rando)
|
||||
inventory = organization.inventories.create(name='test-inventory')
|
||||
assert rando not in inventory.admin_role
|
||||
assert rando in inventory.admin_role
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_disable_activity_stream():
|
||||
with disable_activity_stream():
|
||||
|
||||
@@ -50,13 +50,13 @@ def test_org_factory_roles(organization_factory):
|
||||
teams=['team1', 'team2'],
|
||||
users=['team1:foo', 'bar'],
|
||||
projects=['baz', 'bang'],
|
||||
roles=['team2.member_role:foo', 'team1.admin_role:bar', 'team1.admin_role:team2.admin_role', 'baz.admin_role:foo'],
|
||||
roles=['team2.member_role:foo', 'team1.admin_role:bar', 'team1.member_role:team2.admin_role', 'baz.admin_role:foo'],
|
||||
)
|
||||
|
||||
assert objects.users.bar in objects.teams.team2.admin_role
|
||||
assert objects.users.foo in objects.projects.baz.admin_role
|
||||
assert objects.users.foo in objects.teams.team1.member_role
|
||||
assert objects.teams.team2.admin_role in objects.teams.team1.admin_role.children.all()
|
||||
assert objects.teams.team2.admin_role in objects.teams.team1.member_role.children.all()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import pytest
|
||||
|
||||
from django_test_migrations.plan import all_migrations, nodes_to_tuples
|
||||
from django.utils.timezone import now
|
||||
|
||||
"""
|
||||
Most tests that live in here can probably be deleted at some point. They are mainly
|
||||
@@ -68,3 +69,19 @@ class TestMigrationSmoke:
|
||||
bar_peers = bar.peers.all()
|
||||
assert len(bar_peers) == 1
|
||||
assert fooaddr in bar_peers
|
||||
|
||||
def test_migrate_DAB_RBAC(self, migrator):
|
||||
old_state = migrator.apply_initial_migration(('main', '0190_alter_inventorysource_source_and_more'))
|
||||
Organization = old_state.apps.get_model('main', 'Organization')
|
||||
User = old_state.apps.get_model('auth', 'User')
|
||||
|
||||
org = Organization.objects.create(name='arbitrary-org', created=now(), modified=now())
|
||||
user = User.objects.create(username='random-user')
|
||||
org.read_role.members.add(user)
|
||||
|
||||
new_state = migrator.apply_tested_migration(
|
||||
('main', '0192_custom_roles'),
|
||||
)
|
||||
|
||||
RoleUserAssignment = new_state.apps.get_model('dab_rbac', 'RoleUserAssignment')
|
||||
assert RoleUserAssignment.objects.filter(user=user.id, object_id=org.id).exists()
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.conf import settings
|
||||
|
||||
from awx.api.versioning import reverse
|
||||
@@ -23,25 +20,6 @@ from awx.main.models import ( # noqa
|
||||
User,
|
||||
WorkflowJobTemplate,
|
||||
)
|
||||
from awx.conf import settings_registry
|
||||
|
||||
|
||||
def setup_module(module):
|
||||
# In real-world scenario, named url graph structure is populated by __init__
|
||||
# of URLModificationMiddleware. The way Django bootstraps ensures the initialization
|
||||
# will happen *once and only once*, while the number of initialization is uncontrollable
|
||||
# in unit test environment. So it is wrapped by try-except block to mute any
|
||||
# unwanted exceptions.
|
||||
try:
|
||||
URLModificationMiddleware(mock.Mock())
|
||||
except ImproperlyConfigured:
|
||||
pass
|
||||
|
||||
|
||||
def teardown_module(module):
|
||||
# settings_registry will be persistent states unless we explicitly clean them up.
|
||||
settings_registry.unregister('NAMED_URL_FORMATS')
|
||||
settings_registry.unregister('NAMED_URL_GRAPH_NODES')
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
||||
@@ -3,7 +3,9 @@ import pytest
|
||||
|
||||
from django.db import transaction
|
||||
from awx.api.versioning import reverse
|
||||
from awx.main.models.rbac import Role, ROLE_SINGLETON_SYSTEM_ADMINISTRATOR
|
||||
from awx.main.models.rbac import Role
|
||||
|
||||
from django.test.utils import override_settings
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -31,8 +33,6 @@ def test_get_roles_list_user(organization, inventory, team, get, user):
|
||||
'Users can see all roles they have access to, but not all roles'
|
||||
this_user = user('user-test_get_roles_list_user')
|
||||
organization.member_role.members.add(this_user)
|
||||
custom_role = Role.objects.create(role_field='custom_role-test_get_roles_list_user')
|
||||
organization.member_role.children.add(custom_role)
|
||||
|
||||
url = reverse('api:role_list')
|
||||
response = get(url, this_user)
|
||||
@@ -46,10 +46,8 @@ def test_get_roles_list_user(organization, inventory, team, get, user):
|
||||
for r in roles['results']:
|
||||
role_hash[r['id']] = r
|
||||
|
||||
assert Role.singleton(ROLE_SINGLETON_SYSTEM_ADMINISTRATOR).id in role_hash
|
||||
assert organization.admin_role.id in role_hash
|
||||
assert organization.member_role.id in role_hash
|
||||
assert custom_role.id in role_hash
|
||||
|
||||
assert inventory.admin_role.id not in role_hash
|
||||
assert team.member_role.id not in role_hash
|
||||
@@ -57,7 +55,8 @@ def test_get_roles_list_user(organization, inventory, team, get, user):
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_roles_visibility(get, organization, project, admin, alice, bob):
|
||||
Role.singleton('system_auditor').members.add(alice)
|
||||
alice.is_system_auditor = True
|
||||
alice.save()
|
||||
assert get(reverse('api:role_list') + '?id=%d' % project.update_role.id, user=admin).data['count'] == 1
|
||||
assert get(reverse('api:role_list') + '?id=%d' % project.update_role.id, user=alice).data['count'] == 1
|
||||
assert get(reverse('api:role_list') + '?id=%d' % project.update_role.id, user=bob).data['count'] == 0
|
||||
@@ -67,7 +66,8 @@ def test_roles_visibility(get, organization, project, admin, alice, bob):
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_roles_filter_visibility(get, organization, project, admin, alice, bob):
|
||||
Role.singleton('system_auditor').members.add(alice)
|
||||
alice.is_system_auditor = True
|
||||
alice.save()
|
||||
project.update_role.members.add(admin)
|
||||
|
||||
assert get(reverse('api:user_roles_list', kwargs={'pk': admin.id}) + '?id=%d' % project.update_role.id, user=admin).data['count'] == 1
|
||||
@@ -105,15 +105,6 @@ def test_cant_delete_role(delete, admin, inventory):
|
||||
#
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_user_roles_list(get, admin):
|
||||
url = reverse('api:user_roles_list', kwargs={'pk': admin.id})
|
||||
response = get(url, admin)
|
||||
assert response.status_code == 200
|
||||
roles = response.data
|
||||
assert roles['count'] > 0 # 'system_administrator' role if nothing else
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_user_view_other_user_roles(organization, inventory, team, get, alice, bob):
|
||||
'Users can see roles for other users, but only the roles that that user has access to see as well'
|
||||
@@ -141,7 +132,6 @@ def test_user_view_other_user_roles(organization, inventory, team, get, alice, b
|
||||
|
||||
assert organization.admin_role.id in role_hash
|
||||
assert custom_role.id not in role_hash # doesn't show up in the user roles list, not an explicit grant
|
||||
assert Role.singleton(ROLE_SINGLETON_SYSTEM_ADMINISTRATOR).id not in role_hash
|
||||
assert inventory.admin_role.id not in role_hash
|
||||
assert team.member_role.id not in role_hash # alice can't see this
|
||||
|
||||
@@ -197,6 +187,7 @@ def test_remove_role_from_user(role, post, admin):
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ANSIBLE_BASE_ALLOW_TEAM_ORG_ADMIN=True)
|
||||
def test_get_teams_roles_list(get, team, organization, admin):
|
||||
team.member_role.children.add(organization.admin_role)
|
||||
url = reverse('api:team_roles_list', kwargs={'pk': team.id})
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
import pytest
|
||||
|
||||
from awx.main.models import (
|
||||
Role,
|
||||
Organization,
|
||||
Project,
|
||||
)
|
||||
from awx.main.fields import update_role_parentage_for_instance
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_auto_inheritance_by_children(organization, alice):
|
||||
A = Role.objects.create()
|
||||
B = Role.objects.create()
|
||||
A.members.add(alice)
|
||||
|
||||
assert alice not in organization.admin_role
|
||||
assert Organization.accessible_objects(alice, 'admin_role').count() == 0
|
||||
A.children.add(B)
|
||||
assert alice not in organization.admin_role
|
||||
assert Organization.accessible_objects(alice, 'admin_role').count() == 0
|
||||
A.children.add(organization.admin_role)
|
||||
assert alice in organization.admin_role
|
||||
assert Organization.accessible_objects(alice, 'admin_role').count() == 1
|
||||
A.children.remove(organization.admin_role)
|
||||
assert alice not in organization.admin_role
|
||||
B.children.add(organization.admin_role)
|
||||
assert alice in organization.admin_role
|
||||
B.children.remove(organization.admin_role)
|
||||
assert alice not in organization.admin_role
|
||||
assert Organization.accessible_objects(alice, 'admin_role').count() == 0
|
||||
|
||||
# We've had the case where our pre/post save init handlers in our field descriptors
|
||||
# end up creating a ton of role objects because of various not-so-obvious issues
|
||||
assert Role.objects.count() < 50
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_auto_inheritance_by_parents(organization, alice):
|
||||
A = Role.objects.create()
|
||||
B = Role.objects.create()
|
||||
A.members.add(alice)
|
||||
|
||||
assert alice not in organization.admin_role
|
||||
B.parents.add(A)
|
||||
assert alice not in organization.admin_role
|
||||
organization.admin_role.parents.add(A)
|
||||
assert alice in organization.admin_role
|
||||
organization.admin_role.parents.remove(A)
|
||||
assert alice not in organization.admin_role
|
||||
organization.admin_role.parents.add(B)
|
||||
assert alice in organization.admin_role
|
||||
organization.admin_role.parents.remove(B)
|
||||
assert alice not in organization.admin_role
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_accessible_objects(organization, alice, bob):
|
||||
A = Role.objects.create()
|
||||
A.members.add(alice)
|
||||
B = Role.objects.create()
|
||||
B.members.add(alice)
|
||||
B.members.add(bob)
|
||||
|
||||
assert Organization.accessible_objects(alice, 'admin_role').count() == 0
|
||||
assert Organization.accessible_objects(bob, 'admin_role').count() == 0
|
||||
A.children.add(organization.admin_role)
|
||||
assert Organization.accessible_objects(alice, 'admin_role').count() == 1
|
||||
assert Organization.accessible_objects(bob, 'admin_role').count() == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_team_symantics(organization, team, alice):
|
||||
assert alice not in organization.auditor_role
|
||||
team.member_role.children.add(organization.auditor_role)
|
||||
assert alice not in organization.auditor_role
|
||||
team.member_role.members.add(alice)
|
||||
assert alice in organization.auditor_role
|
||||
team.member_role.members.remove(alice)
|
||||
assert alice not in organization.auditor_role
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_auto_field_adjustments(organization, inventory, team, alice):
|
||||
'Ensures the auto role reparenting is working correctly through non m2m fields'
|
||||
org2 = Organization.objects.create(name='Org 2', description='org 2')
|
||||
org2.admin_role.members.add(alice)
|
||||
assert alice not in inventory.admin_role
|
||||
inventory.organization = org2
|
||||
inventory.save()
|
||||
assert alice in inventory.admin_role
|
||||
inventory.organization = organization
|
||||
inventory.save()
|
||||
assert alice not in inventory.admin_role
|
||||
# assert False
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_implicit_deletes(alice):
|
||||
'Ensures implicit resources and roles delete themselves'
|
||||
delorg = Organization.objects.create(name='test-org')
|
||||
child = Role.objects.create()
|
||||
child.parents.add(delorg.admin_role)
|
||||
delorg.admin_role.members.add(alice)
|
||||
|
||||
admin_role_id = delorg.admin_role.id
|
||||
auditor_role_id = delorg.auditor_role.id
|
||||
|
||||
assert child.ancestors.count() > 1
|
||||
assert Role.objects.filter(id=admin_role_id).count() == 1
|
||||
assert Role.objects.filter(id=auditor_role_id).count() == 1
|
||||
n_alice_roles = alice.roles.count()
|
||||
n_system_admin_children = Role.singleton('system_administrator').children.count()
|
||||
|
||||
delorg.delete()
|
||||
|
||||
assert Role.objects.filter(id=admin_role_id).count() == 0
|
||||
assert Role.objects.filter(id=auditor_role_id).count() == 0
|
||||
assert alice.roles.count() == (n_alice_roles - 1)
|
||||
assert Role.singleton('system_administrator').children.count() == (n_system_admin_children - 1)
|
||||
assert child.ancestors.count() == 1
|
||||
assert child.ancestors.all()[0] == child
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_content_object(user):
|
||||
'Ensure our content_object stuf seems to be working'
|
||||
|
||||
org = Organization.objects.create(name='test-org')
|
||||
assert org.admin_role.content_object.id == org.id
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_hierarchy_rebuilding_multi_path():
|
||||
'Tests a subdtle cases around role hierarchy rebuilding when you have multiple paths to the same role of different length'
|
||||
|
||||
X = Role.objects.create()
|
||||
A = Role.objects.create()
|
||||
B = Role.objects.create()
|
||||
C = Role.objects.create()
|
||||
D = Role.objects.create()
|
||||
|
||||
A.children.add(B)
|
||||
A.children.add(D)
|
||||
B.children.add(C)
|
||||
C.children.add(D)
|
||||
|
||||
assert A.is_ancestor_of(D)
|
||||
assert X.is_ancestor_of(D) is False
|
||||
|
||||
X.children.add(A)
|
||||
|
||||
assert X.is_ancestor_of(D) is True
|
||||
|
||||
X.children.remove(A)
|
||||
|
||||
# This can be the stickler, the rebuilder needs to ensure that D's role
|
||||
# hierarchy is built after both A and C are updated.
|
||||
assert X.is_ancestor_of(D) is False
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_auto_parenting():
|
||||
org1 = Organization.objects.create(name='org1')
|
||||
org2 = Organization.objects.create(name='org2')
|
||||
|
||||
prj1 = Project.objects.create(name='prj1')
|
||||
prj2 = Project.objects.create(name='prj2')
|
||||
|
||||
assert org1.admin_role.is_ancestor_of(prj1.admin_role) is False
|
||||
assert org1.admin_role.is_ancestor_of(prj2.admin_role) is False
|
||||
assert org2.admin_role.is_ancestor_of(prj1.admin_role) is False
|
||||
assert org2.admin_role.is_ancestor_of(prj2.admin_role) is False
|
||||
|
||||
prj1.organization = org1
|
||||
prj1.save()
|
||||
|
||||
assert org1.admin_role.is_ancestor_of(prj1.admin_role)
|
||||
assert org1.admin_role.is_ancestor_of(prj2.admin_role) is False
|
||||
assert org2.admin_role.is_ancestor_of(prj1.admin_role) is False
|
||||
assert org2.admin_role.is_ancestor_of(prj2.admin_role) is False
|
||||
|
||||
prj2.organization = org1
|
||||
prj2.save()
|
||||
|
||||
assert org1.admin_role.is_ancestor_of(prj1.admin_role)
|
||||
assert org1.admin_role.is_ancestor_of(prj2.admin_role)
|
||||
assert org2.admin_role.is_ancestor_of(prj1.admin_role) is False
|
||||
assert org2.admin_role.is_ancestor_of(prj2.admin_role) is False
|
||||
|
||||
prj1.organization = org2
|
||||
prj1.save()
|
||||
|
||||
assert org1.admin_role.is_ancestor_of(prj1.admin_role) is False
|
||||
assert org1.admin_role.is_ancestor_of(prj2.admin_role)
|
||||
assert org2.admin_role.is_ancestor_of(prj1.admin_role)
|
||||
assert org2.admin_role.is_ancestor_of(prj2.admin_role) is False
|
||||
|
||||
prj2.organization = org2
|
||||
prj2.save()
|
||||
|
||||
assert org1.admin_role.is_ancestor_of(prj1.admin_role) is False
|
||||
assert org1.admin_role.is_ancestor_of(prj2.admin_role) is False
|
||||
assert org2.admin_role.is_ancestor_of(prj1.admin_role)
|
||||
assert org2.admin_role.is_ancestor_of(prj2.admin_role)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_update_parents_keeps_teams(team, project):
|
||||
project.update_role.parents.add(team.member_role)
|
||||
assert list(Project.accessible_objects(team.member_role, 'update_role')) == [project] # test prep sanity check
|
||||
update_role_parentage_for_instance(project)
|
||||
assert list(Project.accessible_objects(team.member_role, 'update_role')) == [project] # actual assertion
|
||||
@@ -4,7 +4,7 @@ import pytest
|
||||
from awx.api.versioning import reverse
|
||||
from awx.main.access import BaseAccess, JobTemplateAccess, ScheduleAccess
|
||||
from awx.main.models.jobs import JobTemplate
|
||||
from awx.main.models import Project, Organization, Inventory, Schedule, User
|
||||
from awx.main.models import Project, Organization, Schedule
|
||||
|
||||
|
||||
@mock.patch.object(BaseAccess, 'check_license', return_value=None)
|
||||
@@ -165,7 +165,7 @@ class TestOrphanJobTemplate:
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.job_permissions
|
||||
def test_job_template_creator_access(project, organization, rando, post):
|
||||
def test_job_template_creator_access(project, organization, rando, post, setup_managed_roles):
|
||||
project.use_role.members.add(rando)
|
||||
response = post(
|
||||
url=reverse('api:job_template_list'),
|
||||
@@ -177,7 +177,7 @@ def test_job_template_creator_access(project, organization, rando, post):
|
||||
jt_pk = response.data['id']
|
||||
jt_obj = JobTemplate.objects.get(pk=jt_pk)
|
||||
# Creating a JT should place the creator in the admin role
|
||||
assert rando in jt_obj.admin_role.members.all()
|
||||
assert rando in jt_obj.admin_role
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -283,48 +283,3 @@ class TestProjectOrganization:
|
||||
assert org_admin not in jt.admin_role
|
||||
patch(url=jt.get_absolute_url(), data={'project': project.id}, user=admin_user, expect=200)
|
||||
assert org_admin in jt.admin_role
|
||||
|
||||
def test_inventory_read_transfer_direct(self, patch):
|
||||
orgs = []
|
||||
invs = []
|
||||
admins = []
|
||||
for i in range(2):
|
||||
org = Organization.objects.create(name='org{}'.format(i))
|
||||
org_admin = User.objects.create(username='user{}'.format(i))
|
||||
inv = Inventory.objects.create(organization=org, name='inv{}'.format(i))
|
||||
org.auditor_role.members.add(org_admin)
|
||||
|
||||
orgs.append(org)
|
||||
admins.append(org_admin)
|
||||
invs.append(inv)
|
||||
|
||||
jt = JobTemplate.objects.create(name='foo', inventory=invs[0])
|
||||
assert admins[0] in jt.read_role
|
||||
assert admins[1] not in jt.read_role
|
||||
|
||||
jt.inventory = invs[1]
|
||||
jt.save(update_fields=['inventory'])
|
||||
assert admins[0] not in jt.read_role
|
||||
assert admins[1] in jt.read_role
|
||||
|
||||
def test_inventory_read_transfer_indirect(self, patch):
|
||||
orgs = []
|
||||
admins = []
|
||||
for i in range(2):
|
||||
org = Organization.objects.create(name='org{}'.format(i))
|
||||
org_admin = User.objects.create(username='user{}'.format(i))
|
||||
org.auditor_role.members.add(org_admin)
|
||||
|
||||
orgs.append(org)
|
||||
admins.append(org_admin)
|
||||
|
||||
inv = Inventory.objects.create(organization=orgs[0], name='inv{}'.format(i))
|
||||
|
||||
jt = JobTemplate.objects.create(name='foo', inventory=inv)
|
||||
assert admins[0] in jt.read_role
|
||||
assert admins[1] not in jt.read_role
|
||||
|
||||
inv.organization = orgs[1]
|
||||
inv.save(update_fields=['organization'])
|
||||
assert admins[0] not in jt.read_role
|
||||
assert admins[1] in jt.read_role
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import pytest
|
||||
|
||||
from django.apps import apps
|
||||
|
||||
from awx.main.migrations import _rbac as rbac
|
||||
from awx.main.models import UnifiedJobTemplate, InventorySource, Inventory, JobTemplate, Project, Organization, User
|
||||
from awx.main.models import UnifiedJobTemplate, InventorySource, Inventory, JobTemplate, Project, Organization
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -49,27 +47,3 @@ def test_implied_organization_subquery_job_template():
|
||||
assert jt.test_field is None
|
||||
else:
|
||||
assert jt.test_field == jt.project.organization_id
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_give_explicit_inventory_permission():
|
||||
dual_admin = User.objects.create(username='alice')
|
||||
inv_admin = User.objects.create(username='bob')
|
||||
inv_org = Organization.objects.create(name='inv-org')
|
||||
proj_org = Organization.objects.create(name='proj-org')
|
||||
|
||||
inv_org.admin_role.members.add(inv_admin, dual_admin)
|
||||
proj_org.admin_role.members.add(dual_admin)
|
||||
|
||||
proj = Project.objects.create(name="test-proj", organization=proj_org)
|
||||
inv = Inventory.objects.create(name='test-inv', organization=inv_org)
|
||||
|
||||
jt = JobTemplate.objects.create(name='foo', project=proj, inventory=inv)
|
||||
|
||||
assert dual_admin in jt.admin_role
|
||||
|
||||
rbac.restore_inventory_admins(apps, None)
|
||||
|
||||
assert inv_admin in jt.admin_role.members.all()
|
||||
assert dual_admin not in jt.admin_role.members.all()
|
||||
assert dual_admin in jt.admin_role
|
||||
|
||||
@@ -92,7 +92,7 @@ def test_team_accessible_by(team, user, project):
|
||||
u = user('team_member', False)
|
||||
|
||||
team.member_role.children.add(project.use_role)
|
||||
assert list(Project.accessible_objects(team.member_role, 'read_role')) == [project]
|
||||
assert list(Project.accessible_objects(team, 'read_role')) == [project]
|
||||
assert u not in project.read_role
|
||||
|
||||
team.member_role.members.add(u)
|
||||
@@ -104,7 +104,7 @@ def test_team_accessible_objects(team, user, project):
|
||||
u = user('team_member', False)
|
||||
|
||||
team.member_role.children.add(project.use_role)
|
||||
assert len(Project.accessible_objects(team.member_role, 'read_role')) == 1
|
||||
assert len(Project.accessible_objects(team, 'read_role')) == 1
|
||||
assert not Project.accessible_objects(u, 'read_role')
|
||||
|
||||
team.member_role.members.add(u)
|
||||
|
||||
@@ -4,7 +4,7 @@ from unittest import mock
|
||||
from django.test import TransactionTestCase
|
||||
|
||||
from awx.main.access import UserAccess, RoleAccess, TeamAccess
|
||||
from awx.main.models import User, Organization, Inventory, Role
|
||||
from awx.main.models import User, Organization, Inventory, get_system_auditor_role
|
||||
|
||||
|
||||
class TestSysAuditorTransactional(TransactionTestCase):
|
||||
@@ -18,7 +18,8 @@ class TestSysAuditorTransactional(TransactionTestCase):
|
||||
|
||||
def test_auditor_caching(self):
|
||||
rando = self.rando()
|
||||
with self.assertNumQueries(1):
|
||||
get_system_auditor_role() # pre-create role, normally done by migrations
|
||||
with self.assertNumQueries(2):
|
||||
v = rando.is_system_auditor
|
||||
assert not v
|
||||
with self.assertNumQueries(0):
|
||||
@@ -153,34 +154,3 @@ def test_org_admin_cannot_delete_member_attached_to_other_group(org_admin, org_m
|
||||
access = UserAccess(org_admin)
|
||||
other_org.member_role.members.add(org_member)
|
||||
assert not access.can_delete(org_member)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('reverse', (True, False))
|
||||
@pytest.mark.django_db
|
||||
def test_consistency_of_is_superuser_flag(reverse):
|
||||
users = [User.objects.create(username='rando_{}'.format(i)) for i in range(2)]
|
||||
for u in users:
|
||||
assert u.is_superuser is False
|
||||
|
||||
system_admin = Role.singleton('system_administrator')
|
||||
if reverse:
|
||||
for u in users:
|
||||
u.roles.add(system_admin)
|
||||
else:
|
||||
system_admin.members.add(*[u.id for u in users]) # like .add(42, 54)
|
||||
|
||||
for u in users:
|
||||
u.refresh_from_db()
|
||||
assert u.is_superuser is True
|
||||
|
||||
users[0].roles.clear()
|
||||
for u in users:
|
||||
u.refresh_from_db()
|
||||
assert users[0].is_superuser is False
|
||||
assert users[1].is_superuser is True
|
||||
|
||||
system_admin.members.clear()
|
||||
|
||||
for u in users:
|
||||
u.refresh_from_db()
|
||||
assert u.is_superuser is False
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.django_db()
|
||||
def test_admin_not_member(team):
|
||||
"""Test to ensure we don't add admin_role as a parent to team.member_role, as
|
||||
this creates a cycle with organization administration, which we've decided
|
||||
to remove support for
|
||||
|
||||
(2016-06-16) I think this might have been resolved. I'm asserting
|
||||
this to be true in the mean time.
|
||||
"""
|
||||
|
||||
assert team.admin_role.is_ancestor_of(team.member_role) is True
|
||||
@@ -52,7 +52,7 @@ class TestDumpAuthConfigCommand(TestCase):
|
||||
super().setUp()
|
||||
self.expected_config = [
|
||||
{
|
||||
"type": "awx.authentication.authenticator_plugins.saml",
|
||||
"type": "ansible_base.authentication.authenticator_plugins.saml",
|
||||
"name": "Keycloak",
|
||||
"enabled": True,
|
||||
"create_objects": True,
|
||||
@@ -94,14 +94,14 @@ class TestDumpAuthConfigCommand(TestCase):
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "awx.authentication.authenticator_plugins.ldap",
|
||||
"name": "1",
|
||||
"type": "ansible_base.authentication.authenticator_plugins.ldap",
|
||||
"name": "LDAP_1",
|
||||
"enabled": True,
|
||||
"create_objects": True,
|
||||
"users_unique": False,
|
||||
"remove_users": True,
|
||||
"configuration": {
|
||||
"SERVER_URI": "SERVER_URI",
|
||||
"SERVER_URI": ["SERVER_URI"],
|
||||
"BIND_DN": "BIND_DN",
|
||||
"BIND_PASSWORD": "BIND_PASSWORD",
|
||||
"CONNECTION_OPTIONS": {},
|
||||
@@ -119,4 +119,14 @@ class TestDumpAuthConfigCommand(TestCase):
|
||||
def test_json_returned_from_cmd(self):
|
||||
output = StringIO()
|
||||
call_command("dump_auth_config", stdout=output)
|
||||
assert json.loads(output.getvalue()) == self.expected_config
|
||||
cmmd_output = json.loads(output.getvalue())
|
||||
|
||||
# check configured SAML return
|
||||
assert cmmd_output[0] == self.expected_config[0]
|
||||
|
||||
# check configured LDAP return
|
||||
assert cmmd_output[2] == self.expected_config[1]
|
||||
|
||||
# check unconfigured LDAP return
|
||||
assert "LDAP_0_missing_fields" in cmmd_output[1]
|
||||
assert cmmd_output[1]["LDAP_0_missing_fields"] == ['SERVER_URI', 'GROUP_TYPE', 'GROUP_TYPE_PARAMS', 'USER_DN_TEMPLATE', 'USER_ATTR_MAP']
|
||||
|
||||
@@ -1106,6 +1106,44 @@ class TestJobCredentials(TestJobExecution):
|
||||
config = open(local_path, 'r').read()
|
||||
assert config == hcl_config
|
||||
|
||||
def test_terraform_gcs_backend_credentials(self, job, private_data_dir, mock_me):
|
||||
terraform = CredentialType.defaults['terraform']()
|
||||
hcl_config = '''
|
||||
backend "gcs" {
|
||||
bucket = "gce_storage"
|
||||
}
|
||||
'''
|
||||
gce_backend_credentials = '''
|
||||
{
|
||||
"type": "service_account",
|
||||
"project_id": "sample",
|
||||
"private_key_id": "eeeeeeeeeeeeeeeeeeeeeeeeeee",
|
||||
"private_key": "-----BEGIN PRIVATE KEY-----\naaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n-----END PRIVATE KEY-----\n",
|
||||
"client_email": "sample@sample.iam.gserviceaccount.com",
|
||||
"client_id": "0123456789",
|
||||
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
||||
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/cloud-content-robot%40sample.iam.gserviceaccount.com",
|
||||
}
|
||||
'''
|
||||
credential = Credential(pk=1, credential_type=terraform, inputs={'configuration': hcl_config, 'gce_credentials': gce_backend_credentials})
|
||||
credential.inputs['configuration'] = encrypt_field(credential, 'configuration')
|
||||
credential.inputs['gce_credentials'] = encrypt_field(credential, 'gce_credentials')
|
||||
job.credentials.add(credential)
|
||||
|
||||
env = {}
|
||||
safe_env = {}
|
||||
credential.credential_type.inject_credential(credential, env, safe_env, [], private_data_dir)
|
||||
|
||||
local_path = to_host_path(env['TF_BACKEND_CONFIG_FILE'], private_data_dir)
|
||||
config = open(local_path, 'r').read()
|
||||
assert config == hcl_config
|
||||
|
||||
credentials_path = to_host_path(env['GOOGLE_BACKEND_CREDENTIALS'], private_data_dir)
|
||||
credentials = open(credentials_path, 'r').read()
|
||||
assert credentials == gce_backend_credentials
|
||||
|
||||
def test_custom_environment_injectors_with_jinja_syntax_error(self, private_data_dir, mock_me):
|
||||
some_cloud = CredentialType(
|
||||
kind='cloud',
|
||||
|
||||
@@ -69,7 +69,7 @@ class mockHost:
|
||||
@mock.patch('awx.main.utils.filters.get_model', return_value=mockHost())
|
||||
class TestSmartFilterQueryFromString:
|
||||
@mock.patch(
|
||||
'ansible_base.rest_filters.rest_framework.field_lookup_backend.get_fields_from_path', lambda model, path: ([model], path)
|
||||
'ansible_base.rest_filters.rest_framework.field_lookup_backend.get_fields_from_path', lambda model, path, **kwargs: ([model], path)
|
||||
) # disable field filtering, because a__b isn't a real Host field
|
||||
@pytest.mark.parametrize(
|
||||
"filter_string,q_expected",
|
||||
|
||||
@@ -5,7 +5,6 @@ from collections import deque
|
||||
# Django
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
|
||||
NAMED_URL_RES_DILIMITER = "++"
|
||||
@@ -245,6 +244,8 @@ def _generate_configurations(nodes):
|
||||
|
||||
|
||||
def _dfs(configuration, model, graph, dead_ends, new_deadends, parents):
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
parents.add(model)
|
||||
fields, fk_names = configuration[model][0][:], configuration[model][1][:]
|
||||
adj_list = []
|
||||
@@ -306,3 +307,19 @@ def generate_graph(models):
|
||||
def reset_counters():
|
||||
for node in settings.NAMED_URL_GRAPH.values():
|
||||
node.counter = 0
|
||||
|
||||
|
||||
def _customize_graph():
|
||||
from django.contrib.auth.models import User
|
||||
from awx.main.models import Instance, Schedule, UnifiedJobTemplate
|
||||
|
||||
for model in [Schedule, UnifiedJobTemplate]:
|
||||
if model in settings.NAMED_URL_GRAPH:
|
||||
settings.NAMED_URL_GRAPH[model].remove_bindings()
|
||||
settings.NAMED_URL_GRAPH.pop(model)
|
||||
if User not in settings.NAMED_URL_GRAPH:
|
||||
settings.NAMED_URL_GRAPH[User] = GraphNode(User, ['username'], [])
|
||||
settings.NAMED_URL_GRAPH[User].add_bindings()
|
||||
if Instance not in settings.NAMED_URL_GRAPH:
|
||||
settings.NAMED_URL_GRAPH[Instance] = GraphNode(Instance, ['hostname'], [])
|
||||
settings.NAMED_URL_GRAPH[Instance].add_bindings()
|
||||
|
||||
@@ -242,7 +242,7 @@ class WebSocketRelayManager(object):
|
||||
# In this case, we'll be sharing a redis, no need to relay.
|
||||
if payload.get("hostname") == self.local_hostname:
|
||||
hostname = payload.get("hostname")
|
||||
logger.debug("Received a heartbeat request for {hostname}. Skipping as we use redis for local host.")
|
||||
logger.debug(f"Received a heartbeat request for {hostname}. Skipping as we use redis for local host.")
|
||||
continue
|
||||
|
||||
action = payload.get("action")
|
||||
@@ -285,6 +285,8 @@ class WebSocketRelayManager(object):
|
||||
except asyncio.CancelledError:
|
||||
# Handle the case where the task was already cancelled by the time we got here.
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to cancel relay connection for {hostname}: {e}")
|
||||
|
||||
del self.relay_connections[hostname]
|
||||
|
||||
@@ -295,6 +297,8 @@ class WebSocketRelayManager(object):
|
||||
self.stats_mgr.delete_remote_host_stats(hostname)
|
||||
except KeyError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to delete stats for {hostname}: {e}")
|
||||
|
||||
async def run(self):
|
||||
event_loop = asyncio.get_running_loop()
|
||||
@@ -316,57 +320,77 @@ class WebSocketRelayManager(object):
|
||||
|
||||
task = None
|
||||
|
||||
# Managing the async_conn here so that we can close it if we need to restart the connection
|
||||
async_conn = None
|
||||
|
||||
# Establishes a websocket connection to /websocket/relay on all API servers
|
||||
while True:
|
||||
if not task or task.done():
|
||||
try:
|
||||
while True:
|
||||
if not task or task.done():
|
||||
try:
|
||||
# Try to close the connection if it's open
|
||||
if async_conn:
|
||||
try:
|
||||
await async_conn.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to close connection to database for pg_notify: {e}")
|
||||
|
||||
# and re-establish the connection
|
||||
async_conn = await psycopg.AsyncConnection.connect(
|
||||
dbname=database_conf['NAME'],
|
||||
host=database_conf['HOST'],
|
||||
user=database_conf['USER'],
|
||||
port=database_conf['PORT'],
|
||||
**database_conf.get("OPTIONS", {}),
|
||||
)
|
||||
await async_conn.set_autocommit(True)
|
||||
|
||||
# before creating the task that uses the connection
|
||||
task = event_loop.create_task(self.on_ws_heartbeat(async_conn), name="on_ws_heartbeat")
|
||||
logger.info("Creating `on_ws_heartbeat` task in event loop.")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to connect to database for pg_notify: {e}")
|
||||
|
||||
future_remote_hosts = self.known_hosts.keys()
|
||||
current_remote_hosts = self.relay_connections.keys()
|
||||
deleted_remote_hosts = set(current_remote_hosts) - set(future_remote_hosts)
|
||||
new_remote_hosts = set(future_remote_hosts) - set(current_remote_hosts)
|
||||
|
||||
# This loop handles if we get an advertisement from a host we already know about but
|
||||
# the advertisement has a different IP than we are currently connected to.
|
||||
for hostname, address in self.known_hosts.items():
|
||||
if hostname not in self.relay_connections:
|
||||
# We've picked up a new hostname that we don't know about yet.
|
||||
continue
|
||||
|
||||
if address != self.relay_connections[hostname].remote_host:
|
||||
deleted_remote_hosts.add(hostname)
|
||||
new_remote_hosts.add(hostname)
|
||||
|
||||
# Delete any hosts with closed connections
|
||||
for hostname, relay_conn in self.relay_connections.items():
|
||||
if not relay_conn.connected:
|
||||
deleted_remote_hosts.add(hostname)
|
||||
|
||||
if deleted_remote_hosts:
|
||||
logger.info(f"Removing {deleted_remote_hosts} from websocket broadcast list")
|
||||
await asyncio.gather(*[self.cleanup_offline_host(h) for h in deleted_remote_hosts])
|
||||
|
||||
if new_remote_hosts:
|
||||
logger.info(f"Adding {new_remote_hosts} to websocket broadcast list")
|
||||
|
||||
for h in new_remote_hosts:
|
||||
stats = self.stats_mgr.new_remote_host_stats(h)
|
||||
relay_connection = WebsocketRelayConnection(name=self.local_hostname, stats=stats, remote_host=self.known_hosts[h])
|
||||
relay_connection.start()
|
||||
self.relay_connections[h] = relay_connection
|
||||
|
||||
await asyncio.sleep(settings.BROADCAST_WEBSOCKET_NEW_INSTANCE_POLL_RATE_SECONDS)
|
||||
finally:
|
||||
if async_conn:
|
||||
logger.info("Shutting down db connection for wsrelay.")
|
||||
try:
|
||||
async_conn = await psycopg.AsyncConnection.connect(
|
||||
dbname=database_conf['NAME'],
|
||||
host=database_conf['HOST'],
|
||||
user=database_conf['USER'],
|
||||
port=database_conf['PORT'],
|
||||
**database_conf.get("OPTIONS", {}),
|
||||
)
|
||||
await async_conn.set_autocommit(True)
|
||||
|
||||
task = event_loop.create_task(self.on_ws_heartbeat(async_conn), name="on_ws_heartbeat")
|
||||
logger.info("Creating `on_ws_heartbeat` task in event loop.")
|
||||
|
||||
await async_conn.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to connect to database for pg_notify: {e}")
|
||||
|
||||
future_remote_hosts = self.known_hosts.keys()
|
||||
current_remote_hosts = self.relay_connections.keys()
|
||||
deleted_remote_hosts = set(current_remote_hosts) - set(future_remote_hosts)
|
||||
new_remote_hosts = set(future_remote_hosts) - set(current_remote_hosts)
|
||||
|
||||
# This loop handles if we get an advertisement from a host we already know about but
|
||||
# the advertisement has a different IP than we are currently connected to.
|
||||
for hostname, address in self.known_hosts.items():
|
||||
if hostname not in self.relay_connections:
|
||||
# We've picked up a new hostname that we don't know about yet.
|
||||
continue
|
||||
|
||||
if address != self.relay_connections[hostname].remote_host:
|
||||
deleted_remote_hosts.add(hostname)
|
||||
new_remote_hosts.add(hostname)
|
||||
|
||||
# Delete any hosts with closed connections
|
||||
for hostname, relay_conn in self.relay_connections.items():
|
||||
if not relay_conn.connected:
|
||||
deleted_remote_hosts.add(hostname)
|
||||
|
||||
if deleted_remote_hosts:
|
||||
logger.info(f"Removing {deleted_remote_hosts} from websocket broadcast list")
|
||||
await asyncio.gather(*[self.cleanup_offline_host(h) for h in deleted_remote_hosts])
|
||||
|
||||
if new_remote_hosts:
|
||||
logger.info(f"Adding {new_remote_hosts} to websocket broadcast list")
|
||||
|
||||
for h in new_remote_hosts:
|
||||
stats = self.stats_mgr.new_remote_host_stats(h)
|
||||
relay_connection = WebsocketRelayConnection(name=self.local_hostname, stats=stats, remote_host=self.known_hosts[h])
|
||||
relay_connection.start()
|
||||
self.relay_connections[h] = relay_connection
|
||||
|
||||
await asyncio.sleep(settings.BROADCAST_WEBSOCKET_NEW_INSTANCE_POLL_RATE_SECONDS)
|
||||
logger.info(f"Failed to close connection to database for pg_notify: {e}")
|
||||
|
||||
@@ -277,6 +277,9 @@ SESSION_COOKIE_SECURE = True
|
||||
# Note: This setting may be overridden by database settings.
|
||||
SESSION_COOKIE_AGE = 1800
|
||||
|
||||
# Option to change userLoggedIn cookie SameSite policy.
|
||||
USER_COOKIE_SAMESITE = 'Lax'
|
||||
|
||||
# Name of the cookie that contains the session information.
|
||||
# Note: Changing this value may require changes to any clients.
|
||||
SESSION_COOKIE_NAME = 'awx_sessionid'
|
||||
@@ -355,6 +358,7 @@ INSTALLED_APPS = [
|
||||
'ansible_base.rest_filters',
|
||||
'ansible_base.jwt_consumer',
|
||||
'ansible_base.resource_registry',
|
||||
'ansible_base.rbac',
|
||||
]
|
||||
|
||||
|
||||
@@ -497,6 +501,12 @@ CACHES = {'default': {'BACKEND': 'awx.main.cache.AWXRedisCache', 'LOCATION': 'un
|
||||
SOCIAL_AUTH_STRATEGY = 'social_django.strategy.DjangoStrategy'
|
||||
SOCIAL_AUTH_STORAGE = 'social_django.models.DjangoStorage'
|
||||
SOCIAL_AUTH_USER_MODEL = 'auth.User'
|
||||
ROLE_SINGLETON_USER_RELATIONSHIP = ''
|
||||
ROLE_SINGLETON_TEAM_RELATIONSHIP = ''
|
||||
|
||||
# We want to short-circuit RBAC methods to get permission to system admins and auditors
|
||||
ROLE_BYPASS_SUPERUSER_FLAGS = ['is_superuser']
|
||||
ROLE_BYPASS_ACTION_FLAGS = {'view': 'is_system_auditor'}
|
||||
|
||||
_SOCIAL_AUTH_PIPELINE_BASE = (
|
||||
'social_core.pipeline.social_auth.social_details',
|
||||
@@ -873,6 +883,7 @@ LOGGING = {
|
||||
'loggers': {
|
||||
'django': {'handlers': ['console']},
|
||||
'django.request': {'handlers': ['console', 'file', 'tower_warnings'], 'level': 'WARNING'},
|
||||
'ansible_base': {'handlers': ['console', 'file', 'tower_warnings']},
|
||||
'daphne': {'handlers': ['console', 'file', 'tower_warnings'], 'level': 'INFO'},
|
||||
'rest_framework.request': {'handlers': ['console', 'file', 'tower_warnings'], 'level': 'WARNING', 'propagate': False},
|
||||
'py.warnings': {'handlers': ['console']},
|
||||
@@ -1002,6 +1013,7 @@ MIDDLEWARE = [
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'awx.main.middleware.DisableLocalAuthMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'awx.main.middleware.OptionalURLPrefixPath',
|
||||
'awx.sso.middleware.SocialAuthMiddleware',
|
||||
'crum.CurrentRequestUserMiddleware',
|
||||
'awx.main.middleware.URLModificationMiddleware',
|
||||
@@ -1120,11 +1132,11 @@ METRICS_SUBSYSTEM_CONFIG = {
|
||||
ANSIBLE_BASE_TEAM_MODEL = 'main.Team'
|
||||
ANSIBLE_BASE_ORGANIZATION_MODEL = 'main.Organization'
|
||||
ANSIBLE_BASE_RESOURCE_CONFIG_MODULE = 'awx.resource_api'
|
||||
ANSIBLE_BASE_PERMISSION_MODEL = 'main.Permission'
|
||||
|
||||
from ansible_base.lib import dynamic_config # noqa: E402
|
||||
|
||||
settings_file = os.path.join(os.path.dirname(dynamic_config.__file__), 'dynamic_settings.py')
|
||||
include(settings_file)
|
||||
include(os.path.join(os.path.dirname(dynamic_config.__file__), 'dynamic_settings.py'))
|
||||
|
||||
# Add a postfix to the API URL patterns
|
||||
# example if set to '' API pattern will be /api
|
||||
@@ -1133,3 +1145,37 @@ OPTIONAL_API_URLPATTERN_PREFIX = ''
|
||||
|
||||
# Use AWX base view, to give 401 on unauthenticated requests
|
||||
ANSIBLE_BASE_CUSTOM_VIEW_PARENT = 'awx.api.generics.APIView'
|
||||
|
||||
# Settings for the ansible_base RBAC system
|
||||
|
||||
# This has been moved to data migration code
|
||||
ANSIBLE_BASE_ROLE_PRECREATE = {}
|
||||
|
||||
# Name for auto-created roles that give users permissions to what they create
|
||||
ANSIBLE_BASE_ROLE_CREATOR_NAME = '{cls.__name__} Creator'
|
||||
|
||||
# Use the new Gateway RBAC system for evaluations? You should. We will remove the old system soon.
|
||||
ANSIBLE_BASE_ROLE_SYSTEM_ACTIVATED = True
|
||||
|
||||
# Permissions a user will get when creating a new item
|
||||
ANSIBLE_BASE_CREATOR_DEFAULTS = ['change', 'delete', 'execute', 'use', 'adhoc', 'approve', 'update', 'view']
|
||||
|
||||
# This is a stopgap, will delete after resource registry integration
|
||||
ANSIBLE_BASE_SERVICE_PREFIX = "awx"
|
||||
|
||||
# Temporary, for old roles API compatibility, save child permissions at organization level
|
||||
ANSIBLE_BASE_CACHE_PARENT_PERMISSIONS = True
|
||||
|
||||
# Currently features are enabled to keep compatibility with old system, except custom roles
|
||||
ANSIBLE_BASE_ALLOW_TEAM_ORG_ADMIN = False
|
||||
# ANSIBLE_BASE_ALLOW_CUSTOM_ROLES = True
|
||||
ANSIBLE_BASE_ALLOW_CUSTOM_TEAM_ROLES = False
|
||||
ANSIBLE_BASE_ALLOW_SINGLETON_USER_ROLES = True
|
||||
ANSIBLE_BASE_ALLOW_SINGLETON_TEAM_ROLES = False # System auditor has always been restricted to users
|
||||
ANSIBLE_BASE_ALLOW_SINGLETON_ROLES_API = False # Do not allow creating user-defined system-wide roles
|
||||
|
||||
# system username for django-ansible-base
|
||||
SYSTEM_USERNAME = None
|
||||
|
||||
# Use AWX base view, to give 401 on unauthenticated requests
|
||||
ANSIBLE_BASE_CUSTOM_VIEW_PARENT = 'awx.api.generics.APIView'
|
||||
|
||||
@@ -38,7 +38,9 @@ class CompleteView(BaseRedirectView):
|
||||
response = super(CompleteView, self).dispatch(request, *args, **kwargs)
|
||||
if self.request.user and self.request.user.is_authenticated:
|
||||
logger.info(smart_str(u"User {} logged in".format(self.request.user.username)))
|
||||
response.set_cookie('userLoggedIn', 'true', secure=getattr(settings, 'SESSION_COOKIE_SECURE', False))
|
||||
response.set_cookie(
|
||||
'userLoggedIn', 'true', secure=getattr(settings, 'SESSION_COOKIE_SECURE', False), samesite=getattr(settings, 'USER_COOKIE_SAMESITE', 'Lax')
|
||||
)
|
||||
response.setdefault('X-API-Session-Cookie-Name', getattr(settings, 'SESSION_COOKIE_NAME', 'awx_sessionid'))
|
||||
return response
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import AssociateModal from 'components/AssociateModal';
|
||||
import ErrorDetail from 'components/ErrorDetail';
|
||||
import AlertModal from 'components/AlertModal';
|
||||
import useToast, { AlertVariant } from 'hooks/useToast';
|
||||
import { getQSConfig, parseQueryString, mergeParams } from 'util/qs';
|
||||
import { getQSConfig, parseQueryString } from 'util/qs';
|
||||
import { useLocation, useParams } from 'react-router-dom';
|
||||
import useRequest, { useDismissableError } from 'hooks/useRequest';
|
||||
import DataListToolbar from 'components/DataListToolbar';
|
||||
@@ -106,62 +106,38 @@ function InstancePeerList({ setBreadcrumb }) {
|
||||
const { selected, isAllSelected, handleSelect, clearSelected, selectAll } =
|
||||
useSelected(peers);
|
||||
|
||||
const fetchInstancesToAssociate = useCallback(
|
||||
const fetchPeersToAssociate = useCallback(
|
||||
async (params) => {
|
||||
const address_list = [];
|
||||
|
||||
const instances = await InstancesAPI.read(
|
||||
mergeParams(params, {
|
||||
...{ not__node_type: ['control', 'hybrid'] },
|
||||
})
|
||||
);
|
||||
const receptors = (await ReceptorAPI.read()).data.results;
|
||||
// do not show this instance or instances that are already peered
|
||||
// to this instance (reverse_peers)
|
||||
const not_instances = instance.reverse_peers;
|
||||
not_instances.push(instance.id);
|
||||
|
||||
// get instance ids of the current peered receptor ids
|
||||
const already_peered_instance_ids = [];
|
||||
for (let h = 0; h < instance.peers.length; h++) {
|
||||
const matched = receptors.filter((obj) => obj.id === instance.peers[h]);
|
||||
matched.forEach((element) => {
|
||||
already_peered_instance_ids.push(element.instance);
|
||||
});
|
||||
params.not__instance = not_instances;
|
||||
params.is_internal = false;
|
||||
// do not show the current peers
|
||||
if (instance.peers.length > 0) {
|
||||
params.not__id__in = instance.peers.join(',');
|
||||
}
|
||||
|
||||
for (let q = 0; q < receptors.length; q++) {
|
||||
const receptor = receptors[q];
|
||||
const receptoraddresses = await ReceptorAPI.read(params);
|
||||
|
||||
if (already_peered_instance_ids.includes(receptor.instance)) {
|
||||
// ignore reverse peers
|
||||
continue;
|
||||
}
|
||||
// retrieve the instances that are associated with those receptor addresses
|
||||
const instance_ids = receptoraddresses.data.results.map(
|
||||
(obj) => obj.instance
|
||||
);
|
||||
const instance_ids_str = instance_ids.join(',');
|
||||
const instances = await InstancesAPI.read({ id__in: instance_ids_str });
|
||||
|
||||
if (instance.peers.includes(receptor.id)) {
|
||||
// no links to existing links
|
||||
continue;
|
||||
}
|
||||
|
||||
if (instance.id === receptor.instance) {
|
||||
// no links to thy self
|
||||
continue;
|
||||
}
|
||||
|
||||
if (instance.managed) {
|
||||
// no managed nodes
|
||||
continue;
|
||||
}
|
||||
for (let q = 0; q < receptoraddresses.data.results.length; q++) {
|
||||
const receptor = receptoraddresses.data.results[q];
|
||||
|
||||
const host = instances.data.results.filter(
|
||||
(obj) => obj.id === receptor.instance
|
||||
)[0];
|
||||
|
||||
if (host === undefined) {
|
||||
// no hosts
|
||||
continue;
|
||||
}
|
||||
|
||||
if (receptor.is_internal) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const copy = receptor;
|
||||
copy.hostname = host.hostname;
|
||||
copy.node_type = host.node_type;
|
||||
@@ -169,9 +145,9 @@ function InstancePeerList({ setBreadcrumb }) {
|
||||
address_list.push(copy);
|
||||
}
|
||||
|
||||
instances.data.results = address_list;
|
||||
receptoraddresses.data.results = address_list;
|
||||
|
||||
return instances;
|
||||
return receptoraddresses;
|
||||
},
|
||||
[instance]
|
||||
);
|
||||
@@ -191,7 +167,7 @@ function InstancePeerList({ setBreadcrumb }) {
|
||||
fetchPeers();
|
||||
addToast({
|
||||
id: instancesPeerToAssociate,
|
||||
title: t`Please be sure to run the install bundle for the selected instance(s) again in order to see changes take effect.`,
|
||||
title: t`Please be sure to run the install bundle for ${instance.hostname} again in order to see changes take effect.`,
|
||||
variant: AlertVariant.success,
|
||||
hasTimeout: true,
|
||||
});
|
||||
@@ -315,13 +291,13 @@ function InstancePeerList({ setBreadcrumb }) {
|
||||
{isModalOpen && (
|
||||
<AssociateModal
|
||||
header={t`Instances`}
|
||||
fetchRequest={fetchInstancesToAssociate}
|
||||
fetchRequest={fetchPeersToAssociate}
|
||||
isModalOpen={isModalOpen}
|
||||
onAssociate={handlePeerAssociate}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
title={t`Select Peer Addresses`}
|
||||
optionsRequest={readInstancesOptions}
|
||||
displayKey="hostname"
|
||||
displayKey="address"
|
||||
columns={[
|
||||
{ key: 'hostname', name: t`Name` },
|
||||
{ key: 'address', name: t`Address` },
|
||||
|
||||
@@ -78,12 +78,14 @@ function MiscAuthenticationEdit() {
|
||||
default: OAUTH2_PROVIDER_OPTIONS.default.ACCESS_TOKEN_EXPIRE_SECONDS,
|
||||
type: OAUTH2_PROVIDER_OPTIONS.child.type,
|
||||
label: t`Access Token Expiration`,
|
||||
help_text: t`Access Token Expiration in seconds`,
|
||||
},
|
||||
REFRESH_TOKEN_EXPIRE_SECONDS: {
|
||||
...OAUTH2_PROVIDER_OPTIONS,
|
||||
default: OAUTH2_PROVIDER_OPTIONS.default.REFRESH_TOKEN_EXPIRE_SECONDS,
|
||||
type: OAUTH2_PROVIDER_OPTIONS.child.type,
|
||||
label: t`Refresh Token Expiration`,
|
||||
help_text: t`Refresh Token Expiration in seconds`,
|
||||
},
|
||||
AUTHORIZATION_CODE_EXPIRE_SECONDS: {
|
||||
...OAUTH2_PROVIDER_OPTIONS,
|
||||
@@ -91,6 +93,7 @@ function MiscAuthenticationEdit() {
|
||||
OAUTH2_PROVIDER_OPTIONS.default.AUTHORIZATION_CODE_EXPIRE_SECONDS,
|
||||
type: OAUTH2_PROVIDER_OPTIONS.child.type,
|
||||
label: t`Authorization Code Expiration`,
|
||||
help_text: t`Authorization Code Expiration in seconds`,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
63
awx/urls.py
63
awx/urls.py
@@ -2,43 +2,54 @@
|
||||
# All Rights Reserved.
|
||||
|
||||
from django.conf import settings
|
||||
from django.urls import path, re_path, include
|
||||
from django.urls import re_path, include, path
|
||||
|
||||
from ansible_base.lib.dynamic_config.dynamic_urls import api_urls, api_version_urls, root_urls
|
||||
|
||||
from ansible_base.resource_registry.urls import urlpatterns as resource_api_urls
|
||||
|
||||
from awx.main.views import handle_400, handle_403, handle_404, handle_500, handle_csp_violation, handle_login_redirect
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
re_path(r'', include('awx.ui.urls', namespace='ui')),
|
||||
re_path(r'^ui_next/.*', include('awx.ui_next.urls', namespace='ui_next')),
|
||||
path('api/', include('awx.api.urls', namespace='api')),
|
||||
]
|
||||
def get_urlpatterns(prefix=None):
|
||||
if not prefix:
|
||||
prefix = '/'
|
||||
else:
|
||||
prefix = f'/{prefix}/'
|
||||
|
||||
if settings.OPTIONAL_API_URLPATTERN_PREFIX:
|
||||
urlpatterns += [
|
||||
path(f'api/{settings.OPTIONAL_API_URLPATTERN_PREFIX}/', include('awx.api.urls')),
|
||||
urlpatterns = [
|
||||
re_path(r'', include('awx.ui.urls', namespace='ui')),
|
||||
re_path(r'^ui_next/.*', include('awx.ui_next.urls', namespace='ui_next')),
|
||||
path(f'api{prefix}', include('awx.api.urls', namespace='api')),
|
||||
]
|
||||
|
||||
urlpatterns += [
|
||||
re_path(r'^api/v2/', include(resource_api_urls)),
|
||||
re_path(r'^sso/', include('awx.sso.urls', namespace='sso')),
|
||||
re_path(r'^sso/', include('social_django.urls', namespace='social')),
|
||||
re_path(r'^(?:api/)?400.html$', handle_400),
|
||||
re_path(r'^(?:api/)?403.html$', handle_403),
|
||||
re_path(r'^(?:api/)?404.html$', handle_404),
|
||||
re_path(r'^(?:api/)?500.html$', handle_500),
|
||||
re_path(r'^csp-violation/', handle_csp_violation),
|
||||
re_path(r'^login/', handle_login_redirect),
|
||||
]
|
||||
urlpatterns += [
|
||||
path(f'api{prefix}v2/', include(resource_api_urls)),
|
||||
path(f'api{prefix}v2/', include(api_version_urls)),
|
||||
path(f'api{prefix}', include(api_urls)),
|
||||
path('', include(root_urls)),
|
||||
re_path(r'^sso/', include('awx.sso.urls', namespace='sso')),
|
||||
re_path(r'^sso/', include('social_django.urls', namespace='social')),
|
||||
re_path(r'^(?:api/)?400.html$', handle_400),
|
||||
re_path(r'^(?:api/)?403.html$', handle_403),
|
||||
re_path(r'^(?:api/)?404.html$', handle_404),
|
||||
re_path(r'^(?:api/)?500.html$', handle_500),
|
||||
re_path(r'^csp-violation/', handle_csp_violation),
|
||||
re_path(r'^login/', handle_login_redirect),
|
||||
]
|
||||
|
||||
if settings.SETTINGS_MODULE == 'awx.settings.development':
|
||||
try:
|
||||
import debug_toolbar
|
||||
if settings.SETTINGS_MODULE == 'awx.settings.development':
|
||||
try:
|
||||
import debug_toolbar
|
||||
|
||||
urlpatterns += [re_path(r'^__debug__/', include(debug_toolbar.urls))]
|
||||
except ImportError:
|
||||
pass
|
||||
urlpatterns += [re_path(r'^__debug__/', include(debug_toolbar.urls))]
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
return urlpatterns
|
||||
|
||||
|
||||
urlpatterns = get_urlpatterns()
|
||||
|
||||
handler400 = 'awx.main.views.handle_400'
|
||||
handler403 = 'awx.main.views.handle_403'
|
||||
|
||||
@@ -68,6 +68,7 @@ Notable releases of the `awx.awx` collection:
|
||||
- 7.0.0 is intended to be identical to the content prior to the migration, aside from changes necessary to function as a collection.
|
||||
- 11.0.0 has no non-deprecated modules that depend on the deprecated `tower-cli` [PyPI](https://pypi.org/project/ansible-tower-cli/).
|
||||
- 19.2.1 large renaming purged "tower" names (like options and module names), adding redirects for old names
|
||||
- 21.11.0 "tower" modules deprecated and symlinks removed.
|
||||
- X.X.X added support of named URLs to all modules. Anywhere that previously accepted name or id can also support named URLs
|
||||
- 0.0.1-devel is the version you should see if installing from source, which is intended for development and expected to be unstable.
|
||||
|
||||
@@ -112,7 +113,7 @@ Ansible source, set up a dedicated virtual environment:
|
||||
|
||||
```
|
||||
mkvirtualenv my_new_venv
|
||||
# may need to replace psycopg2 with psycopg2-binary in requirements/requirements.txt
|
||||
# may need to replace psycopg3 with psycopg3-binary in requirements/requirements.txt
|
||||
pip install -r requirements/requirements.txt -r requirements/requirements_dev.txt -r requirements/requirements_git.txt
|
||||
make clean-api
|
||||
pip install -e <path to your Ansible>
|
||||
|
||||
@@ -35,6 +35,9 @@ action_groups:
|
||||
- project
|
||||
- project_update
|
||||
- role
|
||||
- role_definition
|
||||
- role_team_assignment
|
||||
- role_user_assignment
|
||||
- schedule
|
||||
- settings
|
||||
- subscriptions
|
||||
|
||||
@@ -652,7 +652,7 @@ class ControllerAPIModule(ControllerModule):
|
||||
# If we have neither of these, then we can try un-authenticated access
|
||||
self.authenticated = True
|
||||
|
||||
def delete_if_needed(self, existing_item, on_delete=None, auto_exit=True):
|
||||
def delete_if_needed(self, existing_item, item_type=None, on_delete=None, auto_exit=True):
|
||||
# This will exit from the module on its own.
|
||||
# If the method successfully deletes an item and on_delete param is defined,
|
||||
# the on_delete parameter will be called as a method pasing in this object and the json from the response
|
||||
@@ -664,8 +664,9 @@ class ControllerAPIModule(ControllerModule):
|
||||
# If we have an item, we can try to delete it
|
||||
try:
|
||||
item_url = existing_item['url']
|
||||
item_type = existing_item['type']
|
||||
item_id = existing_item['id']
|
||||
if not item_type:
|
||||
item_type = existing_item['type']
|
||||
item_name = self.get_item_name(existing_item, allow_unknown=True)
|
||||
except KeyError as ke:
|
||||
self.fail_json(msg="Unable to process delete of item due to missing data {0}".format(ke))
|
||||
@@ -907,7 +908,7 @@ class ControllerAPIModule(ControllerModule):
|
||||
return True
|
||||
return False
|
||||
|
||||
def update_if_needed(self, existing_item, new_item, on_update=None, auto_exit=True, associations=None):
|
||||
def update_if_needed(self, existing_item, new_item, item_type=None, on_update=None, auto_exit=True, associations=None):
|
||||
# This will exit from the module on its own
|
||||
# If the method successfully updates an item and on_update param is defined,
|
||||
# the on_update parameter will be called as a method pasing in this object and the json from the response
|
||||
@@ -921,7 +922,8 @@ class ControllerAPIModule(ControllerModule):
|
||||
# If we have an item, we can see if it needs an update
|
||||
try:
|
||||
item_url = existing_item['url']
|
||||
item_type = existing_item['type']
|
||||
if not item_type:
|
||||
item_type = existing_item['type']
|
||||
if item_type == 'user':
|
||||
item_name = existing_item['username']
|
||||
elif item_type == 'workflow_job_template_node':
|
||||
@@ -990,7 +992,7 @@ class ControllerAPIModule(ControllerModule):
|
||||
new_item.pop(key)
|
||||
|
||||
if existing_item:
|
||||
return self.update_if_needed(existing_item, new_item, on_update=on_update, auto_exit=auto_exit, associations=associations)
|
||||
return self.update_if_needed(existing_item, new_item, item_type=item_type, on_update=on_update, auto_exit=auto_exit, associations=associations)
|
||||
else:
|
||||
return self.create_if_needed(
|
||||
existing_item, new_item, endpoint, on_create=on_create, item_type=item_type, auto_exit=auto_exit, associations=associations
|
||||
@@ -1036,7 +1038,10 @@ class ControllerAPIModule(ControllerModule):
|
||||
# Grab our start time to compare against for the timeout
|
||||
start = time.time()
|
||||
result = self.get_endpoint(url)
|
||||
while not result['json']['finished']:
|
||||
wait_on_field = 'event_processing_finished'
|
||||
if wait_on_field not in result['json']:
|
||||
wait_on_field = 'finished'
|
||||
while not result['json'][wait_on_field]:
|
||||
# If we are past our time out fail with a message
|
||||
if timeout and timeout < time.time() - start:
|
||||
# Account for Legacy messages
|
||||
|
||||
114
awx_collection/plugins/modules/role_definition.py
Normal file
114
awx_collection/plugins/modules/role_definition.py
Normal file
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/python
|
||||
# coding: utf-8 -*-
|
||||
|
||||
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
|
||||
__metaclass__ = type
|
||||
|
||||
|
||||
ANSIBLE_METADATA = {'metadata_version': '1.1', 'status': ['preview'], 'supported_by': 'community'}
|
||||
|
||||
|
||||
DOCUMENTATION = '''
|
||||
---
|
||||
module: role_definition
|
||||
author: "Seth Foster (@fosterseth)"
|
||||
short_description: Add role definition to Automation Platform Controller
|
||||
description:
|
||||
- Contains a list of permissions and a resource type that can then be assigned to users or teams.
|
||||
options:
|
||||
name:
|
||||
description:
|
||||
- Name of this role definition.
|
||||
required: True
|
||||
type: str
|
||||
permissions:
|
||||
description:
|
||||
- List of permissions to include in the role definition.
|
||||
required: True
|
||||
type: list
|
||||
elements: str
|
||||
content_type:
|
||||
description:
|
||||
- The type of resource this applies to.
|
||||
required: True
|
||||
type: str
|
||||
description:
|
||||
description:
|
||||
- Optional description of this role definition.
|
||||
type: str
|
||||
state:
|
||||
description:
|
||||
- The desired state of the role definition.
|
||||
default: present
|
||||
choices:
|
||||
- present
|
||||
- absent
|
||||
type: str
|
||||
extends_documentation_fragment: awx.awx.auth
|
||||
'''
|
||||
|
||||
|
||||
EXAMPLES = '''
|
||||
- name: Create Role Definition
|
||||
role_definition:
|
||||
name: test_view_jt
|
||||
permissions:
|
||||
- awx.view_jobtemplate
|
||||
- awx.execute_jobtemplate
|
||||
content_type: awx.jobtemplate
|
||||
description: role definition to view and execute jt
|
||||
state: present
|
||||
'''
|
||||
|
||||
from ..module_utils.controller_api import ControllerAPIModule
|
||||
|
||||
|
||||
def main():
|
||||
# Any additional arguments that are not fields of the item can be added here
|
||||
argument_spec = dict(
|
||||
name=dict(required=True, type='str'),
|
||||
permissions=dict(required=True, type='list', elements='str'),
|
||||
content_type=dict(required=True, type='str'),
|
||||
description=dict(required=False, type='str'),
|
||||
state=dict(default='present', choices=['present', 'absent']),
|
||||
)
|
||||
|
||||
module = ControllerAPIModule(argument_spec=argument_spec)
|
||||
|
||||
name = module.params.get('name')
|
||||
permissions = module.params.get('permissions')
|
||||
content_type = module.params.get('content_type')
|
||||
description = module.params.get('description')
|
||||
state = module.params.get('state')
|
||||
if description is None:
|
||||
description = ''
|
||||
|
||||
role_definition = module.get_one('role_definitions', name_or_id=name)
|
||||
|
||||
if state == 'absent':
|
||||
module.delete_if_needed(
|
||||
role_definition,
|
||||
item_type='role_definition',
|
||||
)
|
||||
|
||||
post_kwargs = {
|
||||
'name': name,
|
||||
'permissions': permissions,
|
||||
'content_type': content_type,
|
||||
'description': description
|
||||
}
|
||||
|
||||
if state == 'present':
|
||||
module.create_or_update_if_needed(
|
||||
role_definition,
|
||||
post_kwargs,
|
||||
endpoint='role_definitions',
|
||||
item_type='role_definition',
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
123
awx_collection/plugins/modules/role_team_assignment.py
Normal file
123
awx_collection/plugins/modules/role_team_assignment.py
Normal file
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/python
|
||||
# coding: utf-8 -*-
|
||||
|
||||
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
|
||||
__metaclass__ = type
|
||||
|
||||
|
||||
ANSIBLE_METADATA = {'metadata_version': '1.1', 'status': ['preview'], 'supported_by': 'community'}
|
||||
|
||||
|
||||
DOCUMENTATION = '''
|
||||
---
|
||||
module: role_team_assignment
|
||||
author: "Seth Foster (@fosterseth)"
|
||||
short_description: Gives a team permission to a resource or an organization.
|
||||
description:
|
||||
- Use this endpoint to give a team permission to a resource or an organization.
|
||||
- After creation, the assignment cannot be edited, but can be deleted to remove those permissions.
|
||||
options:
|
||||
role_definition:
|
||||
description:
|
||||
- The name or id of the role definition to assign to the team.
|
||||
required: True
|
||||
type: str
|
||||
object_id:
|
||||
description:
|
||||
- Primary key of the object this assignment applies to.
|
||||
required: True
|
||||
type: int
|
||||
team:
|
||||
description:
|
||||
- The name or id of the team to assign to the object.
|
||||
required: False
|
||||
type: str
|
||||
object_ansible_id:
|
||||
description:
|
||||
- Resource id of the object this role applies to. Alternative to the object_id field.
|
||||
required: False
|
||||
type: int
|
||||
team_ansible_id:
|
||||
description:
|
||||
- Resource id of the team who will receive permissions from this assignment. Alternative to team field.
|
||||
required: False
|
||||
type: int
|
||||
state:
|
||||
description:
|
||||
- The desired state of the role definition.
|
||||
default: present
|
||||
choices:
|
||||
- present
|
||||
- absent
|
||||
type: str
|
||||
extends_documentation_fragment: awx.awx.auth
|
||||
'''
|
||||
|
||||
|
||||
EXAMPLES = '''
|
||||
- name: Give Team A JT permissions
|
||||
role_team_assignment:
|
||||
role_definition: launch JT
|
||||
object_id: 1
|
||||
team: Team A
|
||||
state: present
|
||||
'''
|
||||
|
||||
from ..module_utils.controller_api import ControllerAPIModule
|
||||
|
||||
|
||||
def main():
|
||||
# Any additional arguments that are not fields of the item can be added here
|
||||
argument_spec = dict(
|
||||
team=dict(required=False, type='str'),
|
||||
object_id=dict(required=True, type='int'),
|
||||
role_definition=dict(required=True, type='str'),
|
||||
object_ansible_id=dict(required=False, type='int'),
|
||||
team_ansible_id=dict(required=False, type='int'),
|
||||
state=dict(default='present', choices=['present', 'absent']),
|
||||
)
|
||||
|
||||
module = ControllerAPIModule(argument_spec=argument_spec)
|
||||
|
||||
team = module.params.get('team')
|
||||
object_id = module.params.get('object_id')
|
||||
role_definition_str = module.params.get('role_definition')
|
||||
object_ansible_id = module.params.get('object_ansible_id')
|
||||
team_ansible_id = module.params.get('team_ansible_id')
|
||||
state = module.params.get('state')
|
||||
|
||||
role_definition = module.get_one('role_definitions', allow_none=False, name_or_id=role_definition_str)
|
||||
team = module.get_one('teams', allow_none=False, name_or_id=team)
|
||||
|
||||
kwargs = {
|
||||
'role_definition': role_definition['id'],
|
||||
'object_id': object_id,
|
||||
'team': team['id'],
|
||||
'object_ansible_id': object_ansible_id,
|
||||
'team_ansible_id': team_ansible_id,
|
||||
}
|
||||
|
||||
# get rid of None type values
|
||||
kwargs = {k: v for k, v in kwargs.items() if v is not None}
|
||||
role_team_assignment = module.get_one('role_team_assignments', **{'data': kwargs})
|
||||
|
||||
if state == 'absent':
|
||||
module.delete_if_needed(
|
||||
role_team_assignment,
|
||||
item_type='role_team_assignment',
|
||||
)
|
||||
|
||||
if state == 'present':
|
||||
module.create_if_needed(
|
||||
role_team_assignment,
|
||||
kwargs,
|
||||
endpoint='role_team_assignments',
|
||||
item_type='role_team_assignment',
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
124
awx_collection/plugins/modules/role_user_assignment.py
Normal file
124
awx_collection/plugins/modules/role_user_assignment.py
Normal file
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/python
|
||||
# coding: utf-8 -*-
|
||||
|
||||
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
|
||||
__metaclass__ = type
|
||||
|
||||
|
||||
ANSIBLE_METADATA = {'metadata_version': '1.1', 'status': ['preview'], 'supported_by': 'community'}
|
||||
|
||||
|
||||
DOCUMENTATION = '''
|
||||
---
|
||||
module: role_user_assignment
|
||||
author: "Seth Foster (@fosterseth)"
|
||||
short_description: Gives a user permission to a resource or an organization.
|
||||
description:
|
||||
- Use this endpoint to give a user permission to a resource or an organization.
|
||||
- After creation, the assignment cannot be edited, but can be deleted to remove those permissions.
|
||||
options:
|
||||
role_definition:
|
||||
description:
|
||||
- The name or id of the role definition to assign to the user.
|
||||
required: True
|
||||
type: str
|
||||
object_id:
|
||||
description:
|
||||
- Primary key of the object this assignment applies to.
|
||||
required: True
|
||||
type: int
|
||||
user:
|
||||
description:
|
||||
- The name or id of the user to assign to the object.
|
||||
required: False
|
||||
type: str
|
||||
object_ansible_id:
|
||||
description:
|
||||
- Resource id of the object this role applies to. Alternative to the object_id field.
|
||||
required: False
|
||||
type: int
|
||||
user_ansible_id:
|
||||
description:
|
||||
- Resource id of the user who will receive permissions from this assignment. Alternative to user field.
|
||||
required: False
|
||||
type: int
|
||||
state:
|
||||
description:
|
||||
- The desired state of the role definition.
|
||||
default: present
|
||||
choices:
|
||||
- present
|
||||
- absent
|
||||
type: str
|
||||
extends_documentation_fragment: awx.awx.auth
|
||||
'''
|
||||
|
||||
|
||||
EXAMPLES = '''
|
||||
- name: Give Bob JT permissions
|
||||
role_user_assignment:
|
||||
role_definition: launch JT
|
||||
object_id: 1
|
||||
user: bob
|
||||
state: present
|
||||
'''
|
||||
|
||||
from ..module_utils.controller_api import ControllerAPIModule
|
||||
|
||||
|
||||
def main():
|
||||
# Any additional arguments that are not fields of the item can be added here
|
||||
argument_spec = dict(
|
||||
user=dict(required=False, type='str'),
|
||||
object_id=dict(required=True, type='int'),
|
||||
role_definition=dict(required=True, type='str'),
|
||||
object_ansible_id=dict(required=False, type='int'),
|
||||
user_ansible_id=dict(required=False, type='int'),
|
||||
state=dict(default='present', choices=['present', 'absent']),
|
||||
)
|
||||
|
||||
module = ControllerAPIModule(argument_spec=argument_spec)
|
||||
|
||||
user = module.params.get('user')
|
||||
object_id = module.params.get('object_id')
|
||||
role_definition_str = module.params.get('role_definition')
|
||||
object_ansible_id = module.params.get('object_ansible_id')
|
||||
user_ansible_id = module.params.get('user_ansible_id')
|
||||
state = module.params.get('state')
|
||||
|
||||
role_definition = module.get_one('role_definitions', allow_none=False, name_or_id=role_definition_str)
|
||||
user = module.get_one('users', allow_none=False, name_or_id=user)
|
||||
|
||||
kwargs = {
|
||||
'role_definition': role_definition['id'],
|
||||
'object_id': object_id,
|
||||
'user': user['id'],
|
||||
'object_ansible_id': object_ansible_id,
|
||||
'user_ansible_id': user_ansible_id,
|
||||
}
|
||||
|
||||
# get rid of None type values
|
||||
kwargs = {k: v for k, v in kwargs.items() if v is not None}
|
||||
|
||||
role_user_assignment = module.get_one('role_user_assignments', **{'data': kwargs})
|
||||
|
||||
if state == 'absent':
|
||||
module.delete_if_needed(
|
||||
role_user_assignment,
|
||||
item_type='role_user_assignment',
|
||||
)
|
||||
|
||||
if state == 'present':
|
||||
module.create_if_needed(
|
||||
role_user_assignment,
|
||||
kwargs,
|
||||
endpoint='role_user_assignments',
|
||||
item_type='role_user_assignment',
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -17,6 +17,7 @@ import pytest
|
||||
|
||||
from ansible.module_utils.six import raise_from
|
||||
|
||||
from ansible_base.rbac.models import RoleDefinition, DABPermission
|
||||
from awx.main.tests.functional.conftest import _request
|
||||
from awx.main.tests.functional.conftest import credentialtype_scm, credentialtype_ssh # noqa: F401; pylint: disable=unused-variable
|
||||
from awx.main.models import (
|
||||
@@ -31,9 +32,11 @@ from awx.main.models import (
|
||||
WorkflowJobTemplate,
|
||||
NotificationTemplate,
|
||||
Schedule,
|
||||
Team,
|
||||
)
|
||||
|
||||
from django.db import transaction
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
|
||||
HAS_TOWER_CLI = False
|
||||
@@ -258,6 +261,11 @@ def job_template(project, inventory):
|
||||
return JobTemplate.objects.create(name='test-jt', project=project, inventory=inventory, playbook='helloworld.yml')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def team(organization):
|
||||
return Team.objects.create(name='test-team', organization=organization)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def machine_credential(credentialtype_ssh, organization): # noqa: F811
|
||||
return Credential.objects.create(credential_type=credentialtype_ssh, name='machine-cred', inputs={'username': 'test_user', 'password': 'pas4word'})
|
||||
@@ -331,6 +339,15 @@ def notification_template(organization):
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def job_template_role_definition():
|
||||
rd = RoleDefinition.objects.create(name='test_view_jt', content_type=ContentType.objects.get_for_model(JobTemplate))
|
||||
permission_codenames = ['view_jobtemplate', 'execute_jobtemplate']
|
||||
permissions = DABPermission.objects.filter(codename__in=permission_codenames)
|
||||
rd.permissions.add(*permissions)
|
||||
return rd
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def scm_credential(credentialtype_scm, organization): # noqa: F811
|
||||
return Credential.objects.create(
|
||||
|
||||
@@ -18,9 +18,9 @@ def test_grant_organization_permission(run_module, admin_user, organization, sta
|
||||
assert not result.get('failed', False), result.get('msg', result)
|
||||
|
||||
if state == 'present':
|
||||
assert rando in organization.execute_role
|
||||
assert rando in organization.admin_role
|
||||
else:
|
||||
assert rando not in organization.execute_role
|
||||
assert rando not in organization.admin_role
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
||||
122
awx_collection/test/awx/test_role_definition.py
Normal file
122
awx_collection/test/awx/test_role_definition.py
Normal file
@@ -0,0 +1,122 @@
|
||||
from __future__ import absolute_import, division, print_function
|
||||
|
||||
__metaclass__ = type
|
||||
|
||||
import pytest
|
||||
|
||||
from ansible_base.rbac.models import RoleDefinition
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_new(run_module, admin_user):
|
||||
result = run_module(
|
||||
'role_definition',
|
||||
{
|
||||
'name': 'test_view_jt',
|
||||
'permissions': ['awx.view_jobtemplate', 'awx.execute_jobtemplate'],
|
||||
'content_type': 'awx.jobtemplate',
|
||||
},
|
||||
admin_user)
|
||||
assert result['changed']
|
||||
|
||||
role_definition = RoleDefinition.objects.get(name='test_view_jt')
|
||||
assert role_definition
|
||||
permission_codenames = [p.codename for p in role_definition.permissions.all()]
|
||||
assert set(permission_codenames) == set(['view_jobtemplate', 'execute_jobtemplate'])
|
||||
assert role_definition.content_type.model == 'jobtemplate'
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_update_existing(run_module, admin_user):
|
||||
result = run_module(
|
||||
'role_definition',
|
||||
{
|
||||
'name': 'test_view_jt',
|
||||
'permissions': ['awx.view_jobtemplate'],
|
||||
'content_type': 'awx.jobtemplate',
|
||||
},
|
||||
admin_user)
|
||||
|
||||
assert result['changed']
|
||||
|
||||
role_definition = RoleDefinition.objects.get(name='test_view_jt')
|
||||
permission_codenames = [p.codename for p in role_definition.permissions.all()]
|
||||
assert set(permission_codenames) == set(['view_jobtemplate'])
|
||||
assert role_definition.content_type.model == 'jobtemplate'
|
||||
|
||||
result = run_module(
|
||||
'role_definition',
|
||||
{
|
||||
'name': 'test_view_jt',
|
||||
'permissions': ['awx.view_jobtemplate', 'awx.execute_jobtemplate'],
|
||||
'content_type': 'awx.jobtemplate',
|
||||
},
|
||||
admin_user)
|
||||
|
||||
assert result['changed']
|
||||
|
||||
role_definition.refresh_from_db()
|
||||
permission_codenames = [p.codename for p in role_definition.permissions.all()]
|
||||
assert set(permission_codenames) == set(['view_jobtemplate', 'execute_jobtemplate'])
|
||||
assert role_definition.content_type.model == 'jobtemplate'
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_delete_existing(run_module, admin_user):
|
||||
result = run_module(
|
||||
'role_definition',
|
||||
{
|
||||
'name': 'test_view_jt',
|
||||
'permissions': ['awx.view_jobtemplate', 'awx.execute_jobtemplate'],
|
||||
'content_type': 'awx.jobtemplate',
|
||||
},
|
||||
admin_user)
|
||||
|
||||
assert result['changed']
|
||||
|
||||
role_definition = RoleDefinition.objects.get(name='test_view_jt')
|
||||
assert role_definition
|
||||
|
||||
result = run_module(
|
||||
'role_definition',
|
||||
{
|
||||
'name': 'test_view_jt',
|
||||
'permissions': ['awx.view_jobtemplate', 'awx.execute_jobtemplate'],
|
||||
'content_type': 'awx.jobtemplate',
|
||||
'state': 'absent',
|
||||
},
|
||||
admin_user)
|
||||
|
||||
assert result['changed']
|
||||
|
||||
with pytest.raises(RoleDefinition.DoesNotExist):
|
||||
role_definition.refresh_from_db()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_idempotence(run_module, admin_user):
|
||||
result = run_module(
|
||||
'role_definition',
|
||||
{
|
||||
'name': 'test_view_jt',
|
||||
'permissions': ['awx.view_jobtemplate', 'awx.execute_jobtemplate'],
|
||||
'content_type': 'awx.jobtemplate',
|
||||
},
|
||||
admin_user)
|
||||
|
||||
assert result['changed']
|
||||
|
||||
result = run_module(
|
||||
'role_definition',
|
||||
{
|
||||
'name': 'test_view_jt',
|
||||
'permissions': ['awx.view_jobtemplate', 'awx.execute_jobtemplate'],
|
||||
'content_type': 'awx.jobtemplate',
|
||||
},
|
||||
admin_user)
|
||||
|
||||
assert not result['changed']
|
||||
|
||||
role_definition = RoleDefinition.objects.get(name='test_view_jt')
|
||||
permission_codenames = [p.codename for p in role_definition.permissions.all()]
|
||||
assert set(permission_codenames) == set(['view_jobtemplate', 'execute_jobtemplate'])
|
||||
70
awx_collection/test/awx/test_role_team_assignment.py
Normal file
70
awx_collection/test/awx/test_role_team_assignment.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from __future__ import absolute_import, division, print_function
|
||||
|
||||
__metaclass__ = type
|
||||
|
||||
import pytest
|
||||
|
||||
from ansible_base.rbac.models import RoleTeamAssignment
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_new(run_module, admin_user, team, job_template, job_template_role_definition):
|
||||
result = run_module(
|
||||
'role_team_assignment',
|
||||
{
|
||||
'team': team.name,
|
||||
'object_id': job_template.id,
|
||||
'role_definition': job_template_role_definition.name,
|
||||
},
|
||||
admin_user)
|
||||
assert result['changed']
|
||||
assert RoleTeamAssignment.objects.filter(team=team, object_id=job_template.id, role_definition=job_template_role_definition).exists()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_idempotence(run_module, admin_user, team, job_template, job_template_role_definition):
|
||||
result = run_module(
|
||||
'role_team_assignment',
|
||||
{
|
||||
'team': team.name,
|
||||
'object_id': job_template.id,
|
||||
'role_definition': job_template_role_definition.name,
|
||||
},
|
||||
admin_user)
|
||||
assert result['changed']
|
||||
|
||||
result = run_module(
|
||||
'role_team_assignment',
|
||||
{
|
||||
'team': team.name,
|
||||
'object_id': job_template.id,
|
||||
'role_definition': job_template_role_definition.name,
|
||||
},
|
||||
admin_user)
|
||||
assert not result['changed']
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_delete_existing(run_module, admin_user, team, job_template, job_template_role_definition):
|
||||
result = run_module(
|
||||
'role_team_assignment',
|
||||
{
|
||||
'team': team.name,
|
||||
'object_id': job_template.id,
|
||||
'role_definition': job_template_role_definition.name,
|
||||
},
|
||||
admin_user)
|
||||
assert result['changed']
|
||||
assert RoleTeamAssignment.objects.filter(team=team, object_id=job_template.id, role_definition=job_template_role_definition).exists()
|
||||
|
||||
result = run_module(
|
||||
'role_team_assignment',
|
||||
{
|
||||
'team': team.name,
|
||||
'object_id': job_template.id,
|
||||
'role_definition': job_template_role_definition.name,
|
||||
'state': 'absent'
|
||||
},
|
||||
admin_user)
|
||||
assert result['changed']
|
||||
assert not RoleTeamAssignment.objects.filter(team=team, object_id=job_template.id, role_definition=job_template_role_definition).exists()
|
||||
70
awx_collection/test/awx/test_role_user_assignment.py
Normal file
70
awx_collection/test/awx/test_role_user_assignment.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from __future__ import absolute_import, division, print_function
|
||||
|
||||
__metaclass__ = type
|
||||
|
||||
import pytest
|
||||
|
||||
from ansible_base.rbac.models import RoleUserAssignment
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_new(run_module, admin_user, job_template, job_template_role_definition):
|
||||
result = run_module(
|
||||
'role_user_assignment',
|
||||
{
|
||||
'user': admin_user.username,
|
||||
'object_id': job_template.id,
|
||||
'role_definition': job_template_role_definition.name,
|
||||
},
|
||||
admin_user)
|
||||
assert result['changed']
|
||||
assert RoleUserAssignment.objects.filter(user=admin_user, object_id=job_template.id, role_definition=job_template_role_definition).exists()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_idempotence(run_module, admin_user, job_template, job_template_role_definition):
|
||||
result = run_module(
|
||||
'role_user_assignment',
|
||||
{
|
||||
'user': admin_user.username,
|
||||
'object_id': job_template.id,
|
||||
'role_definition': job_template_role_definition.name,
|
||||
},
|
||||
admin_user)
|
||||
assert result['changed']
|
||||
|
||||
result = run_module(
|
||||
'role_user_assignment',
|
||||
{
|
||||
'user': admin_user.username,
|
||||
'object_id': job_template.id,
|
||||
'role_definition': job_template_role_definition.name,
|
||||
},
|
||||
admin_user)
|
||||
assert not result['changed']
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_delete_existing(run_module, admin_user, job_template, job_template_role_definition):
|
||||
result = run_module(
|
||||
'role_user_assignment',
|
||||
{
|
||||
'user': admin_user.username,
|
||||
'object_id': job_template.id,
|
||||
'role_definition': job_template_role_definition.name,
|
||||
},
|
||||
admin_user)
|
||||
assert result['changed']
|
||||
assert RoleUserAssignment.objects.filter(user=admin_user, object_id=job_template.id, role_definition=job_template_role_definition).exists()
|
||||
|
||||
result = run_module(
|
||||
'role_user_assignment',
|
||||
{
|
||||
'user': admin_user.username,
|
||||
'object_id': job_template.id,
|
||||
'role_definition': job_template_role_definition.name,
|
||||
'state': 'absent'
|
||||
},
|
||||
admin_user)
|
||||
assert result['changed']
|
||||
assert not RoleUserAssignment.objects.filter(user=admin_user, object_id=job_template.id, role_definition=job_template_role_definition).exists()
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
- name: Create Role Definition
|
||||
role_definition:
|
||||
name: test_view_jt
|
||||
permissions:
|
||||
- awx.view_jobtemplate
|
||||
- awx.execute_jobtemplate
|
||||
content_type: awx.jobtemplate
|
||||
description: role definition to launch job
|
||||
state: present
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
that:
|
||||
- result is changed
|
||||
|
||||
- name: Delete Role Definition
|
||||
role_definition:
|
||||
name: test_view_jt
|
||||
permissions:
|
||||
- awx.view_jobtemplate
|
||||
- awx.execute_jobtemplate
|
||||
content_type: awx.jobtemplate
|
||||
description: role definition to launch job
|
||||
state: absent
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
that:
|
||||
- result is changed
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
- name: Create Team
|
||||
team:
|
||||
name: All Stars
|
||||
organization: Default
|
||||
|
||||
- name: Create Job Template
|
||||
job_template:
|
||||
name: Demo Job Template
|
||||
job_type: run
|
||||
inventory: Demo Inventory
|
||||
project: Demo Project
|
||||
playbook: hello_world.yml
|
||||
register: job_template
|
||||
|
||||
- name: Create Role Definition
|
||||
role_definition:
|
||||
name: test_view_jt
|
||||
permissions:
|
||||
- awx.view_jobtemplate
|
||||
- awx.execute_jobtemplate
|
||||
content_type: awx.jobtemplate
|
||||
description: role definition to launch job
|
||||
|
||||
- name: Create Role Team Assignment
|
||||
role_team_assignment:
|
||||
role_definition: test_view_jt
|
||||
team: All Stars
|
||||
object_id: "{{ job_template.id }}"
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
that:
|
||||
- result is changed
|
||||
|
||||
- name: Delete Role Team Assigment
|
||||
role_team_assignment:
|
||||
role_definition: test_view_jt
|
||||
team: All Stars
|
||||
object_id: "{{ job_template.id }}"
|
||||
state: absent
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
that:
|
||||
- result is changed
|
||||
|
||||
- name: Create Role Definition
|
||||
role_definition:
|
||||
name: test_view_jt
|
||||
permissions:
|
||||
- awx.view_jobtemplate
|
||||
- awx.execute_jobtemplate
|
||||
content_type: awx.jobtemplate
|
||||
description: role definition to launch job
|
||||
state: absent
|
||||
|
||||
- name: Delete Team
|
||||
team:
|
||||
name: All Stars
|
||||
organization: Default
|
||||
state: absent
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
- name: Create User
|
||||
user:
|
||||
username: testing_user
|
||||
first_name: testing
|
||||
last_name: user
|
||||
password: password
|
||||
|
||||
- name: Create Job Template
|
||||
job_template:
|
||||
name: Demo Job Template
|
||||
job_type: run
|
||||
inventory: Demo Inventory
|
||||
project: Demo Project
|
||||
playbook: hello_world.yml
|
||||
register: job_template
|
||||
|
||||
- name: Create Role Definition
|
||||
role_definition:
|
||||
name: test_view_jt
|
||||
permissions:
|
||||
- awx.view_jobtemplate
|
||||
- awx.execute_jobtemplate
|
||||
content_type: awx.jobtemplate
|
||||
description: role definition to launch job
|
||||
|
||||
- name: Create Role User Assignment
|
||||
role_user_assignment:
|
||||
role_definition: test_view_jt
|
||||
user: testing_user
|
||||
object_id: "{{ job_template.id }}"
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
that:
|
||||
- result is changed
|
||||
|
||||
- name: Delete Role User Assigment
|
||||
role_user_assignment:
|
||||
role_definition: test_view_jt
|
||||
user: testing_user
|
||||
object_id: "{{ job_template.id }}"
|
||||
state: absent
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
that:
|
||||
- result is changed
|
||||
|
||||
- name: Create Role Definition
|
||||
role_definition:
|
||||
name: test_view_jt
|
||||
permissions:
|
||||
- awx.view_jobtemplate
|
||||
- awx.execute_jobtemplate
|
||||
content_type: awx.jobtemplate
|
||||
description: role definition to launch job
|
||||
state: absent
|
||||
|
||||
- name: Delete User
|
||||
user:
|
||||
username: testing_user
|
||||
state: absent
|
||||
@@ -234,7 +234,7 @@ class ApiV2(base.Base):
|
||||
return endpoint.get(**{identifier: value}, all_pages=True)
|
||||
|
||||
def export_assets(self, **kwargs):
|
||||
self._cache = page.PageCache()
|
||||
self._cache = page.PageCache(self.connection)
|
||||
|
||||
# If no resource kwargs are explicitly used, export everything.
|
||||
all_resources = all(kwargs.get(resource) is None for resource in EXPORTABLE_RESOURCES)
|
||||
@@ -335,7 +335,7 @@ class ApiV2(base.Base):
|
||||
if name == 'roles':
|
||||
indexed_roles = defaultdict(list)
|
||||
for role in S:
|
||||
if 'content_object' not in role:
|
||||
if role.get('content_object') is None:
|
||||
continue
|
||||
indexed_roles[role['content_object']['type']].append(role)
|
||||
self._roles.append((_page, indexed_roles))
|
||||
@@ -411,7 +411,7 @@ class ApiV2(base.Base):
|
||||
# FIXME: deal with pruning existing relations that do not match the import set
|
||||
|
||||
def import_assets(self, data):
|
||||
self._cache = page.PageCache()
|
||||
self._cache = page.PageCache(self.connection)
|
||||
self._related = []
|
||||
self._roles = []
|
||||
|
||||
@@ -420,11 +420,8 @@ class ApiV2(base.Base):
|
||||
for resource in self._dependent_resources():
|
||||
endpoint = getattr(self, resource)
|
||||
|
||||
# Load up existing objects, so that we can try to update or link to them
|
||||
self._cache.get_page(endpoint)
|
||||
imported = self._import_list(endpoint, data.get(resource) or [])
|
||||
changed = changed or imported
|
||||
# FIXME: should we delete existing unpatched assets?
|
||||
|
||||
self._assign_related()
|
||||
self._assign_membership()
|
||||
|
||||
@@ -11,6 +11,7 @@ from awxkit.utils import PseudoNamespace, is_relative_endpoint, are_same_endpoin
|
||||
from awxkit.api import utils
|
||||
from awxkit.api.client import Connection
|
||||
from awxkit.api.registry import URLRegistry
|
||||
from awxkit.api.resources import resources
|
||||
from awxkit.config import config
|
||||
import awxkit.exceptions as exc
|
||||
|
||||
@@ -493,10 +494,11 @@ class TentativePage(str):
|
||||
|
||||
|
||||
class PageCache(object):
|
||||
def __init__(self):
|
||||
def __init__(self, connection=None):
|
||||
self.options = {}
|
||||
self.pages_by_url = {}
|
||||
self.pages_by_natural_key = {}
|
||||
self.connection = connection or Connection(config.base_url, not config.assume_untrusted)
|
||||
|
||||
def get_options(self, page):
|
||||
url = page.endpoint if isinstance(page, Page) else str(page)
|
||||
@@ -550,7 +552,31 @@ class PageCache(object):
|
||||
return self.set_page(page)
|
||||
|
||||
def get_by_natural_key(self, natural_key):
|
||||
endpoint = self.pages_by_natural_key.get(utils.freeze(natural_key))
|
||||
log.debug("get_by_natural_key: %s, endpoint: %s", repr(natural_key), endpoint)
|
||||
if endpoint:
|
||||
return self.get_page(endpoint)
|
||||
page = self.pages_by_natural_key.get(utils.freeze(natural_key))
|
||||
if page is None:
|
||||
# We need some way to get ahold of the top-level resource
|
||||
# list endpoint from the natural_key type. The resources
|
||||
# object more or less has that for each of the detail
|
||||
# views. Just chop off the /<id>/ bit.
|
||||
endpoint = getattr(resources, natural_key['type'], None)
|
||||
if endpoint is None:
|
||||
return
|
||||
endpoint = ''.join([endpoint.rsplit('/', 2)[0], '/'])
|
||||
page_type = get_registered_page(endpoint)
|
||||
|
||||
kwargs = {}
|
||||
for k, v in natural_key.items():
|
||||
if isinstance(v, str) and k != 'type':
|
||||
kwargs[k] = v
|
||||
|
||||
# Do a filtered query against the list endpoint, usually
|
||||
# with the name of the object but sometimes more.
|
||||
list_page = page_type(self.connection, endpoint=endpoint).get(all_pages=True, **kwargs)
|
||||
if 'results' in list_page:
|
||||
for p in list_page.results:
|
||||
self.set_page(p)
|
||||
page = self.pages_by_natural_key.get(utils.freeze(natural_key))
|
||||
|
||||
log.debug("get_by_natural_key: %s, endpoint: %s", repr(natural_key), page)
|
||||
if page:
|
||||
return self.get_page(page)
|
||||
|
||||
@@ -17,7 +17,7 @@ This section describes setting up authentication for the following enterprise sy
|
||||
|
||||
For LDAP authentication, see :ref:`ag_auth_ldap`.
|
||||
|
||||
SAML, RADIUS, and TACACS+ users are categorized as 'Enterprise' users. The following rules apply to Enterprise users:
|
||||
Azure, RADIUS, SAML, and TACACS+ users are categorized as 'Enterprise' users. The following rules apply to Enterprise users:
|
||||
|
||||
- Enterprise users can only be created via the first successful login attempt from remote authentication backend.
|
||||
- Enterprise users cannot be created/authenticated if non-enterprise users with the same name has already been created in AWX.
|
||||
|
||||
@@ -17,7 +17,9 @@ Administrators use LDAP as a source for account authentication information for A
|
||||
|
||||
When so configured, a user who logs in with an LDAP username and password automatically gets an AWX account created for them and they can be automatically placed into organizations as either regular users or organization administrators.
|
||||
|
||||
Users created via an LDAP login cannot change their username, first name, last name, or set a local password for themselves. This is also tunable to restrict editing of other field names.
|
||||
Users created locally in the user interface, take precedence over those logging into controller for their first time with an alternative authentication solution. You must delete the local user if you want to re-use it with another authentication method, such as LDAP.
|
||||
|
||||
Users created through an LDAP login cannot change their username, given name, surname, or set a local password for themselves. You can also configure this to restrict editing of other field names.
|
||||
|
||||
To configure LDAP integration for AWX:
|
||||
|
||||
@@ -84,7 +86,7 @@ Here ``CN=josie,CN=users,DC=website,DC=com`` is the Distinguished Name of the co
|
||||
.. _`django-auth-ldap library`: https://django-auth-ldap.readthedocs.io/en/latest/groups.html#types-of-groups
|
||||
|
||||
|
||||
7. The **LDAP Start TLS** is disabled by default. To enable TLS when the LDAP connection is not using SSL, click the toggle to **ON**.
|
||||
7. The **LDAP Start TLS** is disabled by default. To enable TLS when the LDAP connection is not using SSL/TLS, click the toggle to **ON**.
|
||||
|
||||
.. image:: ../common/images/configure-awx-auth-ldap-start-tls.png
|
||||
|
||||
|
||||
@@ -150,7 +150,7 @@ The easiest and most common way to obtain an OAuth 2 token is to create a person
|
||||
|
||||
::
|
||||
|
||||
curl -XPOST -k -H "Content-type: application/json" -d '{"description":"Personal AWX CLI token", "application":null, "scope":"write"}' https://<USERNAME>:<PASSWORD>@<AWX_SERVER>/api/v2/users/<USER_ID>/personal_tokens/ | python -m json.tool
|
||||
curl -H "Content-type: application/json" -d '{"description":"Personal AWX CLI token", "application":null, "scope":"write"}' https://<USERNAME>:<PASSWORD>@<AWX_SERVER>/api/v2/users/<USER_ID>/personal_tokens/ | python -m json.tool
|
||||
|
||||
You could also pipe the JSON output through ``jq``, if installed.
|
||||
|
||||
@@ -159,7 +159,7 @@ Following is an example of using the personal token to access an API endpoint us
|
||||
|
||||
::
|
||||
|
||||
curl -k -H "Authorization: Bearer <token>" -H "Content-Type: application/json" -X POST -d '{}' https://awx/api/v2/job_templates/5/launch/
|
||||
curl -H "Authorization: Bearer <token>" -H "Content-Type: application/json" -d '{}' https://awx/api/v2/job_templates/5/launch/
|
||||
|
||||
|
||||
In AWX, the OAuth 2 system is built on top of the `Django Oauth Toolkit`_, which provides dedicated endpoints for authorizing, revoking, and refreshing tokens. These endpoints can be found under the ``/api/v2/users/<USER_ID>/personal_tokens/`` endpoint, which also provides detailed examples on some typical usage of those endpoints. These special OAuth 2 endpoints only support using the ``x-www-form-urlencoded`` **Content-type**, so none of the ``api/o/*`` endpoints accept ``application/json``.
|
||||
@@ -217,7 +217,7 @@ This returns a <token-value> that you can use to authenticate with for future re
|
||||
|
||||
::
|
||||
|
||||
curl -H "Authorization: Bearer <token-value>" -H "Content-Type: application/json" -X GET https://<awx>/api/v2/users/
|
||||
curl -H "Authorization: Bearer <token-value>" -H "Content-Type: application/json" https://<awx>/api/v2/users/
|
||||
|
||||
|
||||
The ``-k`` flag may be needed if you have not set up a CA yet and are using SSL.
|
||||
@@ -227,14 +227,14 @@ To revoke a token, you can make a DELETE on the detail page for that token, usin
|
||||
|
||||
::
|
||||
|
||||
curl -ku <user>:<password> -X DELETE https://<awx>/api/v2/tokens/<pk>/
|
||||
curl -u <user>:<password> -X DELETE https://<awx>/api/v2/tokens/<pk>/
|
||||
|
||||
|
||||
Similarly, using a token:
|
||||
|
||||
::
|
||||
|
||||
curl -H "Authorization: Bearer <token-value>" -X DELETE https://<awx>/api/v2/tokens/<pk>/ -k
|
||||
curl -H "Authorization: Bearer <token-value>" -X DELETE https://<awx>/api/v2/tokens/<pk>/
|
||||
|
||||
|
||||
.. _ag_oauth2_token_auth_grant_types:
|
||||
@@ -336,8 +336,7 @@ Logging in is not required for ``password`` grant type, so you can simply use cu
|
||||
|
||||
.. code-block:: text
|
||||
|
||||
curl -k --user <user>:<password> -H "Content-type: application/json" \
|
||||
-X POST \
|
||||
curl --user <user>:<password> -H "Content-type: application/json" \
|
||||
--data '{
|
||||
"description": "Token for Nagios Monitoring app",
|
||||
"application": 1,
|
||||
@@ -398,7 +397,7 @@ The ``/api/o/token/`` endpoint is used for refreshing the access token:
|
||||
|
||||
::
|
||||
|
||||
curl -X POST \
|
||||
curl \
|
||||
-d "grant_type=refresh_token&refresh_token=AL0NK9TTpv0qp54dGbC4VUZtsZ9r8z" \
|
||||
-u "gwSPoasWSdNkMDtBN3Hu2WYQpPWCO9SwUEsKK22l:fI6ZpfocHYBGfm1tP92r0yIgCyfRdDQt0Tos9L8a4fNsJjQQMwp9569eIaUBsaVDgt2eiwOGe0bg5m5vCSstClZmtdy359RVx2rQK5YlIWyPlrolpt2LEpVeKXWaiybo" \
|
||||
http://<awx>/api/o/token/ -i
|
||||
@@ -441,7 +440,7 @@ Revoking an access token by this method is the same as deleting the token resour
|
||||
|
||||
::
|
||||
|
||||
curl -X POST -d "token=rQONsve372fQwuc2pn76k3IHDCYpi7" \
|
||||
curl -d "token=rQONsve372fQwuc2pn76k3IHDCYpi7" \
|
||||
-u "gwSPoasWSdNkMDtBN3Hu2WYQpPWCO9SwUEsKK22l:fI6ZpfocHYBGfm1tP92r0yIgCyfRdDQt0Tos9L8a4fNsJjQQMwp9569eIaUBsaVDgt2eiwOGe0bg5m5vCSstClZmtdy359RVx2rQK5YlIWyPlrolpt2LEpVeKXWaiybo" \
|
||||
http://<awx>/api/o/revoke_token/ -i
|
||||
|
||||
@@ -455,7 +454,7 @@ Revoking an access token by this method is the same as deleting the token resour
|
||||
The **Allow External Users to Create Oauth2 Tokens** (``ALLOW_OAUTH2_FOR_EXTERNAL_USERS`` in the API) setting is disabled by default. External users refer to users authenticated externally with a service like LDAP, or any of the other SSO services. This setting ensures external users cannot *create* their own tokens. If you enable then disable it, any tokens created by external users in the meantime will still exist, and are not automatically revoked.
|
||||
|
||||
|
||||
Alternatively, you can use the ``manage`` utility, :ref:`ag_manage_utility_revoke_tokens`, to revoke tokens as described in the the :ref:`ag_token_utility` section.
|
||||
Alternatively, you can use the ``manage`` utility, :ref:`ag_manage_utility_revoke_tokens`, to revoke tokens as described in the :ref:`ag_token_utility` section.
|
||||
|
||||
|
||||
This setting can be configured at the system-level in the AWX User Interface:
|
||||
|
||||
@@ -11,13 +11,14 @@ Authentication methods help simplify logins for end users--offering single sign-
|
||||
|
||||
Account authentication can be configured in the AWX User Interface and saved to the PostgreSQL database. For instructions, refer to the :ref:`ag_configure_awx` section.
|
||||
|
||||
Account authentication in AWX can be configured to centrally use OAuth2, while enterprise-level account authentication can be configured for SAML, RADIUS, or even LDAP as a source for authentication information. See :ref:`ag_ent_auth`.
|
||||
Account authentication in AWX can be configured to centrally use OAuth2, while enterprise-level account authentication can be configured for :ref:`Azure <ag_auth_azure>`, :ref:`RADIUS <ag_auth_radius>`, :ref:`SAML <ag_auth_saml>`, or even :ref:`LDAP <ag_auth_ldap>` as a source for authentication information. See :ref:`ag_ent_auth` for more detail.
|
||||
|
||||
For websites, such as Microsoft Azure, Google or GitHub, that provide account information, account information is often implemented using the OAuth standard. OAuth is a secure authorization protocol which is commonly used in conjunction with account authentication to grant 3rd party applications a "session token" allowing them to make API calls to providers on the user’s behalf.
|
||||
|
||||
SAML (Security Assertion Markup Language) is an XML-based, open-standard data format for exchanging account authentication and authorization data between an identity provider and a service provider.
|
||||
Security Assertion Markup Language (:ref:`SAML <ag_auth_saml>`) is an XML-based, open-standard data format for exchanging account authentication and authorization data between an identity provider and a service provider.
|
||||
|
||||
The :ref:`RADIUS <ag_auth_radius>` distributed client/server system allows you to secure networks against unauthorized access and can be implemented in network environments requiring high levels of security while maintaining network access for remote users.
|
||||
|
||||
The RADIUS distributed client/server system allows you to secure networks against unauthorized access and can be implemented in network environments requiring high levels of security while maintaining network access for remote users.
|
||||
|
||||
.. _ag_auth_github:
|
||||
|
||||
@@ -378,7 +379,7 @@ the team will always be assigned to the single default organization.
|
||||
}
|
||||
|
||||
|
||||
Team mappings may be specified separately for each account authentication backend, based on which of these you setup. When defined, these configurations take precedence over the the global configuration above.
|
||||
Team mappings may be specified separately for each account authentication backend, based on which of these you setup. When defined, these configurations take precedence over the global configuration above.
|
||||
|
||||
::
|
||||
|
||||
|
||||
@@ -271,7 +271,7 @@ First, determine which is the updated module you want to use from the available
|
||||
|
||||
Next, create a new directory, at the same directory level of your Ansible source playbooks, named ``/library``.
|
||||
|
||||
Once this is created, copy the module you want to use and drop it into the ``/library`` directory--it will be consumed first over your system modules and can be removed once you have updated the the stable version via your normal package manager.
|
||||
Once this is created, copy the module you want to use and drop it into the ``/library`` directory--it will be consumed first over your system modules and can be removed once you have updated the stable version via your normal package manager.
|
||||
|
||||
|
||||
Using callback plugins with AWX
|
||||
|
||||
@@ -94,10 +94,10 @@ In some situations, you can modify the following:
|
||||
|
||||
- A new Host manually created on Inventory w/ inventory sources
|
||||
- In Groups that were created as a result of inventory source syncs
|
||||
- Variables on Host and Group are changeable
|
||||
|
||||
Hosts associated with the Smart Inventory are manifested at view time. If the results of a Smart Inventory contains more than one host with identical hostnames, only one of the matching hosts will be included as part of the Smart Inventory, ordered by Host ID.
|
||||
|
||||
Variables on Host and Group are not changeable even as the local system admin user.
|
||||
|
||||
.. _ug_host_filters:
|
||||
|
||||
|
||||
@@ -961,6 +961,8 @@ Extra Variables
|
||||
|
||||
When you pass survey variables, they are passed as extra variables (``extra_vars``) within AWX. This can be tricky, as passing extra variables to a job template (as you would do with a survey) can override other variables being passed from the inventory and project.
|
||||
|
||||
By default, ``extra_vars`` are marked as ``!unsafe`` unless you specify them on the job template’s Extra Variables section. These are trusted, because they can only be added by users with enough privileges to add or edit a Job Template. For example, nested variables do not expand when entered as a prompt, as the Jinja brackets are treated as a string. For more information about unsafe variables, see `unsafe or raw strings <https://docs.ansible.com/ansible/latest/playbook_guide/playbooks_advanced_syntax.html#unsafe-or-raw-strings>`_.
|
||||
|
||||
For example, say that you have a defined variable for an inventory for ``debug = true``. It is entirely possible that this variable, ``debug = true``, can be overridden in a job template survey.
|
||||
|
||||
To ensure that the variables you need to pass are not overridden, ensure they are included by redefining them in the survey. Keep in mind that extra variables can be defined at the inventory, group, and host levels.
|
||||
@@ -979,7 +981,7 @@ If specifying the ``ALLOW_JINJA_IN_EXTRA_VARS`` parameter, refer to the :ref:`AW
|
||||
The Job Template extra variables dictionary is merged with the Survey variables.
|
||||
|
||||
|
||||
Here are some simplified examples of extra_vars in YAML and JSON formats:
|
||||
Here are some simplified examples of ``extra_vars`` in YAML and JSON formats:
|
||||
|
||||
The configuration in YAML format:
|
||||
|
||||
|
||||
161
docs/rbac.md
161
docs/rbac.md
@@ -1,166 +1,13 @@
|
||||
# Role-Based Access Control (RBAC)
|
||||
|
||||
This document describes the RBAC implementation of the AWX Software.
|
||||
The intended audience of this document is the AWX developer.
|
||||
The Role-Based Access Control system has been moved to the django-ansible-base library.
|
||||
|
||||
https://github.com/ansible/django-ansible-base
|
||||
|
||||
## Overview
|
||||
|
||||
### RBAC - System Basics
|
||||
|
||||
There are three main concepts to be familiar with: Roles, Resources, and Users.
|
||||
Users can be members of a role, which gives them certain access to any
|
||||
resources associated with that role, or any resources associated with "descendent"
|
||||
roles.
|
||||
|
||||
For example, if I have an organization named "MyCompany" and I want to allow
|
||||
two people, "Alice", and "Bob", access to manage all of the settings associated
|
||||
with that organization, I'd make them both members of the organization's `admin_role`.
|
||||
|
||||
It is often the case that you have many Roles in a system, and you want some
|
||||
roles to include all of the capabilities of other roles. For example, you may
|
||||
want a System Administrator to have access to everything that an Organization
|
||||
Administrator has access to, who has everything that a Project Administrator
|
||||
has access to, and so on. We refer to this concept as the 'Role Hierarchy', and
|
||||
is represented by allowing roles to have "Parent Roles". Any permission that a
|
||||
role has is implicitly granted to any parent roles (or parents of those
|
||||
parents, and so on). Of course roles can have more than one parent, and
|
||||
capabilities are implicitly granted to all parents. (Technically speaking, this
|
||||
forms a directional acyclic graph instead of a strict hierarchy, but the
|
||||
concept should remain intuitive.)
|
||||
Illustrations from the old RBAC system, before the move to django-ansible-base.
|
||||
|
||||

|
||||
|
||||
|
||||
### Implementation Overview
|
||||
|
||||
The RBAC system allows you to create and layer roles for controlling access to resources. Any Django Model can
|
||||
be made into a resource in the RBAC system by using the `ResourceMixin`. Once a model is accessible as a resource, you can
|
||||
extend the model definition to have specific roles using the `ImplicitRoleField`. Within the declaration of
|
||||
this role field you can also specify any parents the role may have, and the RBAC system will take care of
|
||||
all of the appropriate ancestral binding that takes place behind the scenes to ensure that the model you've declared
|
||||
is kept up to date as the relations in your model change.
|
||||
|
||||
### Roles
|
||||
|
||||
Roles are defined for a resource. If a role has any parents, these parents will be considered when determining
|
||||
what roles are checked when accessing a resource.
|
||||
|
||||
ResourceA
|
||||
|-- AdminRole
|
||||
|
||||
ResourceB
|
||||
| -- AdminRole
|
||||
|-- parent = ResourceA.AdminRole
|
||||
|
||||
When a user attempts to access ResourceB, we will check for their access using the set of all unique roles, including the parents.
|
||||
|
||||
ResourceA.AdminRole, ResourceB.AdminRole
|
||||
|
||||
This would provide any members of the above roles with access to ResourceB.
|
||||
|
||||
#### Singleton Role
|
||||
|
||||
There is a special case _Singleton Role_ that you can create. This type of role is for system-wide roles.
|
||||
|
||||
### Models
|
||||
|
||||
The RBAC system defines a few new models. These models represent the underlying RBAC implementation and generally will be abstracted away from your daily development tasks by the implicit fields and mixins.
|
||||
|
||||
#### `Role`
|
||||
|
||||
`Role` defines a single role within the RBAC implementation. It encapsulates the `ancestors`, `parents`, and `members` for a role. This model is intentionally kept dumb and it has no explicit knowledge of a `Resource`. The `Role` model (get it?), defines some methods that aid in the granting and creation of roles.
|
||||
|
||||
##### `visible_roles(cls, user)`
|
||||
|
||||
`visible_roles` is a class method that will look up all of the `Role` instances a user can "see". This includes any roles the user is a direct descendent of as well as any ancestor roles.
|
||||
|
||||
##### `singleton(cls, name)`
|
||||
|
||||
The `singleton` class method is a helper method on the `Role` model that helps in the creation of singleton roles. It will return the role by name if it already exists or create and return the new role in the case it does not.
|
||||
|
||||
##### `get_absolute_url(self)`
|
||||
|
||||
`get_absolute_url` returns the consumable URL endpoint for the `Role`.
|
||||
|
||||
##### `rebuild_role_ancestor_list(self)`
|
||||
|
||||
`rebuild_role_ancestor_list` will rebuild the current role ancestry that is stored in the `ancestors` field of a `Role`. This is called for you by `save` and different Django signals.
|
||||
|
||||
##### `is_ancestor_of(self, role)`
|
||||
|
||||
`is_ancestor_of` returns if the given `role` is an ancestor of the current `Role` instance.
|
||||
|
||||
##### `user in role`
|
||||
|
||||
You may use the `user in some_role` syntax to check and see if the specified
|
||||
user is a member of the given role, **or** a member of any ancestor role.
|
||||
|
||||
### Fields
|
||||
|
||||
#### `ImplicitRoleField`
|
||||
|
||||
`ImplicitRoleField` fields are declared on your model. They provide the definition of grantable roles for accessing your resource. You may (and should) use the `parent_role` parameter to specify any parent roles that should inherit privileges implied by the role.
|
||||
|
||||
`parent_role` is the link to any parent roles you want considered when a user
|
||||
is requesting access to your resource. A `parent_role` can be declared as a
|
||||
single string, `"parent.read_role"`, or a list of many roles,
|
||||
`['parentA.read_role', 'parentB.read_role']` which will make each listed role a parent. You can also use the syntax
|
||||
`[('parentA.read_role', 'parentB.read_role'), 'parentC.read_role']` to make
|
||||
`(parentA.read_role OR parentB.read_role) AND 'parentC.read_role` parents (so `parentB.read_role` will be added only if `parentA.read_role` was `None`).
|
||||
If any listed role can't be evaluated (for example if there are `None` components in the path), then they are simply ignored until the value of the field changes.
|
||||
|
||||
|
||||
### Mixins
|
||||
|
||||
#### `ResourceMixin`
|
||||
|
||||
By mixing in the `ResourceMixin` to your model, you are turning your model in to a resource in the eyes of the RBAC implementation. Your model will gain the helper methods that aid in the checking the access a users roles provides them to your resource.
|
||||
|
||||
##### `accessible_objects(cls, user, role_field)`
|
||||
|
||||
`accessible_objects` is a class method to use instead of `Model.objects`. This method will restrict the query of objects to only those that the user has access to - specifically those objects which the user is a member of the specified role (either directly or indirectly).
|
||||
|
||||
```python
|
||||
objects = MyModel.accessible_objects(user, 'admin_role')
|
||||
objects.filter(name__istartswith='december')
|
||||
```
|
||||
|
||||
##### `accessible_pk_qs(cls, user, role_field)`
|
||||
|
||||
`accessible_pk_qs` returns a queryset of ids that match the same role filter as `accessible_objects`.
|
||||
A key difference is that this is more performant to use in subqueries when filtering related models.
|
||||
|
||||
Say that another model, `YourModel` has a ForeignKey reference to `MyModel` via a field `my_model`,
|
||||
and you want to return all instances of `YourModel` that have a visible related `MyModel`.
|
||||
The best way to do this is:
|
||||
|
||||
```python
|
||||
YourModel.filter(my_model=MyModel.accessible_pk_qs(user, 'admin_role'))
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
After exploring the _Overview_, the usage of the RBAC implementation in your code should feel unobtrusive and natural.
|
||||
|
||||
```python
|
||||
# make your model a Resource
|
||||
class Document(Model, ResourceMixin):
|
||||
...
|
||||
# declare your new role
|
||||
readonly_role = ImplicitRoleField()
|
||||
```
|
||||
|
||||
Now that your model is a resource and has a `Role` defined, you can begin to access the helper methods provided to you by the `ResourceMixin` for checking a user's access to your resource. Here is the output of a Python REPL session:
|
||||
|
||||
```python
|
||||
# we've created some documents and a user
|
||||
>>> document = Document.objects.filter(pk=1)
|
||||
>>> user = User.objects.first()
|
||||
>>> user in document.readonly_role
|
||||
False # not accessible by default
|
||||
>>> document.readonly_role.members.add(user)
|
||||
>>> user in document.readonly_role
|
||||
True # now it is accessible
|
||||
>>> user in document.readonly_role
|
||||
False # my role does not have admin permission
|
||||
```
|
||||
|
||||
@@ -22,9 +22,10 @@ django-guid==3.2.1
|
||||
django-oauth-toolkit<2.0.0 # Version 2.0.0 has breaking changes that will need to be worked out before upgrading
|
||||
django-polymorphic
|
||||
django-pglocks
|
||||
django-radius
|
||||
django-solo
|
||||
django-split-settings==1.0.0 # We hit a strange issue where the release process errored when upgrading past 1.0.0 see UPGRADE BLOCKERS
|
||||
djangorestframework
|
||||
djangorestframework>=3.15.0
|
||||
djangorestframework-yaml
|
||||
filelock
|
||||
GitPython>=3.1.37 # CVE-2023-41040
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
adal==1.2.7
|
||||
# via msrestazure
|
||||
aiohttp==3.9.3
|
||||
# via -r /awx_devel/requirements/requirements.in
|
||||
# via
|
||||
# -r /awx_devel/requirements/requirements.in
|
||||
# aiohttp-retry
|
||||
# twilio
|
||||
aiohttp-retry==2.8.3
|
||||
@@ -158,14 +159,15 @@ django-pglocks==1.0.4
|
||||
# via -r /awx_devel/requirements/requirements.in
|
||||
django-polymorphic==3.1.0
|
||||
# via -r /awx_devel/requirements/requirements.in
|
||||
# via -r /awx_devel/requirements/requirements_git.txt
|
||||
django-radius==1.5.1
|
||||
# via -r /awx_devel/requirements/requirements.in
|
||||
django-solo==2.2.0
|
||||
# via -r /awx_devel/requirements/requirements.in
|
||||
django-split-settings==1.0.0
|
||||
# via
|
||||
# -r /awx_devel/requirements/requirements.in
|
||||
# django-ansible-base
|
||||
djangorestframework==3.14.0
|
||||
djangorestframework==3.15.1
|
||||
# via
|
||||
# -r /awx_devel/requirements/requirements.in
|
||||
# django-ansible-base
|
||||
@@ -281,7 +283,7 @@ msal-extensions==1.1.0
|
||||
msgpack==1.0.5
|
||||
# via
|
||||
# -r /awx_devel/requirements/requirements.in
|
||||
# channels-redis
|
||||
# channels-redis
|
||||
msrest==0.7.1
|
||||
# via msrestazure
|
||||
msrestazure==0.6.4
|
||||
@@ -340,9 +342,7 @@ pycparser==2.21
|
||||
pydantic==2.5.0
|
||||
# via inflect
|
||||
pydantic-core==2.14.1
|
||||
# via
|
||||
# -r /awx_devel/requirements/requirements.in
|
||||
# pydantic
|
||||
# via pydantic
|
||||
pygerduty==0.38.3
|
||||
# via -r /awx_devel/requirements/requirements.in
|
||||
pyjwt[crypto]==2.8.0
|
||||
@@ -387,7 +387,6 @@ python3-openid==3.2.0
|
||||
# via -r /awx_devel/requirements/requirements_git.txt
|
||||
pytz==2024.1
|
||||
# via
|
||||
# djangorestframework
|
||||
# irc
|
||||
# tempora
|
||||
pyyaml==6.0.1
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
git+https://github.com/ansible/system-certifi.git@devel#egg=certifi
|
||||
# Remove pbr from requirements.in when moving ansible-runner to requirements.in
|
||||
git+https://github.com/ansible/ansible-runner.git@devel#egg=ansible-runner
|
||||
# django-radius library has not released since several dependency fixes were made
|
||||
# specifically need https://github.com/robgolding/django-radius/pull/27
|
||||
git+https://github.com/ansible/django-radius.git@develop#egg=django-radius
|
||||
git+https://github.com/ansible/python3-saml.git@devel#egg=python3-saml
|
||||
django-ansible-base @ git+https://github.com/ansible/django-ansible-base@devel#egg=django-ansible-base[rest_filters,jwt_consumer,resource_registry]
|
||||
django-ansible-base @ git+https://github.com/ansible/django-ansible-base@devel#egg=django-ansible-base[rest_filters,jwt_consumer,resource_registry,rbac]
|
||||
|
||||
@@ -36,6 +36,8 @@ RUN dnf -y update && dnf install -y 'dnf-command(config-manager)' && \
|
||||
{% endif %}
|
||||
nss \
|
||||
openldap-devel \
|
||||
# pin to older openssl, see jira AAP-23449
|
||||
openssl-3.0.7 \
|
||||
patch \
|
||||
postgresql \
|
||||
postgresql-devel \
|
||||
@@ -120,6 +122,8 @@ RUN dnf -y update && dnf install -y 'dnf-command(config-manager)' && \
|
||||
krb5-workstation \
|
||||
nginx \
|
||||
"openldap >= 2.6.2-3" \
|
||||
# pin to older openssl, see jira AAP-23449
|
||||
openssl-3.0.7 \
|
||||
postgresql \
|
||||
python3.11 \
|
||||
"python3.11-devel" \
|
||||
|
||||
@@ -387,7 +387,7 @@ Now we are ready to configure and plumb Keycloak with AWX. To do this we have pr
|
||||
* Backup and configure the SMAL and OIDC adapter in AWX. NOTE: the private key of any existing SAML or OIDC adapters can not be backed up through the API, you need a DB backup to recover this.
|
||||
|
||||
Before we can run the playbook we need to understand that SAML works by sending redirects between AWX and Keycloak through the browser. Because of this we have to tell both AWX and Keycloak how they will construct the redirect URLs. On the Keycloak side, this is done within the realm configuration and on the AWX side its done through the SAML settings. The playbook requires a variable called `container_reference` to be set. The container_reference variable needs to be how your browser will be able to talk to the running containers. Here are some examples of how to choose a proper container_reference.
|
||||
* If you develop on a mac which runs a Fedora VM which has AWX running within that and the browser you use to access AWX runs on the mac. The the VM with the container has its own IP that is mapped to a name like `tower.home.net`. In this scenario your "container_reference" could be either the IP of the VM or the tower.home.net friendly name.
|
||||
* If you develop on a mac which runs a Fedora VM which has AWX running within that and the browser you use to access AWX runs on the mac. The VM with the container has its own IP that is mapped to a name like `tower.home.net`. In this scenario your "container_reference" could be either the IP of the VM or the tower.home.net friendly name.
|
||||
* If you are on a Fedora work station running AWX and also using a browser on your workstation you could use localhost, your work stations IP or hostname as the container_reference.
|
||||
|
||||
In addition, OIDC works similar but slightly differently. OIDC has browser redirection but OIDC will also communicate from the AWX docker instance to the Keycloak docker instance directly. Any hostnames you might have are likely not propagated down into the AWX container. So we need a method for both the browser and AWX container to talk to Keycloak. For this we will likely use your machines IP address. This can be passed in as a variable called `oidc_reference`. If unset this will default to container_reference which may be viable for some configurations.
|
||||
|
||||
Reference in New Issue
Block a user