외부 계정 수동 등록 허용

This commit is contained in:
Yun Chan 2026-06-28 11:31:07 +09:00
parent bd389a97cc
commit 8ed185ce6c
9 changed files with 3450 additions and 1484 deletions

View file

@ -10,16 +10,21 @@ from typing import Annotated, Literal
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from ..auth_types import AccountStatus, RoleName
from ..auth_sessions import (
ManagedUserPatch,
active_session_count,
deactivate_managed_user,
get_managed_user,
has_admin_access,
is_super_admin_email,
list_managed_users,
update_managed_user,
upsert_managed_user,
)
from ..config import settings
from ..db import acquire, get_pool, healthcheck
from ..deps import Principal, Role, require_role
from ..deps import Principal, require_admin_access
from ..engine_client import engine_client
from ..runtime_policy import require_runtime_fallback_allowed
from ..services.voice import voice_service
@ -28,9 +33,19 @@ from ..store import store
router = APIRouter(prefix="/admin", tags=["admin"])
AdminPrincipal = Annotated[Principal, Depends(require_role(Role.ADMIN))]
AdminPrincipal = Annotated[Principal, Depends(require_admin_access())]
HealthStatus = Literal["ok", "degraded", "down"]
UsageBudgetStatus = Literal["disabled", "ok", "warn", "exceeded"]
TicketCategory = Literal[
"account_access",
"session_review",
"voice_browser",
"content_scenario",
"safety",
"other",
]
TicketPriority = Literal["low", "normal", "high", "urgent"]
TicketStatus = Literal["open", "triaged", "in_progress", "resolved", "closed"]
class AdminServiceHealth(BaseModel):
@ -79,6 +94,84 @@ class AdminUsageResponse(BaseModel):
by_provider: list[AdminUsageBreakdown]
class AdminHealthEvent(BaseModel):
event_id: int
observed_at: float
overall_status: HealthStatus
service_key: str
service_name: str
service_status: HealthStatus
detail: str
metric: str
load: float
class AdminUptimeServiceSummary(BaseModel):
service_key: str
service_name: str
samples: int
ok_samples: int
degraded_samples: int
down_samples: int
latest_status: HealthStatus
latest_observed_at: float | None = None
class AdminUptimeResponse(BaseModel):
source: Literal["database", "unavailable"]
durable: bool
window_hours: int
generated_at: float
sample_count: int
ok_ratio: float
degraded_events: int
down_events: int
last_down_at: float | None = None
services: list[AdminUptimeServiceSummary]
events: list[AdminHealthEvent]
class AdminTicketReporter(BaseModel):
user_id: str | None = None
email: str
display_name: str
role: str
class AdminSupportTicketResponse(BaseModel):
ticket_id: str
reporter: AdminTicketReporter
category: TicketCategory
priority: TicketPriority
status: TicketStatus
subject: str
body: str
source_path: str
assigned_group: str
resolution_note: str
created_at: float
updated_at: float
resolved_at: float | None = None
class AdminTicketSummary(BaseModel):
total: int
open_count: int
high_priority_count: int
stale_count: int
by_status: dict[str, int]
by_category: dict[str, int]
by_priority: dict[str, int]
class AdminTicketsResponse(BaseModel):
source: Literal["database", "unavailable"]
durable: bool
generated_at: float
tickets: list[AdminSupportTicketResponse]
summary: AdminTicketSummary
class AdminEngineConfigResponse(BaseModel):
engine_mode: str
engine_url: str
@ -95,7 +188,11 @@ class AdminEngineConfigPatch(BaseModel):
model: str | None = None
RoleName = Literal["learner", "teacher", "admin"]
class AdminTicketPatch(BaseModel):
status: TicketStatus | None = None
priority: TicketPriority | None = None
assigned_group: str | None = Field(default=None, max_length=120)
resolution_note: str | None = Field(default=None, max_length=2000)
class AdminUserResponse(BaseModel):
@ -103,6 +200,9 @@ class AdminUserResponse(BaseModel):
email: str
display_name: str
role: RoleName
admin_access: bool
super_admin: bool = False
account_status: AccountStatus
cohort_ids: list[str]
affiliation: str
active_sessions: int
@ -120,6 +220,8 @@ class AdminUsersResponse(BaseModel):
class AdminUserPatch(BaseModel):
display_name: str | None = Field(default=None, min_length=1, max_length=80)
role: RoleName | None = None
admin_access: bool | None = None
account_status: AccountStatus | None = None
affiliation: str | None = Field(default=None, max_length=120)
cohort_ids: list[str] | None = None
@ -289,7 +391,10 @@ async def _usage_from_database(window_days: int) -> AdminUsageResponse:
OR cost_usd IS NOT NULL
)
GROUP BY 1, 2
ORDER BY cost_usd DESC, tokens_in + tokens_out DESC, turns DESC
ORDER BY
cost_usd DESC,
COALESCE(SUM(tokens_in), 0) + COALESCE(SUM(tokens_out), 0) DESC,
turns DESC
LIMIT 12
""",
window_days,
@ -321,6 +426,180 @@ async def _usage_from_database(window_days: int) -> AdminUsageResponse:
)
async def _record_health_events(
*,
principal: Principal,
overall_status: HealthStatus,
environment: str,
engine_mode: str,
services: list[AdminServiceHealth],
) -> None:
if not services:
return
try:
async with acquire(role="admin", user_id=principal.user_id) as conn:
await conn.executemany(
"""
INSERT INTO app.admin_health_event (
overall_status,
environment,
engine_mode,
service_key,
service_name,
service_status,
detail,
metric,
load,
captured_by
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::uuid)
""",
[
(
overall_status,
environment,
engine_mode,
service.key,
service.name,
service.status,
service.detail,
service.metric,
service.load,
principal.user_id,
)
for service in services
],
)
except Exception:
# 헬스 화면 자체가 장애 확인 경로라, 이력 적재 실패가 응답을 막으면 안 된다.
return
async def _uptime_from_database(window_hours: int) -> AdminUptimeResponse:
async with acquire(role="admin") as conn:
rows = await conn.fetch(
"""
SELECT
id,
observed_at,
overall_status,
service_key,
service_name,
service_status,
detail,
metric,
load
FROM app.admin_health_event
WHERE observed_at >= now() - ($1::int * interval '1 hour')
ORDER BY observed_at DESC, id DESC
LIMIT 240
""",
window_hours,
)
events = [_health_event_from_row(row) for row in rows]
service_buckets: dict[str, AdminUptimeServiceSummary] = {}
for event in events:
current = service_buckets.get(event.service_key)
if current is None:
current = AdminUptimeServiceSummary(
service_key=event.service_key,
service_name=event.service_name,
samples=0,
ok_samples=0,
degraded_samples=0,
down_samples=0,
latest_status=event.service_status,
latest_observed_at=event.observed_at,
)
service_buckets[event.service_key] = current
current.samples += 1
if event.service_status == "ok":
current.ok_samples += 1
elif event.service_status == "degraded":
current.degraded_samples += 1
else:
current.down_samples += 1
ok_samples = sum(1 for event in events if event.service_status == "ok")
sample_count = len(events)
down_times = [event.observed_at for event in events if event.service_status == "down"]
return AdminUptimeResponse(
source="database",
durable=True,
window_hours=window_hours,
generated_at=time.time(),
sample_count=sample_count,
ok_ratio=round(ok_samples / sample_count, 4) if sample_count else 0.0,
degraded_events=sum(1 for event in events if event.service_status == "degraded"),
down_events=sum(1 for event in events if event.service_status == "down"),
last_down_at=max(down_times) if down_times else None,
services=sorted(
service_buckets.values(),
key=lambda item: (item.latest_status != "down", item.latest_status != "degraded", item.service_key),
),
events=events,
)
async def _tickets_from_database(
*,
ticket_status: TicketStatus | None,
window_days: int,
) -> AdminTicketsResponse:
async with acquire(role="admin") as conn:
rows = await conn.fetch(
"""
SELECT
id,
reporter_id,
reporter_email,
reporter_name,
reporter_role,
category,
priority,
status,
subject,
body,
source_path,
assigned_group,
resolution_note,
created_at,
updated_at,
resolved_at
FROM app.support_ticket
WHERE ($1::text IS NULL OR status = $1)
AND created_at >= now() - ($2::int * interval '1 day')
ORDER BY
CASE
WHEN status = 'open' THEN 0
WHEN status = 'triaged' THEN 1
WHEN status = 'in_progress' THEN 2
WHEN status = 'resolved' THEN 3
ELSE 4
END,
CASE
WHEN priority = 'urgent' THEN 0
WHEN priority = 'high' THEN 1
WHEN priority = 'normal' THEN 2
ELSE 3
END,
updated_at DESC
LIMIT 120
""",
ticket_status,
window_days,
)
tickets = [_ticket_from_row(row) for row in rows]
return AdminTicketsResponse(
source="database",
durable=True,
generated_at=time.time(),
tickets=tickets,
summary=_ticket_summary(tickets),
)
def _usage_from_runtime_store(window_days: int) -> AdminUsageResponse:
window_start = time.time() - (window_days * 86400)
total_turns = 0
@ -404,6 +683,8 @@ class AdminUserCreate(BaseModel):
email: str = Field(..., min_length=3, max_length=254)
display_name: str = Field(..., min_length=1, max_length=80)
role: RoleName = "learner"
admin_access: bool = False
account_status: AccountStatus = "approved"
affiliation: str | None = Field(default=None, max_length=120)
cohort_ids: list[str] = Field(default_factory=list)
@ -425,9 +706,6 @@ def _normalize_email(value: str) -> str:
local, domain = email.rsplit("@", 1)
if not local or not domain:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail="email is invalid")
allowed = {item.strip().lower().lstrip("@") for item in settings.auth_allowed_email_domains if item.strip()}
if domain not in allowed:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="email domain is not allowed")
return email
@ -469,6 +747,17 @@ def _updated_at_ts(value: datetime | None) -> float | None:
return value.timestamp()
def _row_ts(value: object) -> float | None:
if value is None:
return None
if isinstance(value, datetime):
return _updated_at_ts(value)
try:
return float(value)
except (TypeError, ValueError):
return None
def _engine_config_from_row(row) -> AdminEngineConfigResponse:
return AdminEngineConfigResponse(
engine_mode=_normalize_engine_mode(row["engine_mode"]),
@ -481,6 +770,108 @@ def _engine_config_from_row(row) -> AdminEngineConfigResponse:
)
def _health_event_from_row(row) -> AdminHealthEvent:
return AdminHealthEvent(
event_id=int(row["id"]),
observed_at=_row_ts(row["observed_at"]) or 0.0,
overall_status=row["overall_status"],
service_key=row["service_key"],
service_name=row["service_name"],
service_status=row["service_status"],
detail=row["detail"],
metric=row["metric"],
load=_clamp01(float(row["load"] or 0.0)),
)
def _ticket_from_row(row) -> AdminSupportTicketResponse:
return AdminSupportTicketResponse(
ticket_id=str(row["id"]),
reporter=AdminTicketReporter(
user_id=str(row["reporter_id"]) if row["reporter_id"] else None,
email=row["reporter_email"],
display_name=row["reporter_name"],
role=row["reporter_role"],
),
category=row["category"],
priority=row["priority"],
status=row["status"],
subject=row["subject"],
body=row["body"],
source_path=row["source_path"],
assigned_group=row["assigned_group"],
resolution_note=row["resolution_note"],
created_at=_row_ts(row["created_at"]) or 0.0,
updated_at=_row_ts(row["updated_at"]) or 0.0,
resolved_at=_row_ts(row["resolved_at"]),
)
def _empty_ticket_summary() -> AdminTicketSummary:
return AdminTicketSummary(
total=0,
open_count=0,
high_priority_count=0,
stale_count=0,
by_status={},
by_category={},
by_priority={},
)
def _ticket_summary(tickets: list[AdminSupportTicketResponse]) -> AdminTicketSummary:
now = time.time()
by_status: dict[str, int] = {}
by_category: dict[str, int] = {}
by_priority: dict[str, int] = {}
active_tickets = [ticket for ticket in tickets if ticket.status not in {"resolved", "closed"}]
for ticket in tickets:
by_status[ticket.status] = by_status.get(ticket.status, 0) + 1
by_category[ticket.category] = by_category.get(ticket.category, 0) + 1
by_priority[ticket.priority] = by_priority.get(ticket.priority, 0) + 1
return AdminTicketSummary(
total=len(tickets),
open_count=len(active_tickets),
high_priority_count=sum(
1 for ticket in active_tickets if ticket.priority in {"high", "urgent"}
),
stale_count=sum(
1
for ticket in active_tickets
if now - ticket.updated_at > 86400
),
by_status=by_status,
by_category=by_category,
by_priority=by_priority,
)
def _unavailable_uptime(window_hours: int) -> AdminUptimeResponse:
return AdminUptimeResponse(
source="unavailable",
durable=False,
window_hours=window_hours,
generated_at=time.time(),
sample_count=0,
ok_ratio=0.0,
degraded_events=0,
down_events=0,
last_down_at=None,
services=[],
events=[],
)
def _unavailable_tickets() -> AdminTicketsResponse:
return AdminTicketsResponse(
source="unavailable",
durable=False,
generated_at=time.time(),
tickets=[],
summary=_empty_ticket_summary(),
)
async def _current_engine_config() -> AdminEngineConfigResponse:
if _ENGINE_CONFIG is not None:
return _ENGINE_CONFIG
@ -496,9 +887,9 @@ async def _current_engine_config() -> AdminEngineConfigResponse:
)
if row is not None:
return _engine_config_from_row(row)
return _default_engine_config()
except Exception:
require_runtime_fallback_allowed("admin engine config")
require_runtime_fallback_allowed("admin engine config")
return _default_engine_config()
@ -527,12 +918,42 @@ def _engine_unavailable_detail(detail: str) -> str:
return detail
def _requires_super_admin_for_privilege_change(
*,
current_role: RoleName | None,
next_role: RoleName | None,
current_admin_access: bool,
next_admin_access: bool | None,
) -> bool:
if next_admin_access is not None and next_admin_access != current_admin_access:
return True
if next_role == "admin" and current_role != "admin":
return True
if current_role == "admin" and next_role is not None and next_role != "admin":
return True
return False
def _assert_super_admin(principal: Principal) -> None:
if not principal.super_admin:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="super admin required")
def _assert_super_admin_target_mutable(email: str, admin_access: bool | None) -> None:
if is_super_admin_email(email) and admin_access is False:
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="cannot revoke super admin access")
async def _admin_user_response(user, *, durable: bool) -> AdminUserResponse:
effective_admin_access = has_admin_access(user.email, user.role, user.admin_access)
return AdminUserResponse(
user_id=user.user_id,
email=user.email,
display_name=user.display_name,
role=user.role,
admin_access=effective_admin_access,
super_admin=is_super_admin_email(user.email),
account_status=user.account_status,
cohort_ids=user.cohort_ids,
affiliation=user.affiliation,
active_sessions=await active_session_count(user.user_id),
@ -624,12 +1045,20 @@ async def admin_health(principal: AdminPrincipal) -> AdminHealthResponse:
),
]
return AdminHealthResponse(
response = AdminHealthResponse(
status=_overall_status(services),
environment=settings.environment,
engine_mode=current_engine.engine_mode,
services=services,
)
await _record_health_events(
principal=principal,
overall_status=response.status,
environment=response.environment,
engine_mode=response.engine_mode,
services=response.services,
)
return response
@router.get("/usage", response_model=AdminUsageResponse)
@ -645,6 +1074,91 @@ async def admin_usage(
return _usage_from_runtime_store(window_days)
@router.get("/uptime", response_model=AdminUptimeResponse)
async def admin_uptime(
principal: AdminPrincipal,
window_hours: Annotated[int, Query(ge=1, le=720)] = 24,
) -> AdminUptimeResponse:
"""Return persisted health check samples captured by the admin console."""
try:
return await _uptime_from_database(window_hours)
except Exception:
return _unavailable_uptime(window_hours)
@router.get("/tickets", response_model=AdminTicketsResponse)
async def list_tickets(
principal: AdminPrincipal,
ticket_status: Annotated[TicketStatus | None, Query(alias="status")] = None,
window_days: Annotated[int, Query(ge=1, le=365)] = 30,
) -> AdminTicketsResponse:
"""Return user-submitted operational tickets without synthetic fallback rows."""
try:
return await _tickets_from_database(ticket_status=ticket_status, window_days=window_days)
except Exception:
return _unavailable_tickets()
@router.patch("/tickets/{ticket_id}", response_model=AdminSupportTicketResponse)
async def patch_ticket(
ticket_id: str,
body: AdminTicketPatch,
principal: AdminPrincipal,
) -> AdminSupportTicketResponse:
"""Update ticket triage state for administrators."""
try:
async with acquire(role="admin", user_id=principal.user_id) as conn:
row = await conn.fetchrow(
"""
UPDATE app.support_ticket SET
status = COALESCE($2, status),
priority = COALESCE($3, priority),
assigned_group = COALESCE($4, assigned_group),
resolution_note = COALESCE($5, resolution_note),
resolved_at = CASE
WHEN COALESCE($2, status) IN ('resolved', 'closed')
THEN COALESCE(resolved_at, now())
WHEN $2 IS NOT NULL
THEN NULL
ELSE resolved_at
END,
updated_at = now(),
last_activity_at = now()
WHERE id = $1::uuid
RETURNING
id,
reporter_id,
reporter_email,
reporter_name,
reporter_role,
category,
priority,
status,
subject,
body,
source_path,
assigned_group,
resolution_note,
created_at,
updated_at,
resolved_at
""",
ticket_id,
body.status,
body.priority,
body.assigned_group.strip() if body.assigned_group is not None else None,
body.resolution_note.strip() if body.resolution_note is not None else None,
)
except Exception as exc:
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
detail="ticket persistence unavailable",
) from exc
if row is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="ticket not found")
return _ticket_from_row(row)
@router.get("/engine-config", response_model=AdminEngineConfigResponse)
async def get_engine_config(principal: AdminPrincipal) -> AdminEngineConfigResponse:
"""Return the current admin-managed engine settings."""
@ -728,10 +1242,14 @@ async def create_user(
principal: AdminPrincipal,
) -> AdminUserResponse:
"""Create or reactivate a managed user without requiring that user to log in first."""
if body.role == "admin" or body.admin_access:
_assert_super_admin(principal)
user = await upsert_managed_user(
email=_normalize_email(body.email),
display_name=body.display_name,
role=body.role,
admin_access=body.admin_access,
account_status=body.account_status,
affiliation=body.affiliation,
cohort_ids=body.cohort_ids,
reactivate=True,
@ -749,12 +1267,27 @@ async def patch_user(
principal: AdminPrincipal,
) -> AdminUserResponse:
"""Update a server-known user's role/profile for the current API process."""
current = await get_managed_user(user_id)
if current is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="user not found")
if _requires_super_admin_for_privilege_change(
current_role=current.role,
next_role=body.role,
current_admin_access=current.admin_access,
next_admin_access=body.admin_access,
):
_assert_super_admin(principal)
_assert_super_admin_target_mutable(current.email, body.admin_access)
next_user = await update_managed_user(
user_id,
display_name=body.display_name,
role=body.role,
affiliation=body.affiliation,
cohort_ids=body.cohort_ids,
ManagedUserPatch(
display_name=body.display_name,
role=body.role,
admin_access=body.admin_access,
account_status=body.account_status,
affiliation=body.affiliation,
cohort_ids=body.cohort_ids,
),
)
if next_user is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="user not found")
@ -772,6 +1305,11 @@ async def delete_user(
"""Deactivate a managed user and revoke any active browser sessions."""
if user_id == principal.user_id:
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="cannot deactivate current admin")
current = await get_managed_user(user_id)
if current is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="user not found")
if is_super_admin_email(current.email):
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="cannot deactivate super admin")
if not await deactivate_managed_user(user_id):
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="user not found")
return AdminUserDeleteResponse(ok=True, user_id=user_id)