From 286c6ea72df2178a56331436c8b2da5358ac7fbb Mon Sep 17 00:00:00 2001 From: tobiasfremming Date: Sat, 30 May 2026 16:10:52 +0200 Subject: [PATCH 1/4] feat: keycloak integration --- docker-compose.local.yml | 39 +++++ docker-compose.yml | 13 +- keycloak/realm-ragdoll.json | 66 ++++++++ mongo-init.js | 36 ++--- scripts/bootstrap-keycloak-test-user.ps1 | 125 ++++++++++++++++ src/auth/auth_provider/factory.py | 9 ++ .../auth_provider/keycloak_auth_provider.py | 141 ++++++++++++++++++ src/auth/auth_service/auth_service.py | 10 +- src/auth/auth_service/factory.py | 2 + src/auth/auth_service/open_auth_service.py | 15 +- src/config.py | 17 +++ src/globals.py | 21 ++- src/rag_service/dao/user/base.py | 4 + src/rag_service/dao/user/mongodb_user_dao.py | 50 ++++++- src/routes/agents.py | 26 +++- src/routes/auth.py | 3 +- src/routes/debug.py | 78 +++++++++- tests/mocks/mock_user_dao.py | 6 + uv.lock | 49 ++++++ 19 files changed, 656 insertions(+), 54 deletions(-) create mode 100644 keycloak/realm-ragdoll.json create mode 100644 scripts/bootstrap-keycloak-test-user.ps1 create mode 100644 src/auth/auth_provider/keycloak_auth_provider.py diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 581508f..2f973be 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -6,6 +6,7 @@ # # Services: # - mongodb: Local MongoDB database +# - keycloak: Local identity provider # - backend: FastAPI application # # Features: @@ -43,6 +44,31 @@ services: retries: 5 start_period: 10s + # Keycloak Identity Provider + keycloak: + image: quay.io/keycloak/keycloak:26.0 + container_name: ragdoll-keycloak + restart: unless-stopped + command: start-dev --import-realm + environment: + KEYCLOAK_ADMIN: admin + KEYCLOAK_ADMIN_PASSWORD: admin + KC_HTTP_PORT: 8080 + KC_HOSTNAME: localhost + ports: + - "8080:8080" + volumes: + - keycloak_data:/opt/keycloak/data + - ./keycloak/realm-ragdoll.json:/opt/keycloak/data/import/realm-ragdoll.json:ro + networks: + - ragdoll-network + healthcheck: + test: ["CMD-SHELL", "timeout 5 bash -c ' AuthProvider: if config.ENV != "dev": raise ValueError("Cannot login as dev when not in dev environment") return DevAuthProvider(user_db=user_db) + case "keycloak": + return KeycloakAuthProvider( + issuer=config.KEYCLOAK_ISSUER, + jwks_url=config.KEYCLOAK_JWKS_URL, + client_id=config.KEYCLOAK_CLIENT_ID, + verify_audience=config.KEYCLOAK_VERIFY_AUDIENCE, + user_db=user_db, + ) case _: print("no provider mached") raise ValueError("Unvalid provider : " + provider) diff --git a/src/auth/auth_provider/keycloak_auth_provider.py b/src/auth/auth_provider/keycloak_auth_provider.py new file mode 100644 index 0000000..0168eaa --- /dev/null +++ b/src/auth/auth_provider/keycloak_auth_provider.py @@ -0,0 +1,141 @@ +import base64 +import logging +from dataclasses import dataclass +from time import monotonic +from typing import Any + +import jwt +import requests +from cryptography.hazmat.primitives.asymmetric import rsa + +from src.auth.auth_provider.base import AuthProvider +from src.models.users.user import User +from src.rag_service.dao.user.base import UserDao + + +logger = logging.getLogger(__name__) + + +@dataclass +class KeycloakUserData: + id: str + name: str | None + email: str | None + picture: str | None + + +class KeycloakAuthProvider(AuthProvider): + def __init__( + self, + issuer: str, + jwks_url: str, + client_id: str, + verify_audience: bool, + user_db: UserDao, + ): + self.issuer = issuer.rstrip("/") + self.jwks_url = jwks_url + self.client_id = client_id + self.verify_audience = verify_audience + self.user_db = user_db + self._jwks: dict[str, Any] | None = None + self._jwks_loaded_at = 0.0 + self._jwks_ttl_seconds = 300 + + def get_authenticated_user(self, token: str) -> User | None: + user_data = self.authenticate_user(token) + user = self.user_db.get_user_by_provider( + KeycloakAuthProvider.get_provider(), user_data.id + ) + if user is not None: + user.name = user_data.name or user.name + user.email = user_data.email or user.email + user.picture = user_data.picture or user.picture + return self.user_db.set_user(user) + + return self.user_db.set_user( + User( + auth_provider=KeycloakAuthProvider.get_provider(), + provider_user_id=user_data.id, + name=user_data.name, + email=user_data.email, + picture=user_data.picture, + owned_agents=[], + ) + ) + + def authenticate_user(self, token: str) -> KeycloakUserData: + claims = self._decode_token(token) + provider_user_id = claims.get("sub") + if not provider_user_id: + raise ValueError("Keycloak token is missing subject") + + name = claims.get("name") or claims.get("preferred_username") + email = claims.get("email") + picture = claims.get("picture") + return KeycloakUserData(provider_user_id, name, email, picture) + + def _decode_token(self, token: str) -> dict[str, Any]: + header = jwt.get_unverified_header(token) + kid = header.get("kid") + if not kid: + raise ValueError("Keycloak token is missing key id") + + public_key = self._get_public_key(kid) + decode_kwargs: dict[str, Any] = { + "key": public_key, + "algorithms": ["RS256"], + "issuer": self.issuer, + } + if self.verify_audience: + decode_kwargs["audience"] = self.client_id + else: + decode_kwargs["options"] = {"verify_aud": False} + + claims = jwt.decode(token, **decode_kwargs) + if claims.get("azp") and claims["azp"] != self.client_id: + logger.debug( + "Keycloak token authorized party '%s' does not match configured client '%s'", + claims["azp"], + self.client_id, + ) + return claims + + def _get_public_key(self, kid: str): + jwks = self._get_jwks() + key = next((item for item in jwks.get("keys", []) if item.get("kid") == kid), None) + if key is None: + self._jwks = None + jwks = self._get_jwks() + key = next( + (item for item in jwks.get("keys", []) if item.get("kid") == kid), + None, + ) + if key is None: + raise ValueError("No matching Keycloak signing key found") + if key.get("kty") != "RSA": + raise ValueError("Unsupported Keycloak signing key type") + + n = int.from_bytes(self._base64url_decode(key["n"]), byteorder="big") + e = int.from_bytes(self._base64url_decode(key["e"]), byteorder="big") + return rsa.RSAPublicNumbers(e, n).public_key() + + def _get_jwks(self) -> dict[str, Any]: + now = monotonic() + if self._jwks and now - self._jwks_loaded_at < self._jwks_ttl_seconds: + return self._jwks + + response = requests.get(self.jwks_url, timeout=10) + response.raise_for_status() + self._jwks = response.json() + self._jwks_loaded_at = now + return self._jwks + + @staticmethod + def _base64url_decode(value: str) -> bytes: + padding = "=" * (-len(value) % 4) + return base64.urlsafe_b64decode(value + padding) + + @staticmethod + def get_provider() -> str: + return "keycloak" diff --git a/src/auth/auth_service/auth_service.py b/src/auth/auth_service/auth_service.py index efdd781..29e0507 100644 --- a/src/auth/auth_service/auth_service.py +++ b/src/auth/auth_service/auth_service.py @@ -32,6 +32,8 @@ def login_user(self, token: str, provider: str) -> str: if user is None: logger.error("Did not manage to find or create user") raise ValueError("Login failed") + if user.id is None: + raise ValueError("Login failed: user has no local id") return user.id def auth(self, authorize: AuthJWT | None, agent_id: str): @@ -64,14 +66,14 @@ def get_authenticated_user(self, authorize: AuthJWT | None) -> User: return user def _get_or_create_demo_user(self) -> User: - from src.rag_service.dao.user.user_dao import user_dao - - demo_user = user_dao.get_user_by_email("demo@example.com") + demo_user = self.user_db.get_user_by_provider("demo", "demo") if not demo_user: demo_user = User( email="demo@example.com", name="Demo User", + auth_provider="demo", + provider_user_id="demo", api_keys=[], ) - user_dao.set_user(demo_user) + demo_user = self.user_db.set_user(demo_user) return demo_user diff --git a/src/auth/auth_service/factory.py b/src/auth/auth_service/factory.py index 6c618ee..ded9564 100644 --- a/src/auth/auth_service/factory.py +++ b/src/auth/auth_service/factory.py @@ -16,3 +16,5 @@ def auth_service_factory( return AuthService( user_db=user_db, auth_provider_factory=auth_provider_factory ) + case _: + raise ValueError(f"Invalid auth service: {service}") diff --git a/src/auth/auth_service/open_auth_service.py b/src/auth/auth_service/open_auth_service.py index 87b3593..9a7001a 100644 --- a/src/auth/auth_service/open_auth_service.py +++ b/src/auth/auth_service/open_auth_service.py @@ -15,14 +15,17 @@ def __init__(self, user_db: UserDao, agent_db: AgentDAO): self.agent_db = agent_db def get_mock_user(self) -> User: + user = self.user_db.get_user_by_provider("mock", "mock") agents = self.agent_db.get_agents() agent_ids = [agent.id for agent in agents] - user = User( - name="mock", - auth_provider="mock", - provider_user_id="mock", - owned_agents=agent_ids, - ) + if user is None: + user = User( + name="mock", + auth_provider="mock", + provider_user_id="mock", + owned_agents=[], + ) + user.owned_agents = [agent_id for agent_id in agent_ids if agent_id is not None] user = self.user_db.set_user(user) return user diff --git a/src/config.py b/src/config.py index 4122455..9954baa 100644 --- a/src/config.py +++ b/src/config.py @@ -84,6 +84,23 @@ def __init__(self): self.JWT_SECRET: str = os.getenv("JWT_SECRET", "") self.AUTH_SERVICE = os.getenv("AUTH_SERVICE", "service") + self.KEYCLOAK_BASE_URL = os.getenv( + "KEYCLOAK_BASE_URL", "http://localhost:8080" + ).rstrip("/") + self.KEYCLOAK_REALM = os.getenv("KEYCLOAK_REALM", "ragdoll") + self.KEYCLOAK_CLIENT_ID = os.getenv("KEYCLOAK_CLIENT_ID", "ragdoll-config") + self.KEYCLOAK_VERIFY_AUDIENCE = ( + os.getenv("KEYCLOAK_VERIFY_AUDIENCE", "false").lower() == "true" + ) + self.KEYCLOAK_ISSUER = os.getenv( + "KEYCLOAK_ISSUER", + f"{self.KEYCLOAK_BASE_URL}/realms/{self.KEYCLOAK_REALM}", + ).rstrip("/") + self.KEYCLOAK_JWKS_URL = os.getenv( + "KEYCLOAK_JWKS_URL", + f"{self.KEYCLOAK_ISSUER}/protocol/openid-connect/certs", + ) + self.GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID", "set your id here") self.GOOGLE_CLIENT_SECRET = os.getenv( "GOOGLE_CLIENT_SECRET", "set your secret here" diff --git a/src/globals.py b/src/globals.py index 2b71bce..fd911bc 100644 --- a/src/globals.py +++ b/src/globals.py @@ -1,4 +1,6 @@ # These are instantiated once an then used in the rest of the application +import os + from src.access_service.base import AbstractAccessService from src.access_service.factory import AccessServiceConfig, access_service_factory from src.auth.auth_service.base import BaseAuthService @@ -7,20 +9,17 @@ from src.rag_service.dao.agent.base import AgentDAO from src.rag_service.dao.factory import get_agent_dao, get_user_dao from src.rag_service.dao.user.base import UserDao -import os user_dao: UserDao = get_user_dao() agent_dao: AgentDAO = get_agent_dao() -# Use OpenAuthService when DISABLE_AUTH is true -if os.getenv("DISABLE_AUTH", "").lower() == "true": - from src.auth.auth_service.open_auth_service import OpenAuthService - - auth_service = OpenAuthService(user_dao, agent_dao) -else: - from src.auth.auth_service.auth_service import AuthService - - auth_service = AuthService(user_dao, agent_dao) +config = Config() +auth_service_name = ( + "noauth" if os.getenv("DISABLE_AUTH", "").lower() == "true" else config.AUTH_SERVICE +) +auth_service: BaseAuthService = auth_service_factory( + auth_service_name, user_dao, agent_dao +) access_service: AbstractAccessService = access_service_factory( - AccessServiceConfig(Config().ACCESS_SERVICE, agent_dao) + AccessServiceConfig(config.ACCESS_SERVICE, agent_dao) ) diff --git a/src/rag_service/dao/user/base.py b/src/rag_service/dao/user/base.py index 1f03eb4..46b6bb7 100644 --- a/src/rag_service/dao/user/base.py +++ b/src/rag_service/dao/user/base.py @@ -18,6 +18,10 @@ def get_user_by_provider( ) -> User | None: """Retrieve a specific agent by provider and provider given ID.""" + @abstractmethod + def get_user_by_email(self, email: str) -> User | None: + """Retrieve a specific user by email.""" + @abstractmethod def is_reachable(self) -> bool: """Check if the DAO backend is accessible.""" diff --git a/src/rag_service/dao/user/mongodb_user_dao.py b/src/rag_service/dao/user/mongodb_user_dao.py index 99ecce1..9f2d415 100644 --- a/src/rag_service/dao/user/mongodb_user_dao.py +++ b/src/rag_service/dao/user/mongodb_user_dao.py @@ -1,7 +1,7 @@ import logging from bson import ObjectId -from pymongo import MongoClient +from pymongo import ASCENDING, MongoClient from src.config import Config from src.models.users.user import User @@ -17,11 +17,39 @@ def __init__(self): self.client = MongoClient(config.MONGODB_URI) self.db = self.client[config.MONGODB_DATABASE] self.collection = self.db[config.MONGODB_USER_COLLECTION] + self._create_indexes() + + def _create_indexes(self) -> None: + try: + self.collection.create_index([("id", ASCENDING)], unique=True, sparse=True) + self.collection.create_index([("email", ASCENDING)], sparse=True) + self.collection.create_index( + [("auth_provider", ASCENDING), ("provider_user_id", ASCENDING)], + unique=True, + ) + except Exception as e: + logger.warning(f"Could not create user indexes: {e}") + + def _user_from_mongo(self, user_doc: dict) -> User: + user_doc = dict(user_doc) + user_doc.pop("_id", None) + return User(**user_doc) def set_user(self, user: User) -> User: if not user.id: + existing = self.get_user_by_provider( + user.auth_provider, user.provider_user_id + ) + if existing: + user.id = existing.id + user.owned_agents = user.owned_agents or existing.owned_agents + user.api_keys = user.api_keys or existing.api_keys + return self.set_user(user) + # Create new user - result = self.collection.insert_one(user.model_dump()) + user_doc = user.model_dump() + user_doc.pop("id", None) + result = self.collection.insert_one(user_doc) user.id = str(result.inserted_id) # Update the document with the string ID @@ -51,8 +79,7 @@ def get_user_by_id(self, user_id: str) -> User | None: try: user_doc = self.collection.find_one({"_id": ObjectId(user_id)}) if user_doc: - user_doc.pop("_id", None) - return User(**user_doc) + return self._user_from_mongo(user_doc) return None except Exception as e: logger.warning(f"An exception occured when trying to fetch user : {e}") @@ -66,8 +93,7 @@ def get_user_by_provider( {"auth_provider": auth_provider, "provider_user_id": provider_user_id} ) if user_doc: - user_doc.pop("_id", None) - return User(**user_doc) + return self._user_from_mongo(user_doc) return None except Exception as e: logger.warning( @@ -75,6 +101,18 @@ def get_user_by_provider( ) return None + def get_user_by_email(self, email: str) -> User | None: + try: + user_doc = self.collection.find_one({"email": email}) + if user_doc: + return self._user_from_mongo(user_doc) + return None + except Exception as e: + logger.warning( + f"An exception occured when trying to fetch user by email: {e}" + ) + return None + def is_reachable(self) -> bool: try: self.client.admin.command("ping") diff --git a/src/routes/agents.py b/src/routes/agents.py index 796d763..3506708 100644 --- a/src/routes/agents.py +++ b/src/routes/agents.py @@ -60,6 +60,16 @@ def _auth_or_skip(authorize: Optional[AuthJWT], agent_id: str): auth_service.auth(authorize, agent_id) +def _ensure_agent_owner(authorize: Optional[AuthJWT], agent_id: str) -> User | None: + if os.getenv("DISABLE_AUTH", "").lower() == "true" or authorize is None: + return None + + user = auth_service.get_authenticated_user(authorize) + if agent_id not in user.owned_agents: + raise HTTPException(status_code=401, detail="Unauthorized edit of agent") + return user + + class ProviderKeyRequest(BaseModel): provider: str api_key: str @@ -129,7 +139,11 @@ def get_agents(authorize: Annotated[Optional[AuthJWT], Depends(optional_auth)] = user = _get_user_or_demo(authorize) # Changed # returns all agents, owned by the user - return [agent_dao.get_agent_by_id(agent_id) for agent_id in user.owned_agents] + return [ + agent + for agent_id in user.owned_agents + if (agent := agent_dao.get_agent_by_id(agent_id)) is not None + ] # Get a specific agent by ID @@ -151,8 +165,10 @@ def delete_agent( HTTPException: If agent not found """ user = _get_user_or_demo(authorize) # Changed + _auth_or_skip(authorize, agent_id) agent_dao.delete_agent_by_id(agent_id) - user.owned_agents.remove(agent_id) + if agent_id in user.owned_agents: + user.owned_agents.remove(agent_id) user_dao.set_user(user) @@ -227,7 +243,7 @@ def new_access_key( authorize: Annotated[Optional[AuthJWT], Depends(optional_auth)] = None, ): try: - _auth_or_skip(authorize, agent_id) # Changed + _ensure_agent_owner(authorize, agent_id) if expiry_date is None: return access_service.generate_accesskey(name, None, agent_id) else: @@ -248,7 +264,7 @@ def revoke_access_key( agent_id: str, authorize: Annotated[Optional[AuthJWT], Depends(optional_auth)] = None, ): - _auth_or_skip(authorize, agent_id) # Changed + _ensure_agent_owner(authorize, agent_id) try: return access_service.revoke_key(agent_id, access_key_id) except Exception as e: @@ -260,7 +276,7 @@ def get_access_keys( agent_id: str, authorize: Annotated[Optional[AuthJWT], Depends(optional_auth)] = None, ): - _auth_or_skip(authorize, agent_id) # Changed + _ensure_agent_owner(authorize, agent_id) agent = agent_dao.get_agent_by_id(agent_id) if agent is None: raise HTTPException( diff --git a/src/routes/auth.py b/src/routes/auth.py index 0f396a9..69fba11 100644 --- a/src/routes/auth.py +++ b/src/routes/auth.py @@ -1,4 +1,3 @@ -import os import logging from datetime import timedelta from typing import Annotated @@ -71,7 +70,7 @@ def refresh(authorize: Annotated[AuthJWT, Depends()] = None): new_session_token = authorize.create_access_token(subject=user_id) return { "session_token": new_session_token, - "session_token_ttl": config.SESSION_TOKEN_TTL, + "session_token_ttl": int(config.SESSION_TOKEN_TTL) * 1000 * 60, } diff --git a/src/routes/debug.py b/src/routes/debug.py index 4bb146f..976ff3d 100644 --- a/src/routes/debug.py +++ b/src/routes/debug.py @@ -1,12 +1,88 @@ -from fastapi import APIRouter +import os +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field + +from src.globals import agent_dao, user_dao +from src.models.users.user import User from src.utils.global_logs import failure_log, progress_log router = APIRouter() +class BootstrapKeycloakUserRequest(BaseModel): + provider_user_id: str = Field(..., min_length=1) + email: str | None = None + name: str | None = None + attach_all_agents: bool = True + migrate_from_providers: list[str] = Field( + default_factory=lambda: ["demo", "mock", "dev"] + ) + + @router.get("/api/debug/logs") def get_logs(): """Returns the in-memory logs for progress and failure.""" return {"progressLog": progress_log, "failureLog": failure_log} + + +@router.post("/api/debug/bootstrap-keycloak-user") +def bootstrap_keycloak_user(payload: BootstrapKeycloakUserRequest): + """Local migration helper for attaching existing data to a Keycloak user. + + This endpoint is intentionally disabled unless AUTH_BOOTSTRAP_ENABLED=true. + It is useful when moving from DISABLE_AUTH/demo mode to Keycloak locally. + """ + if os.getenv("AUTH_BOOTSTRAP_ENABLED", "").lower() != "true": + raise HTTPException(status_code=404, detail="Not found") + + user = user_dao.get_user_by_provider("keycloak", payload.provider_user_id) + if user is None: + user = User( + auth_provider="keycloak", + provider_user_id=payload.provider_user_id, + email=payload.email, + name=payload.name, + owned_agents=[], + api_keys=[], + ) + else: + user.email = payload.email or user.email + user.name = payload.name or user.name + + owned_agents = set(user.owned_agents) + if payload.attach_all_agents: + owned_agents.update( + agent.id for agent in agent_dao.get_agents() if agent.id is not None + ) + + existing_api_key_ids = {key.id for key in user.api_keys} + for provider in payload.migrate_from_providers: + provider_user_ids = [provider] + if provider == "dev": + provider_user_ids.append("dev-provider-id") + + source_user = None + for provider_user_id in provider_user_ids: + source_user = user_dao.get_user_by_provider(provider, provider_user_id) + if source_user is not None: + break + if source_user is None: + continue + + owned_agents.update(source_user.owned_agents) + for api_key in source_user.api_keys: + if api_key.id not in existing_api_key_ids: + user.api_keys.append(api_key) + existing_api_key_ids.add(api_key.id) + + user.owned_agents = sorted(owned_agents) + user = user_dao.set_user(user) + return { + "id": user.id, + "auth_provider": user.auth_provider, + "provider_user_id": user.provider_user_id, + "owned_agents": user.owned_agents, + "api_key_count": len(user.api_keys), + } diff --git a/tests/mocks/mock_user_dao.py b/tests/mocks/mock_user_dao.py index c5e8b4b..27a7e0a 100644 --- a/tests/mocks/mock_user_dao.py +++ b/tests/mocks/mock_user_dao.py @@ -41,5 +41,11 @@ def get_user_by_provider( return user return None + def get_user_by_email(self, email: str) -> User | None: + for user in self.users: + if user.email == email: + return user + return None + def is_reachable(self) -> bool: return True diff --git a/uv.lock b/uv.lock index 23500e2..61825dc 100644 --- a/uv.lock +++ b/uv.lock @@ -443,6 +443,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/31/55cd413eaccd39125368be33c46de24a1f639f2e12349b0361b4678f3915/eval_type_backport-0.2.2-py3-none-any.whl", hash = "sha256:cb6ad7c393517f476f96d456d0412ea80f0a8cf96f6892834cd9340149111b0a", size = 5830, upload-time = "2024-12-21T20:09:44.175Z" }, ] +[[package]] +name = "faiss-cpu" +version = "1.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "packaging" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/8b/5d2cd7c9fd60bc4d1ca6e9f5e8b0eb57254a7b301daaa12d5853fdf48afe/faiss_cpu-1.14.2-cp310-abi3-macosx_14_0_arm64.whl", hash = "sha256:a20011b8a97318e6d5e29143a773277ca71f1c33140024aeec91f05ad56cdd04", size = 4621203, upload-time = "2026-05-22T19:58:27.415Z" }, + { url = "https://files.pythonhosted.org/packages/84/b1/05876aa7ceafd67a8c53667f574c0354e50ebadfdaf0632d7d9f5c80fffe/faiss_cpu-1.14.2-cp310-abi3-macosx_15_0_x86_64.whl", hash = "sha256:6ba528b5803fe5206bbb38e6ea2c537de0d1482a47c5765982af4e4a48c135e2", size = 6681426, upload-time = "2026-05-22T19:58:29.193Z" }, + { url = "https://files.pythonhosted.org/packages/35/b4/d130a1908ad548671cd5ee9a1b537c7d3753cfdf0b2b134f3c10ccc6b537/faiss_cpu-1.14.2-cp310-abi3-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b340c33212351f04d4f167cb7b22c9e756705a634d3b7b58987651f3ba1f6217", size = 9592827, upload-time = "2026-05-22T19:58:30.772Z" }, + { url = "https://files.pythonhosted.org/packages/7f/66/108f7075591b84852a6b285a81922e773c1c6d3b2c45da8ec4822f1bfab4/faiss_cpu-1.14.2-cp310-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ec4784d9a14973f2eead3a3480e6d14e74253b234e2c12717d4319f21dfadfcd", size = 18234781, upload-time = "2026-05-22T19:58:33.268Z" }, + { url = "https://files.pythonhosted.org/packages/56/b9/a4d076acd9eeaf6fb6f5a29de1a7a0a34de411d0fbe3d8f8c91abb9f97e0/faiss_cpu-1.14.2-cp311-cp311-win_amd64.whl", hash = "sha256:a9d81699ff3ae6e25244efda48c52e406963bbce16d5754073725c14620885c6", size = 16111239, upload-time = "2026-05-22T19:58:38.889Z" }, +] + [[package]] name = "fastapi" version = "0.115.8" @@ -2011,6 +2027,7 @@ source = { virtual = "." } dependencies = [ { name = "bcrypt" }, { name = "cryptography" }, + { name = "faiss-cpu" }, { name = "fastapi" }, { name = "fastapi-jwt-auth-compat" }, { name = "flask" }, @@ -2031,6 +2048,7 @@ dependencies = [ { name = "python-dotenv" }, { name = "python-multipart" }, { name = "requests" }, + { name = "scikit-learn" }, { name = "scipy" }, { name = "soundfile" }, { name = "torch" }, @@ -2054,6 +2072,7 @@ dev = [ requires-dist = [ { name = "bcrypt", specifier = "==3.2.0" }, { name = "cryptography", specifier = ">=46.0.2" }, + { name = "faiss-cpu", specifier = ">=1.8.0" }, { name = "fastapi", specifier = "==0.115.8" }, { name = "fastapi-jwt-auth-compat", specifier = "==1.0.1" }, { name = "flask", specifier = "==3.1.0" }, @@ -2074,6 +2093,7 @@ requires-dist = [ { name = "python-dotenv", specifier = "==1.0.1" }, { name = "python-multipart", specifier = ">=0.0.20" }, { name = "requests", specifier = ">=2.32.3" }, + { name = "scikit-learn", specifier = ">=1.5.0" }, { name = "scipy", specifier = "==1.15.2" }, { name = "soundfile", specifier = ">=0.13.1" }, { name = "torch", specifier = "==2.6.0" }, @@ -2253,6 +2273,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/c3/c0be1135726618dc1e28d181b8c442403d8dbb9e273fd791de2d4384bcdd/safetensors-0.6.2-cp38-abi3-win_amd64.whl", hash = "sha256:c7b214870df923cbc1593c3faee16bec59ea462758699bd3fee399d00aac072c", size = 320192, upload-time = "2025-08-08T13:13:59.467Z" }, ] +[[package]] +name = "scikit-learn" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "joblib" }, + { name = "numpy" }, + { name = "scipy" }, + { name = "threadpoolctl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/92/53ea2181da8ac6bf27170191028aee7251f8f841f8d3edbfdcaf2008fde9/scikit_learn-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:146b4d36f800c013d267b29168813f7a03a43ecd2895d04861f1240b564421da", size = 8595835, upload-time = "2025-12-10T07:07:39.385Z" }, + { url = "https://files.pythonhosted.org/packages/01/18/d154dc1638803adf987910cdd07097d9c526663a55666a97c124d09fb96a/scikit_learn-1.8.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:f984ca4b14914e6b4094c5d52a32ea16b49832c03bd17a110f004db3c223e8e1", size = 8080381, upload-time = "2025-12-10T07:07:41.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/44/226142fcb7b7101e64fdee5f49dbe6288d4c7af8abf593237b70fca080a4/scikit_learn-1.8.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e30adb87f0cc81c7690a84f7932dd66be5bac57cfe16b91cb9151683a4a2d3b", size = 8799632, upload-time = "2025-12-10T07:07:43.899Z" }, + { url = "https://files.pythonhosted.org/packages/36/4d/4a67f30778a45d542bbea5db2dbfa1e9e100bf9ba64aefe34215ba9f11f6/scikit_learn-1.8.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ada8121bcb4dac28d930febc791a69f7cb1673c8495e5eee274190b73a4559c1", size = 9103788, upload-time = "2025-12-10T07:07:45.982Z" }, + { url = "https://files.pythonhosted.org/packages/89/3c/45c352094cfa60050bcbb967b1faf246b22e93cb459f2f907b600f2ceda5/scikit_learn-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:c57b1b610bd1f40ba43970e11ce62821c2e6569e4d74023db19c6b26f246cb3b", size = 8081706, upload-time = "2025-12-10T07:07:48.111Z" }, + { url = "https://files.pythonhosted.org/packages/3d/46/5416595bb395757f754feb20c3d776553a386b661658fb21b7c814e89efe/scikit_learn-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:2838551e011a64e3053ad7618dda9310175f7515f1742fa2d756f7c874c05961", size = 7688451, upload-time = "2025-12-10T07:07:49.873Z" }, +] + [[package]] name = "scipy" version = "1.15.2" @@ -2352,6 +2392,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b2/fe/81695a1aa331a842b582453b605175f419fe8540355886031328089d840a/sympy-1.13.1-py3-none-any.whl", hash = "sha256:db36cdc64bf61b9b24578b6f7bab1ecdd2452cf008f34faa33776680c26d66f8", size = 6189177, upload-time = "2024-07-19T09:26:48.863Z" }, ] +[[package]] +name = "threadpoolctl" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, +] + [[package]] name = "tiktoken" version = "0.12.0" From 7857a419ddc39820a42b859ebcf4da26eca9a26d Mon Sep 17 00:00:00 2001 From: tobiasfremming Date: Sat, 30 May 2026 19:37:17 +0200 Subject: [PATCH 2/4] feat: coopaeration --- src/auth/auth_service/auth_service.py | 5 +- src/models/users/user.py | 4 +- src/rag_service/dao/user/base.py | 8 + src/rag_service/dao/user/mongodb_user_dao.py | 32 +++ src/routes/agents.py | 225 +++++++++++++++++-- tests/mocks/mock_user_dao.py | 21 ++ 6 files changed, 270 insertions(+), 25 deletions(-) diff --git a/src/auth/auth_service/auth_service.py b/src/auth/auth_service/auth_service.py index 29e0507..e6cb50b 100644 --- a/src/auth/auth_service/auth_service.py +++ b/src/auth/auth_service/auth_service.py @@ -41,7 +41,10 @@ def auth(self, authorize: AuthJWT | None, agent_id: str): logger.warning("No authorization provided") raise HTTPException(status_code=401, detail="Unauthorized edit of agent") user = self.get_authenticated_user(authorize) - if agent_id not in user.owned_agents: + if ( + agent_id not in user.owned_agents + and agent_id not in user.collaborating_agents + ): logger.warning( f"User tried to access agent they dont own user: {user.id}, agent : {agent_id}" ) diff --git a/src/models/users/user.py b/src/models/users/user.py index ddc7cd9..d811a54 100644 --- a/src/models/users/user.py +++ b/src/models/users/user.py @@ -17,7 +17,8 @@ class User(BaseModel): name: the name of the user email: the email of the user picture: picture for use on the config site - owned_agents the agent_ids of the agents owned by the user + owned_agents: the agent_ids of the agents owned by the user + collaborating_agents: the agent_ids of agents shared with the user """ id: str | None = Field(default=None, description="Unique identifier for the user") @@ -27,6 +28,7 @@ class User(BaseModel): email: str | None = None picture: str | None = None owned_agents: list[str] = Field(default_factory=list) + collaborating_agents: list[str] = Field(default_factory=list) api_keys: list[UserAPIKey] = Field(default_factory=list) def add_api_key(self, api_key: UserAPIKey) -> None: diff --git a/src/rag_service/dao/user/base.py b/src/rag_service/dao/user/base.py index 46b6bb7..0880dca 100644 --- a/src/rag_service/dao/user/base.py +++ b/src/rag_service/dao/user/base.py @@ -22,6 +22,14 @@ def get_user_by_provider( def get_user_by_email(self, email: str) -> User | None: """Retrieve a specific user by email.""" + @abstractmethod + def search_users(self, query: str, limit: int = 10) -> list[User]: + """Search users by name, email, or provider user id.""" + + @abstractmethod + def get_users_with_agent(self, agent_id: str) -> list[User]: + """Retrieve users that own or collaborate on an agent.""" + @abstractmethod def is_reachable(self) -> bool: """Check if the DAO backend is accessible.""" diff --git a/src/rag_service/dao/user/mongodb_user_dao.py b/src/rag_service/dao/user/mongodb_user_dao.py index 9f2d415..f9adcea 100644 --- a/src/rag_service/dao/user/mongodb_user_dao.py +++ b/src/rag_service/dao/user/mongodb_user_dao.py @@ -1,4 +1,5 @@ import logging +import re from bson import ObjectId from pymongo import ASCENDING, MongoClient @@ -43,6 +44,9 @@ def set_user(self, user: User) -> User: if existing: user.id = existing.id user.owned_agents = user.owned_agents or existing.owned_agents + user.collaborating_agents = ( + user.collaborating_agents or existing.collaborating_agents + ) user.api_keys = user.api_keys or existing.api_keys return self.set_user(user) @@ -113,6 +117,34 @@ def get_user_by_email(self, email: str) -> User | None: ) return None + def search_users(self, query: str, limit: int = 10) -> list[User]: + query = query.strip() + if not query: + return [] + + pattern = re.compile(re.escape(query), re.IGNORECASE) + user_docs = self.collection.find( + { + "$or": [ + {"name": pattern}, + {"email": pattern}, + {"provider_user_id": pattern}, + ] + } + ).limit(limit) + return [self._user_from_mongo(user_doc) for user_doc in user_docs] + + def get_users_with_agent(self, agent_id: str) -> list[User]: + user_docs = self.collection.find( + { + "$or": [ + {"owned_agents": agent_id}, + {"collaborating_agents": agent_id}, + ] + } + ) + return [self._user_from_mongo(user_doc) for user_doc in user_docs] + def is_reachable(self) -> bool: try: self.client.admin.command("ping") diff --git a/src/routes/agents.py b/src/routes/agents.py index 3506708..fef4a3b 100644 --- a/src/routes/agents.py +++ b/src/routes/agents.py @@ -1,6 +1,6 @@ import os from datetime import datetime -from typing import Annotated, Optional +from typing import Annotated from fastapi import APIRouter, Depends, Header, HTTPException, Request from fastapi_jwt_auth import AuthJWT @@ -22,9 +22,13 @@ router = APIRouter() -def optional_auth(request: Request) -> Optional[AuthJWT]: +def _auth_disabled() -> bool: + return os.getenv("DISABLE_AUTH", "").lower() == "true" or config.RUNNING_TESTS + + +def optional_auth(request: Request) -> AuthJWT | None: """Return AuthJWT only if auth is enabled and header is present.""" - if os.getenv("DISABLE_AUTH", "").lower() == "true": + if _auth_disabled(): return None auth_header = request.headers.get("authorization") @@ -35,9 +39,9 @@ def optional_auth(request: Request) -> Optional[AuthJWT]: return AuthJWT(request) -def _get_user_or_demo(authorize: Optional[AuthJWT]) -> User: +def _get_user_or_demo(authorize: AuthJWT | None) -> User: """Get authenticated user or demo user if auth is disabled.""" - if os.getenv("DISABLE_AUTH", "").lower() == "true" or authorize is None: + if _auth_disabled() or authorize is None: demo_user = user_dao.get_user_by_provider("demo", "demo") if not demo_user: demo_user = User( @@ -53,15 +57,15 @@ def _get_user_or_demo(authorize: Optional[AuthJWT]) -> User: return auth_service.get_authenticated_user(authorize) -def _auth_or_skip(authorize: Optional[AuthJWT], agent_id: str): +def _auth_or_skip(authorize: AuthJWT | None, agent_id: str): """Check agent ownership or skip if auth is disabled.""" - if os.getenv("DISABLE_AUTH", "").lower() == "true" or authorize is None: + if _auth_disabled() or authorize is None: return auth_service.auth(authorize, agent_id) -def _ensure_agent_owner(authorize: Optional[AuthJWT], agent_id: str) -> User | None: - if os.getenv("DISABLE_AUTH", "").lower() == "true" or authorize is None: +def _ensure_agent_owner(authorize: AuthJWT | None, agent_id: str) -> User | None: + if _auth_disabled() or authorize is None: return None user = auth_service.get_authenticated_user(authorize) @@ -70,6 +74,53 @@ def _ensure_agent_owner(authorize: Optional[AuthJWT], agent_id: str) -> User | N return user +def _can_access_agent(user: User, agent_id: str) -> bool: + return agent_id in user.owned_agents or agent_id in user.collaborating_agents + + +def _get_agent_owner(agent_id: str) -> User | None: + for user in user_dao.get_users_with_agent(agent_id): + if agent_id in user.owned_agents: + return user + return None + + +def _public_user(user: User, role: str | None = None) -> dict: + return { + "id": user.id or "", + "name": user.name, + "email": user.email, + "picture": user.picture, + "role": role, + } + + +def _scrub_agent_api_keys(agent: Agent) -> Agent: + agent_copy = agent.model_copy() + agent_copy.llm_api_key = "" + agent_copy.embedding_api_key = "" + return agent_copy + + +class UserSearchResult(BaseModel): + id: str + name: str | None = None + email: str | None = None + picture: str | None = None + role: str | None = None + + +class CollaboratorInviteRequest(BaseModel): + user_id: str + + +class CollaboratorsResponse(BaseModel): + owner: UserSearchResult | None + collaborators: list[UserSearchResult] + current_user_id: str | None = None + is_owner: bool = False + + class ProviderKeyRequest(BaseModel): provider: str api_key: str @@ -96,7 +147,7 @@ def _map_embedding_api_error(error: EmbeddingAPIError) -> int: # Update agent @router.post("/update-agent/", response_model=Agent) def create_agent( - agent: Agent, authorize: Annotated[Optional[AuthJWT], Depends(optional_auth)] = None + agent: Agent, authorize: Annotated[AuthJWT | None, Depends(optional_auth)] = None ): """Create a new agent configuration. @@ -109,7 +160,8 @@ def create_agent( """ try: # Check if agent exists - if agent_dao.get_agent_by_id(agent.id) is None: + existing_agent = agent_dao.get_agent_by_id(agent.id) + if existing_agent is None: # Authenticate and get user user = _get_user_or_demo(authorize) # Changed agent = agent_dao.add_agent(agent) @@ -119,7 +171,16 @@ def create_agent( return agent _auth_or_skip(authorize, agent.id) # Changed - return agent_dao.add_agent(agent) + user = _get_user_or_demo(authorize) + if not agent.llm_api_key: + agent.llm_api_key = existing_agent.llm_api_key + if not agent.embedding_api_key: + agent.embedding_api_key = existing_agent.embedding_api_key + + updated_agent = agent_dao.add_agent(agent) + if agent.id not in user.owned_agents: + return _scrub_agent_api_keys(updated_agent) + return updated_agent except ValueError as e: raise HTTPException( @@ -130,7 +191,7 @@ def create_agent( # Get all agents @router.get("/agents/", response_model=list[Agent]) -def get_agents(authorize: Annotated[Optional[AuthJWT], Depends(optional_auth)] = None): +def get_agents(authorize: Annotated[AuthJWT | None, Depends(optional_auth)] = None): """Retrieve all agent configurations. Returns: @@ -138,19 +199,24 @@ def get_agents(authorize: Annotated[Optional[AuthJWT], Depends(optional_auth)] = """ user = _get_user_or_demo(authorize) # Changed - # returns all agents, owned by the user - return [ + owned_agents = [ agent for agent_id in user.owned_agents if (agent := agent_dao.get_agent_by_id(agent_id)) is not None ] + collaborator_agents = [ + _scrub_agent_api_keys(agent) + for agent_id in user.collaborating_agents + if (agent := agent_dao.get_agent_by_id(agent_id)) is not None + ] + return owned_agents + collaborator_agents # Get a specific agent by ID @router.get("/delete-agent") def delete_agent( agent_id: str, - authorize: Annotated[Optional[AuthJWT], Depends(optional_auth)] = None, + authorize: Annotated[AuthJWT | None, Depends(optional_auth)] = None, ): """Deletes a specific agent by ID. @@ -165,18 +231,22 @@ def delete_agent( HTTPException: If agent not found """ user = _get_user_or_demo(authorize) # Changed - _auth_or_skip(authorize, agent_id) + _ensure_agent_owner(authorize, agent_id) agent_dao.delete_agent_by_id(agent_id) if agent_id in user.owned_agents: user.owned_agents.remove(agent_id) user_dao.set_user(user) + for collaborator in user_dao.get_users_with_agent(agent_id): + if agent_id in collaborator.collaborating_agents: + collaborator.collaborating_agents.remove(agent_id) + user_dao.set_user(collaborator) # Get a specific agent by ID @router.get("/fetch-agent", response_model=Agent) def get_agent( agent_id: str, - authorize: Annotated[Optional[AuthJWT], Depends(optional_auth)] = None, + authorize: Annotated[AuthJWT | None, Depends(optional_auth)] = None, ): """Retrieve a specific agent by ID. @@ -191,15 +261,124 @@ def get_agent( HTTPException: If agent not found """ _auth_or_skip(authorize, agent_id) # Changed + user = _get_user_or_demo(authorize) agent = agent_dao.get_agent_by_id(agent_id) if agent is None: raise HTTPException( status_code=404, detail=f"Agent with id {agent_id} not found" ) + if agent_id not in user.owned_agents: + return _scrub_agent_api_keys(agent) return agent +@router.get("/users/search", response_model=list[UserSearchResult]) +def search_users( + q: str, + limit: int = 10, + authorize: Annotated[AuthJWT | None, Depends(optional_auth)] = None, +): + current_user = _get_user_or_demo(authorize) + users = user_dao.search_users(q, min(max(limit, 1), 25)) + return [ + UserSearchResult(**_public_user(user)) + for user in users + if user.id is not None and user.id != current_user.id + ] + + +@router.get( + "/agents/{agent_id}/collaborators", + response_model=CollaboratorsResponse, +) +def get_collaborators( + agent_id: str, + authorize: Annotated[AuthJWT | None, Depends(optional_auth)] = None, +): + current_user = _get_user_or_demo(authorize) + if not _can_access_agent(current_user, agent_id): + raise HTTPException(status_code=401, detail="Unauthorized access to agent") + + users = user_dao.get_users_with_agent(agent_id) + owner = next((user for user in users if agent_id in user.owned_agents), None) + collaborators = [ + UserSearchResult(**_public_user(user, "collaborator")) + for user in users + if agent_id in user.collaborating_agents and user.id is not None + ] + + return CollaboratorsResponse( + owner=UserSearchResult(**_public_user(owner, "owner")) if owner else None, + collaborators=collaborators, + current_user_id=current_user.id, + is_owner=agent_id in current_user.owned_agents, + ) + + +@router.post( + "/agents/{agent_id}/collaborators", + response_model=CollaboratorsResponse, +) +def add_collaborator( + agent_id: str, + payload: CollaboratorInviteRequest, + authorize: Annotated[AuthJWT | None, Depends(optional_auth)] = None, +): + owner = _ensure_agent_owner(authorize, agent_id) + invited_user = user_dao.get_user_by_id(payload.user_id) + if invited_user is None: + raise HTTPException(status_code=404, detail="User not found") + if owner and invited_user.id == owner.id: + raise HTTPException(status_code=400, detail="Owner is already on this agent") + if agent_id in invited_user.owned_agents: + raise HTTPException(status_code=400, detail="User already owns this agent") + if agent_id not in invited_user.collaborating_agents: + invited_user.collaborating_agents.append(agent_id) + user_dao.set_user(invited_user) + return get_collaborators(agent_id, authorize) + + +@router.delete( + "/agents/{agent_id}/collaborators/{user_id}", + response_model=CollaboratorsResponse, +) +def remove_collaborator( + agent_id: str, + user_id: str, + authorize: Annotated[AuthJWT | None, Depends(optional_auth)] = None, +): + owner = _ensure_agent_owner(authorize, agent_id) + if owner and user_id == owner.id: + raise HTTPException(status_code=400, detail="Owner cannot be removed") + + collaborator = user_dao.get_user_by_id(user_id) + if collaborator is None: + raise HTTPException(status_code=404, detail="User not found") + if agent_id in collaborator.collaborating_agents: + collaborator.collaborating_agents.remove(agent_id) + user_dao.set_user(collaborator) + return get_collaborators(agent_id, authorize) + + +@router.post("/agents/{agent_id}/leave") +def leave_agent( + agent_id: str, + authorize: Annotated[AuthJWT | None, Depends(optional_auth)] = None, +): + current_user = _get_user_or_demo(authorize) + if agent_id in current_user.owned_agents: + raise HTTPException( + status_code=400, + detail="Owner cannot leave their own agent. Delete it instead.", + ) + if agent_id not in current_user.collaborating_agents: + raise HTTPException(status_code=404, detail="Collaboration not found") + current_user.collaborating_agents.remove(agent_id) + user_dao.set_user(current_user) + return {"detail": "Left agent"} + + # Get a specific agent by ID using AccessKey for authentication @router.get("/agent-info", response_model=Agent) def agent_info( @@ -240,7 +419,7 @@ def new_access_key( name: str, agent_id: str, expiry_date: str | None = None, - authorize: Annotated[Optional[AuthJWT], Depends(optional_auth)] = None, + authorize: Annotated[AuthJWT | None, Depends(optional_auth)] = None, ): try: _ensure_agent_owner(authorize, agent_id) @@ -262,7 +441,7 @@ def new_access_key( def revoke_access_key( access_key_id: str, agent_id: str, - authorize: Annotated[Optional[AuthJWT], Depends(optional_auth)] = None, + authorize: Annotated[AuthJWT | None, Depends(optional_auth)] = None, ): _ensure_agent_owner(authorize, agent_id) try: @@ -274,7 +453,7 @@ def revoke_access_key( @router.get("/get-accesskeys", response_model=list[AccessKey]) def get_access_keys( agent_id: str, - authorize: Annotated[Optional[AuthJWT], Depends(optional_auth)] = None, + authorize: Annotated[AuthJWT | None, Depends(optional_auth)] = None, ): _ensure_agent_owner(authorize, agent_id) agent = agent_dao.get_agent_by_id(agent_id) @@ -291,7 +470,7 @@ def get_access_keys( @router.post("/get_models", response_model=list[Model]) def fetch_models( payload: ProviderKeyRequest, - authorize: Annotated[Optional[AuthJWT], Depends(optional_auth)] = None, + authorize: Annotated[AuthJWT | None, Depends(optional_auth)] = None, ): """Return all usable models for the requested provider using the supplied API key.""" if os.getenv("DISABLE_AUTH", "").lower() != "true" and authorize is not None: @@ -308,7 +487,7 @@ def fetch_models( @router.post("/get_embedding_models", response_model=list[str]) def fetch_embedding_models( payload: ProviderKeyRequest, - authorize: Annotated[Optional[AuthJWT], Depends(optional_auth)] = None, + authorize: Annotated[AuthJWT | None, Depends(optional_auth)] = None, ): """Return all usable embedding models for the requested provider using the supplied API key.""" if os.getenv("DISABLE_AUTH", "").lower() != "true" and authorize is not None: diff --git a/tests/mocks/mock_user_dao.py b/tests/mocks/mock_user_dao.py index 27a7e0a..69b33e6 100644 --- a/tests/mocks/mock_user_dao.py +++ b/tests/mocks/mock_user_dao.py @@ -47,5 +47,26 @@ def get_user_by_email(self, email: str) -> User | None: return user return None + def search_users(self, query: str, limit: int = 10) -> list[User]: + query = query.lower().strip() + if not query: + return [] + + matches = [] + for user in self.users: + fields = [user.name or "", user.email or "", user.provider_user_id] + if any(query in field.lower() for field in fields): + matches.append(user) + if len(matches) >= limit: + break + return matches + + def get_users_with_agent(self, agent_id: str) -> list[User]: + return [ + user + for user in self.users + if agent_id in user.owned_agents or agent_id in user.collaborating_agents + ] + def is_reachable(self) -> bool: return True From 1005b1a04a8b36f7332ac5a712c0c2c6b7127cd1 Mon Sep 17 00:00:00 2001 From: tobiasfremming Date: Sun, 31 May 2026 00:32:11 +0200 Subject: [PATCH 3/4] feat: enhance agent access control and add chat access key endpoint --- src/routes/agents.py | 57 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 53 insertions(+), 4 deletions(-) diff --git a/src/routes/agents.py b/src/routes/agents.py index fef4a3b..48815a8 100644 --- a/src/routes/agents.py +++ b/src/routes/agents.py @@ -1,5 +1,5 @@ import os -from datetime import datetime +from datetime import datetime, timedelta from typing import Annotated from fastapi import APIRouter, Depends, Header, HTTPException, Request @@ -102,6 +102,16 @@ def _scrub_agent_api_keys(agent: Agent) -> Agent: return agent_copy +def _ensure_agent_access(authorize: AuthJWT | None, agent_id: str) -> User | None: + if _auth_disabled() or authorize is None: + return None + + user = auth_service.get_authenticated_user(authorize) + if not _can_access_agent(user, agent_id): + raise HTTPException(status_code=401, detail="Unauthorized access to agent") + return user + + class UserSearchResult(BaseModel): id: str name: str | None = None @@ -172,6 +182,11 @@ def create_agent( _auth_or_skip(authorize, agent.id) # Changed user = _get_user_or_demo(authorize) + if not agent.llm_provider: + agent.llm_provider = existing_agent.llm_provider + if not agent.llm_model or agent.llm_model == "none": + agent.llm_provider = existing_agent.llm_provider + agent.llm_model = existing_agent.llm_model if not agent.llm_api_key: agent.llm_api_key = existing_agent.llm_api_key if not agent.embedding_api_key: @@ -421,8 +436,8 @@ def new_access_key( expiry_date: str | None = None, authorize: Annotated[AuthJWT | None, Depends(optional_auth)] = None, ): + _ensure_agent_access(authorize, agent_id) try: - _ensure_agent_owner(authorize, agent_id) if expiry_date is None: return access_service.generate_accesskey(name, None, agent_id) else: @@ -433,6 +448,8 @@ def new_access_key( name, expiry_date_formatted, agent_id ) + except HTTPException: + raise except Exception as e: raise HTTPException(status_code=500, detail=f"{e}") from e @@ -443,7 +460,7 @@ def revoke_access_key( agent_id: str, authorize: Annotated[AuthJWT | None, Depends(optional_auth)] = None, ): - _ensure_agent_owner(authorize, agent_id) + _ensure_agent_access(authorize, agent_id) try: return access_service.revoke_key(agent_id, access_key_id) except Exception as e: @@ -455,7 +472,7 @@ def get_access_keys( agent_id: str, authorize: Annotated[AuthJWT | None, Depends(optional_auth)] = None, ): - _ensure_agent_owner(authorize, agent_id) + _ensure_agent_access(authorize, agent_id) agent = agent_dao.get_agent_by_id(agent_id) if agent is None: raise HTTPException( @@ -467,6 +484,38 @@ def get_access_keys( return access_keys +@router.get("/chat-accesskey", response_model=AccessKey) +@router.get("/chat-access-key", response_model=AccessKey) +def chat_access_key( + agent_id: str, + authorize: Annotated[AuthJWT | None, Depends(optional_auth)] = None, +): + user = _ensure_agent_access(authorize, agent_id) + agent = agent_dao.get_agent_by_id(agent_id) + if agent is None: + raise HTTPException( + status_code=404, detail=f" agent of id not found {agent_id}" + ) + + user_key_suffix = user.id if user and user.id else "demo" + key_name = f"Chat Access Key - {user_key_suffix}" + now = datetime.now() + + for access_key in agent.access_key: + if ( + access_key.name == key_name + and access_key.key + and (access_key.expiry_date is None or access_key.expiry_date > now) + ): + return access_key + + return access_service.generate_accesskey( + key_name, + now + timedelta(days=2), + agent_id, + ) + + @router.post("/get_models", response_model=list[Model]) def fetch_models( payload: ProviderKeyRequest, From ba75791c591a3d1ad0fcc9ec413b761afc67d900 Mon Sep 17 00:00:00 2001 From: tobiasfremming Date: Sun, 31 May 2026 18:27:42 +0200 Subject: [PATCH 4/4] feat: update environment configuration for Keycloak integration and Docker setup --- .env.example | 102 +++++++++++++--------------- docker-compose.yml | 166 ++++++++++++++++++++++++++++++--------------- nginx.conf | 46 ++++++++----- 3 files changed, 185 insertions(+), 129 deletions(-) diff --git a/.env.example b/.env.example index 031d990..e3cca7f 100644 --- a/.env.example +++ b/.env.example @@ -1,56 +1,46 @@ -ENV = 'dev' # 'prod' or 'dev' - -# API URLs for frontend -RAGDOLL_CONFIG_API_URL = 'http://localhost:3000' -RAGDOLL_CHAT_API_URL = 'http://localhost:3001' - -# LLM api keys and models - -# OpenAI -OPENAI_API_KEY='your_api_key_here' -GPT_MODEL = 'gpt-4o-mini' - -# Google Gemini -GEMINI_API_KEY='your_api_key_here' -GEMINI_MODEL='gemini-2.0-flash-lite' - -# Idun provider -IDUN_MODEL='openai/gpt-oss-120b' -IDUN_API_URL='https://idun-llm.hpc.ntnu.no/api/chat/completions' -IDUN_API_KEY='your_idun_api_key_here' - - -# Database Configuration -# Production: Use MongoDB Atlas (supports vector search) -MONGODB_URI='mongodb+srv://username:password@cluster.mongodb.net/?retryWrites=true&w=majority' - -# Database and Collections -MONGODB_DATABASE='database' -MONGODB_AGENT_COLLECTION='agents' -MONGODB_CONTEXT_COLLECTION='contexts' -MONGODB_DOCUMENTS_COLLECTION='documents' - -# TODO: Both the context and agent DAOs share the RAG_DATABASE_SYSTEM. -# Fix this by separating it into two variables -RAG_DATABASE_SYSTEM='mongodb' - -# Testing/Mock database settings -# Use MongoDB Atlas for testing with different database name -MOCK_RAG_DATABASE_SYSTEM = 'mongodb' -MOCK_MONGODB_URI='mongodb+srv://username:password@cluster.mongodb.net/?retryWrites=true&w=majority' -MOCK_MONGODB_DATABASE='test_database' - -# AccessService, handles access keys, authorizes use of agents -# Set to "mock" to deactivate access, service removes auth from agent usage -ACCESS_SERVICE = 'service' - -## Set google api-key -GOOGLE_CLIENT_ID="your-google-api-key.apps.googleusercontent.com" -# for encrypting access keys -FERNET_KEY='your_fernet_key_here' - -# for authentication -SESSION_JWT_TOKEN_SECRET="your secret here" - -GOOGLE_CLIENT_ID="your id" -GOOGLE_CLIENT_SECRET="your secret here" \ No newline at end of file +ENV=prod +PUBLIC_BASE_URL=https://iplvr.it.ntnu.no + +# TLS files on the Docker host. Override these if the server stores certs elsewhere. +SSL_CERT_PATH=/root/iplvr.it.ntnu.no.crt +SSL_KEY_PATH=/root/iplvr.it.ntnu.no.key + +# Local MongoDB container used by docker-compose.yml. +MONGODB_ROOT_USERNAME=admin +MONGODB_ROOT_PASSWORD=replace-with-a-long-random-password +MONGODB_DATABASE=ragdoll_prod +MONGODB_AGENT_COLLECTION=agents +MONGODB_CONTEXT_COLLECTION=context +MONGODB_DOCUMENTS_COLLECTION=documents +MONGODB_USER_COLLECTION=users +RAG_DATABASE_SYSTEM=mongodb + +# Auth +AUTH_SERVICE=service +DISABLE_AUTH=false +AUTH_BOOTSTRAP_ENABLED=false +KEYCLOAK_ADMIN_USERNAME=admin +KEYCLOAK_ADMIN_PASSWORD=replace-with-a-long-random-password +KEYCLOAK_REALM=ragdoll +KEYCLOAK_CLIENT_ID=ragdoll-config +KEYCLOAK_CLIENT_SECRET=replace-with-keycloak-client-secret +KEYCLOAK_VERIFY_AUDIENCE=false +KEYCLOAK_ISSUER=https://iplvr.it.ntnu.no/realms/ragdoll +KEYCLOAK_JWKS_URL=https://iplvr.it.ntnu.no/realms/ragdoll/protocol/openid-connect/certs +NEXTAUTH_SECRET=replace-with-a-long-random-secret +JWT_SECRET=replace-with-a-long-random-secret +SESSION_TOKEN_TTL=15 +REFRESH_TOKEN_TTL=14 + +# Encryption. Generate with: +# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" +FERNET_KEY=replace-with-fernet-key + +# LLM providers. Agent-level stored API keys are managed in the UI, but defaults are still useful. +OPENAI_API_KEY=replace-with-openai-key +GPT_MODEL=gpt-4o-mini +GEMINI_API_KEY= +GEMINI_MODEL=gemini-2.0-flash-lite +IDUN_API_KEY= +IDUN_MODEL=openai/gpt-oss-120b +IDUN_API_URL=https://idun-llm.hpc.ntnu.no/api/chat/completions diff --git a/docker-compose.yml b/docker-compose.yml index 229eb5f..653cab4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,30 +1,21 @@ +name: ragdoll + services: - # Reverse proxy handling HTTPS with NTNU cert - nginx: - image: nginx:1.27-alpine # Pinned version with Alpine for reduced size + image: nginx:1.27-alpine container_name: ragdoll-nginx depends_on: - backend-service - frontend-service - chat-service + - keycloak ports: - "80:80" - "443:443" volumes: - # Mount static config and SSL certs - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro - - /root/iplvr.it.ntnu.no.crt:/etc/ssl/certs/server.crt:ro - - /root/iplvr.it.ntnu.no.key:/etc/ssl/private/server.key:ro - # Resource limits for production stability - deploy: - resources: - limits: - cpus: "1.0" - memory: 512M - reservations: - cpus: "0.5" - memory: 256M + - ${SSL_CERT_PATH:-/root/iplvr.it.ntnu.no.crt}:/etc/ssl/certs/server.crt:ro + - ${SSL_KEY_PATH:-/root/iplvr.it.ntnu.no.key}:/etc/ssl/private/server.key:ro restart: unless-stopped logging: driver: "json-file" @@ -32,39 +23,88 @@ services: max-size: "10m" max-file: "3" - # FastAPI backend + mongodb: + image: mongo:7 + container_name: ragdoll-mongodb + restart: unless-stopped + environment: + MONGO_INITDB_ROOT_USERNAME: ${MONGODB_ROOT_USERNAME:-admin} + MONGO_INITDB_ROOT_PASSWORD: ${MONGODB_ROOT_PASSWORD:?Set MONGODB_ROOT_PASSWORD in RAGdoll/.env} + MONGO_INITDB_DATABASE: ${MONGODB_DATABASE:-ragdoll_prod} + volumes: + - mongodb_data:/data/db + - mongodb_config:/data/configdb + - ./mongo-init.js:/docker-entrypoint-initdb.d/mongo-init.js:ro + expose: + - "27017" + healthcheck: + test: + [ + "CMD-SHELL", + "mongosh --quiet --username $$MONGO_INITDB_ROOT_USERNAME --password $$MONGO_INITDB_ROOT_PASSWORD --authenticationDatabase admin --eval 'db.runCommand({ ping: 1 }).ok' localhost:27017/admin", + ] + interval: 10s + timeout: 5s + retries: 10 + start_period: 20s + + keycloak: + image: quay.io/keycloak/keycloak:26.0 + container_name: ragdoll-keycloak + restart: unless-stopped + command: + - start + - --import-realm + - --http-enabled=true + - --proxy-headers=xforwarded + - --hostname=${PUBLIC_BASE_URL:-https://iplvr.it.ntnu.no} + environment: + KC_BOOTSTRAP_ADMIN_USERNAME: ${KEYCLOAK_ADMIN_USERNAME:-admin} + KC_BOOTSTRAP_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:?Set KEYCLOAK_ADMIN_PASSWORD in RAGdoll/.env} + KC_HTTP_PORT: 8080 + KC_HOSTNAME_STRICT: "false" + volumes: + - keycloak_data:/opt/keycloak/data + - ./keycloak/realm-ragdoll.json:/opt/keycloak/data/import/realm-ragdoll.json:ro + expose: + - "8080" + healthcheck: + test: ["CMD-SHELL", "timeout 5 bash -c '