Repository: pycasbin/fastapi-authz (language: python)

Issue:
Make a FastAPI authorization middleware
There are people looking for it. We will maintain an official middleware at this repo.

- https://github.com/tiangolo/fastapi/issues/745
- https://github.com/tiangolo/fastapi/issues/765


Discussion / hints:

Fix patch:
diff --git a/.gitignore b/.gitignore
index b6e4761..b24a7ab 100644
--- a/.gitignore
+++ b/.gitignore
@@ -127,3 +127,7 @@ dmypy.json
 
 # Pyre type checker
 .pyre/
+
+.idea/
+
+test/
\ No newline at end of file
diff --git a/dev-requirements.in b/dev-requirements.in
new file mode 100644
index 0000000..f965d86
--- /dev/null
+++ b/dev-requirements.in
@@ -0,0 +1,11 @@
+fastapi
+casbin
+pytest
+pip-tools
+pytest-cov
+coverage
+pypi-publisher
+bumpversion
+uvicorn
+starlette-auth-toolkit
+requests
\ No newline at end of file
diff --git a/dev-requirements.txt b/dev-requirements.txt
new file mode 100644
index 0000000..51e0d09
--- /dev/null
+++ b/dev-requirements.txt
@@ -0,0 +1,85 @@
+#
+# This file is autogenerated by pip-compile
+# To update, run:
+#
+#    pip-compile dev-requirements.in
+#
+--index-url https://mirrors.aliyun.com/pypi/simple/
+
+atomicwrites==1.4.0
+    # via pytest
+attrs==20.3.0
+    # via pytest
+bump2version==1.0.1
+    # via bumpversion
+bumpversion==0.6.0
+    # via -r dev-requirements.in
+casbin==0.18.1
+    # via -r dev-requirements.in
+certifi==2020.12.5
+    # via requests
+chardet==4.0.0
+    # via requests
+click==7.1.2
+    # via
+    #   pip-tools
+    #   uvicorn
+colorama==0.4.4
+    # via pytest
+coverage==5.4
+    # via
+    #   -r dev-requirements.in
+    #   pytest-cov
+fastapi==0.63.0
+    # via -r dev-requirements.in
+gitdb==4.0.5
+    # via gitpython
+gitpython==0.3.6
+    # via pypi-publisher
+h11==0.12.0
+    # via uvicorn
+idna==2.10
+    # via requests
+iniconfig==1.1.1
+    # via pytest
+packaging==20.9
+    # via pytest
+pip-tools==5.5.0
+    # via -r dev-requirements.in
+pluggy==0.13.1
+    # via pytest
+py==1.10.0
+    # via pytest
+pydantic==1.7.3
+    # via fastapi
+pyparsing==2.4.7
+    # via packaging
+pypi-publisher==0.0.4
+    # via -r dev-requirements.in
+pytest-cov==2.11.1
+    # via -r dev-requirements.in
+pytest==6.2.2
+    # via
+    #   -r dev-requirements.in
+    #   pytest-cov
+requests==2.25.1
+    # via -r dev-requirements.in
+simpleeval==0.9.10
+    # via casbin
+smmap==3.0.5
+    # via gitdb
+starlette-auth-toolkit==0.5.0
+    # via -r dev-requirements.in
+starlette==0.13.6
+    # via
+    #   fastapi
+    #   starlette-auth-toolkit
+toml==0.10.2
+    # via pytest
+urllib3==1.26.3
+    # via requests
+uvicorn==0.13.4
+    # via -r dev-requirements.in
+
+# The following packages are considered to be unsafe in a requirements file:
+# pip
diff --git a/examples/rbac_model.conf b/examples/rbac_model.conf
new file mode 100644
index 0000000..0d4f321
--- /dev/null
+++ b/examples/rbac_model.conf
@@ -0,0 +1,14 @@
+[request_definition]
+r = sub, obj, act
+
+[policy_definition]
+p = sub, obj, act
+
+[role_definition]
+g = _, _
+
+[policy_effect]
+e = some(where (p.eft == allow))
+
+[matchers]
+m = (p.sub == "*" || g(r.sub, p.sub)) && (r.obj == p.obj || keyMatch(r.obj, p.obj)) && (p.act == "*" || r.act == p.act)
\ No newline at end of file
diff --git a/examples/rbac_policy.csv b/examples/rbac_policy.csv
new file mode 100644
index 0000000..ce17ef3
--- /dev/null
+++ b/examples/rbac_policy.csv
@@ -0,0 +1,11 @@
+p, alice, /dataset1/*, GET
+p, alice, /dataset1/resource1, POST
+p, bob, /dataset2/resource1, *
+p, bob, /dataset2/resource2, GET
+p, bob, /dataset2/folder1/*, POST
+p, dataset1_admin, /dataset1/*, *
+p, *, /login, *
+
+p, anonymous, /, GET
+
+g, cathy, dataset1_admin
\ No newline at end of file
diff --git a/fastapi_authz/__init__.py b/fastapi_authz/__init__.py
new file mode 100644
index 0000000..bed8a2b
--- /dev/null
+++ b/fastapi_authz/__init__.py
@@ -0,0 +1,1 @@
+from .middleware import CasbinMiddleware
\ No newline at end of file
diff --git a/fastapi_authz/middleware.py b/fastapi_authz/middleware.py
new file mode 100644
index 0000000..02bd5f4
--- /dev/null
+++ b/fastapi_authz/middleware.py
@@ -0,0 +1,67 @@
+from casbin.enforcer import Enforcer
+from starlette.authentication import BaseUser
+from starlette.requests import Request
+from starlette.responses import JSONResponse
+from starlette.status import HTTP_403_FORBIDDEN
+from starlette.types import ASGIApp, Receive, Scope, Send
+
+
+class CasbinMiddleware:
+    """
+    Middleware for Casbin
+    """
+
+    def __init__(
+            self,
+            app: ASGIApp,
+            enforcer: Enforcer,
+    ) -> None:
+        """
+        Configure Casbin Middleware
+
+        :param app:Retain for ASGI.
+        :param enforcer:Casbin Enforcer, must be initialized before FastAPI start.
+        """
+        self.app = app
+        self.enforcer = enforcer
+
+    async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
+        if scope["type"] not in ("http", "websocket"):
+            await self.app(scope, receive, send)
+            return
+
+        if self._enforce(scope, receive):
+            await self.app(scope, receive, send)
+            return
+        else:
+            response = JSONResponse(
+                status_code=HTTP_403_FORBIDDEN,
+                content="Forbidden"
+            )
+
+            await response(scope, receive, send)
+            return
+
+    def _enforce(self, scope: Scope, receive: Receive) -> bool:
+        """
+        Enforce a request
+
+        :param user: user will be sent to enforcer
+        :param request: ASGI Request
+        :return: Enforce Result
+        """
+
+        request = Request(scope, receive)
+
+        path = request.url.path
+        method = request.method
+        if 'user' not in scope:
+            raise RuntimeError("Casbin Middleware must work with an Authentication Middleware")
+
+        assert isinstance(request.user, BaseUser)
+
+        user = request.user.display_name if request.user.is_authenticated else 'anonymous'
+
+        print(user, path, method)
+
+        return self.enforcer.enforce(user, path, method)
diff --git a/requirements.in b/requirements.in
new file mode 100644
index 0000000..e921e39
--- /dev/null
+++ b/requirements.in
@@ -0,0 +1,2 @@
+fastapi
+casbin
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..a767a13
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,17 @@
+#
+# This file is autogenerated by pip-compile
+# To update, run:
+#
+#    pip-compile requirements.in
+#
+
+casbin==0.18.1
+    # via -r requirements.in
+fastapi==0.63.0
+    # via -r requirements.in
+pydantic==1.7.3
+    # via fastapi
+simpleeval==0.9.10
+    # via casbin
+starlette==0.13.6
+    # via fastapi
diff --git a/setup.cfg b/setup.cfg
new file mode 100644
index 0000000..cb4a338
--- /dev/null
+++ b/setup.cfg
@@ -0,0 +1,5 @@
+[bdist_wheel]
+universal=1
+
+[metadata]
+description-file=README.md
\ No newline at end of file
diff --git a/setup.py b/setup.py
new file mode 100644
index 0000000..239d625
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,64 @@
+from setuptools import setup, find_packages
+from codecs import open
+from os import path
+
+__version__ = "0.0.1"
+desc_file = "README.md"
+
+here = path.abspath(path.dirname(__file__))
+
+# Get the long description from the README file
+with open(path.join(here, desc_file), encoding="utf-8") as f:
+    long_description = f.read()
+
+# get the dependencies and installs
+with open(path.join(here, "requirements.txt"), encoding="utf-8") as f:
+    all_reqs = f.read().split("\n")
+
+install_requires = [x.strip() for x in all_reqs if "git+" not in x]
+dependency_links = [
+    x.strip().replace("git+", "") for x in all_reqs if x.startswith("git+")
+]
+
+setup(
+    name="fastapi-authz",
+    version=__version__,
+    description="An authorization middleware for FastAPI that supports ACL, RBAC, ABAC, based on PyCasbin",
+    long_description=long_description,
+    long_description_content_type="text/markdown",
+    author=["Zxilly"],
+    author_email="zhouxinyu1001@gmail.com",
+    url="https://github.com/pycasbin/fastapi-authz",
+    download_url="https://github.com/pycasbin/fastapi-authz/tarball/" + __version__,
+    license="Apache 2.0",
+    python_requires=">=3.6",
+    classifiers=[
+        "Development Status :: 3 - Alpha",
+        "Intended Audience :: Developers",
+        "Programming Language :: Python :: 3",
+        "Programming Language :: Python :: 3.7",
+        "Programming Language :: Python :: 3.8",
+        "License :: OSI Approved :: Apache Software License",
+        "Operating System :: OS Independent",
+    ],
+    keywords=[
+        "fastapi",
+        "starlette",
+        "middleware",
+        "pycasbin",
+        "casbin",
+        "auth",
+        "authz",
+        "acl",
+        "rbac",
+        "abac",
+        "access control",
+        "authorization",
+        "permission"
+    ],
+    packages=find_packages(exclude=["docs", "test*"]),
+    data_files=[desc_file],
+    include_package_data=True,
+    install_requires=install_requires,
+    dependency_links=dependency_links
+)


Test patch:
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..bb377e8
--- /dev/null
+++ b/tests/__init__.py
@@ -0,0 +1,1 @@
+import pytest
\ No newline at end of file
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..6149b6d
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,44 @@
+import base64
+import binascii
+import os
+
+import casbin
+import pytest
+from fastapi import FastAPI
+from starlette.authentication import AuthenticationBackend, AuthenticationError, AuthCredentials, SimpleUser
+from starlette.middleware.authentication import AuthenticationMiddleware
+
+from fastapi_authz import CasbinMiddleware
+
+
+def get_examples(path):
+    examples_path = os.path.split(os.path.realpath(__file__))[0] + "/../examples/"
+    return os.path.abspath(examples_path + path)
+
+
+class BasicAuth(AuthenticationBackend):
+    async def authenticate(self, request):
+        if "Authorization" not in request.headers:
+            return None
+
+        auth = request.headers["Authorization"]
+        try:
+            scheme, credentials = auth.split()
+            decoded = base64.b64decode(credentials).decode("ascii")
+        except (ValueError, UnicodeDecodeError, binascii.Error):
+            raise AuthenticationError("Invalid basic auth credentials")
+
+        username, _, password = decoded.partition(":")
+        return AuthCredentials(["authenticated"]), SimpleUser(username)
+
+
+@pytest.fixture
+def app_fixture():
+    enforcer = casbin.Enforcer(get_examples("rbac_model.conf"), get_examples("rbac_policy.csv"))
+
+    app = FastAPI()
+
+    app.add_middleware(CasbinMiddleware, enforcer=enforcer)
+    app.add_middleware(AuthenticationMiddleware, backend=BasicAuth())
+
+    yield app
diff --git a/tests/test_middleware.py b/tests/test_middleware.py
new file mode 100644
index 0000000..de41800
--- /dev/null
+++ b/tests/test_middleware.py
@@ -0,0 +1,45 @@
+import pytest
+from starlette.testclient import TestClient
+
+
+@pytest.mark.parametrize(
+    "test_server_path, test_client_path, method, status_code, user, response_body", [
+        ('/dataset1/resource2', '/dataset1/resource2', 'GET', 200, 'alice', 'ok'),
+        ('/dataset1/resource2', '/dataset1/resource2', 'GET', 403, 'notalice', 'Forbidden'),
+        ('/dataset1/resource1', '/dataset1/resource1', 'POST', 200, 'alice', 'ok'),
+    ]
+)
+def test_middleware_authed(app_fixture, test_server_path, test_client_path, method, status_code, user, response_body):
+    @getattr(app_fixture, method.lower())(test_server_path)
+    async def index():
+        return 'ok'
+
+    test_client = TestClient(app_fixture)
+
+    test_response = getattr(test_client, method.lower())(test_client_path, auth=(user, 'password'))
+
+    assert test_response.status_code == status_code
+    assert test_response.json() == response_body
+
+
+@pytest.mark.parametrize(
+    "test_server_path, test_client_path, method, status_code, response_body", [
+        ('/login', '/login', 'GET', 200, 'ok'),
+        ('/', '/', 'GET', 200, 'ok')
+    ]
+)
+def test_middleware_not_authed(app_fixture, test_server_path, test_client_path, method, status_code, response_body):
+    @getattr(app_fixture, method.lower())(test_server_path)
+    async def index():
+        return 'ok'
+
+    test_client = TestClient(app_fixture)
+
+    test_response = getattr(test_client, method.lower())(test_client_path)
+
+    assert test_response.status_code == status_code
+    assert test_response.json() == response_body
+
+
+if __name__ == '__main__':
+    pytest.main()