From a62fc3ba5ebf9769938e958da616717630b11efd Mon Sep 17 00:00:00 2001 From: Adityavardhan Agrawal Date: Sun, 4 May 2025 00:23:18 -0700 Subject: [PATCH] Add ability for devs to create apps for users --- core/api.py | 55 +++++++--------------------- core/auth_utils.py | 69 +++++++++++++++++++++++++++++++++++ ee/__init__.py | 3 ++ ee/routers/__init__.py | 27 ++++++++++++++ ee/routers/cloud_uri.py | 59 ++++++++++++++++++++++++++++++ sdks/python/morphik/async_.py | 6 +++ sdks/python/morphik/sync.py | 19 ++++++++++ 7 files changed, 196 insertions(+), 42 deletions(-) create mode 100644 core/auth_utils.py create mode 100644 ee/__init__.py create mode 100644 ee/routers/__init__.py create mode 100644 ee/routers/cloud_uri.py diff --git a/core/api.py b/core/api.py index f3cb5de..95f360d 100644 --- a/core/api.py +++ b/core/api.py @@ -15,6 +15,7 @@ from fastapi.middleware.cors import CORSMiddleware from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor from core.agent import MorphikAgent +from core.auth_utils import verify_token from core.cache.llama_cache_factory import LlamaCacheFactory from core.completion.litellm_completion import LiteLLMCompletionModel from core.config import get_settings @@ -22,7 +23,7 @@ from core.database.postgres_database import PostgresDatabase from core.embedding.colpali_embedding_model import ColpaliEmbeddingModel from core.embedding.litellm_embedding import LiteLLMEmbeddingModel from core.limits_utils import check_and_increment_limits -from core.models.auth import AuthContext, EntityType +from core.models.auth import AuthContext from core.models.completion import ChunkSource, CompletionResponse from core.models.documents import ChunkResult, Document, DocumentResult from core.models.folders import Folder, FolderCreate @@ -283,49 +284,19 @@ document_service = DocumentService( # Initialize the MorphikAgent once to load tool definitions and avoid repeated I/O morphik_agent = MorphikAgent(document_service=document_service) +# --------------------------------------------------------------------------- +# Mount enterprise-only routes when the proprietary ``ee`` package +# is present. +# --------------------------------------------------------------------------- -async def verify_token(authorization: str = Header(None)) -> AuthContext: - """Verify JWT Bearer token or return dev context if dev_mode is enabled.""" - # Check if dev mode is enabled - if settings.dev_mode: - return AuthContext( - entity_type=EntityType(settings.dev_entity_type), - entity_id=settings.dev_entity_id, - permissions=set(settings.dev_permissions), - user_id=settings.dev_entity_id, # In dev mode, entity_id is also the user_id - ) +try: + from ee.routers import init_app as _init_ee_app # type: ignore - # Normal token verification flow - if not authorization: - raise HTTPException( - status_code=401, - detail="Missing authorization header", - headers={"WWW-Authenticate": "Bearer"}, - ) - try: - if not authorization.startswith("Bearer "): - raise HTTPException(status_code=401, detail="Invalid authorization header") - - token = authorization[7:] # Remove "Bearer " - payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM]) - - if datetime.fromtimestamp(payload["exp"], UTC) < datetime.now(UTC): - raise HTTPException(status_code=401, detail="Token expired") - - # Support both "type" and "entity_type" fields for compatibility - entity_type_field = payload.get("type") or payload.get("entity_type") - if not entity_type_field: - raise HTTPException(status_code=401, detail="Missing entity type in token") - - return AuthContext( - entity_type=EntityType(entity_type_field), - entity_id=payload["entity_id"], - app_id=payload.get("app_id"), - permissions=set(payload.get("permissions", ["read"])), - user_id=payload.get("user_id", payload["entity_id"]), # Use user_id if available, fallback to entity_id - ) - except jwt.InvalidTokenError as e: - raise HTTPException(status_code=401, detail=str(e)) + _init_ee_app(app) # noqa: SLF001 – runtime extension + logger.info("Enterprise routes mounted (ee package detected).") +except ModuleNotFoundError: + # Expected in OSS builds – silently ignore. + logger.debug("Enterprise package not found – running in community mode.") @app.post("/ingest/text", response_model=Document) diff --git a/core/auth_utils.py b/core/auth_utils.py new file mode 100644 index 0000000..6369eae --- /dev/null +++ b/core/auth_utils.py @@ -0,0 +1,69 @@ +from datetime import UTC, datetime + +import jwt +from fastapi import Header, HTTPException + +from core.config import get_settings +from core.models.auth import AuthContext, EntityType + +__all__ = ["verify_token"] + +# Load settings once at import time +settings = get_settings() + + +async def verify_token(authorization: str = Header(None)) -> AuthContext: # noqa: D401 – FastAPI dependency + """Return an :class:`AuthContext` for a valid JWT bearer *authorization* header. + + In *dev_mode* we skip cryptographic checks and fabricate a permissive + context so that local development environments can quickly spin up + without real tokens. + """ + + # ------------------------------------------------------------------ + # 1. Development shortcut – trust everyone when *dev_mode* is active. + # ------------------------------------------------------------------ + if settings.dev_mode: + return AuthContext( + entity_type=EntityType(settings.dev_entity_type), + entity_id=settings.dev_entity_id, + permissions=set(settings.dev_permissions), + user_id=settings.dev_entity_id, # In dev mode, entity_id == user_id + ) + + # ------------------------------------------------------------------ + # 2. Normal token verification flow + # ------------------------------------------------------------------ + if not authorization: + raise HTTPException( + status_code=401, + detail="Missing authorization header", + headers={"WWW-Authenticate": "Bearer"}, + ) + + if not authorization.startswith("Bearer "): + raise HTTPException(status_code=401, detail="Invalid authorization header") + + token = authorization[7:] # Strip "Bearer " prefix + + try: + payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM]) + except jwt.InvalidTokenError as exc: + raise HTTPException(status_code=401, detail=str(exc)) from exc + + # Check expiry manually – jwt.decode does *not* enforce expiry on psycopg2. + if datetime.fromtimestamp(payload["exp"], UTC) < datetime.now(UTC): + raise HTTPException(status_code=401, detail="Token expired") + + # Support both legacy "type" and new "entity_type" fields + entity_type_field = payload.get("type") or payload.get("entity_type") + if entity_type_field is None: + raise HTTPException(status_code=401, detail="Missing entity type in token") + + return AuthContext( + entity_type=EntityType(entity_type_field), + entity_id=payload["entity_id"], + app_id=payload.get("app_id"), + permissions=set(payload.get("permissions", ["read"])), + user_id=payload.get("user_id", payload["entity_id"]), + ) diff --git a/ee/__init__.py b/ee/__init__.py new file mode 100644 index 0000000..015d4b1 --- /dev/null +++ b/ee/__init__.py @@ -0,0 +1,3 @@ +# Initialize the ee Python package for enterprise-only backend features + +__all__: list[str] = [] diff --git a/ee/routers/__init__.py b/ee/routers/__init__.py new file mode 100644 index 0000000..6044b30 --- /dev/null +++ b/ee/routers/__init__.py @@ -0,0 +1,27 @@ +"""Enterprise-only FastAPI routers. + +This sub-package bundles **all** additional HTTP API routes that are only +available in Morphik Enterprise Edition. Each module should expose an +``APIRouter`` instance called ``router`` so that it can be conveniently +mounted via :pyfunc:`ee.init_app`. +""" + +from importlib import import_module +from typing import List + +from fastapi import FastAPI + +__all__: List[str] = [] + + +def init_app(app: FastAPI) -> None: + """Mount all enterprise routers onto the given *app* instance.""" + # Discover routers lazily – import sub-modules that register a global + # ``router`` attribute. Keep the list here explicit to avoid accidental + # exposure of unfinished modules. + for module_path in [ + "ee.routers.cloud_uri", + ]: + mod = import_module(module_path) + if hasattr(mod, "router"): + app.include_router(mod.router) diff --git a/ee/routers/cloud_uri.py b/ee/routers/cloud_uri.py new file mode 100644 index 0000000..2f3363a --- /dev/null +++ b/ee/routers/cloud_uri.py @@ -0,0 +1,59 @@ +"""Enterprise endpoint that generates Morphik Cloud URIs without explicitly +specifying *user_id* in the request body. The user ID is harvested from the +JWT bearer token so that front-ends can call this endpoint with the same token +that they already possess. +""" + +from typing import Dict + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field + +from core.auth_utils import verify_token +from core.models.auth import AuthContext +from core.services.user_service import UserService + +router = APIRouter(prefix="/ee", tags=["Enterprise"]) + + +class GenerateUriEERequest(BaseModel): + """Request body for the EE *generate_uri* endpoint (no ``user_id`` field).""" + + app_id: str = Field(..., description="ID of the application") + name: str = Field(..., description="Name of the application") + expiry_days: int = Field(default=30, description="Token validity in days") + + +@router.post("/create_app", include_in_schema=True) +async def create_app( + request: GenerateUriEERequest, + auth: AuthContext = Depends(verify_token), +) -> Dict[str, str]: + """Generate a cloud URI for *request.app_id* owned by the calling user. + + The *user_id* is derived from the bearer token. The caller can therefore + not create applications for *other* users unless their token carries the + ``admin`` permission (mirroring the community behaviour). + """ + + # "auth" is guaranteed by verify_token. Reuse its user_id. + user_id = auth.user_id or auth.entity_id + + # --- 2. Generate the cloud URI via the UserService ---------------------- + user_service = UserService() + await user_service.initialize() + + name_clean = request.name.replace(" ", "_").lower() + + uri = await user_service.generate_cloud_uri( + user_id=user_id, + app_id=request.app_id, + name=name_clean, + expiry_days=request.expiry_days, + ) + + if not uri: + # The UserService returns *None* when the user exceeded their app quota + raise HTTPException(status_code=403, detail="Application limit reached for this account tier") + + return {"uri": uri, "app_id": request.app_id} diff --git a/sdks/python/morphik/async_.py b/sdks/python/morphik/async_.py index f745012..4d1296e 100644 --- a/sdks/python/morphik/async_.py +++ b/sdks/python/morphik/async_.py @@ -2384,3 +2384,9 @@ class AsyncMorphik: async def __aexit__(self, exc_type, exc_val, exc_tb): await self.close() + + async def create_app(self, app_id: str, name: str, expiry_days: int = 30) -> Dict[str, str]: + """Create a new application in Morphik Cloud and obtain its auth URI (async).""" + + payload = {"app_id": app_id, "name": name, "expiry_days": expiry_days} + return await self._request("POST", "ee/create_app", data=payload) diff --git a/sdks/python/morphik/sync.py b/sdks/python/morphik/sync.py index a8e270f..ca819f4 100644 --- a/sdks/python/morphik/sync.py +++ b/sdks/python/morphik/sync.py @@ -2574,3 +2574,22 @@ class Morphik: def __exit__(self, exc_type, exc_val, exc_tb): self.close() + + def create_app(self, app_id: str, name: str, expiry_days: int = 30) -> Dict[str, str]: + """Create a new application in Morphik Cloud and obtain its auth URI. + + This wraps the enterprise endpoint ``/ee/create_app`` which + returns a dictionary ``{\"uri\": ..., \"app_id\": ...}``. + + Parameters + ---------- + app_id: + Identifier for the new application. + name: + Human-readable application name (will be slugified by the server). + expiry_days: + Token validity period. Defaults to 30 days. + """ + + payload = {"app_id": app_id, "name": name, "expiry_days": expiry_days} + return self._request("POST", "ee/create_app", data=payload)