Initial commit: site monorepo with API, web, and infra.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Admin module."""
|
||||
@@ -0,0 +1,171 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from app.core.dependencies import require_admin, require_superuser
|
||||
from app.modules.admin.schemas import (
|
||||
AdminSettingsPatchIn,
|
||||
AdminRevealSecretIn,
|
||||
AdminUiActivityIn,
|
||||
AdminUserCreateIn,
|
||||
AdminUserPasswordPatchIn,
|
||||
AdminUserPatchIn,
|
||||
)
|
||||
from app.modules.admin.service import (
|
||||
create_admin_user,
|
||||
delete_admin_user,
|
||||
get_admin_settings,
|
||||
get_admin_summary,
|
||||
get_install_secrets,
|
||||
get_diagnostics_report,
|
||||
get_server_log_tail,
|
||||
list_activity_feed,
|
||||
list_admin_users,
|
||||
patch_admin_settings,
|
||||
patch_user,
|
||||
record_ui_activity,
|
||||
reveal_secret,
|
||||
reset_user_password,
|
||||
)
|
||||
from app.modules.analytics.service import get_admin_dashboard_metrics
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/users")
|
||||
async def list_users_route(
|
||||
page: int = Query(default=1, ge=1),
|
||||
limit: int = Query(default=20, ge=1, le=100),
|
||||
_admin=Depends(require_admin),
|
||||
):
|
||||
return list_admin_users(page, limit)
|
||||
|
||||
|
||||
@router.patch("/users/{user_id}")
|
||||
async def patch_user_route(
|
||||
user_id: str,
|
||||
payload: AdminUserPatchIn,
|
||||
admin=Depends(require_admin),
|
||||
):
|
||||
if payload.is_superuser is not None and not admin.is_superuser:
|
||||
raise HTTPException(status_code=403, detail="SUPERUSER_ONLY")
|
||||
try:
|
||||
return patch_user(admin, user_id, payload.role, payload.status, payload.is_superuser)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
|
||||
|
||||
@router.post("/users")
|
||||
async def create_user_route(payload: AdminUserCreateIn, admin=Depends(require_superuser)):
|
||||
try:
|
||||
return create_admin_user(
|
||||
admin,
|
||||
email=payload.email,
|
||||
password=payload.password,
|
||||
role=payload.role,
|
||||
is_superuser=payload.is_superuser,
|
||||
status=payload.status,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
|
||||
|
||||
@router.patch("/users/{user_id}/password")
|
||||
async def reset_user_password_route(
|
||||
user_id: str, payload: AdminUserPasswordPatchIn, admin=Depends(require_superuser)
|
||||
):
|
||||
try:
|
||||
return reset_user_password(admin, user_id, payload.password)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
|
||||
|
||||
@router.delete("/users/{user_id}")
|
||||
async def delete_user_route(user_id: str, admin=Depends(require_superuser)):
|
||||
try:
|
||||
return delete_admin_user(admin, user_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
|
||||
|
||||
@router.get("/summary")
|
||||
async def summary_route(_admin=Depends(require_admin)):
|
||||
return get_admin_summary()
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def stats_route(_admin=Depends(require_admin)):
|
||||
return get_admin_dashboard_metrics()
|
||||
|
||||
|
||||
@router.get("/settings")
|
||||
async def admin_settings_route(_admin=Depends(require_superuser)):
|
||||
return get_admin_settings()
|
||||
|
||||
|
||||
@router.patch("/settings")
|
||||
async def admin_settings_patch_route(payload: AdminSettingsPatchIn, admin=Depends(require_superuser)):
|
||||
return patch_admin_settings(admin, payload.values)
|
||||
|
||||
|
||||
@router.get("/diagnostics/report")
|
||||
async def diagnostics_route(_admin=Depends(require_superuser)):
|
||||
return get_diagnostics_report()
|
||||
|
||||
|
||||
@router.get("/activity-feed")
|
||||
async def activity_feed_route(limit: int = Query(default=200, ge=1, le=1000), _admin=Depends(require_admin)):
|
||||
return list_activity_feed(limit=limit)
|
||||
|
||||
|
||||
@router.post("/ui-activity")
|
||||
async def ui_activity_route(payload: AdminUiActivityIn, admin=Depends(require_admin)):
|
||||
return record_ui_activity(admin, payload.event, payload.meta)
|
||||
|
||||
|
||||
@router.get("/server-log")
|
||||
async def server_log_route(
|
||||
lines: int = Query(default=200, ge=1, le=1000),
|
||||
_admin=Depends(require_superuser),
|
||||
):
|
||||
return get_server_log_tail(lines=lines)
|
||||
|
||||
|
||||
@router.get("/secrets")
|
||||
async def install_secrets_route(_admin=Depends(require_superuser)):
|
||||
return get_install_secrets()
|
||||
|
||||
|
||||
@router.post("/secrets/reveal")
|
||||
async def reveal_secret_route(payload: AdminRevealSecretIn, admin=Depends(require_superuser)):
|
||||
try:
|
||||
return reveal_secret(admin, payload.key)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
|
||||
|
||||
@router.get("/enterprises")
|
||||
async def admin_enterprises(_admin=Depends(require_superuser)):
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.database import session_scope
|
||||
from app.modules.sync.models import Enterprise
|
||||
|
||||
with session_scope() as db:
|
||||
rows = list(db.scalars(select(Enterprise).order_by(Enterprise.name)))
|
||||
return [{"id": r.id, "name": r.name, "slug": r.slug, "status": r.status} for r in rows]
|
||||
|
||||
|
||||
@router.get("/sync-metrics")
|
||||
async def admin_sync_metrics(_admin=Depends(require_superuser)):
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.database import session_scope
|
||||
from app.modules.sync.models import Enterprise
|
||||
from app.modules.sync.service import get_sync_metrics
|
||||
|
||||
with session_scope() as db:
|
||||
enterprises = list(db.scalars(select(Enterprise).order_by(Enterprise.name)))
|
||||
rows = [(e.id, e.name, e.slug) for e in enterprises]
|
||||
return [
|
||||
{"enterprise_id": enterprise_id, "name": name, "slug": slug, **get_sync_metrics(enterprise_id)}
|
||||
for enterprise_id, name, slug in rows
|
||||
]
|
||||
@@ -0,0 +1,46 @@
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field, field_validator
|
||||
|
||||
from app.core.password_policy import validate_password_strength
|
||||
|
||||
|
||||
class AdminUserPatchIn(BaseModel):
|
||||
role: Literal["user", "admin"] | None = None
|
||||
status: Literal["pending", "active", "blocked"] | None = None
|
||||
is_superuser: bool | None = None
|
||||
|
||||
|
||||
class AdminUserCreateIn(BaseModel):
|
||||
email: EmailStr
|
||||
password: str = Field(min_length=8)
|
||||
role: Literal["user", "admin"] = "user"
|
||||
is_superuser: bool = False
|
||||
status: Literal["pending", "active", "blocked"] = "active"
|
||||
|
||||
@field_validator("password")
|
||||
@classmethod
|
||||
def password_policy(cls, value: str) -> str:
|
||||
return validate_password_strength(value)
|
||||
|
||||
|
||||
class AdminUserPasswordPatchIn(BaseModel):
|
||||
password: str = Field(min_length=8)
|
||||
|
||||
@field_validator("password")
|
||||
@classmethod
|
||||
def password_policy(cls, value: str) -> str:
|
||||
return validate_password_strength(value)
|
||||
|
||||
|
||||
class AdminSettingsPatchIn(BaseModel):
|
||||
values: dict
|
||||
|
||||
|
||||
class AdminUiActivityIn(BaseModel):
|
||||
event: str
|
||||
meta: dict | None = None
|
||||
|
||||
|
||||
class AdminRevealSecretIn(BaseModel):
|
||||
key: Literal["database_password", "jwt_access_secret", "jwt_refresh_pepper", "s3_secret_key"]
|
||||
@@ -0,0 +1,94 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.install_secrets import read_install_secrets
|
||||
from app.core.password_denylist import load_denylist
|
||||
from app.core.redis import get_redis_client
|
||||
from app.core.database import session_scope
|
||||
from app.modules.auth.models import RefreshToken
|
||||
from app.modules.users import repository
|
||||
from sqlalchemy import func, select
|
||||
|
||||
|
||||
def build_security_diagnostics_report() -> dict:
|
||||
redis_client = get_redis_client()
|
||||
db = urlparse(settings.database_url)
|
||||
sslmode = parse_qs(db.query).get("sslmode", [""])[0]
|
||||
install_secrets = read_install_secrets()
|
||||
with session_scope() as session:
|
||||
refresh_count = session.scalar(select(func.count()).select_from(RefreshToken)) or 0
|
||||
checks = [
|
||||
{
|
||||
"id": "jwt_access_secret",
|
||||
"status": "ok" if len(settings.jwt_access_secret) >= 32 else "fail",
|
||||
"message": "JWT access secret configured",
|
||||
},
|
||||
{
|
||||
"id": "jwt_refresh_pepper",
|
||||
"status": "ok" if len(settings.jwt_refresh_pepper) >= 32 else "fail",
|
||||
"message": "JWT refresh pepper configured",
|
||||
},
|
||||
{
|
||||
"id": "cookie_secure",
|
||||
"status": "ok" if settings.cookie_secure else "warn",
|
||||
"message": "Refresh cookie Secure flag is enabled",
|
||||
},
|
||||
{
|
||||
"id": "rate_limit_backend",
|
||||
"status": "ok" if redis_client is not None else "warn",
|
||||
"message": "Rate limiter uses Redis backend",
|
||||
},
|
||||
{
|
||||
"id": "password_denylist",
|
||||
"status": "ok" if len(load_denylist()) >= 1000 else "warn",
|
||||
"message": "Password denylist has strong coverage",
|
||||
},
|
||||
{
|
||||
"id": "cors_wildcard",
|
||||
"status": "ok" if "*" not in settings.cors_origins else "fail",
|
||||
"message": "CORS does not include wildcard origin",
|
||||
},
|
||||
{
|
||||
"id": "superuser_exists",
|
||||
"status": "ok" if repository.count_superusers() > 0 else "fail",
|
||||
"message": "At least one superuser exists",
|
||||
},
|
||||
{
|
||||
"id": "docs_production",
|
||||
"status": "warn" if settings.enable_docs else "ok",
|
||||
"message": "API docs are disabled in production",
|
||||
},
|
||||
{
|
||||
"id": "db_default_credentials",
|
||||
"status": "fail" if db.username == "user" and db.password == "pass" else "ok",
|
||||
"message": "Database does not use default credentials",
|
||||
},
|
||||
{
|
||||
"id": "db_localhost_exposed",
|
||||
"status": "fail" if settings.app_env.lower() == "production" and db.hostname in {"localhost", "127.0.0.1"} else "ok",
|
||||
"message": "Production database host is not localhost",
|
||||
},
|
||||
{
|
||||
"id": "db_ssl_mode",
|
||||
"status": "ok" if settings.app_env.lower() != "production" or sslmode == "require" else "warn",
|
||||
"message": "Production database URL uses sslmode=require",
|
||||
},
|
||||
{
|
||||
"id": "db_refresh_token_table_size",
|
||||
"status": "warn" if refresh_count > 10000 else "ok",
|
||||
"message": "Refresh token table size is under threshold",
|
||||
},
|
||||
{
|
||||
"id": "install_secrets_initialized",
|
||||
"status": "ok" if bool(install_secrets) else "fail",
|
||||
"message": "Install secrets file is initialized",
|
||||
},
|
||||
{
|
||||
"id": "install_secrets_locked",
|
||||
"status": "ok" if install_secrets.get("SECRETS_LOCKED") == "true" else "warn",
|
||||
"message": "Install secrets are locked after bootstrap",
|
||||
},
|
||||
]
|
||||
return {"checks": checks}
|
||||
@@ -0,0 +1,209 @@
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.app_settings import apply_settings_to_app, get_settings_payload, write_settings
|
||||
from app.core.audit_log import read_audit_events, write_audit_event
|
||||
from app.core.config import settings
|
||||
from app.core.install_secrets import install_secrets_payload, reveal_install_secret
|
||||
from app.core.jwt_denylist import bump_auth_epoch
|
||||
from app.core.security import hash_password
|
||||
from app.modules.admin.security_diagnostics import build_security_diagnostics_report
|
||||
from app.modules.auth.service import revoke_user_refresh_family
|
||||
from app.modules.users import repository
|
||||
|
||||
|
||||
def list_admin_users(page: int, limit: int) -> dict:
|
||||
users, total = repository.list_users(page, limit)
|
||||
return {
|
||||
"data": [
|
||||
{
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"role": user.role,
|
||||
"is_superuser": user.is_superuser,
|
||||
"status": user.status,
|
||||
}
|
||||
for user in users
|
||||
],
|
||||
"meta": {"total": total, "page": page, "limit": limit},
|
||||
}
|
||||
|
||||
|
||||
def _ensure_last_superuser_protection(target, role: str | None, is_superuser: bool | None) -> None:
|
||||
super_count = repository.count_superusers()
|
||||
role_becomes_admin = target.role if role is None else role
|
||||
super_becomes_true = target.is_superuser if is_superuser is None else is_superuser
|
||||
is_losing_super = target.role == "admin" and target.is_superuser and (
|
||||
role_becomes_admin != "admin" or not super_becomes_true
|
||||
)
|
||||
if is_losing_super and super_count <= 1:
|
||||
raise ValueError("LAST_SUPERUSER_PROTECTED")
|
||||
|
||||
|
||||
def patch_user(
|
||||
admin_user, target_user_id: str, role: str | None, status: str | None, is_superuser: bool | None = None
|
||||
) -> dict:
|
||||
target = repository.get_user_by_id(target_user_id)
|
||||
if not target:
|
||||
raise ValueError("USER_NOT_FOUND")
|
||||
if admin_user.id == target_user_id and role and role != "admin":
|
||||
raise ValueError("SELF_DEMOTION_FORBIDDEN")
|
||||
if admin_user.id == target_user_id and is_superuser is False:
|
||||
raise ValueError("SELF_DEMOTION_FORBIDDEN")
|
||||
if admin_user.id == target_user_id and status == "blocked":
|
||||
raise ValueError("SELF_BLOCK_FORBIDDEN")
|
||||
|
||||
admin_count = repository.count_admins()
|
||||
if target.role == "admin" and role and role != "admin" and admin_count <= 1:
|
||||
raise ValueError("LAST_ADMIN_PROTECTED")
|
||||
_ensure_last_superuser_protection(target, role, is_superuser)
|
||||
|
||||
if role:
|
||||
target.role = role
|
||||
if is_superuser is not None:
|
||||
target.is_superuser = bool(is_superuser)
|
||||
if status:
|
||||
target.status = status
|
||||
if status == "blocked":
|
||||
bump_auth_epoch(target.id)
|
||||
revoke_user_refresh_family(target.id)
|
||||
repository.update_user(target)
|
||||
write_audit_event(
|
||||
action="admin.user.patch",
|
||||
actor_user_id=admin_user.id,
|
||||
actor_email=admin_user.email,
|
||||
details={"target_user_id": target.id},
|
||||
)
|
||||
return {
|
||||
"id": target.id,
|
||||
"email": target.email,
|
||||
"role": target.role,
|
||||
"is_superuser": target.is_superuser,
|
||||
"status": target.status,
|
||||
}
|
||||
|
||||
|
||||
def create_admin_user(admin_user, email: str, password: str, role: str, is_superuser: bool, status: str) -> dict:
|
||||
if repository.get_user_by_email(email):
|
||||
raise ValueError("USER_EXISTS")
|
||||
user = repository.create_user(
|
||||
email=email,
|
||||
password_hash=hash_password(password),
|
||||
role=role,
|
||||
is_superuser=is_superuser,
|
||||
status=status,
|
||||
)
|
||||
write_audit_event(
|
||||
action="admin.user.create",
|
||||
actor_user_id=admin_user.id,
|
||||
actor_email=admin_user.email,
|
||||
details={"target_user_id": user.id},
|
||||
)
|
||||
return {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"role": user.role,
|
||||
"is_superuser": user.is_superuser,
|
||||
"status": user.status,
|
||||
}
|
||||
|
||||
|
||||
def reset_user_password(admin_user, target_user_id: str, password: str) -> dict:
|
||||
target = repository.get_user_by_id(target_user_id)
|
||||
if not target:
|
||||
raise ValueError("USER_NOT_FOUND")
|
||||
target.password_hash = hash_password(password)
|
||||
repository.update_user(target)
|
||||
bump_auth_epoch(target.id)
|
||||
revoke_user_refresh_family(target.id)
|
||||
write_audit_event(
|
||||
action="admin.user.reset_password",
|
||||
actor_user_id=admin_user.id,
|
||||
actor_email=admin_user.email,
|
||||
details={"target_user_id": target.id},
|
||||
)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
def delete_admin_user(admin_user, target_user_id: str) -> dict:
|
||||
target = repository.get_user_by_id(target_user_id)
|
||||
if not target:
|
||||
raise ValueError("USER_NOT_FOUND")
|
||||
if admin_user.id == target.id:
|
||||
raise ValueError("SELF_DELETE_FORBIDDEN")
|
||||
_ensure_last_superuser_protection(target, "user", False)
|
||||
if target.role == "admin" and repository.count_admins() <= 1:
|
||||
raise ValueError("LAST_ADMIN_PROTECTED")
|
||||
repository.delete_user(target.id)
|
||||
revoke_user_refresh_family(target.id)
|
||||
write_audit_event(
|
||||
action="admin.user.delete",
|
||||
actor_user_id=admin_user.id,
|
||||
actor_email=admin_user.email,
|
||||
details={"target_user_id": target.id},
|
||||
)
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
def get_admin_summary() -> dict:
|
||||
return {
|
||||
"users_count": repository.count_users(),
|
||||
"registrations_day": repository.count_users_registered_today(),
|
||||
"admins_count": repository.count_admins(),
|
||||
"superusers_count": repository.count_superusers(),
|
||||
}
|
||||
|
||||
|
||||
def get_admin_settings() -> dict:
|
||||
return get_settings_payload()
|
||||
|
||||
|
||||
def patch_admin_settings(admin_user, values: dict) -> dict:
|
||||
merged = write_settings(values)
|
||||
apply_settings_to_app(merged)
|
||||
write_audit_event(
|
||||
action="admin.settings.patch",
|
||||
actor_user_id=admin_user.id,
|
||||
actor_email=admin_user.email,
|
||||
details={"updated_keys": sorted(values.keys())},
|
||||
)
|
||||
return get_settings_payload()
|
||||
|
||||
|
||||
def get_diagnostics_report() -> dict:
|
||||
return build_security_diagnostics_report()
|
||||
|
||||
|
||||
def list_activity_feed(limit: int = 200) -> dict:
|
||||
return {"events": read_audit_events(limit=limit)}
|
||||
|
||||
|
||||
def record_ui_activity(admin_user, event: str, meta: dict | None) -> dict:
|
||||
write_audit_event(
|
||||
action=f"ui.{event}",
|
||||
actor_user_id=admin_user.id,
|
||||
actor_email=admin_user.email,
|
||||
details=meta or {},
|
||||
)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
def get_server_log_tail(lines: int = 200) -> dict:
|
||||
path = Path(settings.server_log_path)
|
||||
if not path.exists():
|
||||
return {"lines": []}
|
||||
return {"lines": path.read_text(encoding="utf-8", errors="ignore").splitlines()[-lines:]}
|
||||
|
||||
|
||||
def get_install_secrets() -> dict:
|
||||
return install_secrets_payload()
|
||||
|
||||
|
||||
def reveal_secret(admin_user, key: str) -> dict:
|
||||
value = reveal_install_secret(key)
|
||||
write_audit_event(
|
||||
action="admin.secrets.reveal",
|
||||
actor_user_id=admin_user.id,
|
||||
actor_email=admin_user.email,
|
||||
details={"key": key},
|
||||
)
|
||||
return {"key": key, "value": value}
|
||||
Reference in New Issue
Block a user