Repository: CMSgov/bluebutton-web-server

Issue:
Add URL pattern mating support to Protected Capability
Add URL pattern mating support to Protected Capability model.  For example, something like this could work:

[
    ["POST", "/fhir/v3/Practitioner"],
    ["PUT", "/fhir/v3/Practitioner/[id]"],
    ["POST", "/fhir/v3/Organization"],
    ["PUT", "/fhir/v3/Organization/[id]"],
    ["POST", "/npi/v1/update-all/[npi]"],
    ["POST", "/npi/v1/update-partial/[npi]"],
    ["GET", "/fhir/v3/metadata"]
]

Perhaps how the pattern format is expressed in the form should simply match how django URLs work. I leave that to you how best to address.



Discussion:

Resolving patch:
diff --git a/apps/capabilities/models.py b/apps/capabilities/models.py
--- a/apps/capabilities/models.py
+++ b/apps/capabilities/models.py
@@ -2,10 +2,18 @@
 from __future__ import unicode_literals
 
 import json
+import re
+
+try:
+    # this import should work on python2 only
+    from itertools import izip as zip
+except ImportError:
+    pass
 
 from django.db import models
-from django.utils.encoding import python_2_unicode_compatible
 from django.contrib.auth.models import Group
+from django.utils.encoding import python_2_unicode_compatible
+from django.utils.lru_cache import lru_cache
 
 
 @python_2_unicode_compatible
@@ -35,9 +43,63 @@ def resources_as_dict(self):
                 protected_resources[method].append(path)
         return protected_resources
 
+    def allow(self, method, path):
+        """
+        Check if the capability allow access for `method` and `path`.
+        """
+        resources = self.resources_as_dict()
+        for allowed_path in resources.get(method, []):
+            if _match(path, allowed_path):
+                return True
+        return False
+
     def scope(self):
         return self.slug
 
     class Meta:
         verbose_name_plural = "Protected Capabilities"
         verbose_name        = "Protected Capability"
+
+
+@lru_cache()
+def _tokenize_path(path):
+    """
+    Helper function that removes trailing slash
+    and split the path into bits.
+
+    e.g.: "/api/foo/" -> ["", "api", "foo"]
+    """
+    return path.rstrip("/").split("/")
+
+
+URL_BIT_PATTERN = re.compile(r"\[.*\]")
+
+
+@lru_cache()
+def _match(request_path, allowed_path):
+    """
+    Helper function that check if request_path matches with allowed_path
+    """
+    # normalize and tokenize both paths
+    # from '/api/foo' to ['', 'api', 'foo']
+    request_tokens = _tokenize_path(request_path)
+    allowed_tokens = _tokenize_path(allowed_path)
+
+    # if the number of tokens is different we know
+    # that the request path does not match the allowed path
+    if len(request_tokens) != len(allowed_tokens):
+        return False
+
+    # otherwise we start the comparison token by token
+    for request_token, allowed_token in zip(request_tokens, allowed_tokens):
+        # if the allowed token matches a pattern like "[id]"
+        # the match is valid and move to the next token
+        if URL_BIT_PATTERN.match(allowed_token):
+            continue
+
+        # we can break the loop and return False
+        # when we encounter the first non-matching token
+        if request_token != allowed_token:
+            return False
+
+    return True
diff --git a/apps/dot_ext/models.py b/apps/dot_ext/models.py
--- a/apps/dot_ext/models.py
+++ b/apps/dot_ext/models.py
@@ -48,13 +48,11 @@ def allow_resource(self, method, path):
                      self.name, method, path)
 
         for scope in self.scope.all():
-            resources = scope.resources_as_dict()
-            logger.debug("application '%s': checking access with scope '%s'", self.name, resources)
-            # both paths are normalized removing trailing slash
-            for allowed_path in resources.get(method, []):
-                if path.rstrip('/') == allowed_path.rstrip('/'):
-                    logger.info(self._messages['resource_allowed'], self.name, method, path)
-                    return True
+            logger.debug("application '%s': checking access with scope '%s'",
+                         self.name, scope.protected_resources)
+            if scope.allow(method, path):
+                logger.info(self._messages['resource_allowed'], self.name, method, path)
+                return True
 
         logger.info(self._messages['resource_forbidden'], self.name, method, path)
         return False


Test patch:
diff --git a/apps/dot_ext/tests.py b/apps/dot_ext/tests.py
--- a/apps/dot_ext/tests.py
+++ b/apps/dot_ext/tests.py
@@ -36,8 +36,22 @@ def test_allow_resource_method_return_false_for_forbidden_resource(self):
         self.assertFalse(app.allow_resource('GET', '/api/foo/'))
 
     def test_allow_resource_method_return_true_for_allowed_resource(self):
-        # create an empty capability
+        # create a capability
         capability = self._create_capability('Capability', [['GET', '/api/foo/']])
         # create an application with the capability
         app = self._create_application('john_app', capability=capability)
         self.assertTrue(app.allow_resource('GET', '/api/foo/'))
+
+    def test_allow_resource_with_patterns(self):
+        # create a capability that uses urls with '[*]' patterns
+        capability = self._create_capability(
+            'Capability', [
+                ['GET', '/api/foo/[id]/'],
+                ['GET', '/api/foo/[id]/bar/[id]'],
+            ]
+        )
+        # create an application with the capability
+        app = self._create_application('john_app', capability=capability)
+        self.assertFalse(app.allow_resource('GET', '/api/foo/'))
+        self.assertTrue(app.allow_resource('GET', '/api/foo/1/'))
+        self.assertTrue(app.allow_resource('GET', '/api/foo/1/bar/2'))