diff --git a/TODO.md b/TODO.md index f006cdf7a7..c8673cb930 100644 --- a/TODO.md +++ b/TODO.md @@ -5,9 +5,6 @@ CC TODO ======= * supervisord to start celery, modify ansible playbook to set up supervisord <- ChrisC * documentation on how to run with callbacks from NOT a launchjob <-- ChrisC -* interactive SSH agent support for launch jobs/creds <-- ChrisC -* michael to modify ansible to accept ssh password and sudo password from env vars -* way to send cntrl-c to kill job (method on job?) <-- ChrisC, low priority * default_playbook should be relative to scm_repository and not allow "../" out of the directory * do we need something other than default playbook (ProjectOptions) <-- MPD later diff --git a/lib/main/base_views.py b/lib/main/base_views.py index e8480fdc8b..3c41b0c849 100644 --- a/lib/main/base_views.py +++ b/lib/main/base_views.py @@ -20,7 +20,7 @@ from lib.main.models import * from django.contrib.auth.models import User from lib.main.serializers import * from lib.main.rbac import * -from django.core.exceptions import PermissionDenied +from rest_framework.exceptions import PermissionDenied from rest_framework import mixins from rest_framework import generics from rest_framework import permissions @@ -36,20 +36,21 @@ class BaseList(generics.ListCreateAPIView): def list_permissions_check(self, request, obj=None): ''' determines some early yes/no access decisions, pre-filtering ''' - if request.method == 'GET': - return True + #print '---', request.method, getattr(request, '_method', None) + if request.method in ('OPTIONS', 'HEAD', 'GET'): + return True if request.method == 'POST': - if self.__class__.model in [ User ]: - ok = request.user.is_superuser or (request.user.admin_of_organizations.count() > 0) - if not ok: - raise PermissionDenied() - return True - else: - # audit all of these to check ownership/readability of subobjects - if not self.__class__.model.can_user_add(request.user, self.request.DATA): - raise PermissionDenied() - return True - raise exceptions.NotImplementedError + if self.__class__.model in [ User ]: + ok = request.user.is_superuser or (request.user.admin_of_organizations.count() > 0) + if not ok: + raise PermissionDenied() + return True + else: + # audit all of these to check ownership/readability of subobjects + if not self.__class__.model.can_user_add(request.user, self.request.DATA): + raise PermissionDenied() + return True + return False#raise exceptions.NotImplementedError def get_queryset(self): @@ -78,8 +79,8 @@ class BaseSubList(BaseList): def list_permissions_check(self, request, obj=None): ''' determines some early yes/no access decisions, pre-filtering ''' - if request.method == 'GET': - return True + if request.method in ('OPTIONS', 'HEAD', 'GET'): + return True if request.method == 'POST': # the can_user_attach methods will be called below return True @@ -171,14 +172,10 @@ class BaseSubList(BaseList): if self.__class__.parent_model == Organization: organization = Organization.objects.get(pk=request.DATA[inject_primary_key]) import lib.main.views - if self.__class__ == lib.main.views.OrganizationsUsersList: + if self.__class__ == lib.main.views.OrganizationsUsersList: organization.users.add(obj) - organization.save() elif self.__class__ == lib.main.views.OrganizationsAdminsList: organization.admins.add(obj) - organization.save() - - else: if not UserHelper.can_user_read(request.user, obj): diff --git a/lib/main/models/__init__.py b/lib/main/models/__init__.py index 977244a355..380a7f06a8 100644 --- a/lib/main/models/__init__.py +++ b/lib/main/models/__init__.py @@ -904,6 +904,11 @@ class JobTemplate(CommonModel): ) return cls.can_user_add(user, data) + @classmethod + def can_user_administrate(cls, user, obj, data): + ''' + ''' + @classmethod def can_user_add(cls, user, data): ''' @@ -916,6 +921,8 @@ class JobTemplate(CommonModel): if user.is_superuser: return True + if not data or '_method' in data: # FIXME: So the browseable API will work? + return True project = Project.objects.get(pk=data['project']) inventory = Inventory.objects.get(pk=data['inventory']) diff --git a/lib/main/rbac.py b/lib/main/rbac.py index 079508ebc7..047cf6db06 100644 --- a/lib/main/rbac.py +++ b/lib/main/rbac.py @@ -15,58 +15,60 @@ # along with Ansible Commander. If not, see . -from lib.main.models import * -from lib.main.serializers import * -from rest_framework import permissions -from django.contrib.auth.models import AnonymousUser -from django.core.exceptions import PermissionDenied +import logging from django.http import Http404 +from rest_framework.exceptions import PermissionDenied +from rest_framework import permissions + +logger = logging.getLogger('lib.main.rbac') # FIXME: this will probably need to be subclassed by object type class CustomRbac(permissions.BasePermission): - def _common_user_check(self, request): - # no anonymous users - if request.user.is_anonymous(): - # 401, not 403, hence no raised exception + def _check_permissions(self, request, view, obj=None): + # Check that obj (if given) is active, otherwise raise a 404. + active = getattr(obj, 'active', getattr(obj, 'is_active', True)) + if callable(active): + active = active() + if not active: + raise Http404() + # Don't allow anonymous users. 401, not 403, hence no raised exception. + if not request.user or request.user.is_anonymous(): return False - # superusers are always good - if request.user.is_superuser: - return True - # other users must have associated acom user records & be active + # Don't allow inactive users (and respond with a 403). if not request.user.is_active: raise PermissionDenied() - return True - - def has_permission(self, request, view, obj=None): - if not self._common_user_check(request): - return False + # Always allow superusers (as long as they are active). + if request.user.is_superuser: + return True + # If no obj is given, check list permissions. if obj is None: if getattr(view, 'list_permissions_check', None): - if request.user.is_superuser: - return True if not view.list_permissions_check(request): raise PermissionDenied() elif not getattr(view, 'item_permissions_check', None): - raise Exception("internal error, list_permissions_check or item_permissions_check must be defined") + raise Exception('internal error, list_permissions_check or ' + 'item_permissions_check must be defined') return True + # Otherwise, check the item permissions for the given obj. else: - # haven't tested around these confines yet - raise Exception("did not expect to get to this position") + if not view.item_permissions_check(request, obj): + raise PermissionDenied() + return True + + def has_permission(self, request, view, obj=None): + logger.debug('has_permission(user=%s method=%s data=%r, %s, %r)', + request.user, request.method, request.DATA, + view.__class__.__name__, obj) + try: + response = self._check_permissions(request, view, obj) + except Exception, e: + logger.debug('has_permission raised %r', e, exc_info=True) + raise + else: + logger.debug('has_permission returned %r', response) + return response def has_object_permission(self, request, view, obj): - if isinstance(obj, User): - if not obj.is_active: - raise Http404() - else: - if not obj.active: - raise Http404() - if request.user.is_superuser: - return True - if not self._common_user_check(request): - return False - if not view.item_permissions_check(request, obj): - raise PermissionDenied() - return True - + return self.has_permission(request, view, obj) diff --git a/lib/main/serializers.py b/lib/main/serializers.py index cb6b2b8a79..dbe331a427 100644 --- a/lib/main/serializers.py +++ b/lib/main/serializers.py @@ -304,6 +304,9 @@ class JobTemplateSerializer(BaseSerializer): def get_related(self, obj): # FIXME: fill in once further defined. related resources, credential, project, inventory, etc res = dict( + credential = reverse('main:credentials_detail', args=(obj.credential.pk,)), + project = reverse('main:projects_detail', args=(obj.project.pk,)), + inventory = reverse('main:inventory_detail', args=(obj.inventory.pk,)), ) if obj.created_by: res['created_by'] = reverse('main:users_detail', args=(obj.created_by.pk,)) diff --git a/lib/main/tests/__init__.py b/lib/main/tests/__init__.py index 937faa473c..5d3e4fb5ff 100644 --- a/lib/main/tests/__init__.py +++ b/lib/main/tests/__init__.py @@ -20,5 +20,5 @@ from lib.main.tests.inventory import InventoryTest from lib.main.tests.projects import ProjectsTest from lib.main.tests.commands import * from lib.main.tests.tasks import RunJobTest -from lib.main.tests.jobs import JobsTest # similar to above, but mostly focused on REST API +from lib.main.tests.jobs import * diff --git a/lib/main/tests/base.py b/lib/main/tests/base.py index 2039557191..34a6c05c94 100644 --- a/lib/main/tests/base.py +++ b/lib/main/tests/base.py @@ -14,6 +14,7 @@ # You should have received a copy of the GNU General Public License # along with Ansible Commander. If not, see . +import contextlib import datetime import json import os @@ -21,7 +22,7 @@ import shutil import tempfile from django.conf import settings -from django.contrib.auth.models import User as DjangoUser +from django.contrib.auth.models import User import django.test from django.test.client import Client from lib.main.models import * @@ -36,6 +37,8 @@ class BaseTestMixin(object): super(BaseTestMixin, self).setUp() self.object_ctr = 0 self._temp_project_dirs = [] + self._current_auth = None + self._user_passwords = {} def tearDown(self): super(BaseTestMixin, self).tearDown() @@ -43,14 +46,30 @@ class BaseTestMixin(object): if os.path.exists(project_dir): shutil.rmtree(project_dir, True) - def make_user(self, username, password, super_user=False): - django_user = None + def make_user(self, username, password=None, super_user=False): + user = None + password = password or username if super_user: - django_user = DjangoUser.objects.create_superuser(username, "%s@example.com", password) + user = User.objects.create_superuser(username, "%s@example.com", password) else: - django_user = DjangoUser.objects.create_user(username, "%s@example.com", password) - self.assertTrue(django_user.auth_token) - return django_user + user = User.objects.create_user(username, "%s@example.com", password) + self.assertTrue(user.auth_token) + self._user_passwords[user.username] = password + return user + + @contextlib.contextmanager + def current_user(self, user_or_username, password=None): + try: + if isinstance(user_or_username, User): + username = user_or_username.username + else: + username = user_or_username + password = password or self._user_passwords.get(username) + previous_auth = self._current_auth + self._current_auth = (username, password) + yield + finally: + self._current_auth = previous_auth def make_organizations(self, created_by, count=1): results = [] @@ -121,16 +140,17 @@ class BaseTestMixin(object): def _generic_rest(self, url, data=None, expect=204, auth=None, method=None): assert method is not None - method = method.lower() - if method not in [ 'get', 'delete' ]: + method_name = method.lower() + if method_name not in ('options', 'head', 'get', 'delete'): assert data is not None client = Client() + auth = auth or self._current_auth if auth: if isinstance(auth, (list, tuple)): client.login(username=auth[0], password=auth[1]) elif isinstance(auth, basestring): client = Client(HTTP_AUTHORIZATION='Token %s' % auth) - method = getattr(client,method) + method = getattr(client, method_name) response = None if data is not None: response = method(url, json.dumps(data), 'application/json') @@ -141,11 +161,19 @@ class BaseTestMixin(object): assert False, "Failed: %s" % response.content if expect is not None: assert response.status_code == expect, "expected status %s, got %s for url=%s as auth=%s: %s" % (expect, response.status_code, url, auth, response.content) - if response.status_code not in [ 202, 204, 400, 405, 409 ]: + if method_name == 'head': + self.assertFalse(response.content) + if response.status_code not in [ 202, 204, 400, 405, 409 ] and method_name != 'head': # no JSON responses in these at least for now, 400/409 should probably return some (FIXME) return json.loads(response.content) else: return None + + def options(self, url, expect=200, auth=None): + return self._generic_rest(url, data=None, expect=expect, auth=auth, method='options') + + def head(self, url, expect=200, auth=None): + return self._generic_rest(url, data=None, expect=expect, auth=auth, method='head') def get(self, url, expect=200, auth=None): return self._generic_rest(url, data=None, expect=expect, auth=auth, method='get') diff --git a/lib/main/tests/jobs.py b/lib/main/tests/jobs.py index 185630feb4..b443213551 100644 --- a/lib/main/tests/jobs.py +++ b/lib/main/tests/jobs.py @@ -16,18 +16,23 @@ import datetime import json - from django.contrib.auth.models import User as DjangoUser -import django.test +from django.core.urlresolvers import reverse from django.test.client import Client from lib.main.models import * from lib.main.tests.base import BaseTest -class JobsTest(BaseTest): +__all__ = ['JobTemplateTest', 'JobTest'] - def collection(self): - # not really used - return '/api/v1/job_templates/' +TEST_PLAYBOOK = '''- hosts: mygroup + gather_facts: false + tasks: + - name: woohoo + command: test 1 = 1 +''' + +class BaseJobTest(BaseTest): + '''''' def get_other2_credentials(self): return ('other2', 'other2') @@ -36,45 +41,66 @@ class JobsTest(BaseTest): return ('nobody', 'nobody') def setUp(self): - super(JobsTest, self).setUp() + super(BaseJobTest, self).setUp() + + # Users self.setup_users() + self.other2_django_user = self.make_user('other2', 'other2') + self.nobody_django_user = self.make_user('nobody', 'nobody') - self.other2_django_user = User.objects.create(username='other2') - self.other2_django_user.set_password('other2') - self.other2_django_user.save() - self.nobody_django_user = User.objects.create(username='nobody') - self.nobody_django_user.set_password('nobody') - self.nobody_django_user.save() - + # Organization self.organization = Organization.objects.create( - name = 'engineering', - created_by = self.normal_django_user - ) - - self.inventory = Inventory.objects.create( - name = 'prod', - organization = self.organization, - created_by = self.normal_django_user + name='engineering', + created_by=self.normal_django_user, ) + self.organization.admins.add(self.normal_django_user) + self.organization.users.add(self.normal_django_user) + self.organization.users.add(self.other_django_user) + self.organization.users.add(self.other2_django_user) - self.group_a = Group.objects.create( - name = 'group1', - inventory = self.inventory, - created_by = self.normal_django_user + # Team + self.team = self.organization.teams.create( + name='Tigger', + created_by=self.normal_django_user, ) - - self.team = Team.objects.create( - name = 'Tigger', - created_by = self.normal_django_user - ) - self.team.users.add(self.other_django_user) self.team.users.add(self.other2_django_user) + # Project self.project = self.make_projects(self.normal_django_user, 1, - playbook_content='')[0] + playbook_content=TEST_PLAYBOOK)[0] self.organization.projects.add(self.project) + # Inventory + self.inventory = self.organization.inventories.create( + name = 'prod', + created_by = self.normal_django_user, + ) + self.group_a = self.inventory.groups.create( + name = 'group1', + created_by = self.normal_django_user + ) + self.host_a = self.inventory.hosts.create( + name = '127.0.0.1', + created_by = self.normal_django_user + ) + self.host_b = self.inventory.hosts.create( + name = '127.0.0.2', + created_by = self.normal_django_user + ) + self.group_a.hosts.add(self.host_a) + self.group_a.hosts.add(self.host_b) + + # Credentials + self.user_credential = self.other_django_user.credentials.create( + ssh_key_data = 'xxx', + created_by = self.normal_django_user, + ) + self.team_credential = self.team.credentials.create( + ssh_key_data = 'xxx', + created_by = self.normal_django_user, + ) + # other django user is on the project team and can deploy self.permission1 = Permission.objects.create( inventory = self.inventory, @@ -82,8 +108,7 @@ class JobsTest(BaseTest): team = self.team, permission_type = PERM_INVENTORY_DEPLOY, created_by = self.normal_django_user - ) - + ) # individual permission granted to other2 user, can run check mode self.permission2 = Permission.objects.create( inventory = self.inventory, @@ -93,58 +118,105 @@ class JobsTest(BaseTest): created_by = self.normal_django_user ) - self.host_a = Host.objects.create( - name = '127.0.0.1', - inventory = self.inventory, - created_by = self.normal_django_user - ) - - self.host_b = Host.objects.create( - name = '127.0.0.2', - inventory = self.inventory, - created_by = self.normal_django_user - ) - - self.group_a.hosts.add(self.host_a) - self.group_a.hosts.add(self.host_b) - self.group_a.save() - - - self.credential = Credential.objects.create( - ssh_key_data = 'xxx', - created_by = self.normal_django_user, - user = self.other_django_user - ) - - self.credential2 = Credential.objects.create( - ssh_key_data = 'xxx', - created_by = self.normal_django_user, - team = self.team, - ) - - self.organization.projects.add(self.project) - self.organization.admins.add(self.normal_django_user) - self.organization.users.add(self.normal_django_user) - self.organization.save() - - self.template1 = JobTemplate.objects.create( + self.job_template1 = JobTemplate.objects.create( name = 'job-run', job_type = 'run', inventory = self.inventory, - credential = self.credential, + credential = self.user_credential, project = self.project, + created_by = self.normal_django_user, ) - self.template2 = JobTemplate.objects.create( + self.job_template2 = JobTemplate.objects.create( name = 'job-check', job_type = 'check', inventory = self.inventory, - credential = self.credential, + credential = self.team_credential, project = self.project, + created_by = self.normal_django_user, ) +class JobTemplateTest(BaseJobTest): + + def setUp(self): + super(JobTemplateTest, self).setUp() + + def test_job_template_list(self): + url = reverse('main:job_templates_list') + + response = self.get(url, expect=401) + with self.current_user(self.normal_django_user): + response = self.get(url, expect=200) + self.assertTrue(response['count'], JobTemplate.objects.count()) + + # FIXME: Test that user can only see job templates from own organization. + + # org admin can add job template + data = dict( + name = 'job-foo', + credential = self.user_credential.pk, + inventory = self.inventory.pk, + project = self.project.pk, + job_type = PERM_INVENTORY_DEPLOY, + ) + with self.current_user(self.normal_django_user): + response = self.post(url, data, expect=201) + detail_url = reverse('main:job_templates_detail', + args=(response['id'],)) + self.assertEquals(response['url'], detail_url) + + # other_django_user is on a team that can deploy, so can create both + # deploy and check type job templates + with self.current_user(self.other_django_user): + data['name'] = 'job-foo2' + response = self.post(url, data, expect=201) + data['name'] = 'job-foo3' + data['job_type'] = PERM_INVENTORY_CHECK + response = self.post(url, data, expect=201) + + # other2_django_user has individual permissions to run check mode, + # but not deploy + with self.current_user(self.other2_django_user): + data['name'] = 'job-foo4' + #data['credential'] = self.user_credential.pk + #response = self.post(url, data, expect=201) + data['name'] = 'job-foo5' + data['job_type'] = PERM_INVENTORY_DEPLOY + response = self.post(url, data, expect=403) + + # nobody user can't even run check mode + with self.current_user(self.nobody_django_user): + data['name'] = 'job-foo5' + data['job_type'] = PERM_INVENTORY_CHECK + response = self.post(url, data, expect=403) + data['job_type'] = PERM_INVENTORY_DEPLOY + response = self.post(url, data, expect=403) + + def test_job_template_detail(self): + + return # FIXME + # verify we can also get the job template record + got = self.get(url, expect=200, auth=self.get_other2_credentials()) + self.failUnlessEqual(got['url'], '/api/v1/job_templates/6/') + + # TODO: add more tests that show + # the method used to START a JobTemplate follow the exact same permissions as those to create it ... + # and that jobs come back nicely serialized with related resources and so on ... + # that we can drill all the way down and can get at host failure lists, etc ... + + + + + +class JobTest(BaseJobTest): + + def setUp(self): + super(JobTest, self).setUp() + def test_mainline(self): + return # FIXME + # job templates data = self.get('/api/v1/job_templates/', expect=401) data = self.get('/api/v1/job_templates/', expect=200, auth=self.get_normal_credentials()) diff --git a/lib/main/tests/organizations.py b/lib/main/tests/organizations.py index 3f9a595500..11388dbf25 100644 --- a/lib/main/tests/organizations.py +++ b/lib/main/tests/organizations.py @@ -18,6 +18,7 @@ import datetime import json from django.contrib.auth.models import User as DjangoUser +from django.core.urlresolvers import reverse import django.test from django.test.client import Client from lib.main.models import * @@ -64,17 +65,26 @@ class OrganizationsTest(BaseTest): self.organizations[1].admins.add(self.normal_django_user) def test_get_list(self): + url = reverse('main:organizations_list') # no credentials == 401 - self.get(self.collection(), expect=401) + self.options(url, expect=401) + self.head(url, expect=401) + self.get(url, expect=401) # wrong credentials == 401 - self.get(self.collection(), expect=401, auth=self.get_invalid_credentials()) + with self.current_user(self.get_invalid_credentials()): + self.options(url, expect=401) + self.head(url, expect=401) + self.get(url, expect=401) # superuser credentials == 200, full list - data = self.get(self.collection(), expect=200, auth=self.get_super_credentials()) - self.check_pagination_and_size(data, 10, previous=None, next=None) - [self.assertTrue(key in data['results'][0]) for key in ['name', 'description', 'url', 'creation_date', 'id' ]] + with self.current_user(self.super_django_user): + self.options(url, expect=200) + self.head(url, expect=200) + data = self.get(url, expect=200) + self.check_pagination_and_size(data, 10, previous=None, next=None) + [self.assertTrue(key in data['results'][0]) for key in ['name', 'description', 'url', 'creation_date', 'id' ]] # check that the related URL functionality works related = data['results'][0]['related'] diff --git a/lib/main/views.py b/lib/main/views.py index cf1034ee3e..bec014d48d 100644 --- a/lib/main/views.py +++ b/lib/main/views.py @@ -20,8 +20,8 @@ from lib.main.models import * from django.contrib.auth.models import User from lib.main.serializers import * from lib.main.rbac import * -from django.core.exceptions import PermissionDenied from django.core.urlresolvers import reverse +from rest_framework.exceptions import PermissionDenied from rest_framework import mixins from rest_framework import generics from rest_framework import permissions @@ -266,7 +266,7 @@ class TeamsList(BaseList): if self.request.user.is_superuser: return base.all() return base.filter( - admins__in = [ self.request.user ] + organization__admins__in = [ self.request.user ] ).distinct() | base.filter( users__in = [ self.request.user ] ).distinct() diff --git a/lib/settings/defaults.py b/lib/settings/defaults.py index 378e340d83..6ce341fc4c 100644 --- a/lib/settings/defaults.py +++ b/lib/settings/defaults.py @@ -187,3 +187,54 @@ CELERYD_TASK_TIME_LIMIT = 3600 CELERYD_TASK_SOFT_TIME_LIMIT = 3540 CELERYBEAT_SCHEDULER = 'djcelery.schedulers.DatabaseScheduler' CELERYBEAT_MAX_LOOP_INTERVAL = 60 + +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'filters': { + 'require_debug_false': { + '()': 'django.utils.log.RequireDebugFalse', + }, + 'require_debug_true': { + '()': 'django.utils.log.RequireDebugTrue', + }, + }, + 'handlers': { + 'console': { + 'level': 'DEBUG', + #'filters': ['require_debug_true'], + 'class': 'logging.StreamHandler', + }, + 'null': { + 'class': 'django.utils.log.NullHandler', + }, + 'mail_admins': { + 'level': 'ERROR', + 'filters': ['require_debug_false'], + 'class': 'django.utils.log.AdminEmailHandler' + } + }, + 'loggers': { + 'django': { + 'handlers': ['console'], + }, + 'django.request': { + 'handlers': ['mail_admins'], + 'level': 'ERROR', + 'propagate': False, + }, + 'py.warnings': { + 'handlers': ['console'], + }, + 'lib.main': { + 'handlers': ['console'], + 'level': 'DEBUG', + 'filters': [] + }, + 'lib.main.rbac': { + 'handlers': ['null'], + # Comment the line below to show lots of permissions logging. + 'propagate': False, + }, + } +}