This commit is contained in:
JiriUhlir
2026-06-18 11:58:23 +02:00
parent 284e013753
commit 6934f22253
18 changed files with 1367 additions and 17 deletions
+108
View File
@@ -0,0 +1,108 @@
"""Per-request credential extraction from X- headers (FastAPI dependencies).
The service is a STATELESS proxy: it stores no secrets. Every credential is
supplied per request as an X- header and used only to talk to the upstream API
(see AGENTS.md "Secrets v parametrech"). Declaring the headers as FastAPI
``Header`` parameters makes them appear per-operation in Swagger, including the
"Try it out" form.
Google Analytics (chosen model: token has precedence over service account):
* ``X-GA-Access-Token`` - a ready OAuth2 access token; used directly as Bearer.
* ``X-GA-Credentials`` - base64-encoded service-account JSON key; the proxy
mints a short-lived access token from it.
* ``X-GA-Quota-Project`` - optional billing/quota project id.
At least one of token / credentials must be present.
Sklik:
* ``X-Sklik-Token`` - the Sklik API token from account settings. The proxy
calls ``client.loginByToken`` to obtain a session.
"""
from __future__ import annotations
import base64
import binascii
import json
from dataclasses import dataclass
from fastapi import Header
from .errors import MissingCredentialsError
# --- Google Analytics ---------------------------------------------------------
@dataclass
class GaCredentials:
access_token: str | None
service_account_info: dict | None
quota_project: str | None
def get_ga_credentials(
x_ga_access_token: str | None = Header(
default=None,
alias="X-GA-Access-Token",
description="Ready OAuth2 access token used directly as a Bearer token. "
"Takes precedence over X-GA-Credentials.",
),
x_ga_credentials: str | None = Header(
default=None,
alias="X-GA-Credentials",
description="Base64-encoded Google service-account JSON key. The proxy "
"mints a short-lived access token from it (scope analytics.readonly). "
"Used only if X-GA-Access-Token is absent.",
),
x_ga_quota_project: str | None = Header(
default=None,
alias="X-GA-Quota-Project",
description="Optional Google Cloud project id used for quota/billing "
"(sets the x-goog-user-project header upstream).",
),
) -> GaCredentials:
"""Resolve GA credentials from headers. Token wins over service account."""
access_token = (x_ga_access_token or "").strip() or None
service_account_info: dict | None = None
raw = (x_ga_credentials or "").strip()
if raw:
try:
decoded = base64.b64decode(raw, validate=True)
except (binascii.Error, ValueError) as exc:
raise MissingCredentialsError(
"X-GA-Credentials is not valid base64."
) from exc
try:
service_account_info = json.loads(decoded)
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
raise MissingCredentialsError(
"X-GA-Credentials does not decode to valid JSON."
) from exc
if not isinstance(service_account_info, dict):
raise MissingCredentialsError(
"X-GA-Credentials JSON must be a service-account object."
)
if not access_token and service_account_info is None:
raise MissingCredentialsError(
"Provide either X-GA-Access-Token or X-GA-Credentials."
)
return GaCredentials(
access_token=access_token,
service_account_info=service_account_info,
quota_project=(x_ga_quota_project or "").strip() or None,
)
# --- Sklik --------------------------------------------------------------------
def get_sklik_token(
x_sklik_token: str | None = Header(
default=None,
alias="X-Sklik-Token",
description="Sklik API token from Sklik account settings. The proxy uses "
"it to obtain a session via client.loginByToken.",
),
) -> str:
token = (x_sklik_token or "").strip()
if not token:
raise MissingCredentialsError("X-Sklik-Token header is required.")
return token