commit c72a830dec86687bb87bead9e5d591606a88d22a
Author: Kostas Papadimitriou <kpap@grnet.gr>
Date:   Mon Oct 31 13:27:24 2011 +0200

    Configurable limit for per-user ssh keys, Refs #1517

diff --git a/settings.d/31-userdata.conf b/settings.d/31-userdata.conf
index b87a631..2a45708 100644
--- a/settings.d/31-userdata.conf
+++ b/settings.d/31-userdata.conf
@@ -3,3 +3,6 @@ USERDATA_SSH_KEY_LENGTH = 2048
 
 # Generated SSH key exponent
 USERDATA_SSH_KEY_EXPONENT = 65537
+
+# Maximum number of ssh keys a user is allowed to have
+MAX_SSH_KEYS_PER_USER = 2
diff --git a/ui/static/snf/css/main.css b/ui/static/snf/css/main.css
index 2f6385d..e54f29a 100644
--- a/ui/static/snf/css/main.css
+++ b/ui/static/snf/css/main.css
@@ -6357,10 +6357,16 @@ input.has-errors {
     padding:0 !important;
 }
 
+.models-view .form-messages {
+    margin-bottom: 10px;
+}
+
 .models-view .list-messages {
     margin-top: 10px;
 }
 
+.models-view .form-messages .error,
+.models-view .form-messages .success,
 .models-view .list-messages .error,
 .models-view .list-messages .success,
 #user_public_keys .private-cont {
@@ -6372,12 +6378,20 @@ input.has-errors {
     margin-top: 5px;
 }
 
+.models-view .form-messages .error,
 .models-view .list-messages .error {
     background-color: #DE8D87;
     color:  #782421 !important;
     border-color: #782421;
 }
 
+#user_public_keys .limit-msg {
+    color: #800;
+    position: absolute;
+    right: 20px;
+    top: 25px;
+}
+
 #user_public_keys .private-cont {
     margin-top: 10px;
 }
diff --git a/ui/static/snf/js/models.js b/ui/static/snf/js/models.js
index a045db2..9b9f260 100644
--- a/ui/static/snf/js/models.js
+++ b/ui/static/snf/js/models.js
@@ -1764,6 +1764,7 @@
             
             options.success = function () { return success(m) };
             options.errror = error;
+            options.skip_api_error = true;
             
             this.create(m.attributes, options);
         }
diff --git a/ui/static/snf/js/sync.js b/ui/static/snf/js/sync.js
index e390d25..0746aec 100644
--- a/ui/static/snf/js/sync.js
+++ b/ui/static/snf/js/sync.js
@@ -226,7 +226,7 @@
             
             // determine if we need to call our callback wrapper
             var call_api_handler = true;
-
+            
             // request handles errors by itself, s
             if (handler_type == "error" && this.skip_api_error) {
                 call_api_handler = false
diff --git a/ui/static/snf/js/ui/web/ui_model_views.js b/ui/static/snf/js/ui/web/ui_model_views.js
index 788d7f5..60fdf8f 100644
--- a/ui/static/snf/js/ui/web/ui_model_views.js
+++ b/ui/static/snf/js/ui/web/ui_model_views.js
@@ -148,6 +148,7 @@
         reset_form_errors: function() {
             this.form.find(".form-field").removeClass("error");
             this.form.find(".form-field .errors").empty();
+            this.form.find(".form-messages").empty();
         },
 
         show_form_errors: function(errors) {
@@ -163,11 +164,16 @@
                     field.find(".errors").append(error_el);
                 });
             }, this));
-            //var el = $('<div class="error">{1}</div>'.format(type, msg));
-            //this.list_messages.append(el);
+            
+            var msg = errors[''];
+            if (msg) {
+                var el = $('<div class="error">{0}</div>'.format(msg));
+                this.$(".form-messages").append(el);
+            }
         },
 
         clean_form_errors: function() {
+
         },
         
         submit_form: function() {
@@ -272,14 +278,27 @@
                     this.show_list_msg("success", this.create_success_msg || "Entry created");
                 }, this),
 
-                error: _.bind(function(){
-                    this.show_form_errors({'': this.create_failed_msg || 'Entry submition failed'})
+                error: _.bind(function(data, xhr){
+                    var resp_error = "";
+                    // try to parse response
+                    try {
+                        json_resp = JSON.parse(xhr.responseText);
+                        resp_error = json_resp.errors[json_resp.non_field_key].join("<br />");
+                    } catch (err) {}
+
+                    var form_error = resp_error != "" ? 
+                                this.create_failed_msg + " ({0})".format(resp_error) : 
+                                this.create_failed_msg;
+
+                    this.show_form_errors({'': form_error || 'Entry submition failed'})
                 }, this),
 
                 complete: _.bind(function(){
                     this.submiting = false;
                     this.form.find("form-action.submit").addClass("in-progress");
-                }, this)
+                }, this),
+
+                skip_api_error: true
             }
 
             if (this.editing_id && this.collection.get(this.editing_id)) {
diff --git a/ui/static/snf/js/ui/web/ui_public_keys_view.js b/ui/static/snf/js/ui/web/ui_public_keys_view.js
index 9ea67cf..206468d 100644
--- a/ui/static/snf/js/ui/web/ui_public_keys_view.js
+++ b/ui/static/snf/js/ui/web/ui_public_keys_view.js
@@ -23,10 +23,12 @@
         create_success_msg: 'Public key created successfully.',
         create_failed_msg: 'Failed to create public key.',
 
+
         initialize: function(options) {
             views.PublicKeysView.__super__.initialize.apply(this, arguments);
             this.$(".private-cont").hide();
             _.bindAll(this);
+            this.keys_limit = snf.config.userdata_keys_limit || 10000;
         },
 
         append_actions: function(el, model) {
@@ -110,8 +112,19 @@
 
         __generate_new: function(generate_text) {
             var self = this;
-            var key = storage.keys.generate_new(_.bind(this.__save_new, this, generate_text), function(){
-                self.show_list_msg("error", "Cannot generate new key pair");
+            var key = storage.keys.generate_new(_.bind(this.__save_new, this, generate_text), function(xhr){
+                var resp_error = "";
+                // try to parse response
+                try {
+                    json_resp = JSON.parse(xhr.responseText);
+                    resp_error = json_resp.errors[json_resp.non_field_key].join("<br />");
+                } catch (err) {}
+                
+                var msg = "Cannot generate new key pair";
+                if (resp_error) {
+                    msg += " ({0})".format(resp_error);
+                }
+                self.show_list_msg("error", msg);
                 self.generating = false;
                 self.download_private = false;
                 self.$(".add-generate").text(generate_text).removeClass(
@@ -153,6 +166,21 @@
             return el;
         },
 
+        update_list: function() {
+            views.PublicKeysView.__super__.update_list.apply(this, arguments);
+            this.check_limit();
+        },
+
+        check_limit: function() {
+            if (snf.storage.keys.length >= this.keys_limit) {
+                this.$(".collection-action").hide();
+                this.$(".limit-msg").show();
+            } else {
+                this.$(".collection-action").show();
+                this.$(".limit-msg").hide();
+            }
+        },
+
         update_form_from_model: function(model) {
             this.form.find("input.input-name").val(model.get("name"));
             this.form.find("textarea.input-content").val(model.get("content"));
diff --git a/ui/templates/home.html b/ui/templates/home.html
index bb51003..1380ecc 100644
--- a/ui/templates/home.html
+++ b/ui/templates/home.html
@@ -554,6 +554,7 @@ We welcome any suggestions, questions and bug reports you may have.{% endblocktr
             synnefo.config.api_url = '/api/v1.1';
             synnefo.config.logout_url = '{{ logout_redirect }}';
             synnefo.config.userdata_keys_url = '{% url keys_collection %}';
+            synnefo.config.userdata_keys_limit = {{ userdata_keys_limit }};
 
             synnefo.config.media_url = '{{ UI_MEDIA_URL }}';
             synnefo.config.js_url = '{{ SYNNEFO_JS_URL }}';
diff --git a/ui/userdata/models.py b/ui/userdata/models.py
index 9b0d12a..b39c3aa 100644
--- a/ui/userdata/models.py
+++ b/ui/userdata/models.py
@@ -1,4 +1,7 @@
 from django.db import models
+from django.conf import settings
+from django.core.exceptions import ValidationError, NON_FIELD_ERRORS
+
 from synnefo.db import models as synnefo_models
 
 User = synnefo_models.SynnefoUser
@@ -25,3 +28,11 @@ class PublicKeyPair(ProfileModel):
 
     class Meta:
         app_label = 'userdata'
+
+    def clean(self):
+        if PublicKeyPair.user_limit_exceeded(self.user):
+            raise ValidationError("SSH keys limit exceeded.")
+
+    @classmethod
+    def user_limit_exceeded(cls, user):
+        return PublicKeyPair.objects.filter(user=user).count() >= settings.MAX_SSH_KEYS_PER_USER
diff --git a/ui/userdata/rest.py b/ui/userdata/rest.py
index 62e1530..48a7f5b 100644
--- a/ui/userdata/rest.py
+++ b/ui/userdata/rest.py
@@ -4,6 +4,8 @@ from django.utils import simplejson as json
 from django.core import serializers
 from django.core.urlresolvers import reverse
 
+from django.core.exceptions import ValidationError, NON_FIELD_ERRORS
+
 # base view class
 # https://github.com/bfirsh/django-class-based-views/blob/master/class_based_views/base.py
 class View(object):
@@ -62,8 +64,14 @@ class View(object):
                         raise http.HttpResponseServerError('Invalid JSON data.')
                 else:
                     raise http.HttpResponseServerError('Unsupported Content-Type.')
+            try:
+                return getattr(self, request.method.upper())(request, data, *args, **kwargs)
+            except ValidationError, e:
+                # specific response for validation errors
+                return http.HttpResponseServerError(json.dumps({'errors':
+                    e.message_dict, 'non_field_key':
+                    NON_FIELD_ERRORS }))
 
-            return getattr(self, request.method.upper())(request, data, *args, **kwargs)
         else:
             allowed_methods = [m for m in self.method_names if hasattr(self, m)]
             return http.HttpResponseNotAllowed(allowed_methods)
@@ -137,6 +145,7 @@ class ResourceView(JSONRestView):
     def PUT(self, request, data, *args, **kwargs):
         instance = self.instance()
         self.update_instance(instance, data, self.exclude_fields)
+        instance.full_clean()
         instance.save()
         return self.GET(request, data, *args, **kwargs)
 
@@ -161,6 +170,7 @@ class CollectionView(JSONRestView):
     def POST(self, request, data, *args, **kwargs):
         instance = self.model()
         self.update_instance(instance, data, self.exclude_fields)
+        instance.full_clean()
         instance.save()
         return self.json_response(self.instance_to_dict(instance,
             self.exclude_fields))
@@ -184,6 +194,7 @@ class UserCollectionView(CollectionView):
         instance = self.model()
         self.update_instance(instance, data, self.exclude_fields)
         instance.user = request.user
+        instance.full_clean()
         instance.save()
         return self.json_response(self.instance_to_dict(instance,
             self.exclude_fields))
diff --git a/ui/userdata/templates/userdata/public_keys_view.html b/ui/userdata/templates/userdata/public_keys_view.html
index adf73e2..c27c72c 100644
--- a/ui/userdata/templates/userdata/public_keys_view.html
+++ b/ui/userdata/templates/userdata/public_keys_view.html
@@ -11,6 +11,7 @@
                     <div class="collection-action add add-new">{% trans "create/import new" %}</div>
                     <div class="collection-action generate add-generate">{% trans "generate new" %}</div>
                 </div>
+                <div class="limit-msg">{% trans "SSH keys limit reached." %}</div>
                 <div class="model-description">
                 <p>You can use SSH keys to establish a secure connection 
 between your computer and the virtual machines. </p>
diff --git a/ui/userdata/tests.py b/ui/userdata/tests.py
index d856fbd..b649f76 100644
--- a/ui/userdata/tests.py
+++ b/ui/userdata/tests.py
@@ -10,6 +10,7 @@ from django.conf import settings
 from django.test.client import Client
 from django.core.urlresolvers import clear_url_caches
 from django.utils import simplejson as json
+from django.conf import settings
 
 from synnefo.ui.userdata.models import User
 from synnefo.ui.userdata.models import *
@@ -101,3 +102,23 @@ class TestRestViews(TestCase):
         # private key is base64 encoded
         base64.b64decode(private)
 
+    def test_invalid_data(self):
+        resp = self.client.post("/keys", json.dumps({'content':"""key 2 content"""}),
+                content_type='application/json')
+
+        self.assertEqual(resp.status_code, 500)
+        self.assertEqual(resp.content, """{"non_field_key": "__all__", "errors": """
+                                       """{"name": ["This field cannot be blank."]}}""")
+
+        settings.MAX_SSH_KEYS_PER_USER = 2
+
+        # test ssh limit
+        resp = self.client.post("/keys", json.dumps({'name':'key1', 'content':"""key 1 content"""}),
+                content_type='application/json')
+        resp = self.client.post("/keys", json.dumps({'name':'key1', 'content':"""key 1 content"""}),
+                content_type='application/json')
+        resp = self.client.post("/keys", json.dumps({'name':'key1', 'content':"""key 1 content"""}),
+                content_type='application/json')
+        self.assertEqual(resp.status_code, 500)
+        self.assertEqual(resp.content, """{"non_field_key": "__all__", "errors": """
+                                       """{"__all__": ["SSH keys limit exceeded."]}}""")
diff --git a/ui/userdata/views.py b/ui/userdata/views.py
index de720c1..47025a3 100644
--- a/ui/userdata/views.py
+++ b/ui/userdata/views.py
@@ -32,6 +32,10 @@ def generate_key_pair(request):
     if not SUPPORT_GENERATE_KEYS:
         raise Exception("Application does not support ssh keys generation")
 
+    if PublicKeyPair.user_limit_exceeded(request.user):
+        raise http.HttpResponseServerError("SSH keys limit exceeded");
+
+
     # generate RSA key
     key = M2C.RSA.gen_key(SSH_KEY_LENGTH, SSH_KEY_EXPONENT, lambda x: "");
 
diff --git a/ui/views.py b/ui/views.py
index e556d00..10695d3 100644
--- a/ui/views.py
+++ b/ui/views.py
@@ -91,6 +91,9 @@ UI_SYNNEFO_JS_WEB_URL = getattr(settings,
 
 VM_NAME_TEMPLATE = getattr(settings, "VM_CREATE_NAME_TPL", "My {0} server")
 
+# ssh keys
+MAX_SSH_KEYS_PER_USER = getattr(settings, "MAX_SSH_KEYS_PER_USER")
+
 def template(name, context):
     template_path = os.path.join(os.path.dirname(__file__), "templates/")
     current_template = template_path + name + '.html'
@@ -129,6 +132,7 @@ def home(request):
                'vm_name_template': json.dumps(VM_NAME_TEMPLATE)
                'support_ssh_os_list': json.dumps(SUPPORT_SSH_OS_LIST),
                'os_created_users': json.dumps(OS_CREATED_USERS),
+               'userdata_keys_limit': json.dumps(MAX_SSH_KEYS_PER_USER),
                }
     return template('home', context)
 
