Skip to content

Commit fa51681

Browse files
authored
Outer auth methods (#185)
## Изменения Созданы новые плагины для синхронизации паролей во внешних сервисах: Airflow, Coder, Postgres, Mailu ## Детали реализации - Созданы 4 класса новых плагинов аутентификации - Добавлены переменные в CI - Поправлены ошибки EmailAuthMethod, OuterPluginMeta и AuthPluginMeta, связанные с синхронизацией
1 parent 50bc106 commit fa51681

12 files changed

Lines changed: 315 additions & 29 deletions

File tree

.github/workflows/build_and_publish.yml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,14 @@ jobs:
114114
--env VK_CLIENT_ID='${{ secrets.VK_CLIENT_ID }}' \
115115
--env VK_CLIENT_ACCESS_TOKEN='${{ secrets.VK_CLIENT_ACCESS_TOKEN }}' \
116116
--env VK_CLIENT_SECRET='${{ secrets.VK_CLIENT_SECRET }}' \
117+
--env AIRFLOW_AUTH_BASE_URL='${{ vars.AIRFLOW_AUTH_BASE_URL }}' \
118+
--env AIRFLOW_AUTH_ADMIN_USERNAME='${{ secrets.AIRFLOW_AUTH_ADMIN_USERNAME }}' \
119+
--env AIRFLOW_AUTH_ADMIN_PASSWORD='${{ secrets.AIRFLOW_AUTH_ADMIN_PASSWORD }}' \
120+
--env CODER_AUTH_BASE_URL='${{ vars.CODER_AUTH_BASE_URL }}' \
121+
--env CODER_AUTH_ADMIN_TOKEN='${{ secrets.CODER_AUTH_ADMIN_TOKEN }}' \
122+
--env MAILU_AUTH_BASE_URL='${{ vars.MAILU_AUTH_BASE_URL }}' \
123+
--env MAILU_AUTH_API_KEY='${{ secrets.MAILU_AUTH_API_KEY }}' \
124+
--env POSTGRES_AUTH_DB_DSN='${{ secrets.POSTGRES_AUTH_DB_DSN }}' \
117125
--env ENCRYPTION_KEY='${{ secrets.ENCRYPTION_KEY }}' \
118126
--env KAFKA_DSN='${{ secrets.KAFKA_DSN }}' \
119127
--env KAFKA_LOGIN='${{ secrets.KAFKA_LOGIN }}' \
@@ -190,6 +198,14 @@ jobs:
190198
--env VK_CLIENT_ID='${{ secrets.VK_CLIENT_ID }}' \
191199
--env VK_CLIENT_ACCESS_TOKEN='${{ secrets.VK_CLIENT_ACCESS_TOKEN }}' \
192200
--env VK_CLIENT_SECRET='${{ secrets.VK_CLIENT_SECRET }}' \
201+
--env AIRFLOW_AUTH_BASE_URL='${{ vars.AIRFLOW_AUTH_BASE_URL }}' \
202+
--env AIRFLOW_AUTH_ADMIN_USERNAME='${{ secrets.AIRFLOW_AUTH_ADMIN_USERNAME }}' \
203+
--env AIRFLOW_AUTH_ADMIN_PASSWORD='${{ secrets.AIRFLOW_AUTH_ADMIN_PASSWORD }}' \
204+
--env CODER_AUTH_BASE_URL='${{ vars.CODER_AUTH_BASE_URL }}' \
205+
--env CODER_AUTH_ADMIN_TOKEN='${{ secrets.CODER_AUTH_ADMIN_TOKEN }}' \
206+
--env MAILU_AUTH_BASE_URL='${{ vars.MAILU_AUTH_BASE_URL }}' \
207+
--env MAILU_AUTH_API_KEY='${{ secrets.MAILU_AUTH_API_KEY }}' \
208+
--env POSTGRES_AUTH_DB_DSN='${{ secrets.POSTGRES_AUTH_DB_DSN }}' \
193209
--env ENCRYPTION_KEY='${{ secrets.ENCRYPTION_KEY }}' \
194210
--env KAFKA_DSN='${{ secrets.KAFKA_DSN }}' \
195211
--env KAFKA_LOGIN='${{ secrets.KAFKA_LOGIN }}' \

Makefile

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -35,18 +35,29 @@ create-user:
3535

3636
create-admin:
3737
source ./venv/bin/activate && python -m auth_backend user create --email test-admin@profcomff.com --password string
38-
source ./venv/bin/activate && python -m auth_backend scope create --name auth.group.create --comment auth.group.create --creator 1
39-
source ./venv/bin/activate && python -m auth_backend scope create --name auth.group.delete --comment auth.group.delete --creator 1
40-
source ./venv/bin/activate && python -m auth_backend scope create --name auth.group.read --comment auth.group.read --creator 1
41-
source ./venv/bin/activate && python -m auth_backend scope create --name auth.group.update --comment auth.group.update --creator 1
42-
source ./venv/bin/activate && python -m auth_backend scope create --name auth.scope.create --comment auth.scope.create --creator 1
43-
source ./venv/bin/activate && python -m auth_backend scope create --name auth.scope.delete --comment auth.scope.delete --creator 1
44-
source ./venv/bin/activate && python -m auth_backend scope create --name auth.scope.read --comment auth.scope.read --creator 1
45-
source ./venv/bin/activate && python -m auth_backend scope create --name auth.scope.update --comment auth.scope.update --creator 1
46-
source ./venv/bin/activate && python -m auth_backend scope create --name auth.user.delete --comment auth.user.delete --creator 1
47-
source ./venv/bin/activate && python -m auth_backend scope create --name auth.user.read --comment auth.user.read --creator 1
48-
source ./venv/bin/activate && python -m auth_backend scope create --name auth.user.update --comment auth.user.update --creator 1
49-
source ./venv/bin/activate && python -m auth_backend group create --name root --scopes 1 2 3 4 5 6 7 8 9 10 11
38+
source ./venv/bin/activate && python -m auth_backend scope create --name auth.group.create --comment auth.group.create --creator 1
39+
source ./venv/bin/activate && python -m auth_backend scope create --name auth.group.delete --comment auth.group.delete --creator 1
40+
source ./venv/bin/activate && python -m auth_backend scope create --name auth.group.read --comment auth.group.read --creator 1
41+
source ./venv/bin/activate && python -m auth_backend scope create --name auth.group.update --comment auth.group.update --creator 1
42+
source ./venv/bin/activate && python -m auth_backend scope create --name auth.scope.create --comment auth.scope.create --creator 1
43+
source ./venv/bin/activate && python -m auth_backend scope create --name auth.scope.delete --comment auth.scope.delete --creator 1
44+
source ./venv/bin/activate && python -m auth_backend scope create --name auth.scope.read --comment auth.scope.read --creator 1
45+
source ./venv/bin/activate && python -m auth_backend scope create --name auth.scope.update --comment auth.scope.update --creator 1
46+
source ./venv/bin/activate && python -m auth_backend scope create --name auth.user.delete --comment auth.user.delete --creator 1
47+
source ./venv/bin/activate && python -m auth_backend scope create --name auth.user.read --comment auth.user.read --creator 1
48+
source ./venv/bin/activate && python -m auth_backend scope create --name auth.user.update --comment auth.user.update --creator 1
49+
source ./venv/bin/activate && python -m auth_backend scope create --name auth.airflow_outer_auth.link.read --comment auth.airflow_outer_auth.link.read --creator 1
50+
source ./venv/bin/activate && python -m auth_backend scope create --name auth.airflow_outer_auth.link.create --comment auth.airflow_outer_auth.link.create --creator 1
51+
source ./venv/bin/activate && python -m auth_backend scope create --name auth.airflow_outer_auth.link.delete --comment auth.airflow_outer_auth.link.delete --creator 1
52+
source ./venv/bin/activate && python -m auth_backend scope create --name auth.coder_outer_auth.link.read --comment auth.coder_outer_auth.link.read --creator 1
53+
source ./venv/bin/activate && python -m auth_backend scope create --name auth.coder_outer_auth.link.create --comment auth.coder_outer_auth.link.create --creator 1
54+
source ./venv/bin/activate && python -m auth_backend scope create --name auth.coder_outer_auth.link.delete --comment auth.coder_outer_auth.link.delete --creator 1
55+
source ./venv/bin/activate && python -m auth_backend scope create --name auth.mailu_outer_auth.link.read --comment auth.mailu_outer_auth.link.read --creator 1
56+
source ./venv/bin/activate && python -m auth_backend scope create --name auth.mailu_outer_auth.link.create --comment auth.mailu_outer_auth.link.create --creator 1
57+
source ./venv/bin/activate && python -m auth_backend scope create --name auth.mailu_outer_auth.link.delete --comment auth.mailu_outer_auth.link.delete --creator 1
58+
source ./venv/bin/activate && python -m auth_backend scope create --name auth.postgres_outer_auth.link.read --comment auth.postgres_outer_auth.link.read --creator 1
59+
source ./venv/bin/activate && python -m auth_backend scope create --name auth.postgres_outer_auth.link.create --comment auth.postgres_outer_auth.link.create --creator 1
60+
source ./venv/bin/activate && python -m auth_backend scope create --name auth.postgres_outer_auth.link.delete --comment auth.postgres_outer_auth.link.delete --creator 1
5061
source ./venv/bin/activate && python -m auth_backend user_group create --user_id 1 --group_id 1
5162

5263
login-user:

auth_backend/auth_method/base.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,11 +107,11 @@ async def user_updated(
107107
*[m.on_user_update(new_user, old_user) for m in AuthPluginMeta.active_auth_methods()],
108108
return_exceptions=True,
109109
)
110+
exceptions = [exc for exc in exceptions if exc]
110111
if len(exceptions) > 0:
111112
logger.error("Following errors occurred during on_user_update: ")
112113
for exc in exceptions:
113-
if exc:
114-
logger.error(exc)
114+
logger.error(exc, exc_info=exc)
115115

116116
@classmethod
117117
async def on_user_update(cls, new_user: dict[str, Any], old_user: dict[str, Any] | None = None):

auth_backend/auth_method/outer.py

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from fastapi import Depends
66
from fastapi.exceptions import HTTPException
77
from fastapi_sqlalchemy import db
8-
from starlette.status import HTTP_403_FORBIDDEN, HTTP_404_NOT_FOUND, HTTP_409_CONFLICT
8+
from starlette.status import HTTP_403_FORBIDDEN, HTTP_404_NOT_FOUND, HTTP_409_CONFLICT, HTTP_424_FAILED_DEPENDENCY
99

1010
from auth_backend.auth_method.base import AuthPluginMeta
1111
from auth_backend.base import Base
@@ -34,6 +34,13 @@ def __init__(self, user_id):
3434
super().__init__(status_code=HTTP_404_NOT_FOUND, detail=f"User id={user_id} not linked")
3535

3636

37+
class ConnectionIssue(HTTPException, OuterAuthException):
38+
"""Ошибка запроса к внешнему сервису"""
39+
40+
def __init__(self, user_id):
41+
super().__init__(status_code=HTTP_424_FAILED_DEPENDENCY, detail=f"User id={user_id} not linked")
42+
43+
3744
class UserLinkingForbidden(HTTPException, OuterAuthException):
3845
"""У пользователя недостаточно прав для привязки аккаунта к внешнему сервису"""
3946

@@ -52,29 +59,38 @@ class LinkOuterAccount(Base):
5259
class OuterAuthMeta(AuthPluginMeta, metaclass=ABCMeta):
5360
"""Позволяет подключить внешний сервис для синхронизации пароля"""
5461

55-
__BASE_SCOPE: str
62+
_BASE_SCOPE: str
63+
64+
def __new__(cls, *args, **kwargs):
65+
cls._BASE_SCOPE = f"auth.{cls.get_name()}.link"
66+
logger.info(
67+
f"Init authmethod {cls.get_name()}, scopes: %s, %s, %s",
68+
cls.get_scope(),
69+
cls.post_scope(),
70+
cls.delete_scope(),
71+
)
72+
return super().__new__(cls)
5673

5774
def __init__(self):
5875
super().__init__()
5976
self.router.add_api_route("/{user_id}/link", self._get_link, methods=["GET"])
6077
self.router.add_api_route("/{user_id}/link", self._link, methods=["POST"])
61-
self.router.add_api_route("/{user_id}/unlink", self._unlink, methods=["DELETE"])
62-
self.__BASE_SCOPE = f"auth.{self.get_name()}.link"
78+
self.router.add_api_route("/{user_id}/link", self._unlink, methods=["DELETE"])
6379

6480
@classmethod
6581
def get_scope(cls):
6682
"""Права, необходимые пользователю для получения данных о внешнем аккаунте"""
67-
return cls.__BASE_SCOPE + ".read"
83+
return cls._BASE_SCOPE + ".read"
6884

6985
@classmethod
7086
def post_scope(cls):
7187
"""Права, необходимые пользователю для создания данных о внешнем аккаунте"""
72-
return cls.__BASE_SCOPE + ".create"
88+
return cls._BASE_SCOPE + ".create"
7389

7490
@classmethod
7591
def delete_scope(cls):
7692
"""Права, необходимые пользователю для удаления данных о внешнем аккаунте"""
77-
return cls.__BASE_SCOPE + ".delete"
93+
return cls._BASE_SCOPE + ".delete"
7894

7995
@classmethod
8096
@abstractmethod
@@ -103,34 +119,41 @@ async def on_user_update(cls, new_user: dict[str, Any], old_user: dict[str, Any]
103119
104120
Описания входных параметров соответствует параметрам `AuthMethodMeta.user_updated`.
105121
"""
122+
# logger.debug("on_user_update class=%s started, new_user=%s, old_user=%s", cls.get_name(), new_user, old_user)
106123
if not new_user or not old_user:
107124
# Пользователь был только что создан или удален
108125
# Тут не будет дополнительных методов
126+
logger.debug("%s not new_user or not old_user, closing", cls.get_name())
109127
return
110128

111129
user_id = new_user.get("user_id")
112130
password = new_user.get("email", {}).get("password")
113131
if not password:
114132
# В этом событии пароль не обновлялся, ничего не делаем
133+
logger.debug("%s not password, closing", cls.get_name())
115134
return
116135

117136
username = await cls.__get_username(user_id)
118137
if not username:
119138
# У пользователя нет имени во внешнем сервисе
139+
logger.debug("%s not username, closing", cls.get_name())
120140
return
121141

122142
if await cls._is_outer_user_exists(username.value):
143+
logger.debug("%s user exists, changing password", cls.get_name())
123144
await cls._update_outer_user_password(username.value, password)
124145
else:
125146
# Мы не нашли этого пользователя во внешнем сервисе
126147
# Разорвем связку и кинем лог
148+
logger.debug("%s user not exists, unlinking", cls.get_name())
127149
username.is_deleted = True
128150
logger.error(
129151
"User id=%d has username %s, which can't be found in %s",
130152
user_id,
131153
username.value,
132154
cls.get_name(),
133155
)
156+
logger.debug("on_user_update class=%s finished", cls.get_name())
134157

135158
@classmethod
136159
async def _get_link(
@@ -184,3 +207,4 @@ async def _unlink(
184207
if not username:
185208
raise UserNotLinked(user_id)
186209
username.is_deleted = True
210+
db.session.commit()

auth_backend/auth_plugins/__init__.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
from auth_backend.auth_method import AUTH_METHODS, AuthPluginMeta
22

3+
from .airflow import AirflowOuterAuth
4+
from .coder import CoderOuterAuth
35
from .email import Email
46
from .github import GithubAuth
57
from .google import GoogleAuth
68
from .keycloak import KeycloakAuth
79
from .lkmsu import LkmsuAuth
10+
from .mailu import MailuOuterAuth
811
from .mymsu import MyMsuAuth
912
from .physics import PhysicsAuth
13+
from .postgres import PostgresOuterAuth
1014
from .telegram import TelegramAuth
1115
from .vk import VkAuth
1216
from .yandex import YandexAuth
@@ -15,7 +19,9 @@
1519
__all__ = [
1620
"AUTH_METHODS",
1721
"AuthPluginMeta",
22+
# Основной провайдер
1823
"Email",
24+
# Oauth провайдеры
1925
"GoogleAuth",
2026
"PhysicsAuth",
2127
"LkmsuAuth",
@@ -25,4 +31,9 @@
2531
"VkAuth",
2632
"GithubAuth",
2733
"KeycloakAuth",
34+
# Провайдеры синхронизации паролей
35+
"PostgresOuterAuth",
36+
"CoderOuterAuth",
37+
"AirflowOuterAuth",
38+
"MailuOuterAuth",
2839
]
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import logging
2+
3+
import aiohttp
4+
from pydantic import AnyUrl
5+
6+
from auth_backend.auth_method.outer import ConnectionIssue, OuterAuthMeta
7+
from auth_backend.settings import Settings
8+
9+
10+
logger = logging.getLogger(__name__)
11+
12+
13+
class AirflowOuterAuthSettings(Settings):
14+
AIRFLOW_AUTH_BASE_URL: AnyUrl | None = None
15+
AIRFLOW_AUTH_ADMIN_USERNAME: str | None = None
16+
AIRFLOW_AUTH_ADMIN_PASSWORD: str | None = None
17+
18+
19+
class AirflowOuterAuth(OuterAuthMeta):
20+
prefix = '/airflow'
21+
settings = AirflowOuterAuthSettings()
22+
23+
@classmethod
24+
async def _is_outer_user_exists(cls, username: str) -> bool:
25+
"""Проверяет наличие пользователя в Airflow"""
26+
logger.debug("_is_outer_user_exists class=%s started", cls.get_name())
27+
async with aiohttp.ClientSession() as session:
28+
async with session.get(
29+
str(cls.settings.AIRFLOW_AUTH_BASE_URL).removesuffix('/') + '/auth/fab/v1/users/' + username,
30+
auth=aiohttp.BasicAuth(
31+
cls.settings.AIRFLOW_AUTH_ADMIN_USERNAME, cls.settings.AIRFLOW_AUTH_ADMIN_PASSWORD
32+
),
33+
) as response:
34+
if not response.ok:
35+
raise ConnectionIssue(response.text)
36+
res: dict[str] = await response.json()
37+
return res.get('username') == username
38+
39+
@classmethod
40+
async def _update_outer_user_password(cls, username: str, password: str):
41+
"""Устанавливает пользователю новый пароль в Airflow"""
42+
logger.debug("_update_outer_user_password class=%s started", cls.get_name())
43+
res = False
44+
async with aiohttp.ClientSession() as session:
45+
async with session.patch(
46+
str(cls.settings.AIRFLOW_AUTH_BASE_URL).removesuffix('/') + '/auth/fab/v1/users' + username,
47+
auth=(cls.settings.AIRFLOW_AUTH_ADMIN_USERNAME, cls.settings.AIRFLOW_AUTH_ADMIN_PASSWORD),
48+
json={'password': password},
49+
) as response:
50+
res = response.ok
51+
logger.debug("_update_outer_user_password class=%s response %s", cls.get_name(), str(response.status))
52+
if res:
53+
logger.info("User %s updated in Airflow", username)
54+
else:
55+
logger.error("User %s can't be updated in Airflow. Error: %s", username, res)

auth_backend/auth_plugins/coder.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import logging
2+
3+
import aiohttp
4+
from pydantic import AnyUrl
5+
6+
from auth_backend.auth_method.outer import ConnectionIssue, OuterAuthMeta
7+
from auth_backend.settings import Settings
8+
9+
10+
logger = logging.getLogger(__name__)
11+
12+
13+
class CoderOuterAuthSettings(Settings):
14+
CODER_AUTH_BASE_URL: AnyUrl | None = None
15+
CODER_AUTH_ADMIN_TOKEN: str | None = None
16+
17+
18+
class CoderOuterAuth(OuterAuthMeta):
19+
prefix = '/coder'
20+
settings = CoderOuterAuthSettings()
21+
22+
@classmethod
23+
async def _is_outer_user_exists(cls, username: str) -> bool:
24+
"""Проверяет наличие пользователя в Coder"""
25+
logger.debug("_is_outer_user_exists class=%s started", cls.get_name())
26+
async with aiohttp.ClientSession() as session:
27+
async with session.get(
28+
str(cls.settings.CODER_AUTH_BASE_URL).removesuffix('/') + '/api/v2/users/' + username,
29+
headers={'Coder-Session-Token': cls.settings.CODER_AUTH_ADMIN_TOKEN, 'Accept': 'application/json'},
30+
) as response:
31+
if not response.ok:
32+
raise ConnectionIssue(response.text)
33+
res: dict[str] = await response.json()
34+
return res.get('username') == username
35+
36+
@classmethod
37+
async def _update_outer_user_password(cls, username: str, password: str):
38+
"""Устанавливает пользователю новый пароль в Coder"""
39+
logger.debug("_update_outer_user_password class=%s started", cls.get_name())
40+
res = False
41+
async with aiohttp.ClientSession() as session:
42+
async with session.put(
43+
str(cls.settings.CODER_AUTH_BASE_URL).removesuffix('/') + '/api/v2/users/' + username + '/password',
44+
headers={'Coder-Session-Token': cls.settings.CODER_AUTH_ADMIN_TOKEN, 'Accept': 'application/json'},
45+
json={'password': password},
46+
) as response:
47+
res: dict[str] = response.ok
48+
logger.debug("_update_outer_user_password class=%s response %s", cls.get_name(), str(response.status))
49+
if res:
50+
logger.info("User %s updated in Coder", username)
51+
else:
52+
logger.error("User %s can't be updated in Coder. Error: %s", username, res)

0 commit comments

Comments
 (0)