mirror of
https://github.com/james-m-jordan/morphik-core.git
synced 2025-05-09 19:32:38 +00:00
Add ability for devs to create apps for users
This commit is contained in:
parent
7ef3bd2baa
commit
a62fc3ba5e
53
core/api.py
53
core/api.py
@ -15,6 +15,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
|
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
|
||||||
|
|
||||||
from core.agent import MorphikAgent
|
from core.agent import MorphikAgent
|
||||||
|
from core.auth_utils import verify_token
|
||||||
from core.cache.llama_cache_factory import LlamaCacheFactory
|
from core.cache.llama_cache_factory import LlamaCacheFactory
|
||||||
from core.completion.litellm_completion import LiteLLMCompletionModel
|
from core.completion.litellm_completion import LiteLLMCompletionModel
|
||||||
from core.config import get_settings
|
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.colpali_embedding_model import ColpaliEmbeddingModel
|
||||||
from core.embedding.litellm_embedding import LiteLLMEmbeddingModel
|
from core.embedding.litellm_embedding import LiteLLMEmbeddingModel
|
||||||
from core.limits_utils import check_and_increment_limits
|
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.completion import ChunkSource, CompletionResponse
|
||||||
from core.models.documents import ChunkResult, Document, DocumentResult
|
from core.models.documents import ChunkResult, Document, DocumentResult
|
||||||
from core.models.folders import Folder, FolderCreate
|
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
|
# Initialize the MorphikAgent once to load tool definitions and avoid repeated I/O
|
||||||
morphik_agent = MorphikAgent(document_service=document_service)
|
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
|
|
||||||
)
|
|
||||||
|
|
||||||
# Normal token verification flow
|
|
||||||
if not authorization:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=401,
|
|
||||||
detail="Missing authorization header",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
if not authorization.startswith("Bearer "):
|
from ee.routers import init_app as _init_ee_app # type: ignore
|
||||||
raise HTTPException(status_code=401, detail="Invalid authorization header")
|
|
||||||
|
|
||||||
token = authorization[7:] # Remove "Bearer "
|
_init_ee_app(app) # noqa: SLF001 – runtime extension
|
||||||
payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM])
|
logger.info("Enterprise routes mounted (ee package detected).")
|
||||||
|
except ModuleNotFoundError:
|
||||||
if datetime.fromtimestamp(payload["exp"], UTC) < datetime.now(UTC):
|
# Expected in OSS builds – silently ignore.
|
||||||
raise HTTPException(status_code=401, detail="Token expired")
|
logger.debug("Enterprise package not found – running in community mode.")
|
||||||
|
|
||||||
# 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))
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/ingest/text", response_model=Document)
|
@app.post("/ingest/text", response_model=Document)
|
||||||
|
69
core/auth_utils.py
Normal file
69
core/auth_utils.py
Normal 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
3
ee/__init__.py
Normal 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
27
ee/routers/__init__.py
Normal 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
59
ee/routers/cloud_uri.py
Normal 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}
|
@ -2384,3 +2384,9 @@ class AsyncMorphik:
|
|||||||
|
|
||||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||||
await self.close()
|
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)
|
||||||
|
@ -2574,3 +2574,22 @@ class Morphik:
|
|||||||
|
|
||||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||||
self.close()
|
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)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user