mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-11-03 02:26:43 +09:00
GHSA-r4q7-x795-vr4j was one forgotten check, but the audit behind it found the same shape across the surface: `@jwt_required()` authenticates, it does not authorize, and ServerKit has no default-deny to catch the difference. This closes the routes that audit left open — fleet-monitor alert acknowledge/resolve, recycle-bin restore, nginx test/diff, registrar sync/test, server-template drift checks, uptime tracking on/off, server ping and remote compose ps/logs, refresh-capabilities, template test-db-connection, deploy branches, buildpack detect — plus three inline gaps: a viewer could widen their own API key's scopes, the demo-deploy flag was doing double duty as an authorization one, and tagging a shared resource had no write check at all while attaching one did. `_ensure_group_mutable` only ever blocked *system* queue groups, so every queue_bus mutation now also calls `_ensure_group_accessible` (owner-or-admin) — renaming, draining or deleting another user's group was open to any authenticated caller. The rbac policy decorators authenticate through `auth_required()`, so they replace `@jwt_required()` rather than stack under it; stacking rejects API-key callers before the policy can authorize them (tests/test_auth_decorator_boundaries.py enforces this). Where a route then needed the caller it reads `get_current_user()` — `get_jwt_identity()` raises for an API-key request. The decorators also carry a `_sk_authz` marker so a static test can see the decision from the source. `migrations.get_migration_history` moves to `require_admin_user()` for the same reason its siblings did: the inline `User.ROLE_ADMIN` check it kept was already reading two names this module no longer imports, so the route raised NameError for every caller, admin included. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
162 lines
4.7 KiB
Python
162 lines
4.7 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."""
|
|
# Same bar as create_key: a viewer (e.g. demoted after key creation) must
|
|
# not widen an existing key's scopes/tier.
|
|
user = get_current_user()
|
|
if not user or not user.is_developer:
|
|
return jsonify({'error': 'Developer access required'}), 403
|
|
|
|
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)
|