mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-30 16:47:01 +09:00
Add session-family revocation and migration 097, require current credentials for password changes, constrain API keys, and require verified WebAuthn authentication. Update browser token handling and add boundary regressions. Existing JWTs require fresh login; restricted keys deny undeclared endpoints.
142 lines
6.1 KiB
Python
142 lines
6.1 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 current_app, g, jsonify, request
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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 enforce_request_scope(api_key):
|
|
"""Fail closed for restricted keys when a route has no scope policy.
|
|
|
|
Role checks alone cannot constrain an administrator-owned read-only key.
|
|
Legacy empty scope lists, like '*', retain their existing full-access
|
|
meaning; both still pass through the route's authentication/RBAC policy.
|
|
"""
|
|
assigned = api_key.get_scopes()
|
|
if not assigned or FULL_ACCESS_SCOPE in assigned:
|
|
return None
|
|
view = current_app.view_functions.get(request.endpoint)
|
|
required = getattr(view, '_sk_api_scopes', ()) if view else ()
|
|
if not required:
|
|
return jsonify({'error': 'This endpoint does not allow restricted API keys'}), 403
|
|
for scope in required:
|
|
if not api_key.has_scope(scope):
|
|
return jsonify({'error': f'Insufficient API key scope: {scope}'}), 403
|
|
return None
|
|
|
|
|
|
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)
|
|
# functools.wraps preserves this metadata through outer RBAC wrappers.
|
|
# Merge nested declarations: a later decorator may only add constraints.
|
|
wrapper._sk_api_scopes = tuple(dict.fromkeys(
|
|
(*getattr(fn, '_sk_api_scopes', ()), *scopes)))
|
|
return wrapper
|
|
return decorator
|