Self-hosted dashboard and admin auth (#4837)

Co-authored-by: kartik-mem0 <kartik.labhshetwar@mem0.ai>
This commit is contained in:
Gabriel Stein
2026-04-23 19:06:36 +05:30
committed by GitHub
co-authored by kartik-mem0
parent 15feaa8ac4
commit db8ac61713
252 changed files with 20275 additions and 331 deletions
View File
+88
View File
@@ -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.")
+212
View File
@@ -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.")
+77
View File
@@ -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")
+47
View File
@@ -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