Repository: qpid-dispatch Updated Branches: refs/heads/crolke-DISPATCH-188-1 e621de2a1 -> 9d1dcb112
Add policy engine and a test policy Project: http://git-wip-us.apache.org/repos/asf/qpid-dispatch/repo Commit: http://git-wip-us.apache.org/repos/asf/qpid-dispatch/commit/9d1dcb11 Tree: http://git-wip-us.apache.org/repos/asf/qpid-dispatch/tree/9d1dcb11 Diff: http://git-wip-us.apache.org/repos/asf/qpid-dispatch/diff/9d1dcb11 Branch: refs/heads/crolke-DISPATCH-188-1 Commit: 9d1dcb1120c693c16440f50183641b12a2502669 Parents: e621de2 Author: Chuck Rolke <[email protected]> Authored: Mon Nov 30 18:10:10 2015 -0500 Committer: Chuck Rolke <[email protected]> Committed: Mon Nov 30 18:10:10 2015 -0500 ---------------------------------------------------------------------- .../qpid_dispatch_internal/management/policy.py | 363 +++++++++++++++++++ tests/policy-1/policy-photoserver.conf | 148 ++++++++ 2 files changed, 511 insertions(+) ---------------------------------------------------------------------- http://git-wip-us.apache.org/repos/asf/qpid-dispatch/blob/9d1dcb11/python/qpid_dispatch_internal/management/policy.py ---------------------------------------------------------------------- diff --git a/python/qpid_dispatch_internal/management/policy.py b/python/qpid_dispatch_internal/management/policy.py new file mode 100644 index 0000000..7009dd4 --- /dev/null +++ b/python/qpid_dispatch_internal/management/policy.py @@ -0,0 +1,363 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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 +# + +""" +Utilities for command-line programs. +""" + +import sys, optparse, os +import ConfigParser +from collections import Sequence, Mapping +from qpid_dispatch_site import VERSION +import pdb #; pdb.set_trace() +#from traceback import format_exc +import ast + + + +"""Entity implementing the business logic of user connection/access policy. + +Reading configuration files is treated as a set of CREATE operations. + +Provides interfaces for per-listener policy lookup: + +- Listener accept +- AMQP Open +""" + +class PolicyError(Exception): + def __init__(self, value): + self.value = value + def __str__(self): + return repr(self.value) + + +class PolicyValidator(): + """ + Validate incoming configuration for legal schema. + - Warn about section options that go unused. + - Disallow negative max connection numbers. + - Check that connectionOrigins resolve to IP hosts + """ + schema_version = 1 + + schema_allowed_options = [(), ( + 'connectionAllowUnrestricted', + 'connectionOrigins', + 'connectionPolicy', + 'maximumConnections', + 'maximumConnectionsPerHost', + 'maximumConnectionsPerUser', + 'policies', + 'policyVersion', + 'roles', + 'schemaVersion') + ] + schema_disallowed_options = [(), + () + ] + + allowed_opts = () + disallowed_opts = () + validator = None + + def __init__(self, schema_version=1): + """ + Create a validator for the given schema version. + @param[in] schema_version version selector + """ + if schema_version != 1: + raise PolicyError( + "Illegal policy schema version %s. Must be '1'." % schema_version) + self.schema_version = schema_version + self.allowed_opts = self.schema_allowed_options[schema_version] + self.disallowed_opts = self.schema_disallowed_options[schema_version] + self.validator = self.validate_v1 + + + def validateNumber(self, val, v_min, v_max, errors): + """ + Range check a numeric int policy value + @param[in] val policy value to check + @param[in] v_min minumum value + @param[in] v_max maximum value. zero disables check + @param[out] errors failure message + @return v_min <= val <= v_max + """ + error = "" + try: + v_int = int(val) + except Exception, e: + errors.append("Value '%s' does not resolve to an integer." % val) + return False + if v_int < v_min: + errors.append("Value '%s' is below minimum '%s'." % (val, v_min)) + return False + if v_max > 0 and v_int > v_max: + errors.append("Value '%s' is above maximum '%s'." % (val, v_max)) + return False + return True + + + def validate_v1(self, name, policy_in, policy_out, warnings, errors): + """ + Validate a schema. + @param[in] name - application name + @param[in] policy_in - section from ConfigParser as a list of tuples + @param[out] policy_out - validated policy as nested map + @param[out] warnings - nonfatal irregularities observed + @param[out] errors - descriptions of failure + @return - policy is usable + """ + cerror = [] + # validate the options + for (key, val) in policy_in: + if key not in self.allowed_opts: + warnings.append("Application '%s' option '%s' is ignored." % + (name, key)) + if key in self.disallowed_opts: + errors.append("Application '%s' option '%s' is disallowed." % + (name, key)) + return False + if key == "schemaVersion": + if not int(self.schema_version) == int(val): + errors.append("Application '%s' expected schema version '%s' but is '%s'." % + (name, self.schema_version, val)) + return False + policy_out[key] = val + if key == "policyVersion": + if not self.validateNumber(val, 0, 0, cerror): + errors.append("Application '%s' option '%s' must resolve to a positive integer: '%s'." % + (name, key, cerror[0])) + return False + policy_out[key] = val + elif key in ['maximumConnections', + 'maximumConnectionsPerHost', + 'maximumConnectionsPerUser' + ]: + if not self.validateNumber(val, 0, 65535, cerror): + msg = ("Application '%s' option '%s' has error '%s'." % + (name, key, cerror[0])) + errors.append(msg) + return False + policy_out[key] = val + elif key in ['connectionOrigins', + 'connectionPolicy', + 'policies', + 'roles' + ]: + try: + submap = ast.literal_eval(val) + if not type(submap) is dict: + errors.append("Application '%s' option '%s' must be of type 'dict' but is '%s'" % + (name, key, type(submap))) + return False + if key == "policies": + for pname in submap: + for setting in submap[pname]: + sval = submap[pname][setting] + if setting in ['max_frame_size', + 'max_message_size', + 'max_receivers', + 'max_senders', + 'max_session_window', + 'max_sessions' + ]: + if not self.validateNumber(sval, 0, 0, cerror): + errors.append("Application '%s' option '%s' policy '%s' setting '%s' has error '%s'." % + (name, key, pname, setting, cerror[0])) + return False + elif setting in ['allow_anonymous_sender', + 'allow_dynamic_src' + ]: + if not type(sval) is bool: + errors.append("Application '%s' option '%s' policy '%s' setting '%s' has illegal boolean value '%s'." % + (name, key, pname, setting, sval)) + return False + elif setting in ['sources', + 'targets' + ]: + if not type(sval) is list: + errors.append("Application '%s' option '%s' policy '%s' setting '%s' must be type 'list' but is '%s'." % + (name, key, pname, setting, type(sval))) + return False + else: + warnings.append("Application '%s' option '%s' policy '%s' setting '%s' is ignored." % + (name, key, pname, setting)) + policy_out[key] = submap + except Exception, e: + errors.append("Application '%s' option '%s' error processing %s map: %s" % + (name, key, e)) + return False + return True + + +class Policy(): + """The policy database.""" + + data = {} + folder = "." + schema_version = 1 + validator = None + + def __init__(self, folder=".", schema_version=1): + """ + Create instance + @params folder: relative path from __file__ to conf file folder + """ + self.folder = folder + self.schema_version = schema_version + self.validator = PolicyValidator(schema_version) + self.policy_io_read_files() + + # + # Policy file I/O + # + def policy_io_read_files(self): + """ + Read all conf files and create the policies they contain. + """ + apath = os.path.abspath(os.path.dirname(__file__)) + apath = os.path.join(apath, self.folder) + for i in os.listdir(apath): + if i.endswith(".conf"): + self.policy_io_read_file(os.path.join(apath, i)) + + def policy_io_read_file(self, fn): + """ + Read a single policy config file. + A file may hold multiple policies in separate ConfigParser sections. + All policies validated before any are committed. + Create each policy in db. + @param fn: absolute path to file + """ + try: + cp = ConfigParser.ConfigParser() + cp.optionxform = str + cp.read(fn) + + except Exception, e: + raise PolicyError( + "Error processing policy configuration file '%s' : %s" % (fn, e)) + newpolicies = {} + for policy in cp.sections(): + warnings = [] + diag = [] + candidate = {} + if not self.validator.validator(policy, cp.items(policy), candidate, warnings, diag): + msg = "Policy file '%s' is invalid: %s" % (fn, diag[0]) + raise PolicyError( msg ) + if len(warnings) > 0: + print ("LogMe: Policy file '%s' application '%s' has warnings: %s" % + (fn, policy, warnings)) + newpolicies[policy] = candidate + for newpol in newpolicies: + self.data[newpol] = newpolicies[newpol] + + # + # CRUD interface + # + def policy_create(self, name, policy, validate=True): + """ + Create named policy + @param name: policy name + @param policy: policy data + """ + warnings = [] + diag = [] + candidate = {} + result = self.validator.validator(name, policy, candidate, warnings, diag) + if validate and not result: + raise PolicyError( "Policy '%s' is invalid: %s" % (name, diag[0]) ) + if len(warnings) > 0: + print ("LogMe: Application '%s' has warnings: %s" % + (name, warnings)) + self.data[name] = candidate + + def policy_read(self, name): + """Read named policy""" + return self.data[name] + + def policy_update(self, name, policy): + """Update named policy""" + pass + + def policy_delete(self, name): + """Delete named policy""" + del self.data[name] + + # + # db enumerator + # + def policy_db_get_names(self): + """Return a list of policy names.""" + return self.data.keys() + + +# +# HACK ALERT: Temporary +# Functions related to main +# +class ExitStatus(Exception): + """Raised if a command wants a non-0 exit status from the script""" + def __init__(self, status): self.status = status + +def main_except(argv): + + usage = "usage: %prog [options]\nRead and print all conf files in a folder." + parser = optparse.OptionParser(usage=usage) + parser.set_defaults(folder="../../../tests/policy-1") + parser.add_option("-f", "--folder", action="store", type="string", dest="folder", + help="Use named folder instead of policy-1") + parser.add_option("-d", "--dump", action="store_true", dest="dump", + help="Dump policy details") + + (options, args) = parser.parse_args() + + policy = Policy(options.folder) + + print("policy names: %s" % policy.policy_db_get_names()) + + if options.dump: + print("Policy details:") + for pname in policy.policy_db_get_names(): + print("policy : %s" % pname) + p = ("%s" % policy.policy_read(pname)) + print(p.replace('\\n', '\n')) + + newpolicy = [('versionId', 3), ('maximumConnections', '20')] + policy.policy_create('test', newpolicy) + + print("policy names with test: %s" % policy.policy_db_get_names()) + + print("policy test data:") + print(policy.policy_read('test')) + +def main(argv): + try: + main_except(argv) + return 0 + except ExitStatus, e: + return e.status + except Exception, e: + print "%s: %s"%(type(e).__name__, e) + return 1 + +if __name__ == "__main__": + sys.exit(main(sys.argv)) http://git-wip-us.apache.org/repos/asf/qpid-dispatch/blob/9d1dcb11/tests/policy-1/policy-photoserver.conf ---------------------------------------------------------------------- diff --git a/tests/policy-1/policy-photoserver.conf b/tests/policy-1/policy-photoserver.conf new file mode 100644 index 0000000..621d417 --- /dev/null +++ b/tests/policy-1/policy-photoserver.conf @@ -0,0 +1,148 @@ +## +## Licensed to the Apache Software Foundation (ASF) under one +## or more contributor license agreements. See the NOTICE file +## distributed with this work for additional information +## regarding copyright ownership. The ASF licenses this file +## to you 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 +## + +# Definitions for photoserver application +[photoserver] + +# policy schema used in this conf file +schemaVersion : 1 + +# a version number to resolve multiple instances of this policy +policyVersion : 1 + +# Aggregate connection limits +maximumConnections : 10 +maximumConnectionsPerUser : 5 +maximumConnectionsPerHost : 5 + +# roles is a map. +# key = role name +# value = list of authid names assigned to the role +roles: { + 'anonymous' : ['anonymous'], + 'users' : ['u1', 'u2'], + 'paidsubscribers' : ['p1', 'p2'], + 'test' : ['zeke', 'ynot'], + 'admin' : ['alice', 'bob', 'ellen'], + 'superuser' : ['ellen'] + } + +# connectionOrigins is a map. +# key = origin name +# value = list of host addresses or host address ranges +connectionOrigins: { + 'Ten18': ['10.18.0.0,10.18.255.255'], + 'EllensWS': ['72.135.2.9'], + 'TheLabs': ['10.48.0.0,10.48.255.255','192.168.100.0,192.168.100.255'], + 'Localhost': ['127.0.0.1','::1'], + } + +# connectionPolicy is a map. +# key = role name +# value = list of connection origin names +connectionPolicy: { + 'admin' : ['Ten18', 'TheLabs', 'Localhost'], + 'test' : ['TheLabs'], + 'superuser' : ['Localhost', 'EllensWS'] + } + +# connectionAllowUnrestricted - If a user is not restricted by a connectionPolicy +# then is this user allowed to connect? +connectionAllowUnrestricted : True + +# policy is a map. +# key = role name or authid name +# value = policy containing: +# - values passed in AMQP Open and Attach performatives +# - allowed source and target names in AMQP Attach +# +policies: { + 'anonymous' : { + 'max_frame_size' : 111111, + 'max_message_size' : 111111, + 'max_session_window' : 111111, + 'max_sessions' : 1, + 'max_senders' : 11, + 'max_receivers' : 11, + 'allow_dynamic_src' : False, + 'allow_anonymous_sender' : False, + 'sources' : ['public'], + 'targets' : [] + }, + 'users' : { + 'max_frame_size' : 222222, + 'max_message_size' : 222222, + 'max_session_window' : 222222, + 'max_sessions' : 2, + 'max_senders' : 22, + 'max_receivers' : 22, + 'allow_dynamic_src' : False, + 'allow_anonymous_sender' : False, + 'sources' : ['public', 'private'], + 'targets' : ['public'] + }, + 'paidsubscribers' : { + 'max_frame_size' : 333333, + 'max_message_size' : 333333, + 'max_session_window' : 333333, + 'max_sessions' : 3, + 'max_senders' : 33, + 'max_receivers' : 33, + 'allow_dynamic_src' : True, + 'allow_anonymous_sender' : False, + 'sources' : ['public', 'private'], + 'targets' : ['public', 'private'] + }, + 'test' : { + 'max_frame_size' : 444444, + 'max_message_size' : 444444, + 'max_session_window' : 444444, + 'max_sessions' : 4, + 'max_senders' : 44, + 'max_receivers' : 44, + 'allow_dynamic_src' : True, + 'allow_anonymous_sender' : True, + 'sources' : ['private'], + 'targets' : ['private'] + }, + 'admin' : { + 'max_frame_size' : 555555, + 'max_message_size' : 555555, + 'max_session_window' : 555555, + 'max_sessions' : 5, + 'max_senders' : 55, + 'max_receivers' : 55, + 'allow_dynamic_src' : True, + 'allow_anonymous_sender' : True, + 'sources' : ['public', 'private', 'management'], + 'targets' : ['public', 'private', 'management'] + }, + 'superuser' : { + 'max_frame_size' : 666666, + 'max_message_size' : 666666, + 'max_session_window' : 666666, + 'max_sessions' : 6, + 'max_senders' : 66, + 'max_receivers' : 66, + 'allow_dynamic_src' : False, + 'allow_anonymous_sender' : False, + 'sources' : ['public', 'private', 'management', 'root'], + 'targets' : ['public', 'private', 'management', 'root'] + } + } --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
