Files
mcphub/core/api_keys.py
airano-ir f203ca88de
Some checks failed
Release / Test before release (push) Has been cancelled
Release / Publish to PyPI (push) Has been cancelled
Release / Publish to Docker Hub (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled
feat(v3.12.0): media pipeline, AI image generation, capability probe, companion v2.9.0
Three-month batch sync from internal repo (~80 commits) covering Tracks F.5a, F.7e, F.8, F.17, F.18, F.X.

WordPress media pipeline
- Pillow-based optimization, AI image generation (OpenAI / Stability / Replicate / Google Nano Banana / OpenRouter), chunked + resumable uploads, bulk delete/reassign, idempotent retries.

Capability discovery (F.7e)
- Per-site credential probe + adapters for WordPress / WooCommerce / Gitea, tier-fit unions granted ∪ roles, capability badge UI with HTMX partial re-check, install hint in every companion-unreachable error.

Companion plugin overhaul
- Renamed wordpress-plugin/airano-mcp-seo-bridge → wordpress-plugin/airano-mcp-bridge.
- Eight new endpoints: /capabilities, /bulk-meta, /export, /cache-purge, /transient-flush, /site-health, /audit-hook, /upload-and-attach.
- wp.org Plugin Check pass: i18n, WP_Filesystem, scheme allowlist on audit-hook URL.

Other
- Gitea ergonomics (F.17): batch files, tree, search, compare, releases, fork.
- Opportunistic bcrypt upgrade for legacy SHA-256 admin keys (F.8).
- n8n refactor: structured errors, capability probe, missing tools backfilled.
- Idempotency-Key dedup for AI media upload retries; WP client fast-fails on unreachable sites.

Docs
- README + CLAUDE.md drop the fixed "633 tools" claim. The total grows with each release; per-plugin approximations + dashboard-surfaced counts replace it.
- Tools/Tests badges removed in favour of "Plugins: 10".

Deployment
- PyPI mirror chain, optional BUILD_HTTP_PROXY, Alpine→Yandex apk mirror, Debian-slim Plan-B Dockerfile, mirror.gcr.io variant.

CI
- Black + Ruff clean on Python 3.12; pytest tests/ green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:25:58 +02:00

566 lines
18 KiB
Python

"""
API Key Management System
Comprehensive per-project API key management with scopes, expiration,
and audit trail.
"""
import hashlib
import hmac
import json
import logging
import os
import secrets
from dataclasses import asdict, dataclass
from datetime import datetime, timedelta
from pathlib import Path
import bcrypt
logger = logging.getLogger(__name__)
# Valid scope values
VALID_SCOPES = ["read", "write", "admin"]
# Scope can be single ("read") or multiple space-separated ("read write admin")
Scope = str
def validate_scope(scope: str) -> bool:
"""
Validate that scope contains only valid scope values.
Args:
scope: Single scope ("read") or space-separated scopes ("read write admin")
Returns:
True if all scopes are valid, False otherwise
"""
if not scope:
return False
scope_list = scope.split()
return all(s in VALID_SCOPES for s in scope_list)
def normalize_scope(scope: str) -> str:
"""
Normalize scope string by removing duplicates and sorting.
Args:
scope: Single scope or space-separated scopes
Returns:
Normalized scope string (e.g., "admin read write" -> "read write admin")
"""
scope_list = scope.split()
# Remove duplicates, sort by priority (read < write < admin)
unique_scopes = []
for s in ["read", "write", "admin"]:
if s in scope_list:
unique_scopes.append(s)
return " ".join(unique_scopes)
@dataclass
class APIKey:
"""
Represents an API key with metadata.
Fields:
key_id: Unique identifier for the key
key_hash: SHA256 hash of the actual key (for storage)
project_id: Project this key belongs to ("*" for all projects)
scope: Access scope - single ("read") or multiple space-separated ("read write admin")
created_at: ISO timestamp when created
expires_at: Optional ISO timestamp when expires
last_used_at: Optional ISO timestamp of last use
usage_count: Number of times used
description: Optional description
revoked: Whether the key has been revoked
"""
key_id: str
key_hash: str # bcrypt hash (new keys) or SHA-256 hex (legacy)
project_id: str
scope: Scope
created_at: str
expires_at: str | None = None
last_used_at: str | None = None
usage_count: int = 0
description: str | None = None
revoked: bool = False
def to_dict(self) -> dict:
"""Convert to dictionary for JSON serialization."""
return asdict(self)
@classmethod
def from_dict(cls, data: dict) -> "APIKey":
"""Create from dictionary."""
return cls(**data)
def is_expired(self) -> bool:
"""Check if key has expired."""
if not self.expires_at:
return False
expires = datetime.fromisoformat(self.expires_at)
return datetime.now() > expires
def is_valid(self) -> bool:
"""Check if key is valid (not revoked, not expired)."""
return not self.revoked and not self.is_expired()
class APIKeyManager:
"""
Manages per-project API keys with persistence.
Features:
- Persistent JSON storage
- Key creation with scopes
- Key validation with project and scope checking
- Key rotation and revocation
- Usage tracking
- Expiration support
"""
def __init__(self, storage_path: str = "data/api_keys.json"):
"""
Initialize API Key Manager.
Args:
storage_path: Path to JSON file for key storage
"""
self.storage_path = Path(storage_path)
self.keys: dict[str, APIKey] = {}
# Ensure storage directory exists (with graceful fallback)
try:
self.storage_path.parent.mkdir(parents=True, exist_ok=True)
except PermissionError:
# Fallback to /tmp if we can't create in data/
logger.warning(
f"Cannot create directory {self.storage_path.parent}, " f"falling back to /tmp"
)
self.storage_path = Path("/tmp/api_keys.json")
self.storage_path.parent.mkdir(parents=True, exist_ok=True)
# Load existing keys
self._load_keys()
logger.info(
f"API Key Manager initialized with {len(self.keys)} keys "
f"(storage: {self.storage_path})"
)
def _load_keys(self) -> None:
"""Load keys from storage file."""
if not self.storage_path.exists():
logger.info("No existing keys file found, starting fresh")
return
try:
with open(self.storage_path) as f:
data = json.load(f)
self.keys = {
key_id: APIKey.from_dict(key_data) for key_id, key_data in data.items()
}
logger.info(f"Loaded {len(self.keys)} keys from storage")
except Exception as e:
logger.error(f"Failed to load keys: {e}")
self.keys = {}
def _save_keys(self) -> None:
"""Save keys to storage file."""
try:
data = {key_id: key.to_dict() for key_id, key in self.keys.items()}
with open(self.storage_path, "w") as f:
json.dump(data, f, indent=2)
logger.debug(f"Saved {len(self.keys)} keys to storage")
except Exception as e:
logger.error(f"Failed to save keys: {e}")
def _hash_key(self, api_key: str) -> str:
"""Hash API key for storage using bcrypt."""
return bcrypt.hashpw(api_key.encode(), bcrypt.gensalt()).decode()
@staticmethod
def _is_bcrypt_hash(key_hash: str) -> bool:
"""Return True if the stored hash is in bcrypt format ($2a/$2b/$2y)."""
return key_hash.startswith("$2")
def _verify_key(self, api_key: str, key_hash: str) -> bool:
"""Verify API key against stored hash.
Supports both the current bcrypt format and a legacy SHA-256
fallback so pre-bcrypt keys keep working. F.8 security-
hardening note: SHA-256 is a fast hash with no per-hash salt —
a stolen keys.json file would enable offline brute-force on
any legacy entry. We therefore:
1. accept legacy hashes here (so customers aren't locked out), and
2. opportunistically upgrade every legacy hash to bcrypt the
first time it's successfully verified (see
:meth:`_upgrade_legacy_hash`).
Brand-new keys created via :meth:`create_key` are always
bcrypt-hashed — legacy SHA-256 only appears for rows that
existed before the F.4 / F.8 hardening passes.
"""
if self._is_bcrypt_hash(key_hash):
try:
return bcrypt.checkpw(api_key.encode(), key_hash.encode())
except ValueError:
# Truncated / corrupt bcrypt hash — treat as mismatch
# rather than raising.
return False
# Legacy SHA-256 fallback (constant-time compare to avoid
# timing oracles on the legacy-hash path).
expected = hashlib.sha256(api_key.encode()).hexdigest()
return hmac.compare_digest(expected, key_hash)
def _upgrade_legacy_hash(self, key: APIKey, api_key: str) -> bool:
"""Re-hash a legacy SHA-256 entry with bcrypt and persist.
Called from the verify paths the moment a legacy hash is
successfully matched, so the next verify uses bcrypt. Returns
True when an upgrade happened, False otherwise. Errors during
persist are logged but swallowed — the key still validates,
we just try again next time.
"""
if self._is_bcrypt_hash(key.key_hash):
return False
try:
key.key_hash = self._hash_key(api_key)
self._save_keys()
logger.info("Upgraded legacy SHA-256 key hash %s to bcrypt", key.key_id)
return True
except Exception as exc: # pragma: no cover — defensive
logger.warning("Failed to upgrade legacy hash for key %s: %s", key.key_id, exc)
return False
def create_key(
self,
project_id: str,
scope: Scope = "read",
expires_in_days: int | None = None,
description: str | None = None,
) -> dict[str, str]:
"""
Create a new API key.
Args:
project_id: Project ID ("*" for all projects)
scope: Access scope - single ("read") or multiple space-separated ("read write admin")
expires_in_days: Optional expiration in days
description: Optional description
Returns:
dict: {"key": actual_key, "key_id": key_id, "project_id": project_id, "scope": scope}
Raises:
ValueError: If scope contains invalid values
"""
# Validate and normalize scope
if not validate_scope(scope):
raise ValueError(
f"Invalid scope: {scope}. Must contain only: {', '.join(VALID_SCOPES)}"
)
normalized_scope = normalize_scope(scope)
# Generate secure random key
api_key = f"cmp_{secrets.token_urlsafe(32)}"
key_id = f"key_{secrets.token_urlsafe(16)}"
key_hash = self._hash_key(api_key)
# Calculate expiration
expires_at = None
if expires_in_days:
expires = datetime.now() + timedelta(days=expires_in_days)
expires_at = expires.isoformat()
# Create key object
key = APIKey(
key_id=key_id,
key_hash=key_hash,
project_id=project_id,
scope=normalized_scope,
created_at=datetime.now().isoformat(),
expires_at=expires_at,
description=description,
)
# Store and save
self.keys[key_id] = key
self._save_keys()
logger.info(
f"Created API key {key_id} for project {project_id} " f"with scope '{normalized_scope}'"
)
return {
"key": api_key,
"key_id": key_id,
"scope": normalized_scope,
"project_id": project_id,
"expires_at": expires_at,
}
def validate_key(
self,
api_key: str,
project_id: str,
required_scope: Scope = "read",
skip_project_check: bool = False,
) -> str | None:
"""
Validate API key for project and scope.
Args:
api_key: The API key to validate
project_id: Project to check access for
required_scope: Minimum required scope
skip_project_check: Skip project-level validation (for unified tools)
Returns:
Optional[str]: key_id if valid, None otherwise
"""
# Find key by verifying against stored hash (bcrypt or legacy SHA-256)
for key_id, key in self.keys.items():
if not self._verify_key(api_key, key.key_hash):
continue
# F.8: opportunistically upgrade legacy SHA-256 hashes to bcrypt
# the moment they validate successfully.
self._upgrade_legacy_hash(key, api_key)
# Check if valid (not revoked, not expired)
if not key.is_valid():
logger.warning(
f"Key {key_id} is invalid "
f"(revoked={key.revoked}, expired={key.is_expired()})"
)
return None
# Check project access (unless skipped for unified tools)
if not skip_project_check:
if key.project_id != "*" and key.project_id != project_id:
logger.warning(f"Key {key_id} does not have access to project {project_id}")
return None
# Check scope: key must have required_scope or higher
# Scope hierarchy: admin > write > read
scope_hierarchy = {"read": 0, "write": 1, "admin": 2}
key_scopes = key.scope.split()
# Check if required_scope is directly present
if required_scope in key_scopes:
# Update usage tracking
key.last_used_at = datetime.now().isoformat()
key.usage_count += 1
self._save_keys()
logger.debug(f"Key {key_id} validated successfully (scope: {key.scope})")
return key_id
# Check if key has higher scope (e.g., admin covers write and read)
key_level = max(scope_hierarchy.get(s, 0) for s in key_scopes)
required_level = scope_hierarchy.get(required_scope, 0)
if key_level >= required_level:
# Update usage tracking
key.last_used_at = datetime.now().isoformat()
key.usage_count += 1
self._save_keys()
logger.debug(f"Key {key_id} validated successfully (scope: {key.scope})")
return key_id
logger.warning(
f"Key {key_id} has insufficient scope "
f"({key.scope} does not include {required_scope})"
)
return None
logger.warning("No matching API key found")
return None
def get_key_by_token(self, api_key: str) -> APIKey | None:
"""
Get API key object by token (without project validation).
This method looks up an API key by its raw token value and returns
the APIKey object if found. Unlike validate_key(), it does not
validate against a specific project or scope.
Args:
api_key: The raw API key token (e.g., "cmp_xxx...")
Returns:
Optional[APIKey]: The APIKey object if found, None otherwise
"""
for key_id, key in self.keys.items():
if self._verify_key(api_key, key.key_hash):
# F.8: upgrade legacy SHA-256 hashes on first successful match.
self._upgrade_legacy_hash(key, api_key)
logger.debug(f"Found API key {key_id} by token")
return key
logger.debug("No API key found for provided token")
return None
def revoke_key(self, key_id: str) -> bool:
"""
Revoke an API key.
Args:
key_id: Key ID to revoke
Returns:
bool: True if revoked successfully
"""
if key_id not in self.keys:
logger.warning(f"Key {key_id} not found")
return False
self.keys[key_id].revoked = True
self._save_keys()
logger.info(f"Revoked API key {key_id}")
return True
def delete_key(self, key_id: str) -> bool:
"""
Permanently delete an API key.
Args:
key_id: Key ID to delete
Returns:
bool: True if deleted successfully
"""
if key_id not in self.keys:
logger.warning(f"Key {key_id} not found")
return False
del self.keys[key_id]
self._save_keys()
logger.info(f"Deleted API key {key_id}")
return True
def list_keys(self, project_id: str | None = None, include_revoked: bool = False) -> list[dict]:
"""
List API keys.
Args:
project_id: Optional filter by project
include_revoked: Include revoked keys
Returns:
List of key information (without actual keys)
"""
keys = []
for key_id, key in self.keys.items():
# Filter by project
if project_id and key.project_id != project_id and key.project_id != "*":
continue
# Filter revoked
if not include_revoked and key.revoked:
continue
keys.append(
{
"key_id": key_id,
"project_id": key.project_id,
"scope": key.scope,
"created_at": key.created_at,
"expires_at": key.expires_at,
"last_used_at": key.last_used_at,
"usage_count": key.usage_count,
"description": key.description,
"revoked": key.revoked,
"expired": key.is_expired(),
"valid": key.is_valid(),
}
)
return keys
def rotate_keys(self, project_id: str) -> list[dict[str, str]]:
"""
Rotate all keys for a project.
Creates new keys with same scopes and revokes old ones.
Args:
project_id: Project to rotate keys for
Returns:
List of new key information
"""
old_keys = [
key for key in self.keys.values() if key.project_id == project_id and key.is_valid()
]
new_keys = []
for old_key in old_keys:
# Create new key with same scope
new_key_data = self.create_key(
project_id=project_id,
scope=old_key.scope,
description=f"Rotated from {old_key.key_id}",
)
new_keys.append(new_key_data)
# Revoke old key
self.revoke_key(old_key.key_id)
logger.info(f"Rotated {len(new_keys)} keys for project {project_id}")
return new_keys
def get_key_info(self, key_id: str) -> dict | None:
"""
Get information about a specific key.
Args:
key_id: Key ID
Returns:
Key information or None
"""
if key_id not in self.keys:
return None
key = self.keys[key_id]
return {
"key_id": key_id,
"project_id": key.project_id,
"scope": key.scope,
"created_at": key.created_at,
"expires_at": key.expires_at,
"last_used_at": key.last_used_at,
"usage_count": key.usage_count,
"description": key.description,
"revoked": key.revoked,
"expired": key.is_expired(),
"valid": key.is_valid(),
}
# Global instance
_api_key_manager: APIKeyManager | None = None
def get_api_key_manager() -> APIKeyManager:
"""Get the global API Key Manager instance."""
global _api_key_manager
if _api_key_manager is None:
storage_path = os.getenv("API_KEYS_STORAGE", "data/api_keys.json")
_api_key_manager = APIKeyManager(storage_path)
return _api_key_manager