From fa613a454ff6ed1b8dbf6aa8c9daf87883ca1608 Mon Sep 17 00:00:00 2001 From: avirajsingh7 Date: Tue, 8 Apr 2025 10:15:22 +0530 Subject: [PATCH 01/22] intial setup for rbac --- .../d923a89827eb_added_casbin_rule.py | 52 +++++++++++++++++++ backend/app/core/rbac/rbac.py | 14 +++++ backend/app/core/rbac/rbac_model.conf | 14 +++++ backend/app/main.py | 11 ++++ backend/app/models/__init__.py | 1 + backend/app/models/casbin_rule.py | 19 +++++++ backend/pyproject.toml | 2 + backend/uv.lock | 38 ++++++++++++++ 8 files changed, 151 insertions(+) create mode 100644 backend/app/alembic/versions/d923a89827eb_added_casbin_rule.py create mode 100644 backend/app/core/rbac/rbac.py create mode 100644 backend/app/core/rbac/rbac_model.conf create mode 100644 backend/app/models/casbin_rule.py diff --git a/backend/app/alembic/versions/d923a89827eb_added_casbin_rule.py b/backend/app/alembic/versions/d923a89827eb_added_casbin_rule.py new file mode 100644 index 00000000..cd1006d7 --- /dev/null +++ b/backend/app/alembic/versions/d923a89827eb_added_casbin_rule.py @@ -0,0 +1,52 @@ +"""added casbin rule + +Revision ID: d923a89827eb +Revises: 77dc462dc6b0 +Create Date: 2025-04-08 09:44:57.711016 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + + +# revision identifiers, used by Alembic. +revision = 'd923a89827eb' +down_revision = '77dc462dc6b0' +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) + op.create_table('document', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('owner_id', sa.Uuid(), nullable=False), + sa.Column('fname', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('object_store_url', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['owner_id'], ['user.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('document') + 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/rbac.py b/backend/app/core/rbac/rbac.py new file mode 100644 index 00000000..445000ea --- /dev/null +++ b/backend/app/core/rbac/rbac.py @@ -0,0 +1,14 @@ +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) \ No newline at end of file diff --git a/backend/app/core/rbac/rbac_model.conf b/backend/app/core/rbac/rbac_model.conf new file mode 100644 index 00000000..13ff9f90 --- /dev/null +++ b/backend/app/core/rbac/rbac_model.conf @@ -0,0 +1,14 @@ +[request_definition] +r = sub, obj, act, dom + +[policy_definition] +p = sub, obj, act, dom + +[role_definition] +g = _, _, _ + +[policy_effect] +e = some(where (p.eft == allow)) + +[matchers] +m = g(r.sub, p.sub, r.dom) && r.obj == p.obj && r.act == p.act diff --git a/backend/app/main.py b/backend/app/main.py index 49cc0e9e..7978979c 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.rbac import enforcer, load_policy def custom_generate_unique_id(route: APIRoute) -> str: @@ -16,10 +18,19 @@ 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..883ed35e 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,6 +1,7 @@ from sqlmodel import SQLModel from .auth import Token, TokenPayload +from .casbin_rule import CasbinRule from .document import Document from .message import Message diff --git a/backend/app/models/casbin_rule.py b/backend/app/models/casbin_rule.py new file mode 100644 index 00000000..11675ca0 --- /dev/null +++ b/backend/app/models/casbin_rule.py @@ -0,0 +1,19 @@ +from sqlmodel import SQLModel, Field +from typing import Optional + + +class CasbinRule(SQLModel, table=True): + __tablename__ = "casbin_rule" + + id: Optional[int] = Field(default=None, primary_key=True) + ptype: str = Field(index=True, max_length=255) # "p" for policy rule, "g" for grouping (role) rule + # --- v0 to v5 meanings depend on ptype --- + # For "p" (policy rule): p, sub, dom, obj, act, eft + # For "g" (grouping rule): g, user, role, dom + + v0: Optional[str] = Field(default=None, max_length=255) # Subject (user or role) + v1: Optional[str] = Field(default=None, max_length=255) # Role / Object / Domain + v2: Optional[str] = Field(default=None, max_length=255) # Domain / Action + v3: Optional[str] = Field(default=None, max_length=255) # Object (in policy) or unused + v4: Optional[str] = Field(default=None, max_length=255) # Action (in policy) or unused + v5: Optional[str] = Field(default=None, max_length=255) # Effect ("allow"/"deny") or unused 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/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" From db5db538e9da030e2b8d61136e471c7347a2da5f Mon Sep 17 00:00:00 2001 From: avirajsingh7 Date: Tue, 8 Apr 2025 13:52:34 +0530 Subject: [PATCH 02/22] enforcer for testing --- .../d923a89827eb_added_casbin_rule.py | 58 +++++++++++-------- backend/app/api/deps.py | 9 +++ backend/app/api/routes/project_user.py | 6 +- backend/app/core/rbac/rbac.py | 3 +- backend/app/core/rbac/rbac_model.conf | 4 +- backend/app/main.py | 3 +- backend/app/models/casbin_rule.py | 18 ++++-- 7 files changed, 66 insertions(+), 35 deletions(-) diff --git a/backend/app/alembic/versions/d923a89827eb_added_casbin_rule.py b/backend/app/alembic/versions/d923a89827eb_added_casbin_rule.py index cd1006d7..ac82bfca 100644 --- a/backend/app/alembic/versions/d923a89827eb_added_casbin_rule.py +++ b/backend/app/alembic/versions/d923a89827eb_added_casbin_rule.py @@ -11,42 +11,50 @@ # revision identifiers, used by Alembic. -revision = 'd923a89827eb' -down_revision = '77dc462dc6b0' +revision = "d923a89827eb" +down_revision = "77dc462dc6b0" 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_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) - op.create_table('document', - sa.Column('id', sa.Uuid(), nullable=False), - sa.Column('owner_id', sa.Uuid(), nullable=False), - sa.Column('fname', sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column('object_store_url', sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.Column('deleted_at', sa.DateTime(), nullable=True), - sa.ForeignKeyConstraint(['owner_id'], ['user.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id') + op.create_index( + op.f("ix_casbin_rule_ptype"), "casbin_rule", ["ptype"], unique=False + ) + op.create_table( + "document", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("owner_id", sa.Uuid(), nullable=False), + sa.Column("fname", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column( + "object_store_url", sqlmodel.sql.sqltypes.AutoString(), nullable=False + ), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("deleted_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(["owner_id"], ["user.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), ) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('document') - op.drop_index(op.f('ix_casbin_rule_ptype'), table_name='casbin_rule') - op.drop_table('casbin_rule') + op.drop_table("document") + 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/api/deps.py b/backend/app/api/deps.py index 3bbc85eb..37474947 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -8,6 +8,7 @@ from jwt.exceptions import InvalidTokenError from pydantic import ValidationError from sqlmodel import Session, select +from app.core.rbac.rbac import enforcer from app.core import security from app.core.config import settings @@ -203,3 +204,11 @@ def verify_user_project_organization( current_user.organization_id = organization_id return UserProjectOrg(**current_user.model_dump(), project_id=project_id) + + +def casbin_enforce(sub: str, dom: str, obj: str, act: str): + if not enforcer.enforce(sub, dom, obj, act): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You don't have access to perform this action.", + ) diff --git a/backend/app/api/routes/project_user.py b/backend/app/api/routes/project_user.py index cfa7121f..bded4005 100644 --- a/backend/app/api/routes/project_user.py +++ b/backend/app/api/routes/project_user.py @@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request from sqlmodel import Session from typing import Annotated -from app.api.deps import get_db, verify_user_project_organization +from app.api.deps import get_db, verify_user_project_organization, casbin_enforce from app.crud.project_user import ( add_user_to_project, remove_user_from_project, @@ -62,6 +62,10 @@ def list_project_users( """ Get all users in a project. """ + casbin_enforce( + sub=str(current_user.id), dom="project_2", obj="project_data", act="write" + ) + users, total_count = get_users_by_project( session, current_user.project_id, skip, limit ) diff --git a/backend/app/core/rbac/rbac.py b/backend/app/core/rbac/rbac.py index 445000ea..48a8c8f0 100644 --- a/backend/app/core/rbac/rbac.py +++ b/backend/app/core/rbac/rbac.py @@ -10,5 +10,6 @@ enforcer = Enforcer(config_path, adapter) enforcer.enable_auto_save(True) + async def load_policy(): - await run_in_threadpool(enforcer.load_policy) \ No newline at end of file + 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 index 13ff9f90..5de11139 100644 --- a/backend/app/core/rbac/rbac_model.conf +++ b/backend/app/core/rbac/rbac_model.conf @@ -1,8 +1,8 @@ [request_definition] -r = sub, obj, act, dom +r = sub, dom, obj, act [policy_definition] -p = sub, obj, act, dom +p = sub, obj, act [role_definition] g = _, _, _ diff --git a/backend/app/main.py b/backend/app/main.py index 7978979c..2a3c37fd 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -18,6 +18,7 @@ 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 @@ -30,7 +31,7 @@ async def lifespan(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 + lifespan=lifespan, ) # Set all CORS enabled origins diff --git a/backend/app/models/casbin_rule.py b/backend/app/models/casbin_rule.py index 11675ca0..aeb3cb28 100644 --- a/backend/app/models/casbin_rule.py +++ b/backend/app/models/casbin_rule.py @@ -6,14 +6,22 @@ class CasbinRule(SQLModel, table=True): __tablename__ = "casbin_rule" id: Optional[int] = Field(default=None, primary_key=True) - ptype: str = Field(index=True, max_length=255) # "p" for policy rule, "g" for grouping (role) rule + ptype: str = Field( + index=True, max_length=255 + ) # "p" for policy rule, "g" for grouping (role) rule # --- v0 to v5 meanings depend on ptype --- # For "p" (policy rule): p, sub, dom, obj, act, eft # For "g" (grouping rule): g, user, role, dom - + v0: Optional[str] = Field(default=None, max_length=255) # Subject (user or role) v1: Optional[str] = Field(default=None, max_length=255) # Role / Object / Domain v2: Optional[str] = Field(default=None, max_length=255) # Domain / Action - v3: Optional[str] = Field(default=None, max_length=255) # Object (in policy) or unused - v4: Optional[str] = Field(default=None, max_length=255) # Action (in policy) or unused - v5: Optional[str] = Field(default=None, max_length=255) # Effect ("allow"/"deny") or unused + v3: Optional[str] = Field( + default=None, max_length=255 + ) # Object (in policy) or unused + v4: Optional[str] = Field( + default=None, max_length=255 + ) # Action (in policy) or unused + v5: Optional[str] = Field( + default=None, max_length=255 + ) # Effect ("allow"/"deny") or unused From 740ae714b17417d4110813332ffb2a4326061d2e Mon Sep 17 00:00:00 2001 From: avirajsingh7 Date: Tue, 8 Apr 2025 14:03:08 +0530 Subject: [PATCH 03/22] fix import --- backend/app/models/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 883ed35e..67dfe76c 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -3,7 +3,6 @@ from .auth import Token, TokenPayload from .casbin_rule import CasbinRule from .document import Document - from .message import Message from .project_user import ( From c3bd485df955e53b5a56994cac87f3fc24631c33 Mon Sep 17 00:00:00 2001 From: avirajsingh7 Date: Wed, 9 Apr 2025 13:14:38 +0530 Subject: [PATCH 04/22] matcher improved --- backend/app/api/deps.py | 8 -------- backend/app/api/routes/project_user.py | 5 +++-- backend/app/core/rbac/rbac.py | 11 +++++++++++ backend/app/core/rbac/rbac_model.conf | 16 ++++++++++++---- 4 files changed, 26 insertions(+), 14 deletions(-) diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index 37474947..75179a1b 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -204,11 +204,3 @@ def verify_user_project_organization( current_user.organization_id = organization_id return UserProjectOrg(**current_user.model_dump(), project_id=project_id) - - -def casbin_enforce(sub: str, dom: str, obj: str, act: str): - if not enforcer.enforce(sub, dom, obj, act): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="You don't have access to perform this action.", - ) diff --git a/backend/app/api/routes/project_user.py b/backend/app/api/routes/project_user.py index bded4005..d02b20cc 100644 --- a/backend/app/api/routes/project_user.py +++ b/backend/app/api/routes/project_user.py @@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request from sqlmodel import Session from typing import Annotated -from app.api.deps import get_db, verify_user_project_organization, casbin_enforce +from app.api.deps import get_db, verify_user_project_organization from app.crud.project_user import ( add_user_to_project, remove_user_from_project, @@ -11,6 +11,7 @@ ) from app.models import User, ProjectUserPublic, UserProjectOrg, Message from app.utils import APIResponse +from app.core.rbac.rbac import casbin_enforce router = APIRouter(prefix="/project/users", tags=["project_users"]) @@ -63,7 +64,7 @@ def list_project_users( Get all users in a project. """ casbin_enforce( - sub=str(current_user.id), dom="project_2", obj="project_data", act="write" + sub="user:dana",org="org:2",proj="project:3", obj="project_data", act="write" ) users, total_count = get_users_by_project( diff --git a/backend/app/core/rbac/rbac.py b/backend/app/core/rbac/rbac.py index 48a8c8f0..915ca3f7 100644 --- a/backend/app/core/rbac/rbac.py +++ b/backend/app/core/rbac/rbac.py @@ -3,6 +3,7 @@ from casbin_sqlalchemy_adapter import Adapter from app.core.db import engine from fastapi.concurrency import run_in_threadpool +from fastapi import HTTPException, status config_path = os.path.join(os.path.dirname(__file__), "rbac_model.conf") @@ -13,3 +14,13 @@ async def load_policy(): await run_in_threadpool(enforcer.load_policy) + + +def casbin_enforce(sub: str, obj: str, act: str, org: str= None, proj: str = None): + """Enforces Casbin RBAC rules based on org-level and project-level roles.""" + + if not enforcer.enforce(sub, org, proj, obj, act): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You don't have access to perform this action.", + ) diff --git a/backend/app/core/rbac/rbac_model.conf b/backend/app/core/rbac/rbac_model.conf index 5de11139..484ed5c3 100644 --- a/backend/app/core/rbac/rbac_model.conf +++ b/backend/app/core/rbac/rbac_model.conf @@ -1,14 +1,22 @@ [request_definition] -r = sub, dom, obj, act +r = sub, org, proj, obj, act [policy_definition] -p = sub, obj, act +p = role, obj, act [role_definition] -g = _, _, _ +g = _, _, _ # user → role → org +g2 = _, _, _ # user → role → project +g3 = _, _ # project → org [policy_effect] e = some(where (p.eft == allow)) [matchers] -m = g(r.sub, p.sub, r.dom) && r.obj == p.obj && r.act == p.act +# 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_writer", r.org) && p.role == "project_writer" || 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 From 19e6c699c49ff1de51084918ee5ea8e27b08c3ac Mon Sep 17 00:00:00 2001 From: avirajsingh7 Date: Wed, 9 Apr 2025 13:46:07 +0530 Subject: [PATCH 05/22] fix migration --- .../b5055f0b4a4d_added_casbin_rule.py | 41 +++++++++++++ .../d923a89827eb_added_casbin_rule.py | 60 ------------------- 2 files changed, 41 insertions(+), 60 deletions(-) create mode 100644 backend/app/alembic/versions/b5055f0b4a4d_added_casbin_rule.py delete mode 100644 backend/app/alembic/versions/d923a89827eb_added_casbin_rule.py 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..48239dda --- /dev/null +++ b/backend/app/alembic/versions/b5055f0b4a4d_added_casbin_rule.py @@ -0,0 +1,41 @@ +"""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/alembic/versions/d923a89827eb_added_casbin_rule.py b/backend/app/alembic/versions/d923a89827eb_added_casbin_rule.py deleted file mode 100644 index ac82bfca..00000000 --- a/backend/app/alembic/versions/d923a89827eb_added_casbin_rule.py +++ /dev/null @@ -1,60 +0,0 @@ -"""added casbin rule - -Revision ID: d923a89827eb -Revises: 77dc462dc6b0 -Create Date: 2025-04-08 09:44:57.711016 - -""" -from alembic import op -import sqlalchemy as sa -import sqlmodel.sql.sqltypes - - -# revision identifiers, used by Alembic. -revision = "d923a89827eb" -down_revision = "77dc462dc6b0" -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 - ) - op.create_table( - "document", - sa.Column("id", sa.Uuid(), nullable=False), - sa.Column("owner_id", sa.Uuid(), nullable=False), - sa.Column("fname", sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column( - "object_store_url", sqlmodel.sql.sqltypes.AutoString(), nullable=False - ), - sa.Column("created_at", sa.DateTime(), nullable=False), - sa.Column("deleted_at", sa.DateTime(), nullable=True), - sa.ForeignKeyConstraint(["owner_id"], ["user.id"], ondelete="CASCADE"), - sa.PrimaryKeyConstraint("id"), - ) - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.drop_table("document") - op.drop_index(op.f("ix_casbin_rule_ptype"), table_name="casbin_rule") - op.drop_table("casbin_rule") - # ### end Alembic commands ### From 07367f0c1b861ea55a1061e10a1fea940f38dde8 Mon Sep 17 00:00:00 2001 From: avirajsingh7 Date: Wed, 9 Apr 2025 14:09:13 +0530 Subject: [PATCH 06/22] Added rbac policies --- backend/app/alembic/utils.py | 31 +++++++++++++++++ ...fc2d7ddfa265_add_intial_casbin_policies.py | 34 +++++++++++++++++++ backend/app/core/rbac/rbac_policies.json | 34 +++++++++++++++++++ 3 files changed, 99 insertions(+) create mode 100644 backend/app/alembic/utils.py create mode 100644 backend/app/alembic/versions/fc2d7ddfa265_add_intial_casbin_policies.py create mode 100644 backend/app/core/rbac/rbac_policies.json diff --git a/backend/app/alembic/utils.py b/backend/app/alembic/utils.py new file mode 100644 index 00000000..7aeb674f --- /dev/null +++ b/backend/app/alembic/utils.py @@ -0,0 +1,31 @@ +import json +import os +from sqlalchemy.sql import text +from sqlalchemy.engine import Connection + + +def update_casbin_policies(conn: Connection, file_path: str): + """ + Update Casbin policies from a JSON file and insert into the casbin_rule table. + Warning : This will delete all the existing policies + """ + with open(file_path) as f: + data = json.load(f) + + # Clear all existing policies + conn.execute(text("DELETE FROM casbin_rule WHERE ptype = 'p'")) + + for policy in data.get("permissions", []): + role = policy["role"] + resource = policy["resource"] + actions = policy["actions"] + + 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} + ) + \ No newline at end of file diff --git a/backend/app/alembic/versions/fc2d7ddfa265_add_intial_casbin_policies.py b/backend/app/alembic/versions/fc2d7ddfa265_add_intial_casbin_policies.py new file mode 100644 index 00000000..ed221440 --- /dev/null +++ b/backend/app/alembic/versions/fc2d7ddfa265_add_intial_casbin_policies.py @@ -0,0 +1,34 @@ +"""Add intial casbin policies + +Revision ID: fc2d7ddfa265 +Revises: b5055f0b4a4d +Create Date: 2025-04-09 13:48:15.502779 + +""" +from alembic import op +import sqlalchemy as sa +import os, json +import sqlmodel.sql.sqltypes +from app.alembic.utils import update_casbin_policies + + +# revision identifiers, used by Alembic. +revision = 'fc2d7ddfa265' +down_revision = 'b5055f0b4a4d' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + conn = op.get_bind() + file_path = os.path.join(os.path.dirname(__file__), "../../core/rbac/rbac_policies.json") + update_casbin_policies(conn, file_path) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + # Not needed as only adding policies data + pass + # ### end Alembic commands ### diff --git a/backend/app/core/rbac/rbac_policies.json b/backend/app/core/rbac/rbac_policies.json new file mode 100644 index 00000000..3833d01a --- /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_writer", + "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_writer", + "resource": "project_data", + "actions": ["write", "read"] + }, + { + "role": "project_reader", + "resource": "project_data", + "actions": ["read"] + } + ] +} \ No newline at end of file From d8d387927247adf8df2355831900b186d876037f Mon Sep 17 00:00:00 2001 From: avirajsingh7 Date: Wed, 9 Apr 2025 14:14:42 +0530 Subject: [PATCH 07/22] rename rbac.py to casbin.py --- backend/app/api/deps.py | 1 - backend/app/api/routes/project_user.py | 2 +- backend/app/core/rbac/{rbac.py => casbin.py} | 0 backend/app/main.py | 2 +- 4 files changed, 2 insertions(+), 3 deletions(-) rename backend/app/core/rbac/{rbac.py => casbin.py} (100%) diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index 75179a1b..3bbc85eb 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -8,7 +8,6 @@ from jwt.exceptions import InvalidTokenError from pydantic import ValidationError from sqlmodel import Session, select -from app.core.rbac.rbac import enforcer from app.core import security from app.core.config import settings diff --git a/backend/app/api/routes/project_user.py b/backend/app/api/routes/project_user.py index d02b20cc..b6a4439c 100644 --- a/backend/app/api/routes/project_user.py +++ b/backend/app/api/routes/project_user.py @@ -11,7 +11,7 @@ ) from app.models import User, ProjectUserPublic, UserProjectOrg, Message from app.utils import APIResponse -from app.core.rbac.rbac import casbin_enforce +from app.core.rbac.casbin import casbin_enforce router = APIRouter(prefix="/project/users", tags=["project_users"]) diff --git a/backend/app/core/rbac/rbac.py b/backend/app/core/rbac/casbin.py similarity index 100% rename from backend/app/core/rbac/rbac.py rename to backend/app/core/rbac/casbin.py diff --git a/backend/app/main.py b/backend/app/main.py index 2a3c37fd..c514cffd 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -8,7 +8,7 @@ from app.api.deps import http_exception_handler from app.core.config import settings from contextlib import asynccontextmanager -from app.core.rbac.rbac import enforcer, load_policy +from app.core.rbac.casbin import enforcer, load_policy def custom_generate_unique_id(route: APIRoute) -> str: From 84a643a41540b75813d894ca28e543335d24591d Mon Sep 17 00:00:00 2001 From: avirajsingh7 Date: Wed, 9 Apr 2025 14:39:56 +0530 Subject: [PATCH 08/22] update doc string in casbin table --- backend/app/models/casbin_rule.py | 60 ++++++++++++++++++++----------- 1 file changed, 39 insertions(+), 21 deletions(-) diff --git a/backend/app/models/casbin_rule.py b/backend/app/models/casbin_rule.py index aeb3cb28..c4f077c4 100644 --- a/backend/app/models/casbin_rule.py +++ b/backend/app/models/casbin_rule.py @@ -1,27 +1,45 @@ -from sqlmodel import SQLModel, Field 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) + - "g3" -> project-to-org mapping + + 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 + + For ptype = "g3" (project-org mapping): + - v0: project_id + - v1: org_id + """ + __tablename__ = "casbin_rule" id: Optional[int] = Field(default=None, primary_key=True) - ptype: str = Field( - index=True, max_length=255 - ) # "p" for policy rule, "g" for grouping (role) rule - # --- v0 to v5 meanings depend on ptype --- - # For "p" (policy rule): p, sub, dom, obj, act, eft - # For "g" (grouping rule): g, user, role, dom - - v0: Optional[str] = Field(default=None, max_length=255) # Subject (user or role) - v1: Optional[str] = Field(default=None, max_length=255) # Role / Object / Domain - v2: Optional[str] = Field(default=None, max_length=255) # Domain / Action - v3: Optional[str] = Field( - default=None, max_length=255 - ) # Object (in policy) or unused - v4: Optional[str] = Field( - default=None, max_length=255 - ) # Action (in policy) or unused - v5: Optional[str] = Field( - default=None, max_length=255 - ) # Effect ("allow"/"deny") or unused + 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) From 45d2b6ac54606d7b59a706700ed951be67375c4d Mon Sep 17 00:00:00 2001 From: avirajsingh7 Date: Thu, 10 Apr 2025 16:15:37 +0530 Subject: [PATCH 09/22] Added test cases for permission check --- backend/app/api/routes/project_user.py | 7 +- backend/app/core/rbac/casbin.py | 11 +-- backend/app/core/rbac/rbac_model.conf | 4 +- backend/app/core/rbac/rbac_policies.json | 4 +- backend/app/crud/project.py | 4 + backend/app/main.py | 2 +- backend/app/tests/core/rbac/test_rbac.py | 115 +++++++++++++++++++++++ 7 files changed, 126 insertions(+), 21 deletions(-) create mode 100644 backend/app/tests/core/rbac/test_rbac.py diff --git a/backend/app/api/routes/project_user.py b/backend/app/api/routes/project_user.py index b6a4439c..2ec2bbb1 100644 --- a/backend/app/api/routes/project_user.py +++ b/backend/app/api/routes/project_user.py @@ -11,7 +11,6 @@ ) from app.models import User, ProjectUserPublic, UserProjectOrg, Message from app.utils import APIResponse -from app.core.rbac.casbin import casbin_enforce router = APIRouter(prefix="/project/users", tags=["project_users"]) @@ -63,10 +62,6 @@ def list_project_users( """ Get all users in a project. """ - casbin_enforce( - sub="user:dana",org="org:2",proj="project:3", obj="project_data", act="write" - ) - users, total_count = get_users_by_project( session, current_user.project_id, skip, limit ) @@ -111,4 +106,4 @@ def remove_user( {"message": "User removed from project successfully."} ) except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) + raise HTTPException(status_code=400, detail=str(e)) \ No newline at end of file diff --git a/backend/app/core/rbac/casbin.py b/backend/app/core/rbac/casbin.py index 915ca3f7..cf7674e2 100644 --- a/backend/app/core/rbac/casbin.py +++ b/backend/app/core/rbac/casbin.py @@ -4,6 +4,7 @@ from app.core.db import engine from fastapi.concurrency import run_in_threadpool from fastapi import HTTPException, status +from app.crud.project import get_organization_by_project config_path = os.path.join(os.path.dirname(__file__), "rbac_model.conf") @@ -14,13 +15,3 @@ async def load_policy(): await run_in_threadpool(enforcer.load_policy) - - -def casbin_enforce(sub: str, obj: str, act: str, org: str= None, proj: str = None): - """Enforces Casbin RBAC rules based on org-level and project-level roles.""" - - if not enforcer.enforce(sub, org, proj, obj, act): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="You don't have access to perform this action.", - ) diff --git a/backend/app/core/rbac/rbac_model.conf b/backend/app/core/rbac/rbac_model.conf index 484ed5c3..8eac9639 100644 --- a/backend/app/core/rbac/rbac_model.conf +++ b/backend/app/core/rbac/rbac_model.conf @@ -7,7 +7,6 @@ p = role, obj, act [role_definition] g = _, _, _ # user → role → org g2 = _, _, _ # user → role → project -g3 = _, _ # project → org [policy_effect] e = some(where (p.eft == allow)) @@ -19,4 +18,5 @@ e = some(where (p.eft == allow)) # - 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_writer", r.org) && p.role == "project_writer" || 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 + +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 index 3833d01a..49db41c7 100644 --- a/backend/app/core/rbac/rbac_policies.json +++ b/backend/app/core/rbac/rbac_policies.json @@ -6,7 +6,7 @@ "actions": ["delete", "write", "read"] }, { - "role": "org_writer", + "role": "org_manager", "resource": "org_data", "actions": ["write", "read"] }, @@ -21,7 +21,7 @@ "actions": ["delete", "write", "read"] }, { - "role": "project_writer", + "role": "project_manager", "resource": "project_data", "actions": ["write", "read"] }, diff --git a/backend/app/crud/project.py b/backend/app/crud/project.py index 204adeff..f694ea11 100644 --- a/backend/app/crud/project.py +++ b/backend/app/crud/project.py @@ -21,3 +21,7 @@ def get_project_by_id(*, session: Session, project_id: int) -> Optional[Project] def get_projects_by_organization(*, session: Session, org_id: int) -> List[Project]: statement = select(Project).where(Project.organization_id == org_id) return session.exec(statement).all() + +def get_organization_by_project(*, session: Session, project_id: int) -> Optional[int]: + statement = select(Project.organization_id).where(Project.id == project_id) + return session.exec(statement).first() diff --git a/backend/app/main.py b/backend/app/main.py index c514cffd..79d8e9d0 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -21,7 +21,7 @@ def custom_generate_unique_id(route: APIRoute) -> str: @asynccontextmanager async def lifespan(app: FastAPI): - # ✅ Load Casbin policy on startup + # Load Casbin policy on startup await load_policy() app.state.enforcer = enforcer yield 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..f9765f8b --- /dev/null +++ b/backend/app/tests/core/rbac/test_rbac.py @@ -0,0 +1,115 @@ +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 From 2f77ed061eb49cd68f967be0daa384c650de81e5 Mon Sep 17 00:00:00 2001 From: avirajsingh7 Date: Thu, 10 Apr 2025 16:22:58 +0530 Subject: [PATCH 10/22] remove crud from project file --- backend/app/crud/project.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/backend/app/crud/project.py b/backend/app/crud/project.py index f694ea11..204adeff 100644 --- a/backend/app/crud/project.py +++ b/backend/app/crud/project.py @@ -21,7 +21,3 @@ def get_project_by_id(*, session: Session, project_id: int) -> Optional[Project] def get_projects_by_organization(*, session: Session, org_id: int) -> List[Project]: statement = select(Project).where(Project.organization_id == org_id) return session.exec(statement).all() - -def get_organization_by_project(*, session: Session, project_id: int) -> Optional[int]: - statement = select(Project.organization_id).where(Project.id == project_id) - return session.exec(statement).first() From b0bc7fc04d60d9bf856fe54b29f03bee32e75da3 Mon Sep 17 00:00:00 2001 From: avirajsingh7 Date: Thu, 10 Apr 2025 16:24:37 +0530 Subject: [PATCH 11/22] edited doc string --- backend/app/models/casbin_rule.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/backend/app/models/casbin_rule.py b/backend/app/models/casbin_rule.py index c4f077c4..c0747d79 100644 --- a/backend/app/models/casbin_rule.py +++ b/backend/app/models/casbin_rule.py @@ -7,7 +7,6 @@ class CasbinRule(SQLModel, table=True): Policy type (`ptype`): - "p" -> policy rule (permissions) - - "g" -> subject-role assignment (org-level) - "g2" -> subject-role assignment (project-level) - "g3" -> project-to-org mapping @@ -27,10 +26,6 @@ class CasbinRule(SQLModel, table=True): - v0: user_id - v1: role (e.g., "project_reader", etc.) - v2: project_id - - For ptype = "g3" (project-org mapping): - - v0: project_id - - v1: org_id """ __tablename__ = "casbin_rule" From 967e555782161146e9340e185be1a10c93f3ab1a Mon Sep 17 00:00:00 2001 From: avirajsingh7 Date: Thu, 10 Apr 2025 16:26:04 +0530 Subject: [PATCH 12/22] edited doc string in casbin rule --- backend/app/models/casbin_rule.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/models/casbin_rule.py b/backend/app/models/casbin_rule.py index c0747d79..48616927 100644 --- a/backend/app/models/casbin_rule.py +++ b/backend/app/models/casbin_rule.py @@ -7,8 +7,8 @@ class CasbinRule(SQLModel, table=True): Policy type (`ptype`): - "p" -> policy rule (permissions) + - "g" -> subject-role assignment (org-level) - "g2" -> subject-role assignment (project-level) - - "g3" -> project-to-org mapping Field meanings based on `ptype`: From a5c18b0d9233c99f7050ef6142110288bd265441 Mon Sep 17 00:00:00 2001 From: avirajsingh7 Date: Thu, 10 Apr 2025 16:37:43 +0530 Subject: [PATCH 13/22] format file using precomit --- backend/app/alembic/utils.py | 9 +- .../b5055f0b4a4d_added_casbin_rule.py | 35 ++- ...fc2d7ddfa265_add_intial_casbin_policies.py | 8 +- backend/app/api/routes/project_user.py | 2 +- backend/app/core/rbac/rbac_policies.json | 2 +- backend/app/models/casbin_rule.py | 3 +- backend/app/tests/core/rbac/test_rbac.py | 272 +++++++++++++++--- 7 files changed, 267 insertions(+), 64 deletions(-) diff --git a/backend/app/alembic/utils.py b/backend/app/alembic/utils.py index 7aeb674f..a3772007 100644 --- a/backend/app/alembic/utils.py +++ b/backend/app/alembic/utils.py @@ -22,10 +22,11 @@ def update_casbin_policies(conn: Connection, file_path: str): for action in actions: conn.execute( - text(""" + text( + """ INSERT INTO casbin_rule (ptype, v0, v1, v2) VALUES (:ptype, :v0, :v1, :v2) - """), - {"ptype": "p", "v0": role, "v1": resource, "v2": action} + """ + ), + {"ptype": "p", "v0": role, "v1": resource, "v2": action}, ) - \ No newline at end of file diff --git a/backend/app/alembic/versions/b5055f0b4a4d_added_casbin_rule.py b/backend/app/alembic/versions/b5055f0b4a4d_added_casbin_rule.py index 48239dda..e8e66830 100644 --- a/backend/app/alembic/versions/b5055f0b4a4d_added_casbin_rule.py +++ b/backend/app/alembic/versions/b5055f0b4a4d_added_casbin_rule.py @@ -11,31 +11,36 @@ # revision identifiers, used by Alembic. -revision = 'b5055f0b4a4d' -down_revision = '8d7a05fd0ad4' +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_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 ) - 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') + 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/alembic/versions/fc2d7ddfa265_add_intial_casbin_policies.py b/backend/app/alembic/versions/fc2d7ddfa265_add_intial_casbin_policies.py index ed221440..84a19c1c 100644 --- a/backend/app/alembic/versions/fc2d7ddfa265_add_intial_casbin_policies.py +++ b/backend/app/alembic/versions/fc2d7ddfa265_add_intial_casbin_policies.py @@ -13,8 +13,8 @@ # revision identifiers, used by Alembic. -revision = 'fc2d7ddfa265' -down_revision = 'b5055f0b4a4d' +revision = "fc2d7ddfa265" +down_revision = "b5055f0b4a4d" branch_labels = None depends_on = None @@ -22,7 +22,9 @@ def upgrade(): # ### commands auto generated by Alembic - please adjust! ### conn = op.get_bind() - file_path = os.path.join(os.path.dirname(__file__), "../../core/rbac/rbac_policies.json") + file_path = os.path.join( + os.path.dirname(__file__), "../../core/rbac/rbac_policies.json" + ) update_casbin_policies(conn, file_path) # ### end Alembic commands ### diff --git a/backend/app/api/routes/project_user.py b/backend/app/api/routes/project_user.py index 2ec2bbb1..cfa7121f 100644 --- a/backend/app/api/routes/project_user.py +++ b/backend/app/api/routes/project_user.py @@ -106,4 +106,4 @@ def remove_user( {"message": "User removed from project successfully."} ) except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) \ No newline at end of file + raise HTTPException(status_code=400, detail=str(e)) diff --git a/backend/app/core/rbac/rbac_policies.json b/backend/app/core/rbac/rbac_policies.json index 49db41c7..10f988aa 100644 --- a/backend/app/core/rbac/rbac_policies.json +++ b/backend/app/core/rbac/rbac_policies.json @@ -31,4 +31,4 @@ "actions": ["read"] } ] -} \ No newline at end of file +} diff --git a/backend/app/models/casbin_rule.py b/backend/app/models/casbin_rule.py index 48616927..dd936855 100644 --- a/backend/app/models/casbin_rule.py +++ b/backend/app/models/casbin_rule.py @@ -1,6 +1,7 @@ from typing import Optional from sqlmodel import SQLModel, Field + class CasbinRule(SQLModel, table=True): """ Represents Casbin policy rules for RBAC. @@ -27,7 +28,7 @@ class CasbinRule(SQLModel, table=True): - v1: role (e.g., "project_reader", etc.) - v2: project_id """ - + __tablename__ = "casbin_rule" id: Optional[int] = Field(default=None, primary_key=True) diff --git a/backend/app/tests/core/rbac/test_rbac.py b/backend/app/tests/core/rbac/test_rbac.py index f9765f8b..4bdd94b8 100644 --- a/backend/app/tests/core/rbac/test_rbac.py +++ b/backend/app/tests/core/rbac/test_rbac.py @@ -17,10 +17,7 @@ "admin": "user:10", # project1: project_admin } -TEST_ORGS = { - "org1": "org:1", - "org2": "org:2" -} +TEST_ORGS = {"org1": "org:1", "org2": "org:2"} TEST_PROJECTS = { "project1": "project:1", # org1 @@ -41,13 +38,24 @@ def setup_casbin_rules(request): 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"]) - + 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() @@ -61,55 +69,241 @@ def teardown(): 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. + 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["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 + 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["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["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 + 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["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 + 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 + 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 + 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 + 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 + 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 + assert ( + enforcer.enforce( + TEST_USERS["user2"], + TEST_ORGS["org1"], + TEST_PROJECTS["project1"], + "project_data", + "write", + ) + is True + ) From ae0592159d76214ed3c1672756db5d80c6be9171 Mon Sep 17 00:00:00 2001 From: avirajsingh7 Date: Thu, 10 Apr 2025 20:12:01 +0530 Subject: [PATCH 14/22] file handling for json policies --- backend/app/alembic/utils.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/backend/app/alembic/utils.py b/backend/app/alembic/utils.py index a3772007..75adb3a7 100644 --- a/backend/app/alembic/utils.py +++ b/backend/app/alembic/utils.py @@ -9,16 +9,22 @@ def update_casbin_policies(conn: Connection, file_path: str): Update Casbin policies from a JSON file and insert into the casbin_rule table. Warning : This will delete all the existing policies """ - with open(file_path) as f: - data = json.load(f) + try: + with open(file_path) as f: + data = json.load(f) + except FileNotFoundError: + raise ValueError(f'Policy file not found: {file_path}') # Clear all existing policies conn.execute(text("DELETE FROM casbin_rule WHERE ptype = 'p'")) for policy in data.get("permissions", []): - role = policy["role"] - resource = policy["resource"] - actions = policy["actions"] + try: + role = policy["role"] + resource = policy["resource"] + actions = policy["actions"] + except KeyError as e: + raise ValueError(f'Missing required field in policy: {str(e)}') for action in actions: conn.execute( From b1656979fb8996fd525971e847a6520533052c4a Mon Sep 17 00:00:00 2001 From: avirajsingh7 Date: Thu, 10 Apr 2025 20:14:01 +0530 Subject: [PATCH 15/22] precommit formatting --- backend/app/alembic/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/app/alembic/utils.py b/backend/app/alembic/utils.py index 75adb3a7..ac4f161e 100644 --- a/backend/app/alembic/utils.py +++ b/backend/app/alembic/utils.py @@ -13,7 +13,7 @@ def update_casbin_policies(conn: Connection, file_path: str): with open(file_path) as f: data = json.load(f) except FileNotFoundError: - raise ValueError(f'Policy file not found: {file_path}') + raise ValueError(f"Policy file not found: {file_path}") # Clear all existing policies conn.execute(text("DELETE FROM casbin_rule WHERE ptype = 'p'")) @@ -24,7 +24,7 @@ def update_casbin_policies(conn: Connection, file_path: str): resource = policy["resource"] actions = policy["actions"] except KeyError as e: - raise ValueError(f'Missing required field in policy: {str(e)}') + raise ValueError(f"Missing required field in policy: {str(e)}") for action in actions: conn.execute( From 864d2c71096f2bc37e58e728fe560c1009d65c04 Mon Sep 17 00:00:00 2001 From: avirajsingh7 Date: Thu, 10 Apr 2025 20:20:07 +0530 Subject: [PATCH 16/22] remove unused import --- backend/app/core/rbac/casbin.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/app/core/rbac/casbin.py b/backend/app/core/rbac/casbin.py index cf7674e2..4afc4e6c 100644 --- a/backend/app/core/rbac/casbin.py +++ b/backend/app/core/rbac/casbin.py @@ -3,8 +3,7 @@ from casbin_sqlalchemy_adapter import Adapter from app.core.db import engine from fastapi.concurrency import run_in_threadpool -from fastapi import HTTPException, status -from app.crud.project import get_organization_by_project + config_path = os.path.join(os.path.dirname(__file__), "rbac_model.conf") From be598d1a8ff7f4370191590a692dd734ee1c5067 Mon Sep 17 00:00:00 2001 From: avirajsingh7 Date: Thu, 17 Apr 2025 10:49:43 +0530 Subject: [PATCH 17/22] addes script to update casbin policies of p type --- backend/app/alembic/utils.py | 38 ----------- ...fc2d7ddfa265_add_intial_casbin_policies.py | 36 ---------- .../app/core/rbac/update_casbin_policies.py | 67 +++++++++++++++++++ 3 files changed, 67 insertions(+), 74 deletions(-) delete mode 100644 backend/app/alembic/utils.py delete mode 100644 backend/app/alembic/versions/fc2d7ddfa265_add_intial_casbin_policies.py create mode 100644 backend/app/core/rbac/update_casbin_policies.py diff --git a/backend/app/alembic/utils.py b/backend/app/alembic/utils.py deleted file mode 100644 index ac4f161e..00000000 --- a/backend/app/alembic/utils.py +++ /dev/null @@ -1,38 +0,0 @@ -import json -import os -from sqlalchemy.sql import text -from sqlalchemy.engine import Connection - - -def update_casbin_policies(conn: Connection, file_path: str): - """ - Update Casbin policies from a JSON file and insert into the casbin_rule table. - Warning : This will delete all the existing policies - """ - try: - with open(file_path) as f: - data = json.load(f) - except FileNotFoundError: - raise ValueError(f"Policy file not found: {file_path}") - - # Clear all existing policies - conn.execute(text("DELETE FROM casbin_rule WHERE ptype = 'p'")) - - for policy in data.get("permissions", []): - try: - role = policy["role"] - resource = policy["resource"] - actions = policy["actions"] - except KeyError as e: - raise ValueError(f"Missing required field in policy: {str(e)}") - - 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}, - ) diff --git a/backend/app/alembic/versions/fc2d7ddfa265_add_intial_casbin_policies.py b/backend/app/alembic/versions/fc2d7ddfa265_add_intial_casbin_policies.py deleted file mode 100644 index 84a19c1c..00000000 --- a/backend/app/alembic/versions/fc2d7ddfa265_add_intial_casbin_policies.py +++ /dev/null @@ -1,36 +0,0 @@ -"""Add intial casbin policies - -Revision ID: fc2d7ddfa265 -Revises: b5055f0b4a4d -Create Date: 2025-04-09 13:48:15.502779 - -""" -from alembic import op -import sqlalchemy as sa -import os, json -import sqlmodel.sql.sqltypes -from app.alembic.utils import update_casbin_policies - - -# revision identifiers, used by Alembic. -revision = "fc2d7ddfa265" -down_revision = "b5055f0b4a4d" -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - conn = op.get_bind() - file_path = os.path.join( - os.path.dirname(__file__), "../../core/rbac/rbac_policies.json" - ) - update_casbin_policies(conn, file_path) - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - # Not needed as only adding policies data - pass - # ### end Alembic commands ### 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..56832a2f --- /dev/null +++ b/backend/app/core/rbac/update_casbin_policies.py @@ -0,0 +1,67 @@ +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__) + + +def update_policies(session: Session) -> None: + """ + Update Casbin policies from the local JSON file. + This deletes all existing 'p' policies and inserts new ones. + """ + file_path = os.path.join(os.path.dirname(__file__), "rbac_policies.json") + try: + with open(file_path) as f: + data = json.load(f) + except FileNotFoundError: + raise ValueError(f"Policy file not found: {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() From e63f9897646390d313f65d6b90753a3bbe272606 Mon Sep 17 00:00:00 2001 From: avirajsingh7 Date: Thu, 17 Apr 2025 11:04:44 +0530 Subject: [PATCH 18/22] format file using precommit --- backend/app/core/rbac/update_casbin_policies.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/backend/app/core/rbac/update_casbin_policies.py b/backend/app/core/rbac/update_casbin_policies.py index 56832a2f..4c734c1e 100644 --- a/backend/app/core/rbac/update_casbin_policies.py +++ b/backend/app/core/rbac/update_casbin_policies.py @@ -22,7 +22,7 @@ def update_policies(session: Session) -> None: data = json.load(f) except FileNotFoundError: raise ValueError(f"Policy file not found: {file_path}") - + conn = session.connection() try: @@ -38,13 +38,15 @@ def update_policies(session: Session) -> None: 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(""" + text( + """ INSERT INTO casbin_rule (ptype, v0, v1, v2) VALUES (:ptype, :v0, :v1, :v2) - """), + """ + ), {"ptype": "p", "v0": role, "v1": resource, "v2": action}, ) @@ -56,6 +58,7 @@ def update_policies(session: Session) -> None: session.rollback() raise + def main() -> None: logger.info("Starting Casbin policy update") with Session(engine) as session: From cf54b1779e31ffc8ca32fe54d6ef871eb07d733b Mon Sep 17 00:00:00 2001 From: avirajsingh7 Date: Thu, 17 Apr 2025 11:24:43 +0530 Subject: [PATCH 19/22] update prestart script --- backend/scripts/prestart.sh | 1 + 1 file changed, 1 insertion(+) mode change 100644 => 100755 backend/scripts/prestart.sh 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 From 778a708eda625b8dbe92524cc49f471511a87d8e Mon Sep 17 00:00:00 2001 From: avirajsingh7 Date: Thu, 17 Apr 2025 11:33:59 +0530 Subject: [PATCH 20/22] update test cases to have policies --- backend/app/tests/conftest.py | 2 ++ 1 file changed, 2 insertions(+) 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 From 7cc74c73e514987e0e3e67b8ebb719e2e1c63f31 Mon Sep 17 00:00:00 2001 From: avirajsingh7 Date: Thu, 17 Apr 2025 16:21:01 +0530 Subject: [PATCH 21/22] added test cases for script for updating casbin policies --- .../app/core/rbac/update_casbin_policies.py | 7 +- .../core/rbac/test_update_casbin_policies.py | 98 +++++++++++++++++++ 2 files changed, 102 insertions(+), 3 deletions(-) create mode 100644 backend/app/tests/core/rbac/test_update_casbin_policies.py diff --git a/backend/app/core/rbac/update_casbin_policies.py b/backend/app/core/rbac/update_casbin_policies.py index 4c734c1e..c8c2ea7f 100644 --- a/backend/app/core/rbac/update_casbin_policies.py +++ b/backend/app/core/rbac/update_casbin_policies.py @@ -10,18 +10,19 @@ 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. """ - file_path = os.path.join(os.path.dirname(__file__), "rbac_policies.json") try: - with open(file_path) as f: + with open(policies_file_path) as f: data = json.load(f) except FileNotFoundError: - raise ValueError(f"Policy file not found: {file_path}") + raise ValueError(f"Policy file not found: {policies_file_path}") conn = session.connection() 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..486a8455 --- /dev/null +++ b/backend/app/tests/core/rbac/test_update_casbin_policies.py @@ -0,0 +1,98 @@ +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 From 6d8ce282c28fb5ae2c8093331d1914b203e37859 Mon Sep 17 00:00:00 2001 From: avirajsingh7 Date: Thu, 17 Apr 2025 16:22:09 +0530 Subject: [PATCH 22/22] precommit format file --- .../core/rbac/test_update_casbin_policies.py | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/backend/app/tests/core/rbac/test_update_casbin_policies.py b/backend/app/tests/core/rbac/test_update_casbin_policies.py index 486a8455..88f5a6a2 100644 --- a/backend/app/tests/core/rbac/test_update_casbin_policies.py +++ b/backend/app/tests/core/rbac/test_update_casbin_policies.py @@ -4,7 +4,11 @@ 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.rbac.update_casbin_policies import ( + update_policies, + main, + policies_file_path, +) from app.core.db import engine from app.models import CasbinRule @@ -16,14 +20,14 @@ def test_update_policies_success(db: Session): update_policies(db) # Fetch all 'p' type policies from the database - result = db.exec( - select(CasbinRule).where(CasbinRule.ptype == "p") - ).all() + 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}" + 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) @@ -48,12 +52,12 @@ def test_update_policies_invalid_file(db: Session): # 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: @@ -67,16 +71,16 @@ def test_update_policies_invalid_data(db: Session): # 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: @@ -91,8 +95,6 @@ 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() + + policies = db.exec(select(CasbinRule).where(CasbinRule.ptype == "p")).all() assert len(policies) > 0