Files
ServerKit/backend/app/middleware/api_scope_middleware.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

118 lines
5.0 KiB
Python

"""API key scope enforcement middleware.
This complements RBAC: RBAC governs *who* the user is (role/permissions),
whereas API-key scopes govern *what a given key is allowed to do*. The two are
orthogonal — a key is always owned by a user, and the user's RBAC still applies
on top of the key's scopes.
The ``require_scope`` decorator is a **pass-through for JWT/session requests**:
only requests authenticated with an ``X-API-Key`` header (i.e. ``g.api_key`` is
set by :mod:`app.middleware.api_key_auth`) are scope-checked. JWT users are
governed by RBAC decorators instead. This lets the same endpoint be safely
shared between the web UI (JWT) and programmatic clients (API keys).
"""
from functools import wraps
from flask import g, jsonify
# ---------------------------------------------------------------------------
# Canonical scope catalog
# ---------------------------------------------------------------------------
# Each entry: {key, label, group, description}. ``'*'`` (full access) is handled
# implicitly by ApiKey.has_scope() and surfaced in the UI as a master toggle, so
# it is not listed as a row here — see FULL_ACCESS_SCOPE.
FULL_ACCESS_SCOPE = '*'
SCOPES = [
# Coarse-grained
{'key': 'read', 'label': 'Read (all)', 'group': 'General',
'description': 'Read-only access across all resources.'},
{'key': 'write', 'label': 'Write (all)', 'group': 'General',
'description': 'Create and modify access across all resources.'},
# Applications
{'key': 'apps:read', 'label': 'View applications', 'group': 'Applications',
'description': 'List and inspect managed applications.'},
{'key': 'apps:write', 'label': 'Manage applications', 'group': 'Applications',
'description': 'Create, update, start/stop, and delete applications.'},
{'key': 'apps:deploy', 'label': 'Deploy applications', 'group': 'Applications',
'description': 'Trigger builds and deployments.'},
# Databases
{'key': 'databases:read', 'label': 'View databases', 'group': 'Databases',
'description': 'List databases and inspect schemas.'},
{'key': 'databases:write', 'label': 'Manage databases', 'group': 'Databases',
'description': 'Create, alter, and drop databases and users.'},
# Domains
{'key': 'domains:read', 'label': 'View domains', 'group': 'Domains',
'description': 'List domains and SSL status.'},
{'key': 'domains:write', 'label': 'Manage domains', 'group': 'Domains',
'description': 'Attach domains and manage certificates.'},
# DNS
{'key': 'dns:read', 'label': 'View DNS records', 'group': 'DNS',
'description': 'List DNS zones and records.'},
{'key': 'dns:write', 'label': 'Manage DNS records', 'group': 'DNS',
'description': 'Create, update, and delete DNS records.'},
# Backups
{'key': 'backups:read', 'label': 'View backups', 'group': 'Backups',
'description': 'List backup policies and runs.'},
{'key': 'backups:write', 'label': 'Manage backups', 'group': 'Backups',
'description': 'Create policies, trigger backups, and restore.'},
# Servers
{'key': 'servers:read', 'label': 'View servers', 'group': 'Servers',
'description': 'List servers, agents, and metrics.'},
{'key': 'servers:admin', 'label': 'Administer servers', 'group': 'Servers',
'description': 'Run privileged server and agent operations.'},
# Secrets
{'key': 'secrets:read', 'label': 'Reveal secrets', 'group': 'Secrets',
'description': 'Read unmasked secret values in API responses.'},
]
# Set of valid catalog keys (excludes the implicit full-access scope).
SCOPE_KEYS = {entry['key'] for entry in SCOPES}
def require_scope(*scopes):
"""Enforce that an API-key request carries ALL of ``scopes``.
Semantics:
* If the request is **API-key authenticated** (``g.api_key`` is set), the
key must satisfy every listed scope via ``ApiKey.has_scope()`` (which
honors ``'*'`` full access and ``'resource:*'`` wildcards). The first
unsatisfied scope yields ``403``.
* If the request is **not** API-key authenticated (JWT/session user), the
decorator is a pass-through — those users are governed by RBAC.
This decorator does not itself require authentication; compose it with an
auth decorator (``@jwt_required()``, ``@auth_required()``, etc.) when a route
needs to be authenticated.
Usage::
@bp.route('/things')
@auth_required()
@require_scope('apps:read')
def list_things():
...
"""
def decorator(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
api_key = getattr(g, 'api_key', None)
if api_key is not None:
for scope in scopes:
if not api_key.has_scope(scope):
return jsonify({
'error': f'Insufficient API key scope: {scope}'
}), 403
# JWT/session requests pass through (governed by RBAC).
return fn(*args, **kwargs)
return wrapper
return decorator