mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-11-09 05:24:49 +09:00
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>
206 lines
6.9 KiB
Python
206 lines
6.9 KiB
Python
"""Tests for API-key scope enforcement and sensitive-data masking."""
|
|
import pytest
|
|
from flask import Blueprint, g, jsonify
|
|
|
|
from app.middleware.api_scope_middleware import require_scope, SCOPES, SCOPE_KEYS
|
|
from app.utils.sensitive_data_filter import (
|
|
mask_sensitive,
|
|
is_sensitive_key,
|
|
scopes_for_request,
|
|
MASK,
|
|
SENSITIVE_KEY_PATTERNS,
|
|
)
|
|
from app.models.api_key import ApiKey
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
def _key_with_scopes(scopes):
|
|
"""Build an unsaved ApiKey carrying the given scopes."""
|
|
k = ApiKey(user_id=1, name='test', key_prefix='sk_test', key_hash='x')
|
|
k.set_scopes(scopes)
|
|
return k
|
|
|
|
|
|
@pytest.fixture
|
|
def scoped_app(app):
|
|
"""Register a throwaway blueprint whose route requires 'apps:read'."""
|
|
bp = Blueprint('scope_probe', __name__)
|
|
|
|
@bp.route('/probe')
|
|
@require_scope('apps:read')
|
|
def probe():
|
|
return jsonify({'ok': True})
|
|
|
|
app.register_blueprint(bp, url_prefix='/__test')
|
|
return app
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# require_scope: API-key requests
|
|
# ---------------------------------------------------------------------------
|
|
def test_require_scope_blocks_key_missing_scope(scoped_app):
|
|
@scoped_app.route('/__probe_block')
|
|
@require_scope('apps:read')
|
|
def _block(): # pragma: no cover - exercised via client
|
|
return jsonify({'ok': True})
|
|
|
|
with scoped_app.test_request_context('/__probe_block'):
|
|
g.api_key = _key_with_scopes(['databases:read'])
|
|
rv = _block()
|
|
body, status = rv
|
|
assert status == 403
|
|
assert 'Insufficient API key scope' in body.get_json()['error']
|
|
|
|
|
|
def test_require_scope_allows_key_with_exact_scope(scoped_app):
|
|
@scoped_app.route('/__probe_exact')
|
|
@require_scope('apps:read')
|
|
def _exact():
|
|
return jsonify({'ok': True})
|
|
|
|
with scoped_app.test_request_context('/__probe_exact'):
|
|
g.api_key = _key_with_scopes(['apps:read'])
|
|
rv = _exact()
|
|
assert rv.get_json() == {'ok': True}
|
|
|
|
|
|
def test_require_scope_allows_full_access_star(scoped_app):
|
|
@scoped_app.route('/__probe_star')
|
|
@require_scope('apps:read', 'databases:write')
|
|
def _star():
|
|
return jsonify({'ok': True})
|
|
|
|
with scoped_app.test_request_context('/__probe_star'):
|
|
g.api_key = _key_with_scopes(['*'])
|
|
rv = _star()
|
|
assert rv.get_json() == {'ok': True}
|
|
|
|
|
|
def test_require_scope_allows_resource_wildcard(scoped_app):
|
|
@scoped_app.route('/__probe_wild')
|
|
@require_scope('apps:read')
|
|
def _wild():
|
|
return jsonify({'ok': True})
|
|
|
|
with scoped_app.test_request_context('/__probe_wild'):
|
|
g.api_key = _key_with_scopes(['apps:*'])
|
|
rv = _wild()
|
|
assert rv.get_json() == {'ok': True}
|
|
|
|
|
|
def test_require_scope_requires_all_listed_scopes(scoped_app):
|
|
@scoped_app.route('/__probe_all')
|
|
@require_scope('apps:read', 'apps:deploy')
|
|
def _all():
|
|
return jsonify({'ok': True})
|
|
|
|
# Has apps:read but not apps:deploy -> blocked.
|
|
with scoped_app.test_request_context('/__probe_all'):
|
|
g.api_key = _key_with_scopes(['apps:read'])
|
|
rv = _all()
|
|
_, status = rv
|
|
assert status == 403
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# require_scope: JWT/session requests pass through
|
|
# ---------------------------------------------------------------------------
|
|
def test_require_scope_passthrough_for_jwt(scoped_app):
|
|
@scoped_app.route('/__probe_jwt')
|
|
@require_scope('apps:read')
|
|
def _jwt():
|
|
return jsonify({'ok': True})
|
|
|
|
# No g.api_key set -> treated as JWT/session -> pass through.
|
|
with scoped_app.test_request_context('/__probe_jwt'):
|
|
assert getattr(g, 'api_key', None) is None
|
|
rv = _jwt()
|
|
assert rv.get_json() == {'ok': True}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Scope catalog sanity
|
|
# ---------------------------------------------------------------------------
|
|
def test_scope_catalog_covers_required_scopes():
|
|
required = {
|
|
'read', 'write', 'apps:read', 'apps:write', 'apps:deploy',
|
|
'databases:read', 'databases:write', 'domains:read', 'domains:write',
|
|
'dns:read', 'dns:write', 'backups:read', 'backups:write',
|
|
'servers:read', 'servers:admin', 'secrets:read',
|
|
}
|
|
assert required.issubset(SCOPE_KEYS)
|
|
# Every entry is well-formed.
|
|
for entry in SCOPES:
|
|
assert {'key', 'label', 'group', 'description'} <= set(entry)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# mask_sensitive
|
|
# ---------------------------------------------------------------------------
|
|
def test_mask_sensitive_redacts_known_keys():
|
|
data = {
|
|
'name': 'db1',
|
|
'password': 'hunter2',
|
|
'api_key': 'sk_abc',
|
|
'token': 'tok',
|
|
'private_key': '-----BEGIN-----',
|
|
'nested': {'db_secret': 'shh', 'host': 'localhost'},
|
|
'list': [{'credential': 'c'}, {'plain': 'ok'}],
|
|
}
|
|
out = mask_sensitive(data, allowed_scopes=['apps:read'])
|
|
|
|
assert out['name'] == 'db1'
|
|
assert out['password'] == MASK
|
|
assert out['api_key'] == MASK
|
|
assert out['token'] == MASK
|
|
assert out['private_key'] == MASK
|
|
assert out['nested']['db_secret'] == MASK
|
|
assert out['nested']['host'] == 'localhost'
|
|
assert out['list'][0]['credential'] == MASK
|
|
assert out['list'][1]['plain'] == 'ok'
|
|
# Original is not mutated.
|
|
assert data['password'] == 'hunter2'
|
|
|
|
|
|
def test_mask_sensitive_respects_secrets_read_scope():
|
|
data = {'password': 'hunter2', 'key_hash': 'deadbeef'}
|
|
out = mask_sensitive(data, allowed_scopes=['secrets:read'])
|
|
assert out['password'] == 'hunter2'
|
|
assert out['key_hash'] == 'deadbeef'
|
|
|
|
|
|
def test_mask_sensitive_full_access_reveals():
|
|
data = {'secret': 's'}
|
|
assert mask_sensitive(data, allowed_scopes=['*'])['secret'] == 's'
|
|
|
|
|
|
def test_mask_sensitive_leaves_none_values():
|
|
data = {'password': None}
|
|
assert mask_sensitive(data, allowed_scopes=['read'])['password'] is None
|
|
|
|
|
|
def test_is_sensitive_key_false_positive_exceptions():
|
|
assert is_sensitive_key('password') is True
|
|
assert is_sensitive_key('is_secret') is False
|
|
assert is_sensitive_key('token_expires_at') is False
|
|
assert is_sensitive_key('name') is False
|
|
|
|
|
|
def test_sensitive_key_patterns_exported():
|
|
assert 'password' in SENSITIVE_KEY_PATTERNS
|
|
assert 'token' in SENSITIVE_KEY_PATTERNS
|
|
|
|
|
|
def test_scopes_for_request_defaults_to_full_access_without_key(app):
|
|
with app.test_request_context('/'):
|
|
# No g.api_key -> JWT/session caller -> full access.
|
|
assert scopes_for_request() == ['*']
|
|
|
|
|
|
def test_scopes_for_request_uses_api_key_scopes(app):
|
|
with app.test_request_context('/'):
|
|
g.api_key = _key_with_scopes(['apps:read', 'dns:read'])
|
|
assert scopes_for_request() == ['apps:read', 'dns:read']
|