#!/usr/local/bin/python import os,sys from BaseHTTPServer import BaseHTTPRequestHandler from ZSI import * from M2Crypto import SSL from M2Crypto.SSL.SSLServer import SSLServer import M2Crypto.httpslib import re import random import string import subprocess import tempfile from fedd_services import * from fedd_util import * class fedd_proj: """ 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. """ # Attributes that can be parsed from the configuration file bool_attrs = ("dynamic_projects", "project_priority") emulab_attrs = ("boss", "ops", "domain", "fileserver", "eventserver") id_attrs = ("testbed", "cert_file", "trusted_certs", "proxy", "proxy_trusted_certs", "cert_pwd") class access_project: """ A project description used to grant access to this testbed. The description includes a name and a list of node types to which the project will be granted access. """ def __init__(self, name, nt): self.name = name self.node_types = list(nt) def __repr__(self): if len(self.node_types) > 0: return "access_proj('%s', ['%s'])" % \ (self.name, str("','").join(self.node_types)) else: return "access_proj('%s', [])" % self.name class parse_error(RuntimeError): """Raised if the configuration file is unparsable""" pass def __init__(self, config=None): """ Initializer. Parses a configuration if one is given. """ # Create instance attributes from the static lists for a in fedd_proj.bool_attrs: setattr(self, a, False) for a in fedd_proj.emulab_attrs + fedd_proj.id_attrs: setattr(self, a, None) # Other attributes self.attrs = {} self.access = {} self.fedid_category = {} self.fedid_default = "user" self.restricted = [] self.wap = '/usr/testbed/sbin/wap' self.newproj = '/usr/testbed/sbin/newproj' self.mkproj = '/usr/testbed/sbin/mkproj' self.grantnodetype = '/usr/testbed/sbin/grantnodetype' # Read the configuration if config != None: self.read_config(config) def dump_state(self): """ Dump the state read from a configuration file. Mostly for debugging. """ for a in fedd_proj.bool_attrs: print "%s: %s" % (a, getattr(self, a )) for a in fedd_proj.emulab_attrs + fedd_proj.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_id(self, id): """ Utility to get an object from the polymorphic IDType. Only fedids and usernames are currently understood. If neither is present None is returned. If both are present (which is a bug) the fedid is returned. """ if id == None: return None elif getattr(id, "get_element_fedid", None) != None: return fedid(id.get_element_fedid()) elif getattr(id, "get_element_username", None) != None: return id.get_element_username() else: return None def get_users(self, obj): """ Return a list of the IDs of the users in obj (accessed using get_id) """ if obj.get_element_user() != None: return [ self.get_id(u.get_element_userID()) \ for u in obj.get_element_user() \ if u.get_element_userID() != None] else: return None def random_string(self, s, n=3): """Append n random ASCII characters to s and return the string""" rv = s for i in range(0,n): rv += random.choice(string.ascii_letters) return rv def write_attr_xml(self, file, root, lines): """ Write an emulab config file for a dynamic project. Format is lines[1] """ # Convert a pair to an attribute line out_attr = lambda a,v : \ '%s' % (a, v) f = os.fdopen(file, "w") f.write("<%s>\n" % root) f.write("\n".join([out_attr(*l) for l in lines])) f.write("\n" % root) f.close() def dynamic_project(self, found, ssh): """Create a dynamic project with ssh access""" user_fields = [ ("name", "Federation User %s" % found[1]), ("email", "%s-fed@isi.deterlab.net" % found[1]), ("password", self.random_string("", 8)), ("login", found[1]), ("address", "4676 Admiralty"), ("city", "Marina del Rey"), ("state", "CA"), ("zip", "90292"), ("country", "USA"), ("phone", "310-448-9190"), ("title", "None"), ("affiliation", "USC/ISI") ] proj_fields = [ ("name", found[0].name), ("short description", "dynamic federated project"), ("URL", "http://www.isi.edu/~faber"), ("funders", "USC/USU"), ("long description", "Federation access control"), ("public", "1"), ("num_pcs", "100"), ("linkedtous", "1") ] # tempfiles for the parameter files uf, userfile = tempfile.mkstemp(prefix="usr", suffix=".xml", dir="/tmp") pf, projfile = tempfile.mkstemp(prefix="proj", suffix=".xml", dir="/tmp") # A few more dynamic fields for s in ssh: user_fields.append(("pubkey", s)) proj_fields.append(("newuser_xml", userfile)) # Write out the files self.write_attr_xml(uf, "user", user_fields) self.write_attr_xml(pf, "project", proj_fields) # Generate the commands (only grantnodetype's are dynamic) cmds = [ (self.wap, self.newproj, projfile), (self.wap, self.mkproj, found[0].name) ] for nt in found[0].node_types: cmds.append((self.wap, self.grantnodetype, '-p', found[0].name, nt)) # Create the projects rc = 0 for cmd in cmds: if self.dynamic_projects: try: rc = subprocess.call(cmd) except OSerror, e: raise Fault(Fault.Server, "Dynamic project subprocess creation error "+ \ "[%s] (%s)" % (cmd[1], e.strerror)) else: print >>sys.stdout, str(" ").join(cmd) if rc != 0: raise Fault(Fault.Server, "Dynamic project subprocess error " +\ "[%s] (%d)" % (cmd[1], rc)) # Clean up tempfiles os.unlink(userfile) os.unlink(projfile) def proxy_request(self, dt, req): """ Send req on to the real destination in dt and return the response Req is just the requestType object. This function re-wraps it. It also rethrows any faults. """ tc = self.proxy_trusted_certs or self.trusted_certs # No retry loop here. Proxy servers must correctly authenticate # themselves without help try: ctx = fedd_ssl_context(self.cert_file, tc, password=self.cert_pwd) except SSL.SSLError: raise Fault(Fault.Server, "Server certificates misconfigured") loc = feddServiceLocator(); port = loc.getfeddPortType(dt, transport=M2Crypto.httpslib.HTTPSConnection, transdict={ 'ssl_context' : ctx }) # Reconstruct the full request message msg = RequestAccessRequestMessage() msg.set_element_RequestAccessRequestBody(req) try: resp = port.RequestAccess(msg) except ZSI.FaultException, e: raise e.fault return resp 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 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 """ tb = None project = None user = None principal_type = self.fedid_category.get(fid, self.fedid_default) if principal_type == "testbed": tb = fid p = req.get_element_project() if p != None: if p.get_element_name() != None: project = self.get_id(p.get_element_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 Fault(Fault.Client, "User asserting multiple fedids") elif len(fedids) == 1 and fedids[0] != fid: raise Fault(Fault.Client, "User asserting different fedid") project = None tb = None elif principal_type == "project": if isinstance(project, type(fid)) and fid != project: raise Fault(Fault.Client, "Project asserting different fedid") tb = None # Ready to look up access print "Lookup %s %s %s: " % (tb, project, user) found, user_match = self.find_access((tb, project, user)) print "Found: ", found if found == None: raise Fault(Fault.Server, "Access denied") # resolve and in found dyn_proj = False dyn_user = False if found[0].name == "": if project != None: found[0].name = project else : raise Fault(Fault.Server, "Project matched when no project given") elif found[0].name == "": found[0].name = self.random_string("project", 3) dyn_proj = True if found[1] == "": if user_match == "": if user != None: found = (found[0], user[0]) else: raise Fault(Fault.Server, "Matched on anonymous request") else: found = (found[0], user_match) elif found[1] == "": found = (found[0], self.random_string("user", 4)) dyn_user = True return found, (dyn_user, dyn_proj) def build_response(self, resp, alloc_id, ap, ssh): """ 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 """ # 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': { 'name': pack_id(ap[0].name), 'user': [ { 'userID': pack_id(ap[1]), 'access' : [ { 'sshPubkey': x } for x in ssh ], } ] } }, } if len(self.attrs) > 0: msg['emulab']['fedAttr'] = \ [ { 'attribute': x, 'value' : y } \ for x,y in self.attrs.iteritems()] resp.set_element_RequestAccessResponseBody( pack_soap(resp, "RequestAccessResponseBody", msg)) def soap_RequestAccess(self, ps, fid): req = ps.Parse(RequestAccessRequestMessage.typecode) resp = RequestAccessResponseMessage() if req == None: raise Fault(Fault.Client, "No request??") req = req.get_element_RequestAccessRequestBody() if req == None: raise Fault(Fault.Client, "No request body??") dt = req.get_element_destinationTestbed() if dt != None: dt = dt.get_element_uri() if dt == None or dt == self.testbed: # Request for this fedd found, dyn = self.lookup_access(req, fid) # Check for access to restricted nodes if req.get_element_resources() != None and \ req.get_element_resources().get_element_node() != None: resources = req.get_element_resources() inaccessible = [ t for n in resources.get_element_node()\ if n.get_element_hardware() != None \ for t in n.get_element_hardware() if t in self.restricted and \ t not in found[0].node_types] if len(inaccessible) > 0: raise Fault(Fault.Server, "Access denied (nodetypes %s)" % \ str(', ').join(inaccessible)) ssh = [ x.get_element_sshPubkey() \ for x in req.get_element_access() \ if x.get_element_sshPubkey() != None] if len(ssh) > 0: if dyn[1]: self.dynamic_project(found, ssh) else: pass # SSH key additions else: raise Fault(Fault.Client, "SSH access parameters required") self.build_response(resp, req.get_element_allocID(), found, ssh) return resp else: # Proxy the request return self.proxy_request(dt, req) 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 fedd_proj.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 fedd_proj.parse_error(\ "Unparsable line in trustfile %s line %d" % (trust, lineno)) f.close() def read_config(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 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 + ")" bool_re = re.compile('(' + '|'.join(fedd_proj.bool_attrs) + '):\s+(true|false)', re.IGNORECASE) string_re = re.compile( "(" + \ '|'.join(fedd_proj.emulab_attrs + fedd_proj.id_attrs) + \ '):\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*\)', re.IGNORECASE) trustfile_re = re.compile("trustfile:\s*(.*)", re.IGNORECASE) restricted_re = re.compile("restricted:\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 # Boolean attribute line m = bool_re.match(line); if m != None: attr, val = m.group(1,2) setattr(self, attr.lower(), bool(val.lower() == "true")) continue # String attribute line m = string_re.match(line) if m != None: attr, val = m.group(1,2) setattr(self, attr.lower(), val) 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 # Access line (t, p, u) -> (ap, au) 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]) au = m.group(5) if au.startswith("fedid:"): au = fedid(hexstr=aus[len("fedid:"):]) access_val = (fedd_proj.access_project(aps[0], aps[1:]), au) self.access[access_key] = access_val continue # Trustfile inclusion m = trustfile_re.match(line) if m != None: self.read_trust(m.group(1)) continue # Restricted node types m = restricted_re.match(line) if m != None: self.restricted.append(m.group(1)) continue # Nothing matched to here: unknown line - raise exception f.close() raise fedd_proj.parse_error("Unknown statement at line %d of %s" % \ (lineno, config)) f.close() def new_feddservice(configfile): return fedd_proj(configfile)