Update admin theme/layout and refresh README details.
Align the project baseline with the latest admin interface styling and layout structure while documenting setup and usage updates in README.
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Admin module."""
|
||||
@@ -0,0 +1,142 @@
|
||||
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))
|
||||
@@ -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,206 @@
|
||||
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.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":
|
||||
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)
|
||||
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