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>
489 lines
18 KiB
Python
489 lines
18 KiB
Python
"""Polymorphic shared resources API — tags + shared variable groups.
|
|
|
|
Mounted at ``/api/v1/shared``. Everything here is a JWT-protected facade over
|
|
:class:`~app.services.shared_resource_service.SharedResourceService`. Secret
|
|
variable values are always masked in responses via ``to_dict(mask_secrets=True)``
|
|
plus a defense-in-depth pass through :func:`mask_sensitive`.
|
|
"""
|
|
from flask import Blueprint, jsonify, request
|
|
from flask_jwt_extended import jwt_required
|
|
|
|
from app.middleware.rbac import (
|
|
get_current_user, require_workspace_access, require_workspace_role,
|
|
)
|
|
from app.services.resource_grant_service import ResourceGrantService
|
|
from app.services.shared_resource_service import SharedResourceService
|
|
from app.models.shared_resource import SharedVariableGroup
|
|
from app.utils.sensitive_data_filter import mask_sensitive
|
|
|
|
shared_resources_bp = Blueprint('shared_resources', __name__)
|
|
|
|
# Workspace roles allowed to mutate shared variable groups and attachments.
|
|
_WRITE_ROLES = ['owner', 'admin', 'member']
|
|
|
|
|
|
def _bad(msg, code=400):
|
|
return jsonify({'error': msg}), code
|
|
|
|
|
|
def _require(data, *keys):
|
|
"""Return the first missing key name, or None if all present/truthy."""
|
|
for k in keys:
|
|
if data.get(k) in (None, ''):
|
|
return k
|
|
return None
|
|
|
|
|
|
# ------------------------------------------------------------ authorization
|
|
|
|
def _scope_workspace_id(scope_type, scope_id):
|
|
"""Resolve a group scope to its owning workspace id, or None.
|
|
|
|
Hierarchy: Workspace ← Project ← Environment. A dangling or malformed
|
|
scope returns None so callers can deny without leaking what exists.
|
|
"""
|
|
from app.models.workspace import Workspace
|
|
from app.models.project import Project
|
|
from app.models.environment import Environment
|
|
try:
|
|
sid = int(scope_id)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
if scope_type == SharedVariableGroup.SCOPE_WORKSPACE:
|
|
ws = Workspace.query.get(sid)
|
|
return ws.id if ws else None
|
|
if scope_type == SharedVariableGroup.SCOPE_PROJECT:
|
|
project = Project.query.get(sid)
|
|
return project.workspace_id if project else None
|
|
if scope_type == SharedVariableGroup.SCOPE_ENVIRONMENT:
|
|
env = Environment.query.get(sid)
|
|
if not env:
|
|
return None
|
|
project = Project.query.get(env.project_id)
|
|
return project.workspace_id if project else None
|
|
return None
|
|
|
|
|
|
def _resolve_app(resource_type, resource_id):
|
|
"""Resolve an app-backed resource (application, wordpress) to its
|
|
Application, or None."""
|
|
from app.models.application import Application
|
|
from app.models.wordpress_site import WordPressSite
|
|
try:
|
|
rid = int(resource_id)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
if resource_type == 'application':
|
|
return Application.query_active().filter_by(id=rid).first()
|
|
if resource_type == 'wordpress':
|
|
site = WordPressSite.query.get(rid)
|
|
return (Application.query_active().filter_by(id=site.application_id).first()
|
|
if site else None)
|
|
return None
|
|
|
|
|
|
def _check_scope(scope_type, scope_id, write=False):
|
|
"""Authorize the current user against a variable-group scope.
|
|
|
|
Returns None when allowed, else a ``(response, status)`` tuple. Panel
|
|
admins bypass. 'application' scopes gate on can_access_app / can_edit_app;
|
|
workspace/project/environment scopes resolve to the owning workspace and
|
|
gate on membership (read) or write roles (mutation).
|
|
"""
|
|
user = get_current_user()
|
|
if user is None:
|
|
return _bad('authentication required', 401)
|
|
if user.is_admin:
|
|
return None
|
|
if scope_type == 'application':
|
|
application = _resolve_app('application', scope_id)
|
|
if application is None:
|
|
return _bad('scope not found', 404)
|
|
allowed = (ResourceGrantService.can_edit_app(user, application) if write
|
|
else ResourceGrantService.can_access_app(user, application))
|
|
return None if allowed else _bad('access denied', 403)
|
|
workspace_id = _scope_workspace_id(scope_type, scope_id)
|
|
if workspace_id is None:
|
|
return _bad('scope not found', 404)
|
|
if write:
|
|
return require_workspace_role(workspace_id, user, _WRITE_ROLES)
|
|
return require_workspace_access(workspace_id, user)
|
|
|
|
|
|
def _get_scoped_group(group_id, write=False):
|
|
"""Fetch a group and authorize the caller against its scope.
|
|
|
|
Returns ``(group, None)`` on success or ``(None, response)`` to return.
|
|
"""
|
|
group = SharedResourceService.get_group(group_id)
|
|
if not group:
|
|
return None, _bad('group not found', 404)
|
|
denied = _check_scope(group.scope_type, group.scope_id, write=write)
|
|
if denied:
|
|
return None, denied
|
|
return group, None
|
|
|
|
|
|
def _check_resource_write(resource_type, resource_id):
|
|
"""Authorize attaching/detaching a group to a target resource.
|
|
|
|
Attachments inject the group's variables into the target at deploy time,
|
|
so this fails closed: app-backed targets (application, wordpress) require
|
|
operate (member+) access; workspace-owned targets (database, server)
|
|
require a write role in their workspace; types with no ownership seam
|
|
('service', ...) are admin-only.
|
|
"""
|
|
user = get_current_user()
|
|
if user is None:
|
|
return _bad('authentication required', 401)
|
|
if user.is_admin:
|
|
return None
|
|
if resource_type in ('application', 'wordpress'):
|
|
application = _resolve_app(resource_type, resource_id)
|
|
if application is None:
|
|
return _bad('resource not found', 404)
|
|
if not ResourceGrantService.can_operate_app(user, application):
|
|
return _bad('access denied', 403)
|
|
return None
|
|
if resource_type == 'database':
|
|
from app.models.managed_database import ManagedDatabase
|
|
try:
|
|
row = ManagedDatabase.query_active().filter_by(id=int(resource_id)).first()
|
|
except (TypeError, ValueError):
|
|
row = None
|
|
if row is None:
|
|
return _bad('resource not found', 404)
|
|
if row.workspace_id is None:
|
|
return _bad('access denied', 403)
|
|
return require_workspace_role(row.workspace_id, user, _WRITE_ROLES)
|
|
if resource_type == 'server':
|
|
from app.models.server import Server
|
|
server = Server.query.get(str(resource_id))
|
|
if server is None:
|
|
return _bad('resource not found', 404)
|
|
if server.workspace_id is None:
|
|
return _bad('access denied', 403)
|
|
return require_workspace_role(server.workspace_id, user, _WRITE_ROLES)
|
|
return _bad('access denied', 403)
|
|
|
|
|
|
# ---------------------------------------------------------------- metadata
|
|
|
|
@shared_resources_bp.route('/resource-types', methods=['GET'])
|
|
@jwt_required()
|
|
def resource_types():
|
|
"""The catalog of supported polymorphic resource types."""
|
|
return jsonify({'resource_types': list(SharedResourceService.RESOURCE_TYPES)})
|
|
|
|
|
|
# -------------------------------------------------------------------- tags
|
|
|
|
@shared_resources_bp.route('/tags', methods=['GET'])
|
|
@jwt_required()
|
|
def list_tags():
|
|
"""List tags for a resource, or resources for a tag.
|
|
|
|
``?resource_type=&resource_id=`` → tags on that resource.
|
|
``?tag=`` (optionally with ``resource_type``) → resources carrying the tag.
|
|
"""
|
|
tag = request.args.get('tag')
|
|
resource_type = request.args.get('resource_type')
|
|
resource_id = request.args.get('resource_id')
|
|
|
|
if tag:
|
|
rows = SharedResourceService.list_resources_by_tag(tag, resource_type)
|
|
return jsonify({'resources': [r.to_dict() for r in rows]})
|
|
|
|
if not resource_type or resource_id in (None, ''):
|
|
return _bad('resource_type and resource_id (or tag) are required')
|
|
|
|
rows = SharedResourceService.list_tags(resource_type, resource_id)
|
|
return jsonify({'tags': [r.to_dict() for r in rows]})
|
|
|
|
|
|
@shared_resources_bp.route('/tags', methods=['POST'])
|
|
@jwt_required()
|
|
def add_tag():
|
|
data = request.get_json() or {}
|
|
missing = _require(data, 'resource_type', 'resource_id', 'tag')
|
|
if missing:
|
|
return _bad(f'{missing} is required')
|
|
denied = _check_resource_write(data['resource_type'], data['resource_id'])
|
|
if denied:
|
|
return denied
|
|
try:
|
|
row = SharedResourceService.add_tag(
|
|
data['resource_type'], data['resource_id'], data['tag']
|
|
)
|
|
except ValueError as e:
|
|
return _bad(str(e))
|
|
return jsonify(row.to_dict()), 201
|
|
|
|
|
|
@shared_resources_bp.route('/tags', methods=['DELETE'])
|
|
@jwt_required()
|
|
def remove_tag():
|
|
# Accept either body or query params for convenience.
|
|
data = request.get_json(silent=True) or {}
|
|
resource_type = data.get('resource_type') or request.args.get('resource_type')
|
|
resource_id = data.get('resource_id')
|
|
if resource_id is None:
|
|
resource_id = request.args.get('resource_id')
|
|
tag = data.get('tag') or request.args.get('tag')
|
|
|
|
if not resource_type or resource_id in (None, '') or not tag:
|
|
return _bad('resource_type, resource_id and tag are required')
|
|
|
|
denied = _check_resource_write(resource_type, resource_id)
|
|
if denied:
|
|
return denied
|
|
|
|
removed = SharedResourceService.remove_tag(resource_type, resource_id, tag)
|
|
return jsonify({'removed': removed})
|
|
|
|
|
|
# --------------------------------------------------------- variable groups
|
|
|
|
@shared_resources_bp.route('/variable-groups', methods=['GET'])
|
|
@jwt_required()
|
|
def list_groups():
|
|
scope_type = request.args.get('scope_type')
|
|
scope_id = request.args.get('scope_id')
|
|
if scope_type and scope_id not in (None, ''):
|
|
denied = _check_scope(scope_type, scope_id)
|
|
if denied:
|
|
return denied
|
|
else:
|
|
# An unscoped listing spans every workspace — panel admins only.
|
|
user = get_current_user()
|
|
if user is None or not user.is_admin:
|
|
return _bad('admin access required', 403)
|
|
groups = SharedResourceService.list_groups(
|
|
scope_type=scope_type,
|
|
scope_id=scope_id,
|
|
)
|
|
return jsonify({'groups': [g.to_dict() for g in groups]})
|
|
|
|
|
|
@shared_resources_bp.route('/variable-groups', methods=['POST'])
|
|
@jwt_required()
|
|
def create_group():
|
|
data = request.get_json() or {}
|
|
missing = _require(data, 'scope_type', 'scope_id', 'name')
|
|
if missing:
|
|
return _bad(f'{missing} is required')
|
|
denied = _check_scope(data['scope_type'], data['scope_id'], write=True)
|
|
if denied:
|
|
return denied
|
|
try:
|
|
group = SharedResourceService.create_group(
|
|
data['scope_type'], data['scope_id'], data['name'],
|
|
data.get('description'),
|
|
)
|
|
except ValueError as e:
|
|
return _bad(str(e))
|
|
return jsonify(group.to_dict(include_variables=True)), 201
|
|
|
|
|
|
@shared_resources_bp.route('/variable-groups/<int:group_id>', methods=['GET'])
|
|
@jwt_required()
|
|
def get_group(group_id):
|
|
group, denied = _get_scoped_group(group_id)
|
|
if denied:
|
|
return denied
|
|
payload = group.to_dict(include_variables=True, mask_secrets=True)
|
|
payload['attachments'] = [a.to_dict() for a in group.attachments]
|
|
return jsonify(mask_sensitive(payload))
|
|
|
|
|
|
@shared_resources_bp.route('/variable-groups/<int:group_id>', methods=['PUT'])
|
|
@jwt_required()
|
|
def update_group(group_id):
|
|
group, denied = _get_scoped_group(group_id, write=True)
|
|
if denied:
|
|
return denied
|
|
data = request.get_json() or {}
|
|
try:
|
|
group = SharedResourceService.update_group(
|
|
group.id, name=data.get('name'), description=data.get('description')
|
|
)
|
|
except ValueError as e:
|
|
return _bad(str(e))
|
|
if not group:
|
|
return _bad('group not found', 404)
|
|
return jsonify(group.to_dict(include_variables=True))
|
|
|
|
|
|
@shared_resources_bp.route('/variable-groups/<int:group_id>', methods=['DELETE'])
|
|
@jwt_required()
|
|
def delete_group(group_id):
|
|
group, denied = _get_scoped_group(group_id, write=True)
|
|
if denied:
|
|
return denied
|
|
if not SharedResourceService.delete_group(group.id):
|
|
return _bad('group not found', 404)
|
|
return jsonify({'message': 'group deleted'})
|
|
|
|
|
|
# --------------------------------------------- variables within a group
|
|
|
|
@shared_resources_bp.route('/variable-groups/<int:group_id>/variables', methods=['POST'])
|
|
@jwt_required()
|
|
def add_variable(group_id):
|
|
group, denied = _get_scoped_group(group_id, write=True)
|
|
if denied:
|
|
return denied
|
|
data = request.get_json() or {}
|
|
if data.get('key') in (None, ''):
|
|
return _bad('key is required')
|
|
try:
|
|
var = SharedResourceService.set_variable(
|
|
group.id, data['key'], data.get('value', ''),
|
|
is_secret=bool(data.get('is_secret', False)),
|
|
target_service=data.get('target_service') if 'target_service' in data else None,
|
|
)
|
|
except ValueError as e:
|
|
return _bad(str(e))
|
|
if not var:
|
|
return _bad('group not found', 404)
|
|
return jsonify(var.to_dict(mask_secrets=True)), 201
|
|
|
|
|
|
@shared_resources_bp.route(
|
|
'/variable-groups/<int:group_id>/variables/<int:variable_id>', methods=['PUT'])
|
|
@jwt_required()
|
|
def update_variable(group_id, variable_id):
|
|
group, denied = _get_scoped_group(group_id, write=True)
|
|
if denied:
|
|
return denied
|
|
from app.models.shared_resource import SharedVariable
|
|
existing = SharedVariable.query.get(variable_id)
|
|
if not existing or existing.group_id != group.id:
|
|
return _bad('variable not found', 404)
|
|
data = request.get_json() or {}
|
|
_kwargs = {'value': data.get('value'), 'is_secret': data.get('is_secret')}
|
|
if 'target_service' in data:
|
|
# Only forward when the client sent it, so it's preserved otherwise.
|
|
_kwargs['target_service'] = data.get('target_service')
|
|
var = SharedResourceService.update_variable(variable_id, **_kwargs)
|
|
if not var:
|
|
return _bad('variable not found', 404)
|
|
return jsonify(var.to_dict(mask_secrets=True))
|
|
|
|
|
|
@shared_resources_bp.route(
|
|
'/variable-groups/<int:group_id>/variables/<int:variable_id>', methods=['DELETE'])
|
|
@jwt_required()
|
|
def delete_variable(group_id, variable_id):
|
|
group, denied = _get_scoped_group(group_id, write=True)
|
|
if denied:
|
|
return denied
|
|
from app.models.shared_resource import SharedVariable
|
|
var = SharedVariable.query.get(variable_id)
|
|
if not var or var.group_id != group.id:
|
|
return _bad('variable not found', 404)
|
|
SharedResourceService.delete_variable(variable_id)
|
|
return jsonify({'message': 'variable deleted'})
|
|
|
|
|
|
# --------------------------------------------------------------- attach
|
|
|
|
@shared_resources_bp.route('/variable-groups/<int:group_id>/attach', methods=['POST'])
|
|
@jwt_required()
|
|
def attach_group(group_id):
|
|
data = request.get_json() or {}
|
|
missing = _require(data, 'resource_type', 'resource_id')
|
|
if missing:
|
|
return _bad(f'{missing} is required')
|
|
group, denied = _get_scoped_group(group_id, write=True)
|
|
if denied:
|
|
return denied
|
|
denied = _check_resource_write(data['resource_type'], data['resource_id'])
|
|
if denied:
|
|
return denied
|
|
att = SharedResourceService.attach_group(
|
|
group.id, data['resource_type'], data['resource_id']
|
|
)
|
|
if not att:
|
|
return _bad('group not found', 404)
|
|
return jsonify(att.to_dict()), 201
|
|
|
|
|
|
@shared_resources_bp.route('/variable-groups/<int:group_id>/detach', methods=['POST'])
|
|
@jwt_required()
|
|
def detach_group(group_id):
|
|
data = request.get_json() or {}
|
|
missing = _require(data, 'resource_type', 'resource_id')
|
|
if missing:
|
|
return _bad(f'{missing} is required')
|
|
group, denied = _get_scoped_group(group_id, write=True)
|
|
if denied:
|
|
return denied
|
|
denied = _check_resource_write(data['resource_type'], data['resource_id'])
|
|
if denied:
|
|
return denied
|
|
removed = SharedResourceService.detach_group(
|
|
group.id, data['resource_type'], data['resource_id']
|
|
)
|
|
return jsonify({'detached': removed})
|
|
|
|
|
|
# -------------------------------------------------------------- resolved
|
|
|
|
@shared_resources_bp.route('/resolved', methods=['GET'])
|
|
@jwt_required()
|
|
def resolved():
|
|
"""Effective merged variables for a resource (secrets masked)."""
|
|
resource_type = request.args.get('resource_type')
|
|
resource_id = request.args.get('resource_id')
|
|
if not resource_type or resource_id in (None, ''):
|
|
return _bad('resource_type and resource_id are required')
|
|
|
|
variables = SharedResourceService.resolve_variables(
|
|
resource_type, resource_id, mask_secrets=True
|
|
)
|
|
groups = SharedResourceService.list_attached_groups(resource_type, resource_id)
|
|
payload = {
|
|
'resource_type': resource_type,
|
|
'resource_id': str(resource_id),
|
|
'variables': variables,
|
|
'groups': [g.to_dict() for g in groups],
|
|
}
|
|
return jsonify(mask_sensitive(payload))
|
|
|
|
|
|
@shared_resources_bp.route('/resolved/hierarchical', methods=['GET'])
|
|
@jwt_required()
|
|
def resolved_hierarchical():
|
|
"""Hierarchical effective variables for a resource (secrets masked).
|
|
|
|
Merges scope-inherited groups with the resource's directly-attached groups.
|
|
Precedence, lowest → highest:
|
|
|
|
workspace < project < environment < direct attachments
|
|
|
|
Each returned variable carries a ``source_scope`` provenance marker. Pass the
|
|
scope ids via ``?workspace_id=&project_id=&environment_id=``; any omitted
|
|
scope simply contributes no layer.
|
|
"""
|
|
resource_type = request.args.get('resource_type')
|
|
resource_id = request.args.get('resource_id')
|
|
if not resource_type or resource_id in (None, ''):
|
|
return _bad('resource_type and resource_id are required')
|
|
|
|
context = {
|
|
'workspace_id': request.args.get('workspace_id'),
|
|
'project_id': request.args.get('project_id'),
|
|
'environment_id': request.args.get('environment_id'),
|
|
}
|
|
variables = SharedResourceService.resolve_hierarchical(
|
|
resource_type, resource_id, context=context, mask_secrets=True
|
|
)
|
|
payload = {
|
|
'resource_type': resource_type,
|
|
'resource_id': str(resource_id),
|
|
'context': {k: v for k, v in context.items() if v not in (None, '')},
|
|
'variables': variables,
|
|
}
|
|
return jsonify(mask_sensitive(payload))
|