diff --git a/rootfs/api/migrations/0014_appsettings_whitelist.py b/rootfs/api/migrations/0014_appsettings_whitelist.py new file mode 100644 index 000000000..21b2e1a76 --- /dev/null +++ b/rootfs/api/migrations/0014_appsettings_whitelist.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10 on 2016-08-23 22:58 +from __future__ import unicode_literals + +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0013_auto_20160816_2122'), + ] + + operations = [ + migrations.AddField( + model_name='appsettings', + name='whitelist', + field=django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=50), default=[], size=None), + ), + ] diff --git a/rootfs/api/models/__init__.py b/rootfs/api/models/__init__.py index 2a8148d62..ff14b4cfa 100644 --- a/rootfs/api/models/__init__.py +++ b/rootfs/api/models/__init__.py @@ -17,7 +17,7 @@ from rest_framework.exceptions import ValidationError from rest_framework.authtoken.models import Token -from api.exceptions import DeisException, AlreadyExists, ServiceUnavailable # noqa +from api.exceptions import DeisException, AlreadyExists, ServiceUnavailable, UnprocessableEntity # noqa from api.utils import dict_merge from scheduler import KubeException diff --git a/rootfs/api/models/app.py b/rootfs/api/models/app.py index a50ca20b7..58b1d5043 100644 --- a/rootfs/api/models/app.py +++ b/rootfs/api/models/app.py @@ -430,7 +430,6 @@ def _scale_pods(self, scale_types): 'app_type': scale_type, 'build_type': release.build.type, 'healthcheck': healthcheck, - 'service_annotations': {'maintenance': app_settings.maintenance}, 'routable': routable, 'deploy_batches': batches, 'deploy_timeout': deploy_timeout, @@ -469,7 +468,8 @@ def deploy(self, release, force_deploy=False): raise DeisException('No build associated with this release') app_settings = self.appsettings_set.latest() - service_annotations = {'maintenance': app_settings.maintenance} + addresses = ",".join(address for address in app_settings.whitelist) + service_annotations = {'maintenance': app_settings.maintenance, 'whitelist': addresses} # use create to make sure minimum resources are created self.create() @@ -912,3 +912,16 @@ def _update_application_service(self, namespace, app_type, port, routable=False, # Fix service to old port and app type self._scheduler.update_service(namespace, namespace, data=old_service) raise KubeException(str(e)) from e + + def whitelist(self, whitelist): + """ + Add/ Delete addresses to application whitelist + """ + service = self._fetch_service_config(self.id) + + try: + addresses = ",".join(address for address in whitelist) + service['metadata']['annotations']['router.deis.io/whitelist'] = addresses + self._scheduler.update_service(self.id, self.id, data=service) + except KubeException as e: + raise ServiceUnavailable(str(e)) from e diff --git a/rootfs/api/models/appsettings.py b/rootfs/api/models/appsettings.py index 07a44328d..1d6e7fec8 100644 --- a/rootfs/api/models/appsettings.py +++ b/rootfs/api/models/appsettings.py @@ -1,6 +1,7 @@ import logging from django.conf import settings from django.db import models +from django.contrib.postgres.fields import ArrayField from api.models import UuidAuditedModel from api.exceptions import DeisException, AlreadyExists @@ -15,6 +16,7 @@ class AppSettings(UuidAuditedModel): app = models.ForeignKey('App', on_delete=models.CASCADE) maintenance = models.NullBooleanField(default=None) routable = models.NullBooleanField(default=None) + whitelist = ArrayField(models.CharField(max_length=50), default=[]) class Meta: get_latest_by = 'created' @@ -24,6 +26,17 @@ class Meta: def __str__(self): return "{}-{}".format(self.app.id, str(self.uuid)[:7]) + def new(self, user, whitelist): + """ + Create a new application appSettings using the provided whitelist + on behalf of a user. + """ + + appSettings = AppSettings.objects.create( + owner=user, app=self.app, whitelist=whitelist) + + return appSettings + def update_maintenance(self, previous_settings): old = getattr(previous_settings, 'maintenance', None) new = getattr(self, 'maintenance', None) @@ -52,6 +65,27 @@ def update_routable(self, previous_settings): self.app.routable(new) self.summary += ["{} changed routablity from {} to {}".format(self.owner, old, new)] + def update_whitelist(self, previous_settings): + # If no previous settings then assume it is the first record and do nothing + if not previous_settings: + return + old = getattr(previous_settings, 'whitelist', []) + new = getattr(self, 'whitelist', []) + # if nothing changed copy the settings from previous + if len(new) == 0 and len(old) != 0: + setattr(self, 'whitelist', old) + elif set(old) != set(new): + self.app.whitelist(new) + added = ', '.join(k for k in set(new)-set(old)) + added = 'added ' + added if added else '' + deleted = ', '.join(k for k in set(old)-set(new)) + deleted = 'deleted ' + deleted if deleted else '' + changes = ', '.join(i for i in (added, deleted) if i) + if changes: + if self.summary: + self.summary += ' and ' + self.summary += "{} {}".format(self.owner, changes) + def save(self, *args, **kwargs): self.summary = [] previous_settings = None @@ -63,6 +97,7 @@ def save(self, *args, **kwargs): try: self.update_maintenance(previous_settings) self.update_routable(previous_settings) + self.update_whitelist(previous_settings) except Exception as e: self.delete() raise DeisException(str(e)) from e diff --git a/rootfs/api/serializers.py b/rootfs/api/serializers.py index 843bb9275..c8fee9e40 100644 --- a/rootfs/api/serializers.py +++ b/rootfs/api/serializers.py @@ -7,6 +7,7 @@ import re import jsonschema import idna +import ipaddress from urllib.parse import urlparse from django.contrib.auth.models import User @@ -474,3 +475,18 @@ class Meta: """Metadata options for a :class:`AppSettingsSerializer`.""" model = models.AppSettings fields = '__all__' + + def validate_whitelist(self, data): + for address in data: + try: + ipaddress.ip_address(address) + except: + try: + ipaddress.ip_network(address) + except: + try: + ipaddress.ip_interface(address) + except: + raise serializers.ValidationError( + "The address {} is not valid".format(address)) + return data diff --git a/rootfs/api/tests/test_app_settings.py b/rootfs/api/tests/test_app_settings.py index 4896d65ee..d048108a6 100644 --- a/rootfs/api/tests/test_app_settings.py +++ b/rootfs/api/tests/test_app_settings.py @@ -5,6 +5,8 @@ from rest_framework.authtoken.models import Token from api.models import App +from unittest import mock +from scheduler import KubeException from api.tests import adapter, DeisTransactionTestCase @@ -89,3 +91,82 @@ def test_settings_routable(self, mock_requests): settings) self.assertEqual(response.status_code, 201, response.data) self.assertFalse(app.appsettings_set.latest().routable) + + def test_settings_whitelist(self, mock_requests): + """ + Test that addresses can be added/deleted to whitelist + """ + app_id = self.create_app() + app = App.objects.get(id=app_id) + # add addresses to empty whitelist + addresses = ["1.2.3.4", "0.0.0.0/0"] + whitelist = {'addresses': addresses} + response = self.client.post( + '/v2/apps/{app_id}/whitelist'.format(**locals()), + whitelist) + self.assertEqual(response.status_code, 201, response.data) + self.assertEqual(set(response.data['addresses']), + set(app.appsettings_set.latest().whitelist), response.data) + self.assertEqual(set(response.data['addresses']), set(addresses), response.data) + # get the whitelist + response = self.client.get('/v2/apps/{app_id}/whitelist'.format(**locals())) + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(set(response.data['addresses']), + set(app.appsettings_set.latest().whitelist), response.data) + self.assertEqual(set(response.data['addresses']), set(addresses), response.data) + # add addresses to non-empty whitelist + whitelist = {'addresses': ["2.3.4.5"]} + addresses.extend(["2.3.4.5"]) + response = self.client.post( + '/v2/apps/{app_id}/whitelist'.format(**locals()), + whitelist) + self.assertEqual(response.status_code, 201, response.data) + self.assertEqual(set(response.data['addresses']), + set(app.appsettings_set.latest().whitelist), response.data) + self.assertEqual(set(response.data['addresses']), set(addresses), response.data) + # add exisitng addresses to whitelist + response = self.client.post( + '/v2/apps/{app_id}/whitelist'.format(**locals()), + whitelist) + self.assertEqual(response.status_code, 409, response.data) + # delete non-exisitng address from whitelist + whitelist = {'addresses': ["2.3.4.6"]} + response = self.client.delete( + '/v2/apps/{app_id}/whitelist'.format(**locals()), + whitelist) + self.assertEqual(response.status_code, 422) + # delete an address from whitelist + whitelist = {'addresses': ["2.3.4.5"]} + addresses.remove("2.3.4.5") + response = self.client.delete( + '/v2/apps/{app_id}/whitelist'.format(**locals()), + whitelist) + self.assertEqual(response.status_code, 204, response.data) + self.assertEqual(set(addresses), set(app.appsettings_set.latest().whitelist)) + # pass invalid address + whitelist = {'addresses': ["2.3.4.6.7"]} + response = self.client.post( + '/v2/apps/{app_id}/whitelist'.format(**locals()), + whitelist) + self.assertEqual(response.status_code, 400, response.data) + # update other appsettings and whitelist should be retained + settings = {'maintenance': True} + response = self.client.post( + '/v2/apps/{app.id}/settings'.format(**locals()), + settings) + self.assertEqual(response.status_code, 201, response.data) + self.assertEqual(set(addresses), set(app.appsettings_set.latest().whitelist)) + + def test_kubernetes_service_failure(self, mock_requests): + """ + Cause an Exception in kubernetes services + """ + app_id = self.create_app() + + # scheduler.update_service exception + with mock.patch('scheduler.KubeHTTPClient.update_service') as mock_kube: + mock_kube.side_effect = KubeException('Boom!') + addresses = ["2.3.4.5"] + url = '/v2/apps/{}/whitelist'.format(app_id) + response = self.client.post(url, {'addresses': addresses}) + self.assertEqual(response.status_code, 400, response.data) diff --git a/rootfs/api/urls.py b/rootfs/api/urls.py index dccfd236f..672a3ffcb 100644 --- a/rootfs/api/urls.py +++ b/rootfs/api/urls.py @@ -60,6 +60,9 @@ # application settings url(r"^apps/(?P{})/settings/?".format(settings.APP_URL_REGEX), views.AppSettingsViewSet.as_view({'get': 'retrieve', 'post': 'create'})), + # application ip whitelist + url(r"^apps/(?P{})/whitelist/?".format(settings.APP_URL_REGEX), + views.WhitelistViewSet.as_view({'post': 'create', 'get': 'list', 'delete': 'delete'})), # apps sharing url(r"^apps/(?P{})/perms/(?P[-_\w]+)/?".format(settings.APP_URL_REGEX), views.AppPermsViewSet.as_view({'delete': 'destroy'})), diff --git a/rootfs/api/views.py b/rootfs/api/views.py index c33d53d4b..570d63543 100644 --- a/rootfs/api/views.py +++ b/rootfs/api/views.py @@ -16,7 +16,7 @@ from rest_framework.authtoken.models import Token from api import authentication, models, permissions, serializers, viewsets -from api.models import AlreadyExists, ServiceUnavailable, DeisException +from api.models import AlreadyExists, ServiceUnavailable, DeisException, UnprocessableEntity import logging @@ -301,6 +301,34 @@ class AppSettingsViewSet(AppResourceViewSet): serializer_class = serializers.AppSettingsSerializer +class WhitelistViewSet(AppResourceViewSet): + model = models.AppSettings + serializer_class = serializers.AppSettingsSerializer + + def list(self, *args, **kwargs): + appSettings = self.get_app().appsettings_set.latest() + data = {"addresses": appSettings.whitelist} + return Response(data, status=status.HTTP_200_OK) + + def create(self, request, **kwargs): + appSettings = self.get_app().appsettings_set.latest() + addresses = self.get_serializer().validate_whitelist(request.data.get('addresses')) + addresses = list(set(appSettings.whitelist) | set(addresses)) + new_appsettings = appSettings.new(self.request.user, whitelist=addresses) + return Response({"addresses": new_appsettings.whitelist}, status=status.HTTP_201_CREATED) + + def delete(self, request, **kwargs): + appSettings = self.get_app().appsettings_set.latest() + addresses = self.get_serializer().validate_whitelist(request.data.get('addresses')) + + unfound_addresses = set(addresses) - set(appSettings.whitelist) + if len(unfound_addresses) != 0: + raise UnprocessableEntity('addresses {} does not exist in whitelist'.format(unfound_addresses)) # noqa + addresses = list(set(appSettings.whitelist) - set(addresses)) + appSettings.new(self.request.user, whitelist=addresses) + return Response(status=status.HTTP_204_NO_CONTENT) + + class DomainViewSet(AppResourceViewSet): """A viewset for interacting with Domain objects.""" model = models.Domain