source: TI12-security/trunk/NDGSecurity/python/ndg_security_server/ndg/security/server/wsgi/authz/pep.py @ 7822

Subversion URL: http://proj.badc.rl.ac.uk/svn/ndg/TI12-security/trunk/NDGSecurity/python/ndg_security_server/ndg/security/server/wsgi/authz/pep.py
Revision 7822, 19.1 KB checked in by pjkersha, 9 years ago (diff)

Incomplete - task 16: NDG Security 2.x.x - incl. updated Paster templates

  • fix for Yadis template substitutions
  • updated svn:keywords: Id property
  • Property svn:keywords set to Id
Line 
1'''NDG Security Policy Enforcement Point Module
2
3__author__ = "P J Kershaw"
4__date__ = "11/07/10"
5__copyright__ = "(C) 2010 Science and Technology Facilities Council"
6__license__ = "BSD - see LICENSE file in top-level directory"
7__contact__ = "Philip.Kershaw@stfc.ac.uk"
8__revision__ = '$Id:$'
9'''
10import logging
11log = logging.getLogger(__name__)
12
13import httplib
14from time import time
15
16import webob
17
18from ndg.saml.saml2.core import DecisionType
19from ndg.saml.saml2.binding.soap.client.authzdecisionquery import \
20                                            AuthzDecisionQuerySslSOAPBinding
21                                           
22from ndg.xacml.core import Identifiers as XacmlIdentifiers
23from ndg.xacml.core import context as _xacmlCtx
24from ndg.xacml.core.attribute import Attribute as XacmlAttribute
25from ndg.xacml.core.attributevalue import (
26    AttributeValueClassFactory as XacmlAttributeValueClassFactory, 
27    AttributeValue as XacmlAttributeValue)
28from ndg.xacml.core.context.result import Decision as XacmlDecision
29from ndg.xacml.core.context.pdp import PDP
30from ndg.xacml.parsers.etree.factory import (
31    ReaderFactory as XacmlPolicyReaderFactory)
32   
33from ndg.security.server.wsgi.session import (SessionMiddlewareBase, 
34                                              SessionHandlerMiddleware)
35from ndg.security.common.credentialwallet import SAMLAssertionWallet
36from ndg.security.common.utils import str2Bool
37
38
39class SamlPepFilterConfigError(Exception):
40    """Error with SAML PEP configuration settings"""
41   
42   
43class SamlPepFilter(SessionMiddlewareBase):
44    '''Policy Enforcement Point for ESG with SAML based Interface
45   
46    @requires: ndg.security.server.wsgi.session.SessionHandlerMiddleware
47    instance upstream in the WSGI stack.
48   
49    @cvar AUTHZ_DECISION_QUERY_PARAMS_PREFIX: prefix for SAML authorisation
50    decision query options in config file
51    @type AUTHZ_DECISION_QUERY_PARAMS_PREFIX: string
52   
53    @cvar PARAM_NAMES: list of config option names
54    @type PARAM_NAMES: tuple
55   
56    @ivar __client: SAML authorisation decision query client
57    @type __client: ndg.saml.saml2.binding.soap.client.authzdecisionquery.AuthzDecisionQuerySslSOAPBinding
58    '''
59    AUTHZ_SERVICE_URI = 'authzServiceURI'
60    AUTHZ_DECISION_QUERY_PARAMS_PREFIX = 'authzDecisionQuery.'
61    SESSION_KEY_PARAM_NAME = 'sessionKey'
62    CACHE_DECISIONS_PARAM_NAME = 'cacheDecisions'   
63    LOCAL_POLICY_FILEPATH_PARAM_NAME = 'localPolicyFilePath'
64   
65    CREDENTIAL_WALLET_SESSION_KEYNAME = \
66        SessionHandlerMiddleware.CREDENTIAL_WALLET_SESSION_KEYNAME
67    USERNAME_SESSION_KEYNAME = \
68        SessionHandlerMiddleware.USERNAME_SESSION_KEYNAME
69   
70    PARAM_NAMES = (
71        AUTHZ_SERVICE_URI,
72        SESSION_KEY_PARAM_NAME,
73        CACHE_DECISIONS_PARAM_NAME,
74        LOCAL_POLICY_FILEPATH_PARAM_NAME
75    )
76   
77    XACML_ATTRIBUTEVALUE_CLASS_FACTORY = XacmlAttributeValueClassFactory()
78   
79    __slots__ = (
80        '_app', '__client', '__session', '__localPdp'
81    ) + tuple(('__' + '$__'.join(PARAM_NAMES)).split('$'))
82           
83    def __init__(self, app):
84        '''
85        Add reference to next WSGI middleware/app and create a SAML
86        authorisation decision query client interface
87        '''
88        self._app = app
89        self.__client = AuthzDecisionQuerySslSOAPBinding()
90        self.__session = None
91        self.__authzServiceURI = None
92        self.__sessionKey = None
93        self.__cacheDecisions = False
94        self.__localPdp = None
95        self.__localPolicyFilePath = None
96
97    def _getLocalPolicyFilePath(self):
98        return self.__localPolicyFilePath
99
100    def _setLocalPolicyFilePath(self, value):
101        if not isinstance(value, basestring):
102            raise TypeError('Expecting string type for "localPolicyFilePath" '
103                            'attribute; got %r' % type(value))
104           
105        self.__localPolicyFilePath = value
106
107    localPolicyFilePath = property(_getLocalPolicyFilePath, 
108                                   _setLocalPolicyFilePath, 
109                                   doc="Policy file path for local PDP. It's "
110                                       "initialised to None in which case the "
111                                       "local PDP is disabled and all access "
112                                       "control queries will be routed through "
113                                       "to the authorisation service")
114
115    def _getLocalPdp(self):
116        return self.__localPdp
117
118    def _setLocalPdp(self, value):
119        self.__localPdp = value
120
121    localPdp = property(_getLocalPdp, _setLocalPdp, 
122                        doc="File path for a local PDP which can be used to "
123                            "filters requests from the authorisation service "
124                            "so avoiding the web service call performance "
125                            "penalty")
126
127    def _getClient(self):
128        return self.__client
129
130    def _setClient(self, value):
131        if not isinstance(value, basestring):
132            raise TypeError('Expecting string type for "client" attribute; '
133                            'got %r' % type(value))
134        self.__client = value
135
136    client = property(_getClient, _setClient, 
137                      doc="SAML authorisation decision query SOAP client")
138
139    def _getSession(self):
140        return self.__session
141
142    def _setSession(self, value):
143        self.__session = value
144
145    session = property(_getSession, _setSession, 
146                       doc="Beaker Security Session instance")
147
148    def _getAuthzServiceURI(self):
149        return self.__authzServiceURI
150
151    def _setAuthzServiceURI(self, value):
152        if not isinstance(value, basestring):
153            raise TypeError('Expecting string type for "authzServiceURI" '
154                            'attribute; got %r' % type(value))
155        self.__authzServiceURI = value
156
157    authzServiceURI = property(_getAuthzServiceURI, _setAuthzServiceURI, 
158                               doc="Authorisation Service URI")
159
160    def _getSessionKey(self):
161        return self.__sessionKey
162
163    def _setSessionKey(self, value):
164        if not isinstance(value, basestring):
165            raise TypeError('Expecting string type for "sessionKey" attribute; '
166                            'got %r' % type(value))
167        self.__sessionKey = value
168
169    sessionKey = property(_getSessionKey, _setSessionKey, 
170                          doc="environ key name for Beaker session object")
171
172    def _getCacheDecisions(self):
173        return self.__cacheDecisions
174
175    def _setCacheDecisions(self, value):
176        if isinstance(value, basestring):
177            self.__cacheDecisions = str2Bool(value)
178        elif isinstance(value, bool):
179            self.__cacheDecisions = value
180        else:
181            raise TypeError('Expecting bool/string type for "cacheDecisions" '
182                            'attribute; got %r' % type(value))
183       
184    cacheDecisions = property(_getCacheDecisions, _setCacheDecisions, 
185                              doc="Set to True to make the session cache "
186                                  "authorisation decisions returned from the "
187                                  "Authorisation Service")
188   
189    def initialise(self, prefix='', **kw):
190        '''Initialise object from keyword settings
191       
192        @type prefix: basestring
193        @param prefix: prefix for configuration items
194        @type kw: dict       
195        @param kw: configuration settings
196        dictionary
197        @raise SamlPepFilterConfigError: missing option setting(s)
198        '''
199        # Parse authorisation decision query options
200        queryPrefix = prefix + self.__class__.AUTHZ_DECISION_QUERY_PARAMS_PREFIX
201        self.client.parseKeywords(prefix=queryPrefix, **kw)
202           
203        # Parse other options
204        for name in SamlPepFilter.PARAM_NAMES:
205            paramName = prefix + name
206            value = kw.get(paramName)
207           
208            if value is not None:
209                setattr(self, name, value)
210               
211            elif name != self.__class__.LOCAL_POLICY_FILEPATH_PARAM_NAME:
212                # Policy file setting is optional
213                raise SamlPepFilterConfigError('Missing option %r' % paramName)
214           
215        # Initialise the local PDP 
216        if self.localPolicyFilePath:
217            self.__localPdp = PDP.fromPolicySource(self.localPolicyFilePath, 
218                                                   XacmlPolicyReaderFactory)
219                   
220    @classmethod
221    def filter_app_factory(cls, app, global_conf, prefix='', **app_conf):
222        """Set-up using a Paste app factory pattern. 
223       
224        @type app: callable following WSGI interface
225        @param app: next middleware application in the chain     
226        @type global_conf: dict       
227        @param global_conf: PasteDeploy global configuration dictionary
228        @type prefix: basestring
229        @param prefix: prefix for configuration items
230        @type app_conf: dict       
231        @param app_conf: PasteDeploy application specific configuration
232        dictionary
233        """
234        app = cls(app)
235        app.initialise(prefix=prefix, **app_conf)
236       
237        return app
238               
239    def __call__(self, environ, start_response):
240        """Intercept request and call authorisation service to make an access
241        control decision
242       
243        @type environ: dict
244        @param environ: WSGI environment variables dictionary
245        @type start_response: function
246        @param start_response: standard WSGI start response function
247        @rtype: iterable
248        @return: response
249        """
250        # Get reference to session object - SessionHandler middleware must be in
251        # place upstream of this middleware in the WSGI stack
252        if self.sessionKey not in environ:
253            raise SamlPepFilterConfigError('No beaker session key "%s" found '
254                                           'in environ' % self.sessionKey)
255        self.session = environ[self.sessionKey]
256
257        return self.enforce(environ, start_response)
258
259    def enforce(self, environ, start_response):
260        """Get access control decision from PDP(s) and enforce the decision
261       
262        @type environ: dict
263        @param environ: WSGI environment variables dictionary
264        @type start_response: function
265        @param start_response: standard WSGI start response function
266        @rtype: iterable
267        @return: response
268        """
269        request = webob.Request(environ)
270        requestURI = request.url
271       
272        # Apply local PDP if set
273        if not self.isApplicableRequest(requestURI):
274            # The local PDP has returned a decision that the requested URI is
275            # not applicable and so the authorisation service need not be
276            # invoked.  This step is an efficiency measure to avoid multiple
277            # callouts to the authorisation service for resources which
278            # obviously don't need any restrictions
279            return self._app(environ, start_response)
280           
281        # Check for cached decision
282        if self.cacheDecisions:
283            assertions = self._retrieveCachedAssertions(requestURI)
284        else:
285            assertions = None 
286             
287        noCachedAssertion = assertions is None or len(assertions) == 0
288        if noCachedAssertion:
289            # No stored decision in cache, invoke the authorisation service   
290            self.client.resourceURI = request.url
291           
292            # Nb. user may not be logged in hence REMOTE_USER is not set
293            self.client.subjectID = request.remote_user or ''
294           
295            samlAuthzResponse = self.client.send(uri=self.__authzServiceURI)
296            assertions = samlAuthzResponse.assertions
297           
298            # Record the result in the user's session to enable later
299            # interrogation by any result handler Middleware
300            self.saveResultCtx(self.client.query, samlAuthzResponse)
301       
302       
303        # Set HTTP 403 Forbidden response if any of the decisions returned are
304        # deny or indeterminate status
305        failDecisions = (DecisionType.DENY, DecisionType.INDETERMINATE)
306       
307        # Review decision statement(s) in assertions and enforce the decision
308        assertion = None
309        for assertion in assertions:
310            for authzDecisionStatement in assertion.authzDecisionStatements:
311                if authzDecisionStatement.decision.value in failDecisions:
312                    response = webob.Response()
313                   
314                    if not self.client.subjectID:
315                        # Access failed and the user is not logged in
316                        response.status = httplib.UNAUTHORIZED
317                    else:
318                        # The user is logged in but not authorised
319                        response.status = httplib.FORBIDDEN
320                       
321                    response.body = 'Access denied to %r for user %r' % (
322                                                     self.client.resourceURI,
323                                                     self.client.subjectID)
324                    response.content_type = 'text/plain'
325                    log.info(response.body)
326                    return response(environ, start_response)
327
328        if assertion is None:
329            log.error("No assertions set in authorisation decision response "
330                      "from %r", self.authzServiceURI)
331           
332            response = webob.Response()
333            response.status = httplib.FORBIDDEN
334            response.body = ('An error occurred retrieving an access decision '
335                             'for %r for user %r' % (
336                                             self.client.resourceURI,
337                                             self.client.subjectID))
338            response.content_type = 'text/plain'
339            log.info(response.body)
340            return response(environ, start_response)     
341               
342        # Cache assertion if flag is set and it's one that's been freshly
343        # obtained from an authorisation decision query rather than one
344        # retrieved from the cache
345        if self.cacheDecisions and noCachedAssertion:
346            self._cacheAssertions(request.url, [assertion])
347           
348        # If got through to here then all is well, call next WSGI middleware/app
349        return self._app(environ, start_response)
350
351    def _retrieveCachedAssertions(self, resourceId):
352        """Return assertions containing authorisation decision for the given
353        resource ID.
354       
355        @param resourceId: search for decisions for this resource Id
356        @type resourceId: basestring
357        @return: assertion containing authorisation decision for the given
358        resource ID or None if no wallet has been set or no assertion was
359        found matching the input resource Id
360        @rtype: ndg.saml.saml2.core.Assertion / None type
361        """
362        # Get reference to wallet
363        walletKeyName = self.__class__.CREDENTIAL_WALLET_SESSION_KEYNAME
364        credWallet = self.session.get(walletKeyName)
365        if credWallet is None:
366            return None
367       
368        # Wallet has a dictionary of credential objects keyed by resource ID
369        return credWallet.retrieveCredentials(resourceId)
370       
371    def _cacheAssertions(self, resourceId, assertions):
372        """Cache an authorisation decision from a response retrieved from the
373        authorisation service.  This is invoked only if cacheDecisions boolean
374        is set to True
375       
376        @param resourceId: search for decisions for this resource Id
377        @type resourceId: basestring
378        @param assertions: list of SAML assertions containing authorisation
379        decision statements
380        @type assertions: iterable
381        """
382        walletKeyName = self.__class__.CREDENTIAL_WALLET_SESSION_KEYNAME
383        credWallet = self.session.get(walletKeyName)
384        if credWallet is None:
385            credWallet = SAMLAssertionWallet()
386       
387        credWallet.addCredentials(resourceId, assertions)
388        self.session[walletKeyName] = credWallet
389        self.session.save()
390       
391    def saveResultCtx(self, request, response, save=True):
392        """Set PEP context information in the Beaker session using standard key
393        names.  This is a snapshot of the last request and the response
394        received.  It can be used by downstream middleware to provide contextual
395        information about authorisation decisions
396       
397        @param session: beaker session
398        @type session: beaker.session.SessionObject
399        @param request: authorisation decision query
400        @type request: ndg.saml.saml2.core.AuthzDecisionQuery
401        @param response: authorisation response
402        @type response: ndg.saml.saml2.core.Response
403        @param save: determines whether session is saved or not
404        @type save: bool
405        """
406        self.session[self.__class__.PEPCTX_SESSION_KEYNAME] = {
407            self.__class__.PEPCTX_REQUEST_SESSION_KEYNAME: request, 
408            self.__class__.PEPCTX_RESPONSE_SESSION_KEYNAME: response,
409            self.__class__.PEPCTX_TIMESTAMP_SESSION_KEYNAME: time()
410        }
411       
412        if save:
413            self.session.save()     
414
415    PDP_DENY_RESPONSES = (
416        XacmlDecision.DENY_STR, XacmlDecision.INDETERMINATE_STR
417    )
418   
419    def isApplicableRequest(self, resourceURI):
420        """A local PDP can filter out some requests to avoid the need to call
421        out to the authorisation service
422       
423        @param resourceURI: URI of requested resource
424        @type resourceURI: basestring
425        """
426        if self.__localPdp is None:
427            log.debug("No Local PDP set: passing on request to main "
428                      "authorisation service...")
429            return True
430       
431        xacmlRequest = self._createXacmlRequestCtx(resourceURI)
432        xacmlResponse = self.__localPdp.evaluate(xacmlRequest)
433        for result in xacmlResponse.results:
434            if result.decision.value != XacmlDecision.NOT_APPLICABLE_STR:
435                log.debug("Local PDP returned %s decision, passing request on "
436                          "to main authorisation service ...", 
437                          result.decision.value)
438                return True
439           
440        return False
441
442    def _createXacmlRequestCtx(self, resourceURI):
443        """Wrapper to create a request context for a local PDP - see
444        isApplicableRequest
445       
446        @param resourceURI: URI of requested resource
447        @type resourceURI: basestring
448        """
449        request = _xacmlCtx.request.Request()
450       
451        resource = _xacmlCtx.request.Resource()
452        resourceAttribute = XacmlAttribute()
453        resource.attributes.append(resourceAttribute)
454       
455        resourceAttribute.attributeId = XacmlIdentifiers.Resource.RESOURCE_ID
456                                       
457        XacmlAnyUriAttributeValue = \
458            self.__class__.XACML_ATTRIBUTEVALUE_CLASS_FACTORY(
459                                            XacmlAttributeValue.ANY_TYPE_URI)
460                                   
461        resourceAttribute.dataType = XacmlAnyUriAttributeValue.IDENTIFIER
462        resourceAttribute.attributeValues.append(XacmlAnyUriAttributeValue())
463        resourceAttribute.attributeValues[-1].value = resourceURI
464
465        request.resources.append(resource)
466       
467        return request
468       
469
Note: See TracBrowser for help on using the repository browser.