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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,5 @@ __pycache__/
.playwright-cli/
datas/
dispatch.yaml
web/node_modules/
web/dist/
2 changes: 2 additions & 0 deletions cairn/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ dependencies = [
"pyyaml>=6.0",
"docker>=7.1.0",
"requests>=2.32.3",
"pyjwt>=2.9",
"bcrypt>=4.2",
]

[project.scripts]
Expand Down
1 change: 1 addition & 0 deletions cairn/src/cairn/dispatcher/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ class DispatchConfig(BaseModel):
model_config = ConfigDict(extra="forbid")

server: str
server_token: str = ""
runtime: RuntimeConfig
tasks: TasksConfig
container: ContainerConfig
Expand Down
5 changes: 4 additions & 1 deletion cairn/src/cairn/dispatcher/protocol/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,10 @@ def ok(self) -> bool:


class CairnClient:
def __init__(self, base_url: str, timeout: float = 10.0):
def __init__(self, base_url: str, timeout: float = 10.0, api_key: str = ""):
self._base_url = base_url.rstrip("/")
self._timeout = timeout
self._api_key = api_key
self._summary_adapter = TypeAdapter(list[ProjectSummary])
self._local = threading.local()
self._sessions: dict[int, requests.Session] = {}
Expand Down Expand Up @@ -153,6 +154,8 @@ def _session(self) -> requests.Session:
return session

session = requests.Session()
if self._api_key:
session.headers["X-API-Key"] = self._api_key
adapter = HTTPAdapter(pool_connections=64, pool_maxsize=64, pool_block=False)
session.mount("http://", adapter)
session.mount("https://", adapter)
Expand Down
2 changes: 1 addition & 1 deletion cairn/src/cairn/dispatcher/scheduler/loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ class DispatcherLoop:
def __init__(self, config_path: Path):
self.config_path = config_path
self.config = DispatchConfig.load(config_path)
self.client = CairnClient(self.config.server)
self.client = CairnClient(self.config.server, api_key=self.config.server_token)
self.container_manager = ContainerManager(self.config.container)
self.executor = ThreadPoolExecutor(max_workers=self.config.runtime.max_workers)
self.cleanup_executor = ThreadPoolExecutor(max_workers=max(1, min(8, self.config.runtime.max_workers)))
Expand Down
17 changes: 16 additions & 1 deletion cairn/src/cairn/server/app.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,26 @@
import os
from contextlib import asynccontextmanager
from pathlib import Path

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles

from cairn import __version__
from cairn.server import db
from cairn.server.routers import export, hints, intents, projects, settings
from cairn.server.auth import _api_keys_configured, configure_api_keys
from cairn.server.routers import auth, export, hints, intents, projects, settings

STATIC_DIR = Path(__file__).parent / "static"


@asynccontextmanager
async def lifespan(app: FastAPI):
db.configure(db.DEFAULT_DB)
if not _api_keys_configured():
api_keys = [k.strip() for k in os.environ.get("CAIRN_API_KEYS", "").split(",") if k.strip()]
configure_api_keys(api_keys)
yield


Expand All @@ -25,6 +31,15 @@ async def lifespan(app: FastAPI):
lifespan=lifespan,
)

app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)

app.include_router(auth.router)
app.include_router(settings.router)
app.include_router(projects.router)
app.include_router(hints.router)
Expand Down
152 changes: 152 additions & 0 deletions cairn/src/cairn/server/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
from __future__ import annotations

import hashlib
import secrets
import sqlite3
from datetime import UTC, datetime, timedelta
from typing import Annotated

import bcrypt
import jwt
from fastapi import Depends, Header, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel, Field, field_validator

from cairn.server.db import get_conn

_JWT_ALGORITHM = "HS256"
_ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24
_bearer = HTTPBearer(auto_error=False)
_api_keys: set[str] = set()


def _get_or_create_secret(conn: sqlite3.Connection) -> str:
row = conn.execute("SELECT value FROM kv WHERE key = 'jwt_secret'").fetchone()
if row is not None:
return row["value"]
secret = secrets.token_hex(32)
conn.execute("INSERT INTO kv (key, value) VALUES ('jwt_secret', ?)", (secret,))
return secret


def _jwt_secret() -> str:
with get_conn() as conn:
return _get_or_create_secret(conn)


def _hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()


def _verify_password(password: str, hashed: str) -> bool:
return bcrypt.checkpw(password.encode(), hashed.encode())


def _create_token(user_id: str, username: str) -> str:
payload = {
"sub": user_id,
"username": username,
"exp": datetime.now(UTC) + timedelta(minutes=_ACCESS_TOKEN_EXPIRE_MINUTES),
}
return jwt.encode(payload, _jwt_secret(), algorithm=_JWT_ALGORITHM)


class TokenUser(BaseModel):
id: str
username: str


def get_current_user(
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer)],
) -> TokenUser:
if credentials is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "missing token")
try:
payload = jwt.decode(credentials.credentials, _jwt_secret(), algorithms=[_JWT_ALGORITHM])
return TokenUser(id=payload["sub"], username=payload["username"])
except jwt.ExpiredSignatureError as exc:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "token expired") from exc
except (jwt.InvalidTokenError, KeyError) as exc:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid token") from exc


def configure_api_keys(keys: list[str]) -> None:
_api_keys.clear()
_api_keys.update(k for k in keys if k)


def _api_keys_configured() -> bool:
return len(_api_keys) > 0


def require_auth(
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer)],
x_api_key: Annotated[str | None, Header()] = None,
) -> TokenUser | None:
if x_api_key and x_api_key in _api_keys:
return None
if credentials is not None:
try:
payload = jwt.decode(credentials.credentials, _jwt_secret(), algorithms=[_JWT_ALGORITHM])
return TokenUser(id=payload["sub"], username=payload["username"])
except (jwt.InvalidTokenError, KeyError):
pass
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "authentication required")


class RegisterRequest(BaseModel):
username: str = Field(min_length=3, max_length=32)
password: str = Field(min_length=6, max_length=128)

@field_validator("username")
@classmethod
def validate_username(cls, v: str) -> str:
v = v.strip()
if not v.isalnum() and not all(c.isalnum() or c in "-_" for c in v):
raise ValueError("username must be alphanumeric, hyphens and underscores only")
return v


class LoginRequest(BaseModel):
username: str
password: str


class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer" # noqa: S105
username: str


class UserInfoResponse(BaseModel):
id: str
username: str
created_at: str


def _generate_user_id(username: str) -> str:
return hashlib.sha256(f"{username}-{secrets.token_hex(8)}".encode()).hexdigest()[:16]


def register_user(req: RegisterRequest) -> TokenResponse:
with get_conn() as conn:
existing = conn.execute("SELECT id FROM users WHERE username = ?", (req.username,)).fetchone()
if existing is not None:
raise HTTPException(status.HTTP_409_CONFLICT, "username already exists")
user_id = _generate_user_id(req.username)
now = datetime.now(UTC).isoformat()
conn.execute(
"INSERT INTO users (id, username, password_hash, created_at) VALUES (?, ?, ?, ?)",
(user_id, req.username, _hash_password(req.password), now),
)
return TokenResponse(access_token=_create_token(user_id, req.username), username=req.username)


def login_user(req: LoginRequest) -> TokenResponse:
with get_conn() as conn:
row = conn.execute(
"SELECT id, username, password_hash FROM users WHERE username = ?", (req.username,)
).fetchone()
if row is None or not _verify_password(req.password, row["password_hash"]):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid username or password")
return TokenResponse(access_token=_create_token(row["id"], row["username"]), username=row["username"])
24 changes: 24 additions & 0 deletions cairn/src/cairn/server/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,18 @@
_db_path: Path | None = None

SCHEMA = """\
CREATE TABLE IF NOT EXISTS kv (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);

CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL
);

CREATE TABLE IF NOT EXISTS settings (
intent_timeout INTEGER NOT NULL DEFAULT 15,
reason_timeout INTEGER NOT NULL DEFAULT 15
Expand Down Expand Up @@ -91,6 +103,18 @@ def configure(path: Path) -> None:
with get_conn() as conn:
conn.executescript(SCHEMA)
_ensure_project_columns(conn)
_ensure_user_tables(conn)


def _ensure_user_tables(conn: sqlite3.Connection) -> None:
tables = {row["name"] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")}
if "users" not in tables:
conn.execute(
"CREATE TABLE IF NOT EXISTS users "
"(id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, created_at TEXT NOT NULL)"
)
if "kv" not in tables:
conn.execute("CREATE TABLE IF NOT EXISTS kv (key TEXT PRIMARY KEY, value TEXT NOT NULL)")


def _ensure_project_columns(conn: sqlite3.Connection) -> None:
Expand Down
40 changes: 40 additions & 0 deletions cairn/src/cairn/server/routers/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
from __future__ import annotations

from typing import Annotated

from fastapi import APIRouter, Depends

from cairn.server.auth import (
LoginRequest,
RegisterRequest,
TokenResponse,
TokenUser,
UserInfoResponse,
get_current_user,
login_user,
register_user,
)
from cairn.server.db import get_conn

router = APIRouter(tags=["auth"])


@router.post("/auth/register", response_model=TokenResponse)
def register(body: RegisterRequest):
return register_user(body)


@router.post("/auth/login", response_model=TokenResponse)
def login(body: LoginRequest):
return login_user(body)


@router.get("/auth/me", response_model=UserInfoResponse)
def me(user: Annotated[TokenUser, Depends(get_current_user)]):
with get_conn() as conn:
row = conn.execute("SELECT id, username, created_at FROM users WHERE id = ?", (user.id,)).fetchone()
if row is None:
from fastapi import HTTPException, status

raise HTTPException(status.HTTP_404_NOT_FOUND, "user not found")
return UserInfoResponse(id=row["id"], username=row["username"], created_at=row["created_at"])
5 changes: 3 additions & 2 deletions cairn/src/cairn/server/routers/export.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
from datetime import datetime

import yaml
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import Response

from cairn.server.auth import require_auth
from cairn.server.db import get_conn
from cairn.server.services import expire_reason_leases, expire_workers, get_project_or_404

router = APIRouter(tags=["export"])
router = APIRouter(tags=["export"], dependencies=[Depends(require_auth)])


def format_export_timestamp(value: str | None) -> str | None:
Expand Down
5 changes: 3 additions & 2 deletions cairn/src/cairn/server/routers/hints.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
from fastapi import APIRouter
from fastapi import APIRouter, Depends

from cairn.server.auth import require_auth
from cairn.server.db import get_conn
from cairn.server.models import CreateHintRequest, Hint
from cairn.server.services import check_project_hint_writable, next_hint_id, utcnow

router = APIRouter(tags=["hints"])
router = APIRouter(tags=["hints"], dependencies=[Depends(require_auth)])


@router.post(
Expand Down
5 changes: 3 additions & 2 deletions cairn/src/cairn/server/routers/intents.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from fastapi import APIRouter
from fastapi import APIRouter, Depends

from cairn.server.auth import require_auth
from cairn.server.db import get_conn
from cairn.server.models import (
ConcludeRequest,
Expand All @@ -22,7 +23,7 @@
validate_intent_creator_worker,
)

router = APIRouter(tags=["intents"])
router = APIRouter(tags=["intents"], dependencies=[Depends(require_auth)])


@router.post(
Expand Down
5 changes: 3 additions & 2 deletions cairn/src/cairn/server/routers/projects.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, Depends, HTTPException

from cairn.server.auth import require_auth
from cairn.server.db import get_conn
from cairn.server.models import (
CompleteRequest,
Expand Down Expand Up @@ -38,7 +39,7 @@
validate_goal_not_in_sources,
)

router = APIRouter(tags=["projects"])
router = APIRouter(tags=["projects"], dependencies=[Depends(require_auth)])


@router.get("/projects", response_model=list[ProjectSummary])
Expand Down
Loading
Loading