#!/usr/local/bin/python import os,sys import re import string import copy import pickle import logging from threading import * from util import * from allocate_project import allocate_project_local, allocate_project_remote from access_project import access_project from fedid import fedid, generate_fedid from authorizer import authorizer from service_error import service_error from remote_service import xmlrpc_handler, soap_handler, service_caller # 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 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 = ("project_priority", "allow_proxy") emulab_attrs = ("boss", "ops", "domain", "fileserver", "eventserver") id_attrs = ("proxy", "proxy_cert_file", "proxy_cert_pwd", "proxy_trusted_certs", "project_allocation_uri", "project_allocation_cert_file", "project_allocation_cert_pwd", "project_allocation_trusted_certs") id_list_attrs = ("restricted",) proxy_RequestAccess= service_caller('RequestAccess') proxy_ReleaseAccess= service_caller('ReleaseAccess') def __init__(self, config=None, auth=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 access.bool_attrs: if config.has_option("access", a): setattr(self, a, config.get("access", a)) else: setattr(self, a, False) for a in access.emulab_attrs + 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.types = { } self.allocation = { } self.state = { 'projects': self.projects, 'allocation' : self.allocation, 'keys' : self.keys, 'types': self.types } self.log = logging.getLogger("fedd.access") set_log_level(config, "access", self.log) self.state_lock = Lock() if auth: self.auth = auth else: self.log.error(\ "[access]: No authorizer initialized, creating local one.") auth = authorizer() tb = config.get('access', 'testbed') if tb: self.testbed = [ t.strip() for t in tb.split(',') ] else: self.testbed = [ ] if config.has_option("access", "accessdb"): self.read_access(config.get("access", "accessdb")) self.state_filename = config.get("access", "access_state") 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.project_allocation_cert_file: self.project_allocation_cert_file = \ config.get("globals", "proxy_cert_file") if config.has_option("globals", "proxy_cert_pwd"): self.project_allocation_cert_pwd = \ config.get("globals", "proxy_cert_pwd") if config.has_option("globals", "proxy_trusted_certs"): if not self.project_allocation_trusted_certs: self.project_allocation_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.project_allocation_cert_file: self.project_allocation_cert_file = \ config.get("globals", "cert_file") if has_pwd: self.project_allocation_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.project_allocation_trusted_certs: self.project_allocation_trusted_certs = \ config.get("globals", "trusted_certs") proj_certs = (self.project_allocation_cert_file, self.project_allocation_trusted_certs, self.project_allocation_cert_pwd) self.soap_services = {\ 'RequestAccess': soap_handler("RequestAccess", self.RequestAccess), 'ReleaseAccess': soap_handler("ReleaseAccess", self.ReleaseAccess), } self.xmlrpc_services = {\ 'RequestAccess': xmlrpc_handler('RequestAccess', self.RequestAccess), 'ReleaseAccess': xmlrpc_handler('ReleaseAccess', self.ReleaseAccess), } if not config.has_option("allocate", "uri"): self.allocate_project = \ allocate_project_local(config, auth) else: self.allocate_project = \ allocate_project_remote(config, auth) # 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_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(hexstr=n[len('fedid:'):]) else: return n def auth_name(n): if isinstance(n, basestring): if n =='' or n =='': return None else: return unicode(n) 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)]) auth_key = tuple([ auth_name(x) for x in access_key]) 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 self.auth.set_attribute(auth_key, "access") 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 access.bool_attrs: print "%s: %s" % (a, getattr(self, a )) for a in access.emulab_attrs + 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): if self.state_filename: 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. """ if self.state_filename: 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.types = self.state['types'] 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) # Add the ownership attributes to the authorizer. Note that the # indices of the allocation dict are strings, but the attributes are # fedids, so there is a conversion. for k in self.allocation.keys(): for o in self.allocation[k].get('owners', []): self.auth.set_attribute(o, fedid(hexstr=k)) 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 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) user_fedids = [ u for u in user if isinstance(u, fedid)] # Determine how the caller is representing itself. If its fedid shows # up as a project or a singleton user, let that stand. If neither the # usernames nor the project name is a fedid, the caller is a testbed. if project and isinstance(project, fedid): if project == fid: # The caller is the project (which is already in the tuple # passed in to the authorizer) owners = user_fedids owners.append(project) else: raise service_error(service_error.req, "Project asserting different fedid") else: if fid not in user_fedids: tb = fid owners = user_fedids owners.append(fid) else: if len(fedids) > 1: raise service_error(service_error.req, "User asserting different fedid") else: # Which is a singleton owners = user_fedids # Confirm authorization for u in user: self.log.debug("[lookup_access] Checking access for %s" % \ ((tb, project, u),)) if self.auth.check_attribute((tb, project, u), 'access'): self.log.debug("[lookup_access] Access granted") break else: self.log.debug("[lookup_access] Access Denied") else: raise service_error(service_error.access, "Access denied") # This maps a valid user to the Emulab projects and users to use found, user_match = self.find_access((tb, project, user)) if found == None: raise service_error(service_error.access, "Access denied - cannot map access") # 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),\ owners 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. 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. """ def gateway_hardware(h): if h == 'GWTYPE': return self.attrs.get('connectorType', 'GWTYPE') else: return h # 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 in self.testbed: # Request for this fedd found, dyn, owners = self.lookup_access(req, fid) restricted = None ap = None # If this is a request to export a project and the access project # is not the project to export, access denied. if req.has_key('exportProject'): ep = unpack_id(req['exportProject']) if ep != found[0].name: raise service_error(service_error.access, "Cannot export %s" % ep) # Check for access to restricted nodes if req.has_key('resources') and req['resources'].has_key('node'): resources = req['resources'] restricted = [ gateway_hardware(t) for n in resources['node'] \ if n.has_key('hardware') \ for t in n['hardware'] \ if gateway_hardware(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 the 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] = { } try: pname = ap['project']['name']['localname'] except KeyError: pname = None if dyn[1]: if not pname: 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 if ap.has_key('resources'): if not pname: self.state_lock.release() raise service_error(service_error.internal, "Misformed allocation response?") self.allocation[aid]['types'] = set() nodes = ap['resources'].get('node', []) for n in nodes: for h in n.get('hardware', []): if self.types.has_key((pname, h)): self.types[(pname, h)] += 1 else: self.types[(pname, h)] = 1 self.allocation[aid]['types'].add((pname,h)) 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.allocation[aid]['owners'] = owners self.write_state() self.state_lock.release() for o in owners: self.auth.set_attribute(o, allocID) resp = self.build_response({ 'fedid': allocID } , ap) return resp else: if self.allow_proxy: resp = self.proxy_RequestAccess.call_service(dt, req, self.proxy_cert_file, self.proxy_cert_pwd, self.proxy_trusted_certs) if resp.has_key('RequestAccessResponseBody'): return resp['RequestAccessResponseBody'] else: return None else: raise service_error(service_error.access, "Access proxying denied") 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!?") if req.has_key('destinationTestbed'): dt = unpack_id(req['destinationTestbed']) else: dt = None if dt == None or dt in self.testbed: # Local request try: if req['allocID'].has_key('localname'): auth_attr = aid = req['allocID']['localname'] elif req['allocID'].has_key('fedid'): aid = unicode(req['allocID']['fedid']) auth_attr = 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") self.log.debug("[access] deallocation requested for %s", aid) if not self.auth.check_attribute(fid, auth_attr): self.log.debug("[access] deallocation denied for %s", aid) raise service_error(service_error.access, "Access Denied") # 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. We're somewhat lazy about # deleting authorization attributes. Having access to something # that doesn't exist isn't harmful. del_users = { } del_project = None del_types = set() if self.allocation.has_key(aid): self.log.debug("Found allocation for %s" %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] if self.allocation[aid].has_key('types'): for t in self.allocation[aid]['types']: self.types[t] -= 1 if self.types[t] == 0: if not del_project: del_project = t[0] del_types.add(t[1]) del self.types[t] 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 len(del_types) > 0: msg['resources'] = { 'node': \ [ {'hardware': [ h ] } for h in del_types ]\ } 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") else: if self.allow_proxy: resp = self.proxy_ReleaseAccess.call_service(dt, req, self.proxy_cert_file, self.proxy_cert_pwd, self.proxy_trusted_certs) if resp.has_key('ReleaseAccessResponseBody'): return resp['ReleaseAccessResponseBody'] else: return None else: raise service_error(service_error.access, "Access proxying denied")