Initial commit: CI Agent competitive-intelligence monitoring app
FastAPI + Celery + Next.js + Postgres/Redis app with company monitoring, source collection, LLM-based change analysis, enrichment, and account security (Turnstile, escalating lockout, email verification).
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
"""Per-user API keys - each user can supply their own key for a provider,
|
||||
used in place of the server's global .env-configured key for their own
|
||||
requests (see get_effective_settings, and its call sites in companies.py,
|
||||
reports.py, tasks/collection.py, tasks/enrichment.py, and
|
||||
collection_service.to_company_context for USPTO patents).
|
||||
|
||||
Storage is encrypted at rest (app/core/crypto.py). A key is only ever
|
||||
decrypted for the owning user's own list/set calls or to actually place a
|
||||
provider call on their behalf - never exposed to any other user, admin or
|
||||
not (this is a deliberately different visibility model from the
|
||||
admin-only, localhost-only *server* key box in app/api/v1/system.py).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.core.crypto import decrypt_secret, encrypt_secret
|
||||
from app.models.enums import ApiKeyProvider, SecurityEventType
|
||||
from app.repositories.user_api_key_repository import UserApiKeyRepository
|
||||
from app.repositories.user_security_event_repository import UserSecurityEventRepository
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderMeta:
|
||||
label: str
|
||||
settings_field: str
|
||||
credits_note: str
|
||||
free: bool = False
|
||||
requires_government_id: bool = False
|
||||
|
||||
|
||||
PROVIDER_META: dict[ApiKeyProvider, ProviderMeta] = {
|
||||
ApiKeyProvider.ANTHROPIC: ProviderMeta(
|
||||
label="Anthropic",
|
||||
settings_field="anthropic_api_key",
|
||||
credits_note="Anthropic doesn't expose a credit/usage-balance API.",
|
||||
),
|
||||
ApiKeyProvider.BRAVE_SEARCH: ProviderMeta(
|
||||
label="Brave Search",
|
||||
settings_field="brave_search_api_key",
|
||||
credits_note=(
|
||||
"Brave Search API has no metered balance endpoint (plan-based, not prepaid credits)."
|
||||
),
|
||||
),
|
||||
ApiKeyProvider.NINJAPEAR: ProviderMeta(
|
||||
label="NinjaPear",
|
||||
settings_field="ninjapear_api_key",
|
||||
credits_note="Credit balance shown in System configuration below.",
|
||||
),
|
||||
ApiKeyProvider.USPTO: ProviderMeta(
|
||||
label="USPTO",
|
||||
settings_field="uspto_api_key",
|
||||
credits_note="Free - USPTO Open Data Portal has no usage limit or credit cost.",
|
||||
free=True,
|
||||
requires_government_id=True,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def list_status(
|
||||
db: AsyncSession, user_id: uuid.UUID, settings: Settings
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Never fetches a live NinjaPear credit balance itself - the frontend
|
||||
sources that number from /system/status's own already-fetched
|
||||
ninjapear_credit_balance (see the Settings page's System configuration
|
||||
box) instead of this endpoint making a second, redundant live call."""
|
||||
repo = UserApiKeyRepository(db)
|
||||
stored = {row.provider: row for row in await repo.list_for_user(user_id)}
|
||||
|
||||
results: list[dict[str, Any]] = []
|
||||
for provider, meta in PROVIDER_META.items():
|
||||
row = stored.get(provider)
|
||||
value = decrypt_secret(row.encrypted_key, settings) if row is not None else None
|
||||
results.append(
|
||||
{
|
||||
"provider": provider.value,
|
||||
"label": meta.label,
|
||||
"configured": row is not None,
|
||||
"value": value,
|
||||
"credits": None,
|
||||
"credits_note": meta.credits_note,
|
||||
"free": meta.free,
|
||||
"requires_government_id": meta.requires_government_id,
|
||||
}
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
async def set_key(
|
||||
db: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
provider: ApiKeyProvider,
|
||||
plaintext_key: str,
|
||||
settings: Settings,
|
||||
*,
|
||||
client_ip: str,
|
||||
) -> None:
|
||||
"""An empty/blank key clears the user's override, falling back to the
|
||||
server's global key for that provider again. Every update - including a
|
||||
clear - is logged to this same user's own Account activity."""
|
||||
repo = UserApiKeyRepository(db)
|
||||
stripped = plaintext_key.strip()
|
||||
if not stripped:
|
||||
await repo.delete(user_id, provider)
|
||||
else:
|
||||
await repo.upsert(user_id, provider, encrypt_secret(stripped, settings))
|
||||
await UserSecurityEventRepository(db).create(
|
||||
user_id=user_id, event_type=SecurityEventType.API_KEY_UPDATED, ip_address=client_ip
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def get_effective_settings(
|
||||
db: AsyncSession, user_id: uuid.UUID, settings: Settings
|
||||
) -> Settings:
|
||||
"""A copy of the global settings with any of this user's own stored
|
||||
keys substituted in for the matching field - providers they haven't
|
||||
set their own key for keep using the server's global default."""
|
||||
rows = await UserApiKeyRepository(db).list_for_user(user_id)
|
||||
if not rows:
|
||||
return settings
|
||||
overrides = {
|
||||
PROVIDER_META[row.provider].settings_field: decrypt_secret(row.encrypted_key, settings)
|
||||
for row in rows
|
||||
}
|
||||
return settings.model_copy(update=overrides)
|
||||
Reference in New Issue
Block a user