From be48a734fe5fe444d4fb01aeff395572b3b58b59 Mon Sep 17 00:00:00 2001 From: dhamsey3 <73405591+dhamsey3@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:08:34 +0000 Subject: [PATCH] Close the paths that let strangers run code on the home VM Public registration was on by default, any registered user could deploy any image, and the deploy workflow ran it on the self-hosted runner with no resource limits. Together, anyone who could reach the API could run arbitrary containers on the home network. - Registration is off by default. Accounts are created with python -m app.admin create, which now also creates the tables on a fresh database. - Only admins deploy images outside the platform catalog. The catalog moved to services/catalog.py; the policy is checked when an application is registered, when it is deployed (covering applications registered before this change) and on the Kubernetes deployments path, which now returns 400/403 instead of a 500 for a bad image. - Every container the deploy workflow starts gets memory, CPU and process limits (repository variables, defaults 512m / 1 / 512) and no-new-privileges. - Tokens are never signed with a key published in this repository. Outside production/prod/staging, including the default "local", an unset or placeholder SECRET_KEY used to sign tokens with "change-me-in-production"; it is now replaced by a random per-process key. Production-like environments refuse to start. - Expired sandboxes were never removed: the sweeper ran docker rm from an API container that has no Docker, then marked them expired anyway. With dispatch configured it now triggers the new Teardown Sandbox workflow on the runner, and a failed request leaves the sandbox active so the next sweep retries. A manual run with deployment_id=all removes containers leaked by older versions. - The anonymous sandbox demo is capped at MAX_ACTIVE_SANDBOXES (default 3, 0 disables it) and answers 429 with Retry-After when full. A sandbox whose dispatch fails is marked failed so it frees its slot. - Kubernetes operations and cluster health/pod logs are admin only. - Workflow input checks written as "[[ regex ]] && (( range ))" were skipped when the regex failed, because bash -e ignores failures inside an && list. Each check is now its own line. --- .env.example | 7 +- .github/workflows/deploy-application.yaml | 23 +- .github/workflows/sandbox-deploy.yml | 9 +- .github/workflows/sandbox-teardown.yml | 53 +++++ README.md | 21 +- api/routes/applications.py | 7 + api/routes/catalog.py | 89 +------ api/routes/deployments.py | 8 +- api/routes/kubernetes.py | 10 +- api/routes/monitoring.py | 6 +- api/routes/sandbox.py | 26 +- app/admin.py | 4 +- app/config.py | 33 ++- services/application_service.py | 2 + services/catalog.py | 108 +++++++++ services/deployment_service.py | 2 + services/providers/vm_docker.py | 64 +++-- services/sandbox_service.py | 36 +++ services/sandbox_sweeper.py | 18 +- tests/conftest.py | 1 + tests/unit/test_namespace.py | 44 +++- tests/unit/test_platform_hub.py | 2 +- tests/unit/test_security_hardening.py | 277 ++++++++++++++++++++++ 23 files changed, 697 insertions(+), 153 deletions(-) create mode 100644 .github/workflows/sandbox-teardown.yml create mode 100644 services/catalog.py create mode 100644 tests/unit/test_security_hardening.py diff --git a/.env.example b/.env.example index b0d3a38..60a86a1 100644 --- a/.env.example +++ b/.env.example @@ -2,11 +2,13 @@ ENVIRONMENT=local APP_DEBUG=true DATABASE_URL=sqlite:///./idp.db REDIS_URL=redis://localhost:6379/0 -SECRET_KEY=replace-with-a-long-random-secret +# Generate with: openssl rand -hex 32. Placeholder values are never used to sign tokens. +SECRET_KEY= JWT_ALGORITHM=HS256 ACCESS_TOKEN_EXPIRE_MINUTES=60 ALLOWED_ORIGINS=http://localhost:8000,http://127.0.0.1:8000 -ENABLE_PUBLIC_REGISTRATION=true +# Leave off on any reachable server; create accounts with: python -m app.admin create +ENABLE_PUBLIC_REGISTRATION=false AWS_REGION=us-east-1 DEFAULT_INGRESS_DOMAIN=apps.local DEFAULT_DESTINATION_NAME=home-vm @@ -24,3 +26,4 @@ TERRAFORM_JOB_BACKEND=background TERRAFORM_JOB_REDIS_URL=redis://localhost:6379/1 TERRAFORM_JOB_QUEUE_NAME=terraform-jobs RATE_LIMIT_REQUESTS_PER_HOUR=100 +MAX_ACTIVE_SANDBOXES=3 diff --git a/.github/workflows/deploy-application.yaml b/.github/workflows/deploy-application.yaml index 6a91e78..7b97721 100644 --- a/.github/workflows/deploy-application.yaml +++ b/.github/workflows/deploy-application.yaml @@ -48,6 +48,11 @@ jobs: IDP_API_URL: ${{ vars.IDP_API_URL }} APPLICATION_BASE_URL: ${{ vars.APPLICATION_BASE_URL }} APPLICATION_BIND_ADDRESS: ${{ vars.APPLICATION_BIND_ADDRESS || '127.0.0.1' }} + # Every container this workflow starts gets these limits, so one deployment + # cannot starve the VM it shares with everything else. + CONTAINER_MEMORY_LIMIT: ${{ vars.CONTAINER_MEMORY_LIMIT || '512m' }} + CONTAINER_CPU_LIMIT: ${{ vars.CONTAINER_CPU_LIMIT || '1' }} + CONTAINER_PIDS_LIMIT: ${{ vars.CONTAINER_PIDS_LIMIT || '512' }} CALLBACK_TOKEN: ${{ secrets.DEPLOYMENT_CALLBACK_TOKEN }} steps: - name: Validate deployment request @@ -55,10 +60,16 @@ jobs: [[ "$APPLICATION_ID" =~ ^[0-9]+$ ]] [[ "$APPLICATION_NAME" =~ ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ ]] [[ "$APPLICATION_IMAGE" =~ ^[A-Za-z0-9./:@_-]+$ ]] - [[ "$CONTAINER_PORT" =~ ^[0-9]+$ ]] && (( CONTAINER_PORT >= 1 && CONTAINER_PORT <= 65535 )) - [[ "$HOST_PORT" =~ ^[0-9]+$ ]] && (( HOST_PORT >= 10000 && HOST_PORT <= 65535 )) + [[ "$CONTAINER_PORT" =~ ^[0-9]+$ ]] + (( CONTAINER_PORT >= 1 && CONTAINER_PORT <= 65535 )) + [[ "$HOST_PORT" =~ ^[0-9]+$ ]] + (( HOST_PORT >= 10000 && HOST_PORT <= 65535 )) [[ "$ATTEMPT_ID" =~ ^[a-f0-9]{32}$ ]] [[ "$APPLICATION_BIND_ADDRESS" =~ ^[0-9.]+$ ]] + [[ "$CONTAINER_MEMORY_LIMIT" =~ ^[0-9]+[mg]$ ]] + [[ "$CONTAINER_CPU_LIMIT" =~ ^[0-9]+(\.[0-9]+)?$ ]] + [[ "$CONTAINER_PIDS_LIMIT" =~ ^[0-9]+$ ]] + (( CONTAINER_PIDS_LIMIT >= 32 )) test -n "$IDP_API_URL" test -n "$CALLBACK_TOKEN" @@ -96,6 +107,10 @@ jobs: docker run -d \ --name "idp-db-$APPLICATION_ID" \ --restart unless-stopped \ + --memory "$CONTAINER_MEMORY_LIMIT" \ + --cpus "$CONTAINER_CPU_LIMIT" \ + --pids-limit "$CONTAINER_PIDS_LIMIT" \ + --security-opt no-new-privileges \ --network idp-apps \ --env-file "$secret_dir/postgres.env" \ --volume "idp-db-$APPLICATION_ID:/var/lib/postgresql/data" \ @@ -130,6 +145,10 @@ jobs: docker run -d \ --name "$container_name" \ --restart unless-stopped \ + --memory "$CONTAINER_MEMORY_LIMIT" \ + --cpus "$CONTAINER_CPU_LIMIT" \ + --pids-limit "$CONTAINER_PIDS_LIMIT" \ + --security-opt no-new-privileges \ --network idp-apps \ --env-file "$secret_file" \ --label "idp.application-id=$APPLICATION_ID" \ diff --git a/.github/workflows/sandbox-deploy.yml b/.github/workflows/sandbox-deploy.yml index 9398bc3..59af6f7 100644 --- a/.github/workflows/sandbox-deploy.yml +++ b/.github/workflows/sandbox-deploy.yml @@ -35,9 +35,12 @@ jobs: [[ "$DEPLOYMENT_ID" =~ ^[0-9]+$ ]] [[ "$SANDBOX_NAME" =~ ^sandbox-[a-z0-9-]+-[0-9]+$ ]] [[ "$SANDBOX_IMAGE" =~ ^(mpepping/whoami:latest|nginx:1\.25-alpine)$ ]] - [[ "$CONTAINER_PORT" =~ ^[0-9]+$ ]] && (( CONTAINER_PORT >= 1 && CONTAINER_PORT <= 65535 )) - [[ "$HOST_PORT" =~ ^[0-9]+$ ]] && (( HOST_PORT >= 20000 && HOST_PORT <= 65535 )) - [[ "$TTL_SECONDS" =~ ^[0-9]+$ ]] && (( TTL_SECONDS > 0 && TTL_SECONDS <= 900 )) + [[ "$CONTAINER_PORT" =~ ^[0-9]+$ ]] + (( CONTAINER_PORT >= 1 && CONTAINER_PORT <= 65535 )) + [[ "$HOST_PORT" =~ ^[0-9]+$ ]] + (( HOST_PORT >= 20000 && HOST_PORT <= 65535 )) + [[ "$TTL_SECONDS" =~ ^[0-9]+$ ]] + (( TTL_SECONDS > 0 && TTL_SECONDS <= 900 )) [[ "$APPLICATION_BIND_ADDRESS" =~ ^[0-9.]+$ ]] test -n "$IDP_API_URL" test -n "$CALLBACK_TOKEN" diff --git a/.github/workflows/sandbox-teardown.yml b/.github/workflows/sandbox-teardown.yml new file mode 100644 index 0000000..2c128f6 --- /dev/null +++ b/.github/workflows/sandbox-teardown.yml @@ -0,0 +1,53 @@ +name: Teardown Sandbox + +# The API container cannot reach the runner's Docker, so the sandbox sweeper +# asks this workflow to remove expired sandbox containers. +on: + repository_dispatch: + types: [teardown_sandbox] + workflow_dispatch: + inputs: + deployment_id: + description: Sandbox deployment ID, or "all" to remove every sandbox container on the runner + required: true + default: all + type: string + +permissions: + contents: read + +concurrency: + # Same group as Deploy Sandbox, so tearing down cancels a deploy still in progress. + group: sandbox-${{ github.event.client_payload.deployment_id || inputs.deployment_id }} + cancel-in-progress: true + +jobs: + teardown: + name: Remove sandbox container + runs-on: [self-hosted, linux, idp-vm] + timeout-minutes: 5 + env: + DEPLOYMENT_ID: ${{ github.event.client_payload.deployment_id || inputs.deployment_id }} + steps: + - name: Validate teardown request + run: | + [[ "$DEPLOYMENT_ID" =~ ^([0-9]+|all)$ ]] + if [ "$DEPLOYMENT_ID" = "all" ] && [ "$GITHUB_EVENT_NAME" != "workflow_dispatch" ]; then + echo "Removing every sandbox is only allowed from a manual run" + exit 1 + fi + + - name: Remove sandbox containers + run: | + filters=(--filter "label=idp.sandbox=true") + if [ "$DEPLOYMENT_ID" != "all" ]; then + filters+=(--filter "label=idp.deployment-id=$DEPLOYMENT_ID") + fi + containers="$(docker ps --all --quiet "${filters[@]}")" + if [ -z "$containers" ]; then + echo "No sandbox containers to remove" + exit 0 + fi + echo "Removing sandbox containers: $(echo "$containers" | tr '\n' ' ')" + echo "$containers" | xargs docker rm --force >/dev/null + docker image prune --force --filter "until=24h" >/dev/null 2>&1 || true diff --git a/README.md b/README.md index bf660ac..762222b 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,7 @@ cp .env.example .env python3 -m venv .venv source .venv/bin/activate pip install -r requirements.txt +python -m app.admin create --username admin # registration is off by default uvicorn app.main:app --reload # If venv creation fails on Debian/Ubuntu: @@ -226,6 +227,9 @@ The runner workflow: - keeps one persistent Docker volume per requested PostgreSQL database - generates PostgreSQL credentials on the VM and stores them in a `0600` environment file - injects `DATABASE_URL` into the application without sending the password through the API or GitHub payload +- limits every container to `CONTAINER_MEMORY_LIMIT` (default `512m`), `CONTAINER_CPU_LIMIT` (default `1`) and `CONTAINER_PIDS_LIMIT` (default `512`), adjustable as repository variables, and starts it with `no-new-privileges` + +Sandbox demos run on the same runner. The API's sweeper cannot reach the runner's Docker, so when a sandbox expires it dispatches the **Teardown Sandbox** workflow, which removes the container. If the dispatch fails, the sandbox stays active and the next sweep retries. To remove sandbox containers left behind by older versions, run **Actions > Teardown Sandbox > Run workflow** once with `deployment_id` set to `all`; this also stops any sandbox that is running at that moment. The VM-managed environment file is an initial single-server secret adapter, not a replacement for a managed secrets service. AWS deployments should use AWS Secrets Manager or an equivalent external secret provider. @@ -291,7 +295,7 @@ tests/ Unit tests ### Authentication -- `POST /auth/register` - Register a new user +- `POST /auth/register` - Register a new user (only when `ENABLE_PUBLIC_REGISTRATION=true`; off by default) - `POST /auth/login` - Authenticate and receive JWT token - `GET /auth/me` - Get current user info @@ -309,6 +313,8 @@ tests/ Unit tests ### Kubernetes Operations +Admin only: these act on the whole cluster, not on the caller's own resources. + - `POST /kubernetes/namespace/create` - Create a namespace - `POST /kubernetes/service/expose` - Expose a service - `POST /kubernetes/autoscaling/create` - Configure auto-scaling @@ -316,9 +322,9 @@ tests/ Unit tests ### Monitoring -- `GET /monitoring/cluster/health` - Get cluster health status +- `GET /monitoring/cluster/health` - Get cluster health status (admin only) - `GET /monitoring/metrics` - Prometheus metrics endpoint -- `GET /monitoring/logs/{pod}?namespace=default` - Retrieve pod logs +- `GET /monitoring/logs/{pod}?namespace=default` - Retrieve pod logs (admin only) ### Documentation @@ -342,7 +348,7 @@ For local development without a Kubernetes cluster or Terraform credentials, con KUBERNETES_DRY_RUN=true TERRAFORM_DRY_RUN=true DATABASE_URL=sqlite:///./idp.db -ENABLE_PUBLIC_REGISTRATION=true +ENABLE_PUBLIC_REGISTRATION=true # local only; keep it off on any reachable server ``` ### Install & Run @@ -476,7 +482,12 @@ helm upgrade --install idp-api helm/charts/idp-api \ - Redis-backed rate limiting with local fallback - Non-root Docker container - Security headers and restricted CORS origin configuration -- Production startup validation for weak/default `SECRET_KEY` +- Tokens are never signed with a published key: in `production`, `prod` or `staging` the app refuses to start without a `SECRET_KEY` of at least 32 characters; anywhere else an unset or placeholder key is replaced by a random per-process key, so sessions end on restart until a real key is set +- Public registration off by default; create accounts with `python -m app.admin create` +- Image policy: only admins can deploy images outside the platform catalog (`services/catalog.py`), checked when an application is registered and again when it is deployed +- Memory, CPU and process limits plus `no-new-privileges` on every container the deployment workflow starts +- Kubernetes and pod-log APIs restricted to admins +- The anonymous sandbox demo is capped at `MAX_ACTIVE_SANDBOXES` concurrent sandboxes (default 3; 0 disables it) - Helm defaults: public registration disabled, debug disabled, read-only root filesystem, dropped Linux capabilities - Kubernetes RBAC and network-policy examples - No hardcoded production secret requirement in Helm diff --git a/api/routes/applications.py b/api/routes/applications.py index 9a1d123..db2e0fb 100644 --- a/api/routes/applications.py +++ b/api/routes/applications.py @@ -16,6 +16,7 @@ mark_stale_application_deployments, upsert_application_runtime_deployment, ) +from services.catalog import ImageNotAllowedError, ensure_image_allowed from services.destination_service import destination_readiness router = APIRouter() @@ -88,6 +89,8 @@ def create_application_route( ): try: application = create_application(db, current_user, request) + except ImageNotAllowedError as exc: + raise HTTPException(status_code=403, detail=str(exc)) from exc except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc destination = db.query(Destination).filter(Destination.id == application.destination_id).one() @@ -109,6 +112,10 @@ def deploy_application_route( raise HTTPException(status_code=404, detail="Application not found") if application.source_type != "container_image": raise HTTPException(status_code=409, detail="Repository builds are not configured yet") + try: + ensure_image_allowed(current_user, application.image) + except ImageNotAllowedError as exc: + raise HTTPException(status_code=403, detail=str(exc)) from exc destination = db.query(Destination).filter(Destination.id == application.destination_id).one() try: dispatch_application_deployment(db, application, destination) diff --git a/api/routes/catalog.py b/api/routes/catalog.py index 90f65b8..563f5c5 100644 --- a/api/routes/catalog.py +++ b/api/routes/catalog.py @@ -1,93 +1,8 @@ from fastapi import APIRouter -router = APIRouter() - -APP_TEMPLATES = [ - { - "id": "deploy-demo-app", - "name": "Deploy Demo App", - "description": ( - "Tiny public whoami service with root and /health endpoints for " - "validating the deployment lifecycle." - ), - "default_app_name": "idp-demo", - "image": "mpepping/whoami:latest", - "port": 8000, - "replicas": 1, - "min_replicas": 1, - "max_replicas": 1, - "cpu_threshold": 70, - }, - { - "id": "nginx-web", - "name": "Nginx web app", - "description": "Official nginx image pinned to a stable tag for a tiny static web app.", - "default_app_name": "nginx-web", - "image": "nginx:1.25-alpine", - "port": 80, - "replicas": 2, - "min_replicas": 1, - "max_replicas": 5, - "cpu_threshold": 70, - }, - { - "id": "apache-web", - "name": "Apache web app", - "description": "Official Apache HTTP server image that works as a simple web deployment.", - "default_app_name": "apache-web", - "image": "httpd:2.4", - "port": 80, - "replicas": 2, - "min_replicas": 1, - "max_replicas": 5, - "cpu_threshold": 70, - }, - { - "id": "whoami-api", - "name": "Whoami API", - "description": "Tiny HTTP app that returns request metadata; useful for smoke tests.", - "default_app_name": "whoami-api", - "image": "traefik/whoami:v1.10", - "port": 80, - "replicas": 2, - "min_replicas": 1, - "max_replicas": 6, - "cpu_threshold": 70, - }, - { - "id": "hello-web", - "name": "Hello web app", - "description": "Nginx demo app that serves a simple hello page.", - "default_app_name": "hello-web", - "image": "nginxdemos/hello:plain-text", - "port": 80, - "replicas": 2, - "min_replicas": 1, - "max_replicas": 5, - "cpu_threshold": 70, - }, - { - "id": "echo-server", - "name": "Echo server", - "description": "Small request/response test service for ingress and header debugging.", - "default_app_name": "echo-server", - "image": "ealen/echo-server:0.9.2", - "port": 80, - "replicas": 2, - "min_replicas": 1, - "max_replicas": 5, - "cpu_threshold": 70, - }, -] +from services.catalog import APP_TEMPLATES, IMAGE_CATALOG -IMAGE_CATALOG = [ - {"label": "IDP demo app", "image": "mpepping/whoami:latest", "port": 8000}, - {"label": "nginx 1.25 alpine", "image": "nginx:1.25-alpine", "port": 80}, - {"label": "httpd 2.4", "image": "httpd:2.4", "port": 80}, - {"label": "traefik whoami", "image": "traefik/whoami:v1.10", "port": 80}, - {"label": "nginx hello demo", "image": "nginxdemos/hello:plain-text", "port": 80}, - {"label": "echo server", "image": "ealen/echo-server:0.9.2", "port": 80}, -] +router = APIRouter() @router.get("") diff --git a/api/routes/deployments.py b/api/routes/deployments.py index 0197ca1..a557e1d 100644 --- a/api/routes/deployments.py +++ b/api/routes/deployments.py @@ -17,6 +17,7 @@ provision_application, ) from services.application_service import mark_stale_runtime_deployments +from services.catalog import ImageNotAllowedError from services.providers.factory import get_deployment_provider router = APIRouter() @@ -89,7 +90,12 @@ def create_deployment_route( db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): - return provision_application(db, current_user, request) + try: + return provision_application(db, current_user, request) + except ImageNotAllowedError as exc: + raise HTTPException(status_code=403, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc @router.patch("/{id}", response_model=DeploymentResponse) diff --git a/api/routes/kubernetes.py b/api/routes/kubernetes.py index 203b809..060be5f 100644 --- a/api/routes/kubernetes.py +++ b/api/routes/kubernetes.py @@ -1,7 +1,7 @@ from fastapi import APIRouter, Depends, HTTPException from api.schemas import AutoscalingRequest, IngressRequest, NamespaceRequest, ServiceExposeRequest -from auth.rbac import get_current_user +from auth.rbac import require_role from services.autoscaling_service import create_hpa from services.ingress_service import create_ingress from services.k8s_service import create_namespace @@ -10,14 +10,14 @@ router = APIRouter() @router.post("/namespace/create") -def create_namespace_route(request: NamespaceRequest, current_user=Depends(get_current_user)): +def create_namespace_route(request: NamespaceRequest, current_user=Depends(require_role("admin"))): success = create_namespace(request.name) if success: return {"msg": f"Namespace '{request.name}' created."} raise HTTPException(status_code=500, detail="Failed to create namespace.") @router.post("/service/expose") -def expose_service_route(request: ServiceExposeRequest, current_user=Depends(get_current_user)): +def expose_service_route(request: ServiceExposeRequest, current_user=Depends(require_role("admin"))): success = expose_service( namespace=request.namespace, name=request.name, @@ -30,7 +30,7 @@ def expose_service_route(request: ServiceExposeRequest, current_user=Depends(get raise HTTPException(status_code=500, detail="Failed to expose service.") @router.post("/autoscaling/create") -def create_autoscaling_route(request: AutoscalingRequest, current_user=Depends(get_current_user)): +def create_autoscaling_route(request: AutoscalingRequest, current_user=Depends(require_role("admin"))): success = create_hpa( namespace=request.namespace, deployment=request.deployment, @@ -44,7 +44,7 @@ def create_autoscaling_route(request: AutoscalingRequest, current_user=Depends(g @router.post("/ingress/create") -def create_ingress_route(request: IngressRequest, current_user=Depends(get_current_user)): +def create_ingress_route(request: IngressRequest, current_user=Depends(require_role("admin"))): success = create_ingress( namespace=request.namespace, name=request.name, diff --git a/api/routes/monitoring.py b/api/routes/monitoring.py index ca2b4ed..2e65351 100644 --- a/api/routes/monitoring.py +++ b/api/routes/monitoring.py @@ -2,12 +2,12 @@ from services.monitoring_service import get_cluster_health, get_pod_logs from fastapi import Response from prometheus_client import generate_latest, CONTENT_TYPE_LATEST -from auth.rbac import get_current_user +from auth.rbac import get_current_user, require_role router = APIRouter() @router.get("/cluster/health") -def cluster_health(current_user=Depends(get_current_user)): +def cluster_health(current_user=Depends(require_role("admin"))): return get_cluster_health() @router.get("/metrics") @@ -15,5 +15,5 @@ def metrics(current_user=Depends(get_current_user)): return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST) @router.get("/logs/{pod}") -def logs(pod: str, namespace: str = Query("default"), current_user=Depends(get_current_user)): +def logs(pod: str, namespace: str = Query("default"), current_user=Depends(require_role("admin"))): return get_pod_logs(namespace, pod) diff --git a/api/routes/sandbox.py b/api/routes/sandbox.py index 00ac50c..4e528ee 100644 --- a/api/routes/sandbox.py +++ b/api/routes/sandbox.py @@ -5,7 +5,7 @@ from api.schemas import SandboxDemoRequest, SandboxDemoResponse from database.session import get_db -from services.sandbox_service import SANDBOX_TTL_SECONDS +from services.sandbox_service import SANDBOX_TTL_SECONDS, SandboxCapacityError from services.sandbox_service import create_sandbox_deployment, dispatch_sandbox_deployment router = APIRouter() @@ -43,17 +43,31 @@ def create_sandbox_demo( template_name = template or (request.template if request else None) if template_name is None: raise HTTPException(status_code=422, detail="template is required") + deployment = None try: deployment = create_sandbox_deployment(db, template_name) dispatch_sandbox_deployment(deployment) + except SandboxCapacityError as exc: + raise HTTPException( + status_code=429, + detail=str(exc), + headers={"Retry-After": str(exc.retry_after_seconds)}, + ) from exc except ValueError as exc: + _mark_failed(db, deployment, exc) raise HTTPException(status_code=400, detail=str(exc)) from exc except RuntimeError as exc: - if "deployment" in locals(): - deployment.status = "failed" - deployment.last_error = str(exc) - db.add(deployment) - db.commit() + _mark_failed(db, deployment, exc) raise HTTPException(status_code=424, detail=str(exc)) from exc return _response(deployment, template_name) + + +def _mark_failed(db: Session, deployment, exc: Exception) -> None: + """A sandbox that was never dispatched must not hold one of the capped slots.""" + if deployment is None: + return + deployment.status = "failed" + deployment.last_error = str(exc) + db.add(deployment) + db.commit() diff --git a/app/admin.py b/app/admin.py index 91e9c67..827d9ca 100644 --- a/app/admin.py +++ b/app/admin.py @@ -3,7 +3,7 @@ from auth.jwt_utils import get_password_hash from database.models import User -from database.session import SessionLocal +from database.session import SessionLocal, init_db def upsert_admin(username: str) -> None: @@ -34,6 +34,8 @@ def main() -> None: parser.add_argument("--username", default="admin") args = parser.parse_args() if args.command == "create": + # The tables may not exist yet if the API has never started on this database. + init_db() upsert_admin(args.username) diff --git a/app/config.py b/app/config.py index 780711d..e16de07 100644 --- a/app/config.py +++ b/app/config.py @@ -1,9 +1,16 @@ +import logging +import secrets from functools import lru_cache from pydantic import BaseSettings, Field, validator +logger = logging.getLogger(__name__) + PRODUCTION_ENVIRONMENTS = {"production", "prod", "staging"} DEFAULT_SECRET_KEY = "change-me-in-production" +# Values published in this repository. Anyone can sign tokens with them, so +# they are treated exactly like an unset key. +PLACEHOLDER_SECRET_KEYS = {"", DEFAULT_SECRET_KEY, "replace-with-a-long-random-secret"} class Settings(BaseSettings): @@ -13,11 +20,11 @@ class Settings(BaseSettings): DEBUG: bool = Field(default=True, env="APP_DEBUG") DATABASE_URL: str = Field(default="sqlite:///./idp.db", env="DATABASE_URL") REDIS_URL: str = Field(default="redis://localhost:6379/0", env="REDIS_URL") - SECRET_KEY: str = Field(default=DEFAULT_SECRET_KEY, env="SECRET_KEY") + SECRET_KEY: str = Field(default="", env="SECRET_KEY") JWT_ALGORITHM: str = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES: int = Field(default=60, env="ACCESS_TOKEN_EXPIRE_MINUTES") ALLOWED_ORIGINS: str = Field(default="http://localhost:8000,http://127.0.0.1:8000", env="ALLOWED_ORIGINS") - ENABLE_PUBLIC_REGISTRATION: bool = Field(default=True, env="ENABLE_PUBLIC_REGISTRATION") + ENABLE_PUBLIC_REGISTRATION: bool = Field(default=False, env="ENABLE_PUBLIC_REGISTRATION") AWS_REGION: str = Field(default="us-east-1", env="AWS_REGION") DEFAULT_INGRESS_DOMAIN: str = Field(default="apps.local", env="DEFAULT_INGRESS_DOMAIN") DEFAULT_DESTINATION_NAME: str = Field(default="home-vm", env="DEFAULT_DESTINATION_NAME") @@ -40,16 +47,32 @@ class Settings(BaseSettings): RATE_LIMIT_REQUESTS_PER_HOUR: int = Field(default=100, env="RATE_LIMIT_REQUESTS_PER_HOUR") REQUIRE_AUTH_FOR_PLATFORM_APIS: bool = Field(default=True, env="REQUIRE_AUTH_FOR_PLATFORM_APIS") ENABLE_SANDBOX_SWEEPER: bool = Field(default=True, env="ENABLE_SANDBOX_SWEEPER") + MAX_ACTIVE_SANDBOXES: int = Field(default=3, env="MAX_ACTIVE_SANDBOXES") PREVIEW_ROUTING_ENABLED: bool = Field(default=False, env="PREVIEW_ROUTING_ENABLED") PREVIEW_ROUTING_DOMAIN: str = Field(default="127.0.0.1.nip.io", env="PREVIEW_ROUTING_DOMAIN") PREVIEW_ROUTING_SCHEME: str = Field(default="http", env="PREVIEW_ROUTING_SCHEME") PREVIEW_ROUTING_PORT: int = Field(default=0, env="PREVIEW_ROUTING_PORT") @validator("SECRET_KEY") - def require_strong_secret_for_non_local(cls, value, values): + def require_unguessable_secret(cls, value, values): environment = values.get("ENVIRONMENT", "local").lower() - if environment in PRODUCTION_ENVIRONMENTS and (value == DEFAULT_SECRET_KEY or len(value) < 32): - raise ValueError("SECRET_KEY must be changed to a random value of at least 32 characters") + production_like = environment in PRODUCTION_ENVIRONMENTS + if value in PLACEHOLDER_SECRET_KEYS: + if production_like: + raise ValueError("SECRET_KEY must be set to a random value of at least 32 characters") + logger.warning( + "SECRET_KEY is not set (or is a published placeholder); using a random key for this " + "process. Tokens stop working when the app restarts. Set SECRET_KEY to keep sessions." + ) + return secrets.token_urlsafe(48) + if production_like and len(value) < 32: + raise ValueError("SECRET_KEY must be set to a random value of at least 32 characters") + return value + + @validator("MAX_ACTIVE_SANDBOXES") + def validate_max_active_sandboxes(cls, value): + if value < 0: + raise ValueError("MAX_ACTIVE_SANDBOXES must be 0 or more") return value @validator("TERRAFORM_JOB_BACKEND") diff --git a/services/application_service.py b/services/application_service.py index 7d876db..c0b09fa 100644 --- a/services/application_service.py +++ b/services/application_service.py @@ -8,6 +8,7 @@ from app.config import settings from api.schemas import ApplicationCreateRequest from database.models import Application, Deployment, Destination, User +from services.catalog import ensure_image_allowed from services.deployment_service import validate_docker_image from services.destination_service import destination_readiness, resources_readiness @@ -34,6 +35,7 @@ def create_application( if request.image: validate_docker_image(request.image) + ensure_image_allowed(user, request.image) readiness = destination_readiness(destination) requested_resources = request.resource_requests diff --git a/services/catalog.py b/services/catalog.py new file mode 100644 index 0000000..3056fa2 --- /dev/null +++ b/services/catalog.py @@ -0,0 +1,108 @@ +"""The platform's curated catalog, and the image policy built on it. + +Admins can deploy any image. Everyone else is limited to images listed here, +because a deployment runs the image on the platform's own hosts. +""" + +APP_TEMPLATES = [ + { + "id": "deploy-demo-app", + "name": "Deploy Demo App", + "description": ( + "Tiny public whoami service with root and /health endpoints for " + "validating the deployment lifecycle." + ), + "default_app_name": "idp-demo", + "image": "mpepping/whoami:latest", + "port": 8000, + "replicas": 1, + "min_replicas": 1, + "max_replicas": 1, + "cpu_threshold": 70, + }, + { + "id": "nginx-web", + "name": "Nginx web app", + "description": "Official nginx image pinned to a stable tag for a tiny static web app.", + "default_app_name": "nginx-web", + "image": "nginx:1.25-alpine", + "port": 80, + "replicas": 2, + "min_replicas": 1, + "max_replicas": 5, + "cpu_threshold": 70, + }, + { + "id": "apache-web", + "name": "Apache web app", + "description": "Official Apache HTTP server image that works as a simple web deployment.", + "default_app_name": "apache-web", + "image": "httpd:2.4", + "port": 80, + "replicas": 2, + "min_replicas": 1, + "max_replicas": 5, + "cpu_threshold": 70, + }, + { + "id": "whoami-api", + "name": "Whoami API", + "description": "Tiny HTTP app that returns request metadata; useful for smoke tests.", + "default_app_name": "whoami-api", + "image": "traefik/whoami:v1.10", + "port": 80, + "replicas": 2, + "min_replicas": 1, + "max_replicas": 6, + "cpu_threshold": 70, + }, + { + "id": "hello-web", + "name": "Hello web app", + "description": "Nginx demo app that serves a simple hello page.", + "default_app_name": "hello-web", + "image": "nginxdemos/hello:plain-text", + "port": 80, + "replicas": 2, + "min_replicas": 1, + "max_replicas": 5, + "cpu_threshold": 70, + }, + { + "id": "echo-server", + "name": "Echo server", + "description": "Small request/response test service for ingress and header debugging.", + "default_app_name": "echo-server", + "image": "ealen/echo-server:0.9.2", + "port": 80, + "replicas": 2, + "min_replicas": 1, + "max_replicas": 5, + "cpu_threshold": 70, + }, +] + +IMAGE_CATALOG = [ + {"label": "IDP demo app", "image": "mpepping/whoami:latest", "port": 8000}, + {"label": "nginx 1.25 alpine", "image": "nginx:1.25-alpine", "port": 80}, + {"label": "httpd 2.4", "image": "httpd:2.4", "port": 80}, + {"label": "traefik whoami", "image": "traefik/whoami:v1.10", "port": 80}, + {"label": "nginx hello demo", "image": "nginxdemos/hello:plain-text", "port": 80}, + {"label": "echo server", "image": "ealen/echo-server:0.9.2", "port": 80}, +] + +CATALOG_IMAGES = frozenset( + [template["image"] for template in APP_TEMPLATES] + [entry["image"] for entry in IMAGE_CATALOG] +) + + +class ImageNotAllowedError(Exception): + """The user's role does not allow deploying this image.""" + + +def ensure_image_allowed(user, image) -> None: + if user.role == "admin" or image in CATALOG_IMAGES: + return + raise ImageNotAllowedError( + "Only images from the platform catalog can be deployed. Ask an administrator to deploy custom images." + ) diff --git a/services/deployment_service.py b/services/deployment_service.py index f257950..cf6537b 100644 --- a/services/deployment_service.py +++ b/services/deployment_service.py @@ -10,6 +10,7 @@ from database.models import Deployment, User from services.kubernetes_client import get_k8s_client from services.autoscaling_service import create_hpa +from services.catalog import ensure_image_allowed from services.ingress_service import create_ingress from services.k8s_service import create_namespace from services.service_service import expose_service @@ -146,6 +147,7 @@ def get_kubernetes_deployment_status(namespace: str, name: str) -> Dict[str, Opt def provision_application(db: Session, user: User, request: DeploymentCreateRequest) -> Deployment: validate_docker_image(request.image) + ensure_image_allowed(user, request.image) namespace = request.namespace or _default_namespace(user, request.name) ingress_host = request.ingress_host or _default_host(user, request.name) deployment = Deployment( diff --git a/services/providers/vm_docker.py b/services/providers/vm_docker.py index 63e4f39..1c106f5 100644 --- a/services/providers/vm_docker.py +++ b/services/providers/vm_docker.py @@ -21,38 +21,40 @@ def sandbox_container_names(deployment: Deployment) -> list[str]: return list(dict.fromkeys(names)) +def _repository_dispatch(event_type: str, client_payload: dict) -> None: + response = httpx.post( + f"https://api.github.com/repos/{settings.GITHUB_REPOSITORY}/dispatches", + headers={ + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {settings.GITHUB_DISPATCH_TOKEN}", + "X-GitHub-Api-Version": "2022-11-28", + }, + json={"event_type": event_type, "client_payload": client_payload}, + timeout=20, + ) + if response.status_code != 204: + detail = response.text[:500] if response.text else "no response body" + accepted_permissions = response.headers.get("x-accepted-github-permissions") + if accepted_permissions: + detail = f"{detail} Required permissions: {accepted_permissions}" + raise RuntimeError(f"GitHub rejected the {event_type} dispatch with status {response.status_code}: {detail}") + + class VMRunnerProvider(BaseDeploymentProvider): def dispatch(self, deployment: Deployment) -> None: if not settings.GITHUB_DISPATCH_TOKEN: raise ValueError("GitHub sandbox dispatch is not configured") - response = httpx.post( - f"https://api.github.com/repos/{settings.GITHUB_REPOSITORY}/dispatches", - headers={ - "Accept": "application/vnd.github+json", - "Authorization": f"Bearer {settings.GITHUB_DISPATCH_TOKEN}", - "X-GitHub-Api-Version": "2022-11-28", - }, - json={ - "event_type": "deploy_sandbox", - "client_payload": { - "deployment_id": str(deployment.id), - "name": deployment.name, - "image": deployment.image, - "container_port": str(deployment.container_port), - "host_port": str(deployment.port), - "ttl_seconds": str(SANDBOX_TTL_SECONDS), - }, + _repository_dispatch( + "deploy_sandbox", + { + "deployment_id": str(deployment.id), + "name": deployment.name, + "image": deployment.image, + "container_port": str(deployment.container_port), + "host_port": str(deployment.port), + "ttl_seconds": str(SANDBOX_TTL_SECONDS), }, - timeout=20, ) - if response.status_code != 204: - detail = response.text[:500] if response.text else "no response body" - accepted_permissions = response.headers.get("x-accepted-github-permissions") - if accepted_permissions: - detail = f"{detail} Required permissions: {accepted_permissions}" - raise RuntimeError( - f"GitHub rejected the sandbox dispatch with status {response.status_code}: {detail}" - ) def get_logs(self, deployment: Deployment, tail: int = 100) -> list[str]: metadata = deployment.metadata_json or {} @@ -78,6 +80,16 @@ def get_logs(self, deployment: Deployment, tail: int = 100) -> list[str]: return persisted_logs.splitlines() or [TERMINATED_LOG_MESSAGE] def teardown(self, deployment: Deployment) -> None: + """Removes the sandbox container. Raises if the removal could not be requested. + + Sandboxes run on the self-hosted runner's Docker, which the API container + cannot reach, so when dispatch is configured the runner removes the + container (.github/workflows/sandbox-teardown.yml). Without dispatch the + API is a local development process sharing Docker with its sandboxes. + """ + if settings.GITHUB_DISPATCH_TOKEN: + _repository_dispatch("teardown_sandbox", {"deployment_id": str(deployment.id)}) + return for container_name in sandbox_container_names(deployment): try: subprocess.run( diff --git a/services/sandbox_service.py b/services/sandbox_service.py index 22dc1ee..7b9ec4a 100644 --- a/services/sandbox_service.py +++ b/services/sandbox_service.py @@ -3,6 +3,7 @@ from sqlalchemy.orm import Session +from app.config import settings from database.models import Deployment from services.providers.factory import get_deployment_provider from services.providers.vm_docker import SANDBOX_TTL_SECONDS @@ -20,6 +21,40 @@ } +# Sandboxes in these states may have a container on the runner. Expired-but-unswept +# ones still count: their container is running until the sweeper removes it. +ACTIVE_SANDBOX_STATUSES = ("queued", "deploying", "running") +MIN_RETRY_AFTER_SECONDS = 30 + + +class SandboxCapacityError(Exception): + def __init__(self, message: str, retry_after_seconds: int): + super().__init__(message) + self.retry_after_seconds = retry_after_seconds + + +def ensure_sandbox_capacity(db: Session, now: datetime) -> None: + """Anyone can start a sandbox without logging in, so cap how many run at once.""" + if settings.MAX_ACTIVE_SANDBOXES == 0: + raise SandboxCapacityError("Sandbox demos are disabled", retry_after_seconds=3600) + expiries = [ + row[0] + for row in db.query(Deployment.expires_at).filter( + Deployment.is_sandbox.is_(True), + Deployment.status.in_(ACTIVE_SANDBOX_STATUSES), + ) + ] + if len(expiries) < settings.MAX_ACTIVE_SANDBOXES: + return + # SQLite hands back naive datetimes; they were stored as UTC. + known = [expiry if expiry.tzinfo else expiry.replace(tzinfo=timezone.utc) for expiry in expiries if expiry] + seconds_until_free = int((min(known) - now).total_seconds()) if known else 0 + raise SandboxCapacityError( + "All sandbox slots are in use. Try again in a few minutes.", + retry_after_seconds=max(seconds_until_free, MIN_RETRY_AFTER_SECONDS), + ) + + def sandbox_template(name: str) -> dict[str, Any]: template = SANDBOX_TEMPLATES.get(name) if template is None: @@ -34,6 +69,7 @@ def sandbox_host_port(deployment_id: int) -> int: def create_sandbox_deployment(db: Session, template_name: str) -> Deployment: template = sandbox_template(template_name) now = datetime.now(timezone.utc) + ensure_sandbox_capacity(db, now) deployment = Deployment( owner_id=0, name=f"sandbox-{template_name}", diff --git a/services/sandbox_sweeper.py b/services/sandbox_sweeper.py index b26991c..9b06a0e 100644 --- a/services/sandbox_sweeper.py +++ b/services/sandbox_sweeper.py @@ -36,17 +36,31 @@ def sweep_expired_sandboxes(db: Session, now: Optional[datetime] = None) -> int: ) .all() ) + swept = 0 for deployment in expired: - remove_sandbox_container(deployment) + try: + remove_sandbox_container(deployment) + except Exception as exc: + # Leave it active so the next sweep retries; marking it expired + # here would leave the container running with nothing tracking it. + logger.warning("Could not tear down sandbox %s: %s", deployment.id, exc) + deployment.metadata_json = { + **(deployment.metadata_json or {}), + "teardown_error": str(exc), + "teardown_attempted_at": now.isoformat(), + } + db.add(deployment) + continue deployment.status = "expired" deployment.metadata_json = { **(deployment.metadata_json or {}), "swept_at": now.isoformat(), } db.add(deployment) + swept += 1 if expired: db.commit() - return len(expired) + return swept async def sandbox_sweeper_loop(interval_seconds: int = SANDBOX_SWEEP_INTERVAL_SECONDS) -> None: diff --git a/tests/conftest.py b/tests/conftest.py index 56aaed3..3bf76b8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,6 +4,7 @@ os.environ.setdefault("DATABASE_URL", "sqlite:///./test_idp.db") os.environ.setdefault("ENABLE_SANDBOX_SWEEPER", "false") os.environ.setdefault("KUBERNETES_DRY_RUN", "true") +os.environ.setdefault("ENABLE_PUBLIC_REGISTRATION", "true") import pytest from fastapi.testclient import TestClient diff --git a/tests/unit/test_namespace.py b/tests/unit/test_namespace.py index e3d3873..8778584 100644 --- a/tests/unit/test_namespace.py +++ b/tests/unit/test_namespace.py @@ -1,10 +1,46 @@ -def test_create_namespace(client): - client.post("/auth/register", json={"username": "namespacetest", "password": "testpass123"}) - login = client.post("/auth/login", json={"username": "namespacetest", "password": "testpass123"}) - token = login.json()["access_token"] +from auth.jwt_utils import create_access_token, get_password_hash +from database.models import User +from database.session import SessionLocal + + +def _token_for(username: str, role: str) -> str: + db = SessionLocal() + try: + user = db.query(User).filter(User.username == username).first() + if user is None: + user = User(username=username, hashed_password=get_password_hash("testpass123"), role=role) + db.add(user) + db.commit() + db.refresh(user) + return create_access_token({"sub": str(user.id), "role": user.role}) + finally: + db.close() + + +def test_create_namespace_requires_admin(client): + token = _token_for("namespace-user", "user") + response = client.post( + "/kubernetes/namespace/create", + json={"name": "test-ns"}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == 403 + + +def test_admin_can_create_namespace(client): + token = _token_for("namespace-admin", "admin") response = client.post( "/kubernetes/namespace/create", json={"name": "test-ns"}, headers={"Authorization": f"Bearer {token}"}, ) assert response.status_code in [200, 500] # 500 if cluster not available + + +def test_pod_logs_require_admin(client): + token = _token_for("namespace-user", "user") + response = client.get( + "/monitoring/logs/coredns?namespace=kube-system", + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == 403 diff --git a/tests/unit/test_platform_hub.py b/tests/unit/test_platform_hub.py index 99a7ddd..1396b2e 100644 --- a/tests/unit/test_platform_hub.py +++ b/tests/unit/test_platform_hub.py @@ -48,7 +48,7 @@ def test_application_catalog_records_destination_and_resource_gaps(): Base.metadata.create_all(bind=engine) db = test_session() try: - user = User(username="platform-user", hashed_password="not-used") + user = User(username="platform-user", hashed_password="not-used", role="admin") destination = Destination( name="home-vm", kind="linux_docker", diff --git a/tests/unit/test_security_hardening.py b/tests/unit/test_security_hardening.py new file mode 100644 index 0000000..febc355 --- /dev/null +++ b/tests/unit/test_security_hardening.py @@ -0,0 +1,277 @@ +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest +from fastapi import HTTPException +from pydantic import ValidationError +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +from api.routes.applications import deploy_application_route +from api.routes.sandbox import create_sandbox_demo +from api.schemas import ApplicationCreateRequest +from app.config import DEFAULT_SECRET_KEY, Settings +from database.models import Application, Base, Deployment, Destination, User +from services.application_service import create_application +from services.catalog import ImageNotAllowedError, ensure_image_allowed +from services.providers.vm_docker import VMRunnerProvider +from services.sandbox_service import SandboxCapacityError, create_sandbox_deployment +from services.sandbox_sweeper import sweep_expired_sandboxes + +STRONG_KEY = "k" * 40 + + +@pytest.fixture +def db(): + engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool) + Base.metadata.create_all(bind=engine) + session = sessionmaker(bind=engine)() + try: + yield session + finally: + session.close() + Base.metadata.drop_all(bind=engine) + engine.dispose() + + +def _settings(**overrides): + return Settings(_env_file=None, **overrides) + + +# --- SECRET_KEY: never sign tokens with a key published in this repository --- + + +@pytest.mark.parametrize("placeholder", ["", DEFAULT_SECRET_KEY, "replace-with-a-long-random-secret"]) +def test_production_refuses_missing_or_placeholder_secret_key(placeholder): + with pytest.raises(ValidationError): + _settings(ENVIRONMENT="production", SECRET_KEY=placeholder) + + +def test_production_refuses_unset_secret_key(monkeypatch): + monkeypatch.delenv("SECRET_KEY", raising=False) + with pytest.raises(ValidationError): + _settings(ENVIRONMENT="production") + + +def test_production_refuses_short_secret_key(): + with pytest.raises(ValidationError): + _settings(ENVIRONMENT="production", SECRET_KEY="too-short") + + +def test_production_accepts_strong_secret_key(): + assert _settings(ENVIRONMENT="production", SECRET_KEY=STRONG_KEY).SECRET_KEY == STRONG_KEY + + +@pytest.mark.parametrize("placeholder", ["", DEFAULT_SECRET_KEY, "replace-with-a-long-random-secret"]) +def test_local_replaces_placeholder_secret_key_with_random_one(placeholder): + first = _settings(ENVIRONMENT="local", SECRET_KEY=placeholder).SECRET_KEY + second = _settings(ENVIRONMENT="local", SECRET_KEY=placeholder).SECRET_KEY + assert first != placeholder + assert len(first) >= 32 + assert first != second + + +# --- Registration is opt-in --- + + +def test_public_registration_is_off_by_default(monkeypatch): + monkeypatch.delenv("ENABLE_PUBLIC_REGISTRATION", raising=False) + assert _settings().ENABLE_PUBLIC_REGISTRATION is False + + +def test_register_is_rejected_when_registration_is_disabled(client, monkeypatch): + monkeypatch.setattr("api.routes.auth.settings.ENABLE_PUBLIC_REGISTRATION", False) + response = client.post("/auth/register", json={"username": "latecomer", "password": "testpass123"}) + assert response.status_code == 403 + + +# --- Image policy: only admins deploy images outside the catalog --- + + +def test_regular_user_may_deploy_catalog_images(): + ensure_image_allowed(SimpleNamespace(role="user"), "nginx:1.25-alpine") + + +def test_regular_user_may_not_deploy_custom_images(): + with pytest.raises(ImageNotAllowedError): + ensure_image_allowed(SimpleNamespace(role="user"), "ghcr.io/someone/miner:latest") + + +def test_admin_may_deploy_custom_images(): + ensure_image_allowed(SimpleNamespace(role="admin"), "ghcr.io/example/orders-api:1.0.0") + + +def _home_vm(db): + destination = Destination( + name="home-vm", + kind="linux_docker", + provider="existing_server", + environment="development", + config={"enabled": True, "runner_label": "idp-vm", "deployment_workflow": True}, + capabilities=["containers"], + ) + db.add(destination) + db.commit() + db.refresh(destination) + return destination + + +def _user(db, role): + user = User(username=f"{role}-user", hashed_password="not-used", role=role) + db.add(user) + db.commit() + db.refresh(user) + return user + + +def test_regular_user_cannot_register_application_with_custom_image(db): + destination = _home_vm(db) + request = ApplicationCreateRequest( + name="miner", + source_type="container_image", + image="ghcr.io/someone/miner:latest", + port=8080, + destination_id=destination.id, + ) + with pytest.raises(ImageNotAllowedError): + create_application(db, _user(db, "user"), request) + assert db.query(Application).count() == 0 + + +def test_deploy_rechecks_image_policy_for_existing_applications(db, monkeypatch): + # An application registered before the policy existed must not deploy either. + destination = _home_vm(db) + user = _user(db, "user") + application = Application( + owner_id=user.id, + destination_id=destination.id, + name="legacy-app", + source_type="container_image", + image="ghcr.io/someone/miner:latest", + port=8080, + status="ready_to_deploy", + metadata_json={}, + ) + db.add(application) + db.commit() + dispatch = MagicMock() + monkeypatch.setattr("api.routes.applications.dispatch_application_deployment", dispatch) + + with pytest.raises(HTTPException) as raised: + deploy_application_route(application.id, db=db, current_user=user) + + assert raised.value.status_code == 403 + dispatch.assert_not_called() + + +# --- Sandboxes: capped, and actually removed when they expire --- + + +def test_sandboxes_are_capped(db, monkeypatch): + monkeypatch.setattr("services.sandbox_service.settings.MAX_ACTIVE_SANDBOXES", 2) + create_sandbox_deployment(db, "whoami") + create_sandbox_deployment(db, "nginx") + + with pytest.raises(SandboxCapacityError) as raised: + create_sandbox_deployment(db, "whoami") + + assert raised.value.retry_after_seconds >= 30 + assert db.query(Deployment).count() == 2 + + +def test_finished_sandboxes_free_their_slot(db, monkeypatch): + monkeypatch.setattr("services.sandbox_service.settings.MAX_ACTIVE_SANDBOXES", 1) + first = create_sandbox_deployment(db, "whoami") + first.status = "expired" + db.add(first) + db.commit() + + create_sandbox_deployment(db, "whoami") + + +def test_sandbox_endpoint_returns_429_with_retry_after_when_full(db, monkeypatch): + monkeypatch.setattr("services.sandbox_service.settings.MAX_ACTIVE_SANDBOXES", 1) + monkeypatch.setattr("api.routes.sandbox.dispatch_sandbox_deployment", MagicMock()) + create_sandbox_demo(template="whoami", db=db) + + with pytest.raises(HTTPException) as raised: + create_sandbox_demo(template="whoami", db=db) + + assert raised.value.status_code == 429 + assert int(raised.value.headers["Retry-After"]) >= 30 + + +def test_sandbox_that_could_not_be_dispatched_frees_its_slot(db, monkeypatch): + monkeypatch.setattr("services.sandbox_service.settings.MAX_ACTIVE_SANDBOXES", 1) + monkeypatch.setattr("services.providers.vm_docker.settings.GITHUB_DISPATCH_TOKEN", "") + + with pytest.raises(HTTPException) as raised: + create_sandbox_demo(template="whoami", db=db) + + assert raised.value.status_code == 400 + assert db.query(Deployment).one().status == "failed" + create_sandbox_deployment(db, "whoami") # the slot is free again + + +def test_zero_sandbox_cap_disables_the_demo(db, monkeypatch): + monkeypatch.setattr("services.sandbox_service.settings.MAX_ACTIVE_SANDBOXES", 0) + with pytest.raises(SandboxCapacityError, match="disabled"): + create_sandbox_deployment(db, "whoami") + + +def test_teardown_asks_the_runner_when_dispatch_is_configured(monkeypatch): + post = MagicMock(return_value=SimpleNamespace(status_code=204)) + run = MagicMock() + monkeypatch.setattr("services.providers.vm_docker.httpx.post", post) + monkeypatch.setattr("services.providers.vm_docker.subprocess.run", run) + monkeypatch.setattr("services.providers.vm_docker.settings.GITHUB_DISPATCH_TOKEN", "token") + monkeypatch.setattr("services.providers.vm_docker.settings.GITHUB_REPOSITORY", "example/platform") + + VMRunnerProvider().teardown(Deployment(id=42, name="sandbox-whoami-42")) + + payload = post.call_args.kwargs["json"] + assert payload == {"event_type": "teardown_sandbox", "client_payload": {"deployment_id": "42"}} + run.assert_not_called() + + +def test_teardown_raises_when_github_rejects_it(monkeypatch): + rejected = SimpleNamespace(status_code=403, text="Resource not accessible", headers={}) + monkeypatch.setattr("services.providers.vm_docker.httpx.post", MagicMock(return_value=rejected)) + monkeypatch.setattr("services.providers.vm_docker.settings.GITHUB_DISPATCH_TOKEN", "token") + + with pytest.raises(RuntimeError, match="teardown_sandbox"): + VMRunnerProvider().teardown(Deployment(id=42, name="sandbox-whoami-42")) + + +def test_sweeper_keeps_sandbox_active_until_teardown_succeeds(db, monkeypatch): + now = datetime.now(timezone.utc) + deployment = Deployment( + owner_id=0, + name="sandbox-whoami-1", + namespace="sandbox", + image="mpepping/whoami:latest", + port=20001, + container_port=8000, + replicas=1, + is_sandbox=True, + status="running", + expires_at=now - timedelta(minutes=1), + metadata_json={}, + ) + db.add(deployment) + db.commit() + provider = MagicMock() + provider.teardown.side_effect = RuntimeError("GitHub is down") + monkeypatch.setattr("services.sandbox_sweeper.get_deployment_provider", MagicMock(return_value=provider)) + + assert sweep_expired_sandboxes(db, now=now) == 0 + db.refresh(deployment) + assert deployment.status == "running" + assert deployment.metadata_json["teardown_error"] == "GitHub is down" + + provider.teardown.side_effect = None + assert sweep_expired_sandboxes(db, now=now) == 1 + db.refresh(deployment) + assert deployment.status == "expired"