diff --git a/.github/workflows/docker_build.yml b/.github/workflows/docker_build.yml index fd637ea8d2..4a7b8f3856 100644 --- a/.github/workflows/docker_build.yml +++ b/.github/workflows/docker_build.yml @@ -69,6 +69,10 @@ jobs: } Write-Output "${service}: verified 127.0.0.1:${port}:${port}/tcp" } + if ($services.'pyrit-gui'.environment.PYRIT_AUTH_MODE -ne 'local') { + throw "Expected pyrit-gui to select PYRIT_AUTH_MODE=local." + } + Write-Output "pyrit-gui: verified PYRIT_AUTH_MODE=local" build-and-test-local: name: Build and test (local) diff --git a/doc/gui/0_gui.md b/doc/gui/0_gui.md index 90d8617ea3..0882a5ea98 100644 --- a/doc/gui/0_gui.md +++ b/doc/gui/0_gui.md @@ -11,15 +11,17 @@ There are several ways to run CoPyRIT: If you have PyRIT installed, use the `pyrit_backend` command to start the server. The bundled frontend is served automatically. ```bash -pyrit_backend +pyrit_backend --auth-mode local ``` Then open `http://localhost:8000` in your browser. -Authentication-disabled local servers deny administrator operations by default. To enable administrator operations +Local mode deliberately disables incoming-request Entra authentication and is +restricted to a loopback host by the packaged CLI. Authentication-disabled local +servers deny administrator operations by default. To enable administrator operations (configuration, initializer administration, and creating targets that run model repository code) for a trusted local -development server, set `PYRIT_ALLOW_UNAUTHENTICATED_ADMIN=true`. Never use this -setting on a network-accessible deployment. +development server, set `PYRIT_ALLOW_UNAUTHENTICATED_ADMIN=true`. Never use these local settings on a +network-accessible deployment. ### Docker diff --git a/docker/QUICKSTART.md b/docker/QUICKSTART.md index 3c28d49433..c50ef38efc 100644 --- a/docker/QUICKSTART.md +++ b/docker/QUICKSTART.md @@ -52,6 +52,11 @@ GUI mode (port 8000): python docker/run_pyrit_docker.py gui ``` +The GUI helper explicitly selects local unauthenticated mode and publishes port +8000 only on `127.0.0.1`. It is available from the Docker host, but not through +the host's LAN address. The API keys in `~/.pyrit/.env` authenticate PyRIT to +configured targets; they do not authenticate incoming GUI users. + The run script automatically mounts these files from `~/.pyrit/`: - `.env` — API keys (required) - `.env.local` — Additional environment overrides (optional) @@ -90,6 +95,11 @@ docker compose --profile jupyter up --build docker compose --profile gui up --build ``` +The shipped GUI Compose profile likewise combines `PYRIT_AUTH_MODE=local` with +the loopback-only `127.0.0.1:8000:8000` publication. Shared deployments must +instead select `PYRIT_AUTH_MODE=entra` and provide complete `ENTRA_*` +configuration. + Both profiles publish ports on `127.0.0.1` only by default. Open the GUI at `http://127.0.0.1:8000` or Jupyter at `http://127.0.0.1:8888` on the Docker host. Jupyter requires the access token shown in the container logs. diff --git a/docker/README.md b/docker/README.md index 4397bf6d1f..3988b90e48 100644 --- a/docker/README.md +++ b/docker/README.md @@ -6,6 +6,12 @@ This Docker container provides a pre-configured environment for running PyRIT (P This README contains technical details for working with the Docker setup locally. +> **Security:** The shipped GUI Compose profile is intentionally local-only. It +> combines `PYRIT_AUTH_MODE=local` with a `127.0.0.1:8000` host publication. +> Do not republish that container to a LAN or public interface. Shared +> deployments must use `PYRIT_AUTH_MODE=entra` with complete Entra settings. +> Target API keys authenticate outbound provider calls, not incoming GUI users. + ## Docker CI The `docker_build` workflow builds the devcontainer base, builds the local-source diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 4e216f7564..7463e9dfcc 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -57,6 +57,7 @@ services: - ~/.azure:/home/vscode/.azure environment: - PYRIT_MODE=gui + - PYRIT_AUTH_MODE=local restart: unless-stopped healthcheck: test: ["CMD-SHELL", "curl -sf http://localhost:8000/api/health || exit 1"] diff --git a/docker/run_pyrit_docker.py b/docker/run_pyrit_docker.py index 3971b944ad..9dd6b8e0b0 100644 --- a/docker/run_pyrit_docker.py +++ b/docker/run_pyrit_docker.py @@ -85,6 +85,8 @@ def run_container(mode, tag="latest"): "-v", f"{env_file}:/home/vscode/.pyrit/.env:ro", ] + if mode == "gui": + cmd.extend(["-e", "PYRIT_AUTH_MODE=local"]) # Add .env.local if it exists if env_local_file.exists(): diff --git a/docker/smoke_test.sh b/docker/smoke_test.sh index abc8fd557c..b361d89b07 100644 --- a/docker/smoke_test.sh +++ b/docker/smoke_test.sh @@ -64,7 +64,11 @@ trap cleanup EXIT trap 'exit 130' INT trap 'exit 143' TERM -container_id=$(docker create --env "PYRIT_MODE=$mode" --publish "127.0.0.1::$port" "$image") +docker_args=(--env "PYRIT_MODE=$mode") +if [[ "$mode" == "gui" ]]; then + docker_args+=(--env "PYRIT_AUTH_MODE=local") +fi +container_id=$(docker create "${docker_args[@]}" --publish "127.0.0.1::$port" "$image") docker start "$container_id" address=$(docker port "$container_id" "$port/tcp") base_url="http://$address" diff --git a/frontend/README.md b/frontend/README.md index 35811b6669..8a0c4a38ca 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -282,6 +282,11 @@ servers with reuse disabled, even when `E2E_FRONTEND_PORT` is set. The frontend proxies API requests to `http://localhost:8000` in development. Configure this in `vite.config.ts` if needed. +The Vite development server binds to `127.0.0.1` by default so its `/api` +proxy cannot expose a local unauthenticated backend to the LAN. Environments +that already provide an isolated network boundary, such as a devcontainer with +controlled port forwarding, can explicitly set `PYRIT_FRONTEND_HOST=0.0.0.0`. + The Vite development server disables its own CORS handling. Use the frontend's same-origin `/api` proxy for API requests. Cross-origin API preflights pass to the backend, which applies its configured origin policy. Do not enable diff --git a/frontend/dev.py b/frontend/dev.py index ead87a410b..f4cf137085 100644 --- a/frontend/dev.py +++ b/frontend/dev.py @@ -15,6 +15,8 @@ import time from pathlib import Path +from pyrit.common.authentication import should_default_to_local_authentication + # Ensure emoji and other Unicode characters don't crash on Windows consoles # that use legacy encodings like cp1252. Characters that can't be encoded # are replaced with '?' instead of raising UnicodeEncodeError. @@ -182,6 +184,8 @@ def start_backend(*, config_file: str | None = None, initializers: list[str] | N # Set development mode environment variable env = os.environ.copy() env["PYRIT_DEV_MODE"] = "true" + if should_default_to_local_authentication(environment=env): + env["PYRIT_AUTH_MODE"] = "local" # Force unbuffered output so init logs appear in real-time when piped env["PYTHONUNBUFFERED"] = "1" @@ -226,20 +230,30 @@ def start_frontend(): # Start frontend process with stdout piped so we can detect the actual port npm_cmd = "npm.cmd" if is_windows() else "npm" + environment = os.environ.copy() + environment.setdefault("PYRIT_FRONTEND_HOST", "127.0.0.1") + environment["PYRIT_PYTHON"] = sys.executable return subprocess.Popen( [npm_cmd, "run", "dev"], - env={**os.environ, "PYRIT_PYTHON": sys.executable}, + env=environment, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, ) +def _parse_vite_port(output: str) -> int | None: + """Return the port from Vite's loopback-ready message.""" + import re + + match = re.search(r"Local:\s+http://[^:/\s]+:(\d+)", output) + return int(match.group(1)) if match else None + + def _detect_frontend_port(frontend_process, *, timeout: int = 10) -> int: """Read Vite's stdout to find the actual port it bound to. Falls back to 3000 if detection times out. """ - import re import selectors default_port = 3000 @@ -258,11 +272,10 @@ def _detect_frontend_port(frontend_process, *, timeout: int = 10) -> int: if data: collected += data text = collected.decode(errors="replace") - # Vite prints: ➜ Local: http://localhost:XXXX/ - match = re.search(r"Local:\s+http://localhost:(\d+)", text) - if match: + port = _parse_vite_port(text) + if port is not None: sel.close() - return int(match.group(1)) + return port if frontend_process.poll() is not None: break @@ -273,8 +286,8 @@ def _detect_frontend_port(frontend_process, *, timeout: int = 10) -> int: return default_port -def _forward_backend_output(backend_process): - """Forward backend stdout to the terminal in a background thread. +def _forward_process_output(process): + """Forward child-process stdout to the terminal in a background thread. Runs until the pipe is closed (process exits or stdout is exhausted). """ @@ -282,7 +295,7 @@ def _forward_backend_output(backend_process): def _reader(): try: - for line in backend_process.stdout: + for line in process.stdout: sys.stdout.buffer.write(line) sys.stdout.buffer.flush() except (ValueError, OSError): @@ -331,11 +344,12 @@ def start_servers(*, config_file: str | None = None): print("⏳ Waiting for backend to initialize (this may take a minute)...") # Forward backend output to the terminal in real-time - _forward_backend_output(backend) + _forward_process_output(backend) # Start frontend in parallel while backend initializes frontend = start_frontend() frontend_port = _detect_frontend_port(frontend) + _forward_process_output(frontend) # Now wait for backend to be actually healthy backend_healthy = _wait_for_backend(backend) @@ -508,6 +522,7 @@ def main(): time.sleep(1) frontend = start_frontend() frontend_port = _detect_frontend_port(frontend) + _forward_process_output(frontend) print(f"✅ Frontend running on http://localhost:{frontend_port} (PID: {frontend.pid})") print("\nPress Ctrl+C to stop") try: diff --git a/frontend/e2e/fixtures/analytics_backend.py b/frontend/e2e/fixtures/analytics_backend.py index 27d2d959c4..fee13332c5 100644 --- a/frontend/e2e/fixtures/analytics_backend.py +++ b/frontend/e2e/fixtures/analytics_backend.py @@ -236,6 +236,7 @@ def main() -> None: "LOCALAPPDATA": str(directory), "XDG_CONFIG_HOME": str(directory), "XDG_DATA_HOME": str(directory), + "PYRIT_AUTH_MODE": "local", "PYRIT_CONFIG_FILE": str(config), "PYRIT_DEV_MODE": "true", } diff --git a/frontend/e2e/fixtures/manual_send_backend.py b/frontend/e2e/fixtures/manual_send_backend.py index 91248d1291..03ae968b36 100644 --- a/frontend/e2e/fixtures/manual_send_backend.py +++ b/frontend/e2e/fixtures/manual_send_backend.py @@ -30,7 +30,7 @@ async def _initialize_offline_async(self: ConfigurationLoader, *, raise_on_initi async def _offline_lifespan_async(application: FastAPI) -> AsyncGenerator[None, None]: config = ConfigurationLoader(memory_db_type="in_memory", env_files=[], env_akv_ref=[]) with ( - patch.dict(os.environ, {"PYRIT_DEV_MODE": "true"}, clear=True), + patch.dict(os.environ, {"PYRIT_AUTH_MODE": "local", "PYRIT_DEV_MODE": "true"}, clear=True), patch.object( ConfigurationFileService, "_read_source_async", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 640cc3df0d..514252ae2c 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -86,7 +86,7 @@ export default defineConfig({ { command: `cd .. && uv run --no-sync python -m pyrit.backend.pyrit_backend ` + - `--host 127.0.0.1 --port ${E2E_BACKEND_PORT} --log-level warning ` + + `--auth-mode local --host 127.0.0.1 --port ${E2E_BACKEND_PORT} --log-level warning ` + "--config-file tests/end_to_end/test_config.yaml", env: { PYRIT_DEV_MODE: "true" }, url: `${E2E_BACKEND_URL}/api/health`, diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index f15bb64c86..63746df37f 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -25,6 +25,7 @@ if (!isCompatibilityId(compatibilityId) const logger = createLogger() const originalError = logger.error const backendUrl = process.env.PYRIT_BACKEND_URL ?? 'http://127.0.0.1:8000' +const frontendHost = process.env.PYRIT_FRONTEND_HOST ?? '127.0.0.1' let proxyWarned = false logger.error = (msg, options) => { if (typeof msg === 'string' && msg.includes('http proxy error')) { @@ -58,7 +59,7 @@ export default defineConfig({ }, server: { port: 3000, - host: true, // Listen on all interfaces for devcontainer + host: frontendHost, // Improve HMR performance for devcontainer hmr: { overlay: false, diff --git a/infra/application.bicep b/infra/application.bicep index 2784153406..8f44bc9be4 100644 --- a/infra/application.bicep +++ b/infra/application.bicep @@ -200,6 +200,10 @@ resource containerApp 'Microsoft.App/containerApps@2024-03-01' = { name: 'PYRIT_MODE' value: 'gui' } + { + name: 'PYRIT_AUTH_MODE' + value: 'entra' + } { name: 'AZURE_SQL_SERVER' value: sqlServerFqdn diff --git a/pyrit/backend/README.md b/pyrit/backend/README.md index f425c5e850..88df525873 100644 --- a/pyrit/backend/README.md +++ b/pyrit/backend/README.md @@ -7,15 +7,21 @@ FastAPI-based REST API for PyRIT. ### Run the Server ```bash -# Development server with auto-reload -python -m pyrit.backend.main +# Loopback-only local development without Entra authentication +python -m pyrit.backend.pyrit_backend --auth-mode local --host 127.0.0.1 --reload -# Or with uvicorn directly -uvicorn pyrit.backend.main:app --reload --host 0.0.0.0 --port 8000 +# Equivalent direct Uvicorn launch +PYRIT_AUTH_MODE=local python -m uvicorn pyrit.backend.main:app --reload --host 127.0.0.1 --port 8000 ``` The API will be available at `http://localhost:8000` +For a shared deployment, set `PYRIT_AUTH_MODE=entra`, configure +`ENTRA_TENANT_ID`, `ENTRA_CLIENT_ID`, and `ENTRA_ALLOWED_GROUP_IDS`, and then +bind the server to the intended network interface. Startup fails when neither +explicit local mode nor complete Entra configuration is present. Target-provider +API keys do not authenticate incoming GUI users. + ### API Documentation - Swagger UI: `http://localhost:8000/docs` @@ -250,6 +256,12 @@ CLI wheel, and backend must be available before enabling enforcement in deployme ## Configuration Environment variables: +- `PYRIT_AUTH_MODE` - Incoming-request authentication mode: `local` or `entra` +- `ENTRA_TENANT_ID` - Entra directory used for GUI sign-in +- `ENTRA_CLIENT_ID` - Entra application registration used by the GUI +- `ENTRA_ALLOWED_GROUP_IDS` - Comma-separated groups allowed to use protected APIs +- `ENTRA_ADMIN_GROUP_ID` - Optional group allowed to use administrator APIs +- `PYRIT_ALLOW_UNAUTHENTICATED_ADMIN` - Local-mode-only administrator override - `PYRIT_API_HOST` - Host to bind to (default: localhost) - `PYRIT_API_PORT` - Port to listen on (default: 8000) - `PYRIT_API_RELOAD` - Enable auto-reload (default: false) diff --git a/pyrit/backend/authentication_policy.py b/pyrit/backend/authentication_policy.py new file mode 100644 index 0000000000..bb18233339 --- /dev/null +++ b/pyrit/backend/authentication_policy.py @@ -0,0 +1,133 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +"""Immutable incoming-request authentication policy.""" + +import os +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Literal + +from pyrit.common.authentication import ( + AUTH_MODE_ENVIRONMENT_VARIABLE, + REQUIRED_ENTRA_ENVIRONMENT_VARIABLES, +) + +AuthenticationMode = Literal["local", "entra"] + +_ENTRA_VARIABLES = (*REQUIRED_ENTRA_ENVIRONMENT_VARIABLES, "ENTRA_ADMIN_GROUP_ID") +_UNAUTHENTICATED_ADMIN_VARIABLE = "PYRIT_ALLOW_UNAUTHENTICATED_ADMIN" + + +class AuthenticationConfigurationError(ValueError): + """Authentication settings cannot produce a safe startup policy.""" + + +@dataclass(frozen=True) +class AuthenticationPolicy: + """Validated process-start policy for incoming GUI requests.""" + + mode: AuthenticationMode + tenant_id: str = "" + client_id: str = "" + allowed_group_ids: tuple[str, ...] = () + admin_group_id: str = "" + allow_unauthenticated_admin: bool = False + + @property + def enabled(self) -> bool: + """Whether Microsoft Entra authentication protects ordinary API requests.""" + return self.mode == "entra" + + @property + def allowed_group_ids_csv(self) -> str: + """Normalized group IDs for the frontend bootstrap contract.""" + return ",".join(self.allowed_group_ids) + + +def _parse_boolean_setting(*, environment: Mapping[str, str], name: str) -> bool: + raw_value = environment.get(name, "").strip().casefold() + if not raw_value: + return False + if raw_value == "true": + return True + if raw_value == "false": + return False + raise AuthenticationConfigurationError(f"{name} must be 'true' or 'false' when set.") + + +def _parse_allowed_group_ids(value: str) -> tuple[str, ...]: + return tuple(dict.fromkeys(group_id.strip() for group_id in value.split(",") if group_id.strip())) + + +def resolve_authentication_policy(*, environment: Mapping[str, str] | None = None) -> AuthenticationPolicy: + """ + Resolve and validate the process-start authentication policy. + + Args: + environment: Process environment to validate. Defaults to ``os.environ``. + + Returns: + AuthenticationPolicy: Immutable validated authentication policy. + + Raises: + AuthenticationConfigurationError: If settings are missing, incomplete, + contradictory, or unsupported. + """ + environment = os.environ if environment is None else environment + mode_raw = environment.get(AUTH_MODE_ENVIRONMENT_VARIABLE, "").strip().casefold() + if mode_raw and mode_raw not in {"local", "entra"}: + raise AuthenticationConfigurationError( + f"{AUTH_MODE_ENVIRONMENT_VARIABLE} must be 'local' or 'entra'; received {mode_raw!r}." + ) + + tenant_id = environment.get("ENTRA_TENANT_ID", "").strip() + client_id = environment.get("ENTRA_CLIENT_ID", "").strip() + allowed_group_ids = _parse_allowed_group_ids(environment.get("ENTRA_ALLOWED_GROUP_IDS", "")) + admin_group_id = environment.get("ENTRA_ADMIN_GROUP_ID", "").strip() + allow_unauthenticated_admin = _parse_boolean_setting( + environment=environment, + name=_UNAUTHENTICATED_ADMIN_VARIABLE, + ) + + if mode_raw == "local": + configured_entra_variables = [name for name in _ENTRA_VARIABLES if name in environment] + if configured_entra_variables: + joined_names = ", ".join(configured_entra_variables) + raise AuthenticationConfigurationError( + f"{AUTH_MODE_ENVIRONMENT_VARIABLE}=local cannot be combined with Entra settings: {joined_names}." + ) + return AuthenticationPolicy( + mode="local", + allow_unauthenticated_admin=allow_unauthenticated_admin, + ) + + required_values = { + "ENTRA_TENANT_ID": tenant_id, + "ENTRA_CLIENT_ID": client_id, + "ENTRA_ALLOWED_GROUP_IDS": allowed_group_ids, + } + missing_settings = [name for name, value in required_values.items() if not value] + if missing_settings: + if not mode_raw and not any(name in environment for name in REQUIRED_ENTRA_ENVIRONMENT_VARIABLES): + raise AuthenticationConfigurationError( + "Authentication configuration is absent. For loopback-only local development, " + f"set {AUTH_MODE_ENVIRONMENT_VARIABLE}=local. For a shared deployment, set " + f"{AUTH_MODE_ENVIRONMENT_VARIABLE}=entra and configure " + f"{', '.join(REQUIRED_ENTRA_ENVIRONMENT_VARIABLES)}." + ) + raise AuthenticationConfigurationError( + f"Incomplete Entra ID configuration: {', '.join(missing_settings)} must be set." + ) + if allow_unauthenticated_admin: + raise AuthenticationConfigurationError( + f"{_UNAUTHENTICATED_ADMIN_VARIABLE}=true is not allowed when Entra authentication is enabled." + ) + + return AuthenticationPolicy( + mode="entra", + tenant_id=tenant_id, + client_id=client_id, + allowed_group_ids=allowed_group_ids, + admin_group_id=admin_group_id, + ) diff --git a/pyrit/backend/main.py b/pyrit/backend/main.py index ada946d470..79a7425748 100644 --- a/pyrit/backend/main.py +++ b/pyrit/backend/main.py @@ -21,6 +21,7 @@ import pyrit from pyrit import _compatibility +from pyrit.backend.authentication_policy import resolve_authentication_policy from pyrit.backend.middleware import RequestIdMiddleware, SecurityHeadersMiddleware, register_error_handlers from pyrit.backend.middleware.auth import EntraAuthMiddleware from pyrit.backend.middleware.compatibility import CompatibilityAPI, CompatibilityMiddleware @@ -65,6 +66,10 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: 3. ``PYRIT_CONFIG_FILE`` local path or Azure Blob URI when set """ app.state.compatibility_id = await asyncio.to_thread(_compatibility.get_compatibility_id) + app.state.authentication_policy = resolve_authentication_policy() + if app.state.authentication_policy.enabled and not app.state.authentication_policy.admin_group_id: + logger.warning("ENTRA_ADMIN_GROUP_ID is not set; authenticated users cannot access administrator routes.") + logger.info("Incoming-request authentication mode: %s", app.state.authentication_policy.mode) configuration_file_service = ConfigurationFileService(config_file_value=os.getenv("PYRIT_CONFIG_FILE")) app.state.configuration_file_service = configuration_file_service runtime = RuntimeLifecycle(app=app, source=configuration_file_service) @@ -100,8 +105,8 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: app.add_middleware(RequestSizeLimitMiddleware) app.add_middleware(RuntimeAdmissionMiddleware) -# Microsoft Graph-backed authentication (PKCE — no client secrets needed) -# Disabled if tenant/client configuration is absent; enabled deployments require allowed groups. +# Microsoft Graph-backed authentication (PKCE — no client secrets needed). +# The immutable process-start policy is validated by the application lifespan. app.add_middleware(EntraAuthMiddleware) app.add_middleware(CompatibilityMiddleware) diff --git a/pyrit/backend/middleware/auth.py b/pyrit/backend/middleware/auth.py index 416e90bfe5..2b5c402f0e 100644 --- a/pyrit/backend/middleware/auth.py +++ b/pyrit/backend/middleware/auth.py @@ -11,13 +11,11 @@ """ import logging -import os from collections import OrderedDict -from collections.abc import Mapping from dataclasses import dataclass from hashlib import sha256 from time import monotonic -from typing import Any, ClassVar, cast +from typing import Any, ClassVar import httpx from fastapi import HTTPException, status @@ -27,6 +25,8 @@ from starlette.responses import JSONResponse, Response from starlette.types import ASGIApp +from pyrit.backend.authentication_policy import AuthenticationPolicy + logger = logging.getLogger(__name__) @@ -51,29 +51,32 @@ class AuthenticatedUser: is_admin: bool = False -def authorization_environment(request: Request) -> Mapping[str, str]: - """Return the process-start authorization settings, never reinitialized values.""" +def get_authentication_policy(request: Request) -> AuthenticationPolicy: + """Return the validated process-start authentication policy.""" state = getattr(request.scope.get("app"), "state", None) - environment = getattr(state, "auth_environment", None) - return cast("Mapping[str, str]", environment) if isinstance(environment, dict) else os.environ + policy = getattr(state, "authentication_policy", None) + if not isinstance(policy, AuthenticationPolicy): + raise RuntimeError("Authentication policy was not initialized during application startup.") + return policy def has_admin_access(request: Request) -> bool: """ Return whether the caller is an administrator. - Without authentication, only a server that sets ``PYRIT_ALLOW_UNAUTHENTICATED_ADMIN=true`` - grants administrator access. + Local mode grants anonymous administrator access only when the immutable + process-start policy explicitly enables it. Returns: bool: Whether the caller has administrator access. """ user = getattr(request.state, "user", None) + if isinstance(user, AuthenticatedUser): + return user.is_admin if user is None: - return ( - authorization_environment(request).get("PYRIT_ALLOW_UNAUTHENTICATED_ADMIN", "").strip().casefold() == "true" - ) - return isinstance(user, AuthenticatedUser) and user.is_admin + policy = get_authentication_policy(request) + return policy.mode == "local" and policy.allow_unauthenticated_admin + return False def require_admin(request: Request) -> None: @@ -103,42 +106,10 @@ class EntraAuthMiddleware(BaseHTTPMiddleware): _AUTH_CACHE_MAX_ENTRIES: ClassVar[int] = 256 def __init__(self, app: ASGIApp) -> None: - """Initialize the middleware with Entra ID configuration from environment variables.""" + """Initialize the middleware cache; policy is supplied by application startup.""" super().__init__(app) - tenant_raw = os.getenv("ENTRA_TENANT_ID", "") - client_raw = os.getenv("ENTRA_CLIENT_ID", "") - groups_raw = os.getenv("ENTRA_ALLOWED_GROUP_IDS", "") - admin_group_raw = os.getenv("ENTRA_ADMIN_GROUP_ID", "") - self._tenant_id = tenant_raw.strip() - self._client_id = client_raw.strip() - self._allowed_group_ids: set[str] = {g.strip() for g in groups_raw.split(",") if g.strip()} - self._admin_group_id = admin_group_raw.strip() - self._enabled = any((tenant_raw, client_raw, groups_raw)) self._auth_cache: OrderedDict[str, tuple[float, AuthenticatedUser]] = OrderedDict() - if self._enabled: - missing_settings = [ - name - for name, value in ( - ("ENTRA_TENANT_ID", self._tenant_id), - ("ENTRA_CLIENT_ID", self._client_id), - ("ENTRA_ALLOWED_GROUP_IDS", self._allowed_group_ids), - ) - if not value - ] - if missing_settings: - raise ValueError(f"Incomplete Entra ID configuration: {', '.join(missing_settings)} must be set") - if not self._admin_group_id: - logger.warning( - "ENTRA_ADMIN_GROUP_ID is not set; authenticated users cannot access administrator routes." - ) - logger.info("Entra ID auth middleware enabled (tenant=%s)", self._tenant_id) - else: - logger.warning( - "Entra ID auth middleware DISABLED — ENTRA_TENANT_ID or ENTRA_CLIENT_ID not set. " - "All requests will be allowed without authentication." - ) - async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response: """ Validate the Bearer token and attach user info to request.state. @@ -152,18 +123,19 @@ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) - """ # Skip auth for public paths and static files path = get_route_path(request.scope) - if not self._enabled or path in self._PUBLIC_PATHS or not path.startswith("/api"): + policy = get_authentication_policy(request) + if policy.mode == "local" or path in self._PUBLIC_PATHS or not path.startswith("/api"): return await call_next(request) try: - user = await self._authenticate_request_async(request) + user = await self._authenticate_request_async(request, policy=policy) except AuthenticationError as error: return JSONResponse(status_code=error.status_code, content={"detail": error.detail}) request.state.user = user return await call_next(request) - async def _authenticate_request_async(self, request: Request) -> AuthenticatedUser: + async def _authenticate_request_async(self, request: Request, *, policy: AuthenticationPolicy) -> AuthenticatedUser: """ Extract, validate, and authorize the Bearer token from the request. @@ -187,16 +159,16 @@ async def _authenticate_request_async(self, request: Request) -> AuthenticatedUs if cached_user is not None: return cached_user - user = await self._authenticate_with_graph_async(token=token) + user = await self._authenticate_with_graph_async(token=token, policy=policy) # Authorize the user based on group membership - if not self._is_authorized(user): + if not self._is_authorized(user, policy=policy): logger.warning( "User %s (%s) denied — groups=%s, allowed_groups=%s", user.email, user.oid, user.groups, - self._allowed_group_ids, + policy.allowed_group_ids, ) raise AuthenticationError( status_code=403, @@ -238,12 +210,13 @@ def _cache_user(self, *, cache_key: str, user: AuthenticatedUser) -> None: while len(self._auth_cache) > self._AUTH_CACHE_MAX_ENTRIES: self._auth_cache.popitem(last=False) - async def _authenticate_with_graph_async(self, *, token: str) -> AuthenticatedUser: + async def _authenticate_with_graph_async(self, *, token: str, policy: AuthenticationPolicy) -> AuthenticatedUser: """ Authenticate a delegated access token and retrieve the current user. Args: token (str): The opaque Microsoft Graph access token. + policy: Validated process-start authentication policy. Returns: AuthenticatedUser: The user returned by Graph. @@ -272,33 +245,36 @@ async def _authenticate_with_graph_async(self, *, token: str) -> AuthenticatedUs logger.warning("Microsoft Graph returned an invalid user profile") raise self._graph_unavailable_error() - groups = await self._check_group_memberships_async(client=client, token=token) + groups = await self._check_group_memberships_async(client=client, token=token, policy=policy) user.groups = groups - user.is_admin = self._admin_group_id in groups + user.is_admin = policy.admin_group_id in groups return user except (httpx.RequestError, ValueError) as error: logger.warning("Microsoft Graph authentication failed: %s", type(error).__name__) raise self._graph_unavailable_error() from error - def _is_authorized(self, user: AuthenticatedUser) -> bool: + def _is_authorized(self, user: AuthenticatedUser, *, policy: AuthenticationPolicy) -> bool: """ Check if the user is authorized via group membership. Returns: True if the user's groups intersect with the allowed group IDs, False otherwise. """ - authorized_group_ids = self._allowed_group_ids | {self._admin_group_id} + authorized_group_ids = set(policy.allowed_group_ids) | {policy.admin_group_id} authorized_group_ids.discard("") return bool(authorized_group_ids & set(user.groups)) - async def _check_group_memberships_async(self, *, client: httpx.AsyncClient, token: str) -> list[str]: + async def _check_group_memberships_async( + self, *, client: httpx.AsyncClient, token: str, policy: AuthenticationPolicy + ) -> list[str]: """ Check which allowed groups contain the current user through Graph. Args: client (httpx.AsyncClient): The asynchronous Graph HTTP client. token (str): The opaque Graph access token. + policy: Validated process-start authentication policy. Returns: list[str]: The allowed group IDs containing the current user. @@ -307,7 +283,7 @@ async def _check_group_memberships_async(self, *, client: httpx.AsyncClient, tok AuthenticationError: If Graph rejects or cannot complete the lookup. """ matched_group_ids: list[str] = [] - allowed_group_ids = self._allowed_group_ids | {self._admin_group_id} + allowed_group_ids = set(policy.allowed_group_ids) | {policy.admin_group_id} allowed_group_ids.discard("") sorted_group_ids = sorted(allowed_group_ids) for offset in range(0, len(sorted_group_ids), self._GRAPH_MAX_GROUP_IDS_PER_REQUEST): diff --git a/pyrit/backend/pyrit_backend.py b/pyrit/backend/pyrit_backend.py index fc77c44679..cb455c9eb5 100644 --- a/pyrit/backend/pyrit_backend.py +++ b/pyrit/backend/pyrit_backend.py @@ -40,24 +40,31 @@ def parse_args(*, args: list[str] | None = None) -> Namespace: the config file (~/.pyrit/.pyrit_conf by default, or --config-file). Examples: - # Start backend with default settings - pyrit_backend + # Start a loopback-only backend for local development + pyrit_backend --auth-mode local # Start with a custom config file - pyrit_backend --config-file ./my_config.yaml + pyrit_backend --auth-mode local --config-file ./my_config.yaml - # Start with a config file stored in Azure Blob Storage - pyrit_backend --config-file https://account.blob.core.windows.net/config/.pyrit_conf + # Start an authenticated shared backend + pyrit_backend --auth-mode entra --host 0.0.0.0 - # Start with custom port and host - pyrit_backend --host 0.0.0.0 --port 8080 - - # Start with auto-reload for development - pyrit_backend --reload + # Start with auto-reload for local development + pyrit_backend --auth-mode local --reload """, formatter_class=RawDescriptionHelpFormatter, ) + parser.add_argument( + "--auth-mode", + choices=("local", "entra"), + help=( + "Incoming-request authentication mode. 'local' deliberately disables Entra authentication " + "for loopback-only development; 'entra' requires complete ENTRA_* settings. " + "If omitted, startup requires complete Entra configuration." + ), + ) + parser.add_argument( "--host", type=str, @@ -125,14 +132,28 @@ def main(*, args: list[str] | None = None) -> int: # Forward config file to the FastAPI lifespan via env var if parsed_args.config_file is not None: os.environ["PYRIT_CONFIG_FILE"] = str(parsed_args.config_file) + if parsed_args.auth_mode is not None: + os.environ["PYRIT_AUTH_MODE"] = parsed_args.auth_mode if parsed_args.host not in ("localhost", "127.0.0.1", "::1"): - print( - f"WARNING: Binding pyrit_backend to {parsed_args.host}:{parsed_args.port} exposes the API " - "on a non-loopback interface. The PyRIT backend has no authentication; only do this on " - "a trusted network.", - file=sys.stderr, - ) + if parsed_args.auth_mode == "local": + print( + "ERROR: PYRIT_AUTH_MODE=local may only be used with a loopback host (localhost, 127.0.0.1, or ::1).", + file=sys.stderr, + ) + return 1 + if os.getenv("PYRIT_AUTH_MODE", "").strip().casefold() == "local": + print( + f"WARNING: Binding local-mode pyrit_backend to {parsed_args.host}:{parsed_args.port}; " + "the launcher must enforce loopback-only publication or equivalent network isolation.", + file=sys.stderr, + ) + else: + print( + f"WARNING: Binding pyrit_backend to {parsed_args.host}:{parsed_args.port} exposes the API " + "on a non-loopback interface. Configure complete Entra authentication before sharing it.", + file=sys.stderr, + ) try: _run_server( diff --git a/pyrit/backend/routes/auth.py b/pyrit/backend/routes/auth.py index c2ecd154ce..1ea883f4bb 100644 --- a/pyrit/backend/routes/auth.py +++ b/pyrit/backend/routes/auth.py @@ -10,7 +10,7 @@ from fastapi import APIRouter, Request -from pyrit.backend.middleware.auth import AuthenticatedUser, authorization_environment +from pyrit.backend.middleware.auth import AuthenticatedUser, get_authentication_policy router = APIRouter() _GRAPH_SCOPES = ["https://graph.microsoft.com/User.Read"] @@ -29,18 +29,14 @@ async def get_auth_config_async(request: Request) -> dict[str, str | bool | list dict: Auth configuration with enabled state, clientId, tenantId, allowedGroupIds, and delegated Microsoft Graph scopes. """ - environment = authorization_environment(request) - client_id = environment.get("ENTRA_CLIENT_ID", "").strip() - tenant_id = environment.get("ENTRA_TENANT_ID", "").strip() - allowed_group_ids = environment.get("ENTRA_ALLOWED_GROUP_IDS", "").strip() - enabled = bool(client_id and tenant_id and allowed_group_ids) + policy = get_authentication_policy(request) return { - "enabled": enabled, - "clientId": client_id, - "tenantId": tenant_id, - "allowedGroupIds": allowed_group_ids, - "scopes": list(_GRAPH_SCOPES) if enabled else [], + "enabled": policy.enabled, + "clientId": policy.client_id, + "tenantId": policy.tenant_id, + "allowedGroupIds": policy.allowed_group_ids_csv, + "scopes": list(_GRAPH_SCOPES) if policy.enabled else [], } @@ -50,7 +46,6 @@ async def get_auth_access_async(request: Request) -> dict[str, bool]: user = getattr(request.state, "user", None) is_admin = isinstance(user, AuthenticatedUser) and user.is_admin if user is None: - is_admin = ( - authorization_environment(request).get("PYRIT_ALLOW_UNAUTHENTICATED_ADMIN", "").strip().casefold() == "true" - ) + policy = get_authentication_policy(request) + is_admin = policy.mode == "local" and policy.allow_unauthenticated_admin return {"isAdmin": is_admin} diff --git a/pyrit/backend/services/runtime_lifecycle.py b/pyrit/backend/services/runtime_lifecycle.py index 53ba06918d..72aa8d8398 100644 --- a/pyrit/backend/services/runtime_lifecycle.py +++ b/pyrit/backend/services/runtime_lifecycle.py @@ -38,15 +38,6 @@ class RuntimeLifecycle: def __init__(self, *, app: FastAPI, source: ConfigurationFileService) -> None: """Bind lifecycle state to one app and its immutable configuration source.""" self.app = app - self.app.state.auth_environment = { - key: os.getenv(key, "") - for key in ( - "ENTRA_CLIENT_ID", - "ENTRA_TENANT_ID", - "ENTRA_ALLOWED_GROUP_IDS", - "PYRIT_ALLOW_UNAUTHENTICATED_ADMIN", - ) - } self.source = source self.edit_lock = asyncio.Lock() self.state = "initializing" diff --git a/pyrit/cli/_server_launcher.py b/pyrit/cli/_server_launcher.py index 77f7941370..d8268393a4 100644 --- a/pyrit/cli/_server_launcher.py +++ b/pyrit/cli/_server_launcher.py @@ -24,9 +24,14 @@ from dataclasses import dataclass, field from enum import Enum, auto from pathlib import Path +from typing import TYPE_CHECKING from urllib.parse import urlparse from pyrit.cli._config_reader import DEFAULT_SERVER_STARTUP_TIMEOUT +from pyrit.common.authentication import should_default_to_local_authentication + +if TYPE_CHECKING: + from collections.abc import Mapping _logger = logging.getLogger(__name__) _HEALTH_PROBE_TIMEOUT = 5.0 @@ -55,6 +60,7 @@ def _build_server_launch_plan( startup_timeout: float, executable: str, platform_name: str, + environment: Mapping[str, str] | None = None, ) -> _ServerLaunchPlan: """ Validate launch parameters and build platform-specific process settings. @@ -67,6 +73,7 @@ def _build_server_launch_plan( startup_timeout: Seconds to wait for readiness. executable: Python executable used to launch the backend module. platform_name: Operating-system family, such as ``posix`` or ``nt``. + environment: Process environment inherited by the backend. Returns: _ServerLaunchPlan: Validated command and process settings. @@ -91,6 +98,12 @@ def _build_server_launch_plan( "--port", str(port), ] + environment = os.environ if environment is None else environment + auth_mode = environment.get("PYRIT_AUTH_MODE", "").strip().casefold() + if auth_mode in {"local", "entra"}: + command.extend(["--auth-mode", auth_mode]) + elif should_default_to_local_authentication(environment=environment): + command.extend(["--auth-mode", "local"]) if config_file is not None: command.extend(["--config-file", str(config_file)]) if log_level is not None: diff --git a/pyrit/common/authentication.py b/pyrit/common/authentication.py new file mode 100644 index 0000000000..3a4c9eb02d --- /dev/null +++ b/pyrit/common/authentication.py @@ -0,0 +1,25 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +"""Shared incoming-request authentication environment settings.""" + +from collections.abc import Mapping + +AUTH_MODE_ENVIRONMENT_VARIABLE = "PYRIT_AUTH_MODE" +REQUIRED_ENTRA_ENVIRONMENT_VARIABLES = ( + "ENTRA_TENANT_ID", + "ENTRA_CLIENT_ID", + "ENTRA_ALLOWED_GROUP_IDS", +) + + +def should_default_to_local_authentication(*, environment: Mapping[str, str]) -> bool: + """ + Determine whether a local launcher should explicitly select local authentication. + + Returns: + bool: True only when no authentication intent or required Entra setting is present. + """ + return not environment.get(AUTH_MODE_ENVIRONMENT_VARIABLE, "").strip() and not any( + name in environment for name in REQUIRED_ENTRA_ENVIRONMENT_VARIABLES + ) diff --git a/test_support/analytics_benchmark.py b/test_support/analytics_benchmark.py index b39f5c5f9e..a5b823d22a 100644 --- a/test_support/analytics_benchmark.py +++ b/test_support/analytics_benchmark.py @@ -32,6 +32,7 @@ import httpx + from pyrit.backend.authentication_policy import AuthenticationPolicy from pyrit.backend.middleware.compatibility import CompatibilityAPI from pyrit.memory import SQLiteMemory from pyrit.models import ( @@ -97,7 +98,11 @@ async def sqlite_instance(benchmark_directory: Path) -> AsyncGenerator[SQLiteMem @pytest.fixture async def benchmark_app( - *, benchmark_directory: Path, sqlite_instance: SQLiteMemory, compatibility_id: str + *, + benchmark_directory: Path, + sqlite_instance: SQLiteMemory, + compatibility_id: str, + authentication_policy: AuthenticationPolicy, ) -> AsyncGenerator[CompatibilityAPI, None]: """Exercise production routes, middleware, startup and shutdown with isolated test configuration.""" from pyrit.backend.main import app as backend_app @@ -117,6 +122,7 @@ async def benchmark_app( app = CompatibilityAPI(routes=list(backend_app.router.routes), middleware=backend_app.user_middleware) app.exception_handlers.update(backend_app.exception_handlers) app.state.compatibility_id = compatibility_id + app.state.authentication_policy = authentication_policy source = ConfigurationFileService(config_file_value=str(benchmark_directory / "config.yaml")) app.state.configuration_file_service = source runtime = RuntimeLifecycle(app=app, source=source) diff --git a/test_support/fixtures.py b/test_support/fixtures.py index 4c157dc480..125549dcb7 100644 --- a/test_support/fixtures.py +++ b/test_support/fixtures.py @@ -14,6 +14,7 @@ if TYPE_CHECKING: from collections.abc import Generator, Iterator + from pyrit.backend.authentication_policy import AuthenticationPolicy from pyrit.memory import SQLiteMemory @@ -38,7 +39,7 @@ def use_sqlite_memory(sqlite_memory: SQLiteMemory) -> Generator[None, None, None @pytest.fixture(autouse=True) def compatibility_id() -> Iterator[str]: - """Supply startup provenance and initialize clients that deliberately skip lifespan.""" + """Supply startup provenance to clients that deliberately skip lifespan.""" from pyrit import _compatibility from pyrit.backend.main import app @@ -50,6 +51,17 @@ def compatibility_id() -> Iterator[str]: yield identity +@pytest.fixture(autouse=True) +def authentication_policy() -> Iterator[AuthenticationPolicy]: + """Supply local auth policy to backend apps that deliberately skip lifespan.""" + from pyrit.backend.authentication_policy import AuthenticationPolicy + from pyrit.backend.main import app + + policy = AuthenticationPolicy(mode="local") + with patch.object(app.state, "authentication_policy", policy, create=True): + yield policy + + @pytest.fixture def compatibility_headers(compatibility_id: str) -> dict[str, str]: """Provide the marker required by business API requests.""" diff --git a/tests/integration/backend/test_attack_analytics_integration.py b/tests/integration/backend/test_attack_analytics_integration.py index 65ae92d8cb..476138d7cf 100644 --- a/tests/integration/backend/test_attack_analytics_integration.py +++ b/tests/integration/backend/test_attack_analytics_integration.py @@ -16,7 +16,7 @@ benchmark_directory, sqlite_instance, ) -from test_support.fixtures import compatibility_headers, compatibility_id # noqa: F401 +from test_support.fixtures import authentication_policy, compatibility_headers, compatibility_id # noqa: F401 async def test_100k_attack_analytics_performance_async( diff --git a/tests/unit/backend/conftest.py b/tests/unit/backend/conftest.py index 09760b4026..d37c53770a 100644 --- a/tests/unit/backend/conftest.py +++ b/tests/unit/backend/conftest.py @@ -18,7 +18,7 @@ from pyrit.backend.services.manual_send_scheduler import get_manual_send_scheduler from pyrit.backend.services.message_send_service import get_message_send_service from pyrit.memory import SQLiteMemory -from test_support.fixtures import compatibility_headers, compatibility_id # noqa: F401 +from test_support.fixtures import authentication_policy, compatibility_headers, compatibility_id # noqa: F401 @pytest.fixture diff --git a/tests/unit/backend/test_analytics_lifecycle.py b/tests/unit/backend/test_analytics_lifecycle.py index 3968cb0cb2..20cd31beee 100644 --- a/tests/unit/backend/test_analytics_lifecycle.py +++ b/tests/unit/backend/test_analytics_lifecycle.py @@ -374,10 +374,9 @@ async def test_actual_app_lifespan_restarts_with_a_fresh_native_sdk_async( os.environ, { "PYRIT_CONFIG_FILE": str(path), - "ENTRA_CLIENT_ID": "", - "ENTRA_TENANT_ID": "", - "ENTRA_ALLOWED_GROUP_IDS": "", + "PYRIT_AUTH_MODE": "local", }, + clear=True, ), patch.object(ConfigurationLoader, "initialize_pyrit_async", new_callable=AsyncMock), patch("pyrit.setup.configuration_loader.DEFAULT_CONFIG_PATH", tmp_path / "missing-default.yaml"), diff --git a/tests/unit/backend/test_analytics_routes.py b/tests/unit/backend/test_analytics_routes.py index 2b9ae20973..02e1a766c5 100644 --- a/tests/unit/backend/test_analytics_routes.py +++ b/tests/unit/backend/test_analytics_routes.py @@ -21,6 +21,7 @@ from pyrit import _compatibility from pyrit.analytics import AttackResultAnalytics from pyrit.analytics._execution import AnalyticsExecution +from pyrit.backend.authentication_policy import resolve_authentication_policy from pyrit.backend.main import app as backend_app from pyrit.backend.middleware.auth import AuthenticatedUser, EntraAuthMiddleware from pyrit.backend.middleware.compatibility import CompatibilityAPI @@ -51,11 +52,13 @@ def analytics_app(*, tmp_path: Path, compatibility_id: str) -> Iterator[Compatib with patch.dict( os.environ, { + "PYRIT_AUTH_MODE": "entra", "ENTRA_CLIENT_ID": "test-client", "ENTRA_TENANT_ID": "test-tenant", "ENTRA_ALLOWED_GROUP_IDS": "test-group", "ENTRA_ADMIN_GROUP_ID": "test-admin-group", }, + clear=True, ): app = CompatibilityAPI( routes=list(backend_app.router.routes), @@ -63,6 +66,7 @@ def analytics_app(*, tmp_path: Path, compatibility_id: str) -> Iterator[Compatib exception_handlers=backend_app.exception_handlers, ) app.state.compatibility_id = compatibility_id + app.state.authentication_policy = resolve_authentication_policy() runtime = RuntimeLifecycle( app=app, source=ConfigurationFileService(config_file_value=str(tmp_path / "config.yaml")) ) diff --git a/tests/unit/backend/test_auth_middleware.py b/tests/unit/backend/test_auth_middleware.py index d78320e91f..039cbf6487 100644 --- a/tests/unit/backend/test_auth_middleware.py +++ b/tests/unit/backend/test_auth_middleware.py @@ -8,22 +8,43 @@ import httpx import pytest -from fastapi import HTTPException +from fastapi import FastAPI, HTTPException from starlette.requests import Request from starlette.responses import JSONResponse +from pyrit.backend.authentication_policy import AuthenticationPolicy from pyrit.backend.middleware.auth import AuthenticatedUser, AuthenticationError, EntraAuthMiddleware, require_admin -def _make_middleware(*, allowed_group_ids: str = "allowed-group", admin_group_id: str = "") -> EntraAuthMiddleware: - environment = { - "ENTRA_TENANT_ID": "test-tenant", - "ENTRA_CLIENT_ID": "test-client", - "ENTRA_ALLOWED_GROUP_IDS": allowed_group_ids, - "ENTRA_ADMIN_GROUP_ID": admin_group_id, - } - with patch.dict("os.environ", environment, clear=False): - return EntraAuthMiddleware(MagicMock()) +def _make_middleware() -> EntraAuthMiddleware: + return EntraAuthMiddleware(MagicMock()) + + +def _entra_policy( + *, allowed_group_ids: tuple[str, ...] = ("allowed-group",), admin_group_id: str = "" +) -> AuthenticationPolicy: + return AuthenticationPolicy( + mode="entra", + tenant_id="test-tenant", + client_id="test-client", + allowed_group_ids=allowed_group_ids, + admin_group_id=admin_group_id, + ) + + +def _request_with_policy(policy: AuthenticationPolicy) -> Request: + app = FastAPI() + app.state.authentication_policy = policy + return Request( + { + "type": "http", + "app": app, + "path": "/api/targets", + "root_path": "", + "method": "GET", + "headers": [], + } + ) def _response(*, status_code: int, data: dict[str, object]) -> MagicMock: @@ -40,59 +61,26 @@ def _client_context(client: AsyncMock) -> MagicMock: return context -@pytest.mark.parametrize( - "environment", - [ - {"ENTRA_TENANT_ID": "test-tenant", "ENTRA_CLIENT_ID": "", "ENTRA_ALLOWED_GROUP_IDS": ""}, - {"ENTRA_TENANT_ID": "", "ENTRA_CLIENT_ID": "test-client", "ENTRA_ALLOWED_GROUP_IDS": ""}, - {"ENTRA_TENANT_ID": "", "ENTRA_CLIENT_ID": "", "ENTRA_ALLOWED_GROUP_IDS": "allowed-group"}, - {"ENTRA_TENANT_ID": "test-tenant", "ENTRA_CLIENT_ID": "test-client", "ENTRA_ALLOWED_GROUP_IDS": ""}, - {"ENTRA_TENANT_ID": " ", "ENTRA_CLIENT_ID": " ", "ENTRA_ALLOWED_GROUP_IDS": " , "}, - ], -) -def test_init_rejects_incomplete_auth_configuration(environment: dict[str, str]) -> None: - with patch.dict("os.environ", environment, clear=False): - with pytest.raises(ValueError, match="Incomplete Entra ID configuration"): - EntraAuthMiddleware(MagicMock()) - - -def test_init_allows_auth_disabled_when_configuration_is_absent() -> None: - environment = {"ENTRA_TENANT_ID": "", "ENTRA_CLIENT_ID": "", "ENTRA_ALLOWED_GROUP_IDS": ""} - - with patch.dict("os.environ", environment, clear=False): - middleware = EntraAuthMiddleware(MagicMock()) - - assert middleware._enabled is False - - -def test_init_warns_when_admin_group_is_missing(caplog: pytest.LogCaptureFixture) -> None: - with caplog.at_level("WARNING"), patch.dict("os.environ", {"ENTRA_ADMIN_GROUP_ID": ""}, clear=False): - _make_middleware() - - assert "ENTRA_ADMIN_GROUP_ID is not set" in caplog.text - - def test_require_admin_rejects_anonymous_request_by_default() -> None: - request = Request({"type": "http"}) + request = _request_with_policy(AuthenticationPolicy(mode="local")) request.state.user = None - with patch.dict("os.environ", {"PYRIT_ALLOW_UNAUTHENTICATED_ADMIN": ""}, clear=False): - with pytest.raises(HTTPException) as error: - require_admin(request) + with pytest.raises(HTTPException) as error: + require_admin(request) assert error.value.status_code == 403 def test_require_admin_allows_explicit_local_development_override() -> None: - request = Request({"type": "http"}) + request = _request_with_policy(AuthenticationPolicy(mode="local", allow_unauthenticated_admin=True)) request.state.user = None - with patch.dict("os.environ", {"PYRIT_ALLOW_UNAUTHENTICATED_ADMIN": "true"}, clear=False): - require_admin(request) + require_admin(request) async def test_authenticate_with_graph_resolves_groups_when_restricted() -> None: - middleware = _make_middleware(allowed_group_ids="group-1") + middleware = _make_middleware() + policy = _entra_policy(allowed_group_ids=("group-1",)) client = AsyncMock(spec=httpx.AsyncClient) client.get.return_value = _response( status_code=200, @@ -106,7 +94,7 @@ async def test_authenticate_with_graph_resolves_groups_when_restricted() -> None client.post.return_value = _response(status_code=200, data={"value": ["group-1"]}) with patch("pyrit.backend.middleware.auth.httpx.AsyncClient", return_value=_client_context(client)): - result = await middleware._authenticate_with_graph_async(token="graph-token") + result = await middleware._authenticate_with_graph_async(token="graph-token", policy=policy) assert isinstance(result, AuthenticatedUser) assert result.email == "test@example.com" @@ -116,7 +104,8 @@ async def test_authenticate_with_graph_resolves_groups_when_restricted() -> None async def test_authenticate_with_graph_marks_admin_membership() -> None: - middleware = _make_middleware(allowed_group_ids="allowed-group", admin_group_id="admin-group") + middleware = _make_middleware() + policy = _entra_policy(admin_group_id="admin-group") client = AsyncMock(spec=httpx.AsyncClient) client.get.return_value = _response( status_code=200, @@ -125,10 +114,10 @@ async def test_authenticate_with_graph_marks_admin_membership() -> None: client.post.return_value = _response(status_code=200, data={"value": ["admin-group"]}) with patch("pyrit.backend.middleware.auth.httpx.AsyncClient", return_value=_client_context(client)): - result = await middleware._authenticate_with_graph_async(token="graph-token") + result = await middleware._authenticate_with_graph_async(token="graph-token", policy=policy) assert result.is_admin is True - assert middleware._is_authorized(result) is True + assert middleware._is_authorized(result, policy=policy) is True assert client.post.call_args.kwargs["json"] == {"groupIds": ["admin-group", "allowed-group"]} @@ -143,7 +132,7 @@ async def test_authenticate_with_graph_maps_profile_errors(graph_status: int, ex with patch("pyrit.backend.middleware.auth.httpx.AsyncClient", return_value=_client_context(client)): with pytest.raises(AuthenticationError) as error: - await middleware._authenticate_with_graph_async(token="graph-token") + await middleware._authenticate_with_graph_async(token="graph-token", policy=_entra_policy()) assert error.value.status_code == expected_status @@ -155,7 +144,7 @@ async def test_authenticate_with_graph_returns_service_unavailable_on_network_er with patch("pyrit.backend.middleware.auth.httpx.AsyncClient", return_value=_client_context(client)): with pytest.raises(AuthenticationError) as error: - await middleware._authenticate_with_graph_async(token="graph-token") + await middleware._authenticate_with_graph_async(token="graph-token", policy=_entra_policy()) assert error.value.status_code == 503 @@ -168,21 +157,22 @@ async def test_authenticate_with_graph_rejects_non_object_profile() -> None: with patch("pyrit.backend.middleware.auth.httpx.AsyncClient", return_value=_client_context(client)): with pytest.raises(AuthenticationError) as error: - await middleware._authenticate_with_graph_async(token="graph-token") + await middleware._authenticate_with_graph_async(token="graph-token", policy=_entra_policy()) assert error.value.status_code == 503 async def test_check_group_memberships_batches_allowed_group_ids() -> None: allowed_group_ids = [f"group-{index:02}" for index in range(21)] - middleware = _make_middleware(allowed_group_ids=",".join(reversed(allowed_group_ids))) + middleware = _make_middleware() + policy = _entra_policy(allowed_group_ids=tuple(reversed(allowed_group_ids))) client = AsyncMock(spec=httpx.AsyncClient) client.post.side_effect = [ _response(status_code=200, data={"value": ["group-00"]}), _response(status_code=200, data={"value": ["group-20"]}), ] - result = await middleware._check_group_memberships_async(client=client, token="graph-token") + result = await middleware._check_group_memberships_async(client=client, token="graph-token", policy=policy) assert result == ["group-00", "group-20"] assert client.post.await_count == 2 @@ -197,7 +187,11 @@ async def test_check_group_memberships_maps_graph_errors(graph_status: int, expe client.post.return_value = _response(status_code=graph_status, data={}) with pytest.raises(AuthenticationError) as error: - await middleware._check_group_memberships_async(client=client, token="graph-token") + await middleware._check_group_memberships_async( + client=client, + token="graph-token", + policy=_entra_policy(), + ) assert error.value.status_code == expected_status @@ -214,13 +208,14 @@ async def test_authenticate_with_graph_rejects_non_object_membership_data() -> N with patch("pyrit.backend.middleware.auth.httpx.AsyncClient", return_value=_client_context(client)): with pytest.raises(AuthenticationError) as error: - await middleware._authenticate_with_graph_async(token="graph-token") + await middleware._authenticate_with_graph_async(token="graph-token", policy=_entra_policy()) assert error.value.status_code == 503 async def test_authenticate_request_denies_and_does_not_cache_unauthorized_user() -> None: - middleware = _make_middleware(allowed_group_ids="allowed-group") + middleware = _make_middleware() + policy = _entra_policy() request = MagicMock() request.headers = {"Authorization": "Bearer graph-token"} user = AuthenticatedUser(oid="user-1", name="Test User", email="test@example.com", groups=["other-group"]) @@ -232,9 +227,9 @@ async def test_authenticate_request_denies_and_does_not_cache_unauthorized_user( return_value=user, ) as authenticate: with pytest.raises(AuthenticationError) as first_error: - await middleware._authenticate_request_async(request) + await middleware._authenticate_request_async(request, policy=policy) with pytest.raises(AuthenticationError): - await middleware._authenticate_request_async(request) + await middleware._authenticate_request_async(request, policy=policy) assert first_error.value.status_code == 403 assert first_error.value.detail == "You are not authorized to access this application" @@ -242,7 +237,8 @@ async def test_authenticate_request_denies_and_does_not_cache_unauthorized_user( async def test_authenticate_request_caches_successful_authorization() -> None: - middleware = _make_middleware(admin_group_id="admin-group") + middleware = _make_middleware() + policy = _entra_policy(admin_group_id="admin-group") request = MagicMock() request.headers = {"Authorization": "bearer graph-token"} user = AuthenticatedUser( @@ -259,13 +255,13 @@ async def test_authenticate_request_caches_successful_authorization() -> None: new_callable=AsyncMock, return_value=user, ) as authenticate: - first_result = await middleware._authenticate_request_async(request) - second_result = await middleware._authenticate_request_async(request) + first_result = await middleware._authenticate_request_async(request, policy=policy) + second_result = await middleware._authenticate_request_async(request, policy=policy) assert first_result == user assert second_result == user assert second_result.is_admin is True - authenticate.assert_awaited_once_with(token="graph-token") + authenticate.assert_awaited_once_with(token="graph-token", policy=policy) def test_auth_cache_expires_and_evicts_oldest_entry() -> None: @@ -298,15 +294,14 @@ async def test_authenticate_request_rejects_malformed_authorization_header(autho request.headers = {"Authorization": authorization} with pytest.raises(AuthenticationError) as error: - await middleware._authenticate_request_async(request) + await middleware._authenticate_request_async(request, policy=_entra_policy()) assert error.value.status_code == 401 async def test_dispatch_maps_authentication_error_to_json_response() -> None: middleware = _make_middleware() - request = MagicMock() - request.url.path = "/api/targets" + request = _request_with_policy(_entra_policy()) error = AuthenticationError(status_code=401, detail="Invalid or expired token") with patch.object(middleware, "_authenticate_request_async", new_callable=AsyncMock, side_effect=error): diff --git a/tests/unit/backend/test_auth_route.py b/tests/unit/backend/test_auth_route.py index 14ae1adc74..7797f50ceb 100644 --- a/tests/unit/backend/test_auth_route.py +++ b/tests/unit/backend/test_auth_route.py @@ -3,23 +3,30 @@ """Tests for the public authentication configuration route.""" -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock +from fastapi import FastAPI from starlette.requests import Request +from pyrit.backend.authentication_policy import AuthenticationPolicy from pyrit.backend.middleware.auth import AuthenticatedUser from pyrit.backend.routes.auth import get_auth_access_async, get_auth_config_async -async def test_get_auth_config_returns_enabled_graph_contract() -> None: - environment = { - "ENTRA_TENANT_ID": " tenant-id ", - "ENTRA_CLIENT_ID": " client-id ", - "ENTRA_ALLOWED_GROUP_IDS": " group-1,group-2 ", - } +def _request_with_policy(policy: AuthenticationPolicy) -> Request: + app = FastAPI() + app.state.authentication_policy = policy + return Request({"type": "http", "app": app}) - with patch.dict("os.environ", environment, clear=False): - result = await get_auth_config_async(Request({"type": "http"})) + +async def test_get_auth_config_returns_enabled_graph_contract() -> None: + policy = AuthenticationPolicy( + mode="entra", + tenant_id="tenant-id", + client_id="client-id", + allowed_group_ids=("group-1", "group-2"), + ) + result = await get_auth_config_async(_request_with_policy(policy)) assert result == { "enabled": True, @@ -31,14 +38,7 @@ async def test_get_auth_config_returns_enabled_graph_contract() -> None: async def test_get_auth_config_returns_disabled_contract_when_configuration_is_absent() -> None: - environment = { - "ENTRA_TENANT_ID": "", - "ENTRA_CLIENT_ID": "", - "ENTRA_ALLOWED_GROUP_IDS": "", - } - - with patch.dict("os.environ", environment, clear=False): - result = await get_auth_config_async(Request({"type": "http"})) + result = await get_auth_config_async(_request_with_policy(AuthenticationPolicy(mode="local"))) assert result == { "enabled": False, @@ -49,20 +49,6 @@ async def test_get_auth_config_returns_disabled_contract_when_configuration_is_a } -async def test_get_auth_config_does_not_enable_incomplete_configuration() -> None: - environment = { - "ENTRA_TENANT_ID": "tenant-id", - "ENTRA_CLIENT_ID": "", - "ENTRA_ALLOWED_GROUP_IDS": "group-1", - } - - with patch.dict("os.environ", environment, clear=False): - result = await get_auth_config_async(Request({"type": "http"})) - - assert result["enabled"] is False - assert result["scopes"] == [] - - async def test_get_auth_access_returns_authenticated_admin_state() -> None: request = MagicMock(spec=Request) request.state.user = AuthenticatedUser( @@ -77,8 +63,7 @@ async def test_get_auth_access_returns_authenticated_admin_state() -> None: async def test_get_auth_access_uses_explicit_local_admin_override() -> None: - request = Request({"type": "http"}) + request = _request_with_policy(AuthenticationPolicy(mode="local", allow_unauthenticated_admin=True)) request.state.user = None - with patch.dict("os.environ", {"PYRIT_ALLOW_UNAUTHENTICATED_ADMIN": "true"}, clear=False): - assert await get_auth_access_async(request) == {"isAdmin": True} + assert await get_auth_access_async(request) == {"isAdmin": True} diff --git a/tests/unit/backend/test_authentication_policy.py b/tests/unit/backend/test_authentication_policy.py new file mode 100644 index 0000000000..b35b65c3cd --- /dev/null +++ b/tests/unit/backend/test_authentication_policy.py @@ -0,0 +1,127 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +"""Tests for process-start incoming-request authentication policy.""" + +from collections.abc import Mapping + +import pytest + +from pyrit.backend.authentication_policy import ( + AuthenticationConfigurationError, + resolve_authentication_policy, +) + + +def _entra_environment(**overrides: str) -> dict[str, str]: + environment = { + "PYRIT_AUTH_MODE": "entra", + "ENTRA_TENANT_ID": "tenant-id", + "ENTRA_CLIENT_ID": "client-id", + "ENTRA_ALLOWED_GROUP_IDS": "group-1, group-2", + } + environment.update(overrides) + return environment + + +def test_resolve_authentication_policy_local_mode() -> None: + policy = resolve_authentication_policy(environment={"PYRIT_AUTH_MODE": " local "}) + + assert policy.mode == "local" + assert not policy.enabled + assert policy.allowed_group_ids == () + + +def test_resolve_authentication_policy_local_admin_override() -> None: + policy = resolve_authentication_policy( + environment={ + "PYRIT_AUTH_MODE": "local", + "PYRIT_ALLOW_UNAUTHENTICATED_ADMIN": "TRUE", + } + ) + assert policy.allow_unauthenticated_admin + + +def test_resolve_authentication_policy_explicit_entra_mode() -> None: + policy = resolve_authentication_policy( + environment=_entra_environment( + ENTRA_ALLOWED_GROUP_IDS=" group-2,group-1,group-2 ", + ENTRA_ADMIN_GROUP_ID=" admin-group ", + ) + ) + + assert policy.mode == "entra" + assert policy.enabled + assert policy.tenant_id == "tenant-id" + assert policy.client_id == "client-id" + assert policy.allowed_group_ids == ("group-2", "group-1") + assert policy.allowed_group_ids_csv == "group-2,group-1" + assert policy.admin_group_id == "admin-group" + + +def test_resolve_authentication_policy_infers_entra_from_complete_configuration() -> None: + environment = _entra_environment() + del environment["PYRIT_AUTH_MODE"] + + policy = resolve_authentication_policy(environment=environment) + + assert policy.mode == "entra" + + +@pytest.mark.parametrize("environment", [{}, {"UNRELATED_SETTING": "value"}]) +def test_resolve_authentication_policy_rejects_absent_configuration(environment: Mapping[str, str]) -> None: + with pytest.raises(AuthenticationConfigurationError, match="Authentication configuration is absent"): + resolve_authentication_policy(environment=environment) + + +@pytest.mark.parametrize( + "environment, missing_name", + [ + ({"ENTRA_TENANT_ID": "tenant-id"}, "ENTRA_CLIENT_ID"), + (_entra_environment(ENTRA_TENANT_ID=" "), "ENTRA_TENANT_ID"), + (_entra_environment(ENTRA_CLIENT_ID=""), "ENTRA_CLIENT_ID"), + (_entra_environment(ENTRA_ALLOWED_GROUP_IDS=" , "), "ENTRA_ALLOWED_GROUP_IDS"), + ], +) +def test_resolve_authentication_policy_rejects_incomplete_entra_configuration( + environment: Mapping[str, str], missing_name: str +) -> None: + with pytest.raises(AuthenticationConfigurationError, match=missing_name): + resolve_authentication_policy(environment=environment) + + +@pytest.mark.parametrize("mode", ["disabled", "optional", "true"]) +def test_resolve_authentication_policy_rejects_unknown_mode(mode: str) -> None: + with pytest.raises(AuthenticationConfigurationError, match="must be 'local' or 'entra'"): + resolve_authentication_policy(environment={"PYRIT_AUTH_MODE": mode}) + + +@pytest.mark.parametrize( + "name, value", + [ + ("ENTRA_TENANT_ID", ""), + ("ENTRA_CLIENT_ID", "client-id"), + ("ENTRA_ALLOWED_GROUP_IDS", "group-id"), + ("ENTRA_ADMIN_GROUP_ID", "admin-id"), + ], +) +def test_resolve_authentication_policy_rejects_entra_settings_in_local_mode(name: str, value: str) -> None: + with pytest.raises(AuthenticationConfigurationError, match="cannot be combined"): + resolve_authentication_policy(environment={"PYRIT_AUTH_MODE": "local", name: value}) + + +def test_resolve_authentication_policy_rejects_entra_admin_override() -> None: + environment = _entra_environment(PYRIT_ALLOW_UNAUTHENTICATED_ADMIN="true") + + with pytest.raises(AuthenticationConfigurationError, match="not allowed"): + resolve_authentication_policy(environment=environment) + + +def test_resolve_authentication_policy_rejects_invalid_boolean() -> None: + with pytest.raises(AuthenticationConfigurationError, match="must be 'true' or 'false'"): + resolve_authentication_policy( + environment={ + "PYRIT_AUTH_MODE": "local", + "PYRIT_ALLOW_UNAUTHENTICATED_ADMIN": "yes", + } + ) diff --git a/tests/unit/backend/test_compatibility.py b/tests/unit/backend/test_compatibility.py index 931f0fcc19..5e83f7ae5a 100644 --- a/tests/unit/backend/test_compatibility.py +++ b/tests/unit/backend/test_compatibility.py @@ -16,6 +16,7 @@ import pyrit from pyrit import _compatibility +from pyrit.backend.authentication_policy import resolve_authentication_policy from pyrit.backend.main import app, lifespan from pyrit.backend.middleware.auth import AuthenticatedUser, EntraAuthMiddleware from pyrit.backend.middleware.compatibility import CompatibilityAPI, CompatibilityMiddleware @@ -35,6 +36,7 @@ def guarded_app(*, monkeypatch: pytest.MonkeyPatch, compatibility_id: str) -> Fa monkeypatch.setenv("ENTRA_ADMIN_GROUP_ID", "admin-group") test_app = FastAPI(routes=list(app.router.routes), middleware=app.user_middleware) test_app.state.compatibility_id = compatibility_id + test_app.state.authentication_policy = resolve_authentication_policy() test_app.state.effects = [] def stateful_dependency() -> None: diff --git a/tests/unit/backend/test_main.py b/tests/unit/backend/test_main.py index 855f72971a..87add92255 100644 --- a/tests/unit/backend/test_main.py +++ b/tests/unit/backend/test_main.py @@ -23,7 +23,10 @@ from sqlalchemy import text from starlette.datastructures import State from starlette.exceptions import HTTPException as StarletteHTTPException +from uvicorn import Config, Server +from uvicorn.lifespan.on import LifespanOn +from pyrit.backend.authentication_policy import AuthenticationConfigurationError from pyrit.backend.main import SPAStaticFiles, app, lifespan, setup_frontend from pyrit.backend.models.converters import CreateConverterRequest from pyrit.backend.services.converter_service import ConverterService, get_converter_service @@ -141,7 +144,10 @@ async def close_async() -> None: @pytest.fixture(autouse=True) def isolated_lifespan_state(self) -> Iterator[None]: - with patch.object(app, "state", State()): + with ( + patch.object(app, "state", State()), + patch.dict(os.environ, {"PYRIT_AUTH_MODE": "local"}, clear=False), + ): yield @pytest.mark.parametrize("fail_during_lifespan", [False, True]) @@ -223,6 +229,46 @@ def read_stamp() -> str: mock_scenario_run_lifecycle.reconcile_interrupted_runs_async.assert_awaited_once() mock_scenario_run_lifecycle.shutdown_async.assert_awaited_once() + async def test_lifespan_rejects_absent_authentication_configuration(self) -> None: + with ( + patch.dict(os.environ, {}, clear=True), + patch("pyrit.backend.main.ConfigurationFileService") as configuration, + patch("pyrit.backend.main.setup_frontend") as frontend, + pytest.raises(AuthenticationConfigurationError, match="Authentication configuration is absent"), + ): + async with lifespan(FastAPI()): + pytest.fail("Startup accepted absent authentication configuration") + + configuration.assert_not_called() + frontend.assert_not_called() + + async def test_uvicorn_exits_before_listening_without_authentication_configuration(self) -> None: + test_app = FastAPI(lifespan=lifespan, middleware=app.user_middleware) + server = Server(Config(test_app, host="0.0.0.0", port=8765, lifespan="auto", log_config=None)) + with ( + patch.dict(os.environ, {}, clear=True), + patch("pyrit.backend.main.ConfigurationFileService") as configuration, + patch("pyrit.backend.main.setup_frontend") as frontend, + patch.object( + asyncio.get_running_loop(), + "create_server", + new_callable=AsyncMock, + side_effect=AssertionError("Invalid auth configuration must not open a listener"), + ) as create_listener, + patch.object(server, "main_loop", new_callable=AsyncMock) as serve_requests, + ): + await server.serve() + + assert isinstance(server.lifespan, LifespanOn) + assert server.lifespan.startup_failed + assert server.lifespan.should_exit + assert server.should_exit + assert not server.started + configuration.assert_not_called() + frontend.assert_not_called() + create_listener.assert_not_awaited() + serve_requests.assert_not_awaited() + async def test_lifespan_warns_when_custom_initializers_allowed(self, mock_scenario_run_lifecycle) -> None: """Test that lifespan logs a warning when allow_custom_initializers is enabled.""" fake_config = ConfigurationLoader(allow_custom_initializers=True) diff --git a/tests/unit/backend/test_pyrit_backend.py b/tests/unit/backend/test_pyrit_backend.py index eac89ff301..d03589b7d4 100644 --- a/tests/unit/backend/test_pyrit_backend.py +++ b/tests/unit/backend/test_pyrit_backend.py @@ -13,6 +13,7 @@ class TestParseArgs: def test_parse_args_defaults(self) -> None: args = pyrit_backend.parse_args(args=[]) + assert args.auth_mode is None assert args.host == "localhost" assert args.port == 8000 assert args.config_file is None @@ -38,6 +39,11 @@ def test_parse_args_accepts_reload(self) -> None: args = pyrit_backend.parse_args(args=["--reload"]) assert args.reload is True + def test_parse_args_accepts_auth_mode(self) -> None: + args = pyrit_backend.parse_args(args=["--auth-mode", "local"]) + + assert args.auth_mode == "local" + class TestMain: """Tests for pyrit_backend.main.""" @@ -67,6 +73,16 @@ def test_main_forwards_blob_config_uri_via_env(self, mock_run_server: MagicMock) assert os.environ["PYRIT_CONFIG_FILE"] == blob_uri + @patch("pyrit.backend.pyrit_backend._run_server") + def test_main_forwards_auth_mode_via_env(self, mock_run_server: MagicMock) -> None: + import os + + with patch.dict(os.environ, {}, clear=True): + result = pyrit_backend.main(args=["--auth-mode", "local"]) + + assert result == 0 + assert os.environ["PYRIT_AUTH_MODE"] == "local" + @patch("pyrit.backend.pyrit_backend._run_server") def test_main_passes_host_and_port(self, mock_run_server: MagicMock) -> None: pyrit_backend.main(args=["--host", "0.0.0.0", "--port", "9000"]) @@ -104,12 +120,33 @@ def test_main_forwards_reload_flag(self, mock_run_server: MagicMock) -> None: @patch("pyrit.backend.pyrit_backend._run_server") def test_main_warns_when_binding_non_loopback(self, mock_run_server: MagicMock, capsys) -> None: - pyrit_backend.main(args=["--host", "0.0.0.0", "--port", "9000"]) + with patch.dict("os.environ", {}, clear=True): + pyrit_backend.main(args=["--host", "0.0.0.0", "--port", "9000"]) captured = capsys.readouterr() assert "WARNING" in captured.err assert "0.0.0.0" in captured.err assert "9000" in captured.err + @patch("pyrit.backend.pyrit_backend._run_server") + def test_main_rejects_local_auth_on_non_loopback(self, mock_run_server: MagicMock, capsys) -> None: + with patch.dict("os.environ", {}, clear=True): + result = pyrit_backend.main(args=["--auth-mode", "local", "--host", "0.0.0.0"]) + + assert result == 1 + assert "may only be used with a loopback host" in capsys.readouterr().err + mock_run_server.assert_not_called() + + @patch("pyrit.backend.pyrit_backend._run_server") + def test_main_allows_environment_selected_local_mode_for_container_bind( + self, mock_run_server: MagicMock, capsys + ) -> None: + with patch.dict("os.environ", {"PYRIT_AUTH_MODE": "local"}, clear=True): + result = pyrit_backend.main(args=["--host", "0.0.0.0"]) + + assert result == 0 + assert "launcher must enforce loopback-only publication" in capsys.readouterr().err + mock_run_server.assert_called_once() + @patch("pyrit.backend.pyrit_backend._run_server") def test_main_no_warning_for_localhost(self, mock_run_server: MagicMock, capsys) -> None: pyrit_backend.main(args=["--host", "localhost"]) diff --git a/tests/unit/backend/test_runtime_lifecycle.py b/tests/unit/backend/test_runtime_lifecycle.py index aee78b2711..7e3ce74c6f 100644 --- a/tests/unit/backend/test_runtime_lifecycle.py +++ b/tests/unit/backend/test_runtime_lifecycle.py @@ -18,6 +18,7 @@ from starlette.requests import Request import pyrit.backend.services.runtime_lifecycle as lifecycle_module +from pyrit.backend.authentication_policy import AuthenticationPolicy from pyrit.backend.middleware.auth import require_admin from pyrit.backend.middleware.runtime import RuntimeAdmissionMiddleware from pyrit.backend.routes import configuration, health @@ -42,6 +43,7 @@ def runtime(tmp_path: Path) -> Generator[RuntimeLifecycle, None, None]: ) source = ConfigurationFileService(config_file_value=str(config_path)) app = FastAPI() + app.state.authentication_policy = AuthenticationPolicy(mode="local") with patch.dict(os.environ, {}, clear=True): service = RuntimeLifecycle(app=app, source=source) assert service.topology_supported @@ -558,7 +560,7 @@ async def test_invalid_cold_configuration_requires_restart_but_retains_raw_repai def test_authorization_policy_does_not_change_with_environment(runtime: RuntimeLifecycle) -> None: - runtime.app.state.auth_environment["PYRIT_ALLOW_UNAUTHENTICATED_ADMIN"] = "" + runtime.app.state.authentication_policy = AuthenticationPolicy(mode="local") request = Request({"type": "http", "app": runtime.app}) with patch.dict(os.environ, {"PYRIT_ALLOW_UNAUTHENTICATED_ADMIN": "true"}): with pytest.raises(Exception) as error: diff --git a/tests/unit/cli/test_server_launcher.py b/tests/unit/cli/test_server_launcher.py index 4118b0c95c..f3f217037f 100644 --- a/tests/unit/cli/test_server_launcher.py +++ b/tests/unit/cli/test_server_launcher.py @@ -190,6 +190,7 @@ def test_build_server_launch_plan_constructs_platform_settings( startup_timeout=3.5, executable="python", platform_name=platform_name, + environment={}, ) assert plan.base_url == "http://127.0.0.1:8765" @@ -201,6 +202,8 @@ def test_build_server_launch_plan_constructs_platform_settings( "127.0.0.1", "--port", "8765", + "--auth-mode", + "local", "--config-file", "config.yaml", "--log-level", @@ -211,6 +214,47 @@ def test_build_server_launch_plan_constructs_platform_settings( assert plan.startup_timeout == 3.5 +@pytest.mark.parametrize( + "environment, expected_mode", + [ + ({"PYRIT_AUTH_MODE": "entra"}, "entra"), + ({"PYRIT_AUTH_MODE": "local"}, "local"), + ], +) +def test_build_server_launch_plan_preserves_explicit_auth_mode(environment, expected_mode): + plan = _server_launcher._build_server_launch_plan( + host="localhost", + port=8000, + config_file=None, + log_level=None, + startup_timeout=3.5, + executable="python", + platform_name="posix", + environment=environment, + ) + + assert plan.command[plan.command.index("--auth-mode") + 1] == expected_mode + + +def test_build_server_launch_plan_preserves_complete_entra_inference(): + plan = _server_launcher._build_server_launch_plan( + host="localhost", + port=8000, + config_file=None, + log_level=None, + startup_timeout=3.5, + executable="python", + platform_name="posix", + environment={ + "ENTRA_TENANT_ID": "tenant", + "ENTRA_CLIENT_ID": "client", + "ENTRA_ALLOWED_GROUP_IDS": "group", + }, + ) + + assert "--auth-mode" not in plan.command + + @pytest.mark.parametrize("startup_timeout", [True, 0, -1, float("inf"), float("nan"), "1"]) def test_build_server_launch_plan_rejects_invalid_timeout(startup_timeout): with pytest.raises(ValueError, match="finite number greater than 0"): diff --git a/tests/unit/common/test_authentication.py b/tests/unit/common/test_authentication.py new file mode 100644 index 0000000000..922b8288c6 --- /dev/null +++ b/tests/unit/common/test_authentication.py @@ -0,0 +1,29 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +"""Tests for shared authentication environment settings.""" + +from collections.abc import Mapping + +import pytest + +from pyrit.common.authentication import should_default_to_local_authentication + + +@pytest.mark.parametrize( + "environment", + [ + {"PYRIT_AUTH_MODE": "entra"}, + {"ENTRA_TENANT_ID": "tenant"}, + {"ENTRA_CLIENT_ID": ""}, + {"ENTRA_ALLOWED_GROUP_IDS": "group"}, + ], +) +def test_should_default_to_local_authentication_preserves_auth_configuration( + environment: Mapping[str, str], +) -> None: + assert not should_default_to_local_authentication(environment=environment) + + +def test_should_default_to_local_authentication_accepts_unconfigured_environment() -> None: + assert should_default_to_local_authentication(environment={"UNRELATED": "value"}) diff --git a/tests/unit/docker/test_docker_compose.py b/tests/unit/docker/test_docker_compose.py index 962775c4bb..b5ccbcfb6a 100644 --- a/tests/unit/docker/test_docker_compose.py +++ b/tests/unit/docker/test_docker_compose.py @@ -25,3 +25,10 @@ def test_compose_publishes_only_on_loopback(*, service: str, port: int) -> None: compose = yaml.safe_load(compose_path.read_text(encoding="utf-8")) assert compose["services"][service]["ports"] == [f"127.0.0.1:{port}:{port}"] + + +def test_gui_compose_explicitly_selects_local_authentication() -> None: + compose_path = Path(__file__).resolve().parents[3] / "docker" / "docker-compose.yaml" + compose = yaml.safe_load(compose_path.read_text(encoding="utf-8")) + + assert "PYRIT_AUTH_MODE=local" in compose["services"]["pyrit-gui"]["environment"] diff --git a/tests/unit/frontend/test_dev.py b/tests/unit/frontend/test_dev.py new file mode 100644 index 0000000000..7f4f69d475 --- /dev/null +++ b/tests/unit/frontend/test_dev.py @@ -0,0 +1,149 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +"""Tests for the local frontend development launcher.""" + +import importlib.util +import io +import os +from pathlib import Path +from types import ModuleType +from unittest.mock import MagicMock, patch + + +def _load_dev_module() -> ModuleType: + path = Path(__file__).resolve().parents[3] / "frontend" / "dev.py" + spec = importlib.util.spec_from_file_location("pyrit_frontend_dev", path) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_start_backend_selects_local_authentication() -> None: + module = _load_dev_module() + process = MagicMock() + with ( + patch.object(module.os, "chdir"), + patch.object(module.subprocess, "run"), + patch.object(module.subprocess, "Popen", return_value=process) as popen, + patch.dict(os.environ, {}, clear=True), + ): + assert module.start_backend() is process + + command = popen.call_args.args[0] + environment = popen.call_args.kwargs["env"] + assert command[command.index("--host") + 1] == "localhost" + assert environment["PYRIT_AUTH_MODE"] == "local" + + +def test_start_backend_preserves_explicit_entra_authentication() -> None: + module = _load_dev_module() + environment = { + "PYRIT_AUTH_MODE": "entra", + "ENTRA_TENANT_ID": "tenant", + "ENTRA_CLIENT_ID": "client", + "ENTRA_ALLOWED_GROUP_IDS": "group", + } + with ( + patch.object(module.os, "chdir"), + patch.object(module.subprocess, "run"), + patch.object(module.subprocess, "Popen", return_value=MagicMock()) as popen, + patch.dict(os.environ, environment, clear=True), + ): + module.start_backend() + + child_environment = popen.call_args.kwargs["env"] + assert child_environment["PYRIT_AUTH_MODE"] == "entra" + assert child_environment["ENTRA_TENANT_ID"] == "tenant" + + +def test_start_backend_preserves_complete_entra_inference() -> None: + module = _load_dev_module() + environment = { + "ENTRA_TENANT_ID": "tenant", + "ENTRA_CLIENT_ID": "client", + "ENTRA_ALLOWED_GROUP_IDS": "group", + } + with ( + patch.object(module.os, "chdir"), + patch.object(module.subprocess, "run"), + patch.object(module.subprocess, "Popen", return_value=MagicMock()) as popen, + patch.dict(os.environ, environment, clear=True), + ): + module.start_backend() + + child_environment = popen.call_args.kwargs["env"] + assert "PYRIT_AUTH_MODE" not in child_environment + assert child_environment["ENTRA_TENANT_ID"] == "tenant" + + +def test_start_frontend_binds_vite_to_loopback() -> None: + module = _load_dev_module() + process = MagicMock() + with ( + patch.object(module.os, "chdir"), + patch.object(module.subprocess, "Popen", return_value=process) as popen, + patch.dict(os.environ, {}, clear=True), + ): + assert module.start_frontend() is process + + environment = popen.call_args.kwargs["env"] + assert environment["PYRIT_FRONTEND_HOST"] == "127.0.0.1" + + +def test_start_frontend_preserves_explicit_host_override() -> None: + module = _load_dev_module() + with ( + patch.object(module.os, "chdir"), + patch.object(module.subprocess, "Popen", return_value=MagicMock()) as popen, + patch.dict(os.environ, {"PYRIT_FRONTEND_HOST": "0.0.0.0"}, clear=True), + ): + module.start_frontend() + + assert popen.call_args.kwargs["env"]["PYRIT_FRONTEND_HOST"] == "0.0.0.0" + + +def test_forward_process_output_drains_child_pipe() -> None: + module = _load_dev_module() + process = MagicMock(stdout=io.BytesIO(b"vite ready\n")) + output = MagicMock() + output.buffer = MagicMock() + with patch.object(module.sys, "stdout", output): + thread = module._forward_process_output(process) + thread.join(timeout=1) + + assert not thread.is_alive() + output.buffer.write.assert_called_once_with(b"vite ready\n") + + +def test_parse_vite_port_accepts_loopback_ip_and_nondefault_port() -> None: + module = _load_dev_module() + + assert module._parse_vite_port(" ➜ Local: http://127.0.0.1:3001/\n") == 3001 + assert module._parse_vite_port(" ➜ Local: http://localhost:3002/\n") == 3002 + + +def test_vite_defaults_to_loopback() -> None: + vite_config = (Path(__file__).resolve().parents[3] / "frontend" / "vite.config.ts").read_text(encoding="utf-8") + + assert "process.env.PYRIT_FRONTEND_HOST ?? '127.0.0.1'" in vite_config + assert "host: frontendHost" in vite_config + assert "host: true" not in vite_config + + +def test_seeded_frontend_ci_selects_local_authentication() -> None: + playwright_config = (Path(__file__).resolve().parents[3] / "frontend" / "playwright.config.ts").read_text( + encoding="utf-8" + ) + + assert "--auth-mode local --host 127.0.0.1" in playwright_config + + +def test_analytics_frontend_ci_selects_local_authentication() -> None: + analytics_fixture = ( + Path(__file__).resolve().parents[3] / "frontend" / "e2e" / "fixtures" / "analytics_backend.py" + ).read_text(encoding="utf-8") + + assert '"PYRIT_AUTH_MODE": "local"' in analytics_fixture diff --git a/tests/unit/infra/test_bicep_topology.py b/tests/unit/infra/test_bicep_topology.py index 624ee3331b..68d7162f34 100644 --- a/tests/unit/infra/test_bicep_topology.py +++ b/tests/unit/infra/test_bicep_topology.py @@ -174,6 +174,7 @@ def test_application_reads_existing_state_and_preserves_authentication(self) -> assert "Microsoft.ManagedIdentity/userAssignedIdentities" in identity container_env = container_app["properties"]["template"]["containers"][0]["env"] environment = {value["name"]: value["value"] for value in container_env if isinstance(value, dict)} + assert environment["PYRIT_AUTH_MODE"] == "entra" assert "validatedAllowedGroupObjectIds" in environment["ENTRA_ALLOWED_GROUP_IDS"] assert "validatedAdminGroupObjectId" in environment["ENTRA_ADMIN_GROUP_ID"] assert "Microsoft.ManagedIdentity/userAssignedIdentities" in environment["AZURE_CLIENT_ID"] diff --git a/tests/unit/infra/test_docker_ci.py b/tests/unit/infra/test_docker_ci.py index fd94e1888e..55cf582281 100644 --- a/tests/unit/infra/test_docker_ci.py +++ b/tests/unit/infra/test_docker_ci.py @@ -447,7 +447,10 @@ def test_smoke_checks_use_real_image_entrypoints_and_cleanup( ] return port, endpoint = (8000, "/api/health") if mode == "gui" else (8888, "/api") - assert commands[0] == f"docker create --env PYRIT_MODE={mode} --publish 127.0.0.1::{port} pyrit:{source}-test" + auth_argument = " --env PYRIT_AUTH_MODE=local" if mode == "gui" else "" + assert commands[0] == ( + f"docker create --env PYRIT_MODE={mode}{auth_argument} --publish 127.0.0.1::{port} pyrit:{source}-test" + ) assert f"docker port test-container {port}/tcp" in commands assert any(command.endswith(f"http://127.0.0.1:49153{endpoint}") for command in commands) assert commands[-1] == "docker rm --force test-container"