Some checks failed
API contract / OpenAPI type drift (push) Failing after 1m6s
앱 DB 계정에는 스키마 CREATE 권한이 없어 런타임 CREATE TABLE IF NOT EXISTS가 테이블이 이미 있어도 권한 거부로 실패한다(운영 실측). to_regclass 존재 검사를 먼저 하고, 부트스트랩 SQL(05_runtime_auth.sql)에 DDL을 추가해 신규 볼륨에서도 소유자가 테이블을 만들도록 한다.
485 lines
18 KiB
Python
485 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:
|
|
"""테이블이 없을 때만 DDL을 실행한다.
|
|
|
|
앱 접속 계정(vignette_app)에는 스키마 CREATE 권한이 없다. Postgres는
|
|
``CREATE TABLE IF NOT EXISTS``가 no-op이어도 스키마 CREATE 권한을 먼저
|
|
검사하므로, 부트스트랩 SQL이 이미 만든 테이블이면 검사 없이 건너뛴다.
|
|
"""
|
|
pool = get_pool()
|
|
async with pool.acquire() as conn:
|
|
exists = await conn.fetchval(
|
|
"SELECT to_regclass('app.admin_provider_credential') IS NOT NULL"
|
|
)
|
|
if exists:
|
|
return
|
|
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()}
|