source: fedd/fedd_access.py @ f4f4117

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

refactor configuration parsing to make code extensions more modular

  • Property mode set to 100644
File size: 20.6 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.m2xmlrpclib import SSL_Transport
9from M2Crypto.SSL.SSLServer import SSLServer
10import M2Crypto.httpslib
11import xmlrpclib
12
13import re
14import string
15import copy
16
17from fedd_access_project import access_project
18from fedd_services import *
19from fedd_util import *
20from fedd_allocate_project import *
21import parse_detail
22from service_error import *
23import logging
24
25
26# Make log messages disappear if noone configures a fedd logger
27class nullHandler(logging.Handler):
28    def emit(self, record): pass
29
30fl = logging.getLogger("fedd.access")
31fl.addHandler(nullHandler())
32
33class fedd_access:
34    """
35    The implementation of access control based on mapping users to projects.
36
37    Users can be mapped to existing projects or have projects created
38    dynamically.  This implements both direct requests and proxies.
39    """
40
41    bool_attrs = ("dynamic_projects", "project_priority")
42    emulab_attrs = ("boss", "ops", "domain", "fileserver", "eventserver")
43    id_attrs = ("testbed", "proxy",
44            "proxy_cert_file", "proxy_cert_pwd", "proxy_trusted_certs",
45            "dynamic_projects_url", "dynamic_projects_cert_file", 
46            "dynamic_projects_cert_pwd", "dynamic_projects_trusted_certs")
47    id_list_attrs = ("restricted",)
48
49    def read_trust(self, trust):
50        """
51        Read a trust file that splits fedids into testbeds, users or projects
52
53        Format is:
54
55        [type]
56        fedid
57        fedid
58        default: type
59        """
60        lineno = 0;
61        cat = None
62        cat_re = re.compile("\[(user|testbed|project)\]$", re.IGNORECASE)
63        fedid_re = re.compile("[" + string.hexdigits + "]+$")
64        default_re = re.compile("default:\s*(user|testbed|project)$", 
65                re.IGNORECASE)
66
67        f = open(trust, "r")
68        for line in f:
69            lineno += 1
70            line = line.strip()
71            if len(line) == 0 or line.startswith("#"):
72                continue
73            # Category line
74            m = cat_re.match(line)
75            if m != None:
76                cat = m.group(1).lower()
77                continue
78            # Fedid line
79            m = fedid_re.match(line)
80            if m != None:
81                if cat != None:
82                    self.fedid_category[fedid(hexstr=m.string)] = cat
83                else:
84                    raise parse_error(\
85                            "Bad fedid in trust file (%s) line: %d" % \
86                            (trust, lineno))
87                continue
88            # default line
89            m = default_re.match(line)
90            if m != None:
91                self.fedid_default = m.group(1).lower()
92                continue
93            # Nothing matched - bad line, raise exception
94            f.close()
95            raise parse_error(\
96                    "Unparsable line in trustfile %s line %d" % (trust, lineno))
97        f.close()
98
99    def read_access(self, config):
100        """
101        Read a configuration file and set internal parameters.
102
103        The format is more complex than one might hope.  The basic format is
104        attribute value pairs separated by colons(:) on a signle line.  The
105        attributes in bool_attrs, emulab_attrs and id_attrs can all be set
106        directly using the name: value syntax.  E.g.
107        boss: hostname
108        sets self.boss to hostname.  In addition, there are access lines of the
109        form (tb, proj, user) -> (aproj, auser) that map the first tuple of
110        names to the second for access purposes.  Names in the key (left side)
111        can include "<NONE> or <ANY>" to act as wildcards or to require the
112        fields to be empty.  Similarly aproj or auser can be <SAME> or
113        <DYNAMIC> indicating that either the matching key is to be used or a
114        dynamic user or project will be created.  These names can also be
115        federated IDs (fedid's) if prefixed with fedid:.  Finally, the aproj
116        can be followed with a colon-separated list of node types to which that
117        project has access (or will have access if dynamic).
118        Testbed attributes outside the forms above can be given using the
119        format attribute: name value: value.  The name is a single word and the
120        value continues to the end of the line.  Empty lines and lines startin
121        with a # are ignored.
122
123        Parsing errors result in a parse_error exception being raised.
124        """
125        lineno=0
126        name_expr = "["+string.ascii_letters + string.digits + "\.\-_]+"
127        fedid_expr = "fedid:[" + string.hexdigits + "]+"
128        key_name = "(<ANY>|<NONE>|"+fedid_expr + "|"+ name_expr + ")"
129        access_proj = "(<DYNAMIC>(?::" + name_expr +")*|"+ \
130                "<SAME>" + "(?::" + name_expr + ")*|" + \
131                fedid_expr + "(?::" + name_expr + ")*|" + \
132                name_expr + "(?::" + name_expr + ")*)"
133        access_name = "(<DYNAMIC>|<SAME>|" + fedid_expr + "|"+ name_expr + ")"
134
135        restricted_re = re.compile("restricted:\s*(.*)", re.IGNORECASE)
136        attr_re = re.compile('attribute:\s*([\._\-a-z0-9]+)\s+value:\s*(.*)',
137                re.IGNORECASE)
138        access_re = re.compile('\('+key_name+'\s*,\s*'+key_name+'\s*,\s*'+
139                key_name+'\s*\)\s*->\s*\('+access_proj + '\s*,\s*' + 
140                access_name + '\s*\)', re.IGNORECASE)
141
142        def parse_name(n):
143            if n.startswith('fedid:'): return fedid(n[len('fedid:'):])
144            else: return n
145
146        f = open(config, "r");
147        for line in f:
148            lineno += 1
149            line = line.strip();
150            if len(line) == 0 or line.startswith('#'):
151                continue
152
153            # Extended (attribute: x value: y) attribute line
154            m = attr_re.match(line)
155            if m != None:
156                attr, val = m.group(1,2)
157                self.attrs[attr] = val
158                continue
159
160            # Restricted entry
161            m = restricted_re.match(line)
162            if m != None:
163                val = m.group(1)
164                self.restricted.append(val)
165                continue
166
167            # Access line (t, p, u) -> (ap, au) line
168            m = access_re.match(line)
169            if m != None:
170                access_key = tuple([ parse_name(x) for x in m.group(1,2,3)])
171                aps = m.group(4).split(":");
172                if aps[0] == 'fedid:':
173                    del aps[0]
174                    aps[0] = fedid(hexstr=aps[0])
175
176                au = m.group(5)
177                if au.startswith("fedid:"):
178                    au = fedid(hexstr=aus[len("fedid:"):])
179
180                access_val = (access_project(aps[0], aps[1:]), au)
181
182                self.access[access_key] = access_val
183                continue
184
185            # Nothing matched to here: unknown line - raise exception
186            f.close()
187            raise parse_error("Unknown statement at line %d of %s" % \
188                    (lineno, config))
189        f.close()
190
191
192    def __init__(self, config=None):
193        """
194        Initializer.  Pulls parameters out of the ConfigParser's access section.
195        """
196
197        # Make sure that the configuration is in place
198        if config: 
199            if not config.has_section("access"):
200                config.add_section("access")
201            if not config.has_section("globals"):
202                config.add_section("globals")
203        else:
204            raise RunTimeError("No config to fedd_access")
205
206
207        # Create instance attributes from the static lists
208        for a in fedd_access.bool_attrs:
209            if config.has_option("access", a):
210                setattr(self, a, config.get("access", a))
211            else:
212                setattr(self, a, False)
213
214        for a in fedd_access.emulab_attrs + fedd_access.id_attrs:
215            if config.has_option("access", a):
216                setattr(self, a, config.get("access",a))
217            else:
218                setattr(self, a, None)
219
220        self.attrs = { }
221        self.access = { }
222        self.restricted = [ ]
223        self.fedid_category = { }
224        self.fedid_default = "testbed"
225        if config.has_option("access", "accessdb"):
226            self.read_access(config.get("access", "accessdb"))
227        if config.has_option("access", "trustdb"):
228            self.read_trust(config.get("access", "trustdb"))
229
230        self.log = logging.getLogger("fedd.access")
231
232        # Certs are promoted from the generic to the specific, so without a
233        # specific proxy certificate, the main certificates are used for
234        # proxy interactions. If no dynamic project certificates, then
235        # proxy certs are used, and if none of those the main certs.
236
237        if config.has_option("globals", "proxy_cert_file"):
238            if not self.dynamic_projects_cert_file:
239                self.dynamic_projects_cert_file = \
240                        config.get("globals", "proxy_cert_file")
241                if config.has_option("globals", "porxy_cert_pwd"):
242                    self.dynamic_projects_cert_pwd = \
243                            config.get("globals", "proxy_cert_pwd")
244
245        if config.has_option("globals", "proxy_trusted_certs"):
246            if not self.dynamic_projects_trusted_certs:
247                self.dynamic_projects_trusted_certs =\
248                        config.get("globals", proxy_trusted_certs)
249
250        if config.has_option("globals", "cert_file"):
251            has_pwd = config.has_option("globals", "cert_pwd")
252            if not self.dynamic_projects_cert_file:
253                self.dynamic_projects_cert_file = \
254                        config.get("globals", "cert_file")
255                if has_pwd: 
256                    self.dynamic_projects_cert_pwd = \
257                            config.get("globals", "cert_pwd")
258            if not self.proxy_cert_file:
259                self.proxy_cert_file = config.get("globals", "cert_file")
260                if has_pwd:
261                    self.proxy_cert_pwd = config.get("globals", "cert_pwd")
262
263        if config.get("globals", "trusted_certs"):
264            if not self.proxy_trusted_certs:
265                self.proxy_trusted_certs = \
266                        config.get("globals", "trusted_certs")
267            if not self.dynamic_projects_trusted_certs:
268                self.dynamic_projects_trusted_certs = \
269                        config.get("globals", "trusted_certs")
270
271        proj_certs = (self.dynamic_projects_cert_file, 
272                self.dynamic_projects_trusted_certs,
273                self.dynamic_projects_cert_pwd)
274
275        self.soap_services = {\
276            'RequestAccess': make_soap_handler(\
277                RequestAccessRequestMessage.typecode,\
278                self.RequestAccess, RequestAccessResponseMessage,\
279                "RequestAccessResponseBody")\
280            }
281        self.xmlrpc_services =  {\
282            'RequestAccess': make_xmlrpc_handler(\
283                self.RequestAccess, "RequestAccessResponseBody")\
284            }
285
286
287        if not config.has_option("access", "dynamic_projects_url"):
288            self.allocate_project = \
289                fedd_allocate_project_local(self.dynamic_projects, 
290                        None, proj_certs)
291        else:
292            self.allocate_project = \
293                fedd_allocate_project_remote(self.dynamic_projects, 
294                        config.get("access", "dynamic_projects_url"),
295                        proj_certs)
296
297        # If the project allocator exports services, put them in this object's
298        # maps so that classes that instantiate this can call the services.
299        self.soap_services.update(self.allocate_project.soap_services)
300        self.xmlrpc_services.update(self.allocate_project.xmlrpc_services)
301
302    def dump_state(self):
303        """
304        Dump the state read from a configuration file.  Mostly for debugging.
305        """
306        for a in fedd_access.bool_attrs:
307            print "%s: %s" % (a, getattr(self, a ))
308        for a in fedd_access.emulab_attrs + fedd_access.id_attrs:
309            print "%s: %s" % (a, getattr(self, a))
310        for k, v in self.attrs.iteritems():
311            print "%s %s" % (k, v)
312        print "Access DB:"
313        for k, v in self.access.iteritems():
314            print "%s %s" % (k, v)
315        print "Trust DB:"
316        for k, v in self.fedid_category.iteritems():
317            print "%s %s" % (k, v)
318        print "Restricted: %s" % str(',').join(sorted(self.restricted))
319
320    def get_users(self, obj):
321        """
322        Return a list of the IDs of the users in dict
323        """
324        if obj.has_key('user'):
325            return [ unpack_id(u['userID']) \
326                    for u in obj['user'] if u.has_key('userID') ]
327        else:
328            return None
329
330    def strip_unicode(self, obj):
331        """Loosly de-unicode an object"""
332        if isinstance(obj, dict):
333            for k in obj.keys():
334                obj[k] = self.strip_unicode(obj[k])
335            return obj
336        elif isinstance(obj, basestring):
337            return str(obj)
338        elif getattr(obj, "__iter__", None):
339            return [ self.strip_unicode(x) for x in obj]
340        else:
341            return obj
342
343    def proxy_xmlrpc_request(self, dt, req):
344        """Send an XMLRPC proxy request.  Called if the SOAP RPC fails"""
345
346        # No retry loop here.  Proxy servers must correctly authenticate
347        # themselves without help
348        try:
349            ctx = fedd_ssl_context(self.proxy_cert_file, 
350                    self.proxy_trusted_certs, password=self.proxy_cert_pwd)
351        except SSL.SSLError:
352            raise service_error(service_error.server_config,
353                    "Server certificates misconfigured")
354
355        # Of all the dumbass things.  The XMLRPC library in use here won't
356        # properly encode unicode strings, so we make a copy of req with the
357        # unicode objects converted.  We also convert the destination testbed
358        # to a basic string if it isn't one already.
359        if isinstance(dt, str): url = dt
360        else: url = str(dt)
361
362        r = copy.deepcopy(req)
363        self.strip_unicode(r)
364       
365        transport = SSL_Transport(ctx)
366        port = xmlrpclib.ServerProxy(url, transport=transport)
367
368        # Reconstruct the full request message
369        try:
370            resp = port.RequestAccess(
371                    { "RequestAccessRequestBody": r})
372            resp, method = xmlrpclib.loads(resp)
373        except xmlrpclib.Fault, f:
374            se = service_error(None, f.faultString, f.faultCode)
375            raise se
376        except xmlrpclib.Error, e:
377            raise service_error(service_error.proxy, 
378                    "Remote XMLRPC Fault: %s" % e)
379       
380        if resp[0].has_key('RequestAccessResponseBody'):
381            return resp[0]['RequestAccessResponseBody']
382        else:
383            raise service_error(service_error.proxy, 
384                    "Bad proxy response")
385
386    def proxy_request(self, dt, req):
387        """
388        Send req on to the real destination in dt and return the response
389
390        Req is just the requestType object.  This function re-wraps it.  It
391        also rethrows any faults.
392        """
393        # No retry loop here.  Proxy servers must correctly authenticate
394        # themselves without help
395        try:
396            ctx = fedd_ssl_context(self.proxy_cert_file, 
397                    self.proxy_trusted_certs, password=self.proxy_cert_pwd)
398        except SSL.SSLError:
399            raise service_error(service_error.server_config, 
400                    "Server certificates misconfigured")
401
402        loc = feddServiceLocator();
403        port = loc.getfeddPortType(dt,
404                transport=M2Crypto.httpslib.HTTPSConnection, 
405                transdict={ 'ssl_context' : ctx })
406
407        # Reconstruct the full request message
408        msg = RequestAccessRequestMessage()
409        msg.set_element_RequestAccessRequestBody(
410                pack_soap(msg, "RequestAccessRequestBody", req))
411        try:
412            resp = port.RequestAccess(msg)
413        except ZSI.ParseException, e:
414            raise service_error(service_error.proxy,
415                    "Bad format message (XMLRPC??): %s" %
416                    str(e))
417        r = unpack_soap(resp)
418
419        if r.has_key('RequestAccessResponseBody'):
420            return r['RequestAccessResponseBody']
421        else:
422            raise service_error(service_error.proxy,
423                    "Bad proxy response")
424
425    def permute_wildcards(self, a, p):
426        """Return a copy of a with various fields wildcarded.
427
428        The bits of p control the wildcards.  A set bit is a wildcard
429        replacement with the lowest bit being user then project then testbed.
430        """
431        if p & 1: user = ["<any>"]
432        else: user = a[2]
433        if p & 2: proj = "<any>"
434        else: proj = a[1]
435        if p & 4: tb = "<any>"
436        else: tb = a[0]
437
438        return (tb, proj, user)
439
440    def find_access(self, search):
441        """
442        Search the access DB for a match on this tuple.  Return the matching
443        access tuple and the user that matched.
444       
445        NB, if the initial tuple fails to match we start inserting wildcards in
446        an order determined by self.project_priority.  Try the list of users in
447        order (when wildcarded, there's only one user in the list).
448        """
449        if self.project_priority: perm = (0, 1, 2, 3, 4, 5, 6, 7)
450        else: perm = (0, 2, 1, 3, 4, 6, 5, 7)
451
452        for p in perm: 
453            s = self.permute_wildcards(search, p)
454            # s[2] is None on an anonymous, unwildcarded request
455            if s[2] != None:
456                for u in s[2]:
457                    if self.access.has_key((s[0], s[1], u)):
458                        return (self.access[(s[0], s[1], u)], u)
459            else:
460                if self.access.has_key(s):
461                    return (self.access[s], None)
462        return None, None
463
464    def lookup_access(self, req, fid):
465        """
466        Determine the allowed access for this request.  Return the access and
467        which fields are dynamic.
468
469        The fedid is needed to construct the request
470        """
471        # Search keys
472        tb = None
473        project = None
474        user = None
475        # Return values
476        rp = access_project(None, ())
477        ru = None
478
479
480        principal_type = self.fedid_category.get(fid, self.fedid_default)
481
482        if principal_type == "testbed": tb = fid
483
484        if req.has_key('project'):
485            p = req['project']
486            if p.has_key('name'):
487                project = unpack_id(p['name'])
488            user = self.get_users(p)
489        else:
490            user = self.get_users(req)
491
492        # Now filter by prinicpal type
493        if principal_type == "user":
494            if user != None:
495                fedids = [ u for u in user if isinstance(u, type(fid))]
496                if len(fedids) > 1:
497                    raise service_error(service_error.req,
498                            "User asserting multiple fedids")
499                elif len(fedids) == 1 and fedids[0] != fid:
500                    raise service_error(service_error.req,
501                            "User asserting different fedid")
502            project = None
503            tb = None
504        elif principal_type == "project":
505            if isinstance(project, type(fid)) and fid != project:
506                raise service_error(service_error.req,
507                        "Project asserting different fedid")
508            tb = None
509
510        # Ready to look up access
511        found, user_match = self.find_access((tb, project, user))
512       
513        if found == None:
514            raise service_error(service_error.access,
515                    "Access denied")
516
517        # resolve <dynamic> and <same> in found
518        dyn_proj = False
519        dyn_user = False
520
521        if found[0].name == "<same>":
522            if project != None:
523                rp.name = project
524            else : 
525                raise service_error(\
526                        service_error.server_config,
527                        "Project matched <same> when no project given")
528        elif found[0].name == "<dynamic>":
529            rp.name = None
530            dyn_proj = True
531        else:
532            rp.name = found[0].name
533        rp.node_types = found[0].node_types;
534
535        if found[1] == "<same>":
536            if user_match == "<any>":
537                if user != None: ru = user[0]
538                else: raise service_error(\
539                        service_error.server_config,
540                        "Matched <same> on anonymous request")
541            else:
542                ru = user_match
543        elif found[1] == "<dynamic>":
544            ru = None
545            dyn_user = True
546       
547        return (rp, ru), (dyn_user, dyn_proj)
548
549    def build_response(self, alloc_id, ap):
550        """
551        Create the SOAP response.
552
553        Build the dictionary description of the response and use
554        fedd_utils.pack_soap to create the soap message.  NB that alloc_id is
555        a fedd_services_types.IDType_Holder pulled from the incoming message.
556        ap is the allocate project message returned from a remote project
557        allocation (even if that allocation was done locally).
558        """
559        # Because alloc_id is already a fedd_services_types.IDType_Holder,
560        # there's no need to repack it
561        msg = { 
562                'allocID': alloc_id,
563                'emulab': { 
564                    'domain': self.domain,
565                    'boss': self.boss,
566                    'ops': self.ops,
567                    'fileServer': self.fileserver,
568                    'eventServer': self.eventserver,
569                    'project': ap['project']
570                },
571            }
572        if len(self.attrs) > 0:
573            msg['emulab']['fedAttr'] = \
574                [ { 'attribute': x, 'value' : y } \
575                        for x,y in self.attrs.iteritems()]
576        return msg
577
578    def RequestAccess(self, req, fid):
579        """
580        Handle the access request.  Proxy if not for us.
581
582        Parse out the fields and make the allocations or rejections if for us,
583        otherwise, assuming we're willing to proxy, proxy the request out.
584        """
585
586        # The dance to get into the request body
587        if req.has_key('RequestAccessRequestBody'):
588            req = req['RequestAccessRequestBody']
589        else:
590            raise service_error(service_error.req, "No request!?")
591
592        if req.has_key('destinationTestbed'):
593            dt = unpack_id(req['destinationTestbed'])
594
595        if dt == None or dt == self.testbed:
596            # Request for this fedd
597            found, dyn = self.lookup_access(req, fid)
598            restricted = None
599            ap = None
600
601            # Check for access to restricted nodes
602            if req.has_key('resources') and req['resources'].has_key('node'):
603                resources = req['resources']
604                restricted = [ t for n in resources['node'] \
605                                if n.has_key('hardware') \
606                                    for t in n['hardware'] \
607                                        if t in self.restricted ]
608                inaccessible = [ t for t in restricted \
609                                    if t not in found[0].node_types]
610                if len(inaccessible) > 0:
611                    raise service_error(service_error.access,
612                            "Access denied (nodetypes %s)" % \
613                            str(', ').join(inaccessible))
614            # XXX: This allocates a single user for both service and creation.
615            # This needs to be made more nuanced.
616            tmp_ssh = [ x['sshPubkey'] \
617                    for x in req['serviceAccess'] + req['createAccess'] \
618                        if x.has_key('sshPubkey')]
619
620            # Converting to a set collapses duplicates
621            ssh = set(tmp_ssh)
622
623            if len(ssh) > 0: 
624                if dyn[1]: 
625                    # Compose the dynamic project request
626                    # (only dynamic, dynamic currently allowed)
627                    preq = { 'AllocateProjectRequestBody': \
628                                { 'project' : {\
629                                    'user': [ \
630                                    { 'access': [ { 'sshPubkey': s } ] } \
631                                        for s in ssh ] \
632                                    }\
633                                }\
634                            }
635                    if restricted != None and len(restricted) > 0:
636                        preq['AllocateProjectRequestBody']['resources'] = \
637                            [ {'node': { 'hardware' :  [ h ] } } \
638                                    for h in restricted ]
639                               
640                    ap = self.allocate_project.dynamic_project(preq)
641                else:
642                    # XXX ssh key additions
643                    ap = { 'project': \
644                            { 'name' : { 'localname' : found[0].name },\
645                              'user' : [ {\
646                                'userID': { 'localname' : found[1] }, \
647                                'access': [ { 'sshPubkey': s } for s in ssh]}\
648                                ]\
649                            }\
650                    }
651            else:
652                raise service_error(service_error.req, 
653                        "SSH access parameters required")
654
655            resp = self.build_response(req['allocID'], ap)
656            return resp
657        else:
658            p_fault = None      # Any SOAP failure (sent unless XMLRPC works)
659            try:
660                # Proxy the request using SOAP
661                return self.proxy_request(dt, req)
662            except service_error, e:
663                if e.code == service_error.proxy: p_fault = None
664                else: raise
665            except ZSI.FaultException, f:
666                p_fault = f.fault.detail[0]
667                   
668
669            # If we could not get a valid SOAP response to the request above,
670            # try the same address using XMLRPC and let any faults flow back
671            # out.
672            if p_fault == None:
673                return self.proxy_xmlrpc_request(dt, req)
674            else:
675                # Build the fault
676                body = p_fault.get_element_FeddFaultBody()
677                if body != None:
678                    raise service_error(body.get_element_code(),
679                                body.get_element_desc());
680                else:
681                    raise service_error(\
682                            service_error.proxy,
683                            "Undefined fault from proxy??");
Note: See TracBrowser for help on using the repository browser.