source: fedd/fedd_proj.py @ 2106ed1

axis_examplecompt_changesinfo-opsversion-1.30version-2.00version-3.01version-3.02
Last change on this file since 2106ed1 was 6ff0b91, checked in by Ted Faber <faber@…>, 16 years ago

Reimplementation of fedd as a start in merging the codebases

  • Property mode set to 100644
File size: 17.8 KB
Line 
1#!/usr/local/bin/python
2
3import os,sys
4
5from BaseHTTPServer import BaseHTTPRequestHandler
6from ZSI import *
7from M2Crypto import SSL
8from M2Crypto.SSL.SSLServer import SSLServer
9import M2Crypto.httpslib
10
11import re
12import random
13import string
14import subprocess
15import tempfile
16
17from fedd_services import *
18from fedd_util import *
19
20class fedd_proj:
21    """
22    The implementation of access control based on mapping users to projects.
23
24    Users can be mapped to existing projects or have projects created
25    dynamically.  This implements both direct requests and proxies.
26    """
27    # Attributes that can be parsed from the configuration file
28    bool_attrs = ("dynamic_projects", "project_priority")
29    emulab_attrs = ("boss", "ops", "domain", "fileserver", "eventserver")
30    id_attrs = ("testbed", "cert_file", "trusted_certs", "proxy",
31            "proxy_trusted_certs", "cert_pwd")
32
33    class access_project:
34        """
35        A project description used to grant access to this testbed.
36
37        The description includes a name and a list of node types to which the
38        project will be granted access.
39        """
40        def __init__(self, name, nt):
41            self.name = name
42            self.node_types = list(nt)
43
44        def __repr__(self):
45            if len(self.node_types) > 0:
46                return "access_proj('%s', ['%s'])" % \
47                        (self.name, str("','").join(self.node_types))
48            else:
49                return "access_proj('%s', [])" % self.name
50
51
52    class parse_error(RuntimeError):
53        """Raised if the configuration file is unparsable"""
54        pass
55
56    def __init__(self, config=None):
57        """
58        Initializer.  Parses a configuration if one is given.
59        """
60
61        # Create instance attributes from the static lists
62        for a in fedd_proj.bool_attrs:
63            setattr(self, a, False)
64
65        for a in fedd_proj.emulab_attrs + fedd_proj.id_attrs:
66            setattr(self, a, None)
67
68        # Other attributes
69        self.attrs = {}
70        self.access = {}
71        self.fedid_category = {}
72        self.fedid_default = "user"
73        self.restricted = []
74        self.wap = '/usr/testbed/sbin/wap'
75        self.newproj = '/usr/testbed/sbin/newproj'
76        self.mkproj = '/usr/testbed/sbin/mkproj'
77        self.grantnodetype = '/usr/testbed/sbin/grantnodetype'
78
79        # Read the configuration
80        if config != None: 
81            self.read_config(config)
82
83    def dump_state(self):
84        """
85        Dump the state read from a configuration file.  Mostly for debugging.
86        """
87        for a in fedd_proj.bool_attrs:
88            print "%s: %s" % (a, getattr(self, a ))
89        for a in fedd_proj.emulab_attrs + fedd_proj.id_attrs:
90            print "%s: %s" % (a, getattr(self, a))
91        for k, v in self.attrs.iteritems():
92            print "%s %s" % (k, v)
93        print "Access DB:"
94        for k, v in self.access.iteritems():
95            print "%s %s" % (k, v)
96        print "Trust DB:"
97        for k, v in self.fedid_category.iteritems():
98            print "%s %s" % (k, v)
99        print "Restricted: %s" % str(',').join(sorted(self.restricted))
100
101    def get_id(self, id):
102        """
103        Utility to get an object from the polymorphic IDType.
104       
105        Only fedids and usernames are currently understood.  If neither is
106        present None is returned.  If both are present (which is a bug) the
107        fedid is returned.
108        """
109        if id == None: 
110            return None
111        elif getattr(id, "get_element_fedid", None) != None:
112            return fedid(id.get_element_fedid())
113        elif getattr(id, "get_element_username", None) != None:
114            return id.get_element_username()
115        else:
116            return None
117
118    def get_users(self, obj):
119        """
120        Return a list of the IDs of the users in obj (accessed using get_id)
121        """
122        if obj.get_element_user() != None:
123            return [ self.get_id(u.get_element_userID()) \
124                    for u in obj.get_element_user() \
125                        if u.get_element_userID() != None]
126        else:
127            return None
128
129    def random_string(self, s, n=3):
130        """Append n random ASCII characters to s and return the string"""
131        rv = s
132        for i in range(0,n):
133            rv += random.choice(string.ascii_letters)
134        return rv
135
136    def write_attr_xml(self, file, root, lines):
137        """
138        Write an emulab config file for a dynamic project.
139
140        Format is <root><attribute name=lines[0]>lines[1]</attribute></root>
141        """
142        # Convert a pair to an attribute line
143        out_attr = lambda a,v : \
144                '<attribute name="%s"><value>%s</value></attribute>' % (a, v)
145
146        f = os.fdopen(file, "w")
147        f.write("<%s>\n" % root)
148        f.write("\n".join([out_attr(*l) for l in lines]))
149        f.write("</%s>\n" % root)
150        f.close()
151
152
153    def dynamic_project(self, found, ssh):
154        """Create a dynamic project with ssh access"""
155        user_fields = [
156                ("name", "Federation User %s" % found[1]),
157                ("email", "%s-fed@isi.deterlab.net" % found[1]),
158                ("password", self.random_string("", 8)),
159                ("login", found[1]),
160                ("address", "4676 Admiralty"),
161                ("city", "Marina del Rey"),
162                ("state", "CA"),
163                ("zip", "90292"),
164                ("country", "USA"),
165                ("phone", "310-448-9190"),
166                ("title", "None"),
167                ("affiliation", "USC/ISI")
168        ]
169
170        proj_fields = [
171                ("name", found[0].name),
172                ("short description", "dynamic federated project"),
173                ("URL", "http://www.isi.edu/~faber"),
174                ("funders", "USC/USU"),
175                ("long description", "Federation access control"),
176                ("public", "1"),
177                ("num_pcs", "100"),
178                ("linkedtous", "1")
179        ]
180
181        # tempfiles for the parameter files
182        uf, userfile = tempfile.mkstemp(prefix="usr", suffix=".xml",
183                dir="/tmp")
184        pf, projfile = tempfile.mkstemp(prefix="proj", suffix=".xml",
185                dir="/tmp")
186
187        # A few more dynamic fields
188        for s in ssh:
189            user_fields.append(("pubkey", s))
190        proj_fields.append(("newuser_xml", userfile))
191
192        # Write out the files
193        self.write_attr_xml(uf, "user", user_fields)
194        self.write_attr_xml(pf, "project", proj_fields)
195
196        # Generate the commands (only grantnodetype's are dynamic)
197        cmds = [
198                (self.wap, self.newproj, projfile),
199                (self.wap, self.mkproj, found[0].name)
200                ]
201        for nt in found[0].node_types:
202            cmds.append((self.wap, self.grantnodetype, '-p', found[0].name, nt))
203
204        # Create the projects
205        rc = 0
206        for cmd in cmds:
207            if self.dynamic_projects:
208                try:
209                    rc = subprocess.call(cmd)
210                except OSerror, e:
211                    raise Fault(Fault.Server, 
212                            "Dynamic project subprocess creation error "+ \
213                                    "[%s] (%s)" %  (cmd[1], e.strerror))
214            else:
215                print >>sys.stdout, str(" ").join(cmd)
216
217            if rc != 0: 
218                raise Fault(Fault.Server,
219                        "Dynamic project subprocess error " +\
220                                "[%s] (%d)" % (cmd[1], rc))
221        # Clean up tempfiles
222        os.unlink(userfile)
223        os.unlink(projfile)
224
225    def proxy_request(self, dt, req):
226        """
227        Send req on to the real destination in dt and return the response
228
229        Req is just the requestType object.  This function re-wraps it.  It
230        also rethrows any faults.
231        """
232        tc = self.proxy_trusted_certs or self.trusted_certs
233
234        # No retry loop here.  Proxy servers must correctly authenticate
235        # themselves without help
236        try:
237            ctx = fedd_ssl_context(self.cert_file, tc, password=self.cert_pwd)
238        except SSL.SSLError:
239            raise Fault(Fault.Server, "Server certificates misconfigured")
240
241        loc = feddServiceLocator();
242        port = loc.getfeddPortType(dt,
243                transport=M2Crypto.httpslib.HTTPSConnection, 
244                transdict={ 'ssl_context' : ctx })
245
246        # Reconstruct the full request message
247        msg = RequestAccessRequestMessage()
248        msg.set_element_RequestAccessRequestBody(req)
249
250        try:
251            resp = port.RequestAccess(msg)
252        except ZSI.FaultException, e:
253            raise e.fault
254        return resp
255
256    def permute_wildcards(self, a, p):
257        """Return a copy of a with various fields wildcarded.
258
259        The bits of p control the wildcards.  A set bit is a wildcard
260        replacement with the lowest bit being user then project then testbed.
261        """
262        if p & 1: user = ["<any>"]
263        else: user = a[2]
264        if p & 2: proj = "<any>"
265        else: proj = a[1]
266        if p & 4: tb = "<any>"
267        else: tb = a[0]
268
269        return (tb, proj, user)
270
271    def find_access(self, search):
272        """
273        Search the access DB for a match on this tuple.  Return the matching
274        access tuple and the user that matched.
275       
276        NB, if the initial tuple fails to match we start inserting wildcards in
277        an order determined by self.project_priority.  Try the list of users in
278        order (when wildcarded, there's only one user in the list).
279        """
280        if self.project_priority: perm = (0, 1, 2, 3, 4, 5, 6, 7)
281        else: perm = (0, 2, 1, 3, 4, 6, 5, 7)
282
283        for p in perm: 
284            s = self.permute_wildcards(search, p)
285            # s[2] is None on an anonymous, unwildcarded request
286            if s[2] != None:
287                for u in s[2]:
288                    if self.access.has_key((s[0], s[1], u)):
289                        return (self.access[(s[0], s[1], u)], u)
290            else:
291                if self.access.has_key(s):
292                    return (self.access[s], None)
293        return None
294
295    def lookup_access(self, req, fid):
296        """
297        Determine the allowed access for this request.  Return the access and
298        which fields are dynamic.
299
300        The fedid is needed to construct the request
301        """
302        tb = None
303        project = None
304        user = None
305
306        principal_type = self.fedid_category.get(fid, self.fedid_default)
307
308        if principal_type == "testbed": tb = fid
309
310        p = req.get_element_project()
311        if p != None:
312            if p.get_element_name() != None: 
313                project = self.get_id(p.get_element_name())
314            user = self.get_users(p)
315        else:
316            user = self.get_users(req)
317
318        # Now filter by prinicpal type
319        if principal_type == "user":
320            if user != None:
321                fedids = [ u for u in user if isinstance(u, type(fid))]
322                if len(fedids) > 1:
323                    raise Fault(Fault.Client,
324                            "User asserting multiple fedids")
325                elif len(fedids) == 1 and fedids[0] != fid:
326                    raise Fault(Fault.Client, 
327                            "User asserting different fedid")
328            project = None
329            tb = None
330        elif principal_type == "project":
331            if isinstance(project, type(fid)) and fid != project:
332                raise Fault(Fault.Client,
333                        "Project asserting different fedid")
334            tb = None
335
336        # Ready to look up access
337        print "Lookup %s %s %s: " % (tb, project, user)
338        found, user_match = self.find_access((tb, project, user))
339        print "Found: ", found
340       
341        if found == None: raise Fault(Fault.Server, "Access denied")
342
343        # resolve <dynamic> and <same> in found
344        dyn_proj = False
345        dyn_user = False
346
347        if found[0].name == "<same>":
348            if project != None:
349                found[0].name = project
350            else : 
351                raise Fault(Fault.Server, 
352                        "Project matched <same> when no project given")
353        elif found[0].name == "<dynamic>":
354            found[0].name = self.random_string("project", 3)
355            dyn_proj = True
356
357        if found[1] == "<same>":
358            if user_match == "<any>":
359                if user != None: found = (found[0], user[0])
360                else: raise Fault(Fault.Server,
361                        "Matched <same> on anonymous request")
362            else:
363                found = (found[0], user_match)
364        elif found[1] == "<dynamic>":
365            found = (found[0], self.random_string("user", 4))
366            dyn_user = True
367       
368        return found, (dyn_user, dyn_proj)
369
370    def build_response(self, resp, alloc_id, ap, ssh):
371        """
372        Create the SOAP response.
373
374        Build the dictionary description of the response and use
375        fedd_utils.pack_soap to create the soap message.  NB that alloc_id is
376        a fedd_services_types.IDType_Holder pulled from the incoming message
377        """
378        # Because alloc_id is already a fedd_services_types.IDType_Holder,
379        # there's no need to repack it
380        msg = { 
381                'allocID': alloc_id,
382                'emulab': { 
383                    'domain': self.domain,
384                    'boss': self.boss,
385                    'ops': self.ops,
386                    'fileServer': self.fileserver,
387                    'eventServer': self.eventserver,
388                    'project': {
389                        'name': pack_id(ap[0].name),
390                        'user': [ {
391                            'userID': pack_id(ap[1]),
392                            'access' : [ { 'sshPubkey': x } for x in ssh ],
393                            }
394                        ]
395                    }
396                },
397            }
398        if len(self.attrs) > 0:
399            msg['emulab']['fedAttr'] = \
400                [ { 'attribute': x, 'value' : y } \
401                        for x,y in self.attrs.iteritems()]
402
403        resp.set_element_RequestAccessResponseBody(
404                pack_soap(resp, "RequestAccessResponseBody", msg))
405
406    def soap_RequestAccess(self, ps, fid):
407        req = ps.Parse(RequestAccessRequestMessage.typecode)
408        resp = RequestAccessResponseMessage()
409
410
411        if req == None: raise Fault(Fault.Client, "No request??")
412        req = req.get_element_RequestAccessRequestBody()
413        if req == None: raise Fault(Fault.Client, "No request body??")
414
415
416        dt = req.get_element_destinationTestbed()
417
418        if dt != None: dt = dt.get_element_uri()
419       
420        if dt == None or dt == self.testbed:
421            # Request for this fedd
422            found, dyn = self.lookup_access(req, fid)
423
424            # Check for access to restricted nodes
425            if req.get_element_resources() != None and \
426                    req.get_element_resources().get_element_node() != None:
427
428                resources = req.get_element_resources()
429                inaccessible = [ t for n in resources.get_element_node()\
430                                if n.get_element_hardware() != None \
431                                    for t in n.get_element_hardware()
432                                        if t in self.restricted and \
433                                                t not in found[0].node_types]
434                if len(inaccessible) > 0:
435                    raise Fault(Fault.Server, 
436                            "Access denied (nodetypes %s)" % \
437                            str(', ').join(inaccessible))
438
439            ssh = [ x.get_element_sshPubkey() \
440                    for x in req.get_element_access() \
441                        if x.get_element_sshPubkey() != None]
442
443            if len(ssh) > 0: 
444                if dyn[1]: self.dynamic_project(found, ssh)
445                else: pass    # SSH key additions
446            else:
447                raise Fault(Fault.Client, "SSH access parameters required")
448
449            self.build_response(resp, req.get_element_allocID(), found, ssh)
450            return resp
451        else:
452            # Proxy the request
453            return self.proxy_request(dt, req)
454
455
456    def read_trust(self, trust):
457        """
458        Read a trust file that splits fedids into testbeds, users or projects
459
460        Format is:
461
462        [type]
463        fedid
464        fedid
465        default: type
466        """
467        lineno = 0;
468        cat = None
469        cat_re = re.compile("\[(user|testbed|project)\]$", re.IGNORECASE)
470        fedid_re = re.compile("[" + string.hexdigits + "]+$")
471        default_re = re.compile("default:\s*(user|testbed|project)$", 
472                re.IGNORECASE)
473
474        f = open(trust, "r")
475        for line in f:
476            lineno += 1
477            line = line.strip()
478            if len(line) == 0 or line.startswith("#"):
479                continue
480            # Category line
481            m = cat_re.match(line)
482            if m != None:
483                cat = m.group(1).lower()
484                continue
485            # Fedid line
486            m = fedid_re.match(line)
487            if m != None:
488                if cat != None:
489                    self.fedid_category[fedid(hexstr=m.string)] = cat
490                else:
491                    raise fedd_proj.parse_error(\
492                            "Bad fedid in trust file (%s) line: %d" % \
493                            (trust, lineno))
494                continue
495            # default line
496            m = default_re.match(line)
497            if m != None:
498                self.fedid_default = m.group(1).lower()
499                continue
500            # Nothing matched - bad line, raise exception
501            f.close()
502            raise fedd_proj.parse_error(\
503                    "Unparsable line in trustfile %s line %d" % (trust, lineno))
504        f.close()
505
506    def read_config(self, config):
507        """
508        Read a configuration file and set internal parameters.
509
510        The format is more complex than one might hope.  The basic format is
511        attribute value pairs separated by colons(:) on a signle line.  The
512        attributes in bool_attrs, emulab_attrs and id_attrs can all be set
513        directly using the name: value syntax.  E.g.
514        boss: hostname
515        sets self.boss to hostname.  In addition, there are access lines of the
516        form (tb, proj, user) -> (aproj, auser) that map the first tuple of
517        names to the second for access purposes.  Names in the key (left side)
518        can include "<NONE> or <ANY>" to act as wildcards or to require the
519        fields to be empty.  Similarly aproj or auser can be <SAME> or
520        <DYNAMIC> indicating that either the matching key is to be used or a
521        dynamic user or project will be created.  These names can also be
522        federated IDs (fedid's) if prefixed with fedid:.  Finally, the aproj
523        can be followed with a colon-separated list of node types to which that
524        project has access (or will have access if dynamic).
525        Testbed attributes outside the forms above can be given using the
526        format attribute: name value: value.  The name is a single word and the
527        value continues to the end of the line.  Empty lines and lines startin
528        with a # are ignored.
529
530        Parsing errors result in a parse_error exception being raised.
531        """
532        lineno=0
533        name_expr = "["+string.ascii_letters + string.digits + "\.\-_]+"
534        fedid_expr = "fedid:[" + string.hexdigits + "]+"
535        key_name = "(<ANY>|<NONE>|"+fedid_expr + "|"+ name_expr + ")"
536        access_proj = "(<DYNAMIC>(?::" + name_expr +")*|"+ \
537                "<SAME>" + "(?::" + name_expr + ")*|" + \
538                fedid_expr + "(?::" + name_expr + ")*|" + \
539                name_expr + "(?::" + name_expr + ")*)"
540        access_name = "(<DYNAMIC>|<SAME>|" + fedid_expr + "|"+ name_expr + ")"
541
542        bool_re = re.compile('(' + '|'.join(fedd_proj.bool_attrs) + 
543                '):\s+(true|false)', re.IGNORECASE)
544        string_re = re.compile( "(" + \
545                '|'.join(fedd_proj.emulab_attrs + fedd_proj.id_attrs) + \
546                '):\s*(.*)', re.IGNORECASE)
547        attr_re = re.compile('attribute:\s*([\._\-a-z0-9]+)\s+value:\s*(.*)',
548                re.IGNORECASE)
549        access_re = re.compile('\('+key_name+'\s*,\s*'+key_name+'\s*,\s*'+
550                key_name+'\s*\)\s*->\s*\('+access_proj + '\s*,\s*' + 
551                access_name + '\s*\)', re.IGNORECASE)
552        trustfile_re = re.compile("trustfile:\s*(.*)", re.IGNORECASE)
553        restricted_re = re.compile("restricted:\s*(.*)", re.IGNORECASE)
554
555        def parse_name(n):
556            if n.startswith('fedid:'): return fedid(n[len('fedid:'):])
557            else: return n
558
559        f = open(config, "r");
560        for line in f:
561            lineno += 1
562            line = line.strip();
563            if len(line) == 0 or line.startswith('#'):
564                continue
565
566            # Boolean attribute line
567            m = bool_re.match(line);
568            if m != None:
569                attr, val = m.group(1,2)
570                setattr(self, attr.lower(), bool(val.lower() == "true"))
571                continue
572
573            # String attribute line
574            m = string_re.match(line)
575            if m != None:
576                attr, val = m.group(1,2)
577                setattr(self, attr.lower(), val)
578                continue
579
580            # Extended (attribute: x value: y) attribute line
581            m = attr_re.match(line)
582            if m != None:
583                attr, val = m.group(1,2)
584                self.attrs[attr] = val
585                continue
586
587            # Access line (t, p, u) -> (ap, au) line
588            m = access_re.match(line)
589            if m != None:
590                access_key = tuple([ parse_name(x) for x in m.group(1,2,3)])
591                aps = m.group(4).split(":");
592                if aps[0] == 'fedid:':
593                    del aps[0]
594                    aps[0] = fedid(hexstr=aps[0])
595
596                au = m.group(5)
597                if au.startswith("fedid:"):
598                    au = fedid(hexstr=aus[len("fedid:"):])
599
600                access_val = (fedd_proj.access_project(aps[0], aps[1:]), au)
601
602                self.access[access_key] = access_val
603                continue
604
605            # Trustfile inclusion
606            m = trustfile_re.match(line)
607            if m != None:
608                self.read_trust(m.group(1))
609                continue
610            # Restricted node types
611
612            m = restricted_re.match(line)
613            if m != None:
614                self.restricted.append(m.group(1))
615                continue
616
617            # Nothing matched to here: unknown line - raise exception
618            f.close()
619            raise fedd_proj.parse_error("Unknown statement at line %d of %s" % \
620                    (lineno, config))
621        f.close()
622
623def new_feddservice(configfile):
624    return fedd_proj(configfile)
Note: See TracBrowser for help on using the repository browser.