API tokens authentication in SDK (APPS-14746)#191
API tokens authentication in SDK (APPS-14746)#191jszymanski-rtbh wants to merge 5 commits intomasterfrom
Conversation
|
Testy oraz więcej przykładów używania w changelog, dodam po wstępnym zaakceptowaniu architektury rozwiązania. |
- Introduced `DynamicApiTokenAuth` / `AsyncDynamicApiTokenAuth` for provider-based authentication with per-request token resolution. Tokens are persisted in storage and automatically rotated when entering the rotation window. - Implemented token endpoints clients (sync + async): `heartbeat()` and `rotate()`. - Added token managers (sync + async) providing: - rotation/expiration handling, - `configure()` and `configure_from_env()` helpers. - Added token storage backends: - `InMemoryApiTokenStorage` and `JsonFileApiTokenStorage`, - async variants for both.
562ac52 to
366d5b3
Compare
| def _atomic_write_text(self, text: str) -> None: | ||
| descriptor, temp_path = tempfile.mkstemp( | ||
| prefix=self._path.name + ".", | ||
| dir=self._path.parent, | ||
| ) | ||
| try: | ||
| with os.fdopen( | ||
| descriptor, | ||
| "w", | ||
| encoding="utf-8", | ||
| ) as file: | ||
| file.write(text) | ||
| file.flush() | ||
| os.fsync(file.fileno()) | ||
|
|
||
| try: | ||
| os.chmod(temp_path, 0o600) | ||
| except OSError: | ||
| pass | ||
|
|
||
| os.replace(temp_path, self._path) | ||
|
|
||
| finally: | ||
| try: | ||
| os.remove(temp_path) | ||
| except FileNotFoundError: | ||
| pass | ||
| except OSError: | ||
| pass |
There was a problem hiding this comment.
peku33
left a comment
There was a problem hiding this comment.
Wygląda nieźle, zostawiłem trochę komentarzy 💪
Na pewno warto unormować sposób inicjalizacji i deklaracji zmiennych w klasach:
- czasami wołasz
super().__init__(), a czasami nie. Proponuję wołać zawsze, nawet jeśli dziedziczymy tylko poobject. Dzięki temu jeśli przypadkiem pojawi nam się kiedyś klasa bazowa z jakimiś zmiennymi - nie pominiemy przypadkiem wywołania, bo linter nam to złapie - proponuję generalnie zadeklarować składowe w klasie, żeby patrząc na samą klasę było mniej więcej widać z czego się składa.
| except FileNotFoundError: | ||
| pass | ||
|
|
||
| def _atomic_write_text(self, text: str) -> None: |
There was a problem hiding this comment.
Wydaje mi się, że to nie rozwiązuje nam do końca problemu. O ile lock na managerze jest sensowny, to chyba nie chroni nas nijak przed różnymi wątkami/procesami aktualizaującymi ten sam plik. To chyba nie jest jakiś straszny problem, ale warto mieć go z tyłu głowy.
Swoją drogą - może powinniśmy w ogóle mieć tu metodę typu update_with() która jest contextmanagerem zapewniającym lock na źródle?
There was a problem hiding this comment.
Chyba kwestię tego locka na pliku możemy odpuścić. Manager uruchamia zapis do pliku (save) tylko po udanej rotacji. Nasze API przepuści tylko jeden request z rotacją.
Tutaj tym atomic_write chciałem poradzić sobie z sytuacją jakiegoś połowicznego odczytu/zapisu który skończyłby się błędem
4b52b7e to
83c6afc
Compare
| cachetools = "^7.0.3" | ||
| types-cachetools = "^6.2.0.20251022" |
There was a problem hiding this comment.
Prośba o posortowanie importów, samo types-cachetools może iść do grupki .dev
| @@ -1,3 +1,29 @@ | |||
| # v16.0.0 | |||
There was a problem hiding this comment.
Czy mamy jakiś breaking change? Jeśli tak - warto go tu opisać wraz z instrukcją migracji. Jeśli nie - może wystarczy zrobić z tego wersję 15.1.0?
There was a problem hiding this comment.
Czy potrzebujemy tego pliku? Może jednak wystarczy __main__.py w api_tokens wywołany bezpośrednio?
| try: | ||
| resp_json = response.json() | ||
| return resp_json["data"] | ||
| except (ValueError, KeyError) as exc: | ||
| raise ApiException("Invalid response format") from exc |
There was a problem hiding this comment.
To nam się w sumie powtarza pomiędzy miejscami, więc można:
- zamknąć to w
_validate_response - zamiast
_geti_postmieć `_request(method, path, params, data)
| except (ValueError, KeyError) as exc: | ||
| raise ApiException("Invalid response format") from exc | ||
|
|
||
| def _post(self, path: str, data: dict[str, Any] | None = None, params: dict[str, Any] | None = None) -> Any: |
There was a problem hiding this comment.
Na logikę params powinno iść po path.
| def _post_dict( | ||
| self, path: str, data: dict[str, Any] | None = None, params: dict[str, Any] | None = None |
There was a problem hiding this comment.
analogicznie tu i w drugim kliencie
| async def get_current_api_token(self) -> schema.ApiToken: | ||
| data = await self._get_dict("/tokens/current") | ||
| return schema.ApiToken(**data) | ||
|
|
||
| async def rotate_current_api_token(self) -> schema.RotatedApiToken: | ||
| data = await self._post_dict("/tokens/current/rotate") | ||
| return schema.RotatedApiToken(**data) |
There was a problem hiding this comment.
Te modele to nie jest bardziej jakiś "ApiTokenDetails" vs "ApiTokenRotateResult"?
| return schema.RotatedApiToken(**data) | ||
|
|
||
|
|
||
| class _HttpxApiTokenAuth(httpx.Auth): |
There was a problem hiding this comment.
Prośba o podeklarowanie składowych w klasach i wołanie super.init()
There was a problem hiding this comment.
Ponieważ dość mocno nam się rozmyła struktura w tym module, proponuję zrealizować go przy użyciu clicka (opcjonalnie typera, ale ten jest zdaje się dużo cięższy i m awięcej zależności).
Mamy obecnie coś takiego:
- handler 1
- handler 2
- handler 3
- builder
- argsy 1
- argsy 2
- argsy 3
- wołanie buildera
Helpery przerzućmy na dół.
Przekazywaniem tokena przez argsy cli / env proponuję zastąpić przekazywaniem tokena przez stdin/prompt, systemowy shell obsłuży natywnie dwa pozostałe przypadki. Proponuję spojrzeć co click/typer oferuje na takie okazje, być może po swojemu obsługuje stdin.
Zamiast zaślepiać pustym stringiem - proponuję wziąć pod uwagę stałą długość tokenu (albo przynajmniej założyć że nie może być pusty). Mamy też None.
ApiTokenAuthfor authenticating with a fixed API token.DynamicApiTokenAuth/AsyncDynamicApiTokenAuthfor provider-based authentication with per-request token resolution. Tokens are persisted in storage and automatically rotated when entering the rotation window.heartbeat()androtate().configure()andconfigure_from_env()helpers.InMemoryApiTokenStorageandJsonFileApiTokenStorage,