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")
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

А откуда там наш токен ?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

22 строка в этом файле, я сам записал токен туда : request.session["token"] = token

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

А, типа в пароль будет вставляться токен

ну да, так в целом можно и это будет работать

Но я бы с нейронкой обсудил и посмотрел в сторону кастомных шаблонов. Есть такая штука в SQLAdmin. Нейронка ее норм напишет - мб еще что-то с авторизацией там придумает

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Так я добавил же норм авторизацию нет?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Просто не понял сейчас что ты хочешь

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Temmmmmo ты хочешь просто поменять внешний вид страницы входа с помощью кастомного решения? Правильно понял?

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