Skip to content
136 changes: 136 additions & 0 deletions auth_backend/admin/admin.py
Original file line number Diff line number Diff line change
@@ -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)
51 changes: 51 additions & 0 deletions auth_backend/admin/auth.py
Original file line number Diff line number Diff line change
@@ -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
24 changes: 24 additions & 0 deletions auth_backend/admin/filter.py
Original file line number Diff line number Diff line change
@@ -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):
Comment thread
petrCher marked this conversation as resolved.
"""
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)
Comment thread
petrCher marked this conversation as resolved.
return [(str(self._get_identifier_value(obj)), str(obj)) for obj in objects.scalars().unique().all()]
9 changes: 9 additions & 0 deletions auth_backend/models/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down
15 changes: 15 additions & 0 deletions auth_backend/routes/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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=(
Expand All @@ -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),
Expand All @@ -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)
Expand Down
Loading
Loading