feat: Make a Casbin authorization middleware for Tornado (#2)
diff --git a/.gitignore b/.gitignore index 68bc17f..2dc53ca 100644 --- a/.gitignore +++ b/.gitignore
@@ -157,4 +157,4 @@ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ +.idea/
diff --git a/README.md b/README.md index 1c2b3a7..a9b207b 100644 --- a/README.md +++ b/README.md
@@ -1 +1,98 @@ -# tornado-authz \ No newline at end of file +# tornado-authz + +[](https://discord.gg/S5UjpzGZjN) + +## Installation + +Clone this repo + +```bash +git clone https://github.com/pycasbin/tornado-authz +``` + +## Simple Example + +```python +import asyncio +import tornado +from casbin import Enforcer + +from tornado_authz import CasbinMiddleware + + +# Create a CasbinMiddleware instance with the enforcer +enforcer = Enforcer("../examples/authz_model.conf", "../examples/authz_policy.csv") +middleware = CasbinMiddleware(enforcer) + + +class BaseHandler(tornado.web.RequestHandler): + def get_current_user(self): + user = None + if self.get_secure_cookie("user"): + user = self.get_secure_cookie("user").decode('utf-8') + return user + + def prepare(self): + # Check the permission for the current request + middleware(self) + + +class MainHandler(BaseHandler): + def get(self): + self.write("Main Page") + + +class LoginHandler(BaseHandler): + def get(self): + self.write('<html><body><form action="/login" method="post">' + 'Name: <input type="text" name="name">' + '<input type="submit" value="Sign in">' + '</form></body></html>') + + def post(self): + self.set_secure_cookie("user", self.get_argument("name")) + self.redirect("/dataset1/") + + +class DatasetHandler(BaseHandler): + def get(self): + self.write("You must be alice to see this.") + + +def make_app(): + return tornado.web.Application([ + (r"/", MainHandler), + (r"/login", LoginHandler), + (r"/dataset1/.*", DatasetHandler), + ], cookie_secret="__TODO:_GENERATE_YOUR_OWN_RANDOM_VALUE_HERE__") + + +async def main(): + app = make_app() + app.listen(8888) + await asyncio.Event().wait() + + +if __name__ == "__main__": + asyncio.run(main()) + +``` + +## Documentation + +The authorization determines a request based on ``{subject, object, action}``, which means what ``subject`` can perform +what ``action`` on what ``object``. In this plugin, the meanings are: + +1. ``subject``: the logged-in username +2. ``object``: the URL path for the web resource like `dataset1/item1` +3. ``action``: HTTP method like GET, POST, PUT, DELETE, or the high-level actions you defined like "read-file", "write-blog" + +For how to write authorization policy and other details, please refer to [the Casbin's documentation](https://casbin.org). + +## Getting Help + +- [Casbin](https://casbin.org) + +## License + +This project is under Apache 2.0 License. See the [LICENSE](LICENSE) file for the full license text. \ No newline at end of file
diff --git a/demo/demo.py b/demo/demo.py new file mode 100644 index 0000000..1750a8a --- /dev/null +++ b/demo/demo.py
@@ -0,0 +1,76 @@ +# Copyright 2024 The Casbin Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import asyncio +import tornado +from casbin import Enforcer + +from tornado_authz import CasbinMiddleware + + +# Create a CasbinMiddleware instance with the enforcer +enforcer = Enforcer("../examples/authz_model.conf", "../examples/authz_policy.csv") +middleware = CasbinMiddleware(enforcer) + + +class BaseHandler(tornado.web.RequestHandler): + def get_current_user(self): + user = None + if self.get_secure_cookie("user"): + user = self.get_secure_cookie("user").decode('utf-8') + return user + + def prepare(self): + # Check the permission for the current request + middleware(self) + + +class MainHandler(BaseHandler): + def get(self): + self.write("Main Page") + + +class LoginHandler(BaseHandler): + def get(self): + self.write('<html><body><form action="/login" method="post">' + 'Name: <input type="text" name="name">' + '<input type="submit" value="Sign in">' + '</form></body></html>') + + def post(self): + self.set_secure_cookie("user", self.get_argument("name")) + self.redirect("/dataset1/") + + +class DatasetHandler(BaseHandler): + def get(self): + self.write("You must be alice to see this.") + + +def make_app(): + return tornado.web.Application([ + (r"/", MainHandler), + (r"/login", LoginHandler), + (r"/dataset1/.*", DatasetHandler), + ], cookie_secret="__TODO:_GENERATE_YOUR_OWN_RANDOM_VALUE_HERE__") + + +async def main(): + app = make_app() + app.listen(8888) + await asyncio.Event().wait() + + +if __name__ == "__main__": + asyncio.run(main())
diff --git a/examples/authz_model.conf b/examples/authz_model.conf new file mode 100644 index 0000000..eb7589b --- /dev/null +++ b/examples/authz_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 = (g(r.sub, p.sub) || p.sub == "*") && keyMatch(r.obj, p.obj) && (r.act == p.act || p.act == "*") \ No newline at end of file
diff --git a/examples/authz_policy.csv b/examples/authz_policy.csv new file mode 100644 index 0000000..ce17ef3 --- /dev/null +++ b/examples/authz_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/tornado_authz/__init__.py b/tornado_authz/__init__.py new file mode 100644 index 0000000..fd002c4 --- /dev/null +++ b/tornado_authz/__init__.py
@@ -0,0 +1 @@ +from .middleware import CasbinMiddleware
diff --git a/tornado_authz/middleware.py b/tornado_authz/middleware.py new file mode 100644 index 0000000..3db576e --- /dev/null +++ b/tornado_authz/middleware.py
@@ -0,0 +1,39 @@ +# Copyright 2024 The Casbin Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from tornado.web import RequestHandler +from casbin import Enforcer + + +class CasbinMiddleware: + def __init__(self, enforcer: Enforcer): + self.enforcer = enforcer + + def __call__(self, handler: RequestHandler): + # Check the permission for each request. + if not self.check_permission(handler): + # Not authorized, return HTTP 403 error. + self.require_permission(handler) + + def check_permission(self, handler: RequestHandler): + user = handler.current_user + if not user: + user = 'anonymous' + path = handler.request.uri + method = handler.request.method + return self.enforcer.enforce(user, path, method) + + @staticmethod + def require_permission(handler: RequestHandler): + handler.send_error(403)