source: TI12-security/trunk/python/ndg.security.common/ndg/security/common/authz/pdp/proftp.py @ 4692

Subversion URL: http://proj.badc.rl.ac.uk/svn/ndg/TI12-security/trunk/python/ndg.security.common/ndg/security/common/authz/pdp/proftp.py@4692
Revision 4692, 22.1 KB checked in by pjkersha, 11 years ago (diff)

Refactoring of SSO service to enable use of local AA and SM instances via keys to environ.

Line 
1"""NDG Policy Decision Point for BADC datasets secured with Proftp .ftpaccess
2files
3
4NERC Data Grid Project
5"""
6__author__ = "P J Kershaw"
7__date__ = "04/04/08"
8__copyright__ = "(C) 2008 STFC"
9__contact__ = "Philip.Kershaw@stfc.ac.uk"
10__license__ = \
11"""This software may be distributed under the terms of the Q Public
12License, version 1.0 or later."""
13__contact__ = "Philip.Kershaw@stfc.ac.uk"
14__revision__ = "$Id:gatekeeper.py 3079 2007-11-30 09:39:46Z pjkersha $"
15
16import logging
17log = logging.getLogger(__name__)
18
19import sys # tracefile config param may be set to e.g. sys.stderr
20import urllib2
21import socket
22from ConfigParser import SafeConfigParser
23
24# For parsing of properties file
25from os.path import expandvars as expVars
26
27from ndg.security.common.authz.pdp import PDPInterface, PDPError, \
28    PDPUserAccessDenied, PDPUserNotLoggedIn, PDPMissingResourceConstraints, \
29    PDPUserInsufficientPrivileges
30   
31from ndg.security.common.sessionmanager import SessionManagerClient, SessionNotFound,\
32    SessionCertTimeError, SessionExpired, InvalidSession, \
33    AttributeRequestDenied
34   
35from ndg.security.common.X509 import X500DN               
36
37class InvalidAttributeCertificate(PDPError):
38    "The certificate containing authorisation roles is invalid"
39    def __init__(self, msg=None):
40        PDPError.__init__(self, msg or InvalidAttributeCertificate.__doc__)
41   
42class SessionExpiredMsg(PDPError):
43    'Session has expired.  Please re-login'
44    def __init__(self, msg=None):
45        PDPError.__init__(self, msg or SessionExpiredMsg.__doc__)
46
47class InvalidSessionMsg(PDPError):
48    'Session is invalid.  Please try re-login'
49    def __init__(self, msg=None):
50        PDPError.__init__(self, msg or InvalidSessionMsg.__doc__)
51
52class InitSessionCtxError(PDPError):
53    'A problem occured initialising a session connection'
54    def __init__(self, msg=None):
55        PDPError.__init__(self, msg or InitSessionCtxError.__doc__)
56
57class AttributeCertificateRequestError(PDPError):
58    'A problem occured requesting a certificate containing authorisation roles'
59    def __init__(self, msg=None):
60        PDPError.__init__(self,msg or AttributeCertificateRequestError.__doc__)
61
62class URLCannotBeOpened(PDPError):
63    """Raise from canURLBeOpened PullModelHandler class method
64    if URL is invalid - this method is used to check the AA
65    service"""
66
67
68class ProftpPDP(PDPInterface):
69    """Make access control decision based on access constraints contained in
70    ProFTP .ftpaccess file and a user security token"""
71
72    defParam = {'aaURI': '',
73                'sslCACertFilePathList': [],
74                'tracefile': None,
75                'acCACertFilePathList': [], 
76                'wssCfgFilePath': None,
77                'wssCfgSection': 'DEFAULT',
78                'acIssuer': ''}
79       
80    def __init__(self, cfg=None, cfgSection='DEFAULT', **cfgKw):
81        """Initialise settings for WS-Security and SSL for SOAP
82        call to Session Manager
83       
84        @type cfg: string / ConfigParser object
85        @param cfg: if a string type, this is interpreted as the file path to
86        a configuration file, otherwise it will be treated as a ConfigParser
87        object
88        @type cfgSection: string
89        @param cfgSection: sets the section name to retrieve config params
90        from
91        @type cfgKw: dict
92        @param cfgKw: set parameters as key value pairs.
93        """
94       
95        self.cfgFilePath = cfg
96        self.resrcURI = None
97        self.securityElement = None
98        self.userHandle = None
99       
100        # Set from config file
101        if isinstance(cfg, basestring):
102            self._cfg = SafeConfigParser()
103            self._readConfig(cfg)
104        else:
105            self._cfg = cfg
106       
107        # Parse settings
108        if cfg:
109            self._parseConfig(cfgSection)
110
111        # Separate keywords into PDP and WS-Security specific items
112        paramNames = cfgKw.keys()
113        for paramName in paramNames:
114            if paramName in ProftpPDP.defParam:
115                # Keywords are deleted as they are set
116                setattr(self, paramName, cfgKw.pop('paramName'))
117               
118        # Remaining keys must be for WS-Security config
119        self.wssCfg = cfgKw   
120
121           
122    def _readConfig(self, cfgFilePath):
123        '''Read PDP configuration file'''
124        self._cfg.read(cfgFilePath)
125
126
127    def _parseConfig(self, section='DEFAULT'):
128        '''Extract parameters from _cfg config object'''
129        log.debug("ProftpPDP._parseConfig ...")
130
131        # Copy directly into attribute of this object
132        for paramName, paramVal in ProftpPDP.defParam.items():
133            if not self._cfg.has_option(section, paramName): 
134                # Set default if parameter is missing
135                log.debug("Setting default %s = %s" % (paramName, paramVal))
136                setattr(self, paramName, paramVal)
137                continue
138             
139            if paramName.lower() == 'tracefile':
140                val = self._cfg.get(section, paramName)
141                if val:
142                    setattr(self, paramName, eval(val))
143                else:
144                    setattr(self, paramName, None)
145                       
146            elif isinstance(paramVal, list):
147                listVal = expVars(self._cfg.get(section, paramName)).split()
148                setattr(self, paramName, listVal)
149            else:
150                val = expVars(self._cfg.get(section, paramName))
151                setattr(self, paramName, val) 
152
153
154    def accessPermitted(self, resrcHandle, userHandle, accessType=None):
155        """Make an access control decision based on whether the user is
156        authenticated and has the required roles
157       
158        @type resrcHandle: dict
159        @param resrcHandle: contains resource groups and user IDs determining
160        access
161       
162        @type userHandle: dict
163        @param userHandle: dict with keys 'sid' = user session ID,
164        'h' = Session Manager URI
165       
166        @type accessType: -
167        @param accessType: not implemented - logs a warning if set
168       
169        @rtype: bool
170        @return: True if access permitted; False if denied or else raise
171        an Exception
172       
173        @type uri: string
174        @param uri: URI corresponding to data granule ID
175       
176        @type: dict
177        @param userHandle: containing user session ID and Session Manager
178        address."""
179
180        # User handle contains 'h' = Session Manager URI and 'sid' user
181        # Session ID
182        try:
183            self.smURI = userHandle['h']
184            self.userSessID = userHandle['sid']
185        except KeyError, e:
186            log.error("User handle missing key %s" % e)
187            raise PDPUserNotLoggedIn()
188
189       
190        # Retrieve Attirbute Certificate from user's session held by
191        # Session Manager
192        attCert = self._pullUserSessionAttCert()
193       
194        # Check its validity
195        self._checkAttCert(attCert)
196           
197        # Check cert against resource constraints
198        self._checkProFTPResrcConstraints(resrcHandle, attCert)
199       
200        # Removed AC content from log message -       
201        # Including the Attribute Certificate makes it appear in stdout?! -
202        # and therefore in the HTML output!
203        log.info('ProftpPDP - access granted for user "%s" ' % \
204                 attCert.userId + 'to "%s"' % \
205                 resrcHandle.get('filePath', "<RESOURCE>"))
206
207
208    def _checkProFTPResrcConstraints(self, resrcHandle, attCert):
209        """Check ProFTP access constraints and set the required role(s) for
210        access.  Perl BADC::FTPaccess and NDG::Security::Client code
211        casrry out preliminary checks e.g. is access to the resource
212        constrained by a .ftpaccess file at all or if one exists is it public
213        access.  This method deals with constraints where comparison with
214        Attribute Certificate is needed i.e. info on what roles the user has
215        and/or their id.
216       
217        @type resrcHandle: dict
218        @param resrcHandle: resource user and group constraints
219        @type attCert: ndg.security.common.AttCert.AttCert
220        @param attCert: user Attribute Certificate
221       
222        @raise PDPUserInsufficientPrivileges: if user doesn't have the
223        required roles or ID for access
224        """
225        log.debug("ProftpPDP._checkProFTPResrcConstraints ...")
226       
227        userRoles = attCert.roles
228        log.debug("user has these roles = %s" % userRoles)
229       
230        # Check based on allowed groups
231        allowedGroups = resrcHandle.get('allowedGroups', [])
232        for allowedGroup in allowedGroups:
233             if allowedGroup in userRoles:
234                 log.info('ProftpPDP: User role "%s" is in .ftpaccess allowed '
235                          'groups: %s'%(allowedGroup,', '.join(allowedGroups)))
236                 return
237                 
238        # User must be in all of these groups
239        requiredGroupSets = resrcHandle.get('requiredGroups', [])
240       
241        # Groups are organised into sets
242        for requiredGroupSet in requiredGroupSets:
243            # Each set must be parsed from a string of groups delimited by
244            # 'and's
245            log.debug("requiredGroupSet = %s" % requiredGroupSet)
246            requiredGroups = requiredGroupSet.split(' and ')
247           
248            userHasAllGroups = True
249            for group in requiredGroups:
250                if group not in userRoles:
251                    userHasAllGroups = False
252                    break
253
254            if userHasAllGroups:
255                log.info('ProftpPDP: User has all the required .ftpaccess '
256                         'groups: %s' % ', '.join(requiredGroups))
257                return
258         
259   
260        allowedUsers = resrcHandle.get('allowedUsers', [])
261       
262        # .ftpaccess expects a user ID but AC user ID may be a X.509 cert.
263        # Distinguished Name - try conversion
264        if attCert.userId == str(attCert.holderDN):
265            username = attCert.holderDN['CN']
266            log.debug('Set username "%s" from AC Holder DN' % username)
267        else:
268            username = attCert.userId
269            log.debug('Set username "%s" from AC user ID' % username)
270           
271        if username in allowedUsers:
272            log.info('ProftpPDP: user ID "%s" is in list of allowed users: '
273                     '"%s"' % (username, '", "'.join(allowedUsers)))
274            return
275       
276       
277        # Catch all - default to deny access
278        log.info('Access denied to resource %s for user "%s" with roles "%s"'%\
279                 (resrcHandle, attCert.userId, '", "'.join(userRoles)))
280        raise PDPUserInsufficientPrivileges()
281   
282       
283    def _pullUserSessionAttCert(self):
284        """Check to see if the Session Manager can deliver an Attribute
285        Certificate with the required role to gain access to the resource
286        in question       
287        """
288       
289        log.debug("ProftpPDP._pullUserSessionAttCert ...")
290        try:
291            # Create Session Manager client
292            self.smClnt = SessionManagerClient(uri=self.smURI,
293                            sslCACertFilePathList=self.sslCACertFilePathList,
294                            tracefile=self.tracefile, 
295                            cfg=self.wssCfgFilePath or self._cfg,
296                            cfgFileSection=self.wssCfgSection,
297                            **self.wssCfg)
298        except Exception, e:
299            log.error("ProftpPDP: creating Session Manager client: %s" % e)
300            raise InitSessionCtxError()
301       
302                 
303        try:
304            # Make request for attribute certificate
305            attCert = self.smClnt.getAttCert(attributeAuthorityURI=self.aaURI,
306                                             sessID=self.userSessID)
307            return attCert
308       
309        except AttributeRequestDenied, e:
310            log.info("ProftpPDP - request for attribute certificate denied: "
311                     "%s" % e)
312            raise PDPUserAccessDenied()
313       
314        except SessionNotFound, e:
315            log.info("ProftpPDP - no session found: %s" % e)
316            raise PDPUserNotLoggedIn()
317
318        except SessionExpired, e:
319            log.info("ProftpPDP - session expired: %s" % e)
320            raise InvalidSessionMsg()
321
322        except SessionCertTimeError, e:
323            log.info("ProftpPDP - session cert. time error: %s" % e)
324            raise InvalidSessionMsg()
325           
326        except InvalidSession, e:
327            log.info("ProftpPDP - invalid user session: %s" % e)
328            raise InvalidSessionMsg()
329
330        except Exception, e:
331            log.error("ProftpPDP request for attribute certificate: %s" % e)
332            raise AttributeCertificateRequestError()
333       
334
335    def _checkAttCert(self, attCert):
336        '''Check attribute certificate is valid
337       
338        @type attCert: ndg.security.common.AttCert.AttCert
339        @param attCert: attribute certificate to be check for validity
340       
341        @raise InvalidAttributeCertificate: if signature is invalid or the
342        issuer DN doesn't match the setting in the PDP config'''
343       
344        attCert.certFilePathList = self.acCACertFilePathList
345        try:
346            attCert.isValid(raiseExcep=True)
347        except Exception, e:
348            log.error("Attribute Certificate: %s" % e)
349            raise InvalidAttributeCertificate() 
350         
351        # Check it's issuer is as expected - Convert to X500DN to do equality
352        # test
353        acIssuerDN = X500DN(self.acIssuer)
354        if attCert.issuerDN != acIssuerDN:
355            log.error('ProftpPDP - access denied: Attribute Certificate issuer'
356                      ' DN, "%s" ' % attCert.issuerDN + \
357                      'must match this data provider\'s Attribute Authority '
358                      'DN: "%s"' % acIssuerDN)
359            raise InvalidAttributeCertificate()
360
361
362    @classmethod
363    def urlCanBeOpened(cls, url, timeout=5, raiseExcep=True):
364       """Check url can be opened - adapted from
365       http://mail.python.org/pipermail/python-list/2004-October/289601.html
366       """
367   
368       found = False
369       defTimeOut = socket.getdefaulttimeout()
370       try:
371           socket.setdefaulttimeout(timeout)
372
373           try:
374               urllib2.urlopen(url)
375           except (urllib2.HTTPError, urllib2.URLError,
376                   socket.error, socket.sslerror, AttributeError):
377               if raiseExcep:
378                   raise URLCannotBeOpened()
379           
380           found = True
381         
382       finally:
383           socket.setdefaulttimeout(defTimeOut)
384           
385       return found
386     
387   
388def makeDecision(resrcHandle, userHandle, accessType=None, **kw):
389    '''One call Wrapper interface to ProftpPDP'''
390    return ProftpPDP(**kw)(resrcHandle, userHandle)
391
392import re
393# Ported from Perl BADC::FTPaccess class but untested! - it's been possible to
394# use the Perl directly instead.  THIS CODE IS NOT IN USE
395class FTPAccess(object):   
396    '''Routines connected with reading .ftpaccess files used by proftp to
397    control access to directories. Intended for use by web cgi programs.
398    Makes some simplifying assumptions about the way the files are used:
399   
400      o Only the 'closest' .ftpaccess file is read
401      o Only the '<limit read>' sections are read
402      o Directories are assumed to be either 'public' (indicated by the
403      presence of 'allowall'), or restricted.
404      o For restricted datasets, no one except the specified users and/or those
405      in the specified groups have access.
406   
407    This file contains a class for reading the .ftpaccess files, plus the
408    'readAccess' routine, which checks if the given file or directory
409    is readable by the current user.
410    '''
411
412    FTPACCESS_FILE = ".ftpaccess";
413   
414    # Regular expression for valid characters in group name
415    GROUP_REGX = "A-Za-z0-9_\-"; 
416   
417    def __init__(self, filePath):
418        '''Constructor of class for reading .ftpaccess file'''
419   
420        self.filePath = filePath
421       
422        # Read lines from file into array striping white spaces and comments     
423        self.lines = [line.strip() for line in open(file).readlines() \
424                      if line.lstrip()[0]!= '#']
425
426
427
428    def extractLimitSection(self, limitSectionName):
429        '''Returns lines of file within specified 'limit' section'''
430        # limitSection = Limit type, eg. 'read', 'write'.
431       
432        startDelimPat = re.compile('<limit.*\s%s[\s>]' % limitSectionName)
433        endDelimitPat = re.compile('<\/limit>')
434        limitSection = []
435        for line in self.lines:
436           if push:
437               if endDelimitPat.match(line):
438                   break
439               limitSection += [line]
440   
441           if startDelimPat.match(line):
442               push = True
443   
444        return limitSection
445   
446
447    def allowedUsers(self, limitSectionName):
448   
449        users = None
450   
451        lines = self.extractLimitSection(limitSectionName)
452     
453        userLinePat = re.compile('^AllowUser')
454        userLines = [line for line in lines if userLinePat.match(line)]
455       
456        userPat = re.compile('allowuser\s+(\w+)')
457        users = []
458        for userLine in userLines:
459            mat = userPat.match(userLine)
460            users += [mat.groups()[0]]
461   
462        return users
463
464    groupLinePat = re.compile('^AllowGroup')
465   
466    def getAllowedGroups(self, limitSectionName):
467        '''Returns list of groups that are allowed access. Ignores any lines
468        containing multiple groups separated by commas.'''
469
470       
471        lines = self.extractLimitSection(limitSectionName);
472       
473        groupLines  = [line for line in lines \
474                       if FTPAccess.groupLinePat.match(line)]
475       
476        groupPat = re.compile('allowgroup\s+([$GROUP_REGX]+)')
477       
478        groups = []
479        for groupLine in groupLines:
480            if ',' in groupLine:
481                # If it's got a comma then ignore this line
482                continue
483           
484            mat = groupPat.match(groupLine)
485            groups += [mat.groups(groupLine)]           
486       
487        return groups; 
488
489
490    def getRequiredGroups(self, limitSectionName):
491        '''Returns list of any group lines which contain multiple groups
492        separated by comas. These groups are ANDed together. This subroutine
493        returns a list containing one entry for each line containing multiple
494        groups (in practice I guess that there will only be one line). Each
495        entry contains the group names separated by ' and '.'''
496       
497        requiredGroups = []
498   
499        lines = self.extractLimitSection(limitSectionName);
500       
501        groupLines  = [line for line in lines \
502                       if FTPAccess.groupLinePat.match(line)]
503     
504        requiredGroupPat = re.compile('^allowgroup\s*/')
505        groupDelimPat = re.compile('\s*,\s*')
506        for groupLine in groupLines:
507            if ',' in groupLine:
508                # Multi-group line found
509                groups = groupDelimPat.split(groupLine[len('AllowGroup'):])
510                groupsTxt = ' and '.join(groups)
511                requiredGroups += [groupsTxt]
512   
513        return requiredGroups; 
514
515
516    def publicAccess(self, type):   
517      lines = self.extractLimitSection(type)
518      return 'allowall' in ''.join(lines)
519
520
521    @classmethod
522    def findAllFTPAccessFiles(cls, filePath):
523        '''Returns the full names of all .ftpaccess files above the given file
524        '''
525
526        #   Remove filename if present
527        if os.path.isdir(filePath):
528            dirPath = filePath
529        else:
530            dirPath = os.path.dirname(filePath)
531   
532        files = []
533        while (dirPath):
534            checkFile = os.path.join(dirPath, cls.FTPACCESS_FILE)
535   
536            if os.path.exists(checkFile):     
537                files += [checkFile]
538
539            # Traverse up a directory
540            parentDirPath = os.path.dirname(dirPath)
541            if parentDirPath == dirPath: # root found
542                dirPath = None
543            else:
544                dirPath = parentDirPath
545
546        return files
547
548
549    @classmethod
550    def findNearestFTPAccessFile(cls, filePath):
551        '''Returns the full name of the .ftpaccess file closest to the given
552        file.'''
553   
554        # Remove filename if present
555        if os.path.isdir(filePath):
556            dirPath = filePath
557        else:
558            dirPath = os.path.dirname(filePath)
559   
560        nearestFTPAccessFile = None
561        while (dirPath):
562            checkFile = os.path.join(dirPath, cls.FTPACCESS_FILE)
563   
564            if os.path.exists(checkFile):     
565                nearestFTPAccessFile = checkFile
566                break
567
568            # Traverse up a directory
569            parentDirPath = os.path.dirname(dirPath)
570            if parentDirPath == dirPath: # root found
571                dirPath = None
572            else:
573                dirPath = parentDirPath
574   
575        return nearestFTPAccessFile
576
577
578    def readAccess(cls, filePath):
579        '''Check access constraints on a file - it can make an access decision
580        if no user attribute info is needed e.g. when constraint is 'public'
581       
582        @rtype: tuple
583        @return: Returns flag indicating if the user is allowed to read the
584        directory containing the given file. Also returns dict giving
585        information about how the result was arived at.'''
586       
587        # Check that we do actually have an ftpaccess file to interogate. If
588        # not then grant read access
589        info = {}
590        ftpAccessFile = cls.findNearestFTPAccessFile(filePath)
591        try:
592            ftpAccess = FTPAccess(ftpAccessFile)
593        except IOError:
594            info['noObj'] = True;
595            return True, info
596
597        info['filePath'] = ftpAccessFile
598
599        #  Check for public access
600        if ftpAccess.publicAccess("read"):
601            info['public'] = True
602            return True, info
603
604        #  Check if user is in one of the allowed groups
605        allowedGroups = ftpAccess.getAllowedGroups("read")   
606        info['allowedGroups'] = allowedGroups
607
608        #  Check any lines that contain multiple groups
609        requiredGroups = ftpAccess.getRequiredGroups("read")
610        info['requiredGroups'] = requiredGroups
611   
612        # Check if the user's username is explicitly granted access
613        allowedUsers = ftpAccess.allowedUsers("read")
614        allowedUsers= info['allowedUsers']
615
616        # False because user info is required to determine access decision
617        return False, info
618   
Note: See TracBrowser for help on using the repository browser.