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

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

Fixes to ndg.security.common.authz.pdp.proftp - Pro-FTP based Polciy Decision Point. Tested on zonda.

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