Add ability for devs to create apps for users

This commit is contained in:
Adityavardhan Agrawal 2025-05-04 00:23:18 -07:00
parent 7ef3bd2baa
commit a62fc3ba5e
7 changed files with 196 additions and 42 deletions

View File

@ -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)

69
core/auth_utils.py Normal file
View File

@ -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"]),
)

3
ee/__init__.py Normal file
View File

@ -0,0 +1,3 @@
# Initialize the ee Python package for enterprise-only backend features
__all__: list[str] = []

27
ee/routers/__init__.py Normal file
View File

@ -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)

59
ee/routers/cloud_uri.py Normal file
View File

@ -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}

View File

@ -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)

View File

@ -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)