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
55
core/api.py
55
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)
|
||||
|
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):
|
||||
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):
|
||||
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