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

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

python/Tests/authtest: Pylons AuthKit? OpenID test code - added error2 controller to experiment with WCS AuthKit? IndexError?.

python/ndg.security.common/ndg/security/common/wssecurity/etree.py,
python/ndg.security.common/ndg/security/common/XMLSec.py: fixes following addition of ElementTree XMLSec implementation for signing for SAML Attribute Assertions

python/ndg.security.common/ndg/security/common/xmlsec/etree.py: ElementTree implementation of enveloped signature. Use with new ElementTree based SAML Attribute Assertion code.

python/ndg.security.common/ndg/security/common/authz/pdp/browse.py,

python/ndg.security.common/ndg/security/common/saml/init.py: new SAML Attribute Assertion implementation to replace NDG Attribute Certificate. Hacked from AttCert? module - much work to go.

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