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:
2026-05-25 11:35:27 +03:00
parent 31680a1bc9
commit 320f82ab95
39 changed files with 3359 additions and 707 deletions

251
examples/maintenance-api-bash.sh Executable file
View File

@@ -0,0 +1,251 @@
#!/usr/bin/env bash
# ============================================================================
# Example: External tool triggers maintenance via ss-tools API (bash)
#
# This script shows how to call the ss-tools maintenance API from any
# shell environment — CI/CD pipeline, cron job, or ad-hoc debugging.
#
# Prerequisites:
# - curl, jq (optional, for pretty-printing JSON)
# - An ss-tools API key with `maintenance:start`, `maintenance:end`
#
# Usage:
# ./maintenance-api-bash.sh start "public.messages" 4 "ss-dev"
# ./maintenance-api-bash.sh end "m-abc123"
# ./maintenance-api-bash.sh end-all "ss-dev"
# ============================================================================
set -euo pipefail
# ── Configuration ──────────────────────────────────────────────────────────
# Set these via environment variables or replace inline
BASE_URL="${SS_TOOLS_URL:-http://localhost:8000}"
API_KEY="${SS_TOOLS_API_KEY:-}"
# ── Color helpers (disabled in non-TTY) ────────────────────────────────────
if [[ -t 1 ]]; then
GREEN='\033[0;32m'; RED='\033[0;31m'; YELLOW='\033[1;33m'
CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m'
else
GREEN=''; RED=''; YELLOW=''; CYAN=''; BOLD=''; NC=''
fi
ok() { echo -e "${GREEN}${NC} $*"; }
info() { echo -e "${CYAN} ${NC} $*"; }
warn() { echo -e "${YELLOW}⚠️ ${NC} $*"; }
fail() { echo -e "${RED}${NC} $*"; exit 1; }
# ── Auth check ─────────────────────────────────────────────────────────────
check_auth() {
if [[ -z "$API_KEY" ]]; then
fail "API key not set. Export SS_TOOLS_API_KEY or edit the script."
fi
}
# ── API helpers ────────────────────────────────────────────────────────────
# Call the API with X-API-Key auth and handle common errors
api_call() {
local method="$1" # GET / POST
local endpoint="$2" # /api/maintenance/start
local data="$3" # JSON payload or empty string
local curl_args=(
-X "$method"
-sS
-H "X-API-Key: $API_KEY"
-H "Content-Type: application/json"
)
if [[ -n "$data" ]]; then
curl_args+=(-d "$data")
fi
local http_code
local response_file
response_file=$(mktemp)
http_code=$(curl -w '%{http_code}' "${curl_args[@]}" \
"${BASE_URL}${endpoint}" \
-o "$response_file" 2>/dev/null)
local body
body=$(<"$response_file")
rm -f "$response_file"
case "$http_code" in
202)
echo "$body" # success
return 0
;;
400)
warn "Validation error:"
echo "$body" | jq . 2>/dev/null || echo "$body"
return 1
;;
401)
fail "Authentication failed: invalid or revoked API key"
;;
403)
fail "Permission denied: API key lacks required permission"
;;
404)
local status
status=$(echo "$body" | jq -r '.status // "not_found"' 2>/dev/null)
if [[ "$status" == "already_completed" ]]; then
info "Event was already completed (idempotent)"
return 0
fi
warn "Not found (404):"
echo "$body" | jq . 2>/dev/null || echo "$body"
return 1
;;
*)
fail "Unexpected HTTP $http_code: $(echo "$body" | head -c 500)"
;;
esac
}
# ── Commands ───────────────────────────────────────────────────────────────
cmd_start() {
local tables="$1"
local duration_hours="${2:-4}"
local environment_id="$3"
local message="${4:-}"
local start_time
local end_time
start_time=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
end_time=$(date -u -d "+${duration_hours} hours" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null \
|| date -u -v "+${duration_hours}H" +"%Y-%m-%dT%H:%M:%SZ")
# Build JSON payload
local payload
payload=$(cat <<EOF
{
"tables": [$(echo "$tables" | sed 's/[^,]*/"&"/g')],
"start_time": "$start_time",
"end_time": "$end_time",
"environment_id": "$environment_id"
EOF
)
if [[ -n "$message" ]]; then
payload="$payload"$',\n "message": "'"$message"'"'
fi
payload="$payload"$'\n}'
info "Starting maintenance: tables=${tables}, env=${environment_id}, ${duration_hours}h"
info "Window: ${start_time}${end_time}"
local response
if response=$(api_call POST "/api/maintenance/start" "$payload"); then
local event_id
event_id=$(echo "$response" | jq -r '.maintenance_id' 2>/dev/null || echo "unknown")
local task_id
task_id=$(echo "$response" | jq -r '.task_id' 2>/dev/null || echo "unknown")
ok "Maintenance event created: ${BOLD}${event_id}${NC}"
info "Task: ${task_id}"
echo "$response" | jq . 2>/dev/null || echo "$response"
fi
}
cmd_end() {
local event_id="$1"
info "Ending maintenance event: ${event_id}"
local response
if response=$(api_call POST "/api/maintenance/${event_id}/end" ""); then
ok "Maintenance event end scheduled"
echo "$response" | jq . 2>/dev/null || echo "$response"
fi
}
cmd_end_all() {
local environment_id="${1:-}"
warn "This will remove banners from ALL affected dashboards!"
if [[ -n "$environment_id" ]]; then
info "Scope: environment=${environment_id}"
else
info "Scope: all environments (API key default)"
fi
# Prompt for confirmation in interactive mode
if [[ -t 0 ]]; then
echo -n "Continue? [y/N] "
read -r confirm
if [[ "$confirm" != "y" && "$confirm" != "Y" ]]; then
info "Cancelled."
exit 0
fi
fi
local payload="{}"
if [[ -n "$environment_id" ]]; then
payload=$(cat <<EOF
{"environment_id": "$environment_id"}
EOF
)
fi
local response
if response=$(api_call POST "/api/maintenance/end-all" "$payload"); then
ok "End-all scheduled"
echo "$response" | jq . 2>/dev/null || echo "$response"
fi
}
# ── Main ───────────────────────────────────────────────────────────────────
check_auth
case "${1:-help}" in
start)
if [[ $# -lt 3 ]]; then
echo "Usage: $0 start <tables> [duration_hours] <environment> [message]"
echo ""
echo "Examples:"
echo " $0 start public.messages 4 ss-dev"
echo " $0 start public.messages,public.users 4 ss-prod \"Scheduled ETL\""
exit 1
fi
cmd_start "$2" "${3:-4}" "${4:-}" "${5:-}"
;;
end)
if [[ $# -lt 2 ]]; then
echo "Usage: $0 end <event-id>"
exit 1
fi
cmd_end "$2"
;;
end-all)
cmd_end_all "${2:-}"
;;
*)
cat <<EOF
ss-tools Maintenance CLI
Usage:
$0 start <tables> [hours=4] <environment> [message]
Start maintenance on tables (comma-separated)
$0 end <event-id>
End a specific maintenance event
$0 end-all [environment]
End ALL maintenance (use with caution)
Examples:
$0 start public.messages 4 ss-dev
$0 end m-abc123
$0 end-all ss-dev
Environment:
SS_TOOLS_URL ss-tools base URL (default: http://localhost:8000)
SS_TOOLS_API_KEY API key with required permissions (required)
EOF
exit 0
;;
esac

View 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()