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

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