301 lines
12 KiB
Python
301 lines
12 KiB
Python
"""Credential CRUD routes."""
|
|
|
|
import asyncio
|
|
import logging
|
|
import os
|
|
|
|
from aiohttp import web
|
|
from pydantic import SecretStr
|
|
|
|
from framework.credentials.models import CredentialKey, CredentialObject
|
|
from framework.credentials.store import CredentialStore
|
|
from framework.server.app import validate_agent_path
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _get_store(request: web.Request) -> CredentialStore:
|
|
return request.app["credential_store"]
|
|
|
|
|
|
def _credential_to_dict(cred: CredentialObject) -> dict:
|
|
"""Serialize a CredentialObject to JSON — never include secret values."""
|
|
return {
|
|
"credential_id": cred.id,
|
|
"credential_type": str(cred.credential_type),
|
|
"key_names": list(cred.keys.keys()),
|
|
"created_at": cred.created_at.isoformat() if cred.created_at else None,
|
|
"updated_at": cred.updated_at.isoformat() if cred.updated_at else None,
|
|
}
|
|
|
|
|
|
async def handle_list_credentials(request: web.Request) -> web.Response:
|
|
"""GET /api/credentials — list all credential metadata (no secrets)."""
|
|
store = _get_store(request)
|
|
cred_ids = store.list_credentials()
|
|
credentials = []
|
|
for cid in cred_ids:
|
|
cred = store.get_credential(cid, refresh_if_needed=False)
|
|
if cred:
|
|
credentials.append(_credential_to_dict(cred))
|
|
return web.json_response({"credentials": credentials})
|
|
|
|
|
|
async def handle_get_credential(request: web.Request) -> web.Response:
|
|
"""GET /api/credentials/{credential_id} — get single credential metadata."""
|
|
credential_id = request.match_info["credential_id"]
|
|
store = _get_store(request)
|
|
cred = store.get_credential(credential_id, refresh_if_needed=False)
|
|
if cred is None:
|
|
return web.json_response({"error": f"Credential '{credential_id}' not found"}, status=404)
|
|
return web.json_response(_credential_to_dict(cred))
|
|
|
|
|
|
async def handle_save_credential(request: web.Request) -> web.Response:
|
|
"""POST /api/credentials — store a credential.
|
|
|
|
Body: {"credential_id": "...", "keys": {"key_name": "value", ...}}
|
|
"""
|
|
body = await request.json()
|
|
|
|
credential_id = body.get("credential_id")
|
|
keys = body.get("keys")
|
|
|
|
if not credential_id or not keys or not isinstance(keys, dict):
|
|
return web.json_response({"error": "credential_id and keys are required"}, status=400)
|
|
|
|
# ADEN_API_KEY is stored in the encrypted store via key_storage module
|
|
if credential_id == "aden_api_key":
|
|
key = keys.get("api_key", "").strip()
|
|
if not key:
|
|
return web.json_response({"error": "api_key is required"}, status=400)
|
|
|
|
from framework.credentials.key_storage import save_aden_api_key
|
|
|
|
save_aden_api_key(key)
|
|
|
|
# Immediately sync OAuth tokens from Aden (runs in executor because
|
|
# _presync_aden_tokens makes blocking HTTP calls to the Aden server).
|
|
try:
|
|
from aden_tools.credentials import CREDENTIAL_SPECS
|
|
|
|
from framework.credentials.validation import _presync_aden_tokens
|
|
|
|
loop = asyncio.get_running_loop()
|
|
await loop.run_in_executor(None, _presync_aden_tokens, CREDENTIAL_SPECS)
|
|
except Exception as exc:
|
|
logger.warning("Aden token sync after key save failed: %s", exc)
|
|
|
|
return web.json_response({"saved": "aden_api_key"}, status=201)
|
|
|
|
store = _get_store(request)
|
|
cred = CredentialObject(
|
|
id=credential_id,
|
|
keys={k: CredentialKey(name=k, value=SecretStr(v)) for k, v in keys.items()},
|
|
)
|
|
store.save_credential(cred)
|
|
return web.json_response({"saved": credential_id}, status=201)
|
|
|
|
|
|
async def handle_delete_credential(request: web.Request) -> web.Response:
|
|
"""DELETE /api/credentials/{credential_id} — delete a credential."""
|
|
credential_id = request.match_info["credential_id"]
|
|
|
|
if credential_id == "aden_api_key":
|
|
from framework.credentials.key_storage import delete_aden_api_key
|
|
|
|
deleted = delete_aden_api_key()
|
|
if not deleted:
|
|
return web.json_response({"error": "Credential 'aden_api_key' not found"}, status=404)
|
|
return web.json_response({"deleted": True})
|
|
|
|
store = _get_store(request)
|
|
deleted = store.delete_credential(credential_id)
|
|
if not deleted:
|
|
return web.json_response({"error": f"Credential '{credential_id}' not found"}, status=404)
|
|
return web.json_response({"deleted": True})
|
|
|
|
|
|
async def handle_check_agent(request: web.Request) -> web.Response:
|
|
"""POST /api/credentials/check-agent — check and validate agent credentials.
|
|
|
|
Uses the same ``validate_agent_credentials`` as agent startup:
|
|
1. Presence — is the credential available (env, encrypted store, Aden)?
|
|
2. Health check — does the credential actually work (lightweight HTTP call)?
|
|
|
|
Body: {"agent_path": "...", "verify": true}
|
|
"""
|
|
body = await request.json()
|
|
agent_path = body.get("agent_path")
|
|
verify = body.get("verify", True)
|
|
|
|
if not agent_path:
|
|
return web.json_response({"error": "agent_path is required"}, status=400)
|
|
|
|
try:
|
|
agent_path = str(validate_agent_path(agent_path))
|
|
except ValueError as e:
|
|
return web.json_response({"error": str(e)}, status=400)
|
|
|
|
try:
|
|
from framework.credentials.setup import load_agent_nodes
|
|
from framework.credentials.validation import (
|
|
ensure_credential_key_env,
|
|
validate_agent_credentials,
|
|
)
|
|
|
|
# Load env vars from shell config (same as runtime startup)
|
|
ensure_credential_key_env()
|
|
|
|
nodes = load_agent_nodes(agent_path)
|
|
result = validate_agent_credentials(
|
|
nodes, verify=verify, raise_on_error=False, force_refresh=True
|
|
)
|
|
|
|
# If any credential needs Aden, include ADEN_API_KEY as a first-class row
|
|
if any(c.aden_supported for c in result.credentials):
|
|
aden_key_status = {
|
|
"credential_name": "Aden Platform",
|
|
"credential_id": "aden_api_key",
|
|
"env_var": "ADEN_API_KEY",
|
|
"description": "API key from the Developers tab in Settings",
|
|
"help_url": "https://hive.adenhq.com/",
|
|
"tools": [],
|
|
"node_types": [],
|
|
"available": result.has_aden_key,
|
|
"valid": None,
|
|
"validation_message": None,
|
|
"direct_api_key_supported": True,
|
|
"aden_supported": True, # renders with "Authorize" button to open Aden
|
|
"credential_key": "api_key",
|
|
}
|
|
required = [aden_key_status] + [_status_to_dict(c) for c in result.credentials]
|
|
else:
|
|
required = [_status_to_dict(c) for c in result.credentials]
|
|
|
|
return web.json_response(
|
|
{
|
|
"required": required,
|
|
"has_aden_key": result.has_aden_key,
|
|
}
|
|
)
|
|
except Exception as e:
|
|
logger.exception(f"Error checking agent credentials: {e}")
|
|
return web.json_response(
|
|
{"error": "Internal server error while checking credentials"},
|
|
status=500,
|
|
)
|
|
|
|
|
|
def _status_to_dict(c) -> dict:
|
|
"""Convert a CredentialStatus to the JSON dict expected by the frontend."""
|
|
return {
|
|
"credential_name": c.credential_name,
|
|
"credential_id": c.credential_id,
|
|
"env_var": c.env_var,
|
|
"description": c.description,
|
|
"help_url": c.help_url,
|
|
"tools": c.tools,
|
|
"node_types": c.node_types,
|
|
"available": c.available,
|
|
"direct_api_key_supported": c.direct_api_key_supported,
|
|
"aden_supported": c.aden_supported,
|
|
"credential_key": c.credential_key,
|
|
"valid": c.valid,
|
|
"validation_message": c.validation_message,
|
|
"alternative_group": c.alternative_group,
|
|
}
|
|
|
|
|
|
async def handle_list_specs(request: web.Request) -> web.Response:
|
|
"""GET /api/credentials/specs — list ALL credential specs with availability."""
|
|
try:
|
|
from aden_tools.credentials import CREDENTIAL_SPECS
|
|
|
|
from framework.credentials.storage import (
|
|
CompositeStorage,
|
|
EncryptedFileStorage,
|
|
EnvVarStorage,
|
|
)
|
|
from framework.credentials.store import CredentialStore
|
|
from framework.credentials.validation import _presync_aden_tokens, ensure_credential_key_env
|
|
|
|
ensure_credential_key_env()
|
|
|
|
has_aden_key = bool(os.environ.get("ADEN_API_KEY"))
|
|
if has_aden_key:
|
|
_presync_aden_tokens(CREDENTIAL_SPECS)
|
|
|
|
# Build composite store (env → encrypted file)
|
|
env_mapping = {
|
|
(spec.credential_id or name): spec.env_var for name, spec in CREDENTIAL_SPECS.items()
|
|
}
|
|
env_storage = EnvVarStorage(env_mapping=env_mapping)
|
|
if os.environ.get("HIVE_CREDENTIAL_KEY"):
|
|
storage = CompositeStorage(primary=env_storage, fallbacks=[EncryptedFileStorage()])
|
|
else:
|
|
storage = env_storage
|
|
store = CredentialStore(storage=storage)
|
|
|
|
specs = []
|
|
any_aden = False
|
|
for name, spec in CREDENTIAL_SPECS.items():
|
|
cred_id = spec.credential_id or name
|
|
if spec.aden_supported:
|
|
any_aden = True
|
|
specs.append(
|
|
{
|
|
"credential_name": name,
|
|
"credential_id": cred_id,
|
|
"env_var": spec.env_var,
|
|
"description": spec.description,
|
|
"help_url": spec.help_url,
|
|
"api_key_instructions": spec.api_key_instructions,
|
|
"tools": spec.tools,
|
|
"aden_supported": spec.aden_supported,
|
|
"direct_api_key_supported": spec.direct_api_key_supported,
|
|
"credential_key": spec.credential_key,
|
|
"credential_group": spec.credential_group,
|
|
"available": store.is_available(cred_id),
|
|
}
|
|
)
|
|
|
|
# Include aden_api_key synthetic row if any spec uses Aden
|
|
if any_aden:
|
|
specs.insert(
|
|
0,
|
|
{
|
|
"credential_name": "Aden Platform",
|
|
"credential_id": "aden_api_key",
|
|
"env_var": "ADEN_API_KEY",
|
|
"description": "API key from the Developers tab in Settings",
|
|
"help_url": "https://hive.adenhq.com/",
|
|
"api_key_instructions": "1. Go to hive.adenhq.com\n2. Open Settings > Developers\n3. Copy your API key",
|
|
"tools": [],
|
|
"aden_supported": True,
|
|
"direct_api_key_supported": True,
|
|
"credential_key": "api_key",
|
|
"credential_group": "",
|
|
"available": has_aden_key,
|
|
},
|
|
)
|
|
|
|
return web.json_response({"specs": specs, "has_aden_key": has_aden_key})
|
|
except Exception as e:
|
|
logger.exception(f"Error listing credential specs: {e}")
|
|
return web.json_response(
|
|
{"error": "Internal server error while listing credential specs"},
|
|
status=500,
|
|
)
|
|
|
|
|
|
def register_routes(app: web.Application) -> None:
|
|
"""Register credential routes on the application."""
|
|
# specs and check-agent must be registered BEFORE the {credential_id} wildcard
|
|
app.router.add_get("/api/credentials/specs", handle_list_specs)
|
|
app.router.add_post("/api/credentials/check-agent", handle_check_agent)
|
|
app.router.add_get("/api/credentials", handle_list_credentials)
|
|
app.router.add_post("/api/credentials", handle_save_credential)
|
|
app.router.add_get("/api/credentials/{credential_id}", handle_get_credential)
|
|
app.router.add_delete("/api/credentials/{credential_id}", handle_delete_credential)
|