← Back to team overview

launchpad-reviewers team mailing list archive

[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 = '/'