From 1ed602eeae10935363f67e98b49a45bedc551bac Mon Sep 17 00:00:00 2001 From: "MH.Dmitrii" Date: Fri, 21 Aug 2026 12:28:25 +0300 Subject: [PATCH 1/3] permissions added --- src/models/pydantic_models/model.py | 1 + src/service/auth/auth.py | 8 +++++++- src/web/protected_routes/auth_routes.py | 19 ++++++++++++------- .../protected_user_action_routes.py | 12 ++++++------ 4 files changed, 26 insertions(+), 14 deletions(-) diff --git a/src/models/pydantic_models/model.py b/src/models/pydantic_models/model.py index fa92849..1f050c4 100644 --- a/src/models/pydantic_models/model.py +++ b/src/models/pydantic_models/model.py @@ -42,6 +42,7 @@ class PermissionsGroupsOut(Base): id:Annotated[int, Field(..., description="id of the permission group")] group:Annotated[str, Field(..., max_length=255, description="group name for the permissions")] + permissions: Annotated[list[PermissionsOut], Field(..., description="permissions granted by this group")] class UserCreate(Base): diff --git a/src/service/auth/auth.py b/src/service/auth/auth.py index 25e4019..746e454 100644 --- a/src/service/auth/auth.py +++ b/src/service/auth/auth.py @@ -49,7 +49,7 @@ class CurrentUserService: ) - async def get_current_user(self, token:str)->UserOut: + async def get_current_user(self, token:str, *permissions: str)->UserOut: payload= await self.jwt_service.jwt_decode(token) sub=payload.get("sub") @@ -69,6 +69,12 @@ class CurrentUserService: if user.status is False: raise self.error.credentials_error(detail="This user is deactivated") + effective = {p.permission for p in user.direct_permissions} | {p.permission for group in user.group for p in group.permissions} + missing = set(permissions) - effective + + if missing: + raise self.error.forbidden_error(detail=f"Missing: {missing}") + return UserOut.model_validate(user) diff --git a/src/web/protected_routes/auth_routes.py b/src/web/protected_routes/auth_routes.py index 87e5b5d..5f19f7d 100644 --- a/src/web/protected_routes/auth_routes.py +++ b/src/web/protected_routes/auth_routes.py @@ -8,6 +8,16 @@ from src.service.auth.auth import CurrentUserService, auth_service router=APIRouter(prefix="/protected") oauth2_schema=OAuth2PasswordBearer(tokenUrl="/protected/token", refreshUrl="/protected/refresh") + +def require_permissions(*permissions: str): #permissions check dependency + async def checker( + token: str = Depends(oauth2_schema), + auth: CurrentUserService = Depends(auth_service), #noqa: B008 + ) -> UserOut: + return UserOut.model_validate(await auth.get_current_user(token, *permissions)) + return checker + + @router.post("/token") async def get_access_token(request: Request,response:Response,auth:CurrentUserService=Depends(auth_service), form_data:OAuth2PasswordRequestForm=Depends())->dict: # noqa: B008 @@ -40,16 +50,11 @@ async def get_refresh_token(request:Request,response:Response, refresh_token: st return {"access_token":access_token, "token_type": "bearer"} - -async def get_current_user(token:str = Depends(oauth2_schema), auth:CurrentUserService=Depends(auth_service)) -> UserOut: # noqa: B008 - return UserOut.model_validate(await auth.get_current_user(token)) - - @router.get("/logout") -async def logout(response:Response,refresh_token: str = Cookie(),auth:CurrentUserService=Depends(auth_service),current_user:UserOut=Depends(get_current_user))->bool: # noqa: B008 +async def logout(response:Response,refresh_token: str = Cookie(),auth:CurrentUserService=Depends(auth_service),current_user:UserOut=Depends(require_permissions()))->bool: # noqa: B008 response.delete_cookie("refresh_token") return await auth.logout(refresh_token) @router.get("") -async def protected(current_user:UserOut=Depends(get_current_user))->dict: # noqa: B008 +async def protected(current_user:UserOut=Depends(require_permissions()))->dict: # noqa: B008 return {"protected router": "Hello, this is a protected router"} diff --git a/src/web/protected_routes/protected_user_action_routes.py b/src/web/protected_routes/protected_user_action_routes.py index 440852f..d25d3f9 100644 --- a/src/web/protected_routes/protected_user_action_routes.py +++ b/src/web/protected_routes/protected_user_action_routes.py @@ -2,27 +2,27 @@ from fastapi import APIRouter, Depends from src.models.pydantic_models.model import UserCreate, UserOut, UserUpdate from src.service.users_crud.users_crud import CrudService, crud_service -from src.web.protected_routes.auth_routes import get_current_user +from src.web.protected_routes.auth_routes import require_permissions router=APIRouter(prefix="/user") @router.get("/get_by_email") -async def get_current_user_by_email(email:str, crud:CrudService=Depends(crud_service), current_user=Depends(get_current_user))->UserOut: # noqa: B008 +async def get_current_user_by_email(email:str, crud:CrudService=Depends(crud_service), current_user=Depends(require_permissions("admin")))->UserOut: # noqa: B008 return await crud.get_user_by_email(email) @router.post("/create_user") -async def create_user(data:UserCreate, crud:CrudService=Depends(crud_service), current_user=Depends(get_current_user))->UserOut: #noqa: B008 +async def create_user(data:UserCreate, crud:CrudService=Depends(crud_service), current_user=Depends(require_permissions("admin")))->UserOut: #noqa: B008 return await crud.create_user(data) @router.post("/delete_user_soft") -async def delete_user_soft(email:str, crud:CrudService=Depends(crud_service), current_user=Depends(get_current_user))->bool: #noqa: B008 +async def delete_user_soft(email:str, crud:CrudService=Depends(crud_service), current_user=Depends(require_permissions("admin")))->bool: #noqa: B008 return await crud.delete_user_soft(email) @router.post("/delete_user_hard") -async def delete_user_hard(email:str, crud:CrudService=Depends(crud_service), current_user=Depends(get_current_user))->bool: #noqa: B008 +async def delete_user_hard(email:str, crud:CrudService=Depends(crud_service), current_user=Depends(require_permissions("admin")))->bool: #noqa: B008 return await crud.delete_user_hard(email, current_user) @router.patch("/patch_user") -async def patch_user(email:str, data:UserUpdate, crud:CrudService=Depends(crud_service), current_user=Depends(get_current_user))->UserOut: #noqa: B008 +async def patch_user(email:str, data:UserUpdate, crud:CrudService=Depends(crud_service), current_user=Depends(require_permissions("admin")))->UserOut: #noqa: B008 return await crud.update_user(email, data) \ No newline at end of file -- 2.54.0 From 6d1459f4d57e76e8fcd77f0c201f8e1b9909b310 Mon Sep 17 00:00:00 2001 From: "MH.Dmitrii" Date: Fri, 21 Aug 2026 14:12:45 +0300 Subject: [PATCH 2/3] migrations sync fix --- src/migrations/env.py | 17 +++---- src/migrations/versions/ebe80cd28822_.py | 46 +++++++++++++++++++ src/models/database_models/model.py | 4 +- tests/e2e/test_users_crud.py | 2 +- tests/integrated/test_auth.py | 2 +- .../{test_crud.py => test_users_crud.py} | 0 6 files changed, 56 insertions(+), 15 deletions(-) create mode 100644 src/migrations/versions/ebe80cd28822_.py rename tests/integrated/{test_crud.py => test_users_crud.py} (100%) diff --git a/src/migrations/env.py b/src/migrations/env.py index 58a89af..0d0adce 100644 --- a/src/migrations/env.py +++ b/src/migrations/env.py @@ -1,13 +1,13 @@ from logging.config import fileConfig +from alembic import context +from sqlalchemy import create_engine, pool from src.models.database_models import Model from src.models.database_models.model import engine -from sqlalchemy import engine_from_config -from sqlalchemy import pool - -from alembic import context +sync_url = engine.url.render_as_string(hide_password=False).replace("+aiosqlite", "") +sync_engine = create_engine(sync_url, poolclass=pool.NullPool) # this is the Alembic Config object, which provides # access to the values within the .ini file in use. @@ -23,7 +23,7 @@ if config.config_file_name is not None: # from myapp import mymodel # target_metadata = mymodel.Base.metadata target_metadata = Model.metadata -config.set_main_option("sqlalchemy.url", engine.url.render_as_string(hide_password=False)) +config.set_main_option("sqlalchemy.url", sync_url) # other values from the config, defined by the needs of env.py, # can be acquired: @@ -62,13 +62,8 @@ def run_migrations_online() -> None: and associate a connection with the context. """ - connectable = engine_from_config( - config.get_section(config.config_ini_section, {}), - prefix="sqlalchemy.", - poolclass=pool.NullPool, - ) - with connectable.connect() as connection: + with sync_engine.connect() as connection: context.configure( connection=connection, target_metadata=target_metadata, render_as_batch=True ) diff --git a/src/migrations/versions/ebe80cd28822_.py b/src/migrations/versions/ebe80cd28822_.py new file mode 100644 index 0000000..9ec7bdd --- /dev/null +++ b/src/migrations/versions/ebe80cd28822_.py @@ -0,0 +1,46 @@ +"""empty message + +Revision ID: ebe80cd28822 +Revises: 74814eb1b7f8 +Create Date: 2026-08-21 13:52:49.788041 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'ebe80cd28822' +down_revision: Union[str, Sequence[str], None] = '74814eb1b7f8' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user_direct_permissions', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('fk_user_direct_permissions_user_id_users'), type_='foreignkey') + batch_op.create_foreign_key(batch_op.f('fk_user_direct_permissions_user_id_users'), 'users', ['user_id'], ['id'], ondelete='CASCADE') + + with op.batch_alter_table('user_group', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('fk_user_group_user_id_users'), type_='foreignkey') + batch_op.create_foreign_key(batch_op.f('fk_user_group_user_id_users'), 'users', ['user_id'], ['id'], ondelete='CASCADE') + + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user_group', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('fk_user_group_user_id_users'), type_='foreignkey') + batch_op.create_foreign_key(batch_op.f('fk_user_group_user_id_users'), 'users', ['user_id'], ['id']) + + with op.batch_alter_table('user_direct_permissions', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('fk_user_direct_permissions_user_id_users'), type_='foreignkey') + batch_op.create_foreign_key(batch_op.f('fk_user_direct_permissions_user_id_users'), 'users', ['user_id'], ['id']) + + # ### end Alembic commands ### diff --git a/src/models/database_models/model.py b/src/models/database_models/model.py index 0bca485..ddd0e19 100644 --- a/src/models/database_models/model.py +++ b/src/models/database_models/model.py @@ -75,14 +75,14 @@ class Permissions(Model): user_group_of_permissions=Table( "user_group", Model.metadata, - Column("user_id", ForeignKey("users.id"), primary_key=True), + Column("user_id", ForeignKey("users.id", ondelete="CASCADE"), primary_key=True), Column("permission_group_id", ForeignKey("groups_of_permissions.id"), primary_key=True) ) user_permission=Table( "user_direct_permissions", Model.metadata, - Column("user_id",ForeignKey("users.id"), primary_key=True), + Column("user_id",ForeignKey("users.id", ondelete="CASCADE"), primary_key=True), Column("permission_id", ForeignKey("permissions.id"), primary_key=True) ) diff --git a/tests/e2e/test_users_crud.py b/tests/e2e/test_users_crud.py index 84b436e..6491bab 100644 --- a/tests/e2e/test_users_crud.py +++ b/tests/e2e/test_users_crud.py @@ -241,7 +241,7 @@ class TestCrud: @pytest.mark.parametrize("test_user_fixture", [ - ([], []) + (["admin"], ["admin_group"]) ], indirect=True) @pytest.mark.parametrize("user_record_to_update, expected_exception, expected_status",[ pytest.param({"plain_password": "Wrong_pass"},HTTPStatusError,422,id="Wrong_password"), diff --git a/tests/integrated/test_auth.py b/tests/integrated/test_auth.py index 46ec0a5..7424d1e 100644 --- a/tests/integrated/test_auth.py +++ b/tests/integrated/test_auth.py @@ -48,7 +48,7 @@ class TestAuth: @pytest.mark.parametrize("user_data, uuid, expected_exception,expected_status",[ pytest.param(SimpleNamespace(first_name="test",last_name="test",middle_name="test",email="d@d.d",direct_permissions=[],group=[],status=False), uuid4(), HTTPException,401, id="false_status"), pytest.param(SimpleNamespace(first_name="test",last_name="test",middle_name="test",email="d@d.d",direct_permissions=[],group=[],status=True),1234, HTTPException,401, id="wrong_id"), - pytest.param(SimpleNamespace(status=True),uuid4(), ValidationError,None,id="empty_model_data") + pytest.param(SimpleNamespace(status=True,direct_permissions=[],group=[]),uuid4(), ValidationError,None,id="empty_model_data") ]) async def test_get_current_user_negative(self,current_user_service:CurrentUserService, jwt_service:JwtService, monkeypatch, user_data:SimpleNamespace,expected_exception, expected_status:int, uuid)->None: diff --git a/tests/integrated/test_crud.py b/tests/integrated/test_users_crud.py similarity index 100% rename from tests/integrated/test_crud.py rename to tests/integrated/test_users_crud.py -- 2.54.0 From 88bd61d28b7c182b5ef70862d90fcda8ba727a70 Mon Sep 17 00:00:00 2001 From: "MH.Dmitrii" Date: Sun, 23 Aug 2026 14:51:07 +0300 Subject: [PATCH 3/3] permissions tests --- tests/e2e/test_auth.py | 46 +++++++++++++ tests/e2e/test_users_crud.py | 123 +++++++++++++++++++++++++++++++++++ 2 files changed, 169 insertions(+) create mode 100644 tests/e2e/test_auth.py diff --git a/tests/e2e/test_auth.py b/tests/e2e/test_auth.py new file mode 100644 index 0000000..597680d --- /dev/null +++ b/tests/e2e/test_auth.py @@ -0,0 +1,46 @@ +import allure +import pytest +import requests_async +from httpx import HTTPStatusError + + +class TestAuth: + + async def test_get_access_token_positive(self, target_url:str)->None: + + with allure.step("get_access_token"), pytest.raises(HTTPStatusError) as exc_info: + + response = await requests_async.post(f"{target_url}/protected/token") + response.raise_for_status() + assert exc_info.value.response.status_code != 403 + assert exc_info.value.response.status_code != 401 + + async def test_get_refresh_token_positive(self, target_url:str)->None: + + with allure.step("get_refresh_token"), pytest.raises(HTTPStatusError) as exc_info: + + response = await requests_async.post(f"{target_url}/protected/refresh") + response.raise_for_status() + assert exc_info.value.response.status_code != 403 + assert exc_info.value.response.status_code != 401 + + async def test_get_root_positive(self, target_url:str)->None: + + with allure.step("get_root"), pytest.raises(HTTPStatusError) as exc_info: + + response = await requests_async.get(f"{target_url}/protected") + response.raise_for_status() + assert exc_info.value.response.status_code != 403 + assert exc_info.value.response.status_code == 401 + + @pytest.mark.parametrize("test_user_fixture", [([], [])], indirect=True) + async def test_get_logout_positive(self,test_user_fixture, target_url:str)->None: + + session=test_user_fixture[0] + with allure.step("get_root"), pytest.raises(HTTPStatusError) as exc_info: + + response = await session.get(f"{target_url}/protected/logout") + response.raise_for_status() + assert exc_info.value.response.status_code != 403 + assert exc_info.value.response.status_code != 401 + \ No newline at end of file diff --git a/tests/e2e/test_users_crud.py b/tests/e2e/test_users_crud.py index 6491bab..2ea8b1b 100644 --- a/tests/e2e/test_users_crud.py +++ b/tests/e2e/test_users_crud.py @@ -258,3 +258,126 @@ class TestCrud: response.raise_for_status() assert exc_info.value.response.status_code == expected_status + + @pytest.mark.parametrize("test_user_fixture", [(["admin"], ["admin_group"])], indirect=True) + async def test_get_user_by_email_permissions_positive(self,test_user_fixture, target_url:str)->None: + + session=test_user_fixture[0] + + with allure.step("Get user by email"), pytest.raises(HTTPStatusError) as exc_info: + response = await session.get(f"{target_url}/user/get_by_email") + response.raise_for_status() + + assert exc_info.value.response.status_code != 403 + + + @pytest.mark.parametrize("test_user_fixture", [([], [])], indirect=True) + async def test_get_user_by_email_permissions_negative(self,test_user_fixture, target_url:str)->None: + + session=test_user_fixture[0] + + with allure.step("Get user by email"), pytest.raises(HTTPStatusError) as exc_info: + response = await session.get(f"{target_url}/user/get_by_email") + response.raise_for_status() + + assert exc_info.value.response.status_code == 403 + + @pytest.mark.parametrize("test_user_fixture", [(["admin"], ["admin_group"])], indirect=True) + async def test_create_user_permissions_positive(self,test_user_fixture, target_url:str)->None: + + session=test_user_fixture[0] + + with allure.step("Get user by email"), pytest.raises(HTTPStatusError) as exc_info: + response = await session.post(f"{target_url}/user/create_user") + response.raise_for_status() + + assert exc_info.value.response.status_code != 403 + + + @pytest.mark.parametrize("test_user_fixture", [([], [])], indirect=True) + async def test_create_user_permissions_negative(self,test_user_fixture, target_url:str)->None: + + session=test_user_fixture[0] + + with allure.step("Get user by email"), pytest.raises(HTTPStatusError) as exc_info: + response = await session.post(f"{target_url}/user/create_user") + response.raise_for_status() + + assert exc_info.value.response.status_code == 403 + + + @pytest.mark.parametrize("test_user_fixture", [(["admin"], ["admin_group"])], indirect=True) + async def test_patch_user_permissions_positive(self,test_user_fixture, target_url:str)->None: + + session=test_user_fixture[0] + + with allure.step("Get user by email"), pytest.raises(HTTPStatusError) as exc_info: + response = await session.patch(f"{target_url}/user/patch_user") + response.raise_for_status() + + assert exc_info.value.response.status_code != 403 + + + @pytest.mark.parametrize("test_user_fixture", [([], [])], indirect=True) + async def test_patch_user_permissions_negative(self,test_user_fixture, target_url:str)->None: + + session=test_user_fixture[0] + + with allure.step("Get user by email"), pytest.raises(HTTPStatusError) as exc_info: + response = await session.patch(f"{target_url}/user/patch_user") + response.raise_for_status() + + assert exc_info.value.response.status_code == 403 + + + @pytest.mark.parametrize("test_user_fixture", [(["admin"], ["admin_group"])], indirect=True) + async def test_delete_user_soft_permissions_positive(self,test_user_fixture, target_url:str)->None: + + session=test_user_fixture[0] + + with allure.step("Get user by email"), pytest.raises(HTTPStatusError) as exc_info: + response = await session.post(f"{target_url}/user/delete_user_soft") + response.raise_for_status() + + assert exc_info.value.response.status_code != 403 + + + @pytest.mark.parametrize("test_user_fixture", [([], [])], indirect=True) + async def test_delete_user_soft_permissions_negative(self,test_user_fixture, target_url:str)->None: + + session=test_user_fixture[0] + + with allure.step("Get user by email"), pytest.raises(HTTPStatusError) as exc_info: + response = await session.post(f"{target_url}/user/delete_user_soft") + response.raise_for_status() + + assert exc_info.value.response.status_code == 403 + + + @pytest.mark.parametrize("test_user_fixture", [(["admin"], ["admin_group"])], indirect=True) + async def test_delete_user_hard_permissions_positive(self,test_user_fixture, target_url:str)->None: + + session=test_user_fixture[0] + + with allure.step("Get user by email"), pytest.raises(HTTPStatusError) as exc_info: + response = await session.post(f"{target_url}/user/delete_user_hard") + response.raise_for_status() + + assert exc_info.value.response.status_code != 403 + + + @pytest.mark.parametrize("test_user_fixture", [([], [])], indirect=True) + async def test_delete_user_hard_permissions_negative(self,test_user_fixture, target_url:str)->None: + + session=test_user_fixture[0] + + with allure.step("Get user by email"), pytest.raises(HTTPStatusError) as exc_info: + response = await session.post(f"{target_url}/user/delete_user_hard") + response.raise_for_status() + + assert exc_info.value.response.status_code == 403 + + + + + \ No newline at end of file -- 2.54.0