mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-11-09 05:24:49 +09:00
Plan 70. Domain went through this and TEN read paths were wrong, four with
effects outside the panel. Application has 211 call sites across 68 files and
drives more -- container lifecycles, nginx, backups, registry pulls, the OS
crontab -- so it was audited in full first (docs/plans/70_...md). 180 sites are
now query_active(); the 35 left unfiltered are each commented in place.
**The delete / purge split is what makes the tombstone honest.** delete_app used
to run compose_down(volumes=True) and rmtree(). Keeping a tombstone AND doing
that would put a Restore button next to a row whose data the delete had already
destroyed -- the one promise a recycle bin cannot break. So:
DELETE containers down (VOLUMES KEPT), vhost removed, firewall closed,
cron suspended, row tombstoned. All reversible.
RESTORE vhost rewritten, firewall reopened, cron resumed. Containers left
STOPPED -- "put it back" must not silently start processes or open
ports, the same reasoning that stops domain restore re-issuing certs.
PURGE volumes removed, uploaded source deleted, cron detached.
register() gained on_purge for that, wired into BOTH purge paths. And
purge_expired had NO scheduler -- fine when a tombstone was just a row, not fine
when it holds data volumes and a source tree, so a daily builtin job now runs
it. Without that, "kept until purge" would have meant kept forever and delete
would have quietly stopped reclaiming disk.
A side effect worth naming: the old _remove_data_flag protection for database
engines is now the DEFAULT for every app, and the flag is carried into
?purge=true so an explicit skip-the-bin delete still honours it.
**Cron was the blocker with no mechanical answer.** before_delete never fires on
a soft delete, and clear_application is LOSSY -- it nulls the association
without recording it, so nothing could put it back. suspend_for_application
instead disables only the jobs that were CURRENTLY ENABLED and stamps each
suspended_by_app; resume re-enables exactly those. That flag is the whole point:
without it a restore switches back on jobs the user deliberately turned off.
clear_application survives as purge-only, where lossiness is correct.
**The indirect reach (audit 3.1) is the class that grep cannot find.**
ContainerSleepPolicy, BackupPolicy, DeploymentJob and WafPolicy are enumerated
on a SCHEDULE and resolve application_id afterwards, so they never appear in a
search for Application.query. Left alone, a tombstoned app's sleep policy is
swept forever and wake_app restarts the containers the delete tore down. Those
loops test Application.deleted_ids().
Deliberately NOT scoped: reservation checks (port, name, directory) -- a
tombstone must keep holding those or a new app takes them and the restore is
refused; api/git.py's webhook VISIBILITY gate, which falls through to
"panel-wide visible" so hiding a tombstone would WIDEN access; and cron
attribution, which only labels a row.
Verified: 15 new tests, the migration replayed on a fresh DB (a tombstone frees
its private_slug, two live rows still collide), and a full delete -> restore
round trip. The 20 remaining full-suite failures are the known
serverkit-wordpress-not-installed class and are identical on a clean tree.
Two traps caught by testing, both recorded in the plan: a bad import made the
ENTIRE purge cleanup silently no-op while purge() still reported success
(_run_purge_hook swallows by design, so assert on the warning, not just ok), and
private_slug had to lose unique=True at the MODEL level or a models-built
database burns the slug regardless of the migration.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
482 lines
18 KiB
Python
482 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.get(int(resource_id))
|
|
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')
|
|
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')
|
|
|
|
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))
|