Self-hosted dashboard and admin auth (#4837)
Co-authored-by: kartik-mem0 <kartik.labhshetwar@mem0.ai>
This commit is contained in:
co-authored by
kartik-mem0
parent
15feaa8ac4
commit
db8ac61713
@@ -0,0 +1,88 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from auth import generate_api_key, require_auth
|
||||
from db import get_db
|
||||
from models import APIKey, User
|
||||
from schemas import MessageResponse
|
||||
|
||||
router = APIRouter(prefix="/api-keys", tags=["api-keys"])
|
||||
|
||||
|
||||
class CreateKeyRequest(BaseModel):
|
||||
label: str
|
||||
|
||||
|
||||
class CreateKeyResponse(BaseModel):
|
||||
id: str
|
||||
key: str
|
||||
label: str
|
||||
key_prefix: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class KeyListItem(BaseModel):
|
||||
id: str
|
||||
label: str
|
||||
key_prefix: str
|
||||
created_at: datetime
|
||||
last_used_at: datetime | None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
@router.get("", response_model=list[KeyListItem])
|
||||
def list_keys(user: User = Depends(require_auth), db: Session = Depends(get_db)):
|
||||
keys = (
|
||||
db.execute(
|
||||
select(APIKey)
|
||||
.where(APIKey.created_by == user.id, APIKey.revoked_at.is_(None))
|
||||
.order_by(APIKey.created_at.desc())
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
return [
|
||||
KeyListItem(
|
||||
id=str(k.id),
|
||||
label=k.label,
|
||||
key_prefix=k.key_prefix,
|
||||
created_at=k.created_at,
|
||||
last_used_at=k.last_used_at,
|
||||
)
|
||||
for k in keys
|
||||
]
|
||||
|
||||
|
||||
@router.post("", response_model=CreateKeyResponse, status_code=201)
|
||||
def create_key(body: CreateKeyRequest, user: User = Depends(require_auth), db: Session = Depends(get_db)):
|
||||
full_key, prefix, key_hash = generate_api_key()
|
||||
api_key = APIKey(key_prefix=prefix, key_hash=key_hash, label=body.label, created_by=user.id)
|
||||
db.add(api_key)
|
||||
db.commit()
|
||||
db.refresh(api_key)
|
||||
|
||||
return CreateKeyResponse(
|
||||
id=str(api_key.id),
|
||||
key=full_key,
|
||||
label=api_key.label,
|
||||
key_prefix=prefix,
|
||||
created_at=api_key.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{key_id}", response_model=MessageResponse)
|
||||
def revoke_key(key_id: str, user: User = Depends(require_auth), db: Session = Depends(get_db)):
|
||||
api_key = db.get(APIKey, key_id)
|
||||
if api_key is None or api_key.created_by != user.id:
|
||||
raise HTTPException(status_code=404, detail="API key not found.")
|
||||
if api_key.revoked_at is not None:
|
||||
raise HTTPException(status_code=400, detail="API key is already revoked.")
|
||||
|
||||
api_key.revoked_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
return MessageResponse(message="API key revoked.")
|
||||
@@ -0,0 +1,212 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from auth import (
|
||||
consume_refresh_jti,
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
decode_token,
|
||||
dummy_verify_password,
|
||||
hash_password,
|
||||
require_auth,
|
||||
verify_password,
|
||||
)
|
||||
from db import get_db
|
||||
from models import User
|
||||
from rate_limit import limiter
|
||||
from schemas import MessageResponse
|
||||
from telemetry import capture_admin_registered, capture_onboarding_completed
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
MIN_PASSWORD_LENGTH = 8
|
||||
|
||||
|
||||
def _require_password_length(password: str) -> None:
|
||||
if len(password) < MIN_PASSWORD_LENGTH:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Password must be at least {MIN_PASSWORD_LENGTH} characters.",
|
||||
)
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
name: str
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
class OnboardingCompleteRequest(BaseModel):
|
||||
use_case: str
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
class RefreshRequest(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
|
||||
class UpdateProfileRequest(BaseModel):
|
||||
name: str | None = None
|
||||
email: EmailStr | None = None
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
current_password: str
|
||||
new_password: str
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
email: str
|
||||
role: str
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class SetupStatusResponse(BaseModel):
|
||||
needsSetup: bool
|
||||
|
||||
|
||||
@router.get("/setup-status", response_model=SetupStatusResponse)
|
||||
def setup_status(db: Session = Depends(get_db)):
|
||||
count = db.scalar(select(func.count(User.id)))
|
||||
return SetupStatusResponse(needsSetup=count == 0)
|
||||
|
||||
|
||||
@router.post("/register", response_model=TokenResponse)
|
||||
@limiter.limit("5/minute")
|
||||
def register(request: Request, body: RegisterRequest, db: Session = Depends(get_db)):
|
||||
"""Create the first admin account. Blocked once any user exists."""
|
||||
_require_password_length(body.password)
|
||||
|
||||
if db.scalar(select(func.count(User.id))) > 0:
|
||||
raise HTTPException(status_code=403, detail="Registration is closed. An admin account already exists.")
|
||||
|
||||
user = User(
|
||||
name=body.name,
|
||||
email=body.email,
|
||||
password_hash=hash_password(body.password),
|
||||
role="admin",
|
||||
)
|
||||
db.add(user)
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=403, detail="Registration is closed. An admin account already exists.")
|
||||
db.refresh(user)
|
||||
|
||||
capture_admin_registered(email=body.email)
|
||||
|
||||
return TokenResponse(
|
||||
access_token=create_access_token(str(user.id), user.role),
|
||||
refresh_token=create_refresh_token(str(user.id), db),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
@limiter.limit("10/minute")
|
||||
def login(request: Request, body: LoginRequest, db: Session = Depends(get_db)):
|
||||
user = db.scalar(select(User).where(User.email == body.email))
|
||||
if user is None:
|
||||
dummy_verify_password()
|
||||
raise HTTPException(status_code=401, detail="Invalid email or password.")
|
||||
if not verify_password(body.password, user.password_hash):
|
||||
raise HTTPException(status_code=401, detail="Invalid email or password.")
|
||||
|
||||
user.last_login_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
|
||||
return TokenResponse(
|
||||
access_token=create_access_token(str(user.id), user.role),
|
||||
refresh_token=create_refresh_token(str(user.id), db),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=TokenResponse)
|
||||
@limiter.limit("20/minute")
|
||||
def refresh(request: Request, body: RefreshRequest, db: Session = Depends(get_db)):
|
||||
payload = decode_token(body.refresh_token)
|
||||
if payload.get("type") != "refresh":
|
||||
raise HTTPException(status_code=401, detail="Invalid token type.")
|
||||
|
||||
jti = payload.get("jti")
|
||||
if not jti:
|
||||
raise HTTPException(status_code=401, detail="Refresh token is no longer valid.")
|
||||
|
||||
user = db.get(User, payload["sub"])
|
||||
if user is None:
|
||||
raise HTTPException(status_code=401, detail="User not found.")
|
||||
|
||||
consume_refresh_jti(jti, db)
|
||||
|
||||
return TokenResponse(
|
||||
access_token=create_access_token(str(user.id), user.role),
|
||||
refresh_token=create_refresh_token(str(user.id), db),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
def me(user: User = Depends(require_auth)):
|
||||
return user
|
||||
|
||||
|
||||
@router.patch("/me", response_model=UserResponse)
|
||||
def update_me(
|
||||
body: UpdateProfileRequest,
|
||||
user: User = Depends(require_auth),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
if body.name is not None and body.name.strip():
|
||||
user.name = body.name.strip()
|
||||
|
||||
if body.email is not None and body.email != user.email:
|
||||
collision = db.scalar(select(User).where(User.email == body.email, User.id != user.id))
|
||||
if collision is not None:
|
||||
raise HTTPException(status_code=409, detail="Email is already in use.")
|
||||
user.email = body.email
|
||||
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/change-password", response_model=MessageResponse)
|
||||
def change_password(
|
||||
body: ChangePasswordRequest,
|
||||
user: User = Depends(require_auth),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
if not verify_password(body.current_password, user.password_hash):
|
||||
raise HTTPException(status_code=401, detail="Current password is incorrect.")
|
||||
|
||||
_require_password_length(body.new_password)
|
||||
|
||||
user.password_hash = hash_password(body.new_password)
|
||||
db.commit()
|
||||
return MessageResponse(message="Password updated.")
|
||||
|
||||
|
||||
@router.post("/onboarding-complete", response_model=MessageResponse)
|
||||
def onboarding_complete(body: OnboardingCompleteRequest, user: User = Depends(require_auth)):
|
||||
"""Fire the one-shot telemetry event after the setup wizard reaches its success state."""
|
||||
capture_onboarding_completed(email=user.email, use_case=body.use_case)
|
||||
return MessageResponse(message="Onboarding completed.")
|
||||
@@ -0,0 +1,77 @@
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
|
||||
from auth import verify_auth
|
||||
from errors import upstream_error
|
||||
from schemas import MessageResponse
|
||||
from server_state import get_memory_instance
|
||||
|
||||
router = APIRouter(prefix="/entities", tags=["entities"])
|
||||
|
||||
SCAN_LIMIT = 10_000
|
||||
|
||||
EntityType = Literal["user", "agent", "run"]
|
||||
TYPE_TO_FIELD: dict[EntityType, str] = {"user": "user_id", "agent": "agent_id", "run": "run_id"}
|
||||
|
||||
|
||||
class Entity(BaseModel):
|
||||
id: str
|
||||
type: EntityType
|
||||
total_memories: int
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
|
||||
def _iter_payloads() -> list[dict[str, Any]]:
|
||||
results = get_memory_instance().vector_store.list(top_k=SCAN_LIMIT)
|
||||
rows = results[0] if results and isinstance(results, list) and isinstance(results[0], list) else results or []
|
||||
return [getattr(row, "payload", None) or {} for row in rows]
|
||||
|
||||
|
||||
def _parse_timestamp(value: Any) -> Optional[datetime]:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
@router.get("", response_model=list[Entity])
|
||||
def list_entities(_auth=Depends(verify_auth)):
|
||||
buckets: dict[tuple[EntityType, str], dict[str, Any]] = defaultdict(
|
||||
lambda: {"total_memories": 0, "created_at": None, "updated_at": None}
|
||||
)
|
||||
|
||||
for payload in _iter_payloads():
|
||||
created = _parse_timestamp(payload.get("created_at"))
|
||||
updated = _parse_timestamp(payload.get("updated_at")) or created
|
||||
|
||||
for entity_type, field in TYPE_TO_FIELD.items():
|
||||
value = payload.get(field)
|
||||
if not value:
|
||||
continue
|
||||
bucket = buckets[(entity_type, str(value))]
|
||||
bucket["total_memories"] += 1
|
||||
if created and (bucket["created_at"] is None or created < bucket["created_at"]):
|
||||
bucket["created_at"] = created
|
||||
if updated and (bucket["updated_at"] is None or updated > bucket["updated_at"]):
|
||||
bucket["updated_at"] = updated
|
||||
|
||||
return [
|
||||
Entity(id=entity_id, type=entity_type, **data)
|
||||
for (entity_type, entity_id), data in sorted(buckets.items(), key=lambda item: (item[0][0], item[0][1]))
|
||||
]
|
||||
|
||||
|
||||
@router.delete("/{entity_type}/{entity_id}", response_model=MessageResponse)
|
||||
def delete_entity(entity_type: EntityType, entity_id: str, _auth=Depends(verify_auth)):
|
||||
try:
|
||||
get_memory_instance().delete_all(**{TYPE_TO_FIELD[entity_type]: entity_id})
|
||||
except Exception:
|
||||
raise upstream_error()
|
||||
return MessageResponse(message="Entity deleted")
|
||||
@@ -0,0 +1,47 @@
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from auth import require_auth
|
||||
from db import get_db
|
||||
from models import RequestLog, User
|
||||
|
||||
router = APIRouter(prefix="/requests", tags=["requests"])
|
||||
|
||||
|
||||
class RequestLogItem(BaseModel):
|
||||
id: uuid.UUID
|
||||
method: str
|
||||
path: str
|
||||
status_code: int
|
||||
latency_ms: float
|
||||
auth_type: str
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
API_KEY_AUTH_TYPES = ("api_key", "admin_api_key")
|
||||
|
||||
|
||||
@router.get("", response_model=list[RequestLogItem])
|
||||
def list_requests(
|
||||
user: User = Depends(require_auth),
|
||||
db: Session = Depends(get_db),
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
):
|
||||
logs = (
|
||||
db.execute(
|
||||
select(RequestLog)
|
||||
.where(RequestLog.auth_type.in_(API_KEY_AUTH_TYPES))
|
||||
.order_by(RequestLog.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
return logs
|
||||
Reference in New Issue
Block a user