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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .github/workflows/release-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -129,11 +129,14 @@ jobs:
MAILJET_FROM_ADDRESS: ${{ secrets.MAILJET_FROM_ADDRESS }}
MAILJET_HOST: ${{ secrets.MAILJET_HOST }}
GRAFANA_ALERT_RECIPIENTS: ${{ secrets.GRAFANA_ALERT_RECIPIENTS }}
REDIS_PASSWORD: ${{ secrets.REDIS_PASSWORD }}
MONGO_ROOT_USER: ${{ secrets.MONGO_ROOT_USER }}
MONGO_ROOT_PASSWORD: ${{ secrets.MONGO_ROOT_PASSWORD }}
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
envs: GHCR_TOKEN,GHCR_USER,IMAGE_TAG,GRAFANA_ADMIN_USER,GRAFANA_ADMIN_PASSWORD,MAILJET_API_KEY,MAILJET_SECRET_KEY,MAILJET_FROM_ADDRESS,MAILJET_HOST,GRAFANA_ALERT_RECIPIENTS
envs: GHCR_TOKEN,GHCR_USER,IMAGE_TAG,GRAFANA_ADMIN_USER,GRAFANA_ADMIN_PASSWORD,MAILJET_API_KEY,MAILJET_SECRET_KEY,MAILJET_FROM_ADDRESS,MAILJET_HOST,GRAFANA_ALERT_RECIPIENTS,REDIS_PASSWORD,MONGO_ROOT_USER,MONGO_ROOT_PASSWORD
command_timeout: 10m
script: |
set -e
Expand All @@ -153,6 +156,9 @@ jobs:
export MAILJET_FROM_ADDRESS="$MAILJET_FROM_ADDRESS"
export GF_SMTP_HOST="$MAILJET_HOST"
export GRAFANA_ALERT_RECIPIENTS="$GRAFANA_ALERT_RECIPIENTS"
export REDIS_PASSWORD="$REDIS_PASSWORD"
export MONGO_ROOT_USER="$MONGO_ROOT_USER"
export MONGO_ROOT_PASSWORD="$MONGO_ROOT_PASSWORD"
docker compose pull
docker compose up -d --remove-orphans --no-build --wait --wait-timeout 180

Expand Down
15 changes: 7 additions & 8 deletions backend/app/core/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
import jwt
from fastapi import Request
from fastapi.security import OAuth2PasswordBearer
from passlib.context import CryptContext
from pwdlib import PasswordHash
from pwdlib.hashers.bcrypt import BcryptHasher

from app.core.metrics import SecurityMetrics
from app.domain.user import CSRFValidationError, InvalidCredentialsError
Expand All @@ -20,17 +21,15 @@ def __init__(self, settings: Settings, security_metrics: SecurityMetrics) -> Non
self.settings = settings
self._security_metrics = security_metrics
# --8<-- [start:password_hashing]
self.pwd_context = CryptContext(
schemes=["bcrypt"],
deprecated="auto",
bcrypt__rounds=self.settings.BCRYPT_ROUNDS,
)
self._password_hash = PasswordHash((
BcryptHasher(rounds=self.settings.BCRYPT_ROUNDS),
))

def verify_password(self, plain_password: str, hashed_password: str) -> bool:
return self.pwd_context.verify(plain_password, hashed_password) # type: ignore
return self._password_hash.verify(plain_password, hashed_password)

def get_password_hash(self, password: str) -> str:
return self.pwd_context.hash(password) # type: ignore
return self._password_hash.hash(password)
# --8<-- [end:password_hashing]

# --8<-- [start:create_access_token]
Expand Down
2 changes: 2 additions & 0 deletions backend/app/services/k8s_worker/pod_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ def _build_pod_spec(
containers=[container],
restart_policy="Never",
active_deadline_seconds=timeout,
runtime_class_name=self._settings.K8S_POD_RUNTIME_CLASS_NAME,
host_users=False, # User namespace isolation — remaps container UIDs to unprivileged host UIDs
volumes=[
k8s_client.V1Volume(
name="script-volume",
Expand Down
162 changes: 117 additions & 45 deletions backend/app/services/k8s_worker/worker.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import asyncio
import time
from pathlib import Path
from typing import Any

import structlog
from kubernetes_asyncio import client as k8s_client
Expand Down Expand Up @@ -56,6 +55,7 @@ def __init__(
# Kubernetes clients created from ApiClient
self.v1 = k8s_client.CoreV1Api(api_client)
self.apps_v1 = k8s_client.AppsV1Api(api_client)
self.networking_v1 = k8s_client.NetworkingV1Api(api_client)

# Components
self.pod_builder = PodBuilder(settings=settings)
Expand Down Expand Up @@ -252,62 +252,134 @@ async def _publish_pod_creation_failed(self, command: CreatePodCommandEvent, err
)
await self.producer.produce(event_to_produce=event, key=command.execution_id)

async def ensure_namespace_security(self) -> None:
"""Apply security controls to the executor namespace at startup.

Creates:
- Default-deny NetworkPolicy for executor pods (blocks lateral movement and exfiltration)
- ResourceQuota to cap aggregate pod/resource consumption
- Pod Security Admission labels (Restricted profile)
"""
namespace = self._settings.K8S_NAMESPACE
await self._ensure_executor_network_policy(namespace)
await self._ensure_executor_resource_quota(namespace)
await self._apply_psa_labels(namespace)

async def _ensure_executor_network_policy(self, namespace: str) -> None:
"""Create or update default-deny NetworkPolicy for executor pods."""
policy_name = "executor-deny-all"

policy = k8s_client.V1NetworkPolicy(
api_version="networking.k8s.io/v1",
kind="NetworkPolicy",
metadata=k8s_client.V1ObjectMeta(
name=policy_name,
namespace=namespace,
labels={"app": "integr8s", "component": "security"},
),
spec=k8s_client.V1NetworkPolicySpec(
pod_selector=k8s_client.V1LabelSelector(match_labels={"component": "executor"}),
policy_types=["Ingress", "Egress"],
ingress=[],
egress=[],
),
)

await self.networking_v1.patch_namespaced_network_policy( # type: ignore[call-arg]
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
name=policy_name, namespace=namespace, body=policy,
field_manager="integr8s", force=True,
_content_type="application/apply-patch+yaml",
)
self.logger.info(f"NetworkPolicy '{policy_name}' applied in namespace {namespace}")

async def _ensure_executor_resource_quota(self, namespace: str) -> None:
"""Create or update ResourceQuota to cap aggregate executor pod consumption."""
quota_name = "executor-quota"
n = self._settings.K8S_MAX_CONCURRENT_PODS

quota = k8s_client.V1ResourceQuota(
api_version="v1",
kind="ResourceQuota",
metadata=k8s_client.V1ObjectMeta(
name=quota_name,
namespace=namespace,
labels={"app": "integr8s", "component": "security"},
),
spec=k8s_client.V1ResourceQuotaSpec(
hard={
"pods": str(n),
"requests.cpu": f"{int(self._settings.K8S_POD_CPU_REQUEST.removesuffix('m')) * n}m",
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
"requests.memory": f"{int(self._settings.K8S_POD_MEMORY_REQUEST.removesuffix('Mi')) * n}Mi",
"limits.cpu": f"{int(self._settings.K8S_POD_CPU_LIMIT.removesuffix('m')) * n}m",
"limits.memory": f"{int(self._settings.K8S_POD_MEMORY_LIMIT.removesuffix('Mi')) * n}Mi",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
},
),
)

await self.v1.patch_namespaced_resource_quota( # type: ignore[call-arg]
name=quota_name, namespace=namespace, body=quota,
field_manager="integr8s", force=True,
_content_type="application/apply-patch+yaml",
)
self.logger.info(f"ResourceQuota '{quota_name}' applied in namespace {namespace}")

Comment thread
HardMax71 marked this conversation as resolved.
async def _apply_psa_labels(self, namespace: str) -> None:
"""Apply Pod Security Admission labels to the executor namespace."""
psa_labels = {
"pod-security.kubernetes.io/enforce": "restricted",
"pod-security.kubernetes.io/enforce-version": "latest",
Comment thread
HardMax71 marked this conversation as resolved.
"pod-security.kubernetes.io/warn": "restricted",
"pod-security.kubernetes.io/audit": "restricted",
}

await self.v1.patch_namespace(name=namespace, body={"metadata": {"labels": psa_labels}})
self.logger.info(f"Pod Security Admission labels applied to namespace {namespace}")

async def ensure_image_pre_puller_daemonset(self) -> None:
"""Ensure the runtime image pre-puller DaemonSet exists."""
daemonset_name = "runtime-image-pre-puller"
namespace = self._settings.K8S_NAMESPACE

try:
init_containers = []
init_containers: list[k8s_client.V1Container] = []
all_images = {config.image for lang in RUNTIME_REGISTRY.values() for config in lang.values()}

for i, image_ref in enumerate(sorted(all_images)):
sanitized_image_ref = image_ref.split("/")[-1].replace(":", "-").replace(".", "-").replace("_", "-")
self.logger.info(f"DAEMONSET: before: {image_ref} -> {sanitized_image_ref}")
container_name = f"pull-{i}-{sanitized_image_ref}"
init_containers.append(
{
"name": container_name,
"image": image_ref,
"command": ["/bin/sh", "-c", f'echo "Image {image_ref} pulled."'],
"imagePullPolicy": "Always",
}
)

manifest: dict[str, Any] = {
"apiVersion": "apps/v1",
"kind": "DaemonSet",
"metadata": {"name": daemonset_name, "namespace": namespace},
"spec": {
"selector": {"matchLabels": {"name": daemonset_name}},
"template": {
"metadata": {"labels": {"name": daemonset_name}},
"spec": {
"initContainers": init_containers,
"containers": [{"name": "pause", "image": "registry.k8s.io/pause:3.9"}],
"tolerations": [{"operator": "Exists"}],
},
},
"updateStrategy": {"type": "RollingUpdate"},
},
}
init_containers.append(k8s_client.V1Container(
name=f"pull-{i}-{sanitized_image_ref}",
image=image_ref,
command=["/bin/sh", "-c", f'echo "Image {image_ref} pulled."'],
image_pull_policy="Always",
))

daemonset = k8s_client.V1DaemonSet(
api_version="apps/v1",
kind="DaemonSet",
metadata=k8s_client.V1ObjectMeta(name=daemonset_name, namespace=namespace),
spec=k8s_client.V1DaemonSetSpec(
selector=k8s_client.V1LabelSelector(match_labels={"name": daemonset_name}),
template=k8s_client.V1PodTemplateSpec(
metadata=k8s_client.V1ObjectMeta(labels={"name": daemonset_name}),
spec=k8s_client.V1PodSpec(
init_containers=init_containers,
containers=[k8s_client.V1Container(
name="pause", image="registry.k8s.io/pause:3.9",
)],
tolerations=[k8s_client.V1Toleration(operator="Exists")],
),
),
update_strategy=k8s_client.V1DaemonSetUpdateStrategy(type="RollingUpdate"),
),
)

try:
await self.apps_v1.read_namespaced_daemon_set(name=daemonset_name, namespace=namespace)
self.logger.info(f"DaemonSet '{daemonset_name}' exists. Replacing to ensure it is up-to-date.")
await self.apps_v1.replace_namespaced_daemon_set(
name=daemonset_name, namespace=namespace, body=manifest # type: ignore[arg-type]
)
self.logger.info(f"DaemonSet '{daemonset_name}' replaced successfully.")
except ApiException as e:
if e.status == 404:
self.logger.info(f"DaemonSet '{daemonset_name}' not found. Creating...")
await self.apps_v1.create_namespaced_daemon_set(
namespace=namespace, body=manifest # type: ignore[arg-type]
)
self.logger.info(f"DaemonSet '{daemonset_name}' created successfully.")
else:
raise
await self.apps_v1.patch_namespaced_daemon_set( # type: ignore[call-arg]
name=daemonset_name, namespace=namespace, body=daemonset,
field_manager="integr8s", force=True,
_content_type="application/apply-patch+yaml",
)
self.logger.info(f"DaemonSet '{daemonset_name}' applied successfully")

except ApiException as e:
self.logger.error(f"K8s API error applying DaemonSet '{daemonset_name}': {e.reason}", exc_info=True)
Expand Down
1 change: 1 addition & 0 deletions backend/app/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ def __init__(
K8S_POD_MEMORY_REQUEST: str = "128Mi"
K8S_POD_EXECUTION_TIMEOUT: int = 300 # in seconds
K8S_POD_PRIORITY_CLASS_NAME: str | None = None
K8S_POD_RUNTIME_CLASS_NAME: str | None = None # e.g. "gvisor" for sandboxed execution

SUPPORTED_RUNTIMES: dict[str, LanguageInfoDomain] = Field(default_factory=lambda: RUNTIME_MATRIX)

Expand Down
2 changes: 1 addition & 1 deletion backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ dependencies = [
"opentelemetry-util-http==0.60b1",
"packaging==24.1",
"beanie==2.0.1",
"passlib==1.7.4",
"pwdlib[bcrypt]==0.2.1",
"pathspec==0.12.1",
"prometheus-fastapi-instrumentator==7.0.0",
"prometheus_client==0.21.0",
Expand Down
13 changes: 8 additions & 5 deletions backend/scripts/seed_users.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,15 @@

from app.settings import Settings
from bson import ObjectId
from passlib.context import CryptContext
from pwdlib import PasswordHash
from pwdlib.hashers.bcrypt import BcryptHasher
from pymongo.asynchronous.database import AsyncDatabase
from pymongo.asynchronous.mongo_client import AsyncMongoClient

pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")


async def upsert_user(
db: AsyncDatabase[dict[str, Any]],
pwd_hasher: PasswordHash,
username: str,
email: str,
password: str,
Expand All @@ -42,7 +42,7 @@ async def upsert_user(
{"username": username},
{
"$set": {
"hashed_password": pwd_context.hash(password),
"hashed_password": pwd_hasher.hash(password),
"role": role,
"is_superuser": is_superuser,
"is_active": True,
Expand All @@ -58,7 +58,7 @@ async def upsert_user(
"user_id": str(ObjectId()),
"username": username,
"email": email,
"hashed_password": pwd_context.hash(password),
"hashed_password": pwd_hasher.hash(password),
"role": role,
"is_active": True,
"is_superuser": is_superuser,
Expand All @@ -70,6 +70,7 @@ async def upsert_user(

async def seed_users(settings: Settings) -> None:
"""Seed default users using provided settings for MongoDB connection."""
pwd_hasher = PasswordHash((BcryptHasher(rounds=settings.BCRYPT_ROUNDS),))
default_password = os.environ.get("DEFAULT_USER_PASSWORD", "user123")
admin_password = os.environ.get("ADMIN_USER_PASSWORD", "admin123")

Expand All @@ -80,6 +81,7 @@ async def seed_users(settings: Settings) -> None:
# Default user
await upsert_user(
db,
pwd_hasher,
username="user",
email="user@integr8scode.com",
password=default_password,
Expand All @@ -90,6 +92,7 @@ async def seed_users(settings: Settings) -> None:
# Admin user
await upsert_user(
db,
pwd_hasher,
username="admin",
email="admin@integr8scode.com",
password=admin_password,
Expand Down
1 change: 1 addition & 0 deletions backend/secrets.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@

SECRET_KEY = "CHANGE_ME_min_32_chars_long_!!!!"
MONGODB_URL = "mongodb://root:rootpassword@mongo:27017/integr8scode?authSource=admin"
REDIS_PASSWORD = "redispassword"
Comment thread
HardMax71 marked this conversation as resolved.
Comment thread
HardMax71 marked this conversation as resolved.
Loading
Loading