#!/usr/local/bin/python import os,sys from BaseHTTPServer import BaseHTTPRequestHandler from ZSI import * from M2Crypto import SSL from M2Crypto.m2xmlrpclib import SSL_Transport from M2Crypto.SSL.SSLServer import SSLServer import M2Crypto.httpslib import xmlrpclib import re import string import copy import pickle from threading import * from fedd_access_project import access_project from fedd_services import * from fedd_util import * from fedd_allocate_project import * import parse_detail from service_error import * import logging # Make log messages disappear if noone configures a fedd logger class nullHandler(logging.Handler): def emit(self, record): pass fl = logging.getLogger("fedd.access") fl.addHandler(nullHandler()) class fedd_access: """ The implementation of access control based on mapping users to projects. Users can be mapped to existing projects or have projects created dynamically. This implements both direct requests and proxies. """ class parse_error(RuntimeError): pass bool_attrs = ("dynamic_projects", "project_priority") emulab_attrs = ("boss", "ops", "domain", "fileserver", "eventserver") id_attrs = ("testbed", "proxy", "proxy_cert_file", "proxy_cert_pwd", "proxy_trusted_certs", "dynamic_projects_url", "dynamic_projects_cert_file", "dynamic_projects_cert_pwd", "dynamic_projects_trusted_certs") id_list_attrs = ("restricted",) proxy_request, proxy_xmlrpc_request = make_service_callers('RequestAccess', 'getfeddPortType', RequestAccessRequestMessage, 'RequestAccessRequestBody') def __init__(self, config=None): """ Initializer. Pulls parameters out of the ConfigParser's access section. """ # Make sure that the configuration is in place if config: if not config.has_section("access"): config.add_section("access") if not config.has_section("globals"): config.add_section("globals") else: raise RunTimeError("No config to fedd_access") # Create instance attributes from the static lists for a in fedd_access.bool_attrs: if config.has_option("access", a): setattr(self, a, config.get("access", a)) else: setattr(self, a, False) for a in fedd_access.emulab_attrs + fedd_access.id_attrs: if config.has_option("access", a): setattr(self, a, config.get("access",a)) else: setattr(self, a, None) self.attrs = { } self.access = { } self.restricted = [ ] self.fedid_category = { } self.projects = { } self.keys = { } self.allocation = { } self.state = { 'projects': self.projects, 'allocation' : self.allocation, 'keys' : self.keys } self.state_lock = Lock() self.fedid_default = "testbed" if config.has_option("access", "accessdb"): self.read_access(config.get("access", "accessdb")) if config.has_option("access", "trustdb"): self.read_trust(config.get("access", "trustdb")) self.state_filename = config.get("access", "access_state", "") self.log = logging.getLogger("fedd.access") set_log_level(config, "access", self.log) self.read_state() # Certs are promoted from the generic to the specific, so without a # specific proxy certificate, the main certificates are used for # proxy interactions. If no dynamic project certificates, then # proxy certs are used, and if none of those the main certs. if config.has_option("globals", "proxy_cert_file"): if not self.dynamic_projects_cert_file: self.dynamic_projects_cert_file = \ config.get("globals", "proxy_cert_file") if config.has_option("globals", "porxy_cert_pwd"): self.dynamic_projects_cert_pwd = \ config.get("globals", "proxy_cert_pwd") if config.has_option("globals", "proxy_trusted_certs"): if not self.dynamic_projects_trusted_certs: self.dynamic_projects_trusted_certs =\ config.get("globals", proxy_trusted_certs) if config.has_option("globals", "cert_file"): has_pwd = config.has_option("globals", "cert_pwd") if not self.dynamic_projects_cert_file: self.dynamic_projects_cert_file = \ config.get("globals", "cert_file") if has_pwd: self.dynamic_projects_cert_pwd = \ config.get("globals", "cert_pwd") if not self.proxy_cert_file: self.proxy_cert_file = config.get("globals", "cert_file") if has_pwd: self.proxy_cert_pwd = config.get("globals", "cert_pwd") if config.get("globals", "trusted_certs"): if not self.proxy_trusted_certs: self.proxy_trusted_certs = \ config.get("globals", "trusted_certs") if not self.dynamic_projects_trusted_certs: self.dynamic_projects_trusted_certs = \ config.get("globals", "trusted_certs") proj_certs = (self.dynamic_projects_cert_file, self.dynamic_projects_trusted_certs, self.dynamic_projects_cert_pwd) self.soap_services = {\ 'RequestAccess': make_soap_handler(\ RequestAccessRequestMessage.typecode,\ self.RequestAccess, RequestAccessResponseMessage,\ "RequestAccessResponseBody"), \ 'ReleaseAccess': make_soap_handler(\ ReleaseAccessRequestMessage.typecode,\ self.ReleaseAccess, ReleaseAccessResponseMessage,\ "ReleaseAccessResponseBody")\ } self.xmlrpc_services = {\ 'RequestAccess': make_xmlrpc_handler(\ self.RequestAccess, "RequestAccessResponseBody"),\ 'ReleaseAccess': make_xmlrpc_handler(\ self.ReleaseAccess, "ReleaseAccessResponseBody")\ } if not config.has_option("access", "dynamic_projects_url"): self.allocate_project = \ fedd_allocate_project_local(config) else: self.allocate_project = \ fedd_allocate_project_remote(config) # If the project allocator exports services, put them in this object's # maps so that classes that instantiate this can call the services. self.soap_services.update(self.allocate_project.soap_services) self.xmlrpc_services.update(self.allocate_project.xmlrpc_services) def read_trust(self, trust): """ Read a trust file that splits fedids into testbeds, users or projects Format is: [type] fedid fedid default: type """ lineno = 0; cat = None cat_re = re.compile("\[(user|testbed|project)\]$", re.IGNORECASE) fedid_re = re.compile("[" + string.hexdigits + "]+$") default_re = re.compile("default:\s*(user|testbed|project)$", re.IGNORECASE) f = open(trust, "r") for line in f: lineno += 1 line = line.strip() if len(line) == 0 or line.startswith("#"): continue # Category line m = cat_re.match(line) if m != None: cat = m.group(1).lower() continue # Fedid line m = fedid_re.match(line) if m != None: if cat != None: self.fedid_category[fedid(hexstr=m.string)] = cat else: raise self.parse_error(\ "Bad fedid in trust file (%s) line: %d" % \ (trust, lineno)) continue # default line m = default_re.match(line) if m != None: self.fedid_default = m.group(1).lower() continue # Nothing matched - bad line, raise exception f.close() raise self.parse_error(\ "Unparsable line in trustfile %s line %d" % (trust, lineno)) f.close() def read_access(self, config): """ Read a configuration file and set internal parameters. The format is more complex than one might hope. The basic format is attribute value pairs separated by colons(:) on a signle line. The attributes in bool_attrs, emulab_attrs and id_attrs can all be set directly using the name: value syntax. E.g. boss: hostname sets self.boss to hostname. In addition, there are access lines of the form (tb, proj, user) -> (aproj, auser) that map the first tuple of names to the second for access purposes. Names in the key (left side) can include " or " to act as wildcards or to require the fields to be empty. Similarly aproj or auser can be or indicating that either the matching key is to be used or a dynamic user or project will be created. These names can also be federated IDs (fedid's) if prefixed with fedid:. Finally, the aproj can be followed with a colon-separated list of node types to which that project has access (or will have access if dynamic). Testbed attributes outside the forms above can be given using the format attribute: name value: value. The name is a single word and the value continues to the end of the line. Empty lines and lines startin with a # are ignored. Parsing errors result in a self.parse_error exception being raised. """ lineno=0 name_expr = "["+string.ascii_letters + string.digits + "\.\-_]+" fedid_expr = "fedid:[" + string.hexdigits + "]+" key_name = "(||"+fedid_expr + "|"+ name_expr + ")" access_proj = "((?::" + name_expr +")*|"+ \ "" + "(?::" + name_expr + ")*|" + \ fedid_expr + "(?::" + name_expr + ")*|" + \ name_expr + "(?::" + name_expr + ")*)" access_name = "(||" + fedid_expr + "|"+ name_expr + ")" restricted_re = re.compile("restricted:\s*(.*)", re.IGNORECASE) attr_re = re.compile('attribute:\s*([\._\-a-z0-9]+)\s+value:\s*(.*)', re.IGNORECASE) access_re = re.compile('\('+key_name+'\s*,\s*'+key_name+'\s*,\s*'+ key_name+'\s*\)\s*->\s*\('+access_proj + '\s*,\s*' + access_name + '\s*,\s*' + access_name + '\s*\)', re.IGNORECASE) def parse_name(n): if n.startswith('fedid:'): return fedid(n[len('fedid:'):]) else: return n f = open(config, "r"); for line in f: lineno += 1 line = line.strip(); if len(line) == 0 or line.startswith('#'): continue # Extended (attribute: x value: y) attribute line m = attr_re.match(line) if m != None: attr, val = m.group(1,2) self.attrs[attr] = val continue # Restricted entry m = restricted_re.match(line) if m != None: val = m.group(1) self.restricted.append(val) continue # Access line (t, p, u) -> (ap, cu, su) line m = access_re.match(line) if m != None: access_key = tuple([ parse_name(x) for x in m.group(1,2,3)]) aps = m.group(4).split(":"); if aps[0] == 'fedid:': del aps[0] aps[0] = fedid(hexstr=aps[0]) cu = parse_name(m.group(5)) su = parse_name(m.group(6)) access_val = (access_project(aps[0], aps[1:]), parse_name(m.group(5)), parse_name(m.group(6))) self.access[access_key] = access_val continue # Nothing matched to here: unknown line - raise exception f.close() raise self.parse_error("Unknown statement at line %d of %s" % \ (lineno, config)) f.close() def dump_state(self): """ Dump the state read from a configuration file. Mostly for debugging. """ for a in fedd_access.bool_attrs: print "%s: %s" % (a, getattr(self, a )) for a in fedd_access.emulab_attrs + fedd_access.id_attrs: print "%s: %s" % (a, getattr(self, a)) for k, v in self.attrs.iteritems(): print "%s %s" % (k, v) print "Access DB:" for k, v in self.access.iteritems(): print "%s %s" % (k, v) print "Trust DB:" for k, v in self.fedid_category.iteritems(): print "%s %s" % (k, v) print "Restricted: %s" % str(',').join(sorted(self.restricted)) def get_users(self, obj): """ Return a list of the IDs of the users in dict """ if obj.has_key('user'): return [ unpack_id(u['userID']) \ for u in obj['user'] if u.has_key('userID') ] else: return None def write_state(self): try: f = open(self.state_filename, 'w') pickle.dump(self.state, f) except IOError, e: self.log.error("Can't write file %s: %s" % \ (self.state_filename, e)) except pickle.PicklingError, e: self.log.error("Pickling problem: %s" % e) except TypeError, e: self.log.error("Pickling problem (TypeError): %s" % e) def read_state(self): """ Read a new copy of access state. Old state is overwritten. State format is a simple pickling of the state dictionary. """ try: f = open(self.state_filename, "r") self.state = pickle.load(f) self.allocation = self.state['allocation'] self.projects = self.state['projects'] self.keys = self.state['keys'] self.log.debug("[read_state]: Read state from %s" % \ self.state_filename) except IOError, e: self.log.warning("[read_state]: No saved state: Can't open %s: %s"\ % (self.state_filename, e)) except EOFError, e: self.log.warning("[read_state]: Empty or damaged state file: %s:"\ % self.state_filename) except pickle.UnpicklingError, e: self.log.warning(("[read_state]: No saved state: " + \ "Unpickling failed: %s") % e) def permute_wildcards(self, a, p): """Return a copy of a with various fields wildcarded. The bits of p control the wildcards. A set bit is a wildcard replacement with the lowest bit being user then project then testbed. """ if p & 1: user = [""] else: user = a[2] if p & 2: proj = "" else: proj = a[1] if p & 4: tb = "" else: tb = a[0] return (tb, proj, user) def find_access(self, search): """ Search the access DB for a match on this tuple. Return the matching access tuple and the user that matched. NB, if the initial tuple fails to match we start inserting wildcards in an order determined by self.project_priority. Try the list of users in order (when wildcarded, there's only one user in the list). """ if self.project_priority: perm = (0, 1, 2, 3, 4, 5, 6, 7) else: perm = (0, 2, 1, 3, 4, 6, 5, 7) for p in perm: s = self.permute_wildcards(search, p) # s[2] is None on an anonymous, unwildcarded request if s[2] != None: for u in s[2]: if self.access.has_key((s[0], s[1], u)): return (self.access[(s[0], s[1], u)], u) else: if self.access.has_key(s): return (self.access[s], None) return None, None def lookup_access(self, req, fid): """ Determine the allowed access for this request. Return the access and which fields are dynamic. The fedid is needed to construct the request """ # Search keys tb = None project = None user = None # Return values rp = access_project(None, ()) ru = None principal_type = self.fedid_category.get(fid, self.fedid_default) if principal_type == "testbed": tb = fid if req.has_key('project'): p = req['project'] if p.has_key('name'): project = unpack_id(p['name']) user = self.get_users(p) else: user = self.get_users(req) # Now filter by prinicpal type if principal_type == "user": if user != None: fedids = [ u for u in user if isinstance(u, type(fid))] if len(fedids) > 1: raise service_error(service_error.req, "User asserting multiple fedids") elif len(fedids) == 1 and fedids[0] != fid: raise service_error(service_error.req, "User asserting different fedid") project = None tb = None elif principal_type == "project": if isinstance(project, type(fid)) and fid != project: raise service_error(service_error.req, "Project asserting different fedid") tb = None # Ready to look up access found, user_match = self.find_access((tb, project, user)) if found == None: raise service_error(service_error.access, "Access denied") # resolve and in found dyn_proj = False dyn_create_user = False dyn_service_user = False if found[0].name == "": if project != None: rp.name = project else : raise service_error(\ service_error.server_config, "Project matched when no project given") elif found[0].name == "": rp.name = None dyn_proj = True else: rp.name = found[0].name rp.node_types = found[0].node_types; if found[1] == "": if user_match == "": if user != None: rcu = user[0] else: raise service_error(\ service_error.server_config, "Matched on anonymous request") else: rcu = user_match elif found[1] == "": rcu = None dyn_create_user = True if found[2] == "": if user_match == "": if user != None: rsu = user[0] else: raise service_error(\ service_error.server_config, "Matched on anonymous request") else: rsu = user_match elif found[2] == "": rsu = None dyn_service_user = True return (rp, rcu, rsu), (dyn_create_user, dyn_service_user, dyn_proj) def build_response(self, alloc_id, ap): """ Create the SOAP response. Build the dictionary description of the response and use fedd_utils.pack_soap to create the soap message. NB that alloc_id is a fedd_services_types.IDType_Holder pulled from the incoming message. ap is the allocate project message returned from a remote project allocation (even if that allocation was done locally). """ # Because alloc_id is already a fedd_services_types.IDType_Holder, # there's no need to repack it msg = { 'allocID': alloc_id, 'emulab': { 'domain': self.domain, 'boss': self.boss, 'ops': self.ops, 'fileServer': self.fileserver, 'eventServer': self.eventserver, 'project': ap['project'] }, } if len(self.attrs) > 0: msg['emulab']['fedAttr'] = \ [ { 'attribute': x, 'value' : y } \ for x,y in self.attrs.iteritems()] return msg def RequestAccess(self, req, fid): """ Handle the access request. Proxy if not for us. Parse out the fields and make the allocations or rejections if for us, otherwise, assuming we're willing to proxy, proxy the request out. """ # The dance to get into the request body if req.has_key('RequestAccessRequestBody'): req = req['RequestAccessRequestBody'] else: raise service_error(service_error.req, "No request!?") if req.has_key('destinationTestbed'): dt = unpack_id(req['destinationTestbed']) if dt == None or dt == self.testbed: # Request for this fedd found, dyn = self.lookup_access(req, fid) restricted = None ap = None # Check for access to restricted nodes if req.has_key('resources') and req['resources'].has_key('node'): resources = req['resources'] restricted = [ t for n in resources['node'] \ if n.has_key('hardware') \ for t in n['hardware'] \ if t in self.restricted ] inaccessible = [ t for t in restricted \ if t not in found[0].node_types] if len(inaccessible) > 0: raise service_error(service_error.access, "Access denied (nodetypes %s)" % \ str(', ').join(inaccessible)) # These collect the keys for teh two roles into single sets, one # for creation and one for service. The sets are a simple way to # eliminate duplicates create_ssh = set([ x['sshPubkey'] \ for x in req['createAccess'] \ if x.has_key('sshPubkey')]) service_ssh = set([ x['sshPubkey'] \ for x in req['serviceAccess'] \ if x.has_key('sshPubkey')]) if len(create_ssh) > 0 and len(service_ssh) >0: if dyn[1]: # Compose the dynamic project request # (only dynamic, dynamic currently allowed) preq = { 'AllocateProjectRequestBody': \ { 'project' : {\ 'user': [ \ { \ 'access': [ { 'sshPubkey': s } \ for s in service_ssh ], 'role': "serviceAccess",\ }, \ { \ 'access': [ { 'sshPubkey': s } \ for s in create_ssh ], 'role': "experimentCreation",\ }, \ ], \ }\ }\ } if restricted != None and len(restricted) > 0: preq['AllocateProjectRequestBody']['resources'] = \ [ {'node': { 'hardware' : [ h ] } } \ for h in restricted ] ap = self.allocate_project.dynamic_project(preq) else: preq = {'StaticProjectRequestBody' : \ { 'project': \ { 'name' : { 'localname' : found[0].name },\ 'user' : [ \ {\ 'userID': { 'localname' : found[1] }, \ 'access': [ { 'sshPubkey': s } for s in create_ssh ], 'role': 'experimentCreation'\ },\ {\ 'userID': { 'localname' : found[2] }, \ 'access': [ { 'sshPubkey': s } for s in service_ssh ], 'role': 'serviceAccess'\ },\ ]}\ }\ } if restricted != None and len(restricted) > 0: preq['StaticProjectRequestBody']['resources'] = \ [ {'node': { 'hardware' : [ h ] } } \ for h in restricted ] ap = self.allocate_project.static_project(preq) else: raise service_error(service_error.req, "SSH access parameters required") # keep track of what's been added allocID, alloc_cert = generate_fedid(subj="alloc", log=self.log) aid = unicode(allocID) self.state_lock.acquire() self.allocation[aid] = { } if dyn[1]: try: pname = ap['project']['name']['localname'] except KeyError: self.state_lock.release() raise service_error(service_error.internal, "Misformed allocation response?") if self.projects.has_key(pname): self.projects[pname] += 1 else: self.projects[pname] = 1 self.allocation[aid]['project'] = pname self.allocation[aid]['keys'] = [ ] try: for u in ap['project']['user']: uname = u['userID']['localname'] for k in [ k['sshPubkey'] for k in u['access'] \ if k.has_key('sshPubkey') ]: kv = "%s:%s" % (uname, k) if self.keys.has_key(kv): self.keys[kv] += 1 else: self.keys[kv] = 1 self.allocation[aid]['keys'].append((uname, k)) except KeyError: self.state_lock.release() raise service_error(service_error.internal, "Misformed allocation response?") self.write_state() self.state_lock.release() resp = self.build_response({ 'fedid': allocID } , ap) return resp else: p_fault = None # Any SOAP failure (sent unless XMLRPC works) try: # Proxy the request using SOAP self.log.debug("Sending proxy message to %s" % dt) resp = self.proxy_request(dt, req, feddServiceLocator, self.proxy_cert_file, self.proxy_cert_pwd, self.proxy_trusted_certs) if resp.has_key('RequestAccessResponseBody'): return resp['RequestAccessResponseBody'] elif resp.has_key('Fedd_FaultBody'): raise service_error(resp['FeddFaultBody']['code'], resp['FeddFaultBody']['desc']) except service_error, e: if e.code == service_error.proxy: p_fault = None else: raise except ZSI.FaultException, f: p_fault = f.fault.detail[0] # If we could not get a valid SOAP response to the request above, # try the same address using XMLRPC and let any faults flow back # out. if p_fault == None: resp = self.proxy_xmlrpc_request(dt, req, self.proxy_cert_file, self.proxy_cert_pwd, self.proxy_trusted_certs) if resp.has_key('RequestAccessResponseBody'): return resp['RequestAccessResponseBody'] else: # Build the fault body = p_fault.get_element_FeddFaultBody() if body != None: raise service_error(body.get_element_code(), body.get_element_desc()); else: raise service_error(\ service_error.proxy, "Undefined fault from proxy??"); def ReleaseAccess(self, req, fid): # The dance to get into the request body if req.has_key('ReleaseAccessRequestBody'): req = req['ReleaseAccessRequestBody'] else: raise service_error(service_error.req, "No request!?") try: if req['allocID'].has_key('localname'): aid = req['allocID']['localname'] elif req['allocID'].has_key('fedid'): aid = unicode(req['allocID']['fedid']) else: raise service_error(service_error.req, "Only localnames and fedids are understood") except KeyError: raise service_error(service_error.req, "Badly formed request") # If we know this allocation, reduce the reference counts and remove # the local allocations. Otherwise report an error. If there is an # allocation to delete, del_users will be a dictonary of sets where the # key is the user that owns the keys in the set. We use a set to avoid # duplicates. del_project is just the name of any dynamic project to # delete. del_users = { } del_project = None if self.allocation.has_key(aid): self.state_lock.acquire() for k in self.allocation[aid]['keys']: kk = "%s:%s" % k self.keys[kk] -= 1 if self.keys[kk] == 0: if not del_users.has_key(k[0]): del_users[k[0]] = set() del_users[k[0]].add(k[1]) del self.keys[kk] if self.allocation[aid].has_key('project'): pname = self.allocation[aid]['project'] self.projects[pname] -= 1 if self.projects[pname] == 0: del_project = pname del self.projects[pname] del self.allocation[aid] self.write_state() self.state_lock.release() # If we actually have resources to deallocate, prepare the call. if del_project or del_users: msg = { 'project': { }} if del_project: msg['project']['name']= {'localname': del_project} users = [ ] for u in del_users.keys(): users.append({ 'userID': { 'localname': u },\ 'access' : \ [ {'sshPubkey' : s } for s in del_users[u]]\ }) if users: msg['project']['user'] = users if self.allocate_project.release_project: msg = { 'ReleaseProjectRequestBody' : msg} self.allocate_project.release_project(msg) return { 'allocID': req['allocID'] } else: raise service_error(service_error.req, "No such allocation")