summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorRuben Pollan <meskio@sindominio.net>2015-09-28 23:19:48 +0200
committerRuben Pollan <meskio@sindominio.net>2015-09-28 23:19:48 +0200
commit4de93f472ac224ef32beffa1acd1c98765519160 (patch)
tree2b3065591d5f472ebd6e343b57af2f4c853b5acc
parent1566ac3885d566d0b3d70a44eaf0bd901b2d655e (diff)
parent3c7a41574ed1a97ae168bbbc50b127d17694734a (diff)
Merge branch 'feat/create_db_using_cmd' into develop
- Releases: 0.8.0
-rw-r--r--README.rst33
-rw-r--r--client/src/leap/soledad/client/sync.py12
-rw-r--r--common/changes/create_db_cmd2
-rw-r--r--common/src/leap/soledad/common/command.py55
-rw-r--r--common/src/leap/soledad/common/couch.py62
-rw-r--r--common/src/leap/soledad/common/tests/test_command.py53
-rw-r--r--common/src/leap/soledad/common/tests/test_couch.py45
-rw-r--r--common/src/leap/soledad/common/tests/test_couch_operations_atomicity.py6
-rw-r--r--common/src/leap/soledad/common/tests/test_server.py12
-rw-r--r--server/changes/create_db_cmd3
-rwxr-xr-xserver/pkg/create-user-db59
-rw-r--r--server/src/leap/soledad/server/__init__.py3
12 files changed, 312 insertions, 33 deletions
diff --git a/README.rst b/README.rst
index b98eec06..a2755f92 100644
--- a/README.rst
+++ b/README.rst
@@ -55,3 +55,36 @@ to run tests in development mode you must do the following::
Note that to run CouchDB tests, be sure you have ``CouchDB`` installed on your
system.
+
+
+Privileges
+-----
+In order to prevent privilege escalation, Soledad should not be run as a
+database administrator. This implies the following side effects:
+
+-----------------
+Database creation:
+-----------------
+Can be done via a script located in ``server/pkg/create-user-db``
+It reads a netrc file that should be placed on
+``/etc/couchdb/couchdb-admin.netrc``.
+That file holds the admin credentials in netrc format and should be accessible
+only by 'soledad-admin' user.
+
+The debian package will do the following in order to automate this:
+
+* create a user ``soledad-admin``
+* make this script available as ``create-user-db`` in ``/usr/bin``
+* grant restricted sudo access, that only enables user ``soledad`` to call this
+ exact command via ``soledad-admin`` user.
+
+The server side process, configured via ``/etc/leap/soledad-server.conf``, will
+then use a parameter called 'create_cmd' to know which command is used to
+allocate new databases. All steps of creation process is then handled
+automatically by the server, following the same logic as u1db server.
+
+------------------
+Database deletion:
+------------------
+No code at all handles this and privilege to do so needs to be removed as
+explained before. This can be automated via a simple cron job.
diff --git a/client/src/leap/soledad/client/sync.py b/client/src/leap/soledad/client/sync.py
index 110baa0a..225d3e2d 100644
--- a/client/src/leap/soledad/client/sync.py
+++ b/client/src/leap/soledad/client/sync.py
@@ -69,9 +69,15 @@ class SoledadSynchronizer(Synchronizer):
# get target identifier, its current generation,
# and its last-seen database generation for this source
ensure_callback = None
- (self.target_replica_uid, target_gen, target_trans_id,
- target_my_gen, target_my_trans_id) = yield \
- sync_target.get_sync_info(self.source._replica_uid)
+ try:
+ (self.target_replica_uid, target_gen, target_trans_id,
+ target_my_gen, target_my_trans_id) = yield \
+ sync_target.get_sync_info(self.source._replica_uid)
+ except errors.DatabaseDoesNotExist:
+ logger.debug("Database isn't ready on server. Will be created.")
+ self.target_replica_uid = None
+ target_gen, target_trans_id = 0, ''
+ target_my_gen, target_my_trans_id = 0, ''
logger.debug(
"Soledad target sync info:\n"
diff --git a/common/changes/create_db_cmd b/common/changes/create_db_cmd
new file mode 100644
index 00000000..00bbdf71
--- /dev/null
+++ b/common/changes/create_db_cmd
@@ -0,0 +1,2 @@
+ o Add a sanitized command executor for database creation and re-enable
+ user database creation on CouchServerState via command line.
diff --git a/common/src/leap/soledad/common/command.py b/common/src/leap/soledad/common/command.py
new file mode 100644
index 00000000..811bf135
--- /dev/null
+++ b/common/src/leap/soledad/common/command.py
@@ -0,0 +1,55 @@
+# -*- coding: utf-8 -*-
+# command.py
+# Copyright (C) 2015 LEAP
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
+"""
+Utility to sanitize and run shell commands.
+"""
+
+
+import subprocess
+
+
+def exec_validated_cmd(cmd, argument, validator=None):
+ """
+ Executes cmd, validating argument with a validator function.
+
+ :param cmd: command.
+ :type dbname: str
+ :param argument: argument.
+ :type argument: str
+ :param validator: optional function to validate argument
+ :type validator: function
+
+ :return: exit code and stdout or stderr (if code != 0)
+ :rtype: (int, str)
+ """
+ if validator and not validator(argument):
+ return 1, "invalid argument"
+ command = cmd.split(' ')
+ command.append(argument)
+ try:
+ process = subprocess.Popen(command, stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE)
+ except OSError, e:
+ return 1, e
+ (out, err) = process.communicate()
+ code = process.wait()
+ if code is not 0:
+ return code, err
+ else:
+ return code, out
diff --git a/common/src/leap/soledad/common/couch.py b/common/src/leap/soledad/common/couch.py
index 38041c09..4c5f6400 100644
--- a/common/src/leap/soledad/common/couch.py
+++ b/common/src/leap/soledad/common/couch.py
@@ -60,6 +60,7 @@ from u1db.remote.server_state import ServerState
from leap.soledad.common import ddocs, errors
+from leap.soledad.common.command import exec_validated_cmd
from leap.soledad.common.document import SoledadDocument
@@ -434,6 +435,7 @@ class CouchDatabase(CommonBackend):
self._set_replica_uid(replica_uid)
if ensure_ddocs:
self.ensure_ddocs_on_db()
+ self.ensure_security_ddoc()
self._cache = None
@property
@@ -466,6 +468,21 @@ class CouchDatabase(CommonBackend):
getattr(ddocs, ddoc_name)))
self._database.save(ddoc)
+ def ensure_security_ddoc(self):
+ """
+ Make sure that only soledad user is able to access this database as
+ an unprivileged member, meaning that administration access will
+ be forbidden even inside an user database.
+ The goal is to make sure that only the lowest access level is given
+ to the unprivileged CouchDB user set on the server process.
+ This is achieved by creating a _security design document, see:
+ http://docs.couchdb.org/en/latest/api/database/security.html
+ """
+ security = self._database.security
+ security['members'] = {'names': ['soledad'], 'roles': []}
+ security['admins'] = {'names': [], 'roles': []}
+ self._database.security = security
+
def get_sync_target(self):
"""
Return a SyncTarget object, for another u1db to synchronize with.
@@ -1374,13 +1391,27 @@ class CouchSyncTarget(CommonSyncTarget):
source_replica_transaction_id)
+def is_db_name_valid(name):
+ """
+ Validate a user database using a regular expression.
+
+ :param name: database name.
+ :type name: str
+
+ :return: boolean for name vailidity
+ :rtype: bool
+ """
+ db_name_regex = "^user-[a-f0-9]+$"
+ return re.match(db_name_regex, name) is not None
+
+
class CouchServerState(ServerState):
"""
Inteface of the WSGI server with the CouchDB backend.
"""
- def __init__(self, couch_url):
+ def __init__(self, couch_url, create_cmd=None):
"""
Initialize the couch server state.
@@ -1388,6 +1419,7 @@ class CouchServerState(ServerState):
:type couch_url: str
"""
self.couch_url = couch_url
+ self.create_cmd = create_cmd
def open_database(self, dbname):
"""
@@ -1409,20 +1441,28 @@ class CouchServerState(ServerState):
"""
Ensure couch database exists.
- Usually, this method is used by the server to ensure the existence of
- a database. In our setup, the Soledad user that accesses the underlying
- couch server should never have permission to create (or delete)
- databases. But, in case it ever does, by raising an exception here we
- have one more guarantee that no modified client will be able to
- enforce creation of a database when syncing.
-
:param dbname: The name of the database to ensure.
:type dbname: str
- :raise Unauthorized: Always, because Soledad server is not allowed to
- create databases.
+ :raise Unauthorized: If disabled or other error was raised.
+
+ :return: The CouchDatabase object and its replica_uid.
+ :rtype: (CouchDatabase, str)
"""
- raise Unauthorized()
+ if not self.create_cmd:
+ raise Unauthorized()
+ else:
+ code, out = exec_validated_cmd(self.create_cmd, dbname,
+ validator=is_db_name_valid)
+ if code is not 0:
+ logger.error("""
+ Error while creating database (%s) with (%s) command.
+ Output: %s
+ Exit code: %d
+ """ % (dbname, self.create_cmd, out, code))
+ raise Unauthorized()
+ db = self.open_database(dbname)
+ return db, db.replica_uid
def delete_database(self, dbname):
"""
diff --git a/common/src/leap/soledad/common/tests/test_command.py b/common/src/leap/soledad/common/tests/test_command.py
new file mode 100644
index 00000000..c386bdd2
--- /dev/null
+++ b/common/src/leap/soledad/common/tests/test_command.py
@@ -0,0 +1,53 @@
+# -*- coding: utf-8 -*-
+# test_command.py
+# Copyright (C) 2015 LEAP
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+"""
+Tests for command execution using a validator function for arguments.
+"""
+from twisted.trial import unittest
+from leap.soledad.common.command import exec_validated_cmd
+
+
+class ExecuteValidatedCommandTest(unittest.TestCase):
+
+ def test_argument_validation(self):
+ validator = lambda arg: True if arg is 'valid' else False
+ status, out = exec_validated_cmd("command", "invalid arg", validator)
+ self.assertEquals(status, 1)
+ self.assertEquals(out, "invalid argument")
+ status, out = exec_validated_cmd("echo", "valid", validator)
+ self.assertEquals(status, 0)
+ self.assertEquals(out, "valid\n")
+
+ def test_return_status_code_success(self):
+ status, out = exec_validated_cmd("echo", "arg")
+ self.assertEquals(status, 0)
+ self.assertEquals(out, "arg\n")
+
+ def test_handle_command_with_spaces(self):
+ status, out = exec_validated_cmd("echo I am", "an argument")
+ self.assertEquals(status, 0, out)
+ self.assertEquals(out, "I am an argument\n")
+
+ def test_handle_oserror_on_invalid_command(self):
+ status, out = exec_validated_cmd("inexistent command with", "args")
+ self.assertEquals(status, 1)
+ self.assertIn("No such file or directory", out)
+
+ def test_return_status_code_number_on_failure(self):
+ status, out = exec_validated_cmd("ls", "user-bebacafe")
+ self.assertNotEquals(status, 0)
+ self.assertIn('No such file or directory\n', out)
diff --git a/common/src/leap/soledad/common/tests/test_couch.py b/common/src/leap/soledad/common/tests/test_couch.py
index c8d13667..d1a07a3a 100644
--- a/common/src/leap/soledad/common/tests/test_couch.py
+++ b/common/src/leap/soledad/common/tests/test_couch.py
@@ -28,6 +28,8 @@ from couchdb.client import Server
from uuid import uuid4
from testscenarios import TestWithScenarios
+from twisted.trial import unittest
+from mock import Mock
from u1db import errors as u1db_errors
from u1db import SyncTarget
@@ -1498,3 +1500,46 @@ class CouchDatabaseExceptionsTests(CouchDBTestCase):
self.db._get_transaction_log)
self.create_db(ensure=True, dbname=self.db._dbname)
self.db._get_transaction_log()
+
+ def test_ensure_security_doc(self):
+ """
+ Ensure_security creates a _security ddoc to ensure that only soledad
+ will have the lowest privileged access to an user db.
+ """
+ self.create_db(ensure=False)
+ self.assertFalse(self.db._database.security)
+ self.db.ensure_security_ddoc()
+ security_ddoc = self.db._database.security
+ self.assertIn('admins', security_ddoc)
+ self.assertFalse(security_ddoc['admins']['names'])
+ self.assertIn('members', security_ddoc)
+ self.assertIn('soledad', security_ddoc['members']['names'])
+
+
+class DatabaseNameValidationTest(unittest.TestCase):
+
+ def test_database_name_validation(self):
+ self.assertFalse(couch.is_db_name_valid("user-deadbeef | cat /secret"))
+ self.assertTrue(couch.is_db_name_valid("user-cafe1337"))
+
+
+class CommandBasedDBCreationTest(unittest.TestCase):
+
+ def test_ensure_db_using_custom_command(self):
+ state = couch.CouchServerState("url", create_cmd="echo")
+ mock_db = Mock()
+ mock_db.replica_uid = 'replica_uid'
+ state.open_database = Mock(return_value=mock_db)
+ db, replica_uid = state.ensure_database("user-1337") # works
+ self.assertEquals(mock_db, db)
+ self.assertEquals(mock_db.replica_uid, replica_uid)
+
+ def test_raises_unauthorized_on_failure(self):
+ state = couch.CouchServerState("url", create_cmd="inexistent")
+ self.assertRaises(u1db_errors.Unauthorized,
+ state.ensure_database, "user-1337")
+
+ def test_raises_unauthorized_by_default(self):
+ state = couch.CouchServerState("url")
+ self.assertRaises(u1db_errors.Unauthorized,
+ state.ensure_database, "user-1337")
diff --git a/common/src/leap/soledad/common/tests/test_couch_operations_atomicity.py b/common/src/leap/soledad/common/tests/test_couch_operations_atomicity.py
index 3e8e8cce..507f2984 100644
--- a/common/src/leap/soledad/common/tests/test_couch_operations_atomicity.py
+++ b/common/src/leap/soledad/common/tests/test_couch_operations_atomicity.py
@@ -35,17 +35,11 @@ from leap.soledad.common.tests.util import (
)
from leap.soledad.common.tests.test_couch import CouchDBTestCase
from leap.soledad.common.tests.u1db_tests import TestCaseWithServer
-from leap.soledad.common.tests.test_server import _couch_ensure_database
REPEAT_TIMES = 20
-# monkey path CouchServerState so it can ensure databases.
-
-CouchServerState.ensure_database = _couch_ensure_database
-
-
class CouchAtomicityTestCase(CouchDBTestCase, TestCaseWithServer):
@staticmethod
diff --git a/common/src/leap/soledad/common/tests/test_server.py b/common/src/leap/soledad/common/tests/test_server.py
index f512d6c1..19d2907d 100644
--- a/common/src/leap/soledad/common/tests/test_server.py
+++ b/common/src/leap/soledad/common/tests/test_server.py
@@ -46,18 +46,6 @@ from leap.soledad.server import LockResource
from leap.soledad.server.auth import URLToAuthorization
-# monkey path CouchServerState so it can ensure databases.
-
-def _couch_ensure_database(self, dbname):
- db = CouchDatabase.open_database(
- self.couch_url + '/' + dbname,
- create=True,
- ensure_ddocs=True)
- return db, db._replica_uid
-
-CouchServerState.ensure_database = _couch_ensure_database
-
-
class ServerAuthorizationTestCase(BaseSoledadTest):
"""
diff --git a/server/changes/create_db_cmd b/server/changes/create_db_cmd
new file mode 100644
index 00000000..cee0a935
--- /dev/null
+++ b/server/changes/create_db_cmd
@@ -0,0 +1,3 @@
+ o Adds a new config parameter 'create_cmd', which allows sysadmin to specify
+ which command will create a database. That command was added in
+ pkg/create-user-db and debian package automates steps needed for sudo access.
diff --git a/server/pkg/create-user-db b/server/pkg/create-user-db
new file mode 100755
index 00000000..1a7e77a7
--- /dev/null
+++ b/server/pkg/create-user-db
@@ -0,0 +1,59 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+# create-user-db
+# Copyright (C) 2015 LEAP
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+import os
+import sys
+import netrc
+import argparse
+from leap.soledad.common.couch import CouchDatabase
+from leap.soledad.common.couch import is_db_name_valid
+
+
+description = """
+Creates a user database.
+This is meant to be used by Soledad Server.
+"""
+parser = argparse.ArgumentParser(description=description)
+parser.add_argument('dbname', metavar='user-d34db33f', type=str,
+ help='database name on the format user-{uuid4}')
+NETRC_PATH = '/etc/couchdb/couchdb-admin.netrc'
+
+
+def url_for_db(dbname):
+ if not os.path.exists(NETRC_PATH):
+ print ('netrc not found in %s' % NETRC_PATH)
+ sys.exit(1)
+ parsed_netrc = netrc.netrc(NETRC_PATH)
+ host, (login, _, password) = parsed_netrc.hosts.items()[0]
+ url = ('http://%(login)s:%(password)s@%(host)s:5984/%(dbname)s' % {
+ 'login': login,
+ 'password': password,
+ 'host': host,
+ 'dbname': dbname})
+ return url
+
+
+if __name__ == '__main__':
+ args = parser.parse_args()
+ if not is_db_name_valid(args.dbname):
+ print ("Invalid name! %s" % args.dbname)
+ sys.exit(1)
+ url = url_for_db(args.dbname)
+ db = CouchDatabase.open_database(url=url, create=True,
+ replica_uid=None, ensure_ddocs=True)
+ print ('success! Created %s, replica_uid: %s' %
+ (db._dbname, db.replica_uid))
diff --git a/server/src/leap/soledad/server/__init__.py b/server/src/leap/soledad/server/__init__.py
index 1b795016..bb1c6db0 100644
--- a/server/src/leap/soledad/server/__init__.py
+++ b/server/src/leap/soledad/server/__init__.py
@@ -285,6 +285,7 @@ def load_configuration(file_path):
"""
conf = {
'couch_url': 'http://localhost:5984',
+ 'create_cmd': None
}
config = configparser.ConfigParser()
config.read(file_path)
@@ -303,7 +304,7 @@ def load_configuration(file_path):
def application(environ, start_response):
conf = load_configuration('/etc/leap/soledad-server.conf')
- state = CouchServerState(conf['couch_url'])
+ state = CouchServerState(conf['couch_url'], create_cmd=conf['create_cmd'])
# WSGI application that may be used by `twistd -web`
application = GzipMiddleware(
SoledadTokenAuthMiddleware(SoledadApp(state)))