source: TI12-security/trunk/NDGSecurity/python/ndg_security_server/ndg/security/server/attributeauthority.py @ 7877

Subversion URL: http://proj.badc.rl.ac.uk/svn/ndg/TI12-security/trunk/NDGSecurity/python/ndg_security_server/ndg/security/server/attributeauthority.py@7877
Revision 7877, 50.5 KB checked in by pjkersha, 10 years ago (diff)
  • ndg.security.common.utils.pyopenssl: PyOpenSSL based implementations of SSL Socket and HTTPSConnection compatible with httplib/urllib2
  • ndg.security.server.attributeauthority: tidying and refactoring, incls fix for SAML assertion issuer format - now reflects Response.Issuer format set be query interface caller
  • test attribute and authorisation services - fixed logging format
  • Property svn:eol-style set to native
  • Property svn:keywords set to Id
Line 
1"""NDG Attribute Authority server side code
2
3handles security user attribute (role) queries
4
5NERC DataGrid Project
6"""
7__author__ = "P J Kershaw"
8__date__ = "15/04/05"
9__copyright__ = "(C) 2009 Science and Technology Facilities Council"
10__license__ = "BSD - see LICENSE file in top-level directory"
11__contact__ = "Philip.Kershaw@stfc.ac.uk"
12__revision__ = '$Id:attributeauthority.py 4367 2008-10-29 09:27:59Z pjkersha $'
13import logging
14log = logging.getLogger(__name__)
15
16import os
17import re
18import traceback
19
20# For parsing of properties file
21try: # python 2.5
22    from xml.etree import cElementTree as ElementTree
23except ImportError:
24    # if you've installed it yourself it comes this way
25    import cElementTree as ElementTree
26
27# SAML 2.0 Attribute Query Support - added 20/08/2009
28from uuid import uuid4
29from datetime import timedelta
30
31from ndg.saml.utils import SAMLDateTime
32from ndg.saml.saml2.core import (Response, Assertion, Attribute, 
33                                 AttributeStatement, SAMLVersion, Subject, 
34                                 NameID, Issuer, Conditions, AttributeQuery, 
35                                 XSStringAttributeValue, Status, 
36                                 StatusCode, StatusMessage)
37
38from ndg.security.common.saml_utils.esgf import ESGFSamlNamespaces
39from ndg.security.common.X509 import X500DN
40from ndg.security.common.utils.classfactory import instantiateClass
41from ndg.security.common.utils.factory import importModuleObject
42from ndg.security.common.utils.configfileparsers import (
43    CaseSensitiveConfigParser)
44
45
46class AttributeAuthorityError(Exception):
47    """Exception handling for NDG Attribute Authority class."""
48    def __init__(self, msg):
49        log.error(msg)
50        Exception.__init__(self, msg)
51
52
53class AttributeAuthorityConfigError(Exception):
54    """NDG Attribute Authority error with configuration. e.g. properties file
55    directory permissions or role mapping file"""
56    def __init__(self, msg):
57        log.error(msg)
58        Exception.__init__(self, msg) 
59
60
61class AttributeAuthority(object):
62    """NDG Attribute Authority - rewritten with a SAML 2.0 Attribute Query
63    interface for Earth System Grid
64   
65    @type PROPERTY_DEFAULTS: dict
66    @cvar PROPERTY_DEFAULTS: valid configuration property keywords
67   
68    @type ATTRIBUTE_INTERFACE_PROPERTY_DEFAULTS: dict
69    @cvar ATTRIBUTE_INTERFACE_PROPERTY_DEFAULTS: valid configuration property
70    keywords for the Attribute Interface plugin
71   
72    @type DEFAULT_CONFIG_DIRNAME: string
73    @cvar DEFAULT_CONFIG_DIRNAME: configuration directory under $NDGSEC_DIR -
74    default location for properties file
75   
76    @type DEFAULT_PROPERTY_FILENAME: string
77    @cvar DEFAULT_PROPERTY_FILENAME: default file name for properties file
78    under DEFAULT_CONFIG_DIRNAME
79   
80    @type ATTRIBUTE_INTERFACE_OPTPREFIX: basestring
81    @param ATTRIBUTE_INTERFACE_OPTPREFIX: attribute interface parameters key
82    name - see initAttributeInterface for details
83    """
84
85    DEFAULT_CONFIG_DIRNAME = "conf"
86    DEFAULT_PROPERTY_FILENAME = "attributeAuthority.cfg"
87   
88    # Config file special parameters
89    HERE_OPTNAME = 'here'
90    PREFIX_OPTNAME = 'prefix'
91   
92    # Config file option names
93    ISSUER_NAME_OPTNAME = 'issuerName'
94    ASSERTION_LIFETIME_OPTNAME = 'assertionLifetime'
95   
96    ATTRIBUTE_INTERFACE_OPTPREFIX = 'attributeInterface'
97    ATTRIBUTE_INTERFACE_MOD_FILEPATH_OPTNAME = 'modFilePath'
98    ATTRIBUTE_INTERFACE_CLASSNAME_OPTNAME = 'className'
99   
100    CONFIG_LIST_SEP_PAT = re.compile(',\s*')
101   
102   
103    ATTRIBUTE_INTERFACE_PROPERTY_DEFAULTS = {
104        ATTRIBUTE_INTERFACE_MOD_FILEPATH_OPTNAME:  '',
105        ATTRIBUTE_INTERFACE_CLASSNAME_OPTNAME:    ''
106    }
107   
108    # valid configuration property keywords with accepted default values. 
109    # Values set to not NotImplemented here denote keys which must be specified
110    # in the config
111    PROPERTY_DEFAULTS = { 
112        ISSUER_NAME_OPTNAME:            '',
113        ASSERTION_LIFETIME_OPTNAME:     -1,
114        ATTRIBUTE_INTERFACE_OPTPREFIX:  ATTRIBUTE_INTERFACE_PROPERTY_DEFAULTS
115    }
116
117    __slots__ = (
118        '__assertionLifetime', 
119        '__propFilePath',
120        '__propFileSection',
121        '__propPrefix',
122        '__attributeInterface',
123        '__attributeInterfaceCfg'
124    )
125   
126    def __init__(self):
127        """Create new Attribute Authority instance"""
128        log.info("Initialising service ...")
129       
130        # Initial config file property based attributes
131        self.__assertionLifetime = None
132       
133        self.__propFilePath = None       
134        self.__propFileSection = 'DEFAULT'
135        self.__propPrefix = ''
136       
137        self.__attributeInterfaceCfg = \
138                AttributeAuthority.ATTRIBUTE_INTERFACE_PROPERTY_DEFAULTS.copy()
139       
140    def __getstate__(self):
141        '''Enable pickling with __slots__'''
142        _dict = {}
143        for attrName in AttributeAuthority.__slots__:
144            # Ugly hack to allow for derived classes setting private member
145            # variables
146            if attrName.startswith('__'):
147                attrName = "_AttributeAuthority" + attrName
148               
149            _dict[attrName] = getattr(self, attrName)
150           
151        return _dict
152 
153    def __setstate__(self, attrDict):
154        '''Enable pickling with __slots__'''
155        for attrName, val in attrDict.items():
156            setattr(self, attrName, val)
157   
158    def _getAssertionLifetime(self):
159        return self.__assertionLifetime
160
161    def _setAssertionLifetime(self, value):
162        if isinstance(value, float):
163            self.__assertionLifetime = value
164           
165        elif isinstance(value, (basestring, int, long)):
166            self.__assertionLifetime = float(value)
167        else:
168            raise TypeError('Expecting float, int, long or string type for '
169                            '"assertionLifetime"; got %r' % type(value))
170
171    def _getAttributeInterface(self):
172        return self.__attributeInterface
173
174    def _setAttributeInterface(self, value):
175        if not isinstance(value, AttributeInterface):
176            raise TypeError('Expecting %r type for "attributeInterface" '
177                            'attribute; got %r' %
178                            (AttributeInterface, type(value)))
179           
180        self.__attributeInterface = value
181
182    def _get_attributeInterfaceCfg(self):
183        return self.__attributeInterfaceCfg
184   
185    attributeInterfaceCfg = property(fget=_get_attributeInterfaceCfg,
186                                     doc="Settings for Attribute Interface "
187                                         "initialisation")
188
189    def setPropFilePath(self, val=None):
190        """Set properties file from input or based on environment variable
191        settings
192       
193        @type val: basestring
194        @param val: properties file path"""
195        log.debug("Setting property file path")
196        if not val:
197            if 'NDGSEC_AA_PROPFILEPATH' in os.environ:
198                val = os.environ['NDGSEC_AA_PROPFILEPATH']
199               
200            elif 'NDGSEC_DIR' in os.environ:
201                val = os.path.join(os.environ['NDGSEC_DIR'], 
202                                   AttributeAuthority.DEFAULT_CONFIG_DIRNAME,
203                                   AttributeAuthority.DEFAULT_PROPERTY_FILENAME)
204            else:
205                raise AttributeError('Unable to set default Attribute '
206                                     'Authority properties file path: neither '
207                                     '"NDGSEC_AA_PROPFILEPATH" or "NDGSEC_DIR"'
208                                     ' environment variables are set')
209               
210        if not isinstance(val, basestring):
211            raise AttributeError("Input Properties file path "
212                                 "must be a valid string.")
213     
214        self.__propFilePath = os.path.expandvars(val)
215        log.debug("Path set to: %s" % val)
216       
217    def getPropFilePath(self):
218        '''Get the properties file path
219       
220        @rtype: basestring
221        @return: properties file path'''
222        return self.__propFilePath
223       
224    # Also set up as a property
225    propFilePath = property(fset=setPropFilePath,
226                            fget=getPropFilePath,
227                            doc="path to file containing Attribute Authority "
228                                "configuration parameters.  It defaults to "
229                                "$NDGSEC_AA_PROPFILEPATH or if not set, "
230                                "$NDGSEC_DIR/conf/attributeAuthority.cfg")   
231   
232    def setPropFileSection(self, val=None):
233        """Set section name to read properties from ini file.  This is set from
234        input or based on environment variable setting
235        NDGSEC_AA_PROPFILESECTION
236       
237        @type val: basestring
238        @param val: section name"""
239        if not val:
240            val = os.environ.get('NDGSEC_AA_PROPFILESECTION', 'DEFAULT')
241               
242        if not isinstance(val, basestring):
243            raise AttributeError("Input Properties file section name "
244                                 "must be a valid string.")
245     
246        self.__propFileSection = val
247        log.debug("Properties file section set to: \"%s\"" % val)
248       
249    def getPropFileSection(self):
250        '''Get the section name to extract properties from an ini file -
251        DOES NOT apply to XML file properties
252       
253        @rtype: basestring
254        @return: section name'''
255        return self.__propFileSection
256       
257    # Also set up as a property
258    propFileSection = property(fset=setPropFileSection,
259                               fget=getPropFileSection,
260                               doc="Set the file section name for ini file "
261                                   "properties")   
262   
263    def setPropPrefix(self, val=None):
264        """Set prefix for properties read from ini file.  This is set from
265        input or based on environment variable setting
266        NDGSEC_AA_PROPFILEPREFIX
267       
268        DOES NOT apply to XML file properties
269       
270        @type val: basestring
271        @param val: section name"""
272        log.debug("Setting property file section name")
273        if val is None:
274            val = os.environ.get('NDGSEC_AA_PROPFILEPREFIX', 'DEFAULT')
275               
276        if not isinstance(val, basestring):
277            raise AttributeError("Input Properties file section name "
278                                 "must be a valid string.")
279     
280        self.__propPrefix = val
281        log.debug("Properties file section set to: %s" % val)
282       
283    def getPropPrefix(self):
284        '''Get the prefix name used for properties in an ini file -
285        DOES NOT apply to XML file properties
286       
287        @rtype: basestring
288        @return: section name'''
289        return self.__propPrefix
290   
291       
292    # Also set up as a property
293    propPrefix = property(fset=setPropPrefix,
294                          fget=getPropPrefix,
295                          doc="Set a prefix for ini file properties")   
296
297    assertionLifetime = property(fget=_getAssertionLifetime, 
298                                 fset=_setAssertionLifetime, 
299                                 doc="validity lifetime (s) for Attribute "
300                                     "assertions issued")
301
302    attributeInterface = property(fget=_getAttributeInterface, 
303                                  fset=_setAttributeInterface,
304                                  doc="Attribute Interface object")
305       
306    @classmethod
307    def fromPropertyFile(cls, propFilePath=None, section='DEFAULT',
308                         prefix='attributeauthority.'):
309        """Create new NDG Attribute Authority instance from the property file
310        settings
311
312        @type propFilePath: string
313        @param propFilePath: path to file containing Attribute Authority
314        configuration parameters.  It defaults to $NDGSEC_AA_PROPFILEPATH or
315        if not set, $NDGSEC_DIR/conf/attributeAuthority.cfg
316        @type section: basestring
317        @param section: section of properties file to read from.
318        properties files
319        @type prefix: basestring
320        @param prefix: set a prefix for filtering attribute authority
321        property names - useful where properties are being parsed from a file
322        section containing parameter names for more than one application
323        """
324           
325        attributeAuthority = AttributeAuthority()
326        if section:
327            attributeAuthority.propFileSection = section
328           
329        if prefix:
330            attributeAuthority.propPrefix = prefix
331
332        # If path is None it will default to setting derived from environment
333        # variable - see setPropFilePath()
334        attributeAuthority.propFilePath = propFilePath
335                     
336        attributeAuthority.readProperties()
337        attributeAuthority.initialise()
338   
339        return attributeAuthority
340
341    @classmethod
342    def fromProperties(cls, prefix='attributeauthority.', **prop):
343        """Create new NDG Attribute Authority instance from input property
344        keywords
345
346        @type propPrefix: basestring
347        @param propPrefix: set a prefix for filtering attribute authority
348        property names - useful where properties are being parsed from a file
349        section containing parameter names for more than one application
350        """
351        attributeAuthority = AttributeAuthority()
352        if prefix:
353            attributeAuthority.propPrefix = prefix
354               
355        attributeAuthority.setProperties(**prop)
356        attributeAuthority.initialise()
357       
358        return attributeAuthority
359   
360    def initialise(self):
361        """Convenience method for set up of Attribute Interface, map
362        configuration and PKI"""
363
364        # Instantiate Certificate object
365        log.debug("Reading and checking Attribute Authority X.509 cert. ...")
366       
367        # Load user - user attribute look-up plugin
368        self.initAttributeInterface()
369
370    def setProperties(self, **prop):
371        """Set configuration from an input property dictionary
372        @type prop: dict
373        @param prop: properties dictionary containing configuration items
374        to be set
375        """
376        lenPropPrefix = len(self.propPrefix)
377       
378        # '+ 1' allows for the dot separator
379        lenAttributeInterfacePrefix = len(
380                        AttributeAuthority.ATTRIBUTE_INTERFACE_OPTPREFIX) + 1
381       
382        for name, val in prop.items():
383            if name.startswith(self.propPrefix):
384                name = name[lenPropPrefix:]
385           
386            if name.startswith(
387                            AttributeAuthority.ATTRIBUTE_INTERFACE_OPTPREFIX):
388                name = name[lenAttributeInterfacePrefix:]
389                self.attributeInterfaceCfg[name] = val
390                continue
391           
392            if name not in AttributeAuthority.PROPERTY_DEFAULTS:
393                raise AttributeError('Invalid attribute name "%s"' % name)
394           
395            if isinstance(val, basestring):
396                val = os.path.expandvars(val)
397           
398            if isinstance(AttributeAuthority.PROPERTY_DEFAULTS[name], list):
399                val = AttributeAuthority.CONFIG_LIST_SEP_PAT.split(val)
400               
401            # This makes an implicit call to the appropriate property method
402            try:
403                setattr(self, name, val)
404            except AttributeError:
405                raise AttributeError("Can't set attribute \"%s\": %s" % 
406                                     (name, traceback.format_exc()))
407           
408    def readProperties(self):
409        '''Read the properties files and do some checking/converting of input
410        values
411        '''
412        if not os.path.isfile(self.propFilePath):
413            raise IOError('Error parsing properties file "%s": No such file' % 
414                          self.propFilePath)
415           
416        defaultItems = {
417            AttributeAuthority.HERE_OPTNAME: os.path.dirname(self.propFilePath)
418        }
419       
420        cfg = CaseSensitiveConfigParser(defaults=defaultItems)
421        cfg.read(self.propFilePath)
422       
423        if cfg.has_option(self.propFileSection, 
424                          AttributeAuthority.PREFIX_OPTNAME):
425            self.propPrefix = cfg.get(self.propFileSection, 
426                                      AttributeAuthority.PREFIX_OPTNAME)
427           
428        cfgItems = dict([(name, val) 
429                         for name, val in cfg.items(self.propFileSection)
430                         if (name != AttributeAuthority.HERE_OPTNAME and 
431                             name != AttributeAuthority.PREFIX_OPTNAME)])
432        self.setProperties(**cfgItems)
433
434    def initAttributeInterface(self):
435        '''Load host sites custom user roles interface to enable the AA to
436        # assign roles in an attribute certificate on a getAttCert request'''
437        classProperties = {}
438        classProperties.update(self.attributeInterfaceCfg)
439       
440        className = classProperties.pop('className', None) 
441        if className is None:
442            raise AttributeAuthorityConfigError('No Attribute Interface '
443                                                '"className" property set')
444       
445        # file path may be omitted   
446        modFilePath = classProperties.pop('modFilePath', None) 
447                     
448        self.__attributeInterface = instantiateClass(className,
449                                             moduleFilePath=modFilePath,
450                                             objectType=AttributeInterface,
451                                             classProperties=classProperties)
452
453    def samlAttributeQuery(self, attributeQuery, samlResponse):
454        """Respond to SAML 2.0 Attribute Query.  This method follows the
455        signature for the SAML query interface:
456       
457        ndg.saml.saml2.binding.soap.server.wsgi.queryinterface.SOAPQueryInterfaceMiddleware
458       
459        @param attributeQuery: SAML attribute query to process
460        @type attributeQuery: ndg.saml.saml2.core.AttributeQuery
461        @param samlResponse: partially filled out SAML response.  This method
462        completes it
463        @type samlResponse: ndg.saml.saml2.core.Response
464        """
465        if not isinstance(attributeQuery, AttributeQuery):
466            raise TypeError('Expecting %r for attribute query; got %r' %
467                            (AttributeQuery, type(attributeQuery)))
468       
469        # Attribute Query validation ...
470        if (attributeQuery.subject.nameID.format != 
471            ESGFSamlNamespaces.NAMEID_FORMAT):
472            log.error('SAML Attribute Query subject format is %r; expecting '
473                      '%r' % (attributeQuery.subject.nameID.format,
474                                ESGFSamlNamespaces.NAMEID_FORMAT))
475           
476            samlResponse.status.statusCode.value = StatusCode.REQUESTER_URI
477            samlResponse.status.statusMessage.value = \
478                                "Subject Name ID format is not recognised"
479            return samlResponse
480       
481        elif attributeQuery.issuer.format != Issuer.X509_SUBJECT:
482            log.error('SAML Attribute Query issuer format is %r; expecting '
483                      '%r' % (attributeQuery.issuer.format,
484                              Issuer.X509_SUBJECT))
485            samlResponse.status.statusCode.value = StatusCode.REQUESTER_URI
486            samlResponse.status.statusMessage.value = \
487                                            "Issuer format is not recognised"
488            return samlResponse
489       
490        try:
491            # Return a dictionary of name, value pairs
492            self.attributeInterface.getAttributes(attributeQuery, samlResponse)
493           
494        except InvalidUserId, e:
495            log.exception(e)
496            samlResponse.status.statusCode.value = \
497                                        StatusCode.UNKNOWN_PRINCIPAL_URI
498            return samlResponse
499           
500        except UserIdNotKnown, e:
501            log.exception(e)
502            samlResponse.status.statusCode.value = \
503                                        StatusCode.UNKNOWN_PRINCIPAL_URI
504            samlResponse.status.statusMessage.value = str(e)
505            return samlResponse
506           
507        except InvalidRequestorId, e:
508            log.exception(e)
509            samlResponse.status.statusCode.value = StatusCode.REQUEST_DENIED_URI
510            samlResponse.status.statusMessage.value = str(e)
511            return samlResponse
512           
513        except AttributeReleaseDenied, e:
514            log.exception(e)
515            samlResponse.status.statusCode.value = \
516                                        StatusCode.INVALID_ATTR_NAME_VALUE_URI
517            samlResponse.status.statusMessage.value = str(e)
518            return samlResponse
519           
520        except AttributeNotKnownError, e:
521            log.exception(e)
522            samlResponse.status.statusCode.value = \
523                                        StatusCode.INVALID_ATTR_NAME_VALUE_URI
524            samlResponse.status.statusMessage.value = str(e)
525            return samlResponse
526           
527        except Exception, e:
528            log.exception("Unexpected error calling Attribute Interface "
529                          "for subject [%s] and query issuer [%s]" %
530                          (attributeQuery.subject.nameID.value,
531                           attributeQuery.issuer.value))
532           
533            # SAML spec says application server should set a HTTP 500 Internal
534            # Server error in this case
535            raise 
536
537        return samlResponse
538
539    def samlAttributeQueryFactory(self):
540        """Factory method to create SAML Attribute Query wrapper function
541        @rtype: function
542        @return: samlAttributeQuery method function wrapper
543        """
544        def samlAttributeQueryWrapper(attributeQuery, response):
545            """Attribute Query method.  This must adhere to the function
546            signature specified by
547            ndg.security.server.wsgi.saml.SOAPQueryInterfaceMiddleware
548            @type attributeQuery: ndg.saml.saml2.core.AttributeQuery
549            @param attributeQuery: SAML Attribute Query
550            @rtype: ndg.saml.saml2.core.Response
551            @return: SAML response
552            """
553            return self.samlAttributeQuery(attributeQuery, response)
554       
555        return samlAttributeQueryWrapper
556   
557               
558class AttributeInterfaceError(Exception):
559    """Exception handling for NDG Attribute Authority User Roles interface
560    class."""
561 
562                     
563class AttributeInterfaceConfigError(AttributeInterfaceError):
564    """Invalid configuration set for Attribute interface"""
565 
566                     
567class AttributeInterfaceRetrieveError(AttributeInterfaceError):
568    """Error retrieving attributes for Attribute interface class"""
569
570                       
571class AttributeReleaseDenied(AttributeInterfaceError):
572    """Requestor was denied release of the requested attributes"""
573
574                       
575class AttributeNotKnownError(AttributeInterfaceError):
576    """Requested attribute names are not known to this authority"""
577
578
579class InvalidRequestorId(AttributeInterfaceError):
580    """Requestor is not known or not allowed to request attributes"""
581   
582
583class UserIdNotKnown(AttributeInterfaceError): 
584    """User ID passed to getAttributes is not known to the authority"""
585   
586   
587class InvalidUserId(AttributeInterfaceError):
588    """User Id passed to getAttributes is invalid"""
589   
590   
591class InvalidAttributeFormat(AttributeInterfaceError):
592    """Format for Attribute requested is invalid or not supported"""
593   
594     
595class AttributeInterface(object):
596    """An abstract base class to define the user roles interface to an
597    Attribute Authority.
598
599    Each NDG data centre should implement a derived class which implements
600    the way user roles are provided to its representative Attribute Authority.
601   
602    Roles are expected to indexed by user Distinguished Name (DN).  They
603    could be stored in a database or file."""
604   
605    # Enable derived classes to use slots if desired
606    __slots__ = ()
607   
608    # User defined class may wish to specify a URI for a database interface or
609    # path for a user roles configuration file
610    def __init__(self, **prop):
611        """User Roles base class - derive from this class to define
612        roles interface to Attribute Authority
613       
614        @type prop: dict
615        @param prop: custom properties to pass to this class
616        """
617
618    def getRoles(self, userId):
619        """Virtual method - Derived method should return the roles for the
620        given user's Id or else raise an exception
621       
622        @type userId: string
623        @param userId: user identity e.g. user Distinguished Name
624        @rtype: list
625        @return: list of roles for the given user ID
626        @raise AttributeInterfaceError: an error occured requesting
627        attributes
628        """
629        raise NotImplementedError(self.getRoles.__doc__)
630 
631    def getAttributes(self, attributeQuery, response):
632        """Virtual method should be implemented in a derived class to enable
633        AttributeAuthority.samlAttributeQuery - The derived method should
634        return the attributes requested for the given user's Id or else raise
635        an exception
636       
637        @type attributeQuery: saml.saml2.core.AttributeQuery
638        @param userId: query containing requested attributes
639        @type: saml.saml2.core.Response
640        @param: Response - add an assertion with the list of attributes
641        for the given subject ID in the query or set an error Status code and
642        message
643        @raise AttributeInterfaceError: an error occured requesting
644        attributes
645        @raise AttributeReleaseDeniedError: Requestor was denied release of the
646        requested attributes
647        @raise AttributeNotKnownError: Requested attribute names are not known
648        to this authority
649        """
650        raise NotImplementedError(self.getAttributes.__doc__)
651
652
653class CSVFileAttributeInterface(AttributeInterface):
654    """Attribute Interface based on a Comma Separated Variable file containing
655    user identities and associated attributes.  For test/development purposes
656    only.  The SAML getAttributes method is NOT implemented here
657   
658    The expected file format is:
659   
660    <userID>, <role1>, <role2>, ... <roleN>
661    """
662    def __init__(self, propertiesFilePath=None):
663        """
664        @param propertiesFilePath: file path to Comma Separated file
665        containing user ids and roles
666        @type propertiesFilePath: basestring
667        """
668        if propertiesFilePath is None:
669            raise AttributeError("Expecting propertiesFilePath setting")
670       
671        propertiesFile = open(propertiesFilePath)
672        lines = propertiesFile.readlines()
673       
674        self.attributeMap = {}
675        for line in lines:
676            fields = re.split(',\s*', line.strip())
677            self.attributeMap[fields[0]] = fields[1:]
678   
679    def getRoles(self, userId):
680        """
681        @param userId: user identity to key into attributeMap
682        @type userId: basestring
683        """ 
684        log.debug('CSVFileAttributeInterface.getRoles for user "%s" ...', 
685                  userId)
686        return self.attributeMap.get(userId, [])
687
688
689# Properties file
690from ConfigParser import SafeConfigParser, NoOptionError
691
692try:
693    # PostgreSQL interface
694    from psycopg2 import connect
695except ImportError:
696    pass
697
698
699import traceback
700from string import Template
701try:
702    from sqlalchemy import create_engine, exc
703    sqlAlchemyInstalled = True
704except ImportError:
705    sqlAlchemyInstalled = False
706   
707
708class SQLAlchemyAttributeInterface(AttributeInterface):
709    '''SQLAlchemy based Attribute interface enables the Attribute Authority
710    to interface to any database type supported by it
711   
712    @type SQLQUERY_USERID_KEYNAME: basestring
713    @cvar SQLQUERY_USERID_KEYNAME: key corresponding to string to be
714    substituted into attribute query for user identifier e.g.
715   
716    select attr from user_table where username = $userId
717   
718    @type SAML_VALID_REQUESTOR_DNS_PAT: _sre.SRE_Pattern
719    @param SAML_VALID_REQUESTOR_DNS_PAT: regular expression to split list of
720    SAML requestor DNs.  These must comma separated.  Each comma may be
721    separated by any white space including new line characters
722    ''' 
723    DEFAULT_SAML_ASSERTION_LIFETIME = timedelta(seconds=60*60*8) 
724     
725    SQLQUERY_USERID_KEYNAME = 'userId'
726   
727    ISSUER_NAME_FORMAT = Issuer.X509_SUBJECT
728    CONNECTION_STRING_OPTNAME = 'connectionString'
729    ATTRIBUTE_SQLQUERY_OPTNAME = 'attributeSqlQuery'
730    SAML_SUBJECT_SQLQUERY_OPTNAME = 'samlSubjectSqlQuery'
731    SAML_VALID_REQUESTOR_DNS_OPTNAME = 'samlValidRequestorDNs'
732    SAML_ASSERTION_LIFETIME_OPTNAME = 'samlAssertionLifetime'
733    SAML_ATTRIBUTE2SQLQUERY_OPTNAME = 'samlAttribute2SqlQuery'
734    SAML_ATTRIBUTE2SQLQUERY_OPTNAME_LEN = len(SAML_ATTRIBUTE2SQLQUERY_OPTNAME)
735   
736    SAML_ATTRIBUTE2SQLQUERY_ATTRNAME_DELIMITERS = ('.', '_')
737    SAML_ATTRIBUTE2SQLQUERY_ATTRVAL_PAT = re.compile('\"\W+\"')
738             
739    __slots__ = (
740        CONNECTION_STRING_OPTNAME,
741        ATTRIBUTE_SQLQUERY_OPTNAME,
742        SAML_SUBJECT_SQLQUERY_OPTNAME,
743        SAML_VALID_REQUESTOR_DNS_OPTNAME,
744        SAML_ASSERTION_LIFETIME_OPTNAME,
745        SAML_ATTRIBUTE2SQLQUERY_OPTNAME,
746    )
747    __PRIVATE_ATTR_PREFIX = '_SQLAlchemyAttributeInterface__'
748    __slots__ += tuple([__PRIVATE_ATTR_PREFIX + i for i in __slots__])
749    del i
750   
751#    For Reference - split based on space separated ' or " quoted items
752#    SAML_VALID_REQUESTOR_DNS_PAT = re.compile("['\"]?\s*['\"]")
753   
754    SAML_VALID_REQUESTOR_DNS_PAT = re.compile(',\s*')
755   
756    def __init__(self, **properties):
757        '''Instantiate object taking in settings from the input properties'''
758        log.debug('Initialising SQLAlchemyAttributeInterface instance ...')
759       
760        if not sqlAlchemyInstalled:
761            raise AttributeInterfaceConfigError("SQLAlchemy is not installed")
762
763        self.__connectionString = None
764        self.__attributeSqlQuery = None
765        self.__samlSubjectSqlQuery = None
766        self.__samlValidRequestorDNs = []
767        self.__samlAssertionLifetime = \
768            SQLAlchemyAttributeInterface.DEFAULT_SAML_ASSERTION_LIFETIME
769        self.__samlAttribute2SqlQuery = {}
770       
771        self.setProperties(**properties)
772
773    def __setattr__(self, name, value):
774        """Provide a way to set the attribute map by dynamically handling
775        attribute names containing the SAML attribute name as a suffix e.g.
776       
777        attributeInterface.samlAttribute2SqlQuery_firstName = 'Philip'
778       
779        will update __samlAttribute2SqlQuery with the 'firstName', 'Philip'
780        key value pair.  Similarly,
781       
782        setattr('samlAttribute2SqlQuery.emailAddress', 'pjk@somewhere.ac.uk')
783       
784        sets __samlAttribute2SqlQuery with the 'emailAddress',
785        'pjk@somewhere.ac.uk' key value pair
786       
787        This is useful in enabling settings to be made direct from a dict of
788        option name and values parsed from an ini file.
789        """
790        cls = SQLAlchemyAttributeInterface
791       
792        if name in cls.__slots__:
793            object.__setattr__(self, name, value)
794           
795        elif (len(name) > cls.SAML_ATTRIBUTE2SQLQUERY_OPTNAME_LEN and
796              name[cls.SAML_ATTRIBUTE2SQLQUERY_OPTNAME_LEN] in 
797              cls.SAML_ATTRIBUTE2SQLQUERY_ATTRNAME_DELIMITERS):
798            # A special 'samlAttribute2SqlQuery[._]+' attribute name has been
799            # found.  The first item is the attribute name and the second, the
800            # corresponding SQL query to get the values corresponding to that
801            # name.  An optional 3rd element is a callback which converts the
802            # retrieved SQL query result to required the attribute value type. 
803            # This defaults to do a conversion to XS:String if not explicitly
804            # set
805            _value = value.strip()
806            attr2sqlQueryOpts = [v.strip('"') for v in 
807                self.__class__.SAML_ATTRIBUTE2SQLQUERY_ATTRVAL_PAT.split(_value)
808                ]
809            if len(attr2sqlQueryOpts) > 2:
810                (samlAttributeName, 
811                 samlAttributeSqlQuery, 
812                 samlAttributeValueParserName) = attr2sqlQueryOpts
813               
814                # Get parser from module path provided
815                samlAttributeParser = importModuleObject(
816                                                samlAttributeValueParserName)
817            else:
818                # No attribute value conversion callback given - default to
819                # XS:String
820                samlAttributeName, samlAttributeSqlQuery = attr2sqlQueryOpts
821                samlAttributeParser = self.xsstringAttributeValueParser
822           
823            # Set mapping of attribute name to SQL query + conversion routine
824            # tuple
825            self.__samlAttribute2SqlQuery[samlAttributeName] = (
826                                    samlAttributeSqlQuery, samlAttributeParser)
827        else:
828            raise AttributeError("'SQLAlchemyAttributeInterface' has no "
829                                 "attribute %r" % name)
830
831    def xsstringAttributeValueParser(self, attrVal):
832        """Convert string attribute value retrieved from database query into
833        the respective SAML Attribute Value type
834        """
835        xsstringAttrVal = XSStringAttributeValue()
836        xsstringAttrVal.value = attrVal
837        return xsstringAttrVal
838   
839    def setProperties(self, prefix='', **properties):
840        for name, val in properties.items():
841            if prefix:
842                if name.startswith(prefix):
843                    name = name.replace(prefix, '', 1)
844                    setattr(self, name, val)
845            else:
846                setattr(self, name, val)
847           
848    def _getSamlAssertionLifetime(self):
849        return self.__samlAssertionLifetime
850
851    def _setSamlAssertionLifetime(self, value):
852        if isinstance(value, timedelta):
853            self.__samlAssertionLifetime = value
854           
855        if isinstance(value, (float, int, long)):
856            self.__samlAssertionLifetime = timedelta(seconds=value)
857           
858        elif isinstance(value, basestring):
859            self.__samlAssertionLifetime = timedelta(seconds=float(value))
860        else:
861            raise TypeError('Expecting float, int, long, string or timedelta '
862                'type for "%s"; got %r' % 
863                (SQLAlchemyAttributeInterface.SAML_ASSERTION_LIFETIME_OPTNAME,
864                 type(value)))
865
866    samlAssertionLifetime = property(_getSamlAssertionLifetime, 
867                                     _setSamlAssertionLifetime, 
868                                     doc="Time validity for SAML Assertion "
869                                         "set in SAML Response returned from "
870                                         "getAttributes")
871
872    def _getSamlSubjectSqlQuery(self):
873        return self.__samlSubjectSqlQuery
874
875    def _setSamlSubjectSqlQuery(self, value):
876        if not isinstance(value, basestring):
877            raise TypeError('Expecting string type for "%s" attribute; got %r'%
878                    (SQLAlchemyAttributeInterface.SAML_SUBJECT_SQLQUERY_OPTNAME,
879                     type(value)))
880           
881        self.__samlSubjectSqlQuery = value
882
883    samlSubjectSqlQuery = property(_getSamlSubjectSqlQuery, 
884                                   _setSamlSubjectSqlQuery, 
885                                   doc="SAML Subject SQL Query")
886
887    def _getSamlAttribute2SqlQuery(self):
888        return self.__samlAttribute2SqlQuery
889
890    def _setSamlAttribute2SqlQuery(self, value):
891        if isinstance(value, dict):
892            # Validate string type for keys and values
893            invalidItems = [(k, v) for k, v in value.items() 
894                            if (not isinstance(k, basestring) or 
895                                not isinstance(v, basestring))]
896            if invalidItems:
897                raise TypeError('Expecting string type for "%s" dict items; '
898                                'got these/this invalid item(s) %r' % 
899                (SQLAlchemyAttributeInterface.SAML_ATTRIBUTE2SQLQUERY_OPTNAME,
900                 invalidItems))
901               
902            self.__samlAttribute2SqlQuery = value
903           
904        elif isinstance(value, (tuple, list)):
905            for query in value:
906                if not isinstance(query, basestring):
907                    raise TypeError('Expecting string type for "%s" '
908                                    'attribute items; got %r' %
909                (SQLAlchemyAttributeInterface.SAML_ATTRIBUTE2SQLQUERY_OPTNAME,
910                 type(value)))
911                   
912            self.__samlAttribute2SqlQuery = value                 
913        else:
914            raise TypeError('Expecting dict type for "%s" attribute; got %r' %
915                (SQLAlchemyAttributeInterface.SAML_ATTRIBUTE2SQLQUERY_OPTNAME,
916                 type(value)))
917           
918    samlAttribute2SqlQuery = property(_getSamlAttribute2SqlQuery, 
919                                      _setSamlAttribute2SqlQuery, 
920                                      doc="SQL Query or queries to obtain the "
921                                          "attribute information to respond "
922                                          "a SAML attribute query.  The "
923                                          "attributes returned from each "
924                                          "query concatenated together, must "
925                                          "exactly match the SAML attribute "
926                                          "names set in the samlAttributeNames "
927                                          "property")
928
929    def _getSamlValidRequestorDNs(self):
930        return self.__samlValidRequestorDNs
931
932    def _setSamlValidRequestorDNs(self, value):
933        if isinstance(value, basestring):
934           
935            pat = SQLAlchemyAttributeInterface.SAML_VALID_REQUESTOR_DNS_PAT
936            self.__samlValidRequestorDNs = [
937                X500DN.fromString(dn) for dn in pat.split(value)
938            ]
939           
940        elif isinstance(value, (tuple, list)):
941            self.__samlValidRequestorDNs = [X500DN.fromString(dn) 
942                                            for dn in value]
943        else:
944            raise TypeError('Expecting list/tuple or basestring type for "%s" '
945                'attribute; got %r' %
946                (SQLAlchemyAttributeInterface.SAML_VALID_REQUESTOR_DNS_OPTNAME,
947                 type(value)))
948   
949    samlValidRequestorDNs = property(_getSamlValidRequestorDNs, 
950                                     _setSamlValidRequestorDNs, 
951                                     doc="list of certificate Distinguished "
952                                         "Names referring to the client "
953                                         "identities permitted to query the "
954                                         "Attribute Authority via the SAML "
955                                         "Attribute Query interface")
956   
957    def _getConnectionString(self):
958        return self.__connectionString
959
960    def _setConnectionString(self, value):
961        if not isinstance(value, basestring):
962            raise TypeError('Expecting string type for "%s" attribute; got %r'%
963                        (SQLAlchemyAttributeInterface.CONNECTION_STRING_OPTNAME,
964                         type(value)))
965        self.__connectionString = value
966
967    connectionString = property(fget=_getConnectionString, 
968                                fset=_setConnectionString, 
969                                doc="Database connection string")
970
971    def _getAttributeSqlQuery(self):
972        return self.__attributeSqlQuery
973
974    def _setAttributeSqlQuery(self, value):
975        if not isinstance(value, basestring):
976            raise TypeError('Expecting string type for "%s" attribute; got %r'% 
977                    (SQLAlchemyAttributeInterface.ATTRIBUTE_SQLQUERY_OPTNAME,
978                     type(value)))
979        self.__attributeSqlQuery = value
980
981    attributeSqlQuery = property(fget=_getAttributeSqlQuery, 
982                                 fset=_setAttributeSqlQuery, 
983                                 doc="SQL Query for attribute query")
984   
985    def getRoles(self, userId):     
986        """Return valid roles for the given userId
987
988        @type userId: basestring
989        @param userId: user identity
990        @rtype: list
991        @return: list of roles for the given user
992        """
993
994        dbEngine = create_engine(self.connectionString)
995        connection = dbEngine.connect()
996       
997        try:
998            queryInputs = {
999                SQLAlchemyAttributeInterface.SQLQUERY_USERID_KEYNAME:
1000                userId
1001            }
1002            query = Template(self.attributeSqlQuery).substitute(queryInputs)
1003            result = connection.execute(query)
1004
1005        except exc.ProgrammingError:
1006            raise AttributeInterfaceRetrieveError("Error with SQL Syntax: %s" %
1007                                                  traceback.format_exc())
1008        finally:
1009            connection.close()
1010
1011        try:
1012            attributes = [attr for attr in result][0][0]
1013       
1014        except (IndexError, TypeError):
1015            raise AttributeInterfaceRetrieveError("Error with result set: %s" %
1016                                                  traceback.format_exc())
1017       
1018        log.debug('Attributes=%r retrieved for user=%r' % (attributes, 
1019                                                           userId))
1020       
1021        return attributes
1022
1023    def getAttributes(self, attributeQuery, response):
1024        """Attribute Authority SAML AttributeQuery
1025       
1026        @type attributeQuery: saml.saml2.core.AttributeQuery
1027        @param userId: query containing requested attributes
1028        @type: saml.saml2.core.Response
1029        @param: Response - add an assertion with the list of attributes
1030        for the given subject ID in the query or set an error Status code and
1031        message
1032        @raise AttributeInterfaceError: an error occured requesting
1033        attributes
1034        @raise AttributeReleaseDeniedError: Requestor was denied release of the
1035        requested attributes
1036        @raise AttributeNotKnownError: Requested attribute names are not known
1037        to this authority
1038        """
1039        userId = attributeQuery.subject.nameID.value
1040        requestedAttributeNames = [attribute.name
1041                                   for attribute in attributeQuery.attributes]
1042       
1043        requestorDN = X500DN.fromString(attributeQuery.issuer.value)
1044
1045        if not self._queryDbForSamlSubject(userId):
1046            raise UserIdNotKnown('Subject Id "%s" is not known to this '
1047                                 'authority' % userId)
1048
1049        if requestorDN not in self.samlValidRequestorDNs:
1050            raise InvalidRequestorId('Requestor identity "%s" is invalid' %
1051                                     requestorDN)
1052
1053        unknownAttrNames = [attrName for attrName in requestedAttributeNames
1054                            if attrName not in self.samlAttribute2SqlQuery]
1055
1056        if len(unknownAttrNames) > 0:
1057            raise AttributeNotKnownError("Unknown attributes requested: %r" %
1058                                         unknownAttrNames)
1059       
1060        # Create a new assertion to hold the attributes to be returned
1061        assertion = Assertion()
1062
1063        assertion.version = SAMLVersion(SAMLVersion.VERSION_20)
1064        assertion.id = str(uuid4())
1065        assertion.issueInstant = response.issueInstant
1066   
1067        # Assumes SAML response issuer details as set by -
1068        # ndg.security.server.wsgi.saml.SOAPQueryInterfaceMiddleware
1069        assertion.issuer = Issuer()
1070        assertion.issuer.value = response.issuer.value
1071       
1072        if response.issuer.format:
1073            assertion.issuer.format = response.issuer.format
1074
1075        assertion.conditions = Conditions()
1076        assertion.conditions.notBefore = assertion.issueInstant
1077        assertion.conditions.notOnOrAfter = (assertion.conditions.notBefore + 
1078                                             self.samlAssertionLifetime)
1079
1080        assertion.subject = Subject()
1081        assertion.subject.nameID = NameID()
1082        assertion.subject.nameID.format = attributeQuery.subject.nameID.format
1083        assertion.subject.nameID.value = attributeQuery.subject.nameID.value
1084
1085        attributeStatement = AttributeStatement()
1086
1087        # Query the database for the requested attributes and return them
1088        # mapped to their attribute names as specified by the attributeNames
1089        # property
1090        for requestedAttribute in attributeQuery.attributes:
1091            attributeVals = self._queryDbForSamlAttributes(
1092                                                    requestedAttribute.name, 
1093                                                    userId)
1094
1095            # Make a new SAML attribute object to hold the values obtained
1096            attribute = Attribute()
1097            attribute.name = requestedAttribute.name           
1098            attribute.nameFormat = requestedAttribute.nameFormat
1099
1100            if requestedAttribute.friendlyName is not None:
1101                attribute.friendlyName = requestedAttribute.friendlyName
1102
1103            # Call specific conversion utility to convert the retrieved field
1104            # to the correct SAML attribute value type
1105            try:
1106                field2SamlAttributeVal = self.samlAttribute2SqlQuery[
1107                                        requestedAttribute.name][-1]
1108            except (IndexError, TypeError), e:
1109                raise AttributeInterfaceConfigError('Bad format for SAML '
1110                                                    'attribute to SQL query '
1111                                                    'look-up for attribute '
1112                                                    'name %r: %s' % 
1113                                                    (requestedAttribute.name,
1114                                                    e))
1115               
1116            for val in attributeVals:
1117                attributeValue = field2SamlAttributeVal(val)
1118                attribute.attributeValues.append(attributeValue)
1119
1120            attributeStatement.attributes.append(attribute)
1121
1122        assertion.attributeStatements.append(attributeStatement)
1123        response.assertions.append(assertion)
1124       
1125    def _queryDbForSamlSubject(self, userId):     
1126        """Check a given SAML subject (user) is registered in the database.
1127        This method is called from the getAttributes() method
1128
1129        @type userId: basestring
1130        @param userId: user identity
1131        @rtype: bool
1132        @return: True/False is user registered?
1133        """
1134        if self.samlSubjectSqlQuery is None:
1135            log.debug('No "self.samlSubjectSqlQuery" property has been set, '
1136                      'skipping SAML subject query step')
1137            return True
1138       
1139        if self.connectionString is None:
1140            raise AttributeInterfaceConfigError('No "connectionString" setting '
1141                                                'has been made')
1142           
1143        dbEngine = create_engine(self.connectionString)
1144       
1145        try:
1146            queryInputs = {
1147                SQLAlchemyAttributeInterface.SQLQUERY_USERID_KEYNAME: userId
1148            }
1149            query = Template(self.samlSubjectSqlQuery).substitute(queryInputs)
1150           
1151        except KeyError, e:
1152            raise AttributeInterfaceConfigError("Invalid key for SAML subject "
1153                        "query string.  The valid key is %r" % 
1154                        SQLAlchemyAttributeInterface.SQLQUERY_USERID_KEYNAME)
1155
1156        log.debug('Checking for SAML subject with SQL Query = "%s"', query)
1157
1158        connection = dbEngine.connect()
1159           
1160        try:
1161            result = connection.execute(query)
1162
1163        except (exc.ProgrammingError, exc.OperationalError):
1164            raise AttributeInterfaceRetrieveError('SQL error: %s' %
1165                                                  traceback.format_exc()) 
1166        finally:
1167            connection.close()
1168
1169        try:
1170            found = [entry for entry in result][0][0] > 0
1171       
1172        except (IndexError, TypeError):
1173            raise AttributeInterfaceRetrieveError("Error with result set: %s" %
1174                                                  traceback.format_exc())
1175       
1176        log.debug('user=%r found=%r' % (userId, found))
1177       
1178        return found
1179     
1180    def _queryDbForSamlAttributes(self, attributeName, userId):     
1181        """Query the database in response to a SAML attribute query
1182       
1183        This method is called from the getAttributes() method
1184
1185        @type userId: basestring
1186        @param userId: user identity
1187        @rtype: bool
1188        @return: True/False is user registered?
1189        """
1190       
1191        if self.connectionString is None:
1192            raise AttributeInterfaceConfigError('No "connectionString" setting '
1193                                                'has been made')
1194
1195        dbEngine = create_engine(self.connectionString)
1196       
1197        try:
1198            queryTmpl = self.samlAttribute2SqlQuery.get(attributeName)[0]
1199           
1200        except (IndexError, TypeError), e:
1201            raise AttributeInterfaceConfigError('Bad format for SAML attribute '
1202                                                'to SQL query look-up: %s' % e)
1203        if queryTmpl is None:
1204            raise AttributeInterfaceConfigError('No SQL query set for '
1205                                                'attribute %r' % attributeName)
1206       
1207        try:
1208            queryInputs = {
1209                SQLAlchemyAttributeInterface.SQLQUERY_USERID_KEYNAME: userId
1210            }
1211            query = Template(queryTmpl).substitute(queryInputs)
1212           
1213        except KeyError, e:
1214            raise AttributeInterfaceConfigError("Invalid key %s for SAML "
1215                        "attribute query string.  The valid key is %r" % 
1216                        (e,
1217                         SQLAlchemyAttributeInterface.SQLQUERY_USERID_KEYNAME))
1218           
1219        log.debug('Checking for SAML attributes with SQL Query = "%s"', query)
1220       
1221        connection = dbEngine.connect()
1222               
1223        try:
1224            result = connection.execute(query)
1225           
1226        except (exc.ProgrammingError, exc.OperationalError):
1227            raise AttributeInterfaceRetrieveError('SQL error: %s' %
1228                                                  traceback.format_exc())
1229        finally:
1230            connection.close()
1231
1232        try:
1233            attributeValues = [entry[0] for entry in result]
1234           
1235        except (IndexError, TypeError):
1236            raise AttributeInterfaceRetrieveError("Error with result set: "
1237                                                  "%s" % traceback.format_exc())
1238       
1239        log.debug('Database results for SAML Attribute query user=%r '
1240                  'attribute values=%r' % (userId, attributeValues))
1241       
1242        return attributeValues
1243     
1244    def __getstate__(self):
1245        '''Explicit pickling required with __slots__'''
1246        return dict([(attrName, getattr(self, attrName)) 
1247                      for attrName in SQLAlchemyAttributeInterface.__slots__])
1248       
1249    def __setstate__(self, attrDict):
1250        '''Enable pickling for use with beaker.session'''
1251        for attr, val in attrDict.items():
1252            setattr(self, attr, val)           
1253
1254       
1255   
1256       
Note: See TracBrowser for help on using the repository browser.