diff --git a/auth_backend/admin/admin.py b/auth_backend/admin/admin.py new file mode 100644 index 00000000..7d200c42 --- /dev/null +++ b/auth_backend/admin/admin.py @@ -0,0 +1,136 @@ +from sqladmin import ModelView +from sqlalchemy import func, select +from sqlalchemy.sql.expression import Select +from starlette.requests import Request + +from auth_backend.admin.filter import FilteredModelConverter +from auth_backend.models.db import Group, Scope, User +from auth_backend.routes.groups import create_group_logic, delete_group_id, patch_group_logic +from auth_backend.routes.scopes import create_scope_logic +from auth_backend.routes.user import patch_user_groups +from auth_backend.schemas.models import GroupPatch, GroupPost, ScopePost + + +class ScopeAdmin(ModelView, model=Scope): + name = "Scope" + name_plural = "Scopes" + column_list = ["id", "name", "comment"] + column_details_list = [ + "id", + "name", + "comment", + "creator_id", + "is_deleted", + ] + column_searchable_list = ["id", "name"] + column_sortable_list = ["id", "name"] + column_default_sort = [("id", False)] + form_excluded_columns = ["create_ts", "update_ts", "groups", "user_sessions", "is_deleted"] + form_converter = FilteredModelConverter + + def list_query(self, request: Request) -> Select: + return select(Scope).where(Scope.is_deleted == False) + + def count_query(self, request: Request) -> Select: + return select(func.count(Scope.id)).where(Scope.is_deleted == False) + + async def insert_model(self, request: Request, data: dict): + user_id = request.session.get("user_id") + scope_inp = ScopePost(**data) + with self.session_maker(expire_on_commit=False) as session: + obj = create_scope_logic(scope_inp, session, user_id) + return Scope.get(obj.id, session=session) + + async def update_model(self, request, pk, data): + with self.session_maker(expire_on_commit=False) as session: + scope_data = {k: v for k, v in data.items() if v is not None} + obj = Scope.update(int(pk), **scope_data, session=session) + session.commit() + return obj + + async def delete_model(self, request, pk): + with self.session_maker(expire_on_commit=False) as session: + Scope.delete(session=session, id=int(pk)) + session.commit() + + +class GroupAdmin(ModelView, model=Group): + name = "Group" + name_plural = "Groups" + column_list = ["id", "name", "scopes", "users", "parent_id"] + column_details_list = [ + "id", + "name", + "parent_id", + "scopes", + "users", + "create_ts", + "update_ts", + "is_deleted", + ] + column_searchable_list = ["name"] + column_sortable_list = ["id", "name", "parent_id", "is_deleted"] + column_default_sort = [("id", False)] + form_excluded_columns = ["child", "users", "create_ts", "update_ts", "is_deleted"] + form_converter = FilteredModelConverter + + def list_query(self, request: Request) -> Select: + return select(Group).where(Group.is_deleted == False) + + def count_query(self, request: Request) -> Select: + return select(func.count(Group.id)).where(Group.is_deleted == False) + + async def insert_model(self, request, data): + scope_ids = [int(s) for s in (data.pop("scopes", None) or [])] + parent_id = int(data["parent_id"]) if data.get("parent_id") else None + group_inp = GroupPost(name=data["name"], parent_id=parent_id, scopes=scope_ids) + with self.session_maker(expire_on_commit=False) as session: + result = create_group_logic(group_inp, session) + return Group.get(result["id"], session=session) + + async def update_model(self, request, pk, data): + scope_ids = [int(s) for s in (data.pop("scopes", None) or [])] + parent_id = int(data["parent_id"]) if data.get("parent_id") else None + group_inp = GroupPatch( + name=data.get("name"), + parent_id=parent_id, + scopes=scope_ids, + ) + with self.session_maker(expire_on_commit=False) as session: + return patch_group_logic(int(pk), group_inp, session) + + async def delete_model(self, request, pk): + with self.session_maker(expire_on_commit=False) as session: + delete_group_id(int(pk), session) + + +class UserAdmin(ModelView, model=User): + name = "User" + name_plural = "Users" + column_list = ["id", "scopes", "groups"] + column_details_list = ["id", "groups", "scopes", "is_deleted"] + column_searchable_list = ["id"] + column_sortable_list = ["id", "is_deleted"] + form_include_pk = False + form_columns = ["groups"] + can_create = False + can_delete = False + column_formatters = { + "scopes": lambda m, a: ", ".join(s.name for s in m.scopes), + } + column_formatters_detail = { + "scopes": lambda m, a: ", ".join(s.name for s in (m.scopes or set())), + } + form_converter = FilteredModelConverter + + def list_query(self, request: Request) -> Select: + return select(User).where(User.is_deleted == False) + + def count_query(self, request: Request) -> Select: + return select(func.count(User.id)).where(User.is_deleted == False) + + async def update_model(self, request, pk, data): + group_ids = [int(group) for group in (data.pop("groups") or [])] + with self.session_maker(expire_on_commit=False) as session: + patch_user_groups(int(pk), group_ids, session) + return User.get(int(pk), session=session) diff --git a/auth_backend/admin/auth.py b/auth_backend/admin/auth.py new file mode 100644 index 00000000..b5bb6ee9 --- /dev/null +++ b/auth_backend/admin/auth.py @@ -0,0 +1,51 @@ +from auth_lib.methods import AuthLib +from fastapi import Request +from sqladmin.authentication import AuthenticationBackend + +from auth_backend.settings import get_settings +from typing import Any + + +settings = get_settings() + +class AdminAuth(AuthenticationBackend): + + async def login(self, request: Request) -> bool: + form = await request.form() + username = form.get("username") + token = form.get("password") + if username != settings.ADMIN_LOGIN: + return False + valid = await self._is_valid_token(token) + if valid is None: + return False + request.session["token"] = token + request.session["user_id"] = valid.get("id") + return True + + async def authenticate(self, request: Request) -> bool: + token = request.session.get("token") + if not token: + return False + userdata = await self._is_valid_token(token) + return userdata is not None + + async def logout(self, request: Request) -> bool: + request.session.clear() + return True + + @staticmethod + async def _is_valid_token(token: str) -> dict[str, Any] | None: + try: + result = AuthLib(auth_url=settings.AUTH_URL).check_token(token) + if not result: + return None + session_scopes = { + scope["name"].lower() for scope in result.get("session_scopes", []) + } + required_scopes = "auth.sqladmin.admin" + if required_scopes not in session_scopes: + return None + return result + except Exception: + return None \ No newline at end of file diff --git a/auth_backend/admin/filter.py b/auth_backend/admin/filter.py new file mode 100644 index 00000000..f8f8dbe4 --- /dev/null +++ b/auth_backend/admin/filter.py @@ -0,0 +1,24 @@ +import anyio +from sqladmin.forms import ModelConverter +from sqladmin.helpers import is_async_session_maker +from sqlalchemy import select + + +class FilteredModelConverter(ModelConverter): + """ + A custom ModelConverter that filters out deleted objects from select options in form with create/update. + """ + + async def _prepare_select_options(self, prop, session_maker): + target_model = prop.mapper.class_ + stmt = select(target_model) + if hasattr(target_model, "is_deleted"): + stmt = stmt.where(target_model.is_deleted == False) + if is_async_session_maker(session_maker): + async with session_maker() as session: + objects = await session.execute(stmt) + return [(str(self._get_identifier_value(obj)), str(obj)) for obj in objects.scalars().unique().all()] + else: + with session_maker() as session: + objects = await anyio.to_thread.run_sync(session.execute, stmt) + return [(str(self._get_identifier_value(obj)), str(obj)) for obj in objects.scalars().unique().all()] diff --git a/auth_backend/models/db.py b/auth_backend/models/db.py index 3cab9a2e..3727d0cc 100644 --- a/auth_backend/models/db.py +++ b/auth_backend/models/db.py @@ -43,6 +43,9 @@ class User(BaseDbModel): secondaryjoin="and_(Group.id==UserGroup.group_id, not_(Group.is_deleted))", ) + def __str__(self): + return str(self.id) + @classmethod def create(cls, *, session: Session, **kwargs) -> User: user: User = super().create(session=session, **kwargs) @@ -114,6 +117,9 @@ class Group(BaseDbModel): secondaryjoin="and_(Scope.id==GroupScope.scope_id, not_(Scope.is_deleted))", ) + def __str__(self): + return self.name + @hybrid_property def indirect_scopes(self) -> set[Scope]: _indirect_scopes = set() @@ -205,6 +211,9 @@ class Scope(BaseDbModel): secondaryjoin="(UserSession.id==UserSessionScope.user_session_id)", ) + def __str__(self): + return self.name + @classmethod def create(cls, *, session: Session, **kwargs) -> Scope: scope: Scope = super().create(session=session, **kwargs) diff --git a/auth_backend/routes/base.py b/auth_backend/routes/base.py index efff10ac..2f4793f4 100644 --- a/auth_backend/routes/base.py +++ b/auth_backend/routes/base.py @@ -2,9 +2,13 @@ from fastapi import FastAPI from fastapi_sqlalchemy import DBSessionMiddleware +from sqladmin import Admin +from sqlalchemy import create_engine from starlette.middleware.cors import CORSMiddleware from auth_backend import __version__ +from auth_backend.admin.admin import GroupAdmin, ScopeAdmin, UserAdmin +from auth_backend.admin.auth import AdminAuth from auth_backend.auth_method import AuthPluginMeta from auth_backend.kafka.kafka import get_kafka_producer from auth_backend.settings import get_settings @@ -23,6 +27,9 @@ async def lifespan(app: FastAPI): settings = get_settings() + +engine = create_engine(str(settings.DB_DSN), pool_pre_ping=True) + app = FastAPI( title='Сервис аутентификации и авторизации', description=( @@ -37,6 +44,10 @@ async def lifespan(app: FastAPI): lifespan=lifespan, ) +admin = Admin( + app, engine=engine, title='Auth admin panel', authentication_backend=AdminAuth(secret_key=settings.ADMIN_SECRET_KEY) +) + app.add_middleware( DBSessionMiddleware, db_url=str(settings.DB_DSN), @@ -51,6 +62,10 @@ async def lifespan(app: FastAPI): allow_headers=settings.CORS_ALLOW_HEADERS, ) +admin.add_view(GroupAdmin) +admin.add_view(ScopeAdmin) +admin.add_view(UserAdmin) + app.include_router(groups_router) app.include_router(scopes_router) app.include_router(user_router) diff --git a/auth_backend/routes/groups.py b/auth_backend/routes/groups.py index a9f9f429..5ff507ad 100644 --- a/auth_backend/routes/groups.py +++ b/auth_backend/routes/groups.py @@ -37,17 +37,10 @@ async def get_group( return GroupGet(**result).model_dump(exclude_unset=True) -@groups.post("", response_model=Group) -async def create_group( - group_inp: GroupPost, - _: UserSession = Depends(UnionAuth(scopes=["auth.group.create"], allow_none=False, auto_error=True)), -) -> dict[str, str | int]: - """ - Scopes: `["auth.group.create"]` - """ - if group_inp.parent_id and not db.session.query(DbGroup).get(group_inp.parent_id): +def create_group_logic(group_inp: GroupPost, session) -> dict: + if group_inp.parent_id and not session.query(DbGroup).get(group_inp.parent_id): raise ObjectNotFound(Group, group_inp.parent_id) - if DbGroup.query(session=db.session).filter(DbGroup.name == group_inp.name).one_or_none(): + if DbGroup.query(session=session).filter(DbGroup.name == group_inp.name).one_or_none(): raise HTTPException( status_code=409, detail=StatusResponseModel( @@ -57,50 +50,77 @@ async def create_group( scopes = set() if group_inp.scopes: for _scope_id in group_inp.scopes: - scopes.add(Scope.get(session=db.session, id=_scope_id)) + scopes.add(Scope.get(session=session, id=_scope_id)) result = {} - group = DbGroup.create(session=db.session, name=group_inp.name, parent_id=group_inp.parent_id) - db.session.flush() + group = DbGroup.create(session=session, name=group_inp.name, parent_id=group_inp.parent_id) + session.flush() result = result | {"name": group.name, "id": group.id, "parent_id": group.parent_id} for scope in scopes: - GroupScope.create(session=db.session, group_id=group.id, scope_id=scope.id) - db.session.commit() - return Group(**result).model_dump(exclude_unset=True) + GroupScope.create(session=session, group_id=group.id, scope_id=scope.id) + session.commit() + return result -@groups.patch("/{id}", response_model=Group) -async def patch_group( - id: int, - group_inp: GroupPatch, - _: UserSession = Depends(UnionAuth(scopes=["auth.group.update"], allow_none=False, auto_error=True)), -) -> Group: +@groups.post("", response_model=Group) +async def create_group( + group_inp: GroupPost, + _: UserSession = Depends(UnionAuth(scopes=["auth.group.create"], allow_none=False, auto_error=True)), +) -> dict[str, str | int]: """ - Scopes: `["auth.group.update"]` + Scopes: `["auth.group.create"]` """ + result = create_group_logic(group_inp, db.session) + return Group(**result).model_dump(exclude_unset=True) + + +def patch_group_logic(id: int, group_inp: GroupPatch, session) -> DbGroup: if ( - exists_check := DbGroup.query(session=db.session) + exists_check := DbGroup.query(session=session) .filter(DbGroup.name == group_inp.name, DbGroup.id != id) .one_or_none() ): raise AlreadyExists(Group, exists_check.id) - group = DbGroup.get(id, session=db.session) + group = DbGroup.get(id, session=session) if group_inp.parent_id in (row.id for row in group.child): raise HTTPException( status_code=400, detail=StatusResponseModel(status="Error", message="Cycle detected", ru="Найден цикл").model_dump(), ) result = Group.model_validate( - DbGroup.update(id, session=db.session, **group_inp.model_dump(exclude_unset=True, exclude={"scopes"})) + DbGroup.update(id, session=session, **group_inp.model_dump(exclude_unset=True, exclude={"scopes"})) ).model_dump(exclude_unset=True) scopes = set() if group_inp.scopes is not None: for _scope_id in group_inp.scopes: - scopes.add(Scope.get(session=db.session, id=_scope_id)) + scopes.add(Scope.get(session=session, id=_scope_id)) group.scopes = scopes - db.session.commit() + session.commit() + return group + + +@groups.patch("/{id}", response_model=Group) +async def patch_group( + id: int, + group_inp: GroupPatch, + _: UserSession = Depends(UnionAuth(scopes=["auth.group.update"], allow_none=False, auto_error=True)), +) -> Group: + """ + Scopes: `["auth.group.update"]` + """ + group = patch_group_logic(id, group_inp, db.session) return Group.model_validate(group) +def delete_group_id(id: int, session) -> None: + group: DbGroup = DbGroup.get(id, session=session) + if child := group.child: + for children in child: + children.parent_id = group.parent_id + session.flush() + DbGroup.delete(id, session=session) + session.commit() + + @groups.delete("/{id}", response_model=None) async def delete_group( id: int, _: UserSession = Depends(UnionAuth(scopes=["auth.scope.delete"], allow_none=False, auto_error=True)) @@ -108,13 +128,7 @@ async def delete_group( """ Scopes: `["auth.scope.delete"]` """ - group: DbGroup = DbGroup.get(id, session=db.session) - if child := group.child: - for children in child: - children.parent_id = group.parent_id - db.session.flush() - DbGroup.delete(id, session=db.session) - db.session.commit() + delete_group_id(id, db.session) return None diff --git a/auth_backend/routes/scopes.py b/auth_backend/routes/scopes.py index 4091290f..5c3bf409 100644 --- a/auth_backend/routes/scopes.py +++ b/auth_backend/routes/scopes.py @@ -11,6 +11,18 @@ scopes = APIRouter(prefix="/scope", tags=["Scopes"]) +def create_scope_logic(scope: ScopePost, session, creator_id) -> dict: + if Scope.query(session=session).filter(func.lower(Scope.name) == scope.name.lower()).all(): + raise HTTPException( + status_code=409, + detail=StatusResponseModel(status="Error", message="Already exists", ru="Уже существует").model_dump(), + ) + scope.name = scope.name.lower() + retval = ScopeGet.model_validate( + Scope.create(**scope.model_dump(), creator_id=creator_id, session=session) + ) + session.commit() + return retval @scopes.post("", response_model=ScopeGet) async def create_scope( @@ -20,17 +32,8 @@ async def create_scope( """ Scopes: `["auth.scope.create"]` """ - if Scope.query(session=db.session).filter(func.lower(Scope.name) == scope.name.lower()).all(): - raise HTTPException( - status_code=409, - detail=StatusResponseModel(status="Error", message="Already exists", ru="Уже существует").model_dump(), - ) - scope.name = scope.name.lower() - retval = ScopeGet.model_validate( - Scope.create(**scope.model_dump(), creator_id=user_session.user_id, session=db.session) - ) - db.session.commit() - return retval + retval = create_scope_logic(scope, db.session, user_session.user_id) + return ScopeGet.model_validate(retval) @scopes.get("/{id}", response_model=ScopeGet) diff --git a/auth_backend/routes/user.py b/auth_backend/routes/user.py index 02c52940..1e1ae254 100644 --- a/auth_backend/routes/user.py +++ b/auth_backend/routes/user.py @@ -110,34 +110,36 @@ async def get_users( return UsersGet(**result).model_dump(exclude_unset=True) -@user.patch("/{user_id}", response_model=UserModel) -async def patch_user( - user_id: int, - user_inp: UserPatch, - _: UserSession = Depends(UnionAuth(scopes=["auth.user.update"], allow_none=False, auto_error=True)), -) -> UserInfo: - """ - Scopes: `["auth.user.update"]` - """ - user = User.get(user_id, session=db.session) +def patch_user_groups(user_id: int, group_ids: list[int], session) -> None: + user = User.get(user_id, session=session) groups = set() - for group_id in user_inp.groups: - group = Group.get(group_id, session=db.session) + for group_id in group_ids: + group = Group.get(group_id, session=session) groups.add(group) user_groups = set(user.groups) new_groups = groups - user_groups to_delete_groups = user_groups - groups for group in new_groups: - UserGroup.create(session=db.session, user_id=user_id, group_id=group.id) + UserGroup.create(session=session, user_id=user_id, group_id=group.id) for group in to_delete_groups: user_group: UserGroup = ( - UserGroup.query(session=db.session) - .filter(UserGroup.user_id == user_id, UserGroup.group_id == group.id) - .one() + UserGroup.query(session=session).filter(UserGroup.user_id == user_id, UserGroup.group_id == group.id).one() ) - UserGroup.delete(user_group.id, session=db.session) - db.session.commit() - return UserModel.model_validate(user) + UserGroup.delete(user_group.id, session=session) + session.commit() + + +@user.patch("/{user_id}", response_model=UserModel) +async def patch_user( + user_id: int, + user_inp: UserPatch, + _: UserSession = Depends(UnionAuth(scopes=["auth.user.update"], allow_none=False, auto_error=True)), +) -> UserInfo: + """ + Scopes: `["auth.user.update"]` + """ + patch_user_groups(user_id, user_inp.groups, db.session) + return UserModel.model_validate(User.get(user_id, session=db.session)) @user.delete("/{user_id}", response_model=None) diff --git a/auth_backend/settings.py b/auth_backend/settings.py index 04e6d619..847d0948 100644 --- a/auth_backend/settings.py +++ b/auth_backend/settings.py @@ -20,6 +20,10 @@ class Settings(BaseSettings): KAFKA_TIMEOUT: int = 2 KAFKA_LOGIN: str | None = None KAFKA_PASSWORD: str | None = None + ADMIN_SECRET_KEY: str = "default" + ADMIN_LOGIN: str = "admin" + ADMIN_PASSWORD: str = "admin" + AUTH_URL: str = "https://api.test.profcomff.com/auth/" ROOT_PATH: str = '/' + os.getenv('APP_NAME', '') diff --git a/requirements.txt b/requirements.txt index 5e444fe2..83d1d2c8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,6 +16,8 @@ confluent-kafka event-schema-profcomff aiocache python-multipart +sqladmin[full] +auth-lib-profcomff[fastapi] # Google Auth Method google-api-python-client