Add src/selectel_ml_tui/auth.py
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
"""IAM-авторизация Selectel.
|
||||
|
||||
Получение и кэширование IAM-токена проекта через
|
||||
POST {identity_url}/auth/tokens (сервисный пользователь, scope project).
|
||||
Время жизни токена — 24 часа; IamTokenManager автоматически обновляет токен
|
||||
при приближении к истечению (запас 5 минут).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
from typing import cast
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from selectel_ml_tui.config import Credentials, Settings
|
||||
|
||||
|
||||
class IamToken(BaseModel):
|
||||
"""IAM-токен с временем жизни."""
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
value: str = Field(alias="X-Subject-Token")
|
||||
issued_at: dt.datetime = Field(
|
||||
default_factory=lambda: dt.datetime.now(dt.timezone.utc)
|
||||
)
|
||||
expires_at: dt.datetime = Field(alias="expires_at")
|
||||
|
||||
|
||||
class IamAuthError(RuntimeError):
|
||||
"""Ошибка получения IAM-токена."""
|
||||
|
||||
|
||||
def _parse_expires_at(raw: str) -> dt.datetime:
|
||||
"""Распарсить expires_at из ответа IAM (ISO8601, возможно с 'Z')."""
|
||||
text = raw.replace("Z", "+00:00")
|
||||
return dt.datetime.fromisoformat(text)
|
||||
|
||||
|
||||
class IamClient:
|
||||
"""Клиент получения IAM-токена проекта."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
settings: Settings,
|
||||
credentials: Credentials,
|
||||
transport: httpx.AsyncBaseTransport | None = None,
|
||||
) -> None:
|
||||
self._settings = settings
|
||||
self._credentials = credentials
|
||||
self._transport = transport
|
||||
|
||||
async def get_token(self) -> IamToken:
|
||||
"""Получить IAM-токен проекта для аккаунта/домена."""
|
||||
if not self._credentials.service_user or not self._credentials.password:
|
||||
raise IamAuthError(
|
||||
"Не заданы логин/пароль сервисного пользователя. "
|
||||
"Выполните: selectel-ml-tui config"
|
||||
)
|
||||
body = {
|
||||
"auth": {
|
||||
"identity": {
|
||||
"methods": ["password"],
|
||||
"password": {
|
||||
"user": {
|
||||
"name": self._credentials.service_user,
|
||||
"domain": {"name": self._credentials.domain_id},
|
||||
"password": self._credentials.password,
|
||||
}
|
||||
},
|
||||
},
|
||||
"scope": {"project": {"id": self._credentials.project_id}},
|
||||
}
|
||||
}
|
||||
url = f"{self._settings.identity_url}/auth/tokens"
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=30, transport=self._transport
|
||||
) as client:
|
||||
response = await client.post(url, json=body)
|
||||
except httpx.HTTPError as exc:
|
||||
raise IamAuthError(f"Ошибка сети при обращении к {url}: {exc}") from exc
|
||||
if response.status_code not in (200, 201):
|
||||
raise IamAuthError(
|
||||
f"IAM: HTTP {response.status_code}: {response.text[:300]}"
|
||||
)
|
||||
token = cast(str | None, response.headers.get("X-Subject-Token"))
|
||||
if not token:
|
||||
raise IamAuthError("IAM: отсутствует заголовок X-Subject-Token в ответе")
|
||||
data = response.json()
|
||||
expires_raw = (
|
||||
data.get("token", {}).get("expires_at") if isinstance(data, dict) else None
|
||||
)
|
||||
if not isinstance(expires_raw, str):
|
||||
raise IamAuthError("IAM: отсутствует поле token.expires_at в ответе")
|
||||
return IamToken.model_validate(
|
||||
{
|
||||
"X-Subject-Token": token,
|
||||
"expires_at": _parse_expires_at(expires_raw),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class IamTokenManager:
|
||||
"""Кэширует IAM-токен и обновляет его по истечении TTL.
|
||||
|
||||
Позволяет разделить получение токена и его использование клиентами.
|
||||
"""
|
||||
|
||||
SAFETY_MARGIN = dt.timedelta(minutes=5)
|
||||
|
||||
def __init__(self, client: IamClient) -> None:
|
||||
self._client = client
|
||||
self._token: str | None = None
|
||||
self._expires_at: dt.datetime | None = None
|
||||
|
||||
def invalidate(self) -> None:
|
||||
"""Сбросить кэш (например, после HTTP 401)."""
|
||||
self._token = None
|
||||
self._expires_at = None
|
||||
|
||||
def is_valid(self) -> bool:
|
||||
if self._token is None or self._expires_at is None:
|
||||
return False
|
||||
now = dt.datetime.now(dt.timezone.utc)
|
||||
return self._expires_at > now + self.SAFETY_MARGIN
|
||||
|
||||
async def get_token(self) -> str:
|
||||
"""Вернуть валидный токен, при необходимости обновив его."""
|
||||
if self.is_valid():
|
||||
return cast(str, self._token)
|
||||
iam_token = await self._client.get_token()
|
||||
self._token = iam_token.value
|
||||
self._expires_at = iam_token.expires_at
|
||||
return iam_token.value
|
||||
Reference in New Issue
Block a user