Files
ServerKit/backend/app/api/api_keys.py
T
Juan DenisandClaude Opus 4.8 f47d0e1428 feat: Phase 1 foundation — status aggregator, API scopes, onboarding, template magic vars
Four foundational enhancements toward the deploy-code developer experience:

- Container status aggregator (C7): new ContainerStatusService applies a
  deterministic priority hierarchy (degraded > restarting > running:unhealthy >
  starting > running:healthy > exited > unknown) over an app's containers,
  cached with a short TTL. Exposed at /api/v1/status and pushed over a new
  container_status Socket.IO channel; Applications page now renders an
  aggregated ContainerStatusPill with sub-status tooltip.

- API token scopes + sensitive data masking (C10): require_scope() decorator
  enforces fine-grained scopes for API-key requests (pass-through for JWT),
  a canonical SCOPES catalog at /api/v1/api-keys/scopes, and a reusable
  sensitive_data_filter (mask_sensitive) honoring secrets:read. Frontend gains
  a grouped ApiKeyScopesModal picker and per-key scope pills.

- Server onboarding state machine (C8): new onboarding lifecycle
  (pending -> validating -> installing_prerequisites -> installing_docker ->
  pairing_agent -> ready/failed) with ServerOnboardingService, a
  server_onboarding_logs table, servers.onboarding_* columns, REST endpoints,
  and an OnboardingWizard surfaced on the server detail overview.

- Declarative template catalog (C5): documented catalog schema and
  magic-variable resolution (SERVICE_PASSWORD_/USER_/FQDN_/URL_/BASE64_*)
  layered onto the existing YAML template loader without changing behavior for
  templates that don't use them; GET /api/v1/templates/catalog/schema.

Migration 029 adds the onboarding table + columns. 60 new tests pass; frontend
builds clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 18:23:47 -04:00

160 lines
4.5 KiB
Python

"""API Key management endpoints."""
from datetime import datetime
from flask import Blueprint, jsonify, request
from flask_jwt_extended import jwt_required, get_jwt_identity
from app.middleware.rbac import get_current_user
from app.middleware.api_scope_middleware import SCOPES, require_scope
from app.services.api_key_service import ApiKeyService
from app.services.audit_service import AuditService
from app.models.audit_log import AuditLog
api_keys_bp = Blueprint('api_keys', __name__)
@api_keys_bp.route('/scopes', methods=['GET'])
@jwt_required()
def list_scopes():
"""Return the canonical catalog of assignable API key scopes."""
return jsonify({'scopes': SCOPES})
@api_keys_bp.route('/', methods=['GET'])
@jwt_required()
@require_scope('read')
def list_keys():
"""List the current user's API keys."""
user = get_current_user()
if not user or not user.is_developer:
return jsonify({'error': 'Developer access required'}), 403
keys = ApiKeyService.list_keys(user.id)
return jsonify({'api_keys': [k.to_dict() for k in keys]})
@api_keys_bp.route('/', methods=['POST'])
@jwt_required()
def create_key():
"""Create a new API key."""
user = get_current_user()
if not user or not user.is_developer:
return jsonify({'error': 'Developer access required'}), 403
data = request.get_json() or {}
name = data.get('name')
if not name:
return jsonify({'error': 'Name is required'}), 400
scopes = data.get('scopes')
tier = data.get('tier', 'standard')
expires_at = None
if data.get('expires_at'):
try:
expires_at = datetime.fromisoformat(data['expires_at'].replace('Z', '+00:00'))
except (ValueError, AttributeError):
return jsonify({'error': 'Invalid expires_at format'}), 400
api_key, raw_key = ApiKeyService.create_key(
user_id=user.id,
name=name,
scopes=scopes,
tier=tier,
expires_at=expires_at,
)
AuditService.log(
AuditLog.ACTION_API_KEY_CREATE,
user_id=user.id,
target_type='api_key',
target_id=api_key.id,
details={'name': name, 'tier': tier}
)
result = api_key.to_dict()
result['raw_key'] = raw_key # Only exposed once at creation
return jsonify(result), 201
@api_keys_bp.route('/<int:key_id>', methods=['GET'])
@jwt_required()
def get_key(key_id):
"""Get API key details."""
user = get_current_user()
if not user:
return jsonify({'error': 'User not found'}), 404
api_key = ApiKeyService.get_key(key_id, user.id)
if not api_key:
return jsonify({'error': 'API key not found'}), 404
return jsonify(api_key.to_dict())
@api_keys_bp.route('/<int:key_id>', methods=['PUT'])
@jwt_required()
def update_key(key_id):
"""Update API key metadata."""
user = get_current_user()
if not user:
return jsonify({'error': 'User not found'}), 404
data = request.get_json() or {}
api_key = ApiKeyService.update_key(
key_id=key_id,
user_id=user.id,
name=data.get('name'),
scopes=data.get('scopes'),
tier=data.get('tier'),
)
if not api_key:
return jsonify({'error': 'API key not found'}), 404
return jsonify(api_key.to_dict())
@api_keys_bp.route('/<int:key_id>', methods=['DELETE'])
@jwt_required()
def revoke_key(key_id):
"""Revoke an API key."""
user = get_current_user()
if not user:
return jsonify({'error': 'User not found'}), 404
api_key = ApiKeyService.revoke_key(key_id, user.id)
if not api_key:
return jsonify({'error': 'API key not found'}), 404
AuditService.log(
AuditLog.ACTION_API_KEY_REVOKE,
user_id=user.id,
target_type='api_key',
target_id=key_id,
details={'name': api_key.name}
)
return jsonify({'message': 'API key revoked'})
@api_keys_bp.route('/<int:key_id>/rotate', methods=['POST'])
@jwt_required()
def rotate_key(key_id):
"""Rotate an API key (revoke + recreate with same config)."""
user = get_current_user()
if not user:
return jsonify({'error': 'User not found'}), 404
new_key, raw_key = ApiKeyService.rotate_key(key_id, user.id)
if not new_key:
return jsonify({'error': 'API key not found'}), 404
AuditService.log(
AuditLog.ACTION_API_KEY_ROTATE,
user_id=user.id,
target_type='api_key',
target_id=new_key.id,
details={'old_key_id': key_id, 'name': new_key.name}
)
result = new_key.to_dict()
result['raw_key'] = raw_key
return jsonify(result)