feat: add initial code (#2)
* Ignore .idea/
* feat: add async PostgreSQL watcher (#1)
* test: add tests for AsyncPostgresWatcher
* docs: add examples
* docs: update README.md
* feat: add Apache header
* feat: add requirements.txt
* docs: update README.md
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 7de2be5..2b13659 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,53 @@
-# async-postgres-watcher
\ No newline at end of file
+# async-postgres-watcher
+
+[](https://discord.gg/S5UjpzGZjN)
+
+Async casbin role watcher to be used for monitoring updates to casbin policies
+
+## Basic Usage Example
+
+### With Flask-authz
+
+```python
+from flask_authz import CasbinEnforcer
+from async_postgres_watcher import AsyncPostgresWatcher
+from flask import Flask
+from casbin.persist.adapters import FileAdapter
+
+casbin_enforcer = CasbinEnforcer(app, adapter)
+
+watcher = AsyncPostgresWatcher(host=HOST, port=PORT, user=USER, password=PASSWORD, dbname=DBNAME)
+watcher.set_update_callback(casbin_enforcer.e.load_policy)
+
+casbin_enforcer.set_watcher(watcher)
+```
+
+## Basic Usage Example With SSL Enabled
+
+See [asyncpg documentation](https://magicstack.github.io/asyncpg/current/api/index.html#connection) for full details of SSL parameters.
+
+### With Flask-authz
+
+```python
+from flask_authz import CasbinEnforcer
+from async_postgres_watcher import AsyncPostgresWatcher
+from flask import Flask
+from casbin.persist.adapters import FileAdapter
+
+casbin_enforcer = CasbinEnforcer(app, adapter)
+
+# If check_hostname is True, the SSL context is created with sslmode=verify-full.
+# If check_hostname is False, the SSL context is created with sslmode=verify-ca.
+watcher = AsyncPostgresWatcher(host=HOST, port=PORT, user=USER, password=PASSWORD, dbname=DBNAME, sslrootcert=SSLROOTCERT, check_hostname = True, sslcert=SSLCERT, sslkey=SSLKEY)
+
+watcher.set_update_callback(casbin_enforcer.e.load_policy)
+casbin_enforcer.set_watcher(watcher)
+```
+
+## Getting Help
+
+- [PyCasbin](https://github.com/casbin/pycasbin)
+
+## License
+
+This project is under Apache 2.0 License. See the [LICENSE](LICENSE) file for the full license text.
diff --git a/async_postgres_watcher/__init__.py b/async_postgres_watcher/__init__.py
new file mode 100644
index 0000000..8437384
--- /dev/null
+++ b/async_postgres_watcher/__init__.py
@@ -0,0 +1,15 @@
+# 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 .watcher import AsyncPostgresWatcher
diff --git a/async_postgres_watcher/watcher.py b/async_postgres_watcher/watcher.py
new file mode 100644
index 0000000..ff965f9
--- /dev/null
+++ b/async_postgres_watcher/watcher.py
@@ -0,0 +1,100 @@
+# 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 typing import Optional, Callable
+import asyncio
+import asyncpg
+import ssl
+import time
+
+POSTGRESQL_CHANNEL_NAME = "casbin_role_watcher"
+
+
+class AsyncPostgresWatcher:
+ def __init__(
+ self,
+ host: str,
+ user: str,
+ password: str,
+ port: Optional[int] = 5432,
+ dbname: Optional[str] = "postgres",
+ channel_name: Optional[str] = POSTGRESQL_CHANNEL_NAME,
+ sslrootcert: Optional[str] = None,
+ # If True, equivalent to sslmode=verify-full, if False: sslmode=verify-ca.
+ check_hostname: Optional[bool] = True,
+ sslcert: Optional[str] = None,
+ sslkey: Optional[str] = None
+ ):
+ self.loop = asyncio.get_event_loop()
+ self.running = True
+ self.callback = None
+ self.host = host
+ self.port = port
+ self.user = user
+ self.password = password
+ self.dbname = dbname
+ self.channel_name = channel_name
+
+ if sslrootcert is not None and sslcert is not None and sslkey is not None:
+ self.sslctx = ssl.create_default_context(
+ ssl.Purpose.SERVER_AUTH,
+ cafile=sslrootcert
+ )
+ self.sslctx.check_hostname = check_hostname
+ self.sslctx.load_cert_chain(sslcert, keyfile=sslkey)
+ else:
+ self.sslctx = False
+
+ self.loop.create_task(self.subscriber())
+
+ async def notify(self, pid, channel, payload):
+ print(f"Notify: {payload}")
+ if self.callback is not None:
+ if asyncio.iscoroutinefunction(self.callback):
+ await self.callback(payload)
+ else:
+ self.callback(payload)
+
+ async def subscriber(self):
+ conn = await asyncpg.connect(
+ host=self.host,
+ port=self.port,
+ user=self.user,
+ password=self.password,
+ database=self.dbname,
+ ssl=self.sslctx
+ )
+ await conn.add_listener(self.channel_name, self.notify)
+ while self.running:
+ await asyncio.sleep(1) # keep the coroutine alive
+
+ async def set_update_callback(self, fn_name: Callable):
+ print("runtime is set update callback", fn_name)
+ self.callback = fn_name
+
+ async def update(self):
+ conn = await asyncpg.connect(
+ host=self.host,
+ port=self.port,
+ user=self.user,
+ password=self.password,
+ database=self.dbname,
+ ssl=self.sslctx
+ )
+ async with conn.transaction():
+ await conn.execute(
+ f"NOTIFY {self.channel_name},'casbin policy update at {time.time()}'"
+ )
+ await conn.close()
+ return True
diff --git a/examples/rbac_model.conf b/examples/rbac_model.conf
new file mode 100644
index 0000000..0e2d892
--- /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 && (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..0779945
--- /dev/null
+++ b/examples/rbac_policy.csv
@@ -0,0 +1,5 @@
+p,alice,data1,read
+p,bob,data2,write
+p,data2_admin,data2,read
+p,data2_admin,data2,write
+g,alice,data2_admin
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..27abd07
--- /dev/null
+++ b/requirements.txt
Binary files differ
diff --git a/test/test_async_postgres_watcher.py b/test/test_async_postgres_watcher.py
new file mode 100644
index 0000000..442b9ef
--- /dev/null
+++ b/test/test_async_postgres_watcher.py
@@ -0,0 +1,56 @@
+# 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 unittest
+
+from async_postgres_watcher import AsyncPostgresWatcher
+
+# Please set up yourself config
+HOST = "127.0.0.1"
+PORT = 5432
+USER = "postgres"
+PASSWORD = "123456"
+DBNAME = "postgres"
+
+
+class TestConfig(unittest.IsolatedAsyncioTestCase):
+
+ async def asyncSetUp(self):
+ self.pg_watcher = AsyncPostgresWatcher(
+ host=HOST,
+ port=PORT,
+ user=USER,
+ password=PASSWORD,
+ dbname=DBNAME
+ )
+
+ async def test_update_pg_watcher(self):
+ assert await self.pg_watcher.update() is True
+
+ def test_default_update_callback(self):
+ assert self.pg_watcher.callback is None
+
+ async def test_add_update_callback(self):
+ def _test_callback():
+ pass
+
+ await self.pg_watcher.set_update_callback(_test_callback)
+ assert self.pg_watcher.callback == _test_callback
+
+ async def asyncTearDown(self):
+ self.pg_watcher.running = False
+
+
+if __name__ == "__main__":
+ unittest.main()