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

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

Fixes following update to NOCS deployment.

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