diff --git a/backend/app/alembic/versions/b5055f0b4a4d_added_casbin_rule.py b/backend/app/alembic/versions/b5055f0b4a4d_added_casbin_rule.py new file mode 100644 index 00000000..e8e66830 --- /dev/null +++ b/backend/app/alembic/versions/b5055f0b4a4d_added_casbin_rule.py @@ -0,0 +1,46 @@ +"""added casbin rule + +Revision ID: b5055f0b4a4d +Revises: 8d7a05fd0ad4 +Create Date: 2025-04-09 13:45:43.511977 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + + +# revision identifiers, used by Alembic. +revision = "b5055f0b4a4d" +down_revision = "8d7a05fd0ad4" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "casbin_rule", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column( + "ptype", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False + ), + sa.Column("v0", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True), + sa.Column("v1", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True), + sa.Column("v2", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True), + sa.Column("v3", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True), + sa.Column("v4", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True), + sa.Column("v5", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_casbin_rule_ptype"), "casbin_rule", ["ptype"], unique=False + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f("ix_casbin_rule_ptype"), table_name="casbin_rule") + op.drop_table("casbin_rule") + # ### end Alembic commands ### diff --git a/backend/app/core/rbac/casbin.py b/backend/app/core/rbac/casbin.py new file mode 100644 index 00000000..4afc4e6c --- /dev/null +++ b/backend/app/core/rbac/casbin.py @@ -0,0 +1,16 @@ +import os +from casbin import Enforcer +from casbin_sqlalchemy_adapter import Adapter +from app.core.db import engine +from fastapi.concurrency import run_in_threadpool + + +config_path = os.path.join(os.path.dirname(__file__), "rbac_model.conf") + +adapter = Adapter(engine) +enforcer = Enforcer(config_path, adapter) +enforcer.enable_auto_save(True) + + +async def load_policy(): + await run_in_threadpool(enforcer.load_policy) diff --git a/backend/app/core/rbac/rbac_model.conf b/backend/app/core/rbac/rbac_model.conf new file mode 100644 index 00000000..8eac9639 --- /dev/null +++ b/backend/app/core/rbac/rbac_model.conf @@ -0,0 +1,22 @@ +[request_definition] +r = sub, org, proj, obj, act + +[policy_definition] +p = role, obj, act + +[role_definition] +g = _, _, _ # user → role → org +g2 = _, _, _ # user → role → project + +[policy_effect] +e = some(where (p.eft == allow)) + +[matchers] +# Matcher logic: +# - If project ID is not present → check org-level role (g). +# - If project ID is present: +# - Check if user has equivalent org-level role (e.g., org_admin → project_admin). +# - Otherwise, fall back to project-level role check (g2). +# Final match only happens if object and action match as well. + +m = ((r.proj == "" && g(r.sub, p.role, r.org)) || (r.proj != "" && (g(r.sub, "org_admin", r.org) && p.role == "project_admin" || g(r.sub, "org_manager", r.org) && p.role == "project_manager" || g(r.sub, "org_reader", r.org) && p.role == "project_reader" || g2(r.sub, p.role, r.proj)))) && r.obj == p.obj && r.act == p.act diff --git a/backend/app/core/rbac/rbac_policies.json b/backend/app/core/rbac/rbac_policies.json new file mode 100644 index 00000000..10f988aa --- /dev/null +++ b/backend/app/core/rbac/rbac_policies.json @@ -0,0 +1,34 @@ +{ + "permissions": [ + { + "role": "org_admin", + "resource": "org_data", + "actions": ["delete", "write", "read"] + }, + { + "role": "org_manager", + "resource": "org_data", + "actions": ["write", "read"] + }, + { + "role": "org_reader", + "resource": "org_data", + "actions": ["read"] + }, + { + "role": "project_admin", + "resource": "project_data", + "actions": ["delete", "write", "read"] + }, + { + "role": "project_manager", + "resource": "project_data", + "actions": ["write", "read"] + }, + { + "role": "project_reader", + "resource": "project_data", + "actions": ["read"] + } + ] +} diff --git a/backend/app/core/rbac/update_casbin_policies.py b/backend/app/core/rbac/update_casbin_policies.py new file mode 100644 index 00000000..c8c2ea7f --- /dev/null +++ b/backend/app/core/rbac/update_casbin_policies.py @@ -0,0 +1,71 @@ +import os +import json +import logging + +from sqlmodel import Session +from sqlalchemy.sql import text + +from app.core.db import engine + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +policies_file_path = os.path.join(os.path.dirname(__file__), "rbac_policies.json") + + +def update_policies(session: Session) -> None: + """ + Update Casbin policies from the local JSON file. + This deletes all existing 'p' policies and inserts new ones. + """ + try: + with open(policies_file_path) as f: + data = json.load(f) + except FileNotFoundError: + raise ValueError(f"Policy file not found: {policies_file_path}") + + conn = session.connection() + + try: + # Clear existing policies + logger.info("Deleting existing Casbin policies") + conn.execute(text("DELETE FROM casbin_rule WHERE ptype = 'p'")) + + # Insert new policies + for policy in data.get("permissions", []): + role = policy.get("role") + resource = policy.get("resource") + actions = policy.get("actions") + + if not role or not resource or not isinstance(actions, list): + raise ValueError(f"Invalid policy entry: {policy}") + + for action in actions: + conn.execute( + text( + """ + INSERT INTO casbin_rule (ptype, v0, v1, v2) + VALUES (:ptype, :v0, :v1, :v2) + """ + ), + {"ptype": "p", "v0": role, "v1": resource, "v2": action}, + ) + + session.commit() + logger.info("Casbin policies updated successfully.") + + except Exception as e: + logger.error(f"Error updating Casbin policies: {e}") + session.rollback() + raise + + +def main() -> None: + logger.info("Starting Casbin policy update") + with Session(engine) as session: + update_policies(session) + logger.info("Casbin policy update finished") + + +if __name__ == "__main__": + main() diff --git a/backend/app/main.py b/backend/app/main.py index 49cc0e9e..79d8e9d0 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -7,6 +7,8 @@ from app.api.main import api_router from app.api.deps import http_exception_handler from app.core.config import settings +from contextlib import asynccontextmanager +from app.core.rbac.casbin import enforcer, load_policy def custom_generate_unique_id(route: APIRoute) -> str: @@ -16,10 +18,20 @@ def custom_generate_unique_id(route: APIRoute) -> str: if settings.SENTRY_DSN and settings.ENVIRONMENT != "local": sentry_sdk.init(dsn=str(settings.SENTRY_DSN), enable_tracing=True) + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Load Casbin policy on startup + await load_policy() + app.state.enforcer = enforcer + yield + + app = FastAPI( title=settings.PROJECT_NAME, openapi_url=f"{settings.API_V1_STR}/openapi.json", generate_unique_id_function=custom_generate_unique_id, + lifespan=lifespan, ) # Set all CORS enabled origins diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 9b8d7a5f..67dfe76c 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,8 +1,8 @@ from sqlmodel import SQLModel from .auth import Token, TokenPayload +from .casbin_rule import CasbinRule from .document import Document - from .message import Message from .project_user import ( diff --git a/backend/app/models/casbin_rule.py b/backend/app/models/casbin_rule.py new file mode 100644 index 00000000..dd936855 --- /dev/null +++ b/backend/app/models/casbin_rule.py @@ -0,0 +1,41 @@ +from typing import Optional +from sqlmodel import SQLModel, Field + + +class CasbinRule(SQLModel, table=True): + """ + Represents Casbin policy rules for RBAC. + + Policy type (`ptype`): + - "p" -> policy rule (permissions) + - "g" -> subject-role assignment (org-level) + - "g2" -> subject-role assignment (project-level) + + Field meanings based on `ptype`: + + For ptype = "p" (permissions): + - v0: role + - v1: resource (e.g., "org_data", "project_data") + - v2: action (e.g., "read", "write", "delete") + + For ptype = "g" (org-level role assignment): + - v0: user_id + - v1: role (e.g., "org_admin", "org_writer", etc.) + - v2: org_id + + For ptype = "g2" (project-level role assignment): + - v0: user_id + - v1: role (e.g., "project_reader", etc.) + - v2: project_id + """ + + __tablename__ = "casbin_rule" + + id: Optional[int] = Field(default=None, primary_key=True) + ptype: str = Field(index=True, max_length=255) + v0: Optional[str] = Field(default=None, max_length=255) + v1: Optional[str] = Field(default=None, max_length=255) + v2: Optional[str] = Field(default=None, max_length=255) + v3: Optional[str] = Field(default=None, max_length=255) + v4: Optional[str] = Field(default=None, max_length=255) + v5: Optional[str] = Field(default=None, max_length=255) diff --git a/backend/app/tests/conftest.py b/backend/app/tests/conftest.py index 9cd6c497..eaae9ae5 100644 --- a/backend/app/tests/conftest.py +++ b/backend/app/tests/conftest.py @@ -6,6 +6,7 @@ from app.core.config import settings from app.core.db import engine, init_db +from app.core.rbac.update_casbin_policies import update_policies from app.main import app from app.models import ( APIKey, @@ -22,6 +23,7 @@ def db() -> Generator[Session, None, None]: with Session(engine) as session: init_db(session) + update_policies(session) yield session # Delete data in reverse dependency order session.execute(delete(ProjectUser)) # Many-to-many relationship diff --git a/backend/app/tests/core/rbac/test_rbac.py b/backend/app/tests/core/rbac/test_rbac.py new file mode 100644 index 00000000..4bdd94b8 --- /dev/null +++ b/backend/app/tests/core/rbac/test_rbac.py @@ -0,0 +1,309 @@ +import pytest +from sqlmodel import Session, delete +from app.core.rbac.casbin import enforcer +from app.models.casbin_rule import CasbinRule +from app.core.db import engine + +TEST_USERS = { + "user1": "user:1", # org1: org_admin, org2: org_reader + "user2": "user:2", # org1: org_manager + "user3": "user:3", # org2: org_admin + "user4": "user:4", # project1: project_admin, project2: project_reader + "user5": "user:5", # project3: project_manager + "user6": "user:6", # project4: project_admin + "user7": "user:7", # no roles + "mixed": "user:8", # org1: org_admin, org2: org_reader + "reader": "user:9", # project1: project_reader + "admin": "user:10", # project1: project_admin +} + +TEST_ORGS = {"org1": "org:1", "org2": "org:2"} + +TEST_PROJECTS = { + "project1": "project:1", # org1 + "project2": "project:2", # org1 + "project3": "project:3", # org2 + "project4": "project:4", # org2 +} + + +@pytest.fixture(scope="class", autouse=True) +def setup_casbin_rules(request): + # Org roles + enforcer.add_grouping_policy(TEST_USERS["user1"], "org_admin", TEST_ORGS["org1"]) + enforcer.add_grouping_policy(TEST_USERS["user1"], "org_reader", TEST_ORGS["org2"]) + enforcer.add_grouping_policy(TEST_USERS["user2"], "org_manager", TEST_ORGS["org1"]) + enforcer.add_grouping_policy(TEST_USERS["user3"], "org_admin", TEST_ORGS["org2"]) + enforcer.add_grouping_policy(TEST_USERS["mixed"], "org_admin", TEST_ORGS["org1"]) + enforcer.add_grouping_policy(TEST_USERS["mixed"], "org_reader", TEST_ORGS["org2"]) + + # Project roles + enforcer.add_named_grouping_policy( + "g2", TEST_USERS["user4"], "project_admin", TEST_PROJECTS["project1"] + ) + enforcer.add_named_grouping_policy( + "g2", TEST_USERS["user4"], "project_reader", TEST_PROJECTS["project2"] + ) + enforcer.add_named_grouping_policy( + "g2", TEST_USERS["user5"], "project_manager", TEST_PROJECTS["project3"] + ) + enforcer.add_named_grouping_policy( + "g2", TEST_USERS["user6"], "project_admin", TEST_PROJECTS["project4"] + ) + enforcer.add_named_grouping_policy( + "g2", TEST_USERS["reader"], "project_reader", TEST_PROJECTS["project1"] + ) + enforcer.add_named_grouping_policy( + "g2", TEST_USERS["admin"], "project_admin", TEST_PROJECTS["project1"] + ) + + # Save and reload + enforcer.save_policy() + enforcer.load_policy() + + def teardown(): + with Session(engine) as session: + stmt = delete(CasbinRule).where(CasbinRule.ptype.in_(["g", "g2", "g3"])) + session.exec(stmt) + session.commit() + + request.addfinalizer(teardown) + + +class TestRBAC: + """Test suite for Role-Based Access Control (RBAC) functionality using Casbin. + + This class contains tests to verify proper enforcement of organization and project-level + permissions, including inheritance between organization and project roles, cross-organization + access restrictions, and multiple role scenarios. + """ + + def test_org_roles_access(self): + assert ( + enforcer.enforce( + TEST_USERS["user1"], TEST_ORGS["org1"], "", "org_data", "read" + ) + is True + ) + assert ( + enforcer.enforce( + TEST_USERS["user1"], TEST_ORGS["org1"], "", "org_data", "write" + ) + is True + ) + assert ( + enforcer.enforce( + TEST_USERS["user1"], TEST_ORGS["org1"], "", "org_data", "delete" + ) + is True + ) + + assert ( + enforcer.enforce( + TEST_USERS["user2"], TEST_ORGS["org1"], "", "org_data", "read" + ) + is True + ) + assert ( + enforcer.enforce( + TEST_USERS["user2"], TEST_ORGS["org1"], "", "org_data", "write" + ) + is True + ) + assert ( + enforcer.enforce( + TEST_USERS["user2"], TEST_ORGS["org1"], "", "org_data", "delete" + ) + is False + ) + + def test_project_roles_access(self): + assert ( + enforcer.enforce( + TEST_USERS["user4"], + TEST_ORGS["org1"], + TEST_PROJECTS["project2"], + "project_data", + "read", + ) + is True + ) + assert ( + enforcer.enforce( + TEST_USERS["user4"], + TEST_ORGS["org1"], + TEST_PROJECTS["project2"], + "project_data", + "write", + ) + is False + ) + + assert ( + enforcer.enforce( + TEST_USERS["user5"], + TEST_ORGS["org2"], + TEST_PROJECTS["project3"], + "project_data", + "write", + ) + is True + ) + assert ( + enforcer.enforce( + TEST_USERS["user5"], + TEST_ORGS["org2"], + TEST_PROJECTS["project3"], + "project_data", + "read", + ) + is True + ) + + assert ( + enforcer.enforce( + TEST_USERS["user4"], + TEST_ORGS["org1"], + TEST_PROJECTS["project1"], + "project_data", + "delete", + ) + is True + ) + + def test_org_roles_inherit_project_roles(self): + assert ( + enforcer.enforce( + TEST_USERS["user1"], + TEST_ORGS["org1"], + TEST_PROJECTS["project1"], + "project_data", + "read", + ) + is True + ) + assert ( + enforcer.enforce( + TEST_USERS["user1"], + TEST_ORGS["org1"], + TEST_PROJECTS["project1"], + "project_data", + "delete", + ) + is True + ) + + assert ( + enforcer.enforce( + TEST_USERS["user2"], + TEST_ORGS["org1"], + TEST_PROJECTS["project1"], + "project_data", + "write", + ) + is True + ) + + def test_cross_organization_access(self): + assert ( + enforcer.enforce( + TEST_USERS["user3"], TEST_ORGS["org2"], "", "org_data", "read" + ) + is True + ) + assert ( + enforcer.enforce( + TEST_USERS["user3"], + TEST_ORGS["org2"], + TEST_PROJECTS["project4"], + "project_data", + "read", + ) + is True + ) + + def test_invalid_access_across_orgs(self): + assert ( + enforcer.enforce( + TEST_USERS["user4"], + TEST_ORGS["org2"], + TEST_PROJECTS["project3"], + "project_data", + "read", + ) + is False + ) + assert ( + enforcer.enforce( + TEST_USERS["user5"], + TEST_ORGS["org1"], + TEST_PROJECTS["project1"], + "project_data", + "write", + ) + is False + ) + + def test_user_with_different_roles_across_orgs(self): + assert ( + enforcer.enforce( + TEST_USERS["mixed"], TEST_ORGS["org1"], "", "org_data", "delete" + ) + is True + ) + assert ( + enforcer.enforce( + TEST_USERS["mixed"], TEST_ORGS["org2"], "", "org_data", "write" + ) + is False + ) + assert ( + enforcer.enforce( + TEST_USERS["mixed"], TEST_ORGS["org2"], "", "org_data", "read" + ) + is True + ) + + def test_multiple_users_same_project(self): + assert ( + enforcer.enforce( + TEST_USERS["reader"], + TEST_ORGS["org1"], + TEST_PROJECTS["project1"], + "project_data", + "read", + ) + is True + ) + assert ( + enforcer.enforce( + TEST_USERS["reader"], + TEST_ORGS["org1"], + TEST_PROJECTS["project1"], + "project_data", + "delete", + ) + is False + ) + assert ( + enforcer.enforce( + TEST_USERS["admin"], + TEST_ORGS["org1"], + TEST_PROJECTS["project1"], + "project_data", + "delete", + ) + is True + ) + + def test_project_access_via_org_role_only(self): + assert ( + enforcer.enforce( + TEST_USERS["user2"], + TEST_ORGS["org1"], + TEST_PROJECTS["project1"], + "project_data", + "write", + ) + is True + ) diff --git a/backend/app/tests/core/rbac/test_update_casbin_policies.py b/backend/app/tests/core/rbac/test_update_casbin_policies.py new file mode 100644 index 00000000..88f5a6a2 --- /dev/null +++ b/backend/app/tests/core/rbac/test_update_casbin_policies.py @@ -0,0 +1,100 @@ +import os +import json +import pytest +from pathlib import Path +from sqlmodel import Session, select + +from app.core.rbac.update_casbin_policies import ( + update_policies, + main, + policies_file_path, +) +from app.core.db import engine +from app.models import CasbinRule + + +def test_update_policies_success(db: Session): + """Test successful policy update and file-based resource check""" + + # Execute the policy update function + update_policies(db) + + # Fetch all 'p' type policies from the database + result = db.exec(select(CasbinRule).where(CasbinRule.ptype == "p")).all() + + db_policies = {(row.v0, row.v1, row.v2) for row in result} + + # Load and parse the policy file + assert Path( + policies_file_path + ).exists(), f"Policy file not found: {policies_file_path}" + with open(policies_file_path, "r") as f: + data = json.load(f) + + expected_policies = set() + for perm in data["permissions"]: + role = perm["role"] + resource = perm["resource"] + actions = perm["actions"] + for action in actions: + expected_policies.add((role, resource, action)) + + # Compare + missing = expected_policies - db_policies + extra = db_policies - expected_policies + + assert not missing, f"Missing policies: {missing}" + assert not extra, f"Unexpected policies in DB: {extra}" + + +def test_update_policies_invalid_file(db: Session): + """Test handling of invalid policy file""" + # Backup original file path + original_path = policies_file_path + backup_path = original_path + ".bak" + + try: + # Rename the file to simulate it being missing + if os.path.exists(original_path): + os.rename(original_path, backup_path) + + with pytest.raises(ValueError, match="Policy file not found"): + update_policies(db) + finally: + # Restore the file + if os.path.exists(backup_path): + os.rename(backup_path, original_path) + + +def test_update_policies_invalid_data(db: Session): + """Test handling of invalid policy data""" + # Backup original file + original_path = policies_file_path + backup_path = original_path + ".bak" + + try: + # Backup the original file + if os.path.exists(original_path): + os.rename(original_path, backup_path) + + # Create invalid policy file + with open(original_path, "w") as f: + json.dump({"permissions": [{"invalid": "data"}]}, f) + + with pytest.raises(ValueError, match="Invalid policy entry"): + update_policies(db) + finally: + # Restore the original file + if os.path.exists(backup_path): + if os.path.exists(original_path): + os.remove(original_path) + os.rename(backup_path, original_path) + + +def test_main_success(db: Session): + """Test main function success case""" + # This test will use the actual database and file + main() + + policies = db.exec(select(CasbinRule).where(CasbinRule.ptype == "p")).all() + assert len(policies) > 0 diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 59dc70aa..1f4e5c42 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -25,6 +25,8 @@ dependencies = [ "moto[s3]>=5.1.1", "openai>=1.67.0", "pytest>=7.4.4", + "casbin>=1.41.0", + "casbin-sqlalchemy-adapter>=1.4.0", "pre-commit>=3.8.0", ] diff --git a/backend/scripts/prestart.sh b/backend/scripts/prestart.sh old mode 100644 new mode 100755 index ce08c17e..94b17bd1 --- a/backend/scripts/prestart.sh +++ b/backend/scripts/prestart.sh @@ -13,6 +13,7 @@ alembic upgrade head services=( app/initial_data.py app/initial_storage.py + app/core/rbac/update_casbin_policies.py ) for i in ${services[@]}; do diff --git a/backend/uv.lock b/backend/uv.lock index eb95bfda..d893a53a 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -52,6 +52,8 @@ dependencies = [ { name = "alembic" }, { name = "bcrypt" }, { name = "boto3" }, + { name = "casbin" }, + { name = "casbin-sqlalchemy-adapter" }, { name = "email-validator" }, { name = "emails" }, { name = "fastapi", extra = ["standard"] }, @@ -87,6 +89,8 @@ requires-dist = [ { name = "alembic", specifier = ">=1.12.1,<2.0.0" }, { name = "bcrypt", specifier = "==4.0.1" }, { name = "boto3", specifier = ">=1.37.20" }, + { name = "casbin", specifier = ">=1.41.0" }, + { name = "casbin-sqlalchemy-adapter", specifier = ">=1.4.0" }, { name = "email-validator", specifier = ">=2.1.0.post1,<3.0.0.0" }, { name = "emails", specifier = ">=0.6,<1.0" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.114.2,<1.0.0" }, @@ -173,6 +177,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a4/07/14f8ad37f2d12a5ce41206c21820d8cb6561b728e51fad4530dff0552a67/cachetools-5.5.0-py3-none-any.whl", hash = "sha256:02134e8439cdc2ffb62023ce1debca2944c3f289d66bb17ead3ab3dede74b292", size = 9524 }, ] +[[package]] +name = "casbin" +version = "1.41.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "simpleeval" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ea/1b/bdd47681a16840dfef59ad16daac49891a7328633a05d24a37b74a94b987/casbin-1.41.0.tar.gz", hash = "sha256:246d4a03ace8d64a3d6a53a7d0553e06167a876c761047da33bcf6fdce913129", size = 425722 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/07/ce3e62e4c93c15955815dcaf78441002dfaf29cf7088df0a3c85afbf880c/casbin-1.41.0-py3-none-any.whl", hash = "sha256:511ab861bcca89419a4b336923292c77d18b868c351d196587134d3a2d7e8acf", size = 475027 }, +] + +[[package]] +name = "casbin-sqlalchemy-adapter" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "casbin" }, + { name = "sqlalchemy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/4f/2e980d63960acdb106edc97b40abd42a752f71082ded7247a0fde8a0dece/casbin_sqlalchemy_adapter-1.4.0.tar.gz", hash = "sha256:3cc439e3911a0f0c0de66ffde7e0de0586be1e1c772302e003d84164f4bfe291", size = 12353 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/54/cd2fd15e94426feb1744171b3e9f075f9880e115a69c1e3356ad2a07b437/casbin_sqlalchemy_adapter-1.4.0-py3-none-any.whl", hash = "sha256:a3e4e5d05e47fc6e04995ac26d36f53243f87ac9e3c66b24874ce6353e784897", size = 10095 }, +] + [[package]] name = "certifi" version = "2024.8.30" @@ -1522,6 +1551,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 }, ] +[[package]] +name = "simpleeval" +version = "1.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/6f/15be211749430f52f2c8f0c69158a6fc961c03aac93fa28d44d1a6f5ebc7/simpleeval-1.0.3.tar.gz", hash = "sha256:67bbf246040ac3b57c29cf048657b9cf31d4e7b9d6659684daa08ca8f1e45829", size = 24358 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/e9/e58082fbb8cecbb6fb4133033c40cc50c248b1a331582be3a0f39138d65b/simpleeval-1.0.3-py3-none-any.whl", hash = "sha256:e3bdbb8c82c26297c9a153902d0fd1858a6c3774bf53ff4f134788c3f2035c38", size = 15762 }, +] + [[package]] name = "six" version = "1.16.0"