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

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

Working version with independent Policy Enforcement Point (Gatekeeper) + Polciy Decision Point for Pylons Browse code stack

python/ndg.security.server/ndg/security/server/sso/sso/controllers/login.py: extra help info in message for login error

python/ndg.security.test/ndg/security/test/wsSecurity/server/echoServer.py: mod to SignatureHandler? init due to change in WSSecurityConfig interface

python/Tests/authtest/authtest/controllers/test2.py,
python/Tests/authtest/authtest/lib/template.py: missed out on last check in

python/ndg.security.common/ndg/security/common/authz/pdp/proftp.py: udpate to init following change to PDPInterface class for browse code

python/ndg.security.common/ndg/security/common/authz/pdp/init.py: PDPInterface takes cfg keyword which can be file path or a ConfigParser? object

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

  • fixes to XPath queries.
  • BrowsePDP now does some more of the work done previously by ows_server.models.ndgInterface.GateKeep and queries directly for role and AA values direct from the doc root
  • made fix to WS-Security settings - may be picked up from the same config file as the PDP settings

python/ndg.security.common/ndg/security/common/authz/pep.py,
python/ndg.security.common/ndg/security/common/wsSecurity.py: allow generic cfg keyword for file path / config obj input

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