vignette/apps/api/app/services/provider_credentials.py
Yun Chan 4b45d33142
Some checks failed
API contract / OpenAPI type drift (push) Failing after 12m57s
관리자 AI 제공자 연결 기능 추가 (claude·codex·agy·openrouter 토큰 붙여넣기 연결)
관리자 /admin/ai 화면에서 provider 토큰(OAuth 액세스 토큰 또는 API 키)을
발급받아 붙여넣으면 DB에 암호화 저장되고, shared-secret으로 보호되는
게이트웨이 내부 엔드포인트로 push되어 실행 중인 엔진 컨테이너에 즉시
적용된다. NAS처럼 게이트웨이가 컨테이너로 도는 환경에서 CLI·로컬 PC
의존 없이 claude_api·openai·openrouter를 연결할 수 있다.

- 게이트웨이: openrouter 어댑터 신설(chat/completions), OAuth 토큰이면
  Anthropic Bearer 헤더, /internal/provider-credentials GET/POST와
  boot_id 기반 재동기화
- API: app.admin_provider_credential 테이블(idempotent DDL), stdlib
  HMAC-CTR+MAC 암호화 서비스(PROVIDER_CREDENTIAL_SECRET, 폴백
  SESSION_SECRET), /admin/providers CRUD·검증 라우트
- 웹: 제공자 연결 패널(저장·검증·해제, 토큰 힌트만 표시), 엔진 선택에
  OpenRouter 추가, api.gen.ts 재생성
- compose: api 서비스에 PROVIDER_CREDENTIAL_SECRET 전달(로컬·NAS)
2026-09-11 14:06:15 +09:00

474 lines
18 KiB
Python

"""관리자 연결 AI provider 자격증명 저장소.
관리자가 연결 UI에서 붙여넣은 provider 토큰(OAuth 액세스 토큰 또는 API 키)을
DB(``app.admin_provider_credential``)에 암호화 저장하고, 엔진 게이트웨이 프로세스에
shared-secret으로 보호되는 내부 엔드포인트로 push해 ``os.environ``에 주입한다.
- 암호화는 표준 라이브러리만으로 구성한다(HMAC-SHA256 CTR 스트림 + encrypt-then-MAC).
마스터 키는 ``PROVIDER_CREDENTIAL_SECRET``, 비어 있으면 ``SESSION_SECRET``에서 파생한다.
- 토큰 원문은 로그·응답에 절대 내보내지 않는다. 마지막 4글자 힌트만 남긴다.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import os
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Literal
import httpx
from ..config import settings
from ..db import get_pool
from ..engine_client import EngineError, engine_client
PROVIDER_CODES = ("claude", "codex", "agy", "openrouter")
ProviderCode = Literal["claude", "codex", "agy", "openrouter"]
_TOKEN_ENVELOPE_PREFIX = "v1."
_KEY_SIZE = 32
_NONCE_SIZE = 16
_MAC_SIZE = 32
_HKDF_INFO_ENC = b"vignette/provider-credential/enc"
_HKDF_INFO_MAC = b"vignette/provider-credential/mac"
@dataclass(frozen=True, slots=True)
class ProviderMeta:
code: ProviderCode
label: str
description: str
token_page_url: str
token_page_label: str
auth_kinds: tuple[Literal["api_key", "oauth_token"], ...]
guide: str
PROVIDER_CATALOG: tuple[ProviderMeta, ...] = (
ProviderMeta(
code="claude",
label="Claude (Anthropic)",
description=(
"claude_api 엔진과 Claude CLI에 쓰는 Anthropic 자격증명입니다. "
"OAuth 액세스 토큰은 Authorization Bearer로, API 키는 x-api-key로 전송됩니다."
),
token_page_url="https://console.anthropic.com/settings/keys",
token_page_label="Anthropic 콘솔 · API 키",
auth_kinds=("api_key", "oauth_token"),
guide=(
"콘솔에서 로그인 뒤 API 키를 발급해 붙여넣거나, "
"Claude CLI OAuth 토큰(claude setup-token 출력값)을 붙여넣으세요."
),
),
ProviderMeta(
code="codex",
label="Codex / OpenAI",
description=(
"codex_cli 엔진과 OpenAI 호환 엔진에 쓰는 API 토큰입니다. "
"게이트웨이가 OPENAI_API_KEY로 주입합니다."
),
token_page_url="https://platform.openai.com/api-keys",
token_page_label="OpenAI 플랫폼 · API 키",
auth_kinds=("api_key",),
guide=(
"플랫폼에 로그인해 API 키를 발급한 뒤 붙여넣으세요. "
"ChatGPT OAuth 토큰은 Codex CLI 로그인으로도 대체할 수 있습니다."
),
),
ProviderMeta(
code="agy",
label="Agy CLI",
description=(
"agy_cli 엔진이 사용하는 CLI 계정 토큰입니다. "
"게이트웨이가 AGY_API_KEY로 자식 프로세스에 주입합니다."
),
token_page_url="",
token_page_label="Agy 계정 로그인",
auth_kinds=("api_key", "oauth_token"),
guide=(
"Agy 계정에 로그인해 발급받은 접근 토큰을 붙여넣으세요. "
"실제 가용 모델은 게이트웨이의 agy models 조회로 확인됩니다."
),
),
ProviderMeta(
code="openrouter",
label="OpenRouter",
description=(
"OpenRouter를 통해 다중 provider 모델을 쓰는 엔진입니다. "
"게이트웨이가 OPENROUTER_API_KEY로 주입합니다."
),
token_page_url="https://openrouter.ai/keys",
token_page_label="OpenRouter · API 키",
auth_kinds=("api_key",),
guide=(
"OpenRouter에 로그인해 API 키를 발급한 뒤 붙여넣으세요. "
"키에 연결된 계정 크레딧과 모델 목록이 자동 확인됩니다."
),
),
)
def provider_meta(provider: str) -> ProviderMeta | None:
for meta in PROVIDER_CATALOG:
if meta.code == provider:
return meta
return None
class ProviderCredentialError(RuntimeError):
"""자격증명 저장·검증 실패. 원문 토큰을 포함하지 않는다."""
def _master_secret() -> bytes:
configured = settings.provider_credential_secret.get_secret_value().strip()
if configured:
if len(configured) < 16:
raise ProviderCredentialError(
"PROVIDER_CREDENTIAL_SECRET은 16자 이상이어야 합니다."
)
return configured.encode("utf-8")
session_secret = settings.session_secret
if session_secret == "dev-insecure-change-me":
# dev 기본 시크릿은 prod 기동 가드에서 이미 막힌다. dev 편의를 위해 허용.
return ("dev::" + session_secret).encode("utf-8")
if not session_secret or len(session_secret) < 16:
raise ProviderCredentialError(
"자격증명 암호화 키가 없습니다. PROVIDER_CREDENTIAL_SECRET 또는 SESSION_SECRET을 설정하세요."
)
return session_secret.encode("utf-8")
def _derived_key(info: bytes) -> bytes:
return hmac.new(_master_secret(), info, hashlib.sha256).digest()
def encrypt_token(plain: str) -> str:
"""HMAC-SHA256 CTR 스트림 + encrypt-then-MAC 봉투(v1)."""
data = plain.encode("utf-8")
nonce = os.urandom(_NONCE_SIZE)
enc_key = _derived_key(_HKDF_INFO_ENC)
mac_key = _derived_key(_HKDF_INFO_MAC)
stream = bytearray()
counter = 0
while len(stream) < len(data):
stream.extend(
hashlib.sha256(
enc_key + nonce + counter.to_bytes(4, "big")
).digest()
)
counter += 1
ciphertext = bytes(a ^ b for a, b in zip(data, stream))
tag = hmac.new(mac_key, nonce + ciphertext, hashlib.sha256).digest()[:_MAC_SIZE]
blob = nonce + ciphertext + tag
return _TOKEN_ENVELOPE_PREFIX + base64.urlsafe_b64encode(blob).decode("ascii")
def decrypt_token(envelope: str) -> str:
raw = envelope.removeprefix(_TOKEN_ENVELOPE_PREFIX)
try:
blob = base64.urlsafe_b64decode(raw.encode("ascii"))
except (ValueError, UnicodeEncodeError) as exc:
raise ProviderCredentialError("자격증명 봉투가 손상되었습니다.") from exc
if len(blob) < _NONCE_SIZE + _MAC_SIZE + 1:
raise ProviderCredentialError("자격증명 봉투가 손상되었습니다.")
nonce = blob[:_NONCE_SIZE]
ciphertext = blob[_NONCE_SIZE:-_MAC_SIZE]
tag = blob[-_MAC_SIZE:]
mac_key = _derived_key(_HKDF_INFO_MAC)
expected = hmac.new(mac_key, nonce + ciphertext, hashlib.sha256).digest()[:_MAC_SIZE]
if not hmac.compare_digest(tag, expected):
raise ProviderCredentialError("자격증명 봉투 인증에 실패했습니다.")
enc_key = _derived_key(_HKDF_INFO_ENC)
stream = bytearray()
counter = 0
while len(stream) < len(ciphertext):
stream.extend(
hashlib.sha256(
enc_key + nonce + counter.to_bytes(4, "big")
).digest()
)
counter += 1
return bytes(a ^ b for a, b in zip(ciphertext, stream)).decode("utf-8")
def token_hint(plain: str) -> str:
tail = plain[-4:] if len(plain) >= 8 else ""
return f"{tail}" if tail else "저장됨"
def _row_ts(value: Any) -> float | None:
if value is None:
return None
if isinstance(value, datetime):
value = value.replace(tzinfo=timezone.utc) if value.tzinfo is None else value
return value.timestamp()
try:
return float(value)
except (TypeError, ValueError):
return None
@dataclass(frozen=True, slots=True)
class StoredCredential:
provider: str
auth_kind: str
token_hint: str
updated_by: str | None
updated_at: float | None
last_verified_at: float | None
last_verify_ok: bool | None
last_verify_error: str | None
async def ensure_table() -> None:
pool = get_pool()
async with pool.acquire() as conn:
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS app.admin_provider_credential (
provider TEXT PRIMARY KEY,
token_encrypted TEXT NOT NULL,
token_hint TEXT NOT NULL DEFAULT '',
auth_kind TEXT NOT NULL DEFAULT 'api_key'
CHECK (auth_kind IN ('api_key','oauth_token')),
updated_by TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_verified_at TIMESTAMPTZ,
last_verify_ok BOOLEAN,
last_verify_error TEXT
)
"""
)
async def get_credential_row(provider: str) -> StoredCredential | None:
await ensure_table()
pool = get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT provider, token_hint, auth_kind, updated_by, updated_at,
last_verified_at, last_verify_ok, last_verify_error
FROM app.admin_provider_credential
WHERE provider = $1
""",
provider,
)
if row is None:
return None
return StoredCredential(
provider=row["provider"],
auth_kind=row["auth_kind"],
token_hint=row["token_hint"] or "저장됨",
updated_by=row["updated_by"],
updated_at=_row_ts(row["updated_at"]),
last_verified_at=_row_ts(row["last_verified_at"]),
last_verify_ok=row["last_verify_ok"],
last_verify_error=row["last_verify_error"],
)
async def get_plain_token(provider: str) -> tuple[str, str] | None:
"""저장된 토큰 원문과 auth_kind를 반환한다. 없으면 None."""
await ensure_table()
pool = get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT token_encrypted, auth_kind FROM app.admin_provider_credential WHERE provider = $1",
provider,
)
if row is None:
return None
return decrypt_token(row["token_encrypted"]), row["auth_kind"]
async def save_credential(
*, provider: str, token: str, auth_kind: str, updated_by: str
) -> StoredCredential:
if provider not in PROVIDER_CODES:
raise ProviderCredentialError(f"지원하지 않는 provider입니다: {provider}")
if auth_kind not in {"api_key", "oauth_token"}:
raise ProviderCredentialError("auth_kind는 api_key 또는 oauth_token이어야 합니다.")
plain = token.strip()
if not plain:
raise ProviderCredentialError("토큰이 비어 있습니다.")
await ensure_table()
pool = get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
INSERT INTO app.admin_provider_credential (
provider, token_encrypted, token_hint, auth_kind, updated_by, updated_at
)
VALUES ($1, $2, $3, $4, $5, now())
ON CONFLICT (provider) DO UPDATE SET
token_encrypted = EXCLUDED.token_encrypted,
token_hint = EXCLUDED.token_hint,
auth_kind = EXCLUDED.auth_kind,
updated_by = EXCLUDED.updated_by,
updated_at = now(),
last_verified_at = NULL,
last_verify_ok = NULL,
last_verify_error = NULL
RETURNING provider, token_hint, auth_kind, updated_by, updated_at,
last_verified_at, last_verify_ok, last_verify_error
""",
provider,
encrypt_token(plain),
token_hint(plain),
auth_kind,
updated_by,
)
return StoredCredential(
provider=row["provider"],
auth_kind=row["auth_kind"],
token_hint=row["token_hint"] or "저장됨",
updated_by=row["updated_by"],
updated_at=_row_ts(row["updated_at"]),
last_verified_at=_row_ts(row["last_verified_at"]),
last_verify_ok=row["last_verify_ok"],
last_verify_error=row["last_verify_error"],
)
async def delete_credential(provider: str) -> bool:
await ensure_table()
pool = get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"DELETE FROM app.admin_provider_credential WHERE provider = $1 RETURNING provider",
provider,
)
return row is not None
async def mark_verified(provider: str, *, ok: bool, error: str | None) -> None:
await ensure_table()
pool = get_pool()
async with pool.acquire() as conn:
await conn.execute(
"""
UPDATE app.admin_provider_credential
SET last_verified_at = now(), last_verify_ok = $2, last_verify_error = $3
WHERE provider = $1
""",
provider,
ok,
(error or "")[:500] if error else None,
)
# ── 게이트웨이 push 동기화 ────────────────────────────────────────────
_synced_boot_id: str | None = None
_synced_detail: str | None = None
def last_sync_state() -> tuple[str | None, str | None]:
return _synced_boot_id, _synced_detail
async def push_credentials_to_gateway() -> dict[str, Any]:
"""DB에 저장된 전체 자격증명을 게이트웨이 프로세스 환경으로 push한다.
실패해도 예외로 승격하지 않고 상태를 돌려준다 — 게이트웨이가 아직 안 떠 있어도
저장 자체는 성공해야 하고, 다음 동기화 기회(boot_id 불일치)에 다시 push된다.
"""
global _synced_boot_id, _synced_detail
await ensure_table()
pool = get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
"SELECT provider, token_encrypted, auth_kind FROM app.admin_provider_credential"
)
providers = {
row["provider"]: {
"token": decrypt_token(row["token_encrypted"]),
"auth_kind": row["auth_kind"],
}
for row in rows
if row["provider"] in PROVIDER_CODES
}
if not providers:
_synced_boot_id = None
_synced_detail = "저장된 자격증명이 없습니다."
return {"synced": False, "detail": _synced_detail}
try:
result = await engine_client.push_provider_credentials(providers)
status = await engine_client.provider_credentials_status()
_synced_boot_id = str(status.get("boot_id") or result.get("boot_id") or "")
_synced_detail = f"pushed: {','.join(result.get('applied') or [])}"
return {"synced": True, "boot_id": _synced_boot_id, "applied": result.get("applied") or []}
except EngineError as exc:
_synced_detail = str(exc)
return {"synced": False, "detail": _synced_detail}
async def ensure_gateway_synced() -> dict[str, Any]:
"""게이트웨이가 재시작(boot_id 변경)했으면 저장된 자격증명을 재-push한다."""
global _synced_boot_id, _synced_detail
try:
status = await engine_client.provider_credentials_status()
except EngineError as exc:
return {"synced": False, "detail": str(exc)}
boot_id = str(status.get("boot_id") or "")
if boot_id and boot_id == _synced_boot_id:
return {"synced": True, "boot_id": boot_id}
return await push_credentials_to_gateway()
# ── 연결 검증 ────────────────────────────────────────────────────────
_ANTHROPIC_BASE = os.environ.get("ANTHROPIC_API_BASE", "https://api.anthropic.com").rstrip("/")
_OPENAI_BASE = os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1").rstrip("/")
_OPENROUTER_BASE = os.environ.get("OPENROUTER_API_BASE", "https://openrouter.ai/api/v1").rstrip("/")
async def verify_provider(provider: str) -> dict[str, Any]:
"""저장된 토큰으로 provider에 실제 요청을 보내 연결을 확인한다."""
stored = await get_plain_token(provider)
if stored is None:
return {"ok": False, "detail": "저장된 자격증명이 없습니다."}
token, auth_kind = stored
ok = False
detail = ""
try:
if provider == "claude":
headers = (
{"Authorization": f"Bearer {token}", "anthropic-version": "2023-06-01"}
if auth_kind == "oauth_token"
else {"x-api-key": token, "anthropic-version": "2023-06-01"}
)
async with httpx.AsyncClient(timeout=20) as client:
response = await client.get(f"{_ANTHROPIC_BASE}/v1/models", params={"limit": 1}, headers=headers)
ok = response.status_code == 200
detail = "" if ok else f"Anthropic 응답 {response.status_code}"
elif provider == "codex":
async with httpx.AsyncClient(timeout=20) as client:
response = await client.get(f"{_OPENAI_BASE}/models", headers={"Authorization": f"Bearer {token}"})
ok = response.status_code == 200
detail = "" if ok else f"OpenAI 응답 {response.status_code}"
elif provider == "openrouter":
async with httpx.AsyncClient(timeout=20) as client:
response = await client.get(f"{_OPENROUTER_BASE}/key", headers={"Authorization": f"Bearer {token}"})
ok = response.status_code == 200
detail = "" if ok else f"OpenRouter 응답 {response.status_code}"
elif provider == "agy":
# Agy CLI 계정 인증은 게이트웨이의 agy models 조회(ready)가 담당한다.
# 여기서는 토큰 형식 보전 여부만 확인한다.
ok = len(token) >= 8
detail = "" if ok else "토큰이 너무 짧습니다."
else:
detail = f"지원하지 않는 provider입니다: {provider}"
except httpx.HTTPError as exc:
detail = f"연결 확인 요청 실패: {exc}"
await mark_verified(provider, ok=ok, error=None if ok else detail)
return {"ok": ok, "detail": detail, "verified_at": time.time()}