Files
ServerKit/backend/app/api/workspaces.py
T
Juan DenisandClaude Opus 5 fe02ce8eeb fix(auth): resolve the caller through one identity door, not the raw JWT
Plan 76 milestone A, drift/bug row 2 — scheduled as a fix, not a refactor,
because it is a behaviour difference shipping today.

rbac.get_current_user() is the only lookup that knows about both auth
mechanisms: it returns g.api_key_user for an X-API-Key request and falls back
to the JWT. 73 call sites answered "who is calling" with
`User.query.get(get_jwt_identity())` instead, behind 19 near-identical private
helpers (12 named get_current_user(), shadowing the real door).

The audit that scheduled this row predicted those return None for API-key
callers. Measured, it is worse: auth_required() deliberately skips
verify_jwt_in_request() once the key middleware has authenticated, so there is
no JWT context and get_jwt_identity() *raises*. Nine handlers reached through
an API-key-capable policy decorator therefore returned 500 to every API-key
caller:

  bandwidth.get_app_bandwidth          @viewer_required
  servers.set_server_workspace         @developer_required
  servers.create_server                @developer_required
  sso.get_sso_config / update_provider_config / update_general_settings
  source_connections.update_{github,gitlab,bitbucket}_admin_config
  source_connections.complete_github_app_manifest
  databases.execute_{mysql,pg,sqlite,docker}_query   (see below)

Three service helpers had the same lookup inside a blanket `except Exception`,
which converted the raise into a silent user_id=None — the write landed and
the audit trail lost the actor (shared_resource_service, cf_ops_change_service,
secrets_webhooks).

The four databases.execute_*_query sites were a second admin check *inside* an
already-@admin_required route: dead code that always passed for JWT callers and
crashed for API-key ones. They now call require_admin_user() rather than
re-deriving the answer.

Also folds in a sixth _require_admin() found in the cloudflare-ops extension
source (29 gates, the same inverted contract as jobs.py), and fixes
serverkit-gui's blueprint at its sibling-repo source rather than in this repo's
live copy.

Ratchet (second-wave rule 1 — a migration without one regrows):
tests/identity_door_census.py counts both bypass shapes by AST, direct and the
two-line `uid = get_jwt_identity(); User.query.get(uid)` variant. The ceiling is
87, all of them on @jwt_required()-only routes where reading the JWT is correct
today; the ratchet's third assertion holds the API-key-capable population at
exactly zero, so migrating one of those routes to a policy decorator without
fixing its lookup now fails. Verified non-vacuous: an injected bypass fails both
assertions and passes again on revert.

The first wave's controller-boundary baseline is regenerated — this removes 74
persistence crossings (593 -> 519), a debt reduction with no new entries.

Gate: tests/test_api_key_identity.py (7, each red before the fix),
test_identity_door_ratchet.py (10), test_api_controller_boundaries.py (4), plus
the 65 test files covering every touched module: 1024 passed, 10 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 19:33:57 -04:00

251 lines
9.0 KiB
Python

from flask import Blueprint, request, jsonify
from flask_jwt_extended import jwt_required
from app.services.workspace_service import WorkspaceService
from app.services.audit_service import AuditService
from app.models.audit_log import AuditLog
# The workspace guards now live in middleware/rbac.py so any resource blueprint
# can reuse them (plan 19 Decision 2). Re-exported here for existing callers.
from app.middleware.rbac import get_current_user, require_workspace_access, require_workspace_role
workspaces_bp = Blueprint('workspaces', __name__)
@workspaces_bp.route('/', methods=['GET'])
@jwt_required()
def list_workspaces():
user = get_current_user()
include_archived = request.args.get('include_archived', 'false') == 'true'
# Super-admins see all, others see their memberships
if user.is_admin and request.args.get('all') == 'true':
workspaces = WorkspaceService.get_all_workspaces_admin()
return jsonify({'workspaces': workspaces})
workspaces = WorkspaceService.list_workspaces(user_id=user.id, include_archived=include_archived)
# Surface the caller's membership role and the reconciled effective role (#33)
# so the frontend can show read-only state when a workspace caps the user to
# viewer. effective_role is narrow-only: it never exceeds the user's global role.
out = []
for ws in workspaces:
d = ws.to_dict()
d['my_role'] = WorkspaceService.get_user_role(ws.id, user.id)
d['my_effective_role'] = WorkspaceService.effective_role(user, ws.id)
out.append(d)
return jsonify({'workspaces': out})
@workspaces_bp.route('/<int:workspace_id>', methods=['GET'])
@jwt_required()
def get_workspace(workspace_id):
user = get_current_user()
denied = require_workspace_access(workspace_id, user)
if denied:
return denied
ws = WorkspaceService.get_workspace(workspace_id)
if not ws:
return jsonify({'error': 'Workspace not found'}), 404
d = ws.to_dict()
d['my_role'] = WorkspaceService.get_user_role(ws.id, user.id)
d['my_effective_role'] = WorkspaceService.effective_role(user, ws.id)
return jsonify(d)
@workspaces_bp.route('/', methods=['POST'])
@jwt_required()
def create_workspace():
user = get_current_user()
data = request.get_json()
if not data:
return jsonify({'error': 'Request body required'}), 400
try:
ws = WorkspaceService.create_workspace(data, user.id)
AuditService.log(
action=AuditLog.ACTION_RESOURCE_CREATE, user_id=user.id,
target_type='workspace', target_id=ws.id,
details={'name': ws.name}
)
return jsonify(ws.to_dict()), 201
except ValueError as e:
return jsonify({'error': str(e)}), 400
@workspaces_bp.route('/<int:workspace_id>', methods=['PUT'])
@jwt_required()
def update_workspace(workspace_id):
user = get_current_user()
role = WorkspaceService.get_user_role(workspace_id, user.id)
if role not in ['owner', 'admin'] and not user.is_admin:
return jsonify({'error': 'Insufficient permissions'}), 403
data = request.get_json()
ws = WorkspaceService.update_workspace(workspace_id, data)
if not ws:
return jsonify({'error': 'Workspace not found'}), 404
return jsonify(ws.to_dict())
@workspaces_bp.route('/<int:workspace_id>/archive', methods=['POST'])
@jwt_required()
def archive_workspace(workspace_id):
user = get_current_user()
role = WorkspaceService.get_user_role(workspace_id, user.id)
if role != 'owner' and not user.is_admin:
return jsonify({'error': 'Owner access required'}), 403
ws = WorkspaceService.archive_workspace(workspace_id)
if not ws:
return jsonify({'error': 'Workspace not found'}), 404
return jsonify(ws.to_dict())
@workspaces_bp.route('/<int:workspace_id>/restore', methods=['POST'])
@jwt_required()
def restore_workspace(workspace_id):
user = get_current_user()
if not user.is_admin:
return jsonify({'error': 'Admin access required'}), 403
ws = WorkspaceService.restore_workspace(workspace_id)
if not ws:
return jsonify({'error': 'Workspace not found'}), 404
return jsonify(ws.to_dict())
@workspaces_bp.route('/<int:workspace_id>', methods=['DELETE'])
@jwt_required()
def delete_workspace(workspace_id):
user = get_current_user()
if not user.is_admin:
return jsonify({'error': 'Admin access required'}), 403
result = WorkspaceService.delete_workspace(workspace_id)
if not result:
return jsonify({'error': 'Workspace not found'}), 404
return jsonify({'message': 'Workspace deleted'})
# --- Members ---
@workspaces_bp.route('/<int:workspace_id>/members', methods=['GET'])
@jwt_required()
def get_members(workspace_id):
user = get_current_user()
denied = require_workspace_access(workspace_id, user)
if denied:
return denied
members = WorkspaceService.get_members(workspace_id)
return jsonify({'members': [m.to_dict() for m in members]})
@workspaces_bp.route('/<int:workspace_id>/members', methods=['POST'])
@jwt_required()
def add_member(workspace_id):
user = get_current_user()
role = WorkspaceService.get_user_role(workspace_id, user.id)
if role not in ['owner', 'admin'] and not user.is_admin:
return jsonify({'error': 'Insufficient permissions'}), 403
data = request.get_json() or {}
user_id = data.get('user_id')
if not user_id:
return jsonify({'error': 'user_id required'}), 400
try:
member = WorkspaceService.add_member(workspace_id, user_id, data.get('role', 'member'))
return jsonify(member.to_dict()), 201
except ValueError as e:
return jsonify({'error': str(e)}), 400
@workspaces_bp.route('/members/<int:member_id>/role', methods=['PUT'])
@jwt_required()
def update_member_role(member_id):
user = get_current_user()
member = WorkspaceService.get_member(member_id)
if not member:
return jsonify({'error': 'Member not found'}), 404
denied = require_workspace_role(member.workspace_id, user, ['owner', 'admin'])
if denied:
return denied
data = request.get_json() or {}
role = data.get('role')
if not role:
return jsonify({'error': 'role required'}), 400
member = WorkspaceService.update_member_role(member_id, role)
if not member:
return jsonify({'error': 'Member not found'}), 404
return jsonify(member.to_dict())
@workspaces_bp.route('/members/<int:member_id>', methods=['DELETE'])
@jwt_required()
def remove_member(member_id):
user = get_current_user()
member = WorkspaceService.get_member(member_id)
if not member:
return jsonify({'error': 'Member not found'}), 404
denied = require_workspace_role(member.workspace_id, user, ['owner', 'admin'])
if denied:
return denied
try:
result = WorkspaceService.remove_member(member_id)
if not result:
return jsonify({'error': 'Member not found'}), 404
return jsonify({'message': 'Member removed'})
except ValueError as e:
return jsonify({'error': str(e)}), 400
# --- API Keys ---
@workspaces_bp.route('/<int:workspace_id>/api-keys', methods=['GET'])
@jwt_required()
def list_api_keys(workspace_id):
user = get_current_user()
denied = require_workspace_access(workspace_id, user)
if denied:
return denied
keys = WorkspaceService.list_api_keys(workspace_id)
return jsonify({'api_keys': [k.to_dict() for k in keys]})
@workspaces_bp.route('/<int:workspace_id>/api-keys', methods=['POST'])
@jwt_required()
def create_api_key(workspace_id):
user = get_current_user()
denied = require_workspace_role(workspace_id, user, ['owner', 'admin'])
if denied:
return denied
data = request.get_json() or {}
name = data.get('name')
if not name:
return jsonify({'error': 'name required'}), 400
api_key, raw_key = WorkspaceService.create_api_key(
workspace_id, name, scopes=data.get('scopes'), user_id=user.id
)
AuditService.log(
action=AuditLog.ACTION_RESOURCE_CREATE, user_id=user.id,
target_type='workspace_api_key', target_id=api_key.id,
details={'workspace_id': workspace_id, 'name': name}
)
result = api_key.to_dict()
result['key'] = raw_key # Only returned once
return jsonify(result), 201
@workspaces_bp.route('/api-keys/<int:key_id>/revoke', methods=['POST'])
@jwt_required()
def revoke_api_key(key_id):
user = get_current_user()
api_key = WorkspaceService.get_api_key(key_id)
if not api_key:
return jsonify({'error': 'API key not found'}), 404
denied = require_workspace_role(api_key.workspace_id, user, ['owner', 'admin'])
if denied:
return denied
result = WorkspaceService.revoke_api_key(key_id)
if not result:
return jsonify({'error': 'API key not found'}), 404
AuditService.log(
action=AuditLog.ACTION_RESOURCE_DELETE, user_id=user.id,
target_type='workspace_api_key', target_id=key_id,
details={'workspace_id': api_key.workspace_id}
)
return jsonify({'message': 'API key revoked'})