외부 계정 수동 등록 허용
This commit is contained in:
parent
bd389a97cc
commit
8ed185ce6c
9 changed files with 3450 additions and 1484 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -25,8 +25,14 @@ from fastapi import APIRouter, Cookie, HTTPException, Query, Request, Response,
|
|||
from fastapi.responses import RedirectResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..auth_types import AccountStatus, RoleName
|
||||
from ..auth_sessions import (
|
||||
ManagedUser,
|
||||
get_managed_user,
|
||||
get_managed_user_by_email,
|
||||
has_admin_access,
|
||||
InactiveUserError,
|
||||
is_super_admin_email,
|
||||
SessionUser,
|
||||
create_session,
|
||||
record_user_consent,
|
||||
|
|
@ -76,9 +82,17 @@ class MeResponse(BaseModel):
|
|||
user_id: str
|
||||
email: str
|
||||
display_name: str
|
||||
role: str
|
||||
role: RoleName
|
||||
admin_access: bool = False
|
||||
super_admin: bool = False
|
||||
account_status: AccountStatus = "approved"
|
||||
approval_required: bool = False
|
||||
cohort_ids: list[str]
|
||||
consent_at: float | None = None
|
||||
onboarding_completed_at: float | None = None
|
||||
nickname: str = ""
|
||||
self_introduction: str = ""
|
||||
avatar_url: str = ""
|
||||
|
||||
|
||||
class ConsentRequest(BaseModel):
|
||||
|
|
@ -107,7 +121,7 @@ class AuthConfigResponse(BaseModel):
|
|||
|
||||
class DevLoginRequest(BaseModel):
|
||||
email: str
|
||||
role: Literal["learner", "teacher", "admin"] = "learner"
|
||||
role: RoleName = "learner"
|
||||
display_name: str | None = None
|
||||
|
||||
|
||||
|
|
@ -205,8 +219,44 @@ def validate_google_identity_domain(
|
|||
return normalized_email
|
||||
|
||||
|
||||
async def validate_login_identity_email(
|
||||
*,
|
||||
email: str | None,
|
||||
email_verified: bool,
|
||||
hosted_domain: str | None = None,
|
||||
) -> tuple[str, ManagedUser | None]:
|
||||
"""Validate provider email, allowing exact admin-created managed accounts."""
|
||||
async def managed_user_for_email(normalized_email: str) -> ManagedUser | None:
|
||||
try:
|
||||
return await get_managed_user_by_email(normalized_email)
|
||||
except HTTPException:
|
||||
return None
|
||||
|
||||
try:
|
||||
normalized_email = validate_google_identity_domain(
|
||||
email=email,
|
||||
email_verified=email_verified,
|
||||
hosted_domain=hosted_domain,
|
||||
)
|
||||
except HTTPException as exc:
|
||||
normalized_email = _normalize_email(email)
|
||||
if (
|
||||
exc.status_code == status.HTTP_403_FORBIDDEN
|
||||
and normalized_email
|
||||
and _email_domain(normalized_email)
|
||||
and email_verified
|
||||
):
|
||||
managed_user = await managed_user_for_email(normalized_email)
|
||||
if managed_user is not None:
|
||||
return normalized_email, managed_user
|
||||
raise
|
||||
return normalized_email, await managed_user_for_email(normalized_email)
|
||||
|
||||
|
||||
def _role_for_email(email: str) -> Role:
|
||||
normalized = _normalize_email(email)
|
||||
if normalized in _normalize_email_set(settings.auth_super_admin_emails):
|
||||
return Role.ADMIN
|
||||
if normalized in _normalize_email_set(settings.auth_admin_emails):
|
||||
return Role.ADMIN
|
||||
if normalized in _normalize_email_set(settings.auth_teacher_emails):
|
||||
|
|
@ -214,6 +264,22 @@ def _role_for_email(email: str) -> Role:
|
|||
return Role.LEARNER
|
||||
|
||||
|
||||
def _role_for_managed_user(managed_user: ManagedUser | None, fallback: Role) -> Role:
|
||||
if managed_user is None:
|
||||
return fallback
|
||||
if managed_user.role == "admin":
|
||||
return Role.ADMIN
|
||||
if managed_user.role == "teacher":
|
||||
return Role.TEACHER
|
||||
return Role.LEARNER
|
||||
|
||||
|
||||
def _cohort_ids_for_managed_user(managed_user: ManagedUser | None, fallback: list[str]) -> list[str]:
|
||||
if managed_user is not None and managed_user.cohort_ids:
|
||||
return list(managed_user.cohort_ids)
|
||||
return fallback
|
||||
|
||||
|
||||
def _role_for_saml_identity(identity: SamlIdentity) -> Role:
|
||||
hinted = (identity.role_hint or "").strip().lower()
|
||||
if hinted in {"admin", "administrator"}:
|
||||
|
|
@ -573,14 +639,48 @@ def _delete_session_cookie(response: Response) -> None:
|
|||
)
|
||||
|
||||
|
||||
def _me_response(user: SessionUser | Principal) -> MeResponse:
|
||||
async def _me_response(user: SessionUser | Principal) -> MeResponse:
|
||||
managed = await get_managed_user(user.user_id)
|
||||
onboarding_completed_at = getattr(user, "profile_completed_at", None)
|
||||
if managed:
|
||||
onboarding_completed_at = (
|
||||
managed.profile_completed_at
|
||||
if (
|
||||
managed.profile_completed_at is not None
|
||||
and managed.terms_agreed_at is not None
|
||||
and managed.privacy_agreed_at is not None
|
||||
and managed.nickname.strip()
|
||||
and managed.self_introduction.strip()
|
||||
)
|
||||
else None
|
||||
)
|
||||
account_status = (
|
||||
managed.account_status
|
||||
if managed
|
||||
else getattr(user, "account_status", "approved")
|
||||
)
|
||||
email = getattr(user, "email", "")
|
||||
role = user.role.value if isinstance(user.role, Role) else user.role
|
||||
stored_admin_access = managed.admin_access if managed else getattr(user, "admin_access", False)
|
||||
return MeResponse(
|
||||
user_id=user.user_id,
|
||||
email=getattr(user, "email", ""),
|
||||
display_name=getattr(user, "display_name", "") or getattr(user, "email", ""),
|
||||
role=user.role.value if isinstance(user.role, Role) else user.role,
|
||||
email=email,
|
||||
display_name=(
|
||||
(managed.display_name if managed else "")
|
||||
or getattr(user, "display_name", "")
|
||||
or email
|
||||
),
|
||||
role=role,
|
||||
admin_access=has_admin_access(email, role, stored_admin_access),
|
||||
super_admin=is_super_admin_email(email),
|
||||
account_status=account_status,
|
||||
approval_required=account_status != "approved",
|
||||
cohort_ids=user.cohort_ids,
|
||||
consent_at=getattr(user, "consent_at", None),
|
||||
consent_at=(managed.consent_at if managed else getattr(user, "consent_at", None)),
|
||||
onboarding_completed_at=onboarding_completed_at,
|
||||
nickname=(managed.nickname if managed else ""),
|
||||
self_introduction=(managed.self_introduction if managed else ""),
|
||||
avatar_url=(managed.avatar_url if managed else ""),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -812,7 +912,7 @@ async def callback(
|
|||
return _oauth_callback_error("issuer_mismatch", request)
|
||||
|
||||
try:
|
||||
email = validate_google_identity_domain(
|
||||
email, managed_user = await validate_login_identity_email(
|
||||
email=claims.get("email"),
|
||||
email_verified=claims.get("email_verified") in {True, "true", "True", "1", 1},
|
||||
hosted_domain=claims.get("hd"),
|
||||
|
|
@ -825,11 +925,14 @@ async def callback(
|
|||
hosted_domain=_normalize_domain(str(claims.get("hd") or "")),
|
||||
)
|
||||
return _oauth_callback_error("domain_not_allowed", request)
|
||||
role = _role_for_email(email)
|
||||
role = _role_for_managed_user(managed_user, _role_for_email(email))
|
||||
display_name = str(claims.get("name") or email)
|
||||
cohort_ids = _configured_cohort_ids(
|
||||
email=email,
|
||||
hosted_domain=str(claims.get("hd") or ""),
|
||||
cohort_ids = _cohort_ids_for_managed_user(
|
||||
managed_user,
|
||||
_configured_cohort_ids(
|
||||
email=email,
|
||||
hosted_domain=str(claims.get("hd") or ""),
|
||||
),
|
||||
)
|
||||
external_id = _provider_external_id("google", str(claims.get("sub") or ""), email)
|
||||
try:
|
||||
|
|
@ -882,7 +985,7 @@ async def saml_acs(request: Request) -> RedirectResponse:
|
|||
|
||||
try:
|
||||
identity = parse_fixture_response(encoded_response)
|
||||
email = validate_google_identity_domain(
|
||||
email, managed_user = await validate_login_identity_email(
|
||||
email=identity.email,
|
||||
email_verified=True,
|
||||
hosted_domain=_email_domain(identity.email),
|
||||
|
|
@ -890,8 +993,11 @@ async def saml_acs(request: Request) -> RedirectResponse:
|
|||
except (HTTPException, ValueError):
|
||||
return _frontend_login_redirect("saml_assertion_invalid", request)
|
||||
|
||||
role = _role_for_saml_identity(identity)
|
||||
cohort_ids = _configured_cohort_ids(email=email, claim_hint=identity.cohort_hint)
|
||||
role = _role_for_managed_user(managed_user, _role_for_saml_identity(identity))
|
||||
cohort_ids = _cohort_ids_for_managed_user(
|
||||
managed_user,
|
||||
_configured_cohort_ids(email=email, claim_hint=identity.cohort_hint),
|
||||
)
|
||||
external_id = _provider_external_id("saml", identity.subject, email)
|
||||
try:
|
||||
sid, _ = await create_session(
|
||||
|
|
@ -919,7 +1025,7 @@ async def dev_login(request: Request, body: DevLoginRequest, response: Response)
|
|||
if not _dev_login_available(request):
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="dev login is disabled")
|
||||
|
||||
email = validate_google_identity_domain(
|
||||
email, managed_user = await validate_login_identity_email(
|
||||
email=str(body.email),
|
||||
email_verified=True,
|
||||
hosted_domain=_email_domain(str(body.email)),
|
||||
|
|
@ -928,14 +1034,17 @@ async def dev_login(request: Request, body: DevLoginRequest, response: Response)
|
|||
sid, user = await create_session(
|
||||
email=email,
|
||||
display_name=body.display_name or email,
|
||||
role=body.role,
|
||||
cohort_ids=_configured_cohort_ids(email=email),
|
||||
role=_role_for_managed_user(managed_user, Role(body.role)).value,
|
||||
cohort_ids=_cohort_ids_for_managed_user(
|
||||
managed_user,
|
||||
_configured_cohort_ids(email=email),
|
||||
),
|
||||
external_id=_provider_external_id("dev", email, email),
|
||||
)
|
||||
except InactiveUserError as exc:
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="user is inactive") from exc
|
||||
_set_session_cookie(response, sid)
|
||||
return _me_response(user)
|
||||
return await _me_response(user)
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
|
|
@ -956,6 +1065,8 @@ async def accept_consent(
|
|||
principal: CurrentPrincipal,
|
||||
) -> ConsentResponse:
|
||||
"""Record the current learner's practice-session consent receipt."""
|
||||
if principal.role != Role.LEARNER and principal.super_admin:
|
||||
principal = principal.with_role(Role.LEARNER)
|
||||
if principal.role != Role.LEARNER:
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="learner consent only")
|
||||
if not body.accepted:
|
||||
|
|
@ -970,6 +1081,8 @@ async def accept_consent(
|
|||
@router.delete("/consent", response_model=ConsentResponse)
|
||||
async def withdraw_consent(principal: CurrentPrincipal) -> ConsentResponse:
|
||||
"""Withdraw practice-session consent until the learner accepts again."""
|
||||
if principal.role != Role.LEARNER and principal.super_admin:
|
||||
principal = principal.with_role(Role.LEARNER)
|
||||
if principal.role != Role.LEARNER:
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="learner consent only")
|
||||
changed = await withdraw_user_consent(principal.user_id)
|
||||
|
|
@ -982,4 +1095,4 @@ async def withdraw_consent(principal: CurrentPrincipal) -> ConsentResponse:
|
|||
@router.get("/me", response_model=MeResponse)
|
||||
async def me(principal: CurrentPrincipal) -> MeResponse:
|
||||
"""Return the current authenticated user. Unauthenticated requests are 401."""
|
||||
return _me_response(principal)
|
||||
return await _me_response(principal)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue