Skip to content

Commit 2980404

Browse files
committed
Merge pull request #452 from opdemand/external-push
Push domain object for external builder module
2 parents ca97963 + f73b941 commit 2980404

9 files changed

Lines changed: 444 additions & 27 deletions

File tree

.coveragerc

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@ omit =
88
client/models.py
99
cm/__init__.py
1010
cm/models.py
11+
provider/digitalocean.py
12+
provider/ec2.py
1113
provider/models.py
14+
provider/rackspace.py
15+
provider/static.py
1216
web/__init__.py
1317
web/models.py
1418
web/templatetags/__init__.py

api/migrations/0005_auto__add_push__add_unique_push_app_uuid.py

Lines changed: 224 additions & 0 deletions
Large diffs are not rendered by default.

api/models.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -694,6 +694,31 @@ def __str__(self):
694694
return "{0}-v{1}".format(self.app.id, self.version)
695695

696696

697+
@python_2_unicode_compatible
698+
class Push(UuidAuditedModel):
699+
"""
700+
Instance of a push used to trigger an application build
701+
"""
702+
owner = models.ForeignKey(settings.AUTH_USER_MODEL)
703+
app = models.ForeignKey('App')
704+
sha = models.CharField(max_length=40)
705+
706+
fingerprint = models.CharField(max_length=255)
707+
receive_user = models.CharField(max_length=255)
708+
receive_repo = models.CharField(max_length=255)
709+
710+
ssh_connection = models.CharField(max_length=255)
711+
ssh_original_command = models.CharField(max_length=255)
712+
713+
class Meta:
714+
get_latest_by = 'created'
715+
ordering = ['-created']
716+
unique_together = (('app', 'uuid'),)
717+
718+
def __str__(self):
719+
return "{0}-{1}".format(self.app.id, self.sha[8:])
720+
721+
697722
@python_2_unicode_compatible
698723
class Build(UuidAuditedModel):
699724
"""

api/serializers.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,18 @@ class Meta:
8686
read_only_fields = ('created', 'updated')
8787

8888

89+
class PushSerializer(serializers.ModelSerializer):
90+
"""Serialize a :class:`~api.models.Push` model."""
91+
92+
owner = serializers.Field(source='owner.username')
93+
app = serializers.SlugRelatedField(slug_field='id')
94+
95+
class Meta:
96+
"""Metadata options for a :class:`PushSerializer`."""
97+
model = models.Push
98+
read_only_fields = ('uuid', 'created', 'updated')
99+
100+
89101
class ConfigSerializer(serializers.ModelSerializer):
90102
"""Serialize a :class:`~api.models.Config` model."""
91103

api/tests/test_auth.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ def test_auth(self):
6464
url = '/api/flavors'
6565
response = self.client.get(url)
6666
self.assertEqual(response.status_code, 200)
67-
self.assertEqual(response.data['count'], 2)
67+
self.assertEqual(response.data['count'], 20)
6868
# test logout and login
6969
url = '/api/auth/logout/'
7070
response = self.client.post(url, content_type='application/json')

api/tests/test_push.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
"""
2+
Unit tests for the Deis api app.
3+
4+
Run the tests with "./manage.py test api"
5+
"""
6+
7+
from __future__ import unicode_literals
8+
9+
import json
10+
11+
from django.test import TestCase
12+
from django.test.utils import override_settings
13+
14+
from deis import settings
15+
16+
17+
@override_settings(CELERY_ALWAYS_EAGER=True)
18+
class PushTest(TestCase):
19+
20+
"""Tests pushes into the push system"""
21+
22+
fixtures = ['tests.json']
23+
24+
def setUp(self):
25+
self.assertTrue(
26+
self.client.login(username='autotest', password='password'))
27+
url = '/api/providers'
28+
creds = {'secret_key': 'x' * 64, 'access_key': 1 * 20}
29+
body = {'id': 'autotest', 'type': 'mock', 'creds': json.dumps(creds)}
30+
response = self.client.post(url, json.dumps(body), content_type='application/json')
31+
self.assertEqual(response.status_code, 201)
32+
url = '/api/flavors'
33+
body = {
34+
'id': 'autotest',
35+
'provider': 'autotest',
36+
'params': json.dumps({'region': 'us-west-2'}),
37+
}
38+
response = self.client.post(url, json.dumps(body), content_type='application/json')
39+
self.assertEqual(response.status_code, 201)
40+
response = self.client.post('/api/formations', json.dumps(
41+
{'id': 'autotest', 'domain': 'localhost.localdomain'}),
42+
content_type='application/json')
43+
self.assertEqual(response.status_code, 201)
44+
45+
def test_push(self):
46+
"""
47+
Test that a user can push into the system
48+
"""
49+
url = '/api/apps'
50+
body = {'formation': 'autotest'}
51+
response = self.client.post(url, json.dumps(body), content_type='application/json')
52+
self.assertEqual(response.status_code, 201)
53+
app_id = response.data['id']
54+
# prepare a push body
55+
body = {
56+
'sha': 'df1e628f2244b73f9cdf944f880a2b3470a122f4',
57+
'fingerprint': '88:25:ed:67:56:91:3d:c6:1b:7f:42:c6:9b:41:24:80',
58+
'receive_user': 'autotest',
59+
'receive_repo': 'repo.git',
60+
'ssh_connection': '10.0.1.10 50337 172.17.0.143 22',
61+
'ssh_original_command': "git-receive-pack 'repo.git'",
62+
}
63+
# post a request without the auth header
64+
url = "/api/apps/{app_id}/push".format(**locals())
65+
response = self.client.post(url, json.dumps(body), content_type='application/json')
66+
self.assertEqual(response.status_code, 403)
67+
# now try with the builder key in the special auth header
68+
response = self.client.post(url, json.dumps(body), content_type='application/json',
69+
HTTP_X_DEIS_BUILDER_AUTH=settings.BUILDER_KEY)
70+
self.assertEqual(response.status_code, 201)
71+
for k in ('owner', 'app', 'sha', 'fingerprint', 'receive_repo', 'receive_user',
72+
'ssh_connection', 'ssh_original_command'):
73+
self.assertIn(k, response.data)
74+
75+
def test_push_abuse(self):
76+
# create a legit app
77+
url = '/api/apps'
78+
body = {'formation': 'autotest'}
79+
response = self.client.post(url, json.dumps(body), content_type='application/json')
80+
self.assertEqual(response.status_code, 201)
81+
app_id = response.data['id']
82+
# register an evil user
83+
username, password = 'eviluser', 'password'
84+
first_name, last_name = 'Evil', 'User'
85+
email = 'evil@deis.io'
86+
submit = {
87+
'username': username,
88+
'password': password,
89+
'first_name': first_name,
90+
'last_name': last_name,
91+
'email': email,
92+
}
93+
url = '/api/auth/register'
94+
response = self.client.post(url, json.dumps(submit), content_type='application/json')
95+
self.assertEqual(response.status_code, 201)
96+
# prepare a push body that simulates a git push
97+
body = {
98+
'sha': 'df1e628f2244b73f9cdf944f880a2b3470a122f4',
99+
'fingerprint': '88:25:ed:67:56:91:3d:c6:1b:7f:42:c6:9b:41:24:99',
100+
'receive_user': 'eviluser',
101+
'receive_repo': 'repo.git',
102+
'ssh_connection': '10.0.1.10 50337 172.17.0.143 22',
103+
'ssh_original_command': "git-receive-pack 'repo.git'",
104+
}
105+
# try to push as "eviluser"
106+
url = "/api/apps/{app_id}/push".format(**locals())
107+
response = self.client.post(url, json.dumps(body), content_type='application/json',
108+
HTTP_X_DEIS_BUILDER_AUTH=settings.BUILDER_KEY)
109+
self.assertEqual(response.status_code, 403)

api/urls.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,10 @@
189189
Application Release Components
190190
------------------------------
191191
192+
.. http:post:: /api/apps/(string:id)/push/
193+
194+
Create a new :class:`~api.models.Push`.
195+
192196
.. http:get:: /api/apps/(string:id)/config/
193197
194198
List all :class:`~api.models.Config`\s.
@@ -405,6 +409,8 @@
405409
url(r'^formations/?',
406410
views.FormationViewSet.as_view({'get': 'list', 'post': 'create'})),
407411
# application release components
412+
url(r'^apps/(?P<id>[-_\w]+)/push/?',
413+
views.AppPushViewSet.as_view({'post': 'create'})),
408414
url(r'^apps/(?P<id>[-_\w]+)/config/?',
409415
views.AppConfigViewSet.as_view({'get': 'retrieve', 'post': 'create'})),
410416
url(r'^apps/(?P<id>[-_\w]+)/builds/(?P<uuid>[-_\w]+)/?',

api/views.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525

2626
from api import models, serializers, tasks
2727

28+
from deis import settings
29+
2830

2931
class AnonymousAuthentication(BaseAuthentication):
3032

@@ -108,6 +110,22 @@ def has_permission(self, request, view):
108110
return request.method in permissions.SAFE_METHODS or request.user.is_superuser
109111

110112

113+
class HasBuilderAuth(permissions.BasePermission):
114+
"""
115+
View permission to allow builder to perform actions
116+
with a special HTTP header
117+
"""
118+
119+
def has_permission(self, request, view):
120+
"""
121+
Return `True` if permission is granted, `False` otherwise.
122+
"""
123+
auth_header = request.environ.get('HTTP_X_DEIS_BUILDER_AUTH')
124+
if not auth_header:
125+
return False
126+
return auth_header == settings.BUILDER_KEY
127+
128+
111129
class UserRegistrationView(viewsets.GenericViewSet,
112130
viewsets.mixins.CreateModelMixin):
113131
model = User
@@ -532,6 +550,29 @@ def get_object(self, *args, **kwargs):
532550
raise PermissionDenied()
533551

534552

553+
class AppPushViewSet(viewsets.ModelViewSet):
554+
"""RESTful views for :class:`~api.models.Push`."""
555+
556+
model = models.Push
557+
serializer_class = serializers.PushSerializer
558+
559+
permission_classes = (HasBuilderAuth,)
560+
561+
def pre_save(self, obj):
562+
# SECURITY: we trust the receive_user field to map to the push owner
563+
obj.owner = self.request.DATA['owner']
564+
565+
def create(self, request, *args, **kwargs):
566+
request._data = request.DATA.copy()
567+
app = request.DATA['app'] = get_object_or_404(models.App, id=self.kwargs['id'])
568+
# check the user is authorized for this app
569+
user = request.DATA['owner'] = get_object_or_404(
570+
User, username=self.request.DATA['receive_user'])
571+
if user == app.owner or user in get_users_with_perms(app):
572+
return super(AppPushViewSet, self).create(request, *args, **kwargs)
573+
raise PermissionDenied()
574+
575+
535576
class AppConfigViewSet(BaseAppViewSet):
536577
"""RESTful views for :class:`~api.models.Config`."""
537578

deis/settings.py

Lines changed: 22 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -264,42 +264,38 @@
264264
# default deis settings
265265
DEIS_LOG_DIR = os.path.abspath(os.path.join(__file__, '..', '..', 'logs'))
266266
LOG_LINES = 1000
267+
TEMPDIR = tempfile.mkdtemp(prefix='deis')
268+
269+
# security keys and auth tokens
270+
SECRET_KEY = os.environ.get('DEIS_SECRET_KEY', 'CHANGEME_sapm$s%upvsw5l_zuy_&29rkywd^78ff(qi')
271+
BUILDER_KEY = os.environ.get('DEIS_BUILDER_KEY', 'CHANGEME_sapm$s%upvsw5l_zuy_&29rkywd^78ff(qi')
267272

268273
# the config management module to use in api.models
269-
CM_MODULE = 'cm.mock'
270-
TEMPDIR = tempfile.mkdtemp(prefix='deis')
274+
CM_MODULE = os.environ.get('DEIS_CM_MODULE', 'cm.mock')
271275

272276
# default providers, typically overriden in local_settings to include ec2, etc.
273-
PROVIDER_MODULES = ('mock',)
277+
PROVIDER_MODULES = ('mock', 'digitalocean', 'ec2', 'rackspace', 'static')
278+
279+
# default to sqlite3, but allow postgresql config through envvars
280+
DATABASES = {
281+
'default': {
282+
'ENGINE': 'django.db.backends.' + os.environ.get('DATABASE_ENGINE', 'postgresql_psycopg2'),
283+
'NAME': os.environ.get('DATABASE_NAME', 'deis'),
284+
'USER': os.environ.get('DATABASE_USER', 'deis'),
285+
'PASSWORD': os.environ.get('DATABASE_PASSWORD', 'deis'),
286+
'HOST': os.environ.get('DATABASE_HOST', 'localhost'),
287+
}
288+
}
289+
290+
# SECURITY: change this to allowed fqdn's to prevent host poisioning attacks
291+
# see https://docs.djangoproject.com/en/1.5/ref/settings/#std:setting-ALLOWED_HOSTS
292+
ALLOWED_HOSTS = ['*']
274293

275294
# Create a file named "local_settings.py" to contain sensitive settings data
276295
# such as database configuration, admin email, or passwords and keys. It
277296
# should also be used for any settings which differ between development
278297
# and production.
279298
# The local_settings.py file should *not* be checked in to version control.
280-
# A local_setings.py configured for development might look like this:
281-
#
282-
# DEBUG = TEMPLATE_DEBUG = True
283-
# ADMINS = (
284-
# ('Abraham Lincoln', 'abe@example.com'),
285-
# ('George Washington', 'george@example.com'),
286-
# )
287-
# DATABASES = {
288-
# 'default': {
289-
# 'ENGINE': 'django.db.backends.sqlite3',
290-
# 'NAME': 'deis.db',
291-
# 'USER': '',
292-
# 'PASSWORD': '',
293-
# 'HOST': '',
294-
# 'PORT': '',
295-
# }
296-
# }
297-
# SECRET_KEY = 'CHANGEME_sapm$s%upvsw5l_zuy_&29rkywd^78ff(qilmw1#g'
298-
# EMAIL_HOST = 'smtp.sendgrid.com'
299-
# EMAIL_PORT = 587
300-
# EMAIL_HOST_USER = 'foo'
301-
# EMAIL_HOST_PASSWORD = 'bar'
302-
303299

304300
try:
305301
from .local_settings import * # @UnusedWildImport # noqa

0 commit comments

Comments
 (0)