Skip to content

Commit 50811a2

Browse files
author
Matthew Fisher
authored
feat(api): add deis tls (#1004)
1 parent 9b8ede9 commit 50811a2

7 files changed

Lines changed: 215 additions & 48 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# -*- coding: utf-8 -*-
2+
# Generated by Django 1.9.8 on 2016-08-22 21:03
3+
from __future__ import unicode_literals
4+
5+
from django.conf import settings
6+
from django.db import migrations, models
7+
import django.db.models.deletion
8+
import uuid
9+
10+
11+
class Migration(migrations.Migration):
12+
13+
dependencies = [
14+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
15+
('api', '0014_appsettings_whitelist'),
16+
]
17+
18+
operations = [
19+
migrations.CreateModel(
20+
name='TLS',
21+
fields=[
22+
('uuid', models.UUIDField(auto_created=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True, verbose_name='UUID')),
23+
('created', models.DateTimeField(auto_now_add=True)),
24+
('updated', models.DateTimeField(auto_now=True)),
25+
('https_enforced', models.NullBooleanField(default=None)),
26+
('app', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='api.App')),
27+
('owner', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
28+
],
29+
options={
30+
'ordering': ['-created'],
31+
'get_latest_by': 'created',
32+
},
33+
),
34+
migrations.AlterUniqueTogether(
35+
name='tls',
36+
unique_together=set([('app', 'uuid')]),
37+
),
38+
]

rootfs/api/models/__init__.py

Lines changed: 41 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -114,75 +114,68 @@ class Meta:
114114

115115

116116
from .app import App, validate_id_is_docker_compatible, validate_reserved_names, validate_app_structure # noqa
117-
from .key import Key, validate_base64 # noqa
117+
from .appsettings import AppSettings # noqa
118+
from .build import Build # noqa
118119
from .certificate import Certificate, validate_certificate # noqa
120+
from .config import Config # noqa
119121
from .domain import Domain # noqa
122+
from .key import Key, validate_base64 # noqa
120123
from .release import Release # noqa
121-
from .config import Config # noqa
122-
from .build import Build # noqa
123-
from .appsettings import AppSettings # noqa
124+
from .tls import TLS # noqa
124125

125126
# define update/delete callbacks for synchronizing
126127
# models with the configuration management backend
127128

128129

129-
def _log_build_created(**kwargs):
130+
def _log_instance_created(**kwargs):
130131
if kwargs.get('created'):
131-
build = kwargs['instance']
132-
# log only to the controller; this event will be logged in the release summary
133-
build.app.log("build {} created".format(build))
134-
132+
instance = kwargs['instance']
133+
message = '{} {} created'.format(instance.__class__.__name__, instance)
134+
if hasattr(instance, 'app'):
135+
instance.app.log(message)
136+
else:
137+
logger.info(message)
135138

136-
def _log_release_created(**kwargs):
137-
if kwargs.get('created'):
138-
release = kwargs['instance']
139-
# append release lifecycle logs to the app
140-
release.app.log(release.summary)
141139

140+
def _log_instance_updated(**kwargs):
141+
instance = kwargs['instance']
142+
message = '{} {} updated'.format(instance.__class__.__name__, instance)
143+
if hasattr(instance, 'app'):
144+
instance.app.log(message)
145+
else:
146+
logger.info(message)
142147

143-
def _log_config_updated(**kwargs):
144-
config = kwargs['instance']
145-
# log only to the controller; this event will be logged in the release summary
146-
config.app.log("config {} updated".format(config))
147148

149+
def _log_instance_removed(**kwargs):
150+
instance = kwargs['instance']
151+
message = '{} {} removed'.format(instance.__class__.__name__, instance)
152+
if hasattr(instance, 'app'):
153+
instance.app.log(message)
154+
else:
155+
logger.info(message)
148156

149-
def _log_app_settings_updated(**kwargs):
150-
appSettings = kwargs['instance']
151-
# log only to the controller; this event will be logged in the release summary
152-
appSettings.app.log("application settings {} updated".format(appSettings))
153157

154-
155-
def _log_domain_added(**kwargs):
158+
# special case: log the release summary
159+
def _log_release_created(**kwargs):
156160
if kwargs.get('created'):
157-
domain = kwargs['instance']
158-
domain.app.log("domain {} added".format(domain))
159-
160-
161-
def _log_domain_removed(**kwargs):
162-
domain = kwargs['instance']
163-
domain.app.log("domain {} removed".format(domain))
164-
161+
release = kwargs['instance']
162+
# append release lifecycle logs to the app
163+
release.app.log(release.summary)
165164

166-
def _log_cert_added(**kwargs):
167-
if kwargs.get('created'):
168-
cert = kwargs['instance']
169-
logger.info("cert {} added".format(cert))
170165

166+
# Log significant app-related events
167+
post_save.connect(_log_release_created, sender=Release, dispatch_uid='api.models.log')
171168

172-
def _log_cert_removed(**kwargs):
173-
cert = kwargs['instance']
174-
logger.info("cert {} removed".format(cert))
169+
post_save.connect(_log_instance_created, sender=Build, dispatch_uid='api.models.log')
170+
post_save.connect(_log_instance_created, sender=Certificate, dispatch_uid='api.models.log')
171+
post_save.connect(_log_instance_created, sender=Domain, dispatch_uid='api.models.log')
175172

173+
post_save.connect(_log_instance_updated, sender=AppSettings, dispatch_uid='api.models.log')
174+
post_save.connect(_log_instance_updated, sender=Config, dispatch_uid='api.models.log')
176175

177-
# Log significant app-related events
178-
post_save.connect(_log_build_created, sender=Build, dispatch_uid='api.models.log')
179-
post_save.connect(_log_release_created, sender=Release, dispatch_uid='api.models.log')
180-
post_save.connect(_log_config_updated, sender=Config, dispatch_uid='api.models.log')
181-
post_save.connect(_log_domain_added, sender=Domain, dispatch_uid='api.models.log')
182-
post_save.connect(_log_cert_added, sender=Certificate, dispatch_uid='api.models.log')
183-
post_save.connect(_log_app_settings_updated, sender=AppSettings, dispatch_uid='api.models.log')
184-
post_delete.connect(_log_domain_removed, sender=Domain, dispatch_uid='api.models.log')
185-
post_delete.connect(_log_cert_removed, sender=Certificate, dispatch_uid='api.models.log')
176+
post_delete.connect(_log_instance_removed, sender=Certificate, dispatch_uid='api.models.log')
177+
post_delete.connect(_log_instance_removed, sender=Domain, dispatch_uid='api.models.log')
178+
post_delete.connect(_log_instance_removed, sender=TLS, dispatch_uid='api.models.log')
186179

187180

188181
# automatically generate a new token on creation

rootfs/api/models/tls.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
from django.db import models
2+
from django.conf import settings
3+
4+
from api.exceptions import AlreadyExists
5+
from api.models import UuidAuditedModel
6+
7+
8+
class TLS(UuidAuditedModel):
9+
owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT)
10+
app = models.ForeignKey('App', on_delete=models.CASCADE)
11+
https_enforced = models.NullBooleanField(default=None)
12+
13+
class Meta:
14+
get_latest_by = 'created'
15+
unique_together = (('app', 'uuid'))
16+
ordering = ['-created']
17+
18+
def __str__(self):
19+
return "{}-{}".format(self.app.id, str(self.uuid)[:7])
20+
21+
def _check_previous_tls_settings(self):
22+
try:
23+
previous_tls_settings = self.app.tls_set.latest()
24+
25+
if (
26+
previous_tls_settings.https_enforced is not None and
27+
self.https_enforced == previous_tls_settings.https_enforced
28+
):
29+
self.delete()
30+
raise AlreadyExists("{} changed nothing".format(self.owner))
31+
except TLS.DoesNotExist:
32+
pass
33+
34+
def save(self, *args, **kwargs):
35+
self._check_previous_tls_settings()
36+
37+
app = str(self.app)
38+
https_enforced = bool(self.https_enforced)
39+
40+
# get config for the service
41+
config = self._load_service_config(app, 'router')
42+
43+
# See if the ssl.enforce annotation is available
44+
if 'ssl.enforce' not in config:
45+
config['ssl.enforce'] = 'false'
46+
47+
# convert from bool to string
48+
config['ssl.enforce'] = str(https_enforced)
49+
50+
self._save_service_config(app, 'router', config)
51+
52+
# Save to DB
53+
return super(TLS, self).save(*args, **kwargs)

rootfs/api/serializers.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -490,3 +490,15 @@ def validate_whitelist(self, data):
490490
raise serializers.ValidationError(
491491
"The address {} is not valid".format(address))
492492
return data
493+
494+
495+
class TLSSerializer(serializers.ModelSerializer):
496+
"""Serialize a :class:`~api.models.TLS` model."""
497+
498+
app = serializers.SlugRelatedField(slug_field='id', queryset=models.App.objects.all())
499+
owner = serializers.ReadOnlyField(source='owner.username')
500+
501+
class Meta:
502+
"""Metadata options for a :class:`AppSettingsSerializer`."""
503+
model = models.TLS
504+
fields = '__all__'

rootfs/api/tests/test_tls.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import requests_mock
2+
3+
from django.core.cache import cache
4+
from django.contrib.auth.models import User
5+
from rest_framework.authtoken.models import Token
6+
7+
from api.models import App
8+
from api.tests import adapter, DeisTransactionTestCase
9+
10+
11+
@requests_mock.Mocker(real_http=True, adapter=adapter)
12+
class TestTLS(DeisTransactionTestCase):
13+
"""Tests setting and updating config values"""
14+
15+
fixtures = ['tests.json']
16+
17+
def setUp(self):
18+
self.user = User.objects.get(username='autotest')
19+
self.token = Token.objects.get(user=self.user).key
20+
self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
21+
22+
def tearDown(self):
23+
# make sure every test has a clean slate for k8s mocking
24+
cache.clear()
25+
26+
def test_tls_enforced(self, mock_requests):
27+
"""
28+
Test that tls redirection can be enforced
29+
"""
30+
app_id = self.create_app()
31+
app = App.objects.get(id=app_id)
32+
33+
data = {'https_enforced': True}
34+
response = self.client.post(
35+
'/v2/apps/{app_id}/tls'.format(**locals()),
36+
data)
37+
self.assertEqual(response.status_code, 201, response.data)
38+
self.assertTrue(response.data.get('https_enforced'), response.data)
39+
self.assertTrue(app.tls_set.latest().https_enforced)
40+
41+
data = {'https_enforced': False}
42+
response = self.client.post(
43+
'/v2/apps/{app_id}/tls'.format(**locals()),
44+
data)
45+
self.assertEqual(response.status_code, 201, response.data)
46+
self.assertFalse(app.tls_set.latest().https_enforced)
47+
48+
# when the same data is sent again, a 409 is returned
49+
conflict_response = self.client.post(
50+
'/v2/apps/{app_id}/tls'.format(**locals()),
51+
data)
52+
self.assertEqual(conflict_response.status_code, 409, conflict_response.data)
53+
self.assertFalse(app.tls_set.latest().https_enforced)
54+
# also ensure that the previous tls UUID matches the latest,
55+
# confirming this conflicting TLS object was deleted
56+
self.assertEqual(response.data['uuid'], str(app.tls_set.latest().uuid))
57+
58+
# sending bad data returns a 400
59+
data['https_enforced'] = "test"
60+
response = self.client.post(
61+
'/v2/apps/{app_id}/tls'.format(**locals()),
62+
data)
63+
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
@@ -63,6 +63,9 @@
6363
# application ip whitelist
6464
url(r"^apps/(?P<id>{})/whitelist/?".format(settings.APP_URL_REGEX),
6565
views.WhitelistViewSet.as_view({'post': 'create', 'get': 'list', 'delete': 'delete'})),
66+
# application TLS settings
67+
url(r"^apps/(?P<id>{})/tls/?".format(settings.APP_URL_REGEX),
68+
views.TLSViewSet.as_view({'get': 'retrieve', 'post': 'create'})),
6669
# apps sharing
6770
url(r"^apps/(?P<id>{})/perms/(?P<username>[-_\w]+)/?".format(settings.APP_URL_REGEX),
6871
views.AppPermsViewSet.as_view({'delete': 'destroy'})),

rootfs/api/views.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,11 @@ def rollback(self, request, **kwargs):
406406
return Response(response, status=status.HTTP_201_CREATED)
407407

408408

409+
class TLSViewSet(AppResourceViewSet):
410+
model = models.TLS
411+
serializer_class = serializers.TLSSerializer
412+
413+
409414
class BaseHookViewSet(BaseDeisViewSet):
410415
permission_classes = [permissions.HasBuilderAuth]
411416

0 commit comments

Comments
 (0)