Skip to content

Commit cc0a4d5

Browse files
committed
feat(whitelist): Add support for IP whitelist
1 parent 757a8ae commit cc0a4d5

8 files changed

Lines changed: 201 additions & 4 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# -*- coding: utf-8 -*-
2+
# Generated by Django 1.10 on 2016-08-23 22:58
3+
from __future__ import unicode_literals
4+
5+
import django.contrib.postgres.fields
6+
from django.db import migrations, models
7+
8+
9+
class Migration(migrations.Migration):
10+
11+
dependencies = [
12+
('api', '0013_auto_20160816_2122'),
13+
]
14+
15+
operations = [
16+
migrations.AddField(
17+
model_name='appsettings',
18+
name='whitelist',
19+
field=django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=50), default=[], size=None),
20+
),
21+
]

rootfs/api/models/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from rest_framework.exceptions import ValidationError
1818
from rest_framework.authtoken.models import Token
1919

20-
from api.exceptions import DeisException, AlreadyExists, ServiceUnavailable # noqa
20+
from api.exceptions import DeisException, AlreadyExists, ServiceUnavailable, UnprocessableEntity # noqa
2121
from api.utils import dict_merge
2222
from scheduler import KubeException
2323

rootfs/api/models/app.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -430,7 +430,6 @@ def _scale_pods(self, scale_types):
430430
'app_type': scale_type,
431431
'build_type': release.build.type,
432432
'healthcheck': healthcheck,
433-
'service_annotations': {'maintenance': app_settings.maintenance},
434433
'routable': routable,
435434
'deploy_batches': batches,
436435
'deploy_timeout': deploy_timeout,
@@ -469,7 +468,8 @@ def deploy(self, release, force_deploy=False):
469468
raise DeisException('No build associated with this release')
470469

471470
app_settings = self.appsettings_set.latest()
472-
service_annotations = {'maintenance': app_settings.maintenance}
471+
addresses = ",".join(address for address in app_settings.whitelist)
472+
service_annotations = {'maintenance': app_settings.maintenance, 'whitelist': addresses}
473473

474474
# use create to make sure minimum resources are created
475475
self.create()
@@ -912,3 +912,16 @@ def _update_application_service(self, namespace, app_type, port, routable=False,
912912
# Fix service to old port and app type
913913
self._scheduler.update_service(namespace, namespace, data=old_service)
914914
raise KubeException(str(e)) from e
915+
916+
def whitelist(self, whitelist):
917+
"""
918+
Add/ Delete addresses to application whitelist
919+
"""
920+
service = self._fetch_service_config(self.id)
921+
922+
try:
923+
addresses = ",".join(address for address in whitelist)
924+
service['metadata']['annotations']['router.deis.io/whitelist'] = addresses
925+
self._scheduler.update_service(self.id, self.id, data=service)
926+
except KubeException as e:
927+
raise ServiceUnavailable(str(e)) from e

rootfs/api/models/appsettings.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import logging
22
from django.conf import settings
33
from django.db import models
4+
from django.contrib.postgres.fields import ArrayField
45

56
from api.models import UuidAuditedModel
67
from api.exceptions import DeisException, AlreadyExists
@@ -15,6 +16,7 @@ class AppSettings(UuidAuditedModel):
1516
app = models.ForeignKey('App', on_delete=models.CASCADE)
1617
maintenance = models.NullBooleanField(default=None)
1718
routable = models.NullBooleanField(default=None)
19+
whitelist = ArrayField(models.CharField(max_length=50), default=[])
1820

1921
class Meta:
2022
get_latest_by = 'created'
@@ -24,6 +26,17 @@ class Meta:
2426
def __str__(self):
2527
return "{}-{}".format(self.app.id, str(self.uuid)[:7])
2628

29+
def new(self, user, whitelist):
30+
"""
31+
Create a new application appSettings using the provided whitelist
32+
on behalf of a user.
33+
"""
34+
35+
appSettings = AppSettings.objects.create(
36+
owner=user, app=self.app, whitelist=whitelist)
37+
38+
return appSettings
39+
2740
def update_maintenance(self, previous_settings):
2841
old = getattr(previous_settings, 'maintenance', None)
2942
new = getattr(self, 'maintenance', None)
@@ -52,6 +65,27 @@ def update_routable(self, previous_settings):
5265
self.app.routable(new)
5366
self.summary += ["{} changed routablity from {} to {}".format(self.owner, old, new)]
5467

68+
def update_whitelist(self, previous_settings):
69+
# If no previous settings then assume it is the first record and do nothing
70+
if not previous_settings:
71+
return
72+
old = getattr(previous_settings, 'whitelist', [])
73+
new = getattr(self, 'whitelist', [])
74+
# if nothing changed copy the settings from previous
75+
if len(new) == 0 and len(old) != 0:
76+
setattr(self, 'whitelist', old)
77+
elif set(old) != set(new):
78+
self.app.whitelist(new)
79+
added = ', '.join(k for k in set(new)-set(old))
80+
added = 'added ' + added if added else ''
81+
deleted = ', '.join(k for k in set(old)-set(new))
82+
deleted = 'deleted ' + deleted if deleted else ''
83+
changes = ', '.join(i for i in (added, deleted) if i)
84+
if changes:
85+
if self.summary:
86+
self.summary += ' and '
87+
self.summary += "{} {}".format(self.owner, changes)
88+
5589
def save(self, *args, **kwargs):
5690
self.summary = []
5791
previous_settings = None
@@ -63,6 +97,7 @@ def save(self, *args, **kwargs):
6397
try:
6498
self.update_maintenance(previous_settings)
6599
self.update_routable(previous_settings)
100+
self.update_whitelist(previous_settings)
66101
except Exception as e:
67102
self.delete()
68103
raise DeisException(str(e)) from e

rootfs/api/serializers.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import re
88
import jsonschema
99
import idna
10+
import ipaddress
1011
from urllib.parse import urlparse
1112

1213
from django.contrib.auth.models import User
@@ -474,3 +475,18 @@ class Meta:
474475
"""Metadata options for a :class:`AppSettingsSerializer`."""
475476
model = models.AppSettings
476477
fields = '__all__'
478+
479+
def validate_whitelist(self, data):
480+
for address in data:
481+
try:
482+
ipaddress.ip_address(address)
483+
except:
484+
try:
485+
ipaddress.ip_network(address)
486+
except:
487+
try:
488+
ipaddress.ip_interface(address)
489+
except:
490+
raise serializers.ValidationError(
491+
"The address {} is not valid".format(address))
492+
return data

rootfs/api/tests/test_app_settings.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
from rest_framework.authtoken.models import Token
66

77
from api.models import App
8+
from unittest import mock
9+
from scheduler import KubeException
810
from api.tests import adapter, DeisTransactionTestCase
911

1012

@@ -89,3 +91,82 @@ def test_settings_routable(self, mock_requests):
8991
settings)
9092
self.assertEqual(response.status_code, 201, response.data)
9193
self.assertFalse(app.appsettings_set.latest().routable)
94+
95+
def test_settings_whitelist(self, mock_requests):
96+
"""
97+
Test that addresses can be added/deleted to whitelist
98+
"""
99+
app_id = self.create_app()
100+
app = App.objects.get(id=app_id)
101+
# add addresses to empty whitelist
102+
addresses = ["1.2.3.4", "0.0.0.0/0"]
103+
whitelist = {'addresses': addresses}
104+
response = self.client.post(
105+
'/v2/apps/{app_id}/whitelist'.format(**locals()),
106+
whitelist)
107+
self.assertEqual(response.status_code, 201, response.data)
108+
self.assertEqual(set(response.data['addresses']),
109+
set(app.appsettings_set.latest().whitelist), response.data)
110+
self.assertEqual(set(response.data['addresses']), set(addresses), response.data)
111+
# get the whitelist
112+
response = self.client.get('/v2/apps/{app_id}/whitelist'.format(**locals()))
113+
self.assertEqual(response.status_code, 200, response.data)
114+
self.assertEqual(set(response.data['addresses']),
115+
set(app.appsettings_set.latest().whitelist), response.data)
116+
self.assertEqual(set(response.data['addresses']), set(addresses), response.data)
117+
# add addresses to non-empty whitelist
118+
whitelist = {'addresses': ["2.3.4.5"]}
119+
addresses.extend(["2.3.4.5"])
120+
response = self.client.post(
121+
'/v2/apps/{app_id}/whitelist'.format(**locals()),
122+
whitelist)
123+
self.assertEqual(response.status_code, 201, response.data)
124+
self.assertEqual(set(response.data['addresses']),
125+
set(app.appsettings_set.latest().whitelist), response.data)
126+
self.assertEqual(set(response.data['addresses']), set(addresses), response.data)
127+
# add exisitng addresses to whitelist
128+
response = self.client.post(
129+
'/v2/apps/{app_id}/whitelist'.format(**locals()),
130+
whitelist)
131+
self.assertEqual(response.status_code, 409, response.data)
132+
# delete non-exisitng address from whitelist
133+
whitelist = {'addresses': ["2.3.4.6"]}
134+
response = self.client.delete(
135+
'/v2/apps/{app_id}/whitelist'.format(**locals()),
136+
whitelist)
137+
self.assertEqual(response.status_code, 422)
138+
# delete an address from whitelist
139+
whitelist = {'addresses': ["2.3.4.5"]}
140+
addresses.remove("2.3.4.5")
141+
response = self.client.delete(
142+
'/v2/apps/{app_id}/whitelist'.format(**locals()),
143+
whitelist)
144+
self.assertEqual(response.status_code, 204, response.data)
145+
self.assertEqual(set(addresses), set(app.appsettings_set.latest().whitelist))
146+
# pass invalid address
147+
whitelist = {'addresses': ["2.3.4.6.7"]}
148+
response = self.client.post(
149+
'/v2/apps/{app_id}/whitelist'.format(**locals()),
150+
whitelist)
151+
self.assertEqual(response.status_code, 400, response.data)
152+
# update other appsettings and whitelist should be retained
153+
settings = {'maintenance': True}
154+
response = self.client.post(
155+
'/v2/apps/{app.id}/settings'.format(**locals()),
156+
settings)
157+
self.assertEqual(response.status_code, 201, response.data)
158+
self.assertEqual(set(addresses), set(app.appsettings_set.latest().whitelist))
159+
160+
def test_kubernetes_service_failure(self, mock_requests):
161+
"""
162+
Cause an Exception in kubernetes services
163+
"""
164+
app_id = self.create_app()
165+
166+
# scheduler.update_service exception
167+
with mock.patch('scheduler.KubeHTTPClient.update_service') as mock_kube:
168+
mock_kube.side_effect = KubeException('Boom!')
169+
addresses = ["2.3.4.5"]
170+
url = '/v2/apps/{}/whitelist'.format(app_id)
171+
response = self.client.post(url, {'addresses': addresses})
172+
self.assertEqual(response.status_code, 400, response.data)

rootfs/api/urls.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@
6060
# application settings
6161
url(r"^apps/(?P<id>{})/settings/?".format(settings.APP_URL_REGEX),
6262
views.AppSettingsViewSet.as_view({'get': 'retrieve', 'post': 'create'})),
63+
# application ip whitelist
64+
url(r"^apps/(?P<id>{})/whitelist/?".format(settings.APP_URL_REGEX),
65+
views.WhitelistViewSet.as_view({'post': 'create', 'get': 'list', 'delete': 'delete'})),
6366
# apps sharing
6467
url(r"^apps/(?P<id>{})/perms/(?P<username>[-_\w]+)/?".format(settings.APP_URL_REGEX),
6568
views.AppPermsViewSet.as_view({'delete': 'destroy'})),

rootfs/api/views.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from rest_framework.authtoken.models import Token
1717

1818
from api import authentication, models, permissions, serializers, viewsets
19-
from api.models import AlreadyExists, ServiceUnavailable, DeisException
19+
from api.models import AlreadyExists, ServiceUnavailable, DeisException, UnprocessableEntity
2020

2121
import logging
2222

@@ -301,6 +301,34 @@ class AppSettingsViewSet(AppResourceViewSet):
301301
serializer_class = serializers.AppSettingsSerializer
302302

303303

304+
class WhitelistViewSet(AppResourceViewSet):
305+
model = models.AppSettings
306+
serializer_class = serializers.AppSettingsSerializer
307+
308+
def list(self, *args, **kwargs):
309+
appSettings = self.get_app().appsettings_set.latest()
310+
data = {"addresses": appSettings.whitelist}
311+
return Response(data, status=status.HTTP_200_OK)
312+
313+
def create(self, request, **kwargs):
314+
appSettings = self.get_app().appsettings_set.latest()
315+
addresses = self.get_serializer().validate_whitelist(request.data.get('addresses'))
316+
addresses = list(set(appSettings.whitelist) | set(addresses))
317+
new_appsettings = appSettings.new(self.request.user, whitelist=addresses)
318+
return Response({"addresses": new_appsettings.whitelist}, status=status.HTTP_201_CREATED)
319+
320+
def delete(self, request, **kwargs):
321+
appSettings = self.get_app().appsettings_set.latest()
322+
addresses = self.get_serializer().validate_whitelist(request.data.get('addresses'))
323+
324+
unfound_addresses = set(addresses) - set(appSettings.whitelist)
325+
if len(unfound_addresses) != 0:
326+
raise UnprocessableEntity('addresses {} does not exist in whitelist'.format(unfound_addresses)) # noqa
327+
addresses = list(set(appSettings.whitelist) - set(addresses))
328+
appSettings.new(self.request.user, whitelist=addresses)
329+
return Response(status=status.HTTP_204_NO_CONTENT)
330+
331+
304332
class DomainViewSet(AppResourceViewSet):
305333
"""A viewset for interacting with Domain objects."""
306334
model = models.Domain

0 commit comments

Comments
 (0)