Skip to content
This repository has been archived by the owner on May 6, 2020. It is now read-only.

Commit

Permalink
feat(whitelist): Add support for IP whitelist
Browse files Browse the repository at this point in the history
  • Loading branch information
kmala committed Aug 25, 2016
1 parent 757a8ae commit cc0a4d5
Show file tree
Hide file tree
Showing 8 changed files with 201 additions and 4 deletions.
21 changes: 21 additions & 0 deletions 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),
),
]
2 changes: 1 addition & 1 deletion rootfs/api/models/__init__.py
Expand Up @@ -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

Expand Down
17 changes: 15 additions & 2 deletions rootfs/api/models/app.py
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
35 changes: 35 additions & 0 deletions 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
Expand All @@ -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'
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
16 changes: 16 additions & 0 deletions rootfs/api/serializers.py
Expand Up @@ -7,6 +7,7 @@
import re
import jsonschema
import idna
import ipaddress
from urllib.parse import urlparse

from django.contrib.auth.models import User
Expand Down Expand Up @@ -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
81 changes: 81 additions & 0 deletions rootfs/api/tests/test_app_settings.py
Expand Up @@ -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


Expand Down Expand Up @@ -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)
3 changes: 3 additions & 0 deletions rootfs/api/urls.py
Expand Up @@ -60,6 +60,9 @@
# application settings
url(r"^apps/(?P<id>{})/settings/?".format(settings.APP_URL_REGEX),
views.AppSettingsViewSet.as_view({'get': 'retrieve', 'post': 'create'})),
# application ip whitelist
url(r"^apps/(?P<id>{})/whitelist/?".format(settings.APP_URL_REGEX),
views.WhitelistViewSet.as_view({'post': 'create', 'get': 'list', 'delete': 'delete'})),
# apps sharing
url(r"^apps/(?P<id>{})/perms/(?P<username>[-_\w]+)/?".format(settings.APP_URL_REGEX),
views.AppPermsViewSet.as_view({'delete': 'destroy'})),
Expand Down
30 changes: 29 additions & 1 deletion rootfs/api/views.py
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down

0 comments on commit cc0a4d5

Please sign in to comment.