launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #06160
[Merge] lp:~rvb/maas/maas-security-model into lp:maas
Raphaël Badin has proposed merging lp:~rvb/maas/maas-security-model into lp:maas.
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
For more details, see:
https://code.launchpad.net/~rvb/maas/maas-security-model/+merge/89800
This branch adds the security model to maas.
= Details =
The security model is this:
- the site (except login/logout/api-doc pages) is accessible to logged-in users
- the admins have no restrictions
- only the Nodes are protected: all the Nodes are visible by anyone until the are in use by a user (at this stage the owner field is populated). When a Node becomes 'owned', it is only visible to its owner (and admins).
All the views (api, regular views [and static resources]) are protected by a middleware class (AccessMiddleware).
This branch also introduces a MaaSAuthorizationBackend (https://docs.djangoproject.com/en/dev/topics/auth/#handling-authorization-in-custom-backends) that is not used yet.
--
https://code.launchpad.net/~rvb/maas/maas-security-model/+merge/89800
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~rvb/maas/maas-security-model into lp:maas.
=== modified file 'src/maas/development.py'
--- src/maas/development.py 2012-01-23 12:28:04 +0000
+++ src/maas/development.py 2012-01-24 07:07:24 +0000
@@ -135,6 +135,7 @@
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'debug_toolbar.middleware.DebugToolbarMiddleware',
+ 'maasserver.middleware.AccessMiddleware',
)
ROOT_URLCONF = 'maas.urls'
=== modified file 'src/maas/settings.py'
--- src/maas/settings.py 2012-01-20 09:26:53 +0000
+++ src/maas/settings.py 2012-01-24 07:07:24 +0000
@@ -22,6 +22,11 @@
# Location where python-oops should store errors.
OOPS_REPOSITORY = 'logs'
+LOGOUT_URL = '/'
+LOGIN_REDIRECT_URL = '/'
+
+API_URL_REGEXP = '^/api/'
+
DEBUG = False
TEMPLATE_DEBUG = DEBUG
YUI_DEBUG = DEBUG
@@ -131,6 +136,7 @@
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
+ 'maasserver.middleware.AccessMiddleware',
)
ROOT_URLCONF = 'maas.urls'
=== added file 'src/maasserver/middleware.py'
--- src/maasserver/middleware.py 1970-01-01 00:00:00 +0000
+++ src/maasserver/middleware.py 2012-01-24 07:07:24 +0000
@@ -0,0 +1,56 @@
+# Copyright 2012 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+from __future__ import (
+ print_function,
+ unicode_literals,
+ )
+
+"""Access middleware."""
+
+__metaclass__ = type
+__all__ = [
+ "AccessMiddleware",
+ ]
+
+from django.http import HttpResponseRedirect
+from django.conf import settings
+from django.core.urlresolvers import reverse
+from piston.utils import rc
+import re
+
+
+class AccessMiddleware(object):
+ """Protect access to views.
+
+ - login/logout/api-doc urls: authorize unconditionally
+ - static resources urls: authorize unconditionally
+ - API urls: deny (Forbidden error - 401) anonymous requests
+ - views urls: redirect anonymous requests to login page
+ """
+
+ def __init__(self):
+ self.public_urls = tuple(
+ re.compile(url) for url in (
+ reverse('login'), reverse('logout'), reverse('api-doc')))
+ self.api_url = re.compile(settings.API_URL_REGEXP)
+ self.static_url = re.compile(settings.STATIC_URL)
+ self.login_url = reverse('login')
+
+ def process_request(self, request):
+ # API views.
+ if self.api_url.match(request.path):
+ if request.user.is_anonymous():
+ return rc.FORBIDDEN
+ else:
+ return None
+ # Static resources.
+ elif self.static_url.match(request.path):
+ return None
+ else:
+ if request.user.is_anonymous():
+ for url in self.public_urls:
+ if url.match(request.path):
+ return None
+ return HttpResponseRedirect("%s?next=%s" % (
+ self.login_url, request.path))
=== modified file 'src/maasserver/models.py'
--- src/maasserver/models.py 2012-01-20 16:09:56 +0000
+++ src/maasserver/models.py 2012-01-24 07:07:24 +0000
@@ -19,6 +19,7 @@
from uuid import uuid1
from django.contrib import admin
+from django.contrib.auth.models import User
from django.db import models
from maasserver.macaddress import MACAddressField
@@ -41,6 +42,9 @@
return 'node-%s' % uuid1()
+DEFAULT_STATUS = u'NEW'
+
+
NODE_STATUS_CHOICES = (
(u'NEW', u'New'),
(u'READY', u'Ready to Commission'),
@@ -50,13 +54,29 @@
)
+class NodeManager(models.Manager):
+
+ def visible_nodes(self, user):
+ if user.is_superuser:
+ return self.all()
+ else:
+ return self.filter(
+ models.Q(owner__isnull=True) | models.Q(owner=user))
+
+
class Node(CommonInfo):
"""A `Node` represents a physical machine used by the MaaS Server."""
system_id = models.CharField(
max_length=41, unique=True, editable=False,
default=generate_node_system_id)
hostname = models.CharField(max_length=255, default='', blank=True)
- status = models.CharField(max_length=10, choices=NODE_STATUS_CHOICES)
+ status = models.CharField(
+ max_length=10, choices=NODE_STATUS_CHOICES, editable=False,
+ default=DEFAULT_STATUS)
+ owner = models.ForeignKey(
+ User, default=None, blank=True, null=True, editable=False)
+
+ objects = NodeManager()
def __unicode__(self):
if self.hostname:
@@ -92,3 +112,24 @@
# Register the models in the admin site.
admin.site.register(Node)
admin.site.register(MACAddress)
+
+
+class MaaSAuthorizationBackend(object):
+
+ def has_perm(self, user, perm, obj=None):
+ # Only Nodes can be checked. We also don't support perm checking
+ # when obj = None.
+ if not isinstance(obj, Node):
+ raise NotImplementedError(
+ 'Invalid permission check (invalid object type).')
+
+ # Only the generic 'access' permission is supported.
+ if perm != 'access':
+ raise NotImplementedError(
+ 'Invalid permission check (invalid permission name).')
+
+ # Admins are granted all permissions.
+ if user.is_superuser:
+ return True
+
+ return obj.owner in (None, user)
=== added file 'src/maasserver/templates/maasserver/index.html'
--- src/maasserver/templates/maasserver/index.html 1970-01-01 00:00:00 +0000
+++ src/maasserver/templates/maasserver/index.html 2012-01-24 07:07:24 +0000
@@ -0,0 +1,25 @@
+{% extends "maasserver/base.html" %}
+
+{% block content %}
+ <h2>{{ node_list|length }} node{{ node_list|length|pluralize }} in this cluster</h2>
+ {% if node_list|length %}
+ <table>
+ <thead>
+ <tr>
+ <td>System ID</td>
+ <td>Hostname</td>
+ <td>MAC</td>
+ <td>Status</td>
+ </tr>
+ </thead>
+ {% for node in node_list %}
+ <tr>
+ <td>{{ node.system_id }}</td>
+ <td>{{ node.hostname }}</td>
+ <td>{{ node.mac_address_set }}</td>
+ <td>{{ node.status }}</td>
+ </tr>
+ {% endfor %}
+ </table>
+ {% endif%}
+{% endblock %}
=== added directory 'src/maasserver/templates/registration'
=== added file 'src/maasserver/templates/registration/login.html'
--- src/maasserver/templates/registration/login.html 1970-01-01 00:00:00 +0000
+++ src/maasserver/templates/registration/login.html 2012-01-24 07:07:24 +0000
@@ -0,0 +1,29 @@
+{% extends "maasserver/base.html" %}
+{% load url from future %}
+
+{% block content %}
+
+<h2>Login</h2>
+{% if form.errors %}
+ <p>Your username and password didn't match. Please try again.</p>
+{% endif %}
+
+<form method="post" action="{% url 'django.contrib.auth.views.login' %}">
+ <table>
+ <tr>
+ <td>{{ form.username.label_tag }}</td>
+ <td>{{ form.username }}</td>
+ </tr>
+ <tr>
+ <td>{{ form.password.label_tag }}</td>
+ <td>{{ form.password }}</td>
+ </tr>
+ <tr>
+ <td></td><td><input type="submit" value="login" /></td>
+ </tr>
+ </table>
+
+ <input type="hidden" name="next" value="{{ next }}" />
+</form>
+
+{% endblock %}
=== modified file 'src/maasserver/testing/factory.py'
--- src/maasserver/testing/factory.py 2012-01-19 13:56:36 +0000
+++ src/maasserver/testing/factory.py 2012-01-24 07:07:24 +0000
@@ -16,6 +16,7 @@
import random
import string
+from django.contrib.auth.models import User
from maasserver.models import (
MACAddress,
Node,
@@ -47,6 +48,23 @@
mac = MACAddress(mac_address=address, node=node)
return mac
+ def make_user(self, username=None, password=None, email=None):
+ if username is None:
+ username = self.getRandomString(10)
+ if email is None:
+ email = '%s@xxxxxxxxxxx' % self.getRandomString(10)
+ if password is None:
+ password = 'test'
+ return User.objects.create_user(
+ username=username, password=password, email=email)
+
+ def make_admin(self, username=None, password=None, email=None):
+ admin = self.make_user(
+ username=username, password=password, email=email)
+ admin.is_superuser = True
+ admin.save()
+ return admin
+
# Create factory singleton.
factory = Factory()
=== modified file 'src/maasserver/tests/test_api.py'
--- src/maasserver/tests/test_api.py 2012-01-23 12:43:28 +0000
+++ src/maasserver/tests/test_api.py 2012-01-24 07:07:24 +0000
@@ -13,6 +13,7 @@
import json
+from django.test.client import Client
from maasserver.models import (
MACAddress,
Node,
@@ -21,8 +22,21 @@
from maastesting import TestCase
+class NodeAnonAPITest(TestCase):
+
+ def test_anon_nodes_GET(self):
+ response = self.client.get('/api/nodes/')
+ self.assertEqual(401, response.status_code)
+
+
class NodeAPITest(TestCase):
+ def setUp(self):
+ super(NodeAPITest, self).setUp()
+ self.user = factory.make_user(username='user', password='test')
+ self.client = Client()
+ self.client.login(username='user', password='test')
+
def test_nodes_GET(self):
"""
The api allows for fetching the list of Nodes.
@@ -139,6 +153,10 @@
def setUp(self):
super(MACAddressAPITest, self).setUp()
+ self.user = factory.make_user(username='user', password='test')
+ self.client = Client()
+ self.client.login(username='user', password='test')
+
self.node = factory.make_node()
self.mac1 = self.node.add_mac_address('aa:bb:cc:dd:ee:ff')
self.mac2 = self.node.add_mac_address('22:bb:cc:dd:aa:ff')
=== added file 'src/maasserver/tests/test_auth.py'
--- src/maasserver/tests/test_auth.py 1970-01-01 00:00:00 +0000
+++ src/maasserver/tests/test_auth.py 2012-01-24 07:07:24 +0000
@@ -0,0 +1,118 @@
+# Copyright 2012 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+from __future__ import (
+ print_function,
+ unicode_literals,
+ )
+
+"""Test permissions."""
+
+__metaclass__ = type
+__all__ = []
+
+from django.core.urlresolvers import reverse
+
+from maas.testing import TestCase
+
+from maasserver.models import (
+ MaaSAuthorizationBackend,
+ Node,
+ )
+from maasserver.testing.factory import factory
+
+
+class LoginLogoutTest(TestCase):
+
+ def setUp(self):
+ super(LoginLogoutTest, self).setUp()
+ self.user = factory.make_user(username='test', password='test')
+
+ def test_login(self):
+ response = self.client.post(
+ reverse('login'), {'username': 'test', 'password': 'test'})
+
+ self.assertEqual(302, response.status_code)
+ self.assertEqual(self.user.id, self.client.session['_auth_user_id'])
+
+ def test_login_failed(self):
+ response = self.client.post(
+ reverse('login'), {'username': 'test', 'password': 'wrong-pw'})
+
+ self.assertEqual(200, response.status_code)
+ self.assertNotIn('_auth_user_id', self.client.session.keys())
+
+ def test_logout(self):
+ self.user = factory.make_user(username='user', password='test')
+ self.client.login(username='user', password='test')
+ self.client.post(reverse('logout'))
+
+ self.assertNotIn('_auth_user_id', self.client.session.keys())
+
+
+class AuthTestMixin(object):
+
+ def setUp(self):
+ super(AuthTestMixin, self).setUp()
+ self.backend = MaaSAuthorizationBackend()
+ self.admin = factory.make_admin()
+ self.user1 = factory.make_user(username='user1')
+ self.user2 = factory.make_user(username='user2')
+ self.node_user1 = factory.make_node(
+ owner=self.user1, status=u'DEPLOYED')
+ self.node_user2 = factory.make_node(
+ owner=self.user2, status=u'DEPLOYED')
+ self.not_owned_node = factory.make_node(status=u'NEW')
+
+
+class TestMaaSAuthorizationBackend(AuthTestMixin, TestCase):
+
+ def setUp(self):
+ super(TestMaaSAuthorizationBackend, self).setUp()
+ self.backend = MaaSAuthorizationBackend()
+
+ def test_invalid_check_object(self):
+ mac = self.not_owned_node.add_mac_address('AA:BB:CC:DD:EE:FF')
+ self.assertRaises(
+ NotImplementedError, self.backend.has_perm,
+ *[self.admin, 'access', mac])
+
+ def test_invalid_check_permission(self):
+ self.assertRaises(
+ NotImplementedError, self.backend.has_perm,
+ *[self.admin, 'not-access', self.not_owned_node])
+
+ def test_admin_access(self):
+ self.assertTrue(self.backend.has_perm(
+ self.admin, 'access', self.node_user1))
+
+ def test_not_owned_status(self):
+ # A non-admin user can access a node that is not yet owned.
+ self.assertTrue(self.backend.has_perm(
+ self.user1, 'access', self.not_owned_node))
+
+ def test_owned_status_others(self):
+ # A non-admin user cannot access nodes owned by other people.
+ self.assertFalse(self.backend.has_perm(
+ self.user2, 'access', self.node_user1))
+
+ def test_owned_status(self):
+ # A non-admin user can access nodes he owns.
+ self.assertTrue(self.backend.has_perm(
+ self.user1, 'access', self.node_user1))
+
+
+class TestNodeVisibility(AuthTestMixin, TestCase):
+
+ def test_nodes_admin_access(self):
+ # An admin sees all the nodes.
+ self.assertSequenceEqual(
+ [self.node_user1, self.node_user2, self.not_owned_node],
+ Node.objects.visible_nodes(self.admin))
+
+ def test_nodes_not_owned_status(self):
+ # A non-admin user only has access to non-owned nodes and his own
+ # nodes.
+ self.assertSequenceEqual(
+ [self.node_user1, self.not_owned_node],
+ Node.objects.visible_nodes(self.user1))
=== modified file 'src/maasserver/urls.py'
--- src/maasserver/urls.py 2012-01-19 15:33:19 +0000
+++ src/maasserver/urls.py 2012-01-24 07:07:24 +0000
@@ -15,6 +15,7 @@
patterns,
url,
)
+from django.contrib.auth.views import login
from django.views.generic import ListView
from maasserver.api import (
api_doc,
@@ -25,24 +26,43 @@
)
from maasserver.models import Node
from maasserver.views import (
+ logout,
NodesCreateView,
NodeView,
)
from piston.resource import Resource
+# Urls accessible to anonymous users.
urlpatterns = patterns('maasserver.views',
- url(r'^$', ListView.as_view(model=Node), name='index'),
- url(r'^nodes/create/$', NodesCreateView.as_view(), name='node-create'),
+ url(r'^accounts/login/$', login, name='login'),
+ url(r'^accounts/logout/$', logout, name='logout'),
+)
+
+# Urls for logged-in users.
+urlpatterns += patterns('maasserver.views',
+ url(
+ r'^$',
+ ListView.as_view(model=Node, template_name="maasserver/index.html"),
+ name='index'),
+ url(r'^nodes/$', ListView.as_view(model=Node), name='node-list'),
+ url(
+ r'^nodes/create/$', NodesCreateView.as_view(), name='node-create'),
url(r'^nodes/([\w\-]+)/$', NodeView.as_view(), name='node-view'),
)
-# Api.
+# API.
node_handler = Resource(NodeHandler)
nodes_handler = Resource(NodesHandler)
node_mac_handler = Resource(NodeMacHandler)
node_macs_handler = Resource(NodeMacsHandler)
+# API urls accessible to anonymous users.
+urlpatterns += patterns('maasserver.views',
+ url(r'^api/doc/$', api_doc, name='api-doc'),
+)
+
+# API urls for logged-in users.
urlpatterns += patterns('maasserver.views',
url(
r'^api/nodes/(?P<system_id>[\w\-]+)/macs/(?P<mac_address>.+)/$',
@@ -55,6 +75,4 @@
r'^api/nodes/(?P<system_id>[\w\-]+)/$', node_handler,
name='node_handler'),
url(r'^api/nodes/$', nodes_handler, name='nodes_handler'),
-
- url(r'^api/doc/$', api_doc),
)
=== modified file 'src/maasserver/views.py'
--- src/maasserver/views.py 2012-01-19 13:56:36 +0000
+++ src/maasserver/views.py 2012-01-24 07:07:24 +0000
@@ -14,6 +14,7 @@
"NodesCreateView",
]
+from django.contrib.auth.views import logout as dj_logout
from django.shortcuts import get_object_or_404
from django.views.generic import (
CreateView,
@@ -22,17 +23,20 @@
from maasserver.models import Node
+def logout(request):
+ return dj_logout(request, next_page='/')
+
+
class NodeView(ListView):
context_object_name = "node_list"
def get_queryset(self):
node = get_object_or_404(Node, name__iexact=self.args[0])
- return Node.objects.filter(node=node)
+ return Node.visible_nodes.filter(node=node)
class NodesCreateView(CreateView):
model = Node
-# template_name = 'maasserver/node_create.html'
success_url = '/'