diff --git a/backend/alembic/__pycache__/env.cpython-312.pyc b/backend/alembic/__pycache__/env.cpython-312.pyc new file mode 100644 index 00000000..4c30fe80 Binary files /dev/null and b/backend/alembic/__pycache__/env.cpython-312.pyc differ diff --git a/backend/alembic/versions/__pycache__/09fac9965b5b_init_tables.cpython-312.pyc b/backend/alembic/versions/__pycache__/09fac9965b5b_init_tables.cpython-312.pyc new file mode 100644 index 00000000..2e514833 Binary files /dev/null and b/backend/alembic/versions/__pycache__/09fac9965b5b_init_tables.cpython-312.pyc differ diff --git a/backend/alembic/versions/__pycache__/a1b2c3d4e5f6_add_permissions_tables.cpython-312.pyc b/backend/alembic/versions/__pycache__/a1b2c3d4e5f6_add_permissions_tables.cpython-312.pyc new file mode 100644 index 00000000..b6105659 Binary files /dev/null and b/backend/alembic/versions/__pycache__/a1b2c3d4e5f6_add_permissions_tables.cpython-312.pyc differ diff --git a/backend/alembic/versions/a1b2c3d4e5f6_add_permissions_tables.py b/backend/alembic/versions/a1b2c3d4e5f6_add_permissions_tables.py new file mode 100644 index 00000000..e648fbcd --- /dev/null +++ b/backend/alembic/versions/a1b2c3d4e5f6_add_permissions_tables.py @@ -0,0 +1,53 @@ +"""add permissions and role_permissions tables + +Revision ID: a1b2c3d4e5f6 +Revises: 09fac9965b5b +Create Date: 2026-04-15 10:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'a1b2c3d4e5f6' +down_revision: Union[str, None] = '09fac9965b5b' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('permissions', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('name', sa.String(length=50), nullable=False), + sa.Column('description', sa.String(length=255), nullable=True), + sa.Column('resource', sa.String(length=30), nullable=False), + sa.Column('action', sa.String(length=20), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_permissions_action'), 'permissions', ['action'], unique=False) + op.create_index(op.f('ix_permissions_name'), 'permissions', ['name'], unique=True) + op.create_index(op.f('ix_permissions_resource'), 'permissions', ['resource'], unique=False) + + op.create_table('role_permissions', + sa.Column('role_id', sa.Integer(), nullable=False), + sa.Column('permission_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['permission_id'], ['permissions.id'], ), + sa.ForeignKeyConstraint(['role_id'], ['roles.id'], ), + sa.PrimaryKeyConstraint('role_id', 'permission_id'), + sa.UniqueConstraint('role_id', 'permission_id', name='uq_role_permission') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('role_permissions') + op.drop_index(op.f('ix_permissions_resource'), table_name='permissions') + op.drop_index(op.f('ix_permissions_name'), table_name='permissions') + op.drop_index(op.f('ix_permissions_action'), table_name='permissions') + op.drop_table('permissions') + # ### end Alembic commands ### diff --git a/backend/app/__pycache__/__init__.cpython-312.pyc b/backend/app/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 00000000..6003263b Binary files /dev/null and b/backend/app/__pycache__/__init__.cpython-312.pyc differ diff --git a/backend/app/__pycache__/main.cpython-312.pyc b/backend/app/__pycache__/main.cpython-312.pyc new file mode 100644 index 00000000..bd356333 Binary files /dev/null and b/backend/app/__pycache__/main.cpython-312.pyc differ diff --git a/backend/app/api/__pycache__/deps.cpython-312.pyc b/backend/app/api/__pycache__/deps.cpython-312.pyc new file mode 100644 index 00000000..bd74f90d Binary files /dev/null and b/backend/app/api/__pycache__/deps.cpython-312.pyc differ diff --git a/backend/app/api/__pycache__/routers.cpython-312.pyc b/backend/app/api/__pycache__/routers.cpython-312.pyc new file mode 100644 index 00000000..5cdc3b4f Binary files /dev/null and b/backend/app/api/__pycache__/routers.cpython-312.pyc differ diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index c5e77d85..78ec4de1 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -89,6 +89,81 @@ async def get_current_active_superuser( return current_user +def require_permission(permission_name: str): + """ + Dependency factory for checking if a user has a specific permission. + Admin users have all permissions. + """ + + async def permission_checker( + current_user: Annotated[User, Depends(get_current_user)], + ) -> User: + if not current_user.has_permission(permission_name): + logger.warning( + f"User {current_user.id} attempted action without permission: {permission_name}" + ) + raise UserAccessError(error_type="insufficient_permissions") + return current_user + + return permission_checker + + +def require_any_permission(permission_names: list[str]): + """ + Dependency factory for checking if a user has any of the specified permissions. + Admin users have all permissions. + """ + + async def permission_checker( + current_user: Annotated[User, Depends(get_current_user)], + ) -> User: + if not current_user.has_any_permission(permission_names): + logger.warning( + f"User {current_user.id} attempted action without any of permissions: {permission_names}" + ) + raise UserAccessError(error_type="insufficient_permissions") + return current_user + + return permission_checker + + +def require_all_permissions(permission_names: list[str]): + """ + Dependency factory for checking if a user has all of the specified permissions. + Admin users have all permissions. + """ + + async def permission_checker( + current_user: Annotated[User, Depends(get_current_user)], + ) -> User: + if not current_user.has_all_permissions(permission_names): + logger.warning( + f"User {current_user.id} attempted action without all permissions: {permission_names}" + ) + raise UserAccessError(error_type="insufficient_permissions") + return current_user + + return permission_checker + + +def require_role(role_name: str): + """ + Dependency factory for checking if a user has a specific role. + """ + + async def role_checker( + current_user: Annotated[User, Depends(get_current_user)], + ) -> User: + if current_user.role is None or current_user.role.name != role_name: + logger.warning( + f"User {current_user.id} attempted action without role: {role_name}" + ) + raise UserAccessError(error_type="insufficient_permissions") + return current_user + + return role_checker + + async def pagination_params(skip: int = 0, limit: int = 100) -> PaginationParams: """ Get pagination parameters. diff --git a/backend/app/api/endpoints/__pycache__/auth.cpython-312.pyc b/backend/app/api/endpoints/__pycache__/auth.cpython-312.pyc new file mode 100644 index 00000000..2db89bc4 Binary files /dev/null and b/backend/app/api/endpoints/__pycache__/auth.cpython-312.pyc differ diff --git a/backend/app/api/endpoints/__pycache__/permissions.cpython-312.pyc b/backend/app/api/endpoints/__pycache__/permissions.cpython-312.pyc new file mode 100644 index 00000000..e5ab1523 Binary files /dev/null and b/backend/app/api/endpoints/__pycache__/permissions.cpython-312.pyc differ diff --git a/backend/app/api/endpoints/__pycache__/roles.cpython-312.pyc b/backend/app/api/endpoints/__pycache__/roles.cpython-312.pyc new file mode 100644 index 00000000..848add46 Binary files /dev/null and b/backend/app/api/endpoints/__pycache__/roles.cpython-312.pyc differ diff --git a/backend/app/api/endpoints/__pycache__/users.cpython-312.pyc b/backend/app/api/endpoints/__pycache__/users.cpython-312.pyc new file mode 100644 index 00000000..3cfc09b6 Binary files /dev/null and b/backend/app/api/endpoints/__pycache__/users.cpython-312.pyc differ diff --git a/backend/app/api/endpoints/permissions.py b/backend/app/api/endpoints/permissions.py new file mode 100644 index 00000000..454a350b --- /dev/null +++ b/backend/app/api/endpoints/permissions.py @@ -0,0 +1,145 @@ +""" +Permission management endpoints. +""" + +from fastapi import APIRouter, Depends, Request, status +from fastapi.responses import JSONResponse + +from app.api.deps import ( + CurrentSuperUser, + CurrentUser, + PaginationDep, + UnitOfWorkDep, +) +from app.core.exceptions import ValidationError +from app.models.schemas.role import ( + PermissionCreate, + PermissionPublic, + PermissionUpdate, +) +from app.utils.response import create_response + +router = APIRouter(prefix="/permissions", tags=["Permissions"]) + + +@router.get("/", response_model=list[PermissionPublic]) +async def get_permissions( + current_user: CurrentUser, + uow: UnitOfWorkDep, + pagination: PaginationDep, +) -> list[PermissionPublic]: + """ + Get all permissions with pagination. + Requires authentication. + """ + permissions = await uow.permissions.get_all(session=uow.session) + return [PermissionPublic.model_validate(perm) for perm in permissions] + + +@router.get("/by-resource/{resource}", response_model=list[PermissionPublic]) +async def get_permissions_by_resource( + current_user: CurrentUser, + resource: str, + uow: UnitOfWorkDep, +) -> list[PermissionPublic]: + """ + Get permissions by resource. + Requires authentication. + """ + permissions = await uow.permissions.get_by_resource(session=uow.session, resource=resource) + return [PermissionPublic.model_validate(perm) for perm in permissions] + + +@router.get("/{permission_id}", response_model=PermissionPublic) +async def get_permission_by_id( + current_user: CurrentUser, + permission_id: int, + uow: UnitOfWorkDep, +) -> PermissionPublic: + """ + Get permission by ID. + Requires authentication. + """ + permission = await uow.permissions.get(session=uow.session, id=permission_id) + if not permission: + raise ValidationError("permission_not_found") + return PermissionPublic.model_validate(permission) + + +@router.post("/", response_model=PermissionPublic, status_code=status.HTTP_201_CREATED) +async def create_permission( + current_superuser: CurrentSuperUser, + permission_in: PermissionCreate, + uow: UnitOfWorkDep, +) -> PermissionPublic: + """ + Create a new permission. + Requires admin privileges. + """ + existing_permission = await uow.permissions.get_by_name( + session=uow.session, + name=permission_in.name, + ) + if existing_permission: + raise ValidationError("permission_exists") + + new_permission = await uow.permissions.create( + session=uow.session, + obj_in=permission_in, + ) + return PermissionPublic.model_validate(new_permission) + + +@router.patch("/{permission_id}", response_model=PermissionPublic) +async def update_permission( + current_superuser: CurrentSuperUser, + permission_id: int, + permission_in: PermissionUpdate, + uow: UnitOfWorkDep, +) -> PermissionPublic: + """ + Update a permission. + Requires admin privileges. + """ + permission = await uow.permissions.get(session=uow.session, id=permission_id) + if not permission: + raise ValidationError("permission_not_found") + + if permission_in.name and permission_in.name != permission.name: + existing_permission = await uow.permissions.get_by_name( + session=uow.session, + name=permission_in.name, + ) + if existing_permission: + raise ValidationError("permission_exists") + + updated_permission = await uow.permissions.update( + session=uow.session, + db_obj=permission, + obj_in=permission_in, + ) + return PermissionPublic.model_validate(updated_permission) + + +@router.delete("/{permission_id}") +async def delete_permission( + current_superuser: CurrentSuperUser, + permission_id: int, + uow: UnitOfWorkDep, + request: Request, +) -> JSONResponse: + """ + Delete a permission. + Requires admin privileges. + """ + permission = await uow.permissions.get(session=uow.session, id=permission_id) + if not permission: + raise ValidationError("permission_not_found") + + await uow.permissions.delete(session=uow.session, obj=permission) + + return create_response( + status_code=status.HTTP_200_OK, + message=f"Permission {permission.name} deleted successfully", + request=request, + ) diff --git a/backend/app/api/endpoints/roles.py b/backend/app/api/endpoints/roles.py new file mode 100644 index 00000000..dafae65d --- /dev/null +++ b/backend/app/api/endpoints/roles.py @@ -0,0 +1,220 @@ +""" +Role management endpoints. +""" + +from fastapi import APIRouter, Depends, Request, status +from fastapi.responses import JSONResponse + +from app.api.deps import ( + CurrentSuperUser, + CurrentUser, + PaginationDep, + UnitOfWorkDep, +) +from app.core.exceptions import UserAccessError, ValidationError +from app.models.schemas.role import ( + AssignPermissionsRequest, + RoleCreate, + RolePublic, + RoleUpdate, + RoleWithUsers, +) +from app.utils.response import create_response + +router = APIRouter(prefix="/roles", tags=["Roles"]) + + +@router.get("/", response_model=list[RolePublic]) +async def get_roles( + current_user: CurrentUser, + uow: UnitOfWorkDep, + pagination: PaginationDep, +) -> list[RolePublic]: + """ + Get all roles with pagination. + Requires authentication. + """ + roles = await uow.roles.get_all_with_permissions(session=uow.session) + return [RolePublic.model_validate(role) for role in roles] + + +@router.get("/with-users", response_model=list[RoleWithUsers]) +async def get_roles_with_user_count( + current_superuser: CurrentSuperUser, + uow: UnitOfWorkDep, +) -> list[RoleWithUsers]: + """ + Get all roles with user count. + Requires admin privileges. + """ + roles = await uow.roles.get_all_with_permissions(session=uow.session) + result = [] + for role in roles: + user_count = await uow.roles.get_role_user_count(session=uow.session, role_id=role.id) + role_public = RolePublic.model_validate(role) + role_with_users = RoleWithUsers( + id=role_public.id, + name=role_public.name, + description=role_public.description, + permissions=role_public.permissions, + user_count=user_count, + ) + result.append(role_with_users) + return result + + +@router.get("/{role_id}", response_model=RolePublic) +async def get_role_by_id( + current_user: CurrentUser, + role_id: int, + uow: UnitOfWorkDep, +) -> RolePublic: + """ + Get role by ID. + Requires authentication. + """ + role = await uow.roles.get_by_id_with_permissions(session=uow.session, role_id=role_id) + if not role: + raise ValidationError("role_not_found") + return RolePublic.model_validate(role) + + +@router.post("/", response_model=RolePublic, status_code=status.HTTP_201_CREATED) +async def create_role( + current_superuser: CurrentSuperUser, + role_in: RoleCreate, + uow: UnitOfWorkDep, +) -> RolePublic: + """ + Create a new role. + Requires admin privileges. + """ + existing_role = await uow.roles.get_by_name(session=uow.session, name=role_in.name) + if existing_role: + raise ValidationError("role_exists") + + new_role = await uow.roles.create_role_with_permissions( + session=uow.session, + obj_in=role_in, + ) + return RolePublic.model_validate(new_role) + + +@router.patch("/{role_id}", response_model=RolePublic) +async def update_role( + current_superuser: CurrentSuperUser, + role_id: int, + role_in: RoleUpdate, + uow: UnitOfWorkDep, +) -> RolePublic: + """ + Update a role. + Requires admin privileges. + """ + role = await uow.roles.get_by_id_with_permissions(session=uow.session, role_id=role_id) + if not role: + raise ValidationError("role_not_found") + + if role_in.name and role_in.name != role.name: + existing_role = await uow.roles.get_by_name(session=uow.session, name=role_in.name) + if existing_role: + raise ValidationError("role_exists") + + updated_role = await uow.roles.update_role_with_permissions( + session=uow.session, + db_obj=role, + obj_in=role_in, + ) + return RolePublic.model_validate(updated_role) + + +@router.delete("/{role_id}") +async def delete_role( + current_superuser: CurrentSuperUser, + role_id: int, + uow: UnitOfWorkDep, + request: Request, +) -> JSONResponse: + """ + Delete a role. + Requires admin privileges. + Cannot delete roles that have users assigned. + """ + role = await uow.roles.get(session=uow.session, id=role_id) + if not role: + raise ValidationError("role_not_found") + + user_count = await uow.roles.get_role_user_count(session=uow.session, role_id=role_id) + if user_count > 0: + raise UserAccessError("role_has_users") + + await uow.roles.delete(session=uow.session, obj=role) + + return create_response( + status_code=status.HTTP_200_OK, + message=f"Role {role.name} deleted successfully", + request=request, + ) + + +@router.post("/{role_id}/permissions", response_model=RolePublic) +async def assign_permissions_to_role( + current_superuser: CurrentSuperUser, + role_id: int, + permission_ids: list[int], + uow: UnitOfWorkDep, +) -> RolePublic: + """ + Assign permissions to a role. + Requires admin privileges. + """ + role = await uow.roles.assign_permissions_to_role( + session=uow.session, + role_id=role_id, + permission_ids=permission_ids, + ) + if not role: + raise ValidationError("role_not_found") + return RolePublic.model_validate(role) + + +@router.post("/{role_id}/permissions/{permission_id}", response_model=RolePublic) +async def add_permission_to_role( + current_superuser: CurrentSuperUser, + role_id: int, + permission_id: int, + uow: UnitOfWorkDep, +) -> RolePublic: + """ + Add a single permission to a role. + Requires admin privileges. + """ + role = await uow.roles.add_permission_to_role( + session=uow.session, + role_id=role_id, + permission_id=permission_id, + ) + if not role: + raise ValidationError("role_or_permission_not_found") + return RolePublic.model_validate(role) + + +@router.delete("/{role_id}/permissions/{permission_id}", response_model=RolePublic) +async def remove_permission_from_role( + current_superuser: CurrentSuperUser, + role_id: int, + permission_id: int, + uow: UnitOfWorkDep, +) -> RolePublic: + """ + Remove a permission from a role. + Requires admin privileges. + """ + role = await uow.roles.remove_permission_from_role( + session=uow.session, + role_id=role_id, + permission_id=permission_id, + ) + if not role: + raise ValidationError("role_or_permission_not_found") + return RolePublic.model_validate(role) diff --git a/backend/app/api/routers.py b/backend/app/api/routers.py index 3e7b1635..d97633fd 100644 --- a/backend/app/api/routers.py +++ b/backend/app/api/routers.py @@ -4,10 +4,12 @@ from fastapi import APIRouter -from app.api.endpoints import auth, users +from app.api.endpoints import auth, permissions, roles, users from app.core.config import settings api_router = APIRouter() api_router.include_router(auth.router, tags=["Auth"], prefix=settings.API_V1_STR) api_router.include_router(users.router, tags=["Users"], prefix=settings.API_V1_STR) +api_router.include_router(roles.router, tags=["Roles"], prefix=settings.API_V1_STR) +api_router.include_router(permissions.router, tags=["Permissions"], prefix=settings.API_V1_STR) diff --git a/backend/app/core/__pycache__/app.cpython-312.pyc b/backend/app/core/__pycache__/app.cpython-312.pyc new file mode 100644 index 00000000..b11a4814 Binary files /dev/null and b/backend/app/core/__pycache__/app.cpython-312.pyc differ diff --git a/backend/app/core/__pycache__/db.cpython-312.pyc b/backend/app/core/__pycache__/db.cpython-312.pyc new file mode 100644 index 00000000..820c1b6d Binary files /dev/null and b/backend/app/core/__pycache__/db.cpython-312.pyc differ diff --git a/backend/app/core/__pycache__/events.cpython-312.pyc b/backend/app/core/__pycache__/events.cpython-312.pyc new file mode 100644 index 00000000..bb41107f Binary files /dev/null and b/backend/app/core/__pycache__/events.cpython-312.pyc differ diff --git a/backend/app/core/__pycache__/limiter.cpython-312.pyc b/backend/app/core/__pycache__/limiter.cpython-312.pyc new file mode 100644 index 00000000..4c6e9be1 Binary files /dev/null and b/backend/app/core/__pycache__/limiter.cpython-312.pyc differ diff --git a/backend/app/core/__pycache__/middleware.cpython-312.pyc b/backend/app/core/__pycache__/middleware.cpython-312.pyc new file mode 100644 index 00000000..2d585686 Binary files /dev/null and b/backend/app/core/__pycache__/middleware.cpython-312.pyc differ diff --git a/backend/app/core/__pycache__/profiler.cpython-312.pyc b/backend/app/core/__pycache__/profiler.cpython-312.pyc new file mode 100644 index 00000000..0408e8d4 Binary files /dev/null and b/backend/app/core/__pycache__/profiler.cpython-312.pyc differ diff --git a/backend/app/core/__pycache__/rabbit_mq.cpython-312.pyc b/backend/app/core/__pycache__/rabbit_mq.cpython-312.pyc new file mode 100644 index 00000000..72cc19d3 Binary files /dev/null and b/backend/app/core/__pycache__/rabbit_mq.cpython-312.pyc differ diff --git a/backend/app/core/__pycache__/redis.cpython-312.pyc b/backend/app/core/__pycache__/redis.cpython-312.pyc new file mode 100644 index 00000000..f85e844e Binary files /dev/null and b/backend/app/core/__pycache__/redis.cpython-312.pyc differ diff --git a/backend/app/core/app.py b/backend/app/core/app.py index 078872df..45ab63ea 100644 --- a/backend/app/core/app.py +++ b/backend/app/core/app.py @@ -19,6 +19,14 @@ "name": "Auth", "description": "Authorization", }, + { + "name": "Roles", + "description": "Role management for access control", + }, + { + "name": "Permissions", + "description": "Permission management for fine-grained access control", + }, ] diff --git a/backend/app/core/config/__pycache__/__init__.cpython-312.pyc b/backend/app/core/config/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 00000000..02b67e9d Binary files /dev/null and b/backend/app/core/config/__pycache__/__init__.cpython-312.pyc differ diff --git a/backend/app/core/config/__pycache__/base.cpython-312.pyc b/backend/app/core/config/__pycache__/base.cpython-312.pyc new file mode 100644 index 00000000..34bc50f6 Binary files /dev/null and b/backend/app/core/config/__pycache__/base.cpython-312.pyc differ diff --git a/backend/app/core/config/__pycache__/components.cpython-312.pyc b/backend/app/core/config/__pycache__/components.cpython-312.pyc new file mode 100644 index 00000000..8c6fd16d Binary files /dev/null and b/backend/app/core/config/__pycache__/components.cpython-312.pyc differ diff --git a/backend/app/core/config/__pycache__/local.cpython-312.pyc b/backend/app/core/config/__pycache__/local.cpython-312.pyc new file mode 100644 index 00000000..463a0521 Binary files /dev/null and b/backend/app/core/config/__pycache__/local.cpython-312.pyc differ diff --git a/backend/app/core/config/__pycache__/production.cpython-312.pyc b/backend/app/core/config/__pycache__/production.cpython-312.pyc new file mode 100644 index 00000000..e57022c6 Binary files /dev/null and b/backend/app/core/config/__pycache__/production.cpython-312.pyc differ diff --git a/backend/app/core/exceptions/__pycache__/__init__.cpython-312.pyc b/backend/app/core/exceptions/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 00000000..e755deab Binary files /dev/null and b/backend/app/core/exceptions/__pycache__/__init__.cpython-312.pyc differ diff --git a/backend/app/core/exceptions/handlers/__pycache__/__init__.cpython-312.pyc b/backend/app/core/exceptions/handlers/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 00000000..085c0566 Binary files /dev/null and b/backend/app/core/exceptions/handlers/__pycache__/__init__.cpython-312.pyc differ diff --git a/backend/app/core/exceptions/handlers/__pycache__/auth.cpython-312.pyc b/backend/app/core/exceptions/handlers/__pycache__/auth.cpython-312.pyc new file mode 100644 index 00000000..bbcaba6d Binary files /dev/null and b/backend/app/core/exceptions/handlers/__pycache__/auth.cpython-312.pyc differ diff --git a/backend/app/core/exceptions/handlers/__pycache__/database.cpython-312.pyc b/backend/app/core/exceptions/handlers/__pycache__/database.cpython-312.pyc new file mode 100644 index 00000000..cf8d0b76 Binary files /dev/null and b/backend/app/core/exceptions/handlers/__pycache__/database.cpython-312.pyc differ diff --git a/backend/app/core/exceptions/handlers/__pycache__/http.cpython-312.pyc b/backend/app/core/exceptions/handlers/__pycache__/http.cpython-312.pyc new file mode 100644 index 00000000..d0ebe796 Binary files /dev/null and b/backend/app/core/exceptions/handlers/__pycache__/http.cpython-312.pyc differ diff --git a/backend/app/core/exceptions/handlers/__pycache__/other.cpython-312.pyc b/backend/app/core/exceptions/handlers/__pycache__/other.cpython-312.pyc new file mode 100644 index 00000000..bb424b32 Binary files /dev/null and b/backend/app/core/exceptions/handlers/__pycache__/other.cpython-312.pyc differ diff --git a/backend/app/core/exceptions/handlers/__pycache__/utils.cpython-312.pyc b/backend/app/core/exceptions/handlers/__pycache__/utils.cpython-312.pyc new file mode 100644 index 00000000..9890777e Binary files /dev/null and b/backend/app/core/exceptions/handlers/__pycache__/utils.cpython-312.pyc differ diff --git a/backend/app/core/exceptions/types/__pycache__/__init__.cpython-312.pyc b/backend/app/core/exceptions/types/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 00000000..b206a1d0 Binary files /dev/null and b/backend/app/core/exceptions/types/__pycache__/__init__.cpython-312.pyc differ diff --git a/backend/app/core/exceptions/types/__pycache__/base.cpython-312.pyc b/backend/app/core/exceptions/types/__pycache__/base.cpython-312.pyc new file mode 100644 index 00000000..64459cea Binary files /dev/null and b/backend/app/core/exceptions/types/__pycache__/base.cpython-312.pyc differ diff --git a/backend/app/core/exceptions/types/__pycache__/config.cpython-312.pyc b/backend/app/core/exceptions/types/__pycache__/config.cpython-312.pyc new file mode 100644 index 00000000..becdcebd Binary files /dev/null and b/backend/app/core/exceptions/types/__pycache__/config.cpython-312.pyc differ diff --git a/backend/app/core/exceptions/types/__pycache__/database.cpython-312.pyc b/backend/app/core/exceptions/types/__pycache__/database.cpython-312.pyc new file mode 100644 index 00000000..d0f7e97d Binary files /dev/null and b/backend/app/core/exceptions/types/__pycache__/database.cpython-312.pyc differ diff --git a/backend/app/core/exceptions/types/__pycache__/token.cpython-312.pyc b/backend/app/core/exceptions/types/__pycache__/token.cpython-312.pyc new file mode 100644 index 00000000..644a70a3 Binary files /dev/null and b/backend/app/core/exceptions/types/__pycache__/token.cpython-312.pyc differ diff --git a/backend/app/core/exceptions/types/__pycache__/user.cpython-312.pyc b/backend/app/core/exceptions/types/__pycache__/user.cpython-312.pyc new file mode 100644 index 00000000..a0866590 Binary files /dev/null and b/backend/app/core/exceptions/types/__pycache__/user.cpython-312.pyc differ diff --git a/backend/app/core/exceptions/types/__pycache__/validation.cpython-312.pyc b/backend/app/core/exceptions/types/__pycache__/validation.cpython-312.pyc new file mode 100644 index 00000000..3bd97056 Binary files /dev/null and b/backend/app/core/exceptions/types/__pycache__/validation.cpython-312.pyc differ diff --git a/backend/app/core/exceptions/types/user.py b/backend/app/core/exceptions/types/user.py index f302fa6f..b6e189a9 100644 --- a/backend/app/core/exceptions/types/user.py +++ b/backend/app/core/exceptions/types/user.py @@ -36,6 +36,14 @@ def __init__(self, error_type: str) -> None: case "invalid_username": message = "Invalid username." error = "Username is invalid or empty." + case "role_has_users": + status_code = status.HTTP_400_BAD_REQUEST + message = "Cannot delete role." + error = "Role has users assigned. Please reassign users first." + case "insufficient_permissions": + status_code = status.HTTP_403_FORBIDDEN + message = "Access denied." + error = "Insufficient permissions to perform this action." super().__init__( status_code=status_code, diff --git a/backend/app/core/exceptions/types/validation.py b/backend/app/core/exceptions/types/validation.py index 1afd39e2..73c96690 100644 --- a/backend/app/core/exceptions/types/validation.py +++ b/backend/app/core/exceptions/types/validation.py @@ -48,6 +48,30 @@ def __init__(self, error_type: str) -> None: case "invalid_pagination": message = "Pagination parameters must be greater than 0." error = "Invalid pagination parameters" + case "role_not_found": + message = "Role not found" + error = "The specified role does not exist" + case "role_exists": + message = "Role already exists" + error = "A role with this name already exists" + case "permission_not_found": + message = "Permission not found" + error = "The specified permission does not exist" + case "permission_exists": + message = "Permission already exists" + error = "A permission with this name already exists" + case "role_or_permission_not_found": + message = "Role or permission not found" + error = "The specified role or permission does not exist" + case "invalid_permission_name": + message = "Invalid permission name" + error = "Permission name must be between 1 and 50 characters" + case "invalid_resource": + message = "Invalid resource" + error = "Resource must be between 1 and 30 characters" + case "invalid_action": + message = "Invalid action" + error = "Action must be between 1 and 20 characters" super().__init__( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, diff --git a/backend/app/main.py b/backend/app/main.py index 98f06a66..2930282a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -4,9 +4,9 @@ import logging.config import os +import platform from typing import Any, cast -import gunicorn.app.base import sentry_sdk from sentry_sdk.integrations.fastapi import FastApiIntegration from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration @@ -88,58 +88,72 @@ async def health_check() -> dict[str, str]: app.add_middleware(RequestIDMiddleware) -class StandaloneApplication(gunicorn.app.base.BaseApplication): # type: ignore - """ - Standalone application for running the FastAPI app. - """ - - def __init__(self, app: Any, options: dict[str, Any] | None = None) -> None: - """ - Initialize the standalone application. - """ - self.options = options or {} - self.application = app - super().__init__() +if platform.system() != "Windows": + import gunicorn.app.base - def load_config(self) -> None: + class StandaloneApplication(gunicorn.app.base.BaseApplication): # type: ignore """ - Load configuration from options. + Standalone application for running the FastAPI app. """ - config = { - key: value - for key, value in self.options.items() - if key in self.cfg.settings and value is not None - } - for key, value in config.items(): - self.cfg.set(key.lower(), value) - def load(self) -> Any: - """ - Load the application. - """ - return self.application + def __init__(self, app: Any, options: dict[str, Any] | None = None) -> None: + """ + Initialize the standalone application. + """ + self.options = options or {} + self.application = app + super().__init__() + + def load_config(self) -> None: + """ + Load configuration from options. + """ + config = { + key: value + for key, value in self.options.items() + if key in self.cfg.settings and value is not None + } + for key, value in config.items(): + self.cfg.set(key.lower(), value) + + def load(self) -> Any: + """ + Load the application. + """ + return self.application if __name__ == "__main__": - logger.info(f"Starting server on {settings.SERVER_HOST}:{settings.SERVER_PORT}") - - options = { - "bind": f"{settings.SERVER_HOST}:{settings.SERVER_PORT}", - "workers": settings.SERVER_WORKERS, - "worker_class": "uvicorn.workers.UvicornWorker", - "timeout": 90, - "keepalive": 10, - "worker_connections": 1000, - "graceful_timeout": 60, - "limit_request_line": 8190, - "limit_request_fields": 100, - "limit_request_field_size": 8190, - "loglevel": f"{settings.LOG_LEVEL.lower()}", - "preload_app": True, - "reuse_port": True, - "capture_output": True, - "errorlog": "-", - "accesslog": "-", - } - - StandaloneApplication(app, options).run() + if platform.system() == "Windows": + import uvicorn + + logger.info(f"Starting server on {settings.SERVER_HOST}:{settings.SERVER_PORT}") + uvicorn.run( + "app.main:app", + host=settings.SERVER_HOST, + port=settings.SERVER_PORT, + reload=True, + ) + else: + logger.info(f"Starting server on {settings.SERVER_HOST}:{settings.SERVER_PORT}") + + options = { + "bind": f"{settings.SERVER_HOST}:{settings.SERVER_PORT}", + "workers": settings.SERVER_WORKERS, + "worker_class": "uvicorn.workers.UvicornWorker", + "timeout": 90, + "keepalive": 10, + "worker_connections": 1000, + "graceful_timeout": 60, + "limit_request_line": 8190, + "limit_request_fields": 100, + "limit_request_field_size": 8190, + "loglevel": f"{settings.LOG_LEVEL.lower()}", + "preload_app": True, + "reuse_port": True, + "capture_output": True, + "errorlog": "-", + "accesslog": "-", + } + + StandaloneApplication(app, options).run() diff --git a/backend/app/models/__pycache__/__init__.cpython-312.pyc b/backend/app/models/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 00000000..4189d0d4 Binary files /dev/null and b/backend/app/models/__pycache__/__init__.cpython-312.pyc differ diff --git a/backend/app/models/__pycache__/base.cpython-312.pyc b/backend/app/models/__pycache__/base.cpython-312.pyc new file mode 100644 index 00000000..dc04e37c Binary files /dev/null and b/backend/app/models/__pycache__/base.cpython-312.pyc differ diff --git a/backend/app/models/domain/__pycache__/permission.cpython-312.pyc b/backend/app/models/domain/__pycache__/permission.cpython-312.pyc new file mode 100644 index 00000000..6f9f3bec Binary files /dev/null and b/backend/app/models/domain/__pycache__/permission.cpython-312.pyc differ diff --git a/backend/app/models/domain/__pycache__/role.cpython-312.pyc b/backend/app/models/domain/__pycache__/role.cpython-312.pyc new file mode 100644 index 00000000..db9dd045 Binary files /dev/null and b/backend/app/models/domain/__pycache__/role.cpython-312.pyc differ diff --git a/backend/app/models/domain/__pycache__/session.cpython-312.pyc b/backend/app/models/domain/__pycache__/session.cpython-312.pyc new file mode 100644 index 00000000..847c431b Binary files /dev/null and b/backend/app/models/domain/__pycache__/session.cpython-312.pyc differ diff --git a/backend/app/models/domain/__pycache__/user.cpython-312.pyc b/backend/app/models/domain/__pycache__/user.cpython-312.pyc new file mode 100644 index 00000000..f606778e Binary files /dev/null and b/backend/app/models/domain/__pycache__/user.cpython-312.pyc differ diff --git a/backend/app/models/domain/permission.py b/backend/app/models/domain/permission.py new file mode 100644 index 00000000..c98f6603 --- /dev/null +++ b/backend/app/models/domain/permission.py @@ -0,0 +1,74 @@ +import enum +from typing import TYPE_CHECKING + +from sqlalchemy import Column, ForeignKey, Integer, String, Table, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.models.base import Base + +if TYPE_CHECKING: + from app.models.domain.role import Role + + +class PermissionEnum(str, enum.Enum): + """ + Enum for permission names. + """ + + USER_READ = "user:read" + USER_CREATE = "user:create" + USER_UPDATE = "user:update" + USER_DELETE = "user:delete" + + ROLE_READ = "role:read" + ROLE_CREATE = "role:create" + ROLE_UPDATE = "role:update" + ROLE_DELETE = "role:delete" + + PERMISSION_READ = "permission:read" + PERMISSION_CREATE = "permission:create" + PERMISSION_UPDATE = "permission:update" + PERMISSION_DELETE = "permission:delete" + + ENDPOINT_EXECUTE = "endpoint:execute" + ENDPOINT_MANAGE = "endpoint:manage" + + +role_permission = Table( + "role_permissions", + Base.metadata, + Column("role_id", Integer, ForeignKey("roles.id"), primary_key=True), + Column("permission_id", Integer, ForeignKey("permissions.id"), primary_key=True), + UniqueConstraint("role_id", "permission_id", name="uq_role_permission"), +) + + +class Permission(Base): + """ + Permission model for defining access control permissions. + """ + + __tablename__ = "permissions" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column( + String(50), + nullable=False, + unique=True, + index=True, + ) + description: Mapped[str | None] = mapped_column(String(255), nullable=True) + resource: Mapped[str] = mapped_column(String(30), nullable=False, index=True) + action: Mapped[str] = mapped_column(String(20), nullable=False, index=True) + + roles: Mapped[list["Role"]] = relationship( + "Role", + secondary=role_permission, + back_populates="permissions", + ) + + def __repr__(self) -> str: + return f"" + + def __str__(self) -> str: + return self.name diff --git a/backend/app/models/domain/role.py b/backend/app/models/domain/role.py index a3d63cc5..f4fe239d 100644 --- a/backend/app/models/domain/role.py +++ b/backend/app/models/domain/role.py @@ -5,8 +5,10 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship from app.models.base import Base +from app.models.domain.permission import role_permission if TYPE_CHECKING: + from app.models.domain.permission import Permission from app.models.domain.user import User @@ -38,9 +40,26 @@ class Role(Base): description: Mapped[str | None] = mapped_column(String(255), nullable=True) users: Mapped[list["User"]] = relationship("User", back_populates="role") + permissions: Mapped[list["Permission"]] = relationship( + "Permission", + secondary=role_permission, + back_populates="roles", + ) def __repr__(self) -> str: return f"" def __str__(self) -> str: return self.name + + def has_permission(self, permission_name: str) -> bool: + """ + Check if the role has a specific permission. + """ + return any(perm.name == permission_name for perm in self.permissions) + + def get_permission_names(self) -> list[str]: + """ + Get all permission names for this role. + """ + return [perm.name for perm in self.permissions] diff --git a/backend/app/models/domain/user.py b/backend/app/models/domain/user.py index 517504bf..8344e4ec 100644 --- a/backend/app/models/domain/user.py +++ b/backend/app/models/domain/user.py @@ -48,5 +48,44 @@ class User(Base): def is_admin(self) -> bool: return self.role is not None and self.role.name == RoleEnum.ADMIN + def has_permission(self, permission_name: str) -> bool: + """ + Check if the user has a specific permission. + Admin users have all permissions. + """ + if self.is_admin: + return True + if self.role is None: + return False + return self.role.has_permission(permission_name) + + def has_any_permission(self, permission_names: list[str]) -> bool: + """ + Check if the user has any of the specified permissions. + """ + if self.is_admin: + return True + if self.role is None: + return False + return any(self.role.has_permission(p) for p in permission_names) + + def has_all_permissions(self, permission_names: list[str]) -> bool: + """ + Check if the user has all of the specified permissions. + """ + if self.is_admin: + return True + if self.role is None: + return False + return all(self.role.has_permission(p) for p in permission_names) + + def get_permissions(self) -> list[str]: + """ + Get all permission names for this user. + """ + if self.role is None: + return [] + return self.role.get_permission_names() + def __repr__(self) -> str: return f"" diff --git a/backend/app/models/schemas/__pycache__/email.cpython-312.pyc b/backend/app/models/schemas/__pycache__/email.cpython-312.pyc new file mode 100644 index 00000000..38074a33 Binary files /dev/null and b/backend/app/models/schemas/__pycache__/email.cpython-312.pyc differ diff --git a/backend/app/models/schemas/__pycache__/pagination.cpython-312.pyc b/backend/app/models/schemas/__pycache__/pagination.cpython-312.pyc new file mode 100644 index 00000000..0d01cd75 Binary files /dev/null and b/backend/app/models/schemas/__pycache__/pagination.cpython-312.pyc differ diff --git a/backend/app/models/schemas/__pycache__/password.cpython-312.pyc b/backend/app/models/schemas/__pycache__/password.cpython-312.pyc new file mode 100644 index 00000000..78872560 Binary files /dev/null and b/backend/app/models/schemas/__pycache__/password.cpython-312.pyc differ diff --git a/backend/app/models/schemas/__pycache__/response.cpython-312.pyc b/backend/app/models/schemas/__pycache__/response.cpython-312.pyc new file mode 100644 index 00000000..9583b08f Binary files /dev/null and b/backend/app/models/schemas/__pycache__/response.cpython-312.pyc differ diff --git a/backend/app/models/schemas/__pycache__/role.cpython-312.pyc b/backend/app/models/schemas/__pycache__/role.cpython-312.pyc new file mode 100644 index 00000000..52017c51 Binary files /dev/null and b/backend/app/models/schemas/__pycache__/role.cpython-312.pyc differ diff --git a/backend/app/models/schemas/__pycache__/session.cpython-312.pyc b/backend/app/models/schemas/__pycache__/session.cpython-312.pyc new file mode 100644 index 00000000..42bb7377 Binary files /dev/null and b/backend/app/models/schemas/__pycache__/session.cpython-312.pyc differ diff --git a/backend/app/models/schemas/__pycache__/token.cpython-312.pyc b/backend/app/models/schemas/__pycache__/token.cpython-312.pyc new file mode 100644 index 00000000..8b067faa Binary files /dev/null and b/backend/app/models/schemas/__pycache__/token.cpython-312.pyc differ diff --git a/backend/app/models/schemas/__pycache__/user.cpython-312.pyc b/backend/app/models/schemas/__pycache__/user.cpython-312.pyc new file mode 100644 index 00000000..c631b825 Binary files /dev/null and b/backend/app/models/schemas/__pycache__/user.cpython-312.pyc differ diff --git a/backend/app/models/schemas/role.py b/backend/app/models/schemas/role.py index 5db60400..be88d3ac 100644 --- a/backend/app/models/schemas/role.py +++ b/backend/app/models/schemas/role.py @@ -1,21 +1,151 @@ -from pydantic import BaseModel +from typing import Any -from app.models.domain.role import RoleEnum +from pydantic import BaseModel, field_validator +from app.core.exceptions import ValidationError +from app.models.domain.permission import Permission, PermissionEnum +from app.models.domain.role import Role, RoleEnum -class RoleCreate(BaseModel): - """ - Schema for creating a role - """ +class PermissionBase(BaseModel): + name: str + description: str | None = None + resource: str + action: str + + @field_validator("name") + @classmethod + def name_validator(cls, v: str) -> str: + if not v or len(v) > 50: + raise ValidationError("invalid_permission_name") + return v.strip() + + @field_validator("resource") + @classmethod + def resource_validator(cls, v: str) -> str: + if not v or len(v) > 30: + raise ValidationError("invalid_resource") + return v.strip() + + @field_validator("action") + @classmethod + def action_validator(cls, v: str) -> str: + if not v or len(v) > 20: + raise ValidationError("invalid_action") + return v.strip() + + +class PermissionCreate(PermissionBase): + pass + + +class PermissionUpdate(BaseModel): + name: str | None = None + description: str | None = None + resource: str | None = None + action: str | None = None + + @field_validator("name") + @classmethod + def name_validator(cls, v: str | None) -> str | None: + if v is None: + return v + if not v or len(v) > 50: + raise ValidationError("invalid_permission_name") + return v.strip() + + @field_validator("resource") + @classmethod + def resource_validator(cls, v: str | None) -> str | None: + if v is None: + return v + if not v or len(v) > 30: + raise ValidationError("invalid_resource") + return v.strip() + + @field_validator("action") + @classmethod + def action_validator(cls, v: str | None) -> str | None: + if v is None: + return v + if not v or len(v) > 20: + raise ValidationError("invalid_action") + return v.strip() + + +class PermissionPublic(PermissionBase): + id: int + + model_config = {"from_attributes": True, "arbitrary_types_allowed": True} + + +class RoleBase(BaseModel): name: RoleEnum description: str | None = None + @field_validator("name") + @classmethod + def name_validator(cls, v: RoleEnum) -> RoleEnum: + try: + return RoleEnum(v) + except ValueError: + raise ValidationError("invalid_role") -class RoleUpdate(BaseModel): - """ - Schema for updating a role - """ +class RoleCreate(RoleBase): + permissions: list[str] | None = None + + +class RoleUpdate(BaseModel): name: RoleEnum | None = None description: str | None = None + permissions: list[str] | None = None + + @field_validator("name") + @classmethod + def name_validator(cls, v: RoleEnum | None) -> RoleEnum | None: + if v is None: + return v + try: + return RoleEnum(v) + except ValueError: + raise ValidationError("invalid_role") + + +class RolePublic(RoleBase): + id: int + permissions: list[PermissionPublic] = [] + + model_config = {"from_attributes": True, "arbitrary_types_allowed": True} + + @field_validator("permissions", mode="before") + @classmethod + def permissions_from_obj(cls, v: Any) -> list[PermissionPublic]: + if isinstance(v, list): + result = [] + for item in v: + if isinstance(item, Permission): + result.append(PermissionPublic.model_validate(item)) + elif isinstance(item, dict): + result.append(PermissionPublic(**item)) + elif isinstance(item, PermissionPublic): + result.append(item) + return result + return [] + + def permission_names(self) -> list[str]: + return [p.name for p in self.permissions] + + +class RoleWithUsers(RolePublic): + user_count: int = 0 + + +class AssignRoleRequest(BaseModel): + user_id: str + role_id: int + + +class AssignPermissionsRequest(BaseModel): + role_id: int + permission_ids: list[int] diff --git a/backend/app/repositories/__pycache__/__init__.cpython-312.pyc b/backend/app/repositories/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 00000000..e99cce7b Binary files /dev/null and b/backend/app/repositories/__pycache__/__init__.cpython-312.pyc differ diff --git a/backend/app/repositories/__pycache__/base.cpython-312.pyc b/backend/app/repositories/__pycache__/base.cpython-312.pyc new file mode 100644 index 00000000..45b81910 Binary files /dev/null and b/backend/app/repositories/__pycache__/base.cpython-312.pyc differ diff --git a/backend/app/repositories/__pycache__/permission.cpython-312.pyc b/backend/app/repositories/__pycache__/permission.cpython-312.pyc new file mode 100644 index 00000000..cfb03501 Binary files /dev/null and b/backend/app/repositories/__pycache__/permission.cpython-312.pyc differ diff --git a/backend/app/repositories/__pycache__/role.cpython-312.pyc b/backend/app/repositories/__pycache__/role.cpython-312.pyc new file mode 100644 index 00000000..ab09d1b9 Binary files /dev/null and b/backend/app/repositories/__pycache__/role.cpython-312.pyc differ diff --git a/backend/app/repositories/__pycache__/session.cpython-312.pyc b/backend/app/repositories/__pycache__/session.cpython-312.pyc new file mode 100644 index 00000000..ae808d54 Binary files /dev/null and b/backend/app/repositories/__pycache__/session.cpython-312.pyc differ diff --git a/backend/app/repositories/__pycache__/unit_of_work.cpython-312.pyc b/backend/app/repositories/__pycache__/unit_of_work.cpython-312.pyc new file mode 100644 index 00000000..800bc7fe Binary files /dev/null and b/backend/app/repositories/__pycache__/unit_of_work.cpython-312.pyc differ diff --git a/backend/app/repositories/__pycache__/user.cpython-312.pyc b/backend/app/repositories/__pycache__/user.cpython-312.pyc new file mode 100644 index 00000000..7d993906 Binary files /dev/null and b/backend/app/repositories/__pycache__/user.cpython-312.pyc differ diff --git a/backend/app/repositories/permission.py b/backend/app/repositories/permission.py new file mode 100644 index 00000000..a2eac8bb --- /dev/null +++ b/backend/app/repositories/permission.py @@ -0,0 +1,137 @@ +""" +Permission repository for the application. +""" + +import logging + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.models.domain.permission import Permission, PermissionEnum +from app.models.schemas.role import PermissionCreate, PermissionUpdate +from app.repositories.base import BaseRepository + +logger = logging.getLogger("app.repositories.permission") + + +class PermissionRepository(BaseRepository[Permission, PermissionCreate, PermissionUpdate]): + """ + Repository for managing permissions. + """ + + def __init__(self) -> None: + """ + Initialize permission repository. + """ + super().__init__(Permission) + + async def get_by_name( + self, + session: AsyncSession, + name: str, + ) -> Permission | None: + """ + Get permission by name. + """ + logger.debug(f"Getting permission by name: {name}") + query = select(self.model).where(self.model.name == name) + result = await session.execute(query) + return result.scalars().first() + + async def get_by_ids( + self, + session: AsyncSession, + permission_ids: list[int], + ) -> list[Permission]: + """ + Get permissions by list of IDs. + """ + logger.debug(f"Getting permissions by IDs: {permission_ids}") + if not permission_ids: + return [] + query = select(self.model).where(self.model.id.in_(permission_ids)) + result = await session.execute(query) + return list(result.scalars().all()) + + async def get_by_names( + self, + session: AsyncSession, + permission_names: list[str], + ) -> list[Permission]: + """ + Get permissions by list of names. + """ + logger.debug(f"Getting permissions by names: {permission_names}") + if not permission_names: + return [] + query = select(self.model).where(self.model.name.in_(permission_names)) + result = await session.execute(query) + return list(result.scalars().all()) + + async def get_all( + self, + session: AsyncSession, + ) -> list[Permission]: + """ + Get all permissions. + """ + logger.debug("Getting all permissions") + query = select(self.model).order_by(self.model.id) + result = await session.execute(query) + return list(result.scalars().all()) + + async def get_by_resource( + self, + session: AsyncSession, + resource: str, + ) -> list[Permission]: + """ + Get permissions by resource. + """ + logger.debug(f"Getting permissions by resource: {resource}") + query = select(self.model).where(self.model.resource == resource).order_by(self.model.id) + result = await session.execute(query) + return list(result.scalars().all()) + + async def initialize_permissions(self, session: AsyncSession) -> None: + """ + Initialize all default permissions. + """ + logger.info("Initializing default permissions") + + permissions_config = [ + (1, PermissionEnum.USER_READ, "Read user information", "user", "read"), + (2, PermissionEnum.USER_CREATE, "Create new users", "user", "create"), + (3, PermissionEnum.USER_UPDATE, "Update user information", "user", "update"), + (4, PermissionEnum.USER_DELETE, "Delete users", "user", "delete"), + (5, PermissionEnum.ROLE_READ, "Read role information", "role", "read"), + (6, PermissionEnum.ROLE_CREATE, "Create new roles", "role", "create"), + (7, PermissionEnum.ROLE_UPDATE, "Update role information", "role", "update"), + (8, PermissionEnum.ROLE_DELETE, "Delete roles", "role", "delete"), + (9, PermissionEnum.PERMISSION_READ, "Read permission information", "permission", "read"), + (10, PermissionEnum.PERMISSION_CREATE, "Create new permissions", "permission", "create"), + (11, PermissionEnum.PERMISSION_UPDATE, "Update permission information", "permission", "update"), + (12, PermissionEnum.PERMISSION_DELETE, "Delete permissions", "permission", "delete"), + (13, PermissionEnum.ENDPOINT_EXECUTE, "Execute API endpoints", "endpoint", "execute"), + (14, PermissionEnum.ENDPOINT_MANAGE, "Manage API endpoints", "endpoint", "manage"), + ] + + for perm_id, perm_name, description, resource, action in permissions_config: + existing = await self.get_by_name(session, perm_name) + if not existing: + permission = Permission( + id=perm_id, + name=perm_name, + description=description, + resource=resource, + action=action, + ) + session.add(permission) + logger.info(f"Created permission: {perm_name}") + + await session.flush() + logger.info("Permissions initialized successfully") + + +permission_repo = PermissionRepository() diff --git a/backend/app/repositories/role.py b/backend/app/repositories/role.py index 4f2b3415..786943f1 100644 --- a/backend/app/repositories/role.py +++ b/backend/app/repositories/role.py @@ -4,12 +4,16 @@ import logging -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload +from app.models.domain.permission import Permission from app.models.domain.role import Role, RoleEnum +from app.models.domain.user import User from app.models.schemas.role import RoleCreate, RoleUpdate from app.repositories.base import BaseRepository +from app.repositories.permission import permission_repo logger = logging.getLogger("app.repositories.role") @@ -31,22 +35,74 @@ async def get_by_name( name: RoleEnum, ) -> Role | None: """ - Get role by name. + Get role by name with permissions loaded. """ logger.debug(f"Getting role by name: {name}") - query = select(self.model).where(self.model.name == name) + query = ( + select(self.model) + .options(selectinload(self.model.permissions)) + .where(self.model.name == name) + ) result = await session.execute(query) return result.scalars().first() + async def get_by_id_with_permissions( + self, + session: AsyncSession, + role_id: int, + ) -> Role | None: + """ + Get role by ID with permissions loaded. + """ + logger.debug(f"Getting role by ID with permissions: {role_id}") + query = ( + select(self.model) + .options(selectinload(self.model.permissions)) + .where(self.model.id == role_id) + ) + result = await session.execute(query) + return result.scalars().first() + + async def get_all_with_permissions( + self, + session: AsyncSession, + ) -> list[Role]: + """ + Get all roles with permissions loaded. + """ + logger.debug("Getting all roles with permissions") + query = ( + select(self.model) + .options(selectinload(self.model.permissions)) + .order_by(self.model.id) + ) + result = await session.execute(query) + return list(result.scalars().unique().all()) + + async def get_role_user_count( + self, + session: AsyncSession, + role_id: int, + ) -> int: + """ + Get the number of users assigned to a role. + """ + logger.debug(f"Getting user count for role: {role_id}") + query = select(func.count(User.id)).where(User.role_id == role_id) + result = await session.execute(query) + count = result.scalar() + return count if count is not None else 0 + async def _create_new_role( self, session: AsyncSession, name: RoleEnum, description: str | None, role_id: int | None = None, + permissions: list[Permission] | None = None, ) -> Role: """ - Create new role with optional specific ID. + Create new role with optional specific ID and permissions. """ logger.info(f"Creating new role: {name}") @@ -54,6 +110,8 @@ async def _create_new_role( role = Role(id=role_id, name=name) # type: ignore[call-arg] if description is not None: role.description = description + if permissions: + role.permissions = permissions session.add(role) await session.flush() await session.refresh(role) @@ -61,15 +119,73 @@ async def _create_new_role( else: role_data = RoleCreate(name=name, description=description) role = await self.create(session=session, obj_in=role_data) + if permissions: + role.permissions = permissions + await session.flush() + await session.refresh(role) + + return role + + async def create_role_with_permissions( + self, + session: AsyncSession, + obj_in: RoleCreate, + ) -> Role: + """ + Create a new role with permissions. + """ + logger.info(f"Creating role with permissions: {obj_in.name}") + + permissions = [] + if obj_in.permissions: + permissions = await permission_repo.get_by_names(session, obj_in.permissions) + + role = Role( + name=obj_in.name, + description=obj_in.description, + ) + if permissions: + role.permissions = permissions + + session.add(role) + await session.flush() + await session.refresh(role) return role + async def update_role_with_permissions( + self, + session: AsyncSession, + db_obj: Role, + obj_in: RoleUpdate, + ) -> Role: + """ + Update role with permissions. + """ + logger.debug(f"Updating role: {db_obj.id}") + + update_data = obj_in.model_dump(exclude_unset=True, exclude={"permissions"}) + + for field, value in update_data.items(): + setattr(db_obj, field, value) + + if obj_in.permissions is not None: + permissions = await permission_repo.get_by_names(session, obj_in.permissions) + db_obj.permissions = permissions + + session.add(db_obj) + await session.flush() + await session.refresh(db_obj) + + return db_obj + async def get_or_create( self, session: AsyncSession, name: RoleEnum, description: str | None = None, role_id: int | None = None, + permissions: list[Permission] | None = None, ) -> Role: """ Get role by name or create if it doesn't exist. @@ -83,7 +199,7 @@ async def get_or_create( description, ) - return await self._create_new_role(session, name, description, role_id) + return await self._create_new_role(session, name, description, role_id, permissions) async def _update_role_description( self, @@ -100,25 +216,135 @@ async def _update_role_description( role = await self.update(session=session, db_obj=role, obj_in=update_data) return role + async def assign_permissions_to_role( + self, + session: AsyncSession, + role_id: int, + permission_ids: list[int], + ) -> Role | None: + """ + Assign permissions to a role. + """ + logger.info(f"Assigning permissions {permission_ids} to role {role_id}") + + role = await self.get_by_id_with_permissions(session, role_id) + if not role: + return None + + permissions = await permission_repo.get_by_ids(session, permission_ids) + role.permissions = permissions + + session.add(role) + await session.flush() + await session.refresh(role) + + return role + + async def add_permission_to_role( + self, + session: AsyncSession, + role_id: int, + permission_id: int, + ) -> Role | None: + """ + Add a single permission to a role. + """ + logger.info(f"Adding permission {permission_id} to role {role_id}") + + role = await self.get_by_id_with_permissions(session, role_id) + if not role: + return None + + permission = await permission_repo.get(session, permission_id) + if not permission: + return None + + if permission not in role.permissions: + role.permissions.append(permission) + session.add(role) + await session.flush() + await session.refresh(role) + + return role + + async def remove_permission_from_role( + self, + session: AsyncSession, + role_id: int, + permission_id: int, + ) -> Role | None: + """ + Remove a permission from a role. + """ + logger.info(f"Removing permission {permission_id} from role {role_id}") + + role = await self.get_by_id_with_permissions(session, role_id) + if not role: + return None + + permission = await permission_repo.get(session, permission_id) + if not permission: + return None + + if permission in role.permissions: + role.permissions.remove(permission) + session.add(role) + await session.flush() + await session.refresh(role) + + return role + async def initialize_roles(self, session: AsyncSession) -> None: """ - Initialize all roles with descriptions and specific IDs. + Initialize all roles with descriptions, specific IDs, and default permissions. """ - logger.info("Initializing roles with specific IDs") + logger.info("Initializing roles with specific IDs and permissions") + + all_permissions = await permission_repo.get_all(session) + permission_map = {p.name: p for p in all_permissions} + + admin_permissions = list(all_permissions) + + manager_permission_names = [ + "user:read", + "user:create", + "user:update", + "role:read", + "endpoint:execute", + ] + manager_permissions = [ + permission_map[name] for name in manager_permission_names if name in permission_map + ] + + user_permission_names = [ + "user:read", + "endpoint:execute", + ] + user_permissions = [ + permission_map[name] for name in user_permission_names if name in permission_map + ] + + guest_permission_names = [ + "endpoint:execute", + ] + guest_permissions = [ + permission_map[name] for name in guest_permission_names if name in permission_map + ] roles_config = [ - (1, RoleEnum.ADMIN, "Administrator with full access"), - (2, RoleEnum.MANAGER, "Manager with limited administrative access"), - (3, RoleEnum.USER, "Regular user"), - (4, RoleEnum.GUEST, "Guest user with restricted access"), + (1, RoleEnum.ADMIN, "Administrator with full access", admin_permissions), + (2, RoleEnum.MANAGER, "Manager with limited administrative access", manager_permissions), + (3, RoleEnum.USER, "Regular user", user_permissions), + (4, RoleEnum.GUEST, "Guest user with restricted access", guest_permissions), ] - for role_id, role_name, description in roles_config: + for role_id, role_name, description, permissions in roles_config: await self.get_or_create( session=session, name=role_name, description=description, role_id=role_id, + permissions=permissions, ) logger.info("Roles initialized successfully") diff --git a/backend/app/repositories/unit_of_work.py b/backend/app/repositories/unit_of_work.py index 217cab15..cfbccd01 100644 --- a/backend/app/repositories/unit_of_work.py +++ b/backend/app/repositories/unit_of_work.py @@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.db import get_session +from app.repositories.permission import PermissionRepository from app.repositories.role import RoleRepository from app.repositories.session import SessionRepository from app.repositories.user import UserRepository @@ -31,6 +32,7 @@ def __init__(self, session: AsyncSession): self.users = UserRepository() self.sessions = SessionRepository() self.roles = RoleRepository() + self.permissions = PermissionRepository() logger.debug("Unit of Work initialized") async def __aenter__(self) -> "UnitOfWork": diff --git a/backend/app/services/__pycache__/__init__.cpython-312.pyc b/backend/app/services/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 00000000..142dcf26 Binary files /dev/null and b/backend/app/services/__pycache__/__init__.cpython-312.pyc differ diff --git a/backend/app/services/__pycache__/auth_service.cpython-312.pyc b/backend/app/services/__pycache__/auth_service.cpython-312.pyc new file mode 100644 index 00000000..f22dd0b0 Binary files /dev/null and b/backend/app/services/__pycache__/auth_service.cpython-312.pyc differ diff --git a/backend/app/services/__pycache__/user_service.cpython-312.pyc b/backend/app/services/__pycache__/user_service.cpython-312.pyc new file mode 100644 index 00000000..0bb94192 Binary files /dev/null and b/backend/app/services/__pycache__/user_service.cpython-312.pyc differ diff --git a/backend/app/utils/__pycache__/__init__.cpython-312.pyc b/backend/app/utils/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 00000000..c307b876 Binary files /dev/null and b/backend/app/utils/__pycache__/__init__.cpython-312.pyc differ diff --git a/backend/app/utils/__pycache__/auth.cpython-312.pyc b/backend/app/utils/__pycache__/auth.cpython-312.pyc new file mode 100644 index 00000000..099972cd Binary files /dev/null and b/backend/app/utils/__pycache__/auth.cpython-312.pyc differ diff --git a/backend/app/utils/__pycache__/cache.cpython-312.pyc b/backend/app/utils/__pycache__/cache.cpython-312.pyc new file mode 100644 index 00000000..f203c6cc Binary files /dev/null and b/backend/app/utils/__pycache__/cache.cpython-312.pyc differ diff --git a/backend/app/utils/__pycache__/notification.cpython-312.pyc b/backend/app/utils/__pycache__/notification.cpython-312.pyc new file mode 100644 index 00000000..4d64c587 Binary files /dev/null and b/backend/app/utils/__pycache__/notification.cpython-312.pyc differ diff --git a/backend/app/utils/__pycache__/password.cpython-312.pyc b/backend/app/utils/__pycache__/password.cpython-312.pyc new file mode 100644 index 00000000..32d7668f Binary files /dev/null and b/backend/app/utils/__pycache__/password.cpython-312.pyc differ diff --git a/backend/app/utils/__pycache__/response.cpython-312.pyc b/backend/app/utils/__pycache__/response.cpython-312.pyc new file mode 100644 index 00000000..d41cc4e4 Binary files /dev/null and b/backend/app/utils/__pycache__/response.cpython-312.pyc differ diff --git a/backend/app/validators/__pycache__/__init__.cpython-312.pyc b/backend/app/validators/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 00000000..7030ac73 Binary files /dev/null and b/backend/app/validators/__pycache__/__init__.cpython-312.pyc differ diff --git a/backend/app/validators/__pycache__/user.cpython-312.pyc b/backend/app/validators/__pycache__/user.cpython-312.pyc new file mode 100644 index 00000000..9090441b Binary files /dev/null and b/backend/app/validators/__pycache__/user.cpython-312.pyc differ diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 1a8db56d..1c8ff7ea 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -9,15 +9,19 @@ import { Dialog, CloseButton, } from "@chakra-ui/react"; -import { LuLockOpen, LuLock } from "react-icons/lu"; +import { LuLockOpen, LuLock, LuShield, LuHome } from "react-icons/lu"; import { ColorModeButton } from "@/components/ui/color-mode"; import { useGetUserMeQuery } from "@/redux/services/userApi"; import AuthDialog from "./AuthDialog"; import { STORAGE_KEYS } from "@/config/env"; +import { useNavigate, useLocation } from "react-router-dom"; const Header: React.FC = () => { + const navigate = useNavigate(); + const location = useLocation(); const isAuthenticated = useAppSelector((state) => state.auth.isAuthenticated); const user = useAppSelector((state) => state.user); + const isAdmin = user.role === "ADMIN"; const { refetch: refetchUserData } = useGetUserMeQuery(undefined, { skip: !isAuthenticated, @@ -29,13 +33,58 @@ const Header: React.FC = () => { } }; + const isRolesPage = location.pathname === "/roles"; + const isHomePage = location.pathname === "/"; + return ( Fullstack FastAPI Template + + + + {isAdmin && ( + + )} + + + + + + {isAdmin && ( + + )} + + diff --git a/frontend/src/components/RoleDetail.tsx b/frontend/src/components/RoleDetail.tsx new file mode 100644 index 00000000..46690abf --- /dev/null +++ b/frontend/src/components/RoleDetail.tsx @@ -0,0 +1,291 @@ +import { + Box, + Button, + Card, + Flex, + Heading, + Text, + Input, + Textarea, + Field, + Select, + Checkbox, + Spinner, + Badge, + Divider, + IconButton, + Tooltip, +} from "@chakra-ui/react"; +import { useState, useEffect } from "react"; +import { LuPlus, LuX, LuSave, LuShield } from "react-icons/lu"; +import { + useGetRoleByIdQuery, + useUpdateRoleMutation, + useGetPermissionsQuery, + useCreateRoleMutation, +} from "@/redux/services/roleApi"; +import type { Role, RoleUpdate, RoleCreate } from "@/types/role"; +import { toaster } from "@/components/ui/toaster"; +import { TOAST_DURATION } from "@/config/env"; + +interface RoleDetailProps { + roleId: number | null; + onClose: () => void; + onSaved: () => void; +} + +const RoleDetail: React.FC = ({ roleId, onClose, onSaved }) => { + const isNewRole = roleId === null; + const { data: role, isLoading: roleLoading } = useGetRoleByIdQuery(roleId!, { + skip: isNewRole, + }); + const { data: permissions, isLoading: permissionsLoading } = useGetPermissionsQuery({ + skip: 0, + limit: 100, + }); + const [updateRole] = useUpdateRoleMutation(); + const [createRole] = useCreateRoleMutation(); + + const [formData, setFormData] = useState<{ + name: RoleCreate["name"]; + description: string; + selectedPermissions: number[]; + }>({ + name: "USER", + description: "", + selectedPermissions: [], + }); + + const [isSaving, setIsSaving] = useState(false); + + useEffect(() => { + if (role) { + setFormData({ + name: role.name, + description: role.description || "", + selectedPermissions: role.permissions.map((p) => p.id), + }); + } + }, [role]); + + const handlePermissionToggle = (permissionId: number) => { + setFormData((prev) => ({ + ...prev, + selectedPermissions: prev.selectedPermissions.includes(permissionId) + ? prev.selectedPermissions.filter((id) => id !== permissionId) + : [...prev.selectedPermissions, permissionId], + })); + }; + + const handleSave = async () => { + setIsSaving(true); + try { + const permissionNames = permissions + ?.filter((p) => formData.selectedPermissions.includes(p.id)) + .map((p) => p.name); + + if (isNewRole) { + const roleCreate: RoleCreate = { + name: formData.name, + description: formData.description || null, + permissions: permissionNames, + }; + await createRole(roleCreate).unwrap(); + toaster.create({ + title: "Role created", + description: "Role has been created successfully", + type: "success", + duration: TOAST_DURATION, + }); + } else { + const roleUpdate: RoleUpdate = { + name: formData.name, + description: formData.description || null, + permissions: permissionNames, + }; + await updateRole({ + roleId: roleId!, + roleData: roleUpdate, + }).unwrap(); + toaster.create({ + title: "Role updated", + description: "Role has been updated successfully", + type: "success", + duration: TOAST_DURATION, + }); + } + onSaved(); + } catch (err: unknown) { + const errorMessage = + (err as { data?: { message?: string } })?.data?.message || + "Failed to save role"; + toaster.create({ + title: "Error", + description: errorMessage, + type: "error", + duration: TOAST_DURATION, + }); + } finally { + setIsSaving(false); + } + }; + + const getPermissionsByResource = () => { + if (!permissions) return {}; + return permissions.reduce((acc, perm) => { + if (!acc[perm.resource]) { + acc[perm.resource] = []; + } + acc[perm.resource].push(perm); + return acc; + }, {} as Record); + }; + + if (roleLoading && !isNewRole) { + return ( + + + + ); + } + + const permissionsByResource = getPermissionsByResource(); + + return ( + + + + + + {isNewRole ? "Create New Role" : `Edit Role: ${role?.name}`} + + + + + + + + + + + + Role Information + + + + + Role Name + + setFormData((prev) => ({ + ...prev, + name: e.value as RoleCreate["name"], + })) + } + disabled={!isNewRole} + > + + + + + ADMIN + MANAGER + USER + GUEST + + + + + + Description +