feat: add update
This commit is contained in:
@@ -1,32 +1,51 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from typing import List, Literal
|
from typing import Any, List, Literal
|
||||||
import os
|
from urllib.parse import quote
|
||||||
from dotenv import load_dotenv, find_dotenv
|
|
||||||
from pydantic import Field, model_validator
|
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
||||||
from src.infrastructure.vault import create_hvac_client, read_kv2_secret
|
|
||||||
|
|
||||||
env_file = find_dotenv(".env")
|
from dotenv import find_dotenv, load_dotenv
|
||||||
|
from pydantic import Field, PrivateAttr
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
from src.infrastructure.vault.client import VaultClient
|
||||||
|
|
||||||
|
env_file = find_dotenv('.env')
|
||||||
if env_file:
|
if env_file:
|
||||||
load_dotenv(env_file)
|
load_dotenv(env_file)
|
||||||
|
|
||||||
|
|
||||||
|
def _as_int(value: object, default: int) -> int:
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
if isinstance(value, int):
|
||||||
|
return value
|
||||||
|
return int(str(value).strip())
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
VAULT_ADDR: str = Field(default="http://localhost:8200")
|
model_config = SettingsConfigDict(env_file='.env', env_file_encoding='utf-8', case_sensitive=True, extra='ignore')
|
||||||
VAULT_TOKEN: str = Field(..., description="Vault token is required")
|
|
||||||
VAULT_MOUNT_POINT: str = Field(default="secrets")
|
|
||||||
|
|
||||||
VAULT_JWT_KID_PATH: str = "jwt/kid"
|
_vault_database_secrets: dict[str, Any] = PrivateAttr(default_factory=dict)
|
||||||
VAULT_JWT_KIDS_PREFIX: str = "jwt/kids"
|
|
||||||
JWT_KEYS_REFRESH_SECONDS: int = 3600
|
|
||||||
|
|
||||||
DATABASE_HOST: str
|
VAULT_ADDR: str = 'https://corp.vault.elcsa.ru'
|
||||||
|
VAULT_ROLE_ID: str = ''
|
||||||
|
VAULT_SECRET_ID: str = ''
|
||||||
|
VAULT_NAMESPACE: str | None = None
|
||||||
|
VAULT_MOUNT_POINT: str = 'dev-secrets'
|
||||||
|
VAULT_DATABASE_SECRET_PATH: str = 'database'
|
||||||
|
VAULT_RABBIT_SECRET_PATH: str = 'rabbitmq'
|
||||||
|
VAULT_CSRF_SECRET_PATH: str = 'csrf'
|
||||||
|
VAULT_DOCS_SECRET_PATH: str = 'docs'
|
||||||
|
VAULT_JWT_KID_PATH: str = 'jwt/kid'
|
||||||
|
VAULT_JWT_KIDS_PREFIX: str = 'jwt/kids'
|
||||||
|
|
||||||
|
DATABASE_URL_DIRECT: str | None = Field(default=None, validation_alias='DATABASE_URL')
|
||||||
|
DATABASE_HOST: str = ''
|
||||||
DATABASE_PORT: int = Field(default=5432, ge=1, le=65535)
|
DATABASE_PORT: int = Field(default=5432, ge=1, le=65535)
|
||||||
DATABASE_NAME: str
|
DATABASE_NAME: str = ''
|
||||||
DATABASE_USER: str
|
DATABASE_USER: str = ''
|
||||||
DATABASE_PASSWORD: str
|
DATABASE_PASSWORD: str = ''
|
||||||
|
|
||||||
DATABASE_POOL_SIZE: int = 10
|
DATABASE_POOL_SIZE: int = 10
|
||||||
DATABASE_MAX_OVERFLOW: int = 20
|
DATABASE_MAX_OVERFLOW: int = 20
|
||||||
@@ -35,116 +54,166 @@ class Settings(BaseSettings):
|
|||||||
DATABASE_ECHO: bool = False
|
DATABASE_ECHO: bool = False
|
||||||
|
|
||||||
CSRF_SECRET_KEY: str = Field(
|
CSRF_SECRET_KEY: str = Field(
|
||||||
default="change-me-change-me-change-me-change-me",
|
default='change-me-change-me-change-me-change-me',
|
||||||
min_length=32,
|
min_length=32,
|
||||||
)
|
)
|
||||||
|
|
||||||
CSRF_COOKIE_SECURE: bool = False
|
CSRF_COOKIE_SECURE: bool = False
|
||||||
CSRF_COOKIE_HTTPONLY: bool = True
|
CSRF_COOKIE_HTTPONLY: bool = True
|
||||||
CSRF_COOKIE_SAMESITE: Literal["Lax", "Strict", "None"] = "Lax"
|
CSRF_COOKIE_SAMESITE: Literal['Lax', 'Strict', 'None'] = 'Lax'
|
||||||
CSRF_COOKIE_PATH: str = "/"
|
CSRF_COOKIE_PATH: str = '/'
|
||||||
CSRF_COOKIE_DOMAIN: str | None = None
|
CSRF_COOKIE_DOMAIN: str | None = None
|
||||||
|
|
||||||
DOCS_USERNAME: str = "admin"
|
DOCS_USERNAME: str = 'admin'
|
||||||
DOCS_PASSWORD: str = "admin"
|
DOCS_PASSWORD: str = 'admin'
|
||||||
|
|
||||||
JWT_ACCESS_TTL_SECONDS: int = 15 * 60
|
JWT_ACCESS_TTL_SECONDS: int = 15 * 60
|
||||||
JWT_REFRESH_TTL_SECONDS: int = 30 * 24 * 60 * 60
|
JWT_REFRESH_TTL_SECONDS: int = 30 * 24 * 60 * 60
|
||||||
JWT_ISSUER: str | None = None
|
JWT_ISSUER: str | None = None
|
||||||
JWT_AUDIENCE: str | None = None
|
JWT_AUDIENCE: str | None = None
|
||||||
JWT_ALGORITHM: str = "RS256"
|
JWT_ALGORITHM: str = 'RS256'
|
||||||
|
JWT_KEYS_REFRESH_SECONDS: int = 3600
|
||||||
|
|
||||||
REDIS_HOST: str = "localhost"
|
REDIS_HOST: str = 'keydb'
|
||||||
REDIS_PORT: int = 6379
|
REDIS_PORT: int = 6379
|
||||||
REDIS_PASSWORD: str | None = None
|
REDIS_PASSWORD: str | None = None
|
||||||
REDIS_DB: int = 0
|
REDIS_DB: int = 0
|
||||||
|
|
||||||
RABBIT_HOST: str = "localhost"
|
RABBIT_HOST: str = 'localhost'
|
||||||
RABBIT_PORT: int = 5672
|
RABBIT_PORT: int = 5672
|
||||||
RABBIT_USER: str = "guest"
|
RABBIT_USER: str = 'guest'
|
||||||
RABBIT_PASSWORD: str = "guest"
|
RABBIT_PASSWORD: str = 'guest'
|
||||||
RABBIT_VHOST: str = "/"
|
RABBIT_VHOST: str = '/'
|
||||||
|
|
||||||
RABBIT_PUBLISH_PERSIST: bool = True
|
RABBIT_PUBLISH_PERSIST: bool = True
|
||||||
RABBIT_CONNECT_TIMEOUT: int = 5
|
RABBIT_CONNECT_TIMEOUT: int = 5
|
||||||
RABBIT_EMAIL_CODE_QUEUE: str = "email.verification_code"
|
RABBIT_EMAIL_CODE_QUEUE: str = 'email.verification_code'
|
||||||
|
|
||||||
LOG_LEVEL: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO"
|
LOG_LEVEL: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] = 'INFO'
|
||||||
LOG_FORMAT: Literal["JSON", "TEXT"] = "TEXT"
|
LOG_FORMAT: Literal['JSON', 'TEXT'] = 'JSON'
|
||||||
|
|
||||||
model_config = SettingsConfigDict(
|
def _get_vault_secret(self, secrets: dict[str, Any], *keys: str) -> str:
|
||||||
env_file=".env",
|
for key in keys:
|
||||||
env_file_encoding="utf-8",
|
value = secrets.get(key)
|
||||||
case_sensitive=True,
|
if value is not None and str(value).strip() != '':
|
||||||
extra="ignore",
|
return str(value)
|
||||||
)
|
return ''
|
||||||
|
|
||||||
@model_validator(mode="before")
|
def model_post_init(self, __context: Any) -> None:
|
||||||
@classmethod
|
if not self.VAULT_ROLE_ID.strip() or not self.VAULT_SECRET_ID.strip():
|
||||||
def load_from_vault(cls, data: dict):
|
if not self.DATABASE_URL:
|
||||||
addr = data.get("VAULT_ADDR") or os.getenv("VAULT_ADDR") or "http://localhost:8200"
|
raise ValueError(
|
||||||
token = data.get("VAULT_TOKEN") or os.getenv("VAULT_TOKEN")
|
'Set VAULT_ROLE_ID and VAULT_SECRET_ID for Vault, or set DATABASE_URL '
|
||||||
mount = data.get("VAULT_MOUNT_POINT") or os.getenv("VAULT_MOUNT_POINT") or "secrets"
|
'(or DATABASE_HOST, DATABASE_USER, DATABASE_PASSWORD, DATABASE_NAME) in the environment',
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
if not token:
|
client = VaultClient(
|
||||||
raise RuntimeError("VAULT_TOKEN is required")
|
addr=self.VAULT_ADDR,
|
||||||
|
role_id=self.VAULT_ROLE_ID,
|
||||||
|
secret_id=self.VAULT_SECRET_ID,
|
||||||
|
namespace=self.VAULT_NAMESPACE,
|
||||||
|
mount_point=self.VAULT_MOUNT_POINT,
|
||||||
|
)
|
||||||
|
|
||||||
client = create_hvac_client(url=addr, token=token, timeout=5)
|
db = client.read_secret(self.VAULT_DATABASE_SECRET_PATH)
|
||||||
|
object.__setattr__(self, '_vault_database_secrets', db)
|
||||||
|
|
||||||
def safe_read(path: str) -> dict:
|
def kv(d: dict[str, Any], *keys: str) -> Any:
|
||||||
try:
|
for k in keys:
|
||||||
return read_kv2_secret(client=client, mount_point=mount, path=path)
|
if k in d and d[k] is not None:
|
||||||
except Exception:
|
return d[k]
|
||||||
return {}
|
return None
|
||||||
|
|
||||||
database = safe_read("database")
|
if kv(db, 'HOST', 'host') is not None:
|
||||||
rabbitmq = safe_read("rabbitmq")
|
object.__setattr__(self, 'DATABASE_HOST', str(kv(db, 'HOST', 'host')))
|
||||||
csrf = safe_read("csrf")
|
if kv(db, 'PORT', 'port') is not None:
|
||||||
|
object.__setattr__(self, 'DATABASE_PORT', _as_int(kv(db, 'PORT', 'port'), self.DATABASE_PORT))
|
||||||
|
if kv(db, 'NAME', 'name') is not None:
|
||||||
|
object.__setattr__(self, 'DATABASE_NAME', str(kv(db, 'NAME', 'name')))
|
||||||
|
if kv(db, 'USER', 'user') is not None:
|
||||||
|
object.__setattr__(self, 'DATABASE_USER', str(kv(db, 'USER', 'user')))
|
||||||
|
if kv(db, 'PASSWORD', 'password') is not None:
|
||||||
|
object.__setattr__(self, 'DATABASE_PASSWORD', str(kv(db, 'PASSWORD', 'password')))
|
||||||
|
|
||||||
if database:
|
rabbit = client.read_secret_optional(self.VAULT_RABBIT_SECRET_PATH)
|
||||||
required = ["HOST", "NAME", "USER", "PASSWORD", "PORT"]
|
if rabbit:
|
||||||
missing = [k for k in required if k not in database]
|
if kv(rabbit, 'HOST', 'host') is not None:
|
||||||
if missing:
|
object.__setattr__(self, 'RABBIT_HOST', str(kv(rabbit, 'HOST', 'host')))
|
||||||
raise RuntimeError(f"Vault database secret missing keys {missing}")
|
if kv(rabbit, 'PORT', 'port') is not None:
|
||||||
|
object.__setattr__(self, 'RABBIT_PORT', _as_int(kv(rabbit, 'PORT', 'port'), self.RABBIT_PORT))
|
||||||
|
if kv(rabbit, 'USER', 'user') is not None:
|
||||||
|
object.__setattr__(self, 'RABBIT_USER', str(kv(rabbit, 'USER', 'user')))
|
||||||
|
if kv(rabbit, 'PASSWORD', 'password') is not None:
|
||||||
|
object.__setattr__(self, 'RABBIT_PASSWORD', str(kv(rabbit, 'PASSWORD', 'password')))
|
||||||
|
if kv(rabbit, 'VHOST', 'vhost') is not None:
|
||||||
|
object.__setattr__(self, 'RABBIT_VHOST', str(kv(rabbit, 'VHOST', 'vhost')))
|
||||||
|
|
||||||
data["DATABASE_HOST"] = database["HOST"]
|
csrf = client.read_secret_optional(self.VAULT_CSRF_SECRET_PATH)
|
||||||
data["DATABASE_PORT"] = database["PORT"]
|
if csrf and kv(csrf, 'KEY', 'key') is not None:
|
||||||
data["DATABASE_NAME"] = database["NAME"]
|
key = str(kv(csrf, 'KEY', 'key'))
|
||||||
data["DATABASE_USER"] = database["USER"]
|
if len(key) >= 32:
|
||||||
data["DATABASE_PASSWORD"] = database["PASSWORD"]
|
object.__setattr__(self, 'CSRF_SECRET_KEY', key)
|
||||||
|
|
||||||
if rabbitmq:
|
docs = client.read_secret_optional(self.VAULT_DOCS_SECRET_PATH)
|
||||||
data["RABBIT_HOST"] = rabbitmq.get("HOST", data.get("RABBIT_HOST"))
|
if docs:
|
||||||
data["RABBIT_PORT"] = rabbitmq.get("PORT", data.get("RABBIT_PORT"))
|
u = docs.get('DOCS_USERNAME') or docs.get('USERNAME')
|
||||||
data["RABBIT_USER"] = rabbitmq.get("USER", data.get("RABBIT_USER"))
|
p = docs.get('DOCS_PASSWORD') or docs.get('PASSWORD')
|
||||||
data["RABBIT_PASSWORD"] = rabbitmq.get("PASSWORD", data.get("RABBIT_PASSWORD"))
|
if u is not None:
|
||||||
data["RABBIT_VHOST"] = rabbitmq.get("VHOST", data.get("RABBIT_VHOST"))
|
object.__setattr__(self, 'DOCS_USERNAME', str(u))
|
||||||
|
if p is not None:
|
||||||
|
object.__setattr__(self, 'DOCS_PASSWORD', str(p))
|
||||||
|
|
||||||
if csrf:
|
if not self.DATABASE_URL:
|
||||||
data["CSRF_SECRET_KEY"] = csrf.get("KEY", data.get("CSRF_SECRET_KEY"))
|
raise ValueError('Database URL could not be built from Vault database secret')
|
||||||
|
|
||||||
return data
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def DATABASE_URL(self) -> str:
|
def DATABASE_URL(self) -> str:
|
||||||
return (
|
direct = (self.DATABASE_URL_DIRECT or '').strip()
|
||||||
f"postgresql+asyncpg://{self.DATABASE_USER}:{self.DATABASE_PASSWORD}"
|
if direct:
|
||||||
f"@{self.DATABASE_HOST}:{self.DATABASE_PORT}/{self.DATABASE_NAME}"
|
return direct
|
||||||
|
|
||||||
|
ready_url = self._get_vault_secret(
|
||||||
|
self._vault_database_secrets,
|
||||||
|
'DATABASE_URL',
|
||||||
|
'database_url',
|
||||||
)
|
)
|
||||||
|
if ready_url:
|
||||||
|
return ready_url
|
||||||
|
|
||||||
|
host = self._get_vault_secret(self._vault_database_secrets, 'host', 'HOST')
|
||||||
|
port = self._get_vault_secret(self._vault_database_secrets, 'port', 'PORT') or str(self.DATABASE_PORT)
|
||||||
|
user = self._get_vault_secret(self._vault_database_secrets, 'user', 'USER')
|
||||||
|
password = self._get_vault_secret(self._vault_database_secrets, 'password', 'PASSWORD')
|
||||||
|
name = self._get_vault_secret(self._vault_database_secrets, 'name', 'NAME', 'database', 'DATABASE')
|
||||||
|
if not host or not user or not password or not name:
|
||||||
|
h = (self.DATABASE_HOST or '').strip()
|
||||||
|
u = (self.DATABASE_USER or '').strip()
|
||||||
|
p = (self.DATABASE_PASSWORD or '').strip()
|
||||||
|
n = (self.DATABASE_NAME or '').strip()
|
||||||
|
if h and u and p and n:
|
||||||
|
quoted_user = quote(u, safe='')
|
||||||
|
quoted_password = quote(p, safe='')
|
||||||
|
po = str(self.DATABASE_PORT)
|
||||||
|
return f'postgresql+asyncpg://{quoted_user}:{quoted_password}@{h}:{po}/{n}'
|
||||||
|
return ''
|
||||||
|
quoted_user = quote(user, safe='')
|
||||||
|
quoted_password = quote(password, safe='')
|
||||||
|
return f'postgresql+asyncpg://{quoted_user}:{quoted_password}@{host}:{port}/{name}'
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def REDIS_URL(self) -> str:
|
def REDIS_URL(self) -> str:
|
||||||
auth = f":{self.REDIS_PASSWORD}@" if self.REDIS_PASSWORD else ""
|
auth = f':{self.REDIS_PASSWORD}@' if self.REDIS_PASSWORD else ''
|
||||||
return f"redis://{auth}{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
|
return f'redis://{auth}{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}'
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def RABBIT_URL(self) -> str:
|
def RABBIT_URL(self) -> str:
|
||||||
vhost = "%2F" if self.RABBIT_VHOST == "/" else self.RABBIT_VHOST.lstrip("/")
|
vhost = '%2F' if self.RABBIT_VHOST == '/' else self.RABBIT_VHOST.lstrip('/')
|
||||||
return f"amqp://{self.RABBIT_USER}:{self.RABBIT_PASSWORD}@{self.RABBIT_HOST}:{self.RABBIT_PORT}/{vhost}"
|
return f'amqp://{self.RABBIT_USER}:{self.RABBIT_PASSWORD}@{self.RABBIT_HOST}:{self.RABBIT_PORT}/{vhost}'
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def EXCLUDED_PATHS(self) -> List[str]:
|
def EXCLUDED_PATHS(self) -> List[str]:
|
||||||
return ["/docs", "/redoc", "/openapi.json", "/ping", "/health"]
|
return ['/docs', '/redoc', '/openapi.json', '/ping', '/health']
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
@lru_cache(maxsize=1)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
from src.infrastructure.vault.utils import read_kv2_secret, create_hvac_client
|
from src.infrastructure.vault.client import VaultClient
|
||||||
|
from src.infrastructure.vault.utils import create_hvac_client, read_kv2_secret
|
||||||
from src.infrastructure.vault.keys import JwtKeyStore
|
from src.infrastructure.vault.keys import JwtKeyStore
|
||||||
from src.infrastructure.vault.scheduler import start_jwt_keys_scheduler
|
from src.infrastructure.vault.scheduler import start_jwt_keys_scheduler
|
||||||
75
src/infrastructure/vault/client.py
Normal file
75
src/infrastructure/vault/client.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import hvac
|
||||||
|
|
||||||
|
|
||||||
|
def _vault_token_renew_failed(exception: Exception) -> bool:
|
||||||
|
if isinstance(exception, (hvac.exceptions.Forbidden, hvac.exceptions.Unauthorized)):
|
||||||
|
return True
|
||||||
|
message = getattr(exception, 'message', None) or str(exception)
|
||||||
|
if isinstance(message, str):
|
||||||
|
lower = message.lower()
|
||||||
|
return 'permission denied' in lower or 'invalid token' in lower or '403' in lower
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class VaultClient:
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
addr: str,
|
||||||
|
role_id: str,
|
||||||
|
secret_id: str,
|
||||||
|
namespace: str | None,
|
||||||
|
mount_point: str,
|
||||||
|
) -> None:
|
||||||
|
self._mount_point = mount_point
|
||||||
|
self._addr = addr
|
||||||
|
self._role_id = role_id
|
||||||
|
self._secret_id = secret_id
|
||||||
|
self._namespace = namespace
|
||||||
|
self._client = hvac.Client(url=addr, namespace=namespace)
|
||||||
|
self._approle_login()
|
||||||
|
|
||||||
|
def _approle_login(self) -> None:
|
||||||
|
self._client.auth.approle.login(role_id=self._role_id, secret_id=self._secret_id)
|
||||||
|
|
||||||
|
def _renew_or_login(self) -> None:
|
||||||
|
try:
|
||||||
|
self._client.auth.token.renew_self()
|
||||||
|
except Exception:
|
||||||
|
self._approle_login()
|
||||||
|
|
||||||
|
def read_secret(self, path: str) -> dict[str, Any]:
|
||||||
|
for attempt in range(2):
|
||||||
|
try:
|
||||||
|
secret = self._client.secrets.kv.v2.read_secret_version(
|
||||||
|
path=path,
|
||||||
|
mount_point=self._mount_point,
|
||||||
|
)
|
||||||
|
return dict(secret.get('data', {}).get('data', {}))
|
||||||
|
except Exception as exc:
|
||||||
|
if attempt == 0 and _vault_token_renew_failed(exc):
|
||||||
|
self._renew_or_login()
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
|
||||||
|
def read_secret_optional(self, path: str) -> dict[str, Any]:
|
||||||
|
if not path:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return self.read_secret(path)
|
||||||
|
except (hvac.exceptions.InvalidPath, hvac.exceptions.Forbidden, hvac.exceptions.Unauthorized):
|
||||||
|
return {}
|
||||||
|
result: dict[str, Any] = {}
|
||||||
|
for path in paths:
|
||||||
|
if not path:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
result.update(self.read_secret(path))
|
||||||
|
except (hvac.exceptions.InvalidPath, hvac.exceptions.Forbidden, hvac.exceptions.Unauthorized):
|
||||||
|
continue
|
||||||
|
return result
|
||||||
@@ -3,7 +3,7 @@ import asyncio
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from src.application.domain.dto import JwtPublicKeySet, JwtPublicKey
|
from src.application.domain.dto import JwtPublicKeySet, JwtPublicKey
|
||||||
from src.application.domain.exceptions import ApplicationException
|
from src.application.domain.exceptions import ApplicationException
|
||||||
from src.infrastructure.vault import create_hvac_client, read_kv2_secret
|
from src.infrastructure.vault.client import VaultClient
|
||||||
|
|
||||||
|
|
||||||
class JwtKeyStore:
|
class JwtKeyStore:
|
||||||
@@ -19,21 +19,25 @@ class JwtKeyStore:
|
|||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
vault_addr: str,
|
vault_addr: str,
|
||||||
vault_token: str,
|
vault_role_id: str,
|
||||||
|
vault_secret_id: str,
|
||||||
|
vault_namespace: str | None,
|
||||||
mount_point: str,
|
mount_point: str,
|
||||||
kid_path: str = 'jwt/kid',
|
kid_path: str = 'jwt/kid',
|
||||||
kids_prefix: str = 'jwt/kids',
|
kids_prefix: str = 'jwt/kids',
|
||||||
timeout_seconds: int = 5,
|
|
||||||
refresh_ttl_seconds: int = 60,
|
refresh_ttl_seconds: int = 60,
|
||||||
):
|
):
|
||||||
if getattr(self, '_initialized', False):
|
if getattr(self, '_initialized', False):
|
||||||
return
|
return
|
||||||
|
|
||||||
self._vault_addr = vault_addr
|
self._vault_client = VaultClient(
|
||||||
self._vault_token = vault_token
|
addr=vault_addr,
|
||||||
self._timeout = timeout_seconds
|
role_id=vault_role_id,
|
||||||
|
secret_id=vault_secret_id,
|
||||||
|
namespace=vault_namespace,
|
||||||
|
mount_point=mount_point,
|
||||||
|
)
|
||||||
|
|
||||||
self._mount = mount_point
|
|
||||||
self._kid_path = kid_path
|
self._kid_path = kid_path
|
||||||
self._kids_prefix = kids_prefix
|
self._kids_prefix = kids_prefix
|
||||||
|
|
||||||
@@ -52,29 +56,23 @@ class JwtKeyStore:
|
|||||||
return cls._instance
|
return cls._instance
|
||||||
|
|
||||||
def _read_keyset_sync(self) -> JwtPublicKeySet:
|
def _read_keyset_sync(self) -> JwtPublicKeySet:
|
||||||
client = create_hvac_client(url=self._vault_addr, token=self._vault_token, timeout=self._timeout)
|
kids = self._vault_client.read_secret(self._kid_path)
|
||||||
|
|
||||||
kids = read_kv2_secret(client=client, mount_point=self._mount, path=self._kid_path)
|
|
||||||
active_kid = kids.get('active')
|
active_kid = kids.get('active')
|
||||||
previous_kid = kids.get('previous')
|
previous_kid = kids.get('previous')
|
||||||
|
|
||||||
if not active_kid:
|
if not active_kid:
|
||||||
raise RuntimeError('Vault jwt/kid secret missing "active"')
|
raise RuntimeError('Vault jwt/kid secret missing "active"')
|
||||||
|
|
||||||
active = self._read_public_key_sync(client, str(active_kid))
|
active = self._read_public_key_sync(str(active_kid))
|
||||||
|
|
||||||
previous = None
|
previous = None
|
||||||
if previous_kid and previous_kid != active_kid:
|
if previous_kid and previous_kid != active_kid:
|
||||||
previous = self._read_public_key_sync(client, str(previous_kid))
|
previous = self._read_public_key_sync(str(previous_kid))
|
||||||
|
|
||||||
return JwtPublicKeySet(active=active, previous=previous)
|
return JwtPublicKeySet(active=active, previous=previous)
|
||||||
|
|
||||||
def _read_public_key_sync(self, client, kid: str) -> JwtPublicKey:
|
def _read_public_key_sync(self, kid: str) -> JwtPublicKey:
|
||||||
data = read_kv2_secret(
|
data = self._vault_client.read_secret(f'{self._kids_prefix}/{kid}')
|
||||||
client=client,
|
|
||||||
mount_point=self._mount,
|
|
||||||
path=f'{self._kids_prefix}/{kid}',
|
|
||||||
)
|
|
||||||
pub = data.get('public_key')
|
pub = data.get('public_key')
|
||||||
if not pub:
|
if not pub:
|
||||||
raise RuntimeError(f'Vault jwt/kids/{kid} missing public_key')
|
raise RuntimeError(f'Vault jwt/kids/{kid} missing public_key')
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
|||||||
from starlette.middleware.cors import CORSMiddleware
|
from starlette.middleware.cors import CORSMiddleware
|
||||||
from src.application.domain.exceptions import ApplicationException
|
from src.application.domain.exceptions import ApplicationException
|
||||||
from src.infrastructure.cache import create_redis_client
|
from src.infrastructure.cache import create_redis_client
|
||||||
from src.infrastructure.config.settings import get_settings
|
|
||||||
from src.infrastructure.vault import JwtKeyStore, start_jwt_keys_scheduler
|
from src.infrastructure.vault import JwtKeyStore, start_jwt_keys_scheduler
|
||||||
from src.infrastructure.utils import generate_instance_id
|
from src.infrastructure.utils import generate_instance_id
|
||||||
from src.infrastructure.logger import logger
|
from src.infrastructure.logger import logger
|
||||||
@@ -42,9 +41,14 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
|
|
||||||
app.state.redis = create_redis_client()
|
app.state.redis = create_redis_client()
|
||||||
|
|
||||||
|
if not settings.VAULT_ROLE_ID.strip() or not settings.VAULT_SECRET_ID.strip():
|
||||||
|
raise RuntimeError('VAULT_ROLE_ID and VAULT_SECRET_ID must be set')
|
||||||
|
|
||||||
jwt_store = JwtKeyStore(
|
jwt_store = JwtKeyStore(
|
||||||
vault_addr=settings.VAULT_ADDR,
|
vault_addr=settings.VAULT_ADDR,
|
||||||
vault_token=settings.VAULT_TOKEN,
|
vault_role_id=settings.VAULT_ROLE_ID,
|
||||||
|
vault_secret_id=settings.VAULT_SECRET_ID,
|
||||||
|
vault_namespace=settings.VAULT_NAMESPACE,
|
||||||
mount_point=settings.VAULT_MOUNT_POINT,
|
mount_point=settings.VAULT_MOUNT_POINT,
|
||||||
kid_path=settings.VAULT_JWT_KID_PATH,
|
kid_path=settings.VAULT_JWT_KID_PATH,
|
||||||
kids_prefix=settings.VAULT_JWT_KIDS_PREFIX,
|
kids_prefix=settings.VAULT_JWT_KIDS_PREFIX,
|
||||||
|
|||||||
Reference in New Issue
Block a user