diff options
author | Jenkins <jenkins@review.openstack.org> | 2014-04-29 16:03:12 +0000 |
---|---|---|
committer | Gerrit Code Review <review@openstack.org> | 2014-04-29 16:03:12 +0000 |
commit | 21243f5dc9d8d29c26f06db358c782364af95a06 (patch) | |
tree | 9afc9c499686401d8ea32ca7954a139e43aabc41 | |
parent | 5e5351edd3c923c42e22efa01f4df2d1a26c3256 (diff) | |
parent | 27e5d6366128cecb97e8c88a0fac89eb47b35eb8 (diff) | |
download | gear-21243f5dc9d8d29c26f06db358c782364af95a06.tar.gz |
Merge "Add access control"
-rw-r--r-- | doc/source/index.rst | 86 | ||||
-rw-r--r-- | gear/__init__.py | 171 | ||||
-rw-r--r-- | gear/acl.py | 289 | ||||
-rw-r--r-- | gear/cmd/geard.py | 26 | ||||
-rw-r--r-- | gear/tests/test_gear.py | 56 |
5 files changed, 620 insertions, 8 deletions
diff --git a/doc/source/index.rst b/doc/source/index.rst index 5b62114..c8fa5ec 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -158,6 +158,25 @@ Server Usage ------------ .. program-output:: geard --help +The syntax of the optional ACL file consists of a number of sections +identified by the SSL certificate Common Name Subject, and the +arguments to the :py:class:`ACLEntry` constructor as key-value pairs:: + + [<subject>] + register=<regex> + invoke=<regex> + grant=<boolean> + +For example:: + + [my_worker] + register=.* + + [my_client] + invoke=.* + + [my_node_manager] + grant=True Server Objects ^^^^^^^^^^^^^^ @@ -166,6 +185,73 @@ Server Objects :inherited-members: +Access Control +-------------- + +The gear server supports authorization via access control lists. When +an :py:class:`ACL` object is supplied to the server (or a file on the +command line), gear changes from the normal Gearman mode of +allow-by-default to deny-by-default and only clients with ACL entries +will be able to perform actions such as registering functions or +submitting jobs. Authorization is based on the SSL certificate Common +Name Subject associated with the connection. An :py:class:`ACL` +object may be modified programatically at run-time. + +The administrative protocol supports modifying ACLs with the following +commands: + +**acl list** + List the current acls:: + + acl list + client register=None invoke=.* grant=True + worker register=.* invoke=None grant=True + . + +**acl grant <verb> <subject> <pattern>** + Grant the `<verb>` action for functions matching `<pattern>` to + `<subject>`. Verbs can be one of ``register``, ``invoke``, or + ``grant``. This requires the current connection have the grant + permission. Example:: + + acl grant register worker .* + OK + +**acl revoke <verb> <subject>** + Revoke the `<verb>` action from `<subject>`. Verbs can be one of + ``register``, ``invoke``, ``grant``, or ``all`` to indicate all + permissions for the subject should be revoked. This requires the + grant permission, except that a subject may always revoke its own + permissions. Example:: + + acl revoke register worker + OK + +**acl self-revoke <verb>** + Revoke the `<verb>` action from connections associted with the + current certificate subject. Verbs can be one of ``register``, + ``invoke``, ``grant``, or ``all`` to indicate all permissions for + the subject should be revoked. This is similar to ``acl revoke`` + but is a convenience method so that a subject does not need to know + its own common name. A subject always has permission to revoke its + own permissions. Example:: + + acl self-revoke register + OK + +ACL Objects +^^^^^^^^^^^ +.. autoclass:: gear.ACL + :members: + :inherited-members: + +ACLEntry Objects +^^^^^^^^^^^^^^^^ +.. autoclass:: gear.ACLEntry + :members: + :inherited-members: + + Common ------ diff --git a/gear/__init__.py b/gear/__init__.py index e26d698..dcdb15b 100644 --- a/gear/__init__.py +++ b/gear/__init__.py @@ -1,4 +1,4 @@ -# Copyright 2013 OpenStack Foundation +# Copyright 2013-2014 OpenStack Foundation # # Licensed under the Apache License, Version 2.0 (the "License"); you may # not use this file except in compliance with the License. You may obtain @@ -23,6 +23,7 @@ import time import uuid as uuid_module from gear import constants +from gear.acl import ACLError, ACLEntry, ACL # noqa try: import Queue as queue @@ -1292,10 +1293,14 @@ class Client(BaseClient): else: raise ConfigurationError("Invalid precedence value") packet = Packet(constants.REQ, cmd, data) + attempted_connections = set() while True: + if attempted_connections == set(self.active_connections): + break conn = self.getConnection() task = SubmitJobTask(job) conn.pending_tasks.append(task) + attempted_connections.add(conn) try: self.sendPacket(packet, conn) except Exception: @@ -2132,6 +2137,12 @@ class ServerConnection(Connection): self.client_id = None self.functions = set() self.related_jobs = {} + self.ssl_subject = None + if self.use_ssl: + for x in conn.getpeercert()['subject']: + if x[0][0] == 'commonName': + self.ssl_subject = x[0][1] + self.log.debug("SSL subject: %s" % self.ssl_subject) self.changeState("INIT") def _getAdminRequest(self): @@ -2161,11 +2172,13 @@ class Server(BaseClientServer): :arg str client_id: The ID associated with this server. It will be appending to the name of the logger (e.g., gear.Server.server_id). Defaults to 'unknown'. + :arg ACL acl: An :py:class:`ACL` object if the server should apply + access control rules to its connections. """ def __init__(self, port=4730, ssl_key=None, ssl_cert=None, ssl_ca=None, statsd_host=None, statsd_port=8125, statsd_prefix=None, - server_id='unknown'): + server_id='unknown', acl=None): self.port = port self.ssl_key = ssl_key self.ssl_cert = ssl_cert @@ -2176,6 +2189,7 @@ class Server(BaseClientServer): self.jobs = {} self.functions = set() self.max_handle = 0 + self.acl = acl self.connect_wake_read, self.connect_wake_write = os.pipe() self.use_ssl = False @@ -2322,6 +2336,29 @@ class Server(BaseClientServer): self.handleStatus(request) elif request.command.startswith(b'workers'): self.handleWorkers(request) + elif request.command.startswith(b'acl list'): + self.handleACLList(request) + elif request.command.startswith(b'acl grant'): + self.handleACLGrant(request) + elif request.command.startswith(b'acl revoke'): + self.handleACLRevoke(request) + elif request.command.startswith(b'acl self-revoke'): + self.handleACLSelfRevoke(request) + + def _cancelJob(self, request, job, queue): + if self.acl: + if not self.acl.canInvoke(request.connection.ssl_subject, + job.name): + self.log.info("Rejecting cancel job from %s for %s " + "due to ACL" % + (request.connection.ssl_subject, job.name)) + request.connection.sendRaw(b'ERR PERMISSION_DENIED\n') + return + queue.remove(job) + del self.jobs[job.handle] + self._updateStats() + request.connection.sendRaw(b'OK\n') + return def handleCancelJob(self, request): words = request.command.split() @@ -2331,13 +2368,111 @@ class Server(BaseClientServer): for queue in [self.high_queue, self.normal_queue, self.low_queue]: for job in queue: if handle == job.handle: - queue.remove(job) - del self.jobs[handle] - self._updateStats() - request.connection.sendRaw(b'OK\n') - return + return self._cancelJob(request, job, queue) request.connection.sendRaw(b'ERR UNKNOWN_JOB\n') + def handleACLList(self, request): + if self.acl is None: + request.connection.sendRaw(b'ERR ACL_DISABLED\n') + return + for entry in self.acl.getEntries(): + l = "%s\tregister=%s\tinvoke=%s\tgrant=%s\n" % ( + entry.subject, entry.register, entry.invoke, entry.grant) + request.connection.sendRaw(l.encode('utf8')) + request.connection.sendRaw(b'.\n') + + def handleACLGrant(self, request): + # acl grant register worker .* + words = request.command.split(None, 4) + verb = words[2] + subject = words[3] + + if self.acl is None: + request.connection.sendRaw(b'ERR ACL_DISABLED\n') + return + if not self.acl.canGrant(request.connection.ssl_subject): + request.connection.sendRaw(b'ERR PERMISSION_DENIED\n') + return + try: + if verb == 'invoke': + self.acl.grantInvoke(subject, words[4]) + elif verb == 'register': + self.acl.grantRegister(subject, words[4]) + elif verb == 'grant': + self.acl.grantGrant(subject) + else: + request.connection.sendRaw(b'ERR UNKNOWN_ACL_VERB\n') + return + except ACLError, e: + self.log.info("Error in grant command: %s" % (e.message,)) + request.connection.sendRaw(b'ERR UNABLE %s\n' % (e.message,)) + return + request.connection.sendRaw(b'OK\n') + + def handleACLRevoke(self, request): + # acl revoke register worker + words = request.command.split() + verb = words[2] + subject = words[3] + + if self.acl is None: + request.connection.sendRaw(b'ERR ACL_DISABLED\n') + return + if subject != request.connection.ssl_subject: + if not self.acl.canGrant(request.connection.ssl_subject): + request.connection.sendRaw(b'ERR PERMISSION_DENIED\n') + return + try: + if verb == 'invoke': + self.acl.revokeInvoke(subject) + elif verb == 'register': + self.acl.revokeRegister(subject) + elif verb == 'grant': + self.acl.revokeGrant(subject) + elif verb == 'all': + try: + self.acl.remove(subject) + except ACLError: + pass + else: + request.connection.sendRaw(b'ERR UNKNOWN_ACL_VERB\n') + return + except ACLError, e: + self.log.info("Error in revoke command: %s" % (e.message,)) + request.connection.sendRaw(b'ERR UNABLE %s\n' % (e.message,)) + return + request.connection.sendRaw(b'OK\n') + + def handleACLSelfRevoke(self, request): + # acl self-revoke register + words = request.command.split() + verb = words[2] + + if self.acl is None: + request.connection.sendRaw(b'ERR ACL_DISABLED\n') + return + subject = request.connection.ssl_subject + try: + if verb == 'invoke': + self.acl.revokeInvoke(subject) + elif verb == 'register': + self.acl.revokeRegister(subject) + elif verb == 'grant': + self.acl.revokeGrant(subject) + elif verb == 'all': + try: + self.acl.remove(subject) + except ACLError: + pass + else: + request.connection.sendRaw(b'ERR UNKNOWN_ACL_VERB\n') + return + except ACLError, e: + self.log.info("Error in self-revoke command: %s" % (e.message,)) + request.connection.sendRaw(b'ERR UNABLE %s\n' % (e.message,)) + return + request.connection.sendRaw(b'OK\n') + def _getFunctionStats(self): functions = {} for function in self.functions: @@ -2442,6 +2577,14 @@ class Server(BaseClientServer): if not unique: unique = None arguments = packet.getArgument(2, True) + if self.acl: + if not self.acl.canInvoke(packet.connection.ssl_subject, name): + self.log.info("Rejecting SUBMIT_JOB from %s for %s " + "due to ACL" % + (packet.connection.ssl_subject, name)) + self.sendError(packet.connection, 0, + 'Permission denied by ACL') + return self.max_handle += 1 handle = ('H:%s:%s' % (packet.connection.host, self.max_handle)).encode('utf8') @@ -2546,8 +2689,22 @@ class Server(BaseClientServer): name = packet.getArgument(0) packet.connection.client_id = name + def sendError(self, connection, code, text): + data = (str(code).encode('utf8') + b'\x00' + + str(text).encode('utf8') + b'\x00') + p = Packet(constants.RES, constants.ERROR, data) + connection.sendPacket(p) + def handleCanDo(self, packet): name = packet.getArgument(0) + if self.acl: + if not self.acl.canRegister(packet.connection.ssl_subject, name): + self.log.info("Ignoring CAN_DO from %s for %s due to ACL" % + (packet.connection.ssl_subject, name)) + # CAN_DO normally does not merit a response so it is + # not clear that it is appropriate to send an ERROR + # response at this point. + return self.log.debug("Adding function %s to %s" % (name, packet.connection)) packet.connection.functions.add(name) self.functions.add(name) diff --git a/gear/acl.py b/gear/acl.py new file mode 100644 index 0000000..ea79a67 --- /dev/null +++ b/gear/acl.py @@ -0,0 +1,289 @@ +# Copyright 2014 OpenStack Foundation +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import re + + +class ACLError(Exception): + pass + + +class ACLEntry(object): + """An access control list entry. + + :arg str subject: The SSL certificate Subject Common Name to which + the entry applies. + + :arg str register: A regular expression that matches the jobs that + connections with this certificate are permitted to register. + + :arg str invoke: A regular expression that matches the jobs that + connections with this certificate are permitted to invoke. + Also implies the permission to cancel the same set of jobs in + the queue. + + :arg boolean grant: A flag indicating whether connections with + this certificate are permitted to grant access to other + connections. Also implies the permission to revoke access + from other connections. The ability to self-revoke access is + always implied. + """ + + def __init__(self, subject, register=None, invoke=None, grant=False): + self.subject = subject + self.setRegister(register) + self.setInvoke(invoke) + self.setGrant(grant) + + def __repr__(self): + return ('<ACLEntry for %s register=%s invoke=%s grant=%s>' % + (self.subject, self.register, self.invoke, self.grant)) + + def isEmpty(self): + """Checks whether this entry grants any permissions at all. + + :returns: False if any permission is granted, otherwise True. + """ + if (self.register is None and + self.invoke is None and + self.grant is False): + return True + return False + + def canRegister(self, name): + """Check whether this subject is permitted to register a function. + + :arg str name: The function name to check. + :returns: A boolean indicating whether the action should be permitted. + """ + if self.register is None: + return False + if not self._register.match(name): + return False + return True + + def canInvoke(self, name): + """Check whether this subject is permitted to register a function. + + :arg str name: The function name to check. + :returns: A boolean indicating whether the action should be permitted. + """ + if self.invoke is None: + return False + if not self._invoke.match(name): + return False + return True + + def setRegister(self, register): + """Sets the functions that this subject can register. + + :arg str register: A regular expression that matches the jobs that + connections with this certificate are permitted to register. + """ + self.register = register + if register: + try: + self._register = re.compile(register) + except re.error, e: + raise ACLError('Regular expression error: %s' % (e.message,)) + else: + self._register = None + + def setInvoke(self, invoke): + """Sets the functions that this subject can invoke. + + :arg str invoke: A regular expression that matches the jobs that + connections with this certificate are permitted to invoke. + """ + self.invoke = invoke + if invoke: + try: + self._invoke = re.compile(invoke) + except re.error, e: + raise ACLError('Regular expression error: %s' % (e.message,)) + else: + self._invoke = None + + def setGrant(self, grant): + """Sets whether this subject can grant ACLs to others. + + :arg boolean grant: A flag indicating whether connections with + this certificate are permitted to grant access to other + connections. Also implies the permission to revoke access + from other connections. The ability to self-revoke access is + always implied. + """ + self.grant = grant + + +class ACL(object): + """An access control list. + + ACLs are deny-by-default. The checked actions are only allowed if + there is an explicit rule in the ACL granting permission for a + given client (identified by SSL certificate Common Name Subject) + to perform that action. + """ + + def __init__(self): + self.subjects = {} + + def add(self, entry): + """Add an ACL entry. + + :arg Entry entry: The :py:class:`ACLEntry` to add. + :raises ACLError: If there is already an entry for the subject. + """ + if entry.subject in self.subjects: + raise ACLError("An ACL entry for %s already exists" % + (entry.subject,)) + self.subjects[entry.subject] = entry + + def remove(self, subject): + """Remove an ACL entry. + + :arg str subject: The SSL certificate Subject Common Name to + remove from the ACL. + :raises ACLError: If there is no entry for the subject. + """ + if subject not in self.subjects: + raise ACLError("There is no ACL entry for %s" % (subject,)) + del self.subjects[subject] + + def getEntries(self): + """Return a list of current ACL entries. + + :returns: A list of :py:class:`ACLEntry` objects. + """ + items = self.subjects.items() + items.sort(lambda a, b: cmp(a[0], b[0])) + return [x[1] for x in items] + + def canRegister(self, subject, name): + """Check whether a subject is permitted to register a function. + + :arg str subject: The SSL certificate Subject Common Name to + check against. + :arg str name: The function name to check. + :returns: A boolean indicating whether the action should be permitted. + """ + entry = self.subjects.get(subject) + if entry is None: + return False + return entry.canRegister(name) + + def canInvoke(self, subject, name): + """Check whether a subject is permitted to invoke a function. + + :arg str subject: The SSL certificate Subject Common Name to + check against. + :arg str name: The function name to check. + :returns: A boolean indicating whether the action should be permitted. + """ + entry = self.subjects.get(subject) + if entry is None: + return False + return entry.canInvoke(name) + + def canGrant(self, subject): + """Check whether a subject is permitted to grant access to others. + + :arg str subject: The SSL certificate Subject Common Name to + check against. + :returns: A boolean indicating whether the action should be permitted. + """ + entry = self.subjects.get(subject) + if entry is None: + return False + if not entry.grant: + return False + return True + + def grantInvoke(self, subject, invoke): + """Grant permission to invoke certain functions. + + :arg str subject: The SSL certificate Subject Common Name to which + the entry applies. + :arg str invoke: A regular expression that matches the jobs + that connections with this certificate are permitted to + invoke. Also implies the permission to cancel the same + set of jobs in the queue. + """ + e = self.subjects.get(subject) + if not e: + e = ACLEntry(subject) + self.add(e) + e.setInvoke(invoke) + + def grantRegister(self, subject, register): + """Grant permission to register certain functions. + + :arg str subject: The SSL certificate Subject Common Name to which + the entry applies. + :arg str register: A regular expression that matches the jobs that + connections with this certificate are permitted to register. + """ + e = self.subjects.get(subject) + if not e: + e = ACLEntry(subject) + self.add(e) + e.setRegister(register) + + def grantGrant(self, subject): + """Grant permission to grant permissions to other connections. + + :arg str subject: The SSL certificate Subject Common Name to which + the entry applies. + """ + e = self.subjects.get(subject) + if not e: + e = ACLEntry(subject) + self.add(e) + e.setGrant(True) + + def revokeInvoke(self, subject): + """Revoke permission to invoke all functions. + + :arg str subject: The SSL certificate Subject Common Name to which + the entry applies. + """ + e = self.subjects.get(subject) + if e: + e.setInvoke(None) + if e.isEmpty(): + self.remove(subject) + + def revokeRegister(self, subject): + """Revoke permission to register all functions. + + :arg str subject: The SSL certificate Subject Common Name to which + the entry applies. + """ + e = self.subjects.get(subject) + if e: + e.setRegister(None) + if e.isEmpty(): + self.remove(subject) + + def revokeGrant(self, subject): + """Revoke permission to grant permissions to other connections. + + :arg str subject: The SSL certificate Subject Common Name to which + the entry applies. + """ + e = self.subjects.get(subject) + if e: + e.setGrant(False) + if e.isEmpty(): + self.remove(subject) diff --git a/gear/cmd/geard.py b/gear/cmd/geard.py index fa33216..227f1cd 100644 --- a/gear/cmd/geard.py +++ b/gear/cmd/geard.py @@ -14,6 +14,7 @@ # under the License. import argparse +import ConfigParser import daemon import extras import gear @@ -56,6 +57,8 @@ support. help='path to SSL public certificate') parser.add_argument('--ssl-key', dest='ssl_key', metavar='PATH', help='path to SSL private key') + parser.add_argument('--acl', dest='acl', metavar='PATH', + help='path to ACL file') parser.add_argument('--version', dest='version', action='store_true', help='show version') self.args = parser.parse_args() @@ -78,13 +81,34 @@ support. statsd_host = os.environ.get('STATSD_HOST') statsd_port = int(os.environ.get('STATSD_PORT', 8125)) statsd_prefix = os.environ.get('STATSD_PREFIX') + acl = None + if self.args.acl: + aclf = ConfigParser.RawConfigParser() + aclf.read(self.args.acl) + acl = gear.ACL() + for section in aclf.sections(): + if aclf.has_option(section, 'register'): + register = aclf.get(section, 'register') + else: + register = None + if aclf.has_option(section, 'invoke'): + invoke = aclf.get(section, 'invoke') + else: + invoke = None + if aclf.has_option(section, 'grant'): + grant = aclf.getboolean(section, 'grant') + else: + grant = None + entry = gear.ACLEntry(section, register, invoke, grant) + acl.add(entry) self.server = gear.Server(self.args.port, self.args.ssl_key, self.args.ssl_cert, self.args.ssl_ca, statsd_host, statsd_port, - statsd_prefix) + statsd_prefix, + acl=acl) signal.pause() diff --git a/gear/tests/test_gear.py b/gear/tests/test_gear.py index d56d923..31e0d9d 100644 --- a/gear/tests/test_gear.py +++ b/gear/tests/test_gear.py @@ -69,6 +69,62 @@ class TestClient(tests.BaseTestCase): self.assertTrue(job.known) self.assertFalse(job.running) + def test_ACL(self): + acl = gear.ACL() + acl.add(gear.ACLEntry('worker', register='foo.*')) + acl.add(gear.ACLEntry('client', invoke='foo.*')) + acl.add(gear.ACLEntry('manager', grant=True)) + self.assertEqual(len(acl.getEntries()), 3) + + self.assertTrue(acl.canRegister('worker', 'foo-bar')) + self.assertTrue(acl.canRegister('worker', 'foo')) + self.assertFalse(acl.canRegister('worker', 'bar-foo')) + self.assertFalse(acl.canRegister('worker', 'bar')) + self.assertFalse(acl.canInvoke('worker', 'foo')) + self.assertFalse(acl.canGrant('worker')) + + self.assertTrue(acl.canInvoke('client', 'foo-bar')) + self.assertTrue(acl.canInvoke('client', 'foo')) + self.assertFalse(acl.canInvoke('client', 'bar-foo')) + self.assertFalse(acl.canInvoke('client', 'bar')) + self.assertFalse(acl.canRegister('client', 'foo')) + self.assertFalse(acl.canGrant('client')) + + self.assertFalse(acl.canInvoke('manager', 'bar')) + self.assertFalse(acl.canRegister('manager', 'foo')) + self.assertTrue(acl.canGrant('manager')) + + acl.remove('worker') + acl.remove('client') + acl.remove('manager') + + self.assertFalse(acl.canRegister('worker', 'foo')) + self.assertFalse(acl.canInvoke('client', 'foo')) + self.assertFalse(acl.canGrant('manager')) + + self.assertEqual(len(acl.getEntries()), 0) + + def test_ACL_register(self): + acl = gear.ACL() + acl.grantRegister('worker', 'bar.*') + self.assertTrue(acl.canRegister('worker', 'bar')) + acl.revokeRegister('worker') + self.assertFalse(acl.canRegister('worker', 'bar')) + + def test_ACL_invoke(self): + acl = gear.ACL() + acl.grantInvoke('client', 'bar.*') + self.assertTrue(acl.canInvoke('client', 'bar')) + acl.revokeInvoke('client') + self.assertFalse(acl.canInvoke('client', 'bar')) + + def test_ACL_grant(self): + acl = gear.ACL() + acl.grantGrant('manager') + self.assertTrue(acl.canGrant('manager')) + acl.revokeGrant('manager') + self.assertFalse(acl.canGrant('manager')) + def load_tests(loader, in_tests, pattern): return testscenarios.load_tests_apply_scenarios(loader, in_tests, pattern) |