diff --git a/docs/ADMIN_README.md b/docs/ADMIN_README.md index 384786c..a77eb71 100644 --- a/docs/ADMIN_README.md +++ b/docs/ADMIN_README.md @@ -19,6 +19,7 @@ A lightweight web-based admin interface for managing WorkflowEntity objects (Job - **API Key Required**: Admin interface requires a valid API key - **Authentication**: Add `X-API-Key` header to requests - **Secure Access**: All admin endpoints are protected +- **IP allowlist (deployment)**: Set environment variable `ADMIN_ALLOWED_IPS` to restrict which client IPs may access the `/admin` web UI (including static assets and WebSocket upgrades). Comma-separated IPv4/IPv6 addresses and optional CIDR ranges (e.g. `10.0.0.0/8, 203.0.113.4`). If unset or empty, all IPs are allowed. The effective client IP is taken from the first value in `X-Forwarded-For` when present (typical behind a reverse proxy); otherwise the direct peer address is used. Ensure your proxy sets or overwrites `X-Forwarded-For` correctly so the allowlist matches real clients. ### 📊 **Dashboard** diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 171fce4..d5d46d3 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -19,6 +19,21 @@ All notable changes to this project will be documented in this file. ## Unreleased +### Added + +- **`ADMIN_ALLOWED_IPS` for admin UI** — When set, only matching client IPs may access `/admin` (HTML, APIs, static files, WebSocket). Comma-separated IPs and optional CIDR notation; empty or unset allows all. Uses the first address in `X-Forwarded-For` when present. + +### Unit Tests Results + +`just test` + +| Status | Count | +| ---------- | ----- | +| ✅ Passed | 446 | +| 🤔 Skipped | 0 | +| 🔴 Failed | 0 | +| ⏱️ in | 48s | + ## v0.12.0 ### Added diff --git a/docs/api/openapi.json b/docs/api/openapi.json index b09908d..b6cc146 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "Supervaize API", - "description": "API version: v1 Controller version: 0.11.0\n\nAPI for controlling and managing Supervaize agents. \n\nMore information at [https://doc.supervaize.com](https://doc.supervaize.com)\n\n## Authentication\n\nSome endpoints require API key authentication. Protected endpoints expect the API key in the X-API-Key header.\n\n[Swagger](/docs)\n[Redoc](/redoc)\n[OpenAPI](/openapi.json)\n", + "description": "API version: v1 Controller version: 0.12.0\n\nAPI for controlling and managing Supervaize agents. \n\nMore information at [https://doc.supervaize.com](https://doc.supervaize.com)\n\n## Authentication\n\nSome endpoints require API key authentication. Protected endpoints expect the API key in the X-API-Key header.\n\n[Swagger](/docs)\n[Redoc](/redoc)\n[OpenAPI](/openapi.json)\n", "termsOfService": "https://supervaize.com/terms/", "contact": { "name": "Support Team", @@ -4023,7 +4023,7 @@ "type": "string", "format": "date-time", "title": "Timestamp", - "default": "2026-03-23T18:38:30.447473" + "default": "2026-03-23T22:47:03.534997" }, "status_code": { "type": "integer", diff --git a/docs/model_reference/model_core.md b/docs/model_reference/model_core.md index 1d71d79..36958e2 100644 --- a/docs/model_reference/model_core.md +++ b/docs/model_reference/model_core.md @@ -1,6 +1,6 @@ # Model Reference Core -**Version:** 0.11.0 +**Version:** 0.12.0 ### `account.Account` @@ -438,4 +438,4 @@ public_url: full url (including scheme and port) to use for outbound connections ``` -*Uploaded on 2026-03-23 18:38:30* \ No newline at end of file +*Uploaded on 2026-03-23 22:47:03* \ No newline at end of file diff --git a/docs/model_reference/model_extra.md b/docs/model_reference/model_extra.md index f356431..7749721 100644 --- a/docs/model_reference/model_extra.md +++ b/docs/model_reference/model_extra.md @@ -1,6 +1,6 @@ # Model Reference extra -**Version:** 0.11.0 +**Version:** 0.12.0 ### `common.SvBaseModel` @@ -458,7 +458,7 @@ Standard error response model | `error` | `str` | **required** | | | `error_type` | `` | **required** | | | `detail` | `str` | `None` | | -| `timestamp` | `datetime` | datetime.datetime(2026, 3, 23, 18, 38, 29, 522267) | | +| `timestamp` | `datetime` | datetime.datetime(2026, 3, 23, 22, 47, 2, 632663) | | | `status_code` | `int` | **required** | | ### `server.ServerInfo` @@ -490,4 +490,4 @@ A base class for creating Pydantic models. | `details` | `Dict[str, Any]` | **required** | | -*Uploaded on 2026-03-23 18:38:30* \ No newline at end of file +*Uploaded on 2026-03-23 22:47:03* \ No newline at end of file diff --git a/docs/rfc/001-cloud-deployment-cli.md b/docs/rfc/001-cloud-deployment-cli.md index cfcf1d6..36f42b7 100644 --- a/docs/rfc/001-cloud-deployment-cli.md +++ b/docs/rfc/001-cloud-deployment-cli.md @@ -81,6 +81,7 @@ Set for all platforms: - `SUPERVAIZER_PORT` = `--port` - `SV_LOG_LEVEL=INFO` (default) - `SUPERVAIZER_API_KEY` – stored in provider secret store (generated if `--generate-api-key`) +- `ADMIN_ALLOWED_IPS` (optional) – comma-separated IPv4/IPv6 addresses and/or CIDR ranges allowed to access the `/admin` web UI; if unset or empty, all IPs are allowed (see `docs/ADMIN_README.md`). Uses the first hop in `X-Forwarded-For` when present. - **RSA** (choose one): - `SV_RSA_PRIVATE_KEY` (secret value), or - `SV_RSA_PRIVATE_KEY_PATH` (if platform supports volumes) @@ -270,13 +271,11 @@ All deployment artifacts are stored under `.deployment/` directory (added to `.g **Tasks**: 1. **CLI Structure Setup** ✅ - - ✅ Add `deploy` subcommand to existing CLI (`src/supervaizer/cli.py`) - ✅ Create `src/supervaizer/deploy/` module structure - ✅ Add Docker-related dependencies to `pyproject.toml` 2. **Docker Support** ✅ - - ✅ Create `Dockerfile` generator (`src/supervaizer/deploy/docker.py`) - ✅ Create `.dockerignore` generator - ✅ Add `docker-compose.yml` generator for local testing @@ -284,7 +283,6 @@ All deployment artifacts are stored under `.deployment/` directory (added to `.g - ✅ Generate all files under `.deployment/` directory 3. **State Management** ✅ - - ✅ Create deployment state manager (`src/supervaizer/deploy/state.py`) - ✅ Implement `.deployment/state.json` persistence - ✅ Add state validation and migration logic @@ -307,27 +305,23 @@ All deployment artifacts are stored under `.deployment/` directory (added to `.g **Tasks**: 1. **GCP Cloud Run Driver** (`src/supervaizer/deploy/drivers/cloud_run.py`) ✅ - - ✅ Artifact Registry integration - ✅ Secret Manager integration - ✅ Cloud Run service management - ✅ Health check verification 2. **AWS App Runner Driver** (`src/supervaizer/deploy/drivers/aws_app_runner.py`) ✅ - - ✅ ECR integration - ✅ Secrets Manager integration - ✅ App Runner service management - ✅ Health check verification 3. **DigitalOcean App Platform Driver** (`src/supervaizer/deploy/drivers/do_app_platform.py`) ✅ - - ✅ DOCR integration - ✅ App Platform service management - ✅ Health check verification 4. **Driver Testing** ✅ - - ✅ Unit tests for each driver with mocked APIs - ✅ Test authentication and permission handling - ✅ Test resource creation, update, and deletion @@ -350,31 +344,26 @@ All deployment artifacts are stored under `.deployment/` directory (added to `.g **Tasks**: 1. **Plan Command** (`src/supervaizer/deploy/commands/plan.py`) ✅ - - ✅ Resource detection and diff generation - ✅ Cost estimation (optional) - ✅ Dry-run validation 2. **Up Command** (`src/supervaizer/deploy/commands/up.py`) ✅ - - ✅ Orchestrate build → push → deploy → verify workflow - ✅ Handle rollback on failure - ✅ Progress reporting with rich console 3. **Down Command** (`src/supervaizer/deploy/commands/down.py`) ✅ - - ✅ Safe resource cleanup - ✅ Confirmation prompts - ✅ Resource dependency handling 4. **Status Command** (`src/supervaizer/deploy/commands/status.py`) ✅ - - ✅ Service health reporting - ✅ Resource utilization metrics - ✅ Configuration validation 5. **Command Testing** ✅ - - ✅ Unit tests for each command with mocked dependencies - ✅ Test command-line argument parsing and validation - ✅ Test workflow orchestration and error handling @@ -397,7 +386,6 @@ All deployment artifacts are stored under `.deployment/` directory (added to `.g **Tasks**: 1. **Local Command Implementation** (`src/supervaizer/deploy/commands/local.py`) ✅ - - ✅ Docker availability checking - ✅ Test secret generation (API keys and RSA keys) - ✅ Docker Compose service management @@ -405,7 +393,6 @@ All deployment artifacts are stored under `.deployment/` directory (added to `.g - ✅ Service information display 2. **Local Testing Features** ✅ - - ✅ Automatic Docker image building with local-test tag - ✅ Environment variable configuration for local testing - ✅ Comprehensive health endpoint testing @@ -413,21 +400,18 @@ All deployment artifacts are stored under `.deployment/` directory (added to `.g - ✅ Cleanup instructions and resource management 3. **Local Testing Integration** ✅ - - ✅ Integration with existing Docker management system - ✅ Consistent secret generation across local and cloud deployments - ✅ Health check endpoint validation - ✅ Error handling and cleanup on failure 4. **Local Testing Documentation** ✅ - - ✅ Created comprehensive LOCAL_TESTING.md guide - ✅ Added usage examples and troubleshooting - ✅ Documented environment variables and configuration - ✅ Provided cleanup and debugging instructions 5. **Local Testing Validation** ✅ - - ✅ Unit tests for all local testing functions - ✅ Integration tests with Docker Compose - ✅ Health check endpoint testing @@ -445,13 +429,11 @@ All deployment artifacts are stored under `.deployment/` directory (added to `.g **Tasks**: 1. **Secret Management** ✅ - - ✅ Environment variables setup in docker - ✅ Centralized environment variable management with `get_docker_env_vars()` function - ✅ Factorized environment variable handling in Dockerfile generation 2. **Health Verification** ✅ - - ✅ Enhanced health check endpoints with retry logic and exponential backoff - ✅ Created comprehensive `health.py` utility module with `HealthVerifier` class - ✅ Implemented `HealthCheckConfig` for configurable health check parameters @@ -478,9 +460,7 @@ All deployment artifacts are stored under `.deployment/` directory (added to `.g **Tasks**: 1. **Testing** - - **Unit Testing** - - Test all driver classes (`src/supervaizer/deploy/drivers/`) - Test all command implementations (`src/supervaizer/deploy/commands/`) - Test state management (`src/supervaizer/deploy/state.py`) @@ -490,7 +470,6 @@ All deployment artifacts are stored under `.deployment/` directory (added to `.g - Achieve 90%+ code coverage - **Integration Testing** - - Mock cloud provider APIs for all three platforms - Test complete deployment workflows (plan → up → status → down) - Test error handling and rollback scenarios @@ -519,19 +498,16 @@ All deployment artifacts are stored under `.deployment/` directory (added to `.g **Tasks**: 1. **Security Audit** - - Secret handling validation - Permission scope review - Security best practices implementation 2. **Performance Optimization** - - Parallel deployment operations - Caching improvements - Resource cleanup optimization 3. **Release Preparation** - - Version bumping and changelog - Release notes and migration guides - Community feedback integration diff --git a/justfile b/justfile index 99a8a0a..d464ca5 100644 --- a/justfile +++ b/justfile @@ -128,7 +128,7 @@ ready-to-go: just generate_documentation bash -euc 'branch_id="$(but status --json | uv run python tools/get_applied_but_branch_id.py)"; but commit "$branch_id" -m "chore: update documentation" --json --status-after' -# Merge develop to main - after just tag_version +# Merge develop to main merge-to-main: @echo "Switching to main branch..." git checkout main diff --git a/src/supervaizer/admin/ip_allowlist.py b/src/supervaizer/admin/ip_allowlist.py new file mode 100644 index 0000000..21cf1a3 --- /dev/null +++ b/src/supervaizer/admin/ip_allowlist.py @@ -0,0 +1,100 @@ +# Copyright (c) 2024-2025 Alain Prasquier - Supervaize.com. All rights reserved. +# +# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. +# If a copy of the MPL was not distributed with this file, you can obtain one at +# https://mozilla.org/MPL/2.0/. + +"""IP allowlist for the /admin web UI (ADMIN_ALLOWED_IPS).""" + +from __future__ import annotations + +import ipaddress +import os + +from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint +from starlette.requests import Request +from starlette.responses import JSONResponse, Response + +from supervaizer.common import log + +_ENV_KEY = "ADMIN_ALLOWED_IPS" + + +def is_admin_url_path(path: str) -> bool: + """True for /admin and /admin/... but not /administrator.""" + return path == "/admin" or path.startswith("/admin/") + + +def get_effective_client_ip(request: Request) -> str: + """Client IP for allowlist checks (reverse-proxy aware). + + Uses the first address in ``X-Forwarded-For`` when present (typical behind + nginx/Traefik); otherwise ``request.client.host``. + """ + xff = request.headers.get("x-forwarded-for") + if xff: + return xff.split(",")[0].strip() + if request.client and request.client.host: + return request.client.host + return "" + + +def _parse_allowed_entries(raw: str) -> list[str]: + return [p.strip() for p in raw.split(",") if p.strip()] + + +def client_ip_is_allowed(client_ip: str, allowed_raw: str) -> bool: + """Return True if client_ip matches any entry (exact IP or CIDR). + + ``allowed_raw`` is the value of ``ADMIN_ALLOWED_IPS`` (comma-separated). + """ + if not allowed_raw.strip(): + return True + if not client_ip: + return False + try: + addr = ipaddress.ip_address(client_ip) + except ValueError: + log.warning(f"[Admin IP allowlist] Unparseable client IP: {client_ip!r}") + return False + for entry in _parse_allowed_entries(allowed_raw): + if "/" in entry: + try: + net = ipaddress.ip_network(entry, strict=False) + if addr in net: + return True + except ValueError: + log.warning(f"[Admin IP allowlist] Ignoring invalid CIDR: {entry!r}") + continue + else: + try: + if addr == ipaddress.ip_address(entry): + return True + except ValueError: + log.warning(f"[Admin IP allowlist] Ignoring invalid IP: {entry!r}") + continue + return False + + +def admin_request_ip_allowed(request: Request) -> bool: + """True when ``ADMIN_ALLOWED_IPS`` is unset/empty or the client IP is allowed.""" + allowed = os.getenv(_ENV_KEY, "").strip() + if not allowed: + return True + return client_ip_is_allowed(get_effective_client_ip(request), allowed) + + +class AdminIPAllowlistMiddleware(BaseHTTPMiddleware): + """Reject requests to ``/admin`` when ``ADMIN_ALLOWED_IPS`` is set and IP is not listed.""" + + async def dispatch( + self, request: Request, call_next: RequestResponseEndpoint + ) -> Response: + if not is_admin_url_path(request.url.path): + return await call_next(request) + if admin_request_ip_allowed(request): + return await call_next(request) + return JSONResponse( + status_code=403, + content={"detail": "Admin interface is not allowed for this client IP"}, + ) diff --git a/src/supervaizer/server.py b/src/supervaizer/server.py index 1782a92..b68e2ff 100644 --- a/src/supervaizer/server.py +++ b/src/supervaizer/server.py @@ -29,6 +29,7 @@ from supervaizer.__version__ import API_VERSION, VERSION from supervaizer.account import Account +from supervaizer.admin.ip_allowlist import AdminIPAllowlistMiddleware from supervaizer.admin.routes import create_admin_routes from supervaizer.agent import Agent from supervaizer.common import ( @@ -543,6 +544,7 @@ async def validation_exception_handler( # Deploy admin routes if API key is available if self.api_key and admin_interface: + self.app.add_middleware(AdminIPAllowlistMiddleware) log.info( f"[Server launch] 💼 Deploy Admin interface @ {self.public_url}/admin" ) diff --git a/tests/test_admin_ip_allowlist.py b/tests/test_admin_ip_allowlist.py new file mode 100644 index 0000000..ef58618 --- /dev/null +++ b/tests/test_admin_ip_allowlist.py @@ -0,0 +1,139 @@ +# Copyright (c) 2024-2025 Alain Prasquier - Supervaize.com. All rights reserved. +# +# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. +# If a copy of the MPL was not distributed with this file, you can obtain one at +# https://mozilla.org/MPL/2.0/. + +"""Tests for admin IP allowlist (ADMIN_ALLOWED_IPS).""" + +from unittest.mock import Mock + +import pytest +from fastapi import FastAPI, Request +from fastapi.testclient import TestClient + +from supervaizer.admin.ip_allowlist import ( + AdminIPAllowlistMiddleware, + client_ip_is_allowed, + get_effective_client_ip, + is_admin_url_path, +) + + +class TestIsAdminUrlPath: + def test_admin_root(self) -> None: + assert is_admin_url_path("/admin") is True + + def test_admin_nested(self) -> None: + assert is_admin_url_path("/admin/jobs") is True + + def test_not_administrator(self) -> None: + assert is_admin_url_path("/administrator") is False + + +class TestClientIpIsAllowed: + def test_empty_env_allows_any(self) -> None: + assert client_ip_is_allowed("1.2.3.4", "") is True + assert client_ip_is_allowed("1.2.3.4", " ") is True + + def test_exact_match(self) -> None: + assert client_ip_is_allowed("192.168.1.10", "192.168.1.10") is True + assert client_ip_is_allowed("192.168.1.10", "192.168.1.11") is False + + def test_cidr(self) -> None: + assert client_ip_is_allowed("10.0.0.5", "10.0.0.0/8") is True + assert client_ip_is_allowed("192.168.0.1", "10.0.0.0/8") is False + + def test_multiple_entries(self) -> None: + raw = "192.168.1.1, 10.0.0.0/8" + assert client_ip_is_allowed("192.168.1.1", raw) is True + assert client_ip_is_allowed("10.5.5.5", raw) is True + assert client_ip_is_allowed("172.16.0.1", raw) is False + + +class TestGetEffectiveClientIp: + def test_prefers_x_forwarded_for_first(self) -> None: + scope = { + "type": "http", + "headers": [ + (b"x-forwarded-for", b"203.0.113.1, 198.51.100.2"), + ], + "client": ("testclient", 12345), + } + req = Request(scope) + assert get_effective_client_ip(req) == "203.0.113.1" + + def test_fallback_client(self) -> None: + scope = { + "type": "http", + "headers": [], + "client": ("198.51.100.9", 12345), + } + req = Request(scope) + assert get_effective_client_ip(req) == "198.51.100.9" + + +@pytest.fixture +def minimal_admin_app(monkeypatch): + """FastAPI app with admin router + IP middleware (no live server).""" + monkeypatch.setenv("SUPERVAIZER_API_KEY", "test-api-key") + + from unittest.mock import patch + + from supervaizer.admin.routes import create_admin_routes + + mock_storage = Mock() + mock_storage.get_objects.side_effect = lambda obj_type: [] + mock_db = Mock() + mock_db.tables.return_value = [] + mock_storage._db = mock_db + mock_storage.db_path = Mock() + mock_storage.db_path.absolute.return_value = "/tmp/test.db" + + app = FastAPI() + app.add_middleware(AdminIPAllowlistMiddleware) + server = Mock() + server.agents = [] + server.api_key = "test-api-key" + server.supervisor_account = "x" + app.state.server = server + + with patch("supervaizer.admin.routes.StorageManager", return_value=mock_storage): + app.include_router(create_admin_routes(), prefix="/admin") + + return app + + +class TestAdminIPAllowlistMiddleware: + def test_empty_ADMIN_ALLOWED_IPS_allows_all( + self, minimal_admin_app, monkeypatch + ) -> None: + monkeypatch.delenv("ADMIN_ALLOWED_IPS", raising=False) + client = TestClient(minimal_admin_app) + r = client.get("/admin/api/stats") + assert r.status_code == 200 + + def test_blocks_when_not_in_list(self, minimal_admin_app, monkeypatch) -> None: + monkeypatch.setenv("ADMIN_ALLOWED_IPS", "203.0.113.2") + client = TestClient(minimal_admin_app) + r = client.get( + "/admin/api/stats", + headers={"X-Forwarded-For": "203.0.113.1"}, + ) + assert r.status_code == 403 + assert "client IP" in r.json()["detail"] + + def test_allows_when_in_list(self, minimal_admin_app, monkeypatch) -> None: + monkeypatch.setenv("ADMIN_ALLOWED_IPS", "203.0.113.1") + client = TestClient(minimal_admin_app) + r = client.get( + "/admin/api/stats", + headers={"X-Forwarded-For": "203.0.113.1"}, + ) + assert r.status_code == 200 + + def test_non_admin_paths_unaffected(self, minimal_admin_app, monkeypatch) -> None: + monkeypatch.setenv("ADMIN_ALLOWED_IPS", "203.0.113.2") + client = TestClient(minimal_admin_app) + r = client.get("/openapi.json") + assert r.status_code == 200 diff --git a/tools/gh-release-latest-tag.sh b/tools/gh-release-latest-tag.sh index 332cec5..e9a72b3 100644 --- a/tools/gh-release-latest-tag.sh +++ b/tools/gh-release-latest-tag.sh @@ -43,4 +43,3 @@ else fi echo "✅ GitHub release ${LATEST_TAG} created" fi -