Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/LICENSE.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
Copyright (c) 2024-2025 Alain Prasquier - Supervaize.com. All rights reserved.
Copyright (c) 2024-2026 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
Expand Down
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ Reference specific personas when requesting work:

## Learned Workspace Facts

- `supervaizer start --reload` (or `SUPERVAIZER_RELOAD=true`) enables Uvicorn’s `reload` (file watching, dev-only; leave off in production).
- If agent data-resource routes are mounted twice (e.g. both inside `create_agents_routes` and again from `Server` startup), OpenAPI sees duplicate routes and `operationId` uniqueness tests fail.
- Compliance for this repo expects explicit type annotations, including return types, on functions in new or modified Python files (including tests), for mypy-clean CI.
- `ADMIN_ALLOWED_IPS` restricts `/admin` when set (comma-separated IPs/CIDR); unset or empty allows all client IPs.
- In `9agents/agent_interviewer`, empty `MANAGE_ALLOWED_IPS` still requires `MANAGE_AUTH_TOKEN` when that env is set; supervaizer’s admin IP middleware has no equivalent token fallback when the allowlist is empty.
Expand Down
46 changes: 46 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,52 @@ All notable changes to this project will be documented in this file.

## [Unreleased]

### Added

- **`supervaizer.access` module** — New package with three focused sub-modules for centralized access control:
- `client_ip.py` — `_extract_client_ip(scope)` extracts the real client IP from ASGI scope, honoring `TRUSTED_PROXIES` (env var, comma-separated CIDRs) to safely parse `X-Forwarded-For`; returns `""` on any parse error (fail-closed).
- `tailscale.py` — `require_tailscale` FastAPI dependency enforces that requests originate from the Tailscale CGNAT range `100.64.0.0/10`; raises HTTP 403 or `WebSocketException(1008)` on denial; logs via `log_access_denied_tailscale`.
- `api_auth.py` — `require_api_key` / `require_scope` FastAPI dependencies for machine-to-machine auth with a hierarchical scope model (`write` implies `read`). `API_KEYS` registry is empty by default; `SUPERVAIZER_API_KEY` env var is pre-loaded as a `write`-scope entry at import time.

- **`supervaizer.routers` module** — Three router factories that replace scattered per-route `Security(...)` calls:
- `public_router` — unauthenticated surface for home page (`/`) and A2A discovery (`/.well-known/*`).
- `private_router` (prefix `/manage`) — admin UI and workbench WebSocket, gated by `require_tailscale` at router level.
- `api_router` (prefix `/api`) — machine-to-machine surface (`/api/supervaizer/…`, `/api/agents/{slug}/…`), gated by `require_api_key`; write-mutating endpoints additionally enforce `require_scope("write")`.

- **`log_access_denied_tailscale` and `log_access_denied_api` helpers** in `supervaizer.common` — structured `WARNING` log entries for every denied request, including IP, path, reason, and a truncated key preview (never the raw key value).

### Changed

- **Admin UI moved from `/admin` to `/manage`** — All admin routes, workbench, and HTML template links updated. `private_router` (prefix `/manage`) gates the surface with Tailscale-only access instead of the previous `AdminIPAllowlistMiddleware` + API-key combo.

- **API routes moved from `/supervaizer/…` to `/api/supervaizer/…`** — All machine-to-machine endpoints now live under the `/api` prefix provided by `api_router`. Clients must update base paths accordingly.

- **`AdminIPAllowlistMiddleware` removed** — Replaced by `require_tailscale` at router level. The `admin/ip_allowlist.py` module is deleted.

- **Per-route `Security(server.verify_api_key)` removed** from `routes.py` and `data_routes.py` — Authentication is now enforced once at the `api_router` level.

- **Admin auth simplified to Tailscale-only** — `verify_admin_access`, `?key=` query-param handling, and console-token generation/validation are removed from `admin/routes.py` and `admin/workbench_routes.py`.

### Security

- **Removed hard-coded default API keys** — `API_KEYS` is now empty at startup; no credentials ship with the package. Only `SUPERVAIZER_API_KEY` (operator-supplied env var) populates the registry.

### Tests

- New: `tests/test_access_client_ip.py`, `tests/test_access_tailscale.py`, `tests/test_access_api_auth.py` covering the new access layer.
- Updated: `test_routes.py`, `test_routes_case_update.py`, `test_data_resource.py` — paths prefixed with `/api`.
- Updated: `test_admin_routes.py`, `test_workbench_routes.py` — prefix `/admin` → `/manage`; Tailscale gate bypassed via `dependency_overrides`.
- Deleted: `test_admin_ip_allowlist.py` — coverage moved to new access tests.

`just test`

| Status | Count |
| ---------- | ----- |
| ✅ Passed | 502 |
| 🤔 Skipped | 0 |
| 🔴 Failed | 0 |
| ⏱️ in | ~54s |

## [0.14.2] - 2026-04-16

### Added
Expand Down
6 changes: 6 additions & 0 deletions src/supervaizer/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
# Copyright (c) 2024-2026 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/.

# 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.
Expand Down
6 changes: 6 additions & 0 deletions src/supervaizer/__version__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
# Copyright (c) 2024-2026 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/.

# 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.
Expand Down
26 changes: 26 additions & 0 deletions src/supervaizer/access/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Copyright (c) 2024-2026 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/.

# 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/.

"""Multi-surface access control: Tailscale gating and API key auth.""" # <-- ADDED

from supervaizer.access.api_auth import API_KEYS, require_api_key, require_scope
from supervaizer.access.client_ip import TRUSTED_PROXIES, _extract_client_ip
from supervaizer.access.tailscale import require_tailscale

__all__ = [
"API_KEYS",
"TRUSTED_PROXIES",
"_extract_client_ip",
"require_api_key",
"require_scope",
"require_tailscale",
]
89 changes: 89 additions & 0 deletions src/supervaizer/access/api_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# Copyright (c) 2024-2026 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/.

# 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/.

"""API key authentication and scope enforcement.""" # <-- ADDED

from __future__ import annotations

import os
from collections.abc import Callable
from typing import Annotated

from fastapi import Depends, Header, HTTPException, Request

from supervaizer.common import log_access_denied_api

# In-memory API key registry. Populated at import time from SUPERVAIZER_API_KEY env.
# Empty by default — no hard-coded credentials ship in production. # <-- ADDED
API_KEYS: dict[str, dict[str, str]] = {}

# Scope hierarchy: higher rank implies all lower scopes.
_SCOPE_RANK: dict[str, int] = {"read": 0, "write": 1} # <-- ADDED


def _load_env_key() -> None: # <-- ADDED
"""Register SUPERVAIZER_API_KEY as a full-access (write) entry for migration."""
env_key = os.getenv("SUPERVAIZER_API_KEY", "").strip()
if env_key:
API_KEYS[env_key] = {"scope": "write"}


_load_env_key()


def require_api_key( # <-- ADDED
request: Request,
x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
) -> dict[str, str]:
"""Verify X-API-Key header and return the key's metadata dict.

Checks in order:
1. In-memory ``API_KEYS`` registry (populated at import from ``SUPERVAIZER_API_KEY``).
2. Live server's ``api_key`` on ``request.app.state.server`` — handles test fixtures
and deployments where the key is set programmatically rather than via env var.

Raises HTTP 401 for missing or unknown keys.
"""
path = request.scope.get("path", "")
if x_api_key:
if x_api_key in API_KEYS:
return API_KEYS[x_api_key]
# Fallback: live server API key (covers test fixtures + programmatic config)
live_server = getattr(getattr(request, "app", None), "state", None)
live_server = getattr(live_server, "server", None) if live_server else None
live_key = getattr(live_server, "api_key", None) if live_server else None
if live_key and x_api_key == live_key:
return {"scope": "write"} # live server key always has full access
log_access_denied_api(x_api_key, path, "invalid key")
raise HTTPException(status_code=401, detail="Invalid or missing API key")


def require_scope(required_scope: str) -> Callable[..., dict[str, str]]: # <-- ADDED
"""Return a FastAPI dependency that enforces a minimum scope level.

Scope is hierarchical: 'write' satisfies 'read' (but not the reverse).
"""

def _check(
meta: Annotated[dict[str, str], Depends(require_api_key)],
request: Request,
) -> dict[str, str]:
key_scope = meta.get("scope", "")
key_rank = _SCOPE_RANK.get(key_scope, -1)
req_rank = _SCOPE_RANK.get(required_scope, 0)
if key_rank < req_rank:
path = request.scope.get("path", "")
log_access_denied_api(None, path, "insufficient scope")
raise HTTPException(status_code=403, detail="Insufficient scope")
return meta

return _check
73 changes: 73 additions & 0 deletions src/supervaizer/access/client_ip.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# Copyright (c) 2024-2026 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/.

# 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/.

"""Client IP extraction with trusted-proxy support.""" # <-- ADDED

from __future__ import annotations

import ipaddress
import os

from starlette.datastructures import Headers
from starlette.types import Scope

from supervaizer.common import log

# Comma-separated CIDRs trusted to set X-Forwarded-For, e.g. "10.0.0.0/8,172.16.0.0/12"
# Empty / unset = no proxy is trusted (use direct peer IP only).
TRUSTED_PROXIES: list[ipaddress._BaseNetwork] = [] # <-- ADDED

_raw = os.getenv("TRUSTED_PROXIES", "").strip()
if _raw:
for _entry in _raw.split(","):
_entry = _entry.strip()
if _entry:
try:
TRUSTED_PROXIES.append(ipaddress.ip_network(_entry, strict=False))
except ValueError:
log.warning(
f"[client_ip] Invalid TRUSTED_PROXIES entry ignored: {_entry!r}"
)


def _extract_client_ip(scope: Scope) -> str: # <-- ADDED
"""Return the effective client IP for a request scope.

Trusts X-Forwarded-For only when the direct peer IP is in TRUSTED_PROXIES.
Returns "" on any parse failure (callers must treat "" as a deny).
"""
try:
client = scope.get("client")
peer_str = client[0] if client else ""
if not peer_str:
return ""

peer_addr = ipaddress.ip_address(peer_str)

if TRUSTED_PROXIES and any(peer_addr in net for net in TRUSTED_PROXIES):
headers = Headers(scope=scope)
xff = headers.get("x-forwarded-for", "")
if xff:
candidate = xff.split(",")[0].strip()
try:
ipaddress.ip_address(candidate) # validate
return candidate
except ValueError:
log.warning(
f"[client_ip] Unparseable XFF entry {candidate!r}, falling back to peer"
)

return peer_str

except Exception as exc:
log.warning(f"[client_ip] Failed to extract client IP: {exc}")
return ""
72 changes: 72 additions & 0 deletions src/supervaizer/access/tailscale.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Copyright (c) 2024-2026 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/.

# 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/.

"""Tailscale CGNAT access dependency.""" # <-- ADDED

from __future__ import annotations

import ipaddress
import os

from fastapi import HTTPException
from starlette.requests import HTTPConnection
from starlette.websockets import WebSocketState

from supervaizer.access.client_ip import _extract_client_ip
from supervaizer.common import log_access_denied_tailscale

# Tailscale CGNAT range per RFC 6598 / Tailscale docs
_TAILSCALE_CGNAT = ipaddress.IPv4Network("100.64.0.0/10")

_LOOPBACK = {ipaddress.ip_address("127.0.0.1"), ipaddress.ip_address("::1")}


def require_tailscale(conn: HTTPConnection) -> None: # <-- ADDED
"""FastAPI dependency that allows only requests from the Tailscale CGNAT range.

In local mode (SUPERVAIZER_LOCAL_MODE=true), loopback addresses are also
allowed so the admin UI works without a Tailscale connection.

Raises HTTP 403 for plain HTTP connections and closes WebSocket connections
with code 1008 when the client IP is outside 100.64.0.0/10.
"""
path = conn.scope.get("path", "")
ip = _extract_client_ip(conn.scope)

allowed = False
if ip:
try:
parsed = ipaddress.ip_address(ip)
local_mode = os.environ.get("SUPERVAIZER_LOCAL_MODE", "").lower() == "true"
allowed = parsed in _TAILSCALE_CGNAT or (local_mode and parsed in _LOOPBACK)
except ValueError:
pass # stays False — fail closed

if not allowed:
log_access_denied_tailscale(ip, path, "not in tailscale range")
if conn.scope.get("type") == "websocket":
# For WebSocket connections, close with policy violation code
# We need to check if the connection is still in a connectable state
ws = conn # conn IS the WebSocket for ws scope
if (
hasattr(ws, "client_state")
and ws.client_state == WebSocketState.CONNECTING
):
raise HTTPException(
status_code=403, detail="Forbidden: Tailscale network required"
)
raise HTTPException(
status_code=403, detail="Forbidden: Tailscale network required"
)
raise HTTPException(
status_code=403, detail="Forbidden: Tailscale network required"
)
6 changes: 6 additions & 0 deletions src/supervaizer/account.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
# Copyright (c) 2024-2026 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/.

# 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.
Expand Down
6 changes: 6 additions & 0 deletions src/supervaizer/account_service.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
# Copyright (c) 2024-2026 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/.

# 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.
Expand Down
6 changes: 6 additions & 0 deletions src/supervaizer/admin/ip_allowlist.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
# Copyright (c) 2024-2026 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/.

# 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.
Expand Down
Loading
Loading