feat(auth): implement API key authentication and management
Introduce a new API key authentication mechanism to support service-to-service communication (e.g., Airflow, CI/CD) without browser-based JWT login. - Add `APIKey` model and `APIKeyPrincipal` dependency for RBAC. - Implement `require_api_key_or_jwt` dependency with environment scoping. - Add admin CRUD endpoints for API key lifecycle management. - Add API key management UI in System Settings. - Update maintenance API to support explicit `environment_id` and API key auth. - Add i18n support for API keys and connection settings. - Include Python and Bash usage examples in the `examples/` directory. - Update technical specifications and documentation.
This commit is contained in:
331
examples/maintenance-api-python.py
Normal file
331
examples/maintenance-api-python.py
Normal file
@@ -0,0 +1,331 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Example: External tool triggers maintenance via ss-tools API.
|
||||
|
||||
This script demonstrates how an ETL pipeline (Airflow DAG, cron job, CI/CD)
|
||||
can start and end maintenance banners on Superset dashboards using the
|
||||
ss-tools API Key authentication.
|
||||
|
||||
Requirements: Python 3.10+, requests (pip install requests)
|
||||
|
||||
Usage:
|
||||
# Start maintenance
|
||||
python maintenance-api-python.py start \
|
||||
--api-key ssk_M7xqaP2zL9vR4nF8kC1bH5jT6dW0yA3 \
|
||||
--base-url https://ss-tools.example.com \
|
||||
--tables public.messages,public.users \
|
||||
--duration-hours 4 \
|
||||
--message "Scheduled ETL refresh"
|
||||
|
||||
# End maintenance by event ID
|
||||
python maintenance-api-python.py end \
|
||||
--api-key ssk_M7xqaP2zL9vR4nF8kC1bH5jT6dW0yA3 \
|
||||
--base-url https://ss-tools.example.com \
|
||||
--event-id m-abc123
|
||||
|
||||
# End ALL maintenance (use with caution!)
|
||||
python maintenance-api-python.py end-all \
|
||||
--api-key ssk_M7xqaP2zL9vR4nF8kC1bH5jT6dW0yA3 \
|
||||
--base-url https://ss-tools.example.com \
|
||||
--environment ss-dev
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
try:
|
||||
import requests
|
||||
except ImportError:
|
||||
print("Error: 'requests' library is required. Install with: pip install requests")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
API_KEY_HEADER = "X-API-Key"
|
||||
|
||||
|
||||
def start_maintenance(base_url: str, api_key: str, tables: list[str],
|
||||
environment_id: str, duration_hours: int = 4,
|
||||
message: str | None = None,
|
||||
timeout: int = 30) -> dict:
|
||||
"""
|
||||
Start a maintenance event.
|
||||
|
||||
Args:
|
||||
base_url: ss-tools base URL (e.g. https://ss-tools.example.com)
|
||||
api_key: API key with 'maintenance:start' permission
|
||||
tables: List of table names to flag (e.g. ["public.messages"])
|
||||
environment_id: Superset environment (e.g. "ss-dev", "ss-prod")
|
||||
duration_hours: How long the maintenance window lasts
|
||||
message: Optional custom message for the banner
|
||||
timeout: Request timeout in seconds
|
||||
|
||||
Returns:
|
||||
API response dict with task_id and maintenance_id
|
||||
|
||||
Raises:
|
||||
requests.HTTPError: On 4xx/5xx response with error details
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
start_time = now.isoformat()
|
||||
end_time = (now + timedelta(hours=duration_hours)).isoformat()
|
||||
|
||||
payload = {
|
||||
"tables": tables,
|
||||
"start_time": start_time,
|
||||
"end_time": end_time,
|
||||
"environment_id": environment_id,
|
||||
}
|
||||
if message:
|
||||
payload["message"] = message
|
||||
|
||||
print(f"[maintenance] Starting maintenance for tables: {tables}")
|
||||
print(f"[maintenance] Window: {start_time} → {end_time}")
|
||||
print(f"[maintenance] Environment: {environment_id}")
|
||||
|
||||
response = requests.post(
|
||||
f"{base_url}/api/maintenance/start",
|
||||
json=payload,
|
||||
headers={API_KEY_HEADER: api_key},
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
if response.status_code == 202:
|
||||
result = response.json()
|
||||
print(f"[maintenance] ✅ Event created (id={result['maintenance_id']})")
|
||||
print(f"[maintenance] Task: {result.get('task_id', 'N/A')}")
|
||||
return result
|
||||
|
||||
if response.status_code == 400:
|
||||
error = _format_error(response)
|
||||
print(f"[maintenance] ❌ Validation error: {error}")
|
||||
elif response.status_code == 401:
|
||||
print("[maintenance] ❌ Authentication failed: invalid or revoked API key")
|
||||
elif response.status_code == 403:
|
||||
print("[maintenance] ❌ Permission denied: API key lacks 'maintenance:start'")
|
||||
else:
|
||||
print(f"[maintenance] ❌ Unexpected error ({response.status_code}): {response.text[:500]}")
|
||||
|
||||
response.raise_for_status()
|
||||
return {} # unreachable
|
||||
|
||||
|
||||
def end_maintenance(base_url: str, api_key: str, event_id: str,
|
||||
timeout: int = 30) -> dict:
|
||||
"""
|
||||
End a specific maintenance event and remove its banners.
|
||||
|
||||
Args:
|
||||
base_url: ss-tools base URL
|
||||
api_key: API key with 'maintenance:end' permission
|
||||
event_id: The event ID returned by start_maintenance
|
||||
timeout: Request timeout in seconds
|
||||
|
||||
Returns:
|
||||
API response dict
|
||||
|
||||
Raises:
|
||||
requests.HTTPError: On error
|
||||
"""
|
||||
print(f"[maintenance] Ending maintenance event: {event_id}")
|
||||
|
||||
response = requests.post(
|
||||
f"{base_url}/api/maintenance/{event_id}/end",
|
||||
headers={API_KEY_HEADER: api_key},
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
if response.status_code == 202:
|
||||
result = response.json()
|
||||
print(f"[maintenance] ✅ Event end scheduled (task={result.get('task_id', 'N/A')})")
|
||||
return result
|
||||
|
||||
if response.status_code == 404:
|
||||
data = response.json()
|
||||
if data.get("status") == "already_completed":
|
||||
print("[maintenance] ℹ️ Event was already completed (idempotent)")
|
||||
return data
|
||||
print(f"[maintenance] ❌ Event not found: {event_id}")
|
||||
elif response.status_code == 401:
|
||||
print("[maintenance] ❌ Authentication failed: invalid or revoked API key")
|
||||
elif response.status_code == 403:
|
||||
print("[maintenance] ❌ Permission denied: API key lacks 'maintenance:end'")
|
||||
else:
|
||||
print(f"[maintenance] ❌ Unexpected error ({response.status_code}): {response.text[:500]}")
|
||||
|
||||
response.raise_for_status()
|
||||
return {}
|
||||
|
||||
|
||||
def end_all_maintenance(base_url: str, api_key: str,
|
||||
environment_id: str | None = None,
|
||||
timeout: int = 30) -> dict:
|
||||
"""
|
||||
End ALL active maintenance events in the given environment.
|
||||
USE WITH CAUTION — this removes banners from all dashboards.
|
||||
|
||||
Args:
|
||||
base_url: ss-tools base URL
|
||||
api_key: API key with 'maintenance:end_all' permission
|
||||
environment_id: If set, only end events in this environment.
|
||||
If None, uses the API key's default scope.
|
||||
timeout: Request timeout in seconds
|
||||
|
||||
Returns:
|
||||
API response dict
|
||||
|
||||
Raises:
|
||||
requests.HTTPError: On error
|
||||
"""
|
||||
print(f"[maintenance] Ending ALL maintenance events "
|
||||
f"(env={environment_id or 'key-scope'})")
|
||||
print("[maintenance] ⚠️ This will remove banners from ALL affected dashboards!")
|
||||
|
||||
payload = {}
|
||||
if environment_id:
|
||||
payload["environment_id"] = environment_id
|
||||
|
||||
response = requests.post(
|
||||
f"{base_url}/api/maintenance/end-all",
|
||||
json=payload,
|
||||
headers={API_KEY_HEADER: api_key},
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
if response.status_code == 202:
|
||||
result = response.json()
|
||||
print(f"[maintenance] ✅ All events end scheduled (task={result.get('task_id', 'N/A')})")
|
||||
return result
|
||||
|
||||
if response.status_code == 401:
|
||||
print("[maintenance] ❌ Authentication failed: invalid or revoked API key")
|
||||
elif response.status_code == 403:
|
||||
print("[maintenance] ❌ Permission denied: API key lacks 'maintenance:end_all'")
|
||||
elif response.status_code == 400:
|
||||
print(f"[maintenance] ❌ {_format_error(response)}")
|
||||
else:
|
||||
print(f"[maintenance] ❌ Unexpected error ({response.status_code}): {response.text[:500]}")
|
||||
|
||||
response.raise_for_status()
|
||||
return {}
|
||||
|
||||
|
||||
def _format_error(response: requests.Response) -> str:
|
||||
"""Extract human-readable error from API response."""
|
||||
try:
|
||||
data = response.json()
|
||||
if "detail" in data:
|
||||
detail = data["detail"]
|
||||
if isinstance(detail, list):
|
||||
return "; ".join(d.get("msg", str(d)) for d in detail)
|
||||
return str(detail)
|
||||
return response.text[:200]
|
||||
except Exception:
|
||||
return response.text[:200]
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="ss-tools Maintenance Banner CLI",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
# Start 4-hour maintenance on public.messages
|
||||
%(prog)s start --api-key ssk_... --tables public.messages \\\\
|
||||
--environment ss-dev --duration-hours 4
|
||||
|
||||
# End maintenance by event ID
|
||||
%(prog)s end --api-key ssk_... --event-id m-abc123
|
||||
|
||||
# Emergency: end ALL maintenance in ss-dev
|
||||
%(prog)s end-all --api-key ssk_... --environment ss-dev
|
||||
""",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--base-url",
|
||||
default="http://localhost:8000",
|
||||
help="ss-tools base URL (default: %(default)s)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--api-key",
|
||||
required=True,
|
||||
help="API key with required permissions",
|
||||
)
|
||||
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
# start
|
||||
start_parser = subparsers.add_parser("start", help="Start maintenance")
|
||||
start_parser.add_argument(
|
||||
"--tables",
|
||||
required=True,
|
||||
help="Comma-separated table names (e.g. 'public.messages,public.users')",
|
||||
)
|
||||
start_parser.add_argument(
|
||||
"--environment",
|
||||
required=True,
|
||||
dest="environment_id",
|
||||
help="Superset environment ID (e.g. 'ss-dev', 'ss-prod')",
|
||||
)
|
||||
start_parser.add_argument(
|
||||
"--duration-hours",
|
||||
type=int,
|
||||
default=4,
|
||||
help="Maintenance window in hours (default: %(default)s)",
|
||||
)
|
||||
start_parser.add_argument(
|
||||
"--message",
|
||||
default=None,
|
||||
help="Optional message for the banner",
|
||||
)
|
||||
|
||||
# end
|
||||
end_parser = subparsers.add_parser("end", help="End a specific maintenance event")
|
||||
end_parser.add_argument(
|
||||
"--event-id",
|
||||
required=True,
|
||||
help="Maintenance event ID (from start response)",
|
||||
)
|
||||
|
||||
# end-all
|
||||
end_all_parser = subparsers.add_parser(
|
||||
"end-all", help="End ALL maintenance events (use with caution)"
|
||||
)
|
||||
end_all_parser.add_argument(
|
||||
"--environment",
|
||||
dest="environment_id",
|
||||
default=None,
|
||||
help="Limit to specific environment (optional)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
if args.command == "start":
|
||||
tables = [t.strip() for t in args.tables.split(",")]
|
||||
start_maintenance(
|
||||
base_url=args.base_url,
|
||||
api_key=args.api_key,
|
||||
tables=tables,
|
||||
environment_id=args.environment_id,
|
||||
duration_hours=args.duration_hours,
|
||||
message=args.message,
|
||||
)
|
||||
elif args.command == "end":
|
||||
end_maintenance(
|
||||
base_url=args.base_url,
|
||||
api_key=args.api_key,
|
||||
event_id=args.event_id,
|
||||
)
|
||||
elif args.command == "end-all":
|
||||
end_all_maintenance(
|
||||
base_url=args.base_url,
|
||||
api_key=args.api_key,
|
||||
environment_id=args.environment_id,
|
||||
)
|
||||
except requests.HTTPError:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user