Add secure administrator operations console

Provide TOTP-authenticated, role-controlled user and credit workflows with paginated audit data and SQL-backed statistics so operations can manage growth safely.
This commit is contained in:
Rocky
2026-08-17 15:20:34 +08:00
parent 676bfd2451
commit 1a9c518f96
76 changed files with 14602 additions and 3 deletions
+11
View File
@@ -28,6 +28,17 @@ FIELD_ENCRYPTION_KEY=replace-with-exactly-32-random-bytes-as-base64
IDENTITY_HMAC_KEY=replace-with-a-distinct-32-random-bytes-as-base64 IDENTITY_HMAC_KEY=replace-with-a-distinct-32-random-bytes-as-base64
IDENTITY_TOMBSTONE_RETENTION_DAYS=365 IDENTITY_TOMBSTONE_RETENTION_DAYS=365
# Admin console. Enable bootstrap for the first successful startup only, then
# set it back to false and remove all four ADMIN_BOOTSTRAP_* credential values.
ADMIN_ENABLED=false
ADMIN_BOOTSTRAP_ENABLED=false
ADMIN_BOOTSTRAP_OPERATOR_ID=replace-with-random-uuid
ADMIN_BOOTSTRAP_USERNAME=owner
ADMIN_BOOTSTRAP_PASSWORD_HASH=replace-with-argon2id-phc-hash
ADMIN_BOOTSTRAP_TOTP_SECRET_BASE32=replace-with-random-base32-secret
ADMIN_SESSION_HOURS=8
ADMIN_MAXIMUM_MANUAL_GRANT=100000
# Apple identifiers are not secrets, but use the values from your own developer account. # Apple identifiers are not secrets, but use the values from your own developer account.
APPLE_TEAM_ID=replace-with-apple-team-id APPLE_TEAM_ID=replace-with-apple-team-id
APPLE_KEY_ID=replace-with-apple-key-id APPLE_KEY_ID=replace-with-apple-key-id
+5
View File
@@ -16,6 +16,11 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: admin-web/package-lock.json
- uses: actions/setup-java@v4 - uses: actions/setup-java@v4
with: with:
distribution: temurin distribution: temurin
+3
View File
@@ -0,0 +1,3 @@
node_modules/
dist/
.DS_Store
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<meta name="referrer" content="same-origin" />
<title>OSG 运营后台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+2238
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
{
"name": "osg-admin-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"test": "vitest run"
},
"devDependencies": {
"@types/node": "^26.2.0",
"jsdom": "^27.0.1",
"typescript": "^5.9.2",
"vite": "^7.1.2",
"vitest": "^3.2.4"
}
}
+232
View File
@@ -0,0 +1,232 @@
import type {
AdminOperator,
AdminOperatorCreateRequest,
AdminOperatorProvisioning,
AdminSecuritySummary,
AdminLoginResponse,
AuditLogEntry,
CreditGrantRequest,
CreditGrantResponse,
LedgerEntry,
Overview,
PageResult,
ReferralOverview,
SessionResponse,
UserDetail,
UserSummary,
} from "./types";
const API_BASE = "/v1/admin";
const REQUEST_TIMEOUT_MS = 12_000;
interface ApiErrorBody {
code?: string;
error?: {
code?: string;
};
}
export class ApiError extends Error {
constructor(
public readonly code: string,
message: string,
public readonly status: number,
) {
super(message);
this.name = "ApiError";
}
}
let csrfToken = "";
export function setCsrfToken(token?: string): void {
csrfToken = token?.trim() ?? "";
}
function csrfTokenFromCookie(): string {
if (typeof document === "undefined") return "";
const prefix = "osg_admin_csrf=";
return (
document.cookie
.split(";")
.map((part) => part.trim())
.find((part) => part.startsWith(prefix))
?.slice(prefix.length) ?? ""
);
}
function safeMessage(status: number, code?: string): string {
const messages: Record<string, string> = {
INVALID_CREDENTIALS: "用户名、密码或动态验证码错误",
INVALID_TOTP: "动态验证码无效或已过期",
VALIDATION_ERROR: "提交内容不符合要求",
USER_NOT_FOUND: "未找到该用户",
INSUFFICIENT_PERMISSION: "无权执行此操作",
IDEMPOTENCY_CONFLICT: "该赠送请求与已有记录冲突,请核对审计日志",
ADMIN_OPERATOR_NOT_FOUND: "未找到该管理员",
ADMIN_USERNAME_CONFLICT: "该管理员用户名已存在",
CANNOT_DISABLE_SELF: "不能停用当前登录的管理员",
LAST_SUPER_ADMIN_REQUIRED: "必须至少保留一名启用的超级管理员",
RATE_LIMITED: "操作过于频繁,请稍后再试",
};
if (code && messages[code]) return messages[code];
if (status === 401) return "登录状态已失效,请重新登录";
if (status === 403) return "无权执行此操作";
if (status === 409) return "请求冲突,请刷新后重试";
if (status === 429) return "操作过于频繁,请稍后再试";
if (status >= 500) return "服务暂时不可用,请稍后再试";
return "请求失败,请检查后重试";
}
async function request<T>(
path: string,
options: RequestInit = {},
): Promise<T> {
const method = options.method?.toUpperCase() ?? "GET";
const headers = new Headers(options.headers);
headers.set("Accept", "application/json");
if (options.body) headers.set("Content-Type", "application/json");
const csrfRequired =
!["GET", "HEAD", "OPTIONS"].includes(method) && path !== "/auth/login";
if (csrfRequired) {
const requestToken =
csrfToken || csrfTokenFromCookie() || headers.get("X-CSRF-Token") || "";
if (!requestToken) {
throw new ApiError("CSRF_TOKEN_MISSING", "安全令牌缺失,请重新登录", 403);
}
headers.set("X-CSRF-Token", requestToken);
}
try {
const response = await fetch(`${API_BASE}${path}`, {
...options,
headers,
credentials: "include",
signal: options.signal ?? AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!response.ok) {
let body: ApiErrorBody = {};
try {
body = (await response.json()) as ApiErrorBody;
} catch {
// 非 JSON 错误响应不会暴露给界面。
}
const apiError = new ApiError(
body.code ?? body.error?.code ?? `HTTP_${response.status}`,
safeMessage(response.status, body.code ?? body.error?.code),
response.status,
);
if (
response.status === 401 &&
!path.startsWith("/auth/") &&
typeof window !== "undefined"
) {
window.dispatchEvent(new Event("admin:unauthorized"));
}
throw apiError;
}
if (response.status === 204) return undefined as T;
return (await response.json()) as T;
} catch (error) {
if (error instanceof ApiError) throw error;
if (error instanceof DOMException && error.name === "TimeoutError") {
throw new ApiError("REQUEST_TIMEOUT", "请求超时,请稍后再试", 0);
}
throw new ApiError("NETWORK_ERROR", "网络连接失败,请检查网络", 0);
}
}
function query(params: Record<string, string | undefined>): string {
const search = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value) search.set(key, value);
});
const result = search.toString();
return result ? `?${result}` : "";
}
export const adminApi = {
session: () => request<SessionResponse>("/auth/session"),
login: (username: string, password: string, totpCode: string) =>
request<AdminLoginResponse>("/auth/login", {
method: "POST",
body: JSON.stringify({ username, password, totpCode }),
}),
logout: () => request<void>("/auth/logout", { method: "POST" }),
overview: (range: string) =>
request<Overview>(`/overview${query({ range })}`),
referrals: (range: string) =>
request<ReferralOverview>(`/referrals${query({ range })}`),
users: (search: string, cursor?: string) =>
request<PageResult<UserSummary>>(
`/users${query({ q: search.trim(), cursor })}`,
),
user: (userId: string) =>
request<UserDetail>(`/users/${encodeURIComponent(userId)}`),
ledger: (userId: string, cursor?: string) =>
request<PageResult<LedgerEntry>>(
`/users/${encodeURIComponent(userId)}/ledger${query({ cursor })}`,
),
grantCredits: (payload: CreditGrantRequest) =>
request<CreditGrantResponse>("/credits/grants", {
method: "POST",
body: JSON.stringify({
userId: payload.userId,
amount: payload.amount,
reason: payload.reason,
}),
headers: { "Idempotency-Key": payload.idempotencyKey },
}),
auditLogs: (cursor?: string) =>
request<PageResult<AuditLogEntry>>(`/audit${query({ cursor })}`),
operators: (cursor?: string) =>
request<PageResult<AdminOperator>>(`/operators${query({ cursor })}`),
operatorSummary: () =>
request<AdminSecuritySummary>("/operators/summary"),
createOperator: (payload: AdminOperatorCreateRequest) =>
request<AdminOperatorProvisioning>("/operators", {
method: "POST",
body: JSON.stringify(payload),
}),
setOperatorEnabled: (operatorId: string, enabled: boolean) =>
request<void>(
`/operators/${encodeURIComponent(operatorId)}/${enabled ? "enable" : "disable"}`,
{ method: "POST" },
),
unlockOperator: (operatorId: string) =>
request<void>(`/operators/${encodeURIComponent(operatorId)}/unlock`, {
method: "POST",
}),
resetOperatorCredentials: (operatorId: string, password: string) =>
request<AdminOperatorProvisioning>(
`/operators/${encodeURIComponent(operatorId)}/credentials/reset`,
{
method: "POST",
body: JSON.stringify({ password }),
},
),
revokeOperatorSessions: (operatorId: string) =>
request<void>(
`/operators/${encodeURIComponent(operatorId)}/sessions/revoke`,
{ method: "POST" },
),
};
+161
View File
@@ -0,0 +1,161 @@
export type AdminRole = "SUPER_ADMIN" | "SUPPORT" | "ANALYST";
export type AuthState =
| { status: "anonymous" }
| { status: "authenticated"; operatorName: string; role: AdminRole };
export interface SessionResponse {
authenticated: boolean;
operatorName?: string;
role?: AdminRole;
}
export interface AdminLoginResponse {
operatorName: string;
role: AdminRole;
csrfToken: string;
}
export interface TrendPoint {
date: string;
registrations: number;
creditsUsed: number;
}
export interface Overview {
totalUsers: number;
activeUsers: number;
newUsers: number;
totalCreditBalance: number;
creditsGranted: number;
creditsUsed: number;
trend: TrendPoint[];
usage: UserUsageAggregate[];
}
export interface FunnelStep {
label: string;
count: number;
}
export interface ReferralRankingItem {
userId: string;
invited: number;
qualified: number;
creditsEarned: number;
}
export interface ReferralOverview {
pendingBindings: number;
ineligibleBindings: number;
funnel: FunnelStep[];
ranking: ReferralRankingItem[];
}
export interface UserSummary {
userId: string;
displayName: string;
maskedEmail?: string;
status: "active" | "suspended" | "closed";
creditBalance: number;
createdAt: string;
}
export interface UserDetail extends UserSummary {
lastActiveAt?: string;
qualifiedUsage: boolean;
referralCode?: string;
referredByUserId?: string;
usage?: UserUsageAggregate[];
referral?: UserReferral;
}
export interface UserUsageAggregate {
kind: string;
requests: number;
chargedCredits: number;
asrMillis: number;
inputTokens: number;
outputTokens: number;
}
export interface UserReferral {
inviterUserId?: string;
invitedUsers: number;
rewardedInvites: number;
}
export interface PageResult<T> {
items: T[];
nextCursor?: string;
}
export type LedgerEntryType =
| "grant"
| "reserve"
| "settle"
| "refund"
| "adjustment";
export interface LedgerEntry {
entryId: string;
type: LedgerEntryType;
amount: number;
balanceAfter: number;
reasonCode: string;
createdAt: string;
}
export interface CreditGrantRequest {
userId: string;
amount: number;
reason: string;
idempotencyKey: string;
}
export interface CreditGrantResponse {
transactionId: string;
balanceAfter: number;
}
export interface AuditLogEntry {
auditId: string;
operatorName: string;
action: string;
targetType: string;
targetId: string;
requestId?: string;
result: "success" | "rejected";
createdAt: string;
}
export interface AdminOperator {
operatorId: string;
username: string;
role: AdminRole;
enabled: boolean;
failedLoginCount: number;
lockedUntil?: string;
lastLoginAt?: string;
createdAt: string;
updatedAt: string;
}
export interface AdminOperatorProvisioning {
operatorId: string;
operator?: AdminOperator;
totpSecret: string;
otpauthUri: string;
}
export interface AdminSecuritySummary {
enabledOperators: number;
lockedOperators: number;
activeSessions: number;
}
export interface AdminOperatorCreateRequest {
username: string;
password: string;
role: AdminRole;
}
+289
View File
@@ -0,0 +1,289 @@
import { adminApi, ApiError, setCsrfToken } from "./api/client";
import type { AdminRole, AuthState } from "./api/types";
import { renderLoading, showToast } from "./components/ui";
import { escapeHtml } from "./lib/format";
import { renderAudit } from "./pages/audit";
import { renderPasswordLogin } from "./pages/auth";
import { renderCredits } from "./pages/credits";
import { renderOverview } from "./pages/overview";
import { renderReferrals } from "./pages/referrals";
import { renderSecurity } from "./pages/security";
import { renderUsers } from "./pages/users";
type Route =
| "overview"
| "referrals"
| "users"
| "credits"
| "audit"
| "security";
interface RouteDefinition {
id: Route;
label: string;
icon: string;
roles: AdminRole[];
}
const ALL_ROLES: AdminRole[] = ["SUPER_ADMIN", "SUPPORT", "ANALYST"];
const SUPPORT_ROLES: AdminRole[] = ["SUPER_ADMIN", "SUPPORT"];
const SUPER_ADMIN_ONLY: AdminRole[] = ["SUPER_ADMIN"];
const routes: RouteDefinition[] = [
{
id: "overview",
label: "运营总览",
roles: ALL_ROLES,
icon: '<path d="M4 18V10M10 18V6M16 18V3M2 18h18" />',
},
{
id: "referrals",
label: "裂变分析",
roles: ALL_ROLES,
icon:
'<circle cx="5" cy="11" r="2.5" /><circle cx="17" cy="5" r="2.5" /><circle cx="17" cy="17" r="2.5" /><path d="m7.3 9.8 7.4-3.6M7.3 12.2l7.4 3.6" />',
},
{
id: "users",
label: "用户查询",
roles: SUPPORT_ROLES,
icon:
'<circle cx="8" cy="7" r="3" /><circle cx="16" cy="8" r="2.5" /><path d="M2.5 18c.5-3.5 2.3-5.5 5.5-5.5s5 2 5.5 5.5M13 13.5c3.8-.7 6 1 6.5 4.5" />',
},
{
id: "credits",
label: "积分流水",
roles: SUPPORT_ROLES,
icon:
'<rect x="3" y="4" width="16" height="15" rx="2" /><path d="M7 8h8M7 12h8M7 16h5" />',
},
{
id: "audit",
label: "审计日志",
roles: SUPER_ADMIN_ONLY,
icon:
'<path d="M11 2.5 18 5v5.5c0 4.5-2.7 7.3-7 9-4.3-1.7-7-4.5-7-9V5l7-2.5Z" /><path d="m8 11 2 2 4-4" />',
},
{
id: "security",
label: "安全中心",
roles: SUPER_ADMIN_ONLY,
icon:
'<rect x="4" y="9" width="14" height="10" rx="2" /><path d="M7.5 9V6.5a3.5 3.5 0 0 1 7 0V9M11 13v2.5" />',
},
];
export class AdminApp {
private auth: AuthState = { status: "anonymous" };
private renderVersion = 0;
constructor(private readonly root: HTMLElement) {
window.addEventListener("hashchange", () => void this.renderRoute());
window.addEventListener("admin:unauthorized", () => {
setCsrfToken();
this.auth = { status: "anonymous" };
this.render();
showToast("登录状态已失效,请重新登录", "error");
});
}
async start(): Promise<void> {
renderLoading(this.root, "检查登录状态");
try {
const session = await adminApi.session();
if (session.authenticated && session.operatorName && session.role) {
this.auth = {
status: "authenticated",
operatorName: session.operatorName,
role: session.role,
};
}
} catch (error) {
if (!(error instanceof ApiError) || error.status !== 401) {
showToast("暂时无法确认登录状态", "error");
}
}
this.render();
}
private render(): void {
if (this.auth.status === "anonymous") {
renderPasswordLogin(this.root, { onChange: (state) => this.setAuth(state) });
return;
}
this.renderShell();
void this.renderRoute();
}
private setAuth(state: AuthState): void {
this.auth = state;
this.render();
}
private currentRoute(): Route {
const value = window.location.hash.replace(/^#\/?/, "") as Route;
return this.availableRoutes().some((route) => route.id === value)
? value
: "overview";
}
private availableRoutes(): RouteDefinition[] {
if (this.auth.status !== "authenticated") return [];
const role = this.auth.role;
return routes.filter((route) => route.roles.includes(role));
}
private renderShell(): void {
if (this.auth.status !== "authenticated") return;
this.root.innerHTML = `
<a class="skip-link" href="#main-content">跳到主要内容</a>
<div class="app-shell">
<aside class="sidebar" id="primary-navigation" data-sidebar>
<div class="sidebar-brand">
<div class="brand-mark brand-mark--small">OSG</div>
<div><strong>管理中心</strong><span>OSG ACCOUNT</span></div>
</div>
<nav class="nav-list" aria-label="主导航">
${this.availableRoutes()
.map(
(route) => `
<a href="#/${route.id}" data-route="${route.id}">
<svg class="nav-icon" viewBox="0 0 22 22" aria-hidden="true">${route.icon}</svg>
${route.label}
</a>
`,
)
.join("")}
</nav>
<div class="sidebar-footer">
<div class="operator">
<span class="operator-avatar" aria-hidden="true">管</span>
<div><strong>${escapeHtml(this.auth.operatorName)}</strong><span>${roleLabel(this.auth.role)}</span></div>
</div>
<button class="logout-button" data-logout title="安全退出">
<svg viewBox="0 0 22 22" aria-hidden="true"><path d="M9 4H4v14h5M13 7l4 4-4 4M17 11H8" /></svg>
<span>退出</span>
</button>
</div>
</aside>
<div class="main-column">
<header class="mobile-header">
<button class="icon-button" data-menu aria-label="打开导航" aria-expanded="false" aria-controls="primary-navigation">
<svg viewBox="0 0 22 22" aria-hidden="true"><path d="M3 6h16M3 11h16M3 16h16" /></svg>
</button>
<strong data-mobile-title>运营总览</strong>
</header>
<main class="page-content" id="main-content" data-content tabindex="-1"></main>
</div>
<button class="sidebar-scrim" data-scrim aria-label="关闭导航"></button>
</div>
`;
const sidebar = this.root.querySelector<HTMLElement>("[data-sidebar]");
const menuButton =
this.root.querySelector<HTMLButtonElement>("[data-menu]");
const mobileLayout = window.matchMedia("(max-width: 1023px)");
const syncSidebarState = () => {
if (!sidebar) return;
sidebar.inert =
mobileLayout.matches && !sidebar.classList.contains("sidebar--open");
};
const closeMenu = (restoreFocus = false) => {
sidebar?.classList.remove("sidebar--open");
menuButton?.setAttribute("aria-expanded", "false");
syncSidebarState();
if (restoreFocus) menuButton?.focus();
};
const openMenu = () => {
sidebar?.classList.add("sidebar--open");
menuButton?.setAttribute("aria-expanded", "true");
syncSidebarState();
sidebar
?.querySelector<HTMLAnchorElement>('a[aria-current="page"], a')
?.focus();
};
menuButton?.addEventListener("click", openMenu);
this.root
.querySelector<HTMLButtonElement>("[data-scrim]")
?.addEventListener("click", () => closeMenu(true));
this.root.querySelectorAll("[data-route]").forEach((link) => {
link.addEventListener("click", () => closeMenu());
});
sidebar?.addEventListener("keydown", (event) => {
if (event.key === "Escape") closeMenu(true);
});
mobileLayout.addEventListener("change", syncSidebarState);
syncSidebarState();
this.root
.querySelector<HTMLButtonElement>("[data-logout]")
?.addEventListener("click", () => void this.logout());
}
private async renderRoute(): Promise<void> {
if (this.auth.status !== "authenticated") return;
const route = this.currentRoute();
this.root.querySelectorAll<HTMLElement>("[data-route]").forEach((link) => {
const active = link.dataset.route === route;
link.classList.toggle("active", active);
if (active) {
link.setAttribute("aria-current", "page");
} else {
link.removeAttribute("aria-current");
}
});
const routeDefinition = routes.find((item) => item.id === route);
document.title = `${routeDefinition?.label ?? "管理中心"} · OSG`;
const mobileTitle =
this.root.querySelector<HTMLElement>("[data-mobile-title]");
if (mobileTitle) mobileTitle.textContent = routeDefinition?.label ?? "管理中心";
const content = this.root.querySelector<HTMLElement>("[data-content]");
if (!content) return;
const version = ++this.renderVersion;
const stage = document.createElement("div");
stage.className = "page-stage";
renderLoading(content);
if (route === "overview") await renderOverview(stage);
if (route === "referrals") await renderReferrals(stage);
if (route === "users") renderUsers(stage, this.auth.role);
if (route === "credits") renderCredits(stage);
if (route === "audit") await renderAudit(stage);
if (route === "security") {
await renderSecurity(stage, this.auth.operatorName);
}
if (version === this.renderVersion) {
content.replaceChildren(stage);
const heading = content.querySelector<HTMLElement>("h1");
if (heading) {
heading.tabIndex = -1;
heading.focus({ preventScroll: true });
} else {
content.focus({ preventScroll: true });
}
}
}
private async logout(): Promise<void> {
try {
await adminApi.logout();
} catch {
// 即使服务端退出失败,也立即清理前端认证状态。
} finally {
setCsrfToken();
this.auth = { status: "anonymous" };
window.history.replaceState(null, "", window.location.pathname);
this.render();
}
}
}
function roleLabel(role: AdminRole): string {
const labels: Record<AdminRole, string> = {
SUPER_ADMIN: "超级管理员",
SUPPORT: "支持人员",
ANALYST: "分析员",
};
return labels[role];
}
+80
View File
@@ -0,0 +1,80 @@
import type { FunnelStep, TrendPoint } from "../api/types";
import { escapeHtml, formatNumber } from "../lib/format";
export function trendChart(points: TrendPoint[]): string {
if (points.length === 0) return '<div class="empty-state">暂无趋势数据</div>';
const width = 720;
const height = 240;
const padding = 28;
const values = points.map((point) => point.registrations);
const max = Math.max(...values, 1);
const step = points.length > 1 ? (width - padding * 2) / (points.length - 1) : 0;
const coordinates = points.map((point, index) => ({
x: points.length === 1 ? width / 2 : padding + index * step,
y: height - padding - (point.registrations / max) * (height - padding * 2),
point,
}));
const polyline = coordinates.map(({ x, y }) => `${x},${y}`).join(" ");
const midpoint = Math.ceil(max / 2);
return `
<div class="chart-scroll">
<svg class="trend-chart" viewBox="0 0 ${width} ${height}" role="img" aria-labelledby="trend-chart-title trend-chart-description">
<title id="trend-chart-title">新增用户趋势</title>
<desc id="trend-chart-description">横轴为 UTC 日期,纵轴为每日新增用户数。图表后提供完整数据表。</desc>
<line x1="${padding}" y1="${padding}" x2="${width - padding}" y2="${padding}" class="chart-axis" />
<line x1="${padding}" y1="${height / 2}" x2="${width - padding}" y2="${height / 2}" class="chart-axis" />
<line x1="${padding}" y1="${height - padding}" x2="${width - padding}" y2="${height - padding}" class="chart-axis" />
<text x="${padding}" y="${padding - 8}" class="chart-label">${formatNumber(max)}</text>
<text x="${padding}" y="${height / 2 - 8}" class="chart-label">${formatNumber(midpoint)}</text>
<text x="${padding}" y="${height - 8}" class="chart-label">0</text>
<polyline points="${polyline}" class="chart-line" />
${coordinates
.map(
({ x, y, point }) => `
<circle cx="${x}" cy="${y}" r="4" class="chart-dot">
<title>${escapeHtml(point.date)}${formatNumber(point.registrations)} </title>
</circle>
`,
)
.join("")}
</svg>
</div>
<table class="sr-only">
<caption>新增用户与积分消耗趋势完整数据</caption>
<thead><tr><th scope="col">UTC 日期</th><th scope="col">新增用户</th><th scope="col">消耗积分</th></tr></thead>
<tbody>
${points
.map(
(point) =>
`<tr><td>${escapeHtml(point.date)}</td><td>${formatNumber(point.registrations)}</td><td>${formatNumber(point.creditsUsed)}</td></tr>`,
)
.join("")}
</tbody>
</table>
`;
}
export function funnelChart(steps: FunnelStep[]): string {
if (steps.length === 0) return '<div class="empty-state">暂无漏斗数据</div>';
const max = Math.max(...steps.map((step) => step.count), 1);
return `
<div class="funnel" aria-label="裂变漏斗">
${steps
.map((step, index) => {
return `
<div class="funnel-step">
<div class="funnel-label">
<span>${escapeHtml(step.label)}</span>
<strong>${formatNumber(step.count)}</strong>
</div>
<progress class="funnel-progress funnel-progress--${(index % 4) + 1}" max="${max}" value="${step.count}" aria-label="${escapeHtml(step.label)}${formatNumber(step.count)}"></progress>
</div>
`;
})
.join("")}
</div>
`;
}
+66
View File
@@ -0,0 +1,66 @@
import { ApiError } from "../api/client";
import { escapeHtml } from "../lib/format";
export function renderLoading(container: HTMLElement, label = "正在加载"): void {
container.innerHTML = `
<div class="state-card" role="status" aria-live="polite">
<span class="spinner" aria-hidden="true"></span>
<p>${escapeHtml(label)}…</p>
</div>
`;
}
export function renderError(
container: HTMLElement,
error: unknown,
retry?: () => void,
): void {
const message =
error instanceof ApiError ? error.message : "出现未知错误,请稍后重试";
container.innerHTML = `
<div class="state-card state-card--error" role="alert">
<span class="state-icon" aria-hidden="true">!</span>
<h2>无法加载数据</h2>
<p>${escapeHtml(message)}</p>
${retry ? '<button class="button button--secondary" data-retry>重试</button>' : ""}
</div>
`;
container.querySelector<HTMLButtonElement>("[data-retry]")?.addEventListener(
"click",
() => retry?.(),
);
}
export function renderEmpty(message: string): string {
return `<div class="empty-state"><p>${escapeHtml(message)}</p></div>`;
}
export function showToast(message: string, tone: "success" | "error"): void {
const toast = document.createElement("div");
toast.className = `toast toast--${tone}`;
toast.setAttribute("role", tone === "error" ? "alert" : "status");
toast.textContent = message;
document.body.append(toast);
requestAnimationFrame(() => toast.classList.add("toast--visible"));
window.setTimeout(() => {
toast.classList.remove("toast--visible");
window.setTimeout(() => toast.remove(), 180);
}, 3_200);
}
export function setButtonBusy(
button: HTMLButtonElement,
busy: boolean,
busyLabel = "处理中…",
): void {
if (busy) {
button.dataset.label = button.textContent ?? "";
button.textContent = busyLabel;
button.disabled = true;
button.setAttribute("aria-busy", "true");
} else {
button.textContent = button.dataset.label ?? button.textContent;
button.disabled = false;
button.removeAttribute("aria-busy");
}
}
+54
View File
@@ -0,0 +1,54 @@
const dateTimeFormatter = new Intl.DateTimeFormat("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
const numberFormatter = new Intl.NumberFormat("zh-CN");
export function formatDateTime(value?: string): string {
if (!value) return "—";
const date = new Date(value);
return Number.isNaN(date.getTime()) ? "—" : dateTimeFormatter.format(date);
}
export function formatNumber(value: number): string {
return numberFormatter.format(value);
}
export function formatSignedCredits(value: number): string {
return `${value > 0 ? "+" : ""}${formatNumber(value)}`;
}
export function escapeHtml(value: unknown): string {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
export function statusLabel(status: string): string {
const labels: Record<string, string> = {
active: "正常",
suspended: "已停用",
closed: "已关闭",
success: "成功",
rejected: "已拒绝",
grant: "赠送",
reserve: "预留",
settle: "结算",
refund: "退还",
adjustment: "调整",
};
return labels[status] ?? status;
}
export function createIdempotencyKey(): string {
return globalThis.crypto?.randomUUID?.() ??
`grant-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
+7
View File
@@ -0,0 +1,7 @@
import "./styles.css";
import { AdminApp } from "./app";
const root = document.querySelector<HTMLElement>("#app");
if (!root) throw new Error("应用挂载节点不存在");
void new AdminApp(root).start();
+99
View File
@@ -0,0 +1,99 @@
import { adminApi, ApiError } from "../api/client";
import type { AuditLogEntry } from "../api/types";
import {
renderEmpty,
renderError,
renderLoading,
setButtonBusy,
showToast,
} from "../components/ui";
import { escapeHtml, formatDateTime, statusLabel } from "../lib/format";
export async function renderAudit(container: HTMLElement): Promise<void> {
renderLoading(container, "加载审计日志");
try {
const page = await adminApi.auditLogs();
container.innerHTML = `
<div class="page-heading">
<div>
<div class="eyebrow">安全与合规</div>
<h1>审计日志</h1>
<p>追踪管理员操作、目标和执行结果。</p>
</div>
</div>
<section class="panel">
${
page.items.length === 0
? renderEmpty("暂无审计记录")
: `
<div class="table-wrap">
<table>
<caption class="sr-only">管理员审计事件</caption>
<thead><tr><th scope="col">时间</th><th scope="col">操作员</th><th scope="col">操作</th><th scope="col">目标</th><th scope="col">请求 ID</th><th scope="col">结果</th></tr></thead>
<tbody data-audit-body>${auditRows(page.items)}</tbody>
</table>
</div>
${
page.nextCursor
? '<div class="pagination-actions"><button class="button button--secondary" data-audit-more>加载更早记录</button></div>'
: ""
}
`
}
</section>
`;
if (page.nextCursor) bindAuditPagination(container, page.nextCursor);
} catch (error) {
renderError(container, error, () => void renderAudit(container));
}
}
function auditRows(items: AuditLogEntry[]): string {
return items
.map(
(entry) => `
<tr>
<td>${formatDateTime(entry.createdAt)}</td>
<td>${escapeHtml(entry.operatorName)}</td>
<td><code>${escapeHtml(entry.action)}</code></td>
<td>
<span>${escapeHtml(entry.targetType)}</span>
<div class="subtle mono">${escapeHtml(entry.targetId)}</div>
</td>
<td class="mono">${escapeHtml(entry.requestId || "—")}</td>
<td><span class="badge badge--${entry.result}">${statusLabel(entry.result)}</span></td>
</tr>
`,
)
.join("");
}
function bindAuditPagination(container: HTMLElement, initialCursor: string): void {
const button =
container.querySelector<HTMLButtonElement>("[data-audit-more]");
const body = container.querySelector<HTMLTableSectionElement>("[data-audit-body]");
if (!button || !body) return;
let cursor: string | undefined = initialCursor;
button.addEventListener("click", async () => {
if (!cursor) return;
setButtonBusy(button, true, "加载中…");
try {
const page = await adminApi.auditLogs(cursor);
body.insertAdjacentHTML("beforeend", auditRows(page.items));
cursor = page.nextCursor;
if (!cursor) {
button.closest(".pagination-actions")?.remove();
} else {
setButtonBusy(button, false);
button.focus();
}
} catch (error) {
showToast(
error instanceof ApiError ? error.message : "加载审计记录失败",
"error",
);
setButtonBusy(button, false);
}
});
}
+83
View File
@@ -0,0 +1,83 @@
import { adminApi, ApiError, setCsrfToken } from "../api/client";
import type { AuthState } from "../api/types";
import { setButtonBusy } from "../components/ui";
interface AuthCallbacks {
onChange: (state: AuthState) => void;
}
function authFrame(content: string): string {
return `
<main class="auth-shell">
<section class="auth-brand" aria-label="OSG 运营后台">
<div class="brand-mark">OSG</div>
<p>安全、克制、可追溯的运营管理</p>
</section>
<section class="auth-card">${content}</section>
</main>
`;
}
export function renderPasswordLogin(
root: HTMLElement,
callbacks: AuthCallbacks,
): void {
root.innerHTML = authFrame(`
<div class="eyebrow">运营后台</div>
<h1>管理员登录</h1>
<p class="muted">使用管理员凭据和认证器动态验证码登录。凭据不会保存到浏览器。</p>
<form class="form-stack" data-login-form novalidate>
<label>
<span>管理员用户名</span>
<input name="username" type="text" autocomplete="username" minlength="3" maxlength="64" required autofocus />
</label>
<label>
<span>管理员密码</span>
<input name="password" type="password" autocomplete="current-password" minlength="12" required />
</label>
<label>
<span>动态验证码</span>
<input class="totp-input" name="totpCode" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]{6}" maxlength="6" required />
</label>
<p class="form-error" data-error role="alert"></p>
<button class="button button--primary button--wide" type="submit">安全登录</button>
</form>
`);
const form = root.querySelector<HTMLFormElement>("[data-login-form]");
form?.addEventListener("submit", async (event) => {
event.preventDefault();
const button = form.querySelector<HTMLButtonElement>("button");
const errorNode = form.querySelector<HTMLElement>("[data-error]");
const username = new FormData(form).get("username")?.toString().trim() ?? "";
const password = new FormData(form).get("password")?.toString() ?? "";
const totpCode = new FormData(form).get("totpCode")?.toString().trim() ?? "";
if (
username.length < 3 ||
password.length < 12 ||
!/^\d{6}$/.test(totpCode) ||
!button ||
!errorNode
) {
if (errorNode) errorNode.textContent = "请输入有效的用户名、密码和 6 位动态验证码";
return;
}
setButtonBusy(button, true, "验证中…");
errorNode.textContent = "";
try {
const response = await adminApi.login(username, password, totpCode);
form.reset();
setCsrfToken(response.csrfToken);
callbacks.onChange({
status: "authenticated",
operatorName: response.operatorName,
role: response.role,
});
} catch (error) {
errorNode.textContent =
error instanceof ApiError ? error.message : "验证码验证失败";
setButtonBusy(button, false);
}
});
}
+78
View File
@@ -0,0 +1,78 @@
import { adminApi } from "../api/client";
import { renderEmpty, renderError, renderLoading } from "../components/ui";
import {
escapeHtml,
formatDateTime,
formatNumber,
formatSignedCredits,
statusLabel,
} from "../lib/format";
export function renderCredits(container: HTMLElement): void {
container.innerHTML = `
<div class="page-heading">
<div>
<div class="eyebrow">积分账本</div>
<h1>积分流水</h1>
<p>按内部用户 ID 查询不可变积分流水。</p>
</div>
</div>
<section class="panel">
<form class="search-form" data-ledger-form>
<label class="search-box">
<span class="sr-only">内部用户 ID</span>
<span aria-hidden="true">⌕</span>
<input name="userId" type="search" placeholder="输入完整用户 ID" autocomplete="off" maxlength="36" required />
</label>
<button class="button button--primary" type="submit">查询流水</button>
</form>
<div data-ledger-results>${renderEmpty("输入内部用户 ID 开始查询")}</div>
</section>
`;
const form = container.querySelector<HTMLFormElement>("[data-ledger-form]");
form?.addEventListener("submit", (event) => {
event.preventDefault();
const userId = new FormData(form).get("userId")?.toString().trim() ?? "";
if (userId) void loadLedger(container, userId);
});
}
async function loadLedger(container: HTMLElement, userId: string): Promise<void> {
const results = container.querySelector<HTMLElement>("[data-ledger-results]");
if (!results) return;
renderLoading(results, "加载积分流水");
try {
const page = await adminApi.ledger(userId);
results.innerHTML =
page.items.length === 0
? renderEmpty("该用户暂无积分流水")
: `
<div class="result-summary">用户 <span class="mono">${escapeHtml(userId)}</span> · ${formatNumber(page.items.length)} 条记录</div>
<div class="table-wrap">
<table>
<caption class="sr-only">用户 ${escapeHtml(userId)} 的积分流水</caption>
<thead><tr><th scope="col">时间</th><th scope="col">流水号</th><th scope="col">类型</th><th scope="col">变动</th><th scope="col">结余</th><th scope="col">原因</th></tr></thead>
<tbody>
${page.items
.map(
(entry) => `
<tr>
<td>${formatDateTime(entry.createdAt)}</td>
<td class="mono">${escapeHtml(entry.entryId)}</td>
<td>${statusLabel(entry.type)}</td>
<td class="${entry.amount >= 0 ? "positive" : "negative"}">${formatSignedCredits(entry.amount)}</td>
<td>${formatNumber(entry.balanceAfter)}</td>
<td>${escapeHtml(entry.reasonCode)}</td>
</tr>
`,
)
.join("")}
</tbody>
</table>
</div>
`;
} catch (error) {
renderError(results, error, () => void loadLedger(container, userId));
}
}
+102
View File
@@ -0,0 +1,102 @@
import { adminApi } from "../api/client";
import { trendChart } from "../components/charts";
import { renderError, renderLoading } from "../components/ui";
import { escapeHtml, formatNumber } from "../lib/format";
export async function renderOverview(
container: HTMLElement,
range = "30d",
): Promise<void> {
renderLoading(container, "加载总览");
try {
const data = await adminApi.overview(range);
container.innerHTML = `
<div class="page-heading">
<div>
<div class="eyebrow">核心指标</div>
<h1>运营总览</h1>
<p>快速掌握增长与积分消耗情况。</p>
</div>
<fieldset class="segmented-control">
<legend class="sr-only">统计周期</legend>
${rangeOption("7d", "7 天", range)}
${rangeOption("30d", "30 天", range)}
${rangeOption("90d", "90 天", range)}
</fieldset>
</div>
<section class="metric-grid" aria-label="关键指标">
${metric("用户总数", data.totalUsers, "累计账户")}
${metric("活跃用户", data.activeUsers, "当前周期")}
${metric("新增用户", data.newUsers, "当前周期")}
${metric("积分余额", data.totalCreditBalance, "所有账户")}
${metric("赠送积分", data.creditsGranted, "整数积分")}
${metric("消耗积分", data.creditsUsed, "已结算")}
</section>
<section class="panel">
<div class="panel-heading">
<div><h2>新增用户趋势</h2><p>按 UTC 日期统计注册数</p></div>
<span class="legend"><i></i>新增用户</span>
</div>
${trendChart(data.trend)}
</section>
<section class="panel">
<div class="panel-heading"><div><h2>使用汇总</h2><p>按类型统计,不包含用户内容</p></div></div>
${
data.usage.length === 0
? '<p class="muted">当前周期暂无使用记录</p>'
: `
<div class="table-wrap">
<table>
<caption class="sr-only">当前周期使用统计</caption>
<thead><tr><th scope="col">类型</th><th scope="col">请求</th><th scope="col">消耗积分</th><th scope="col">语音毫秒</th><th scope="col">输入 Token</th><th scope="col">输出 Token</th></tr></thead>
<tbody>
${data.usage
.map(
(item) => `
<tr>
<td><code>${escapeHtml(item.kind)}</code></td>
<td>${formatNumber(item.requests)}</td>
<td>${formatNumber(item.chargedCredits)}</td>
<td>${formatNumber(item.asrMillis)}</td>
<td>${formatNumber(item.inputTokens)}</td>
<td>${formatNumber(item.outputTokens)}</td>
</tr>
`,
)
.join("")}
</tbody>
</table>
</div>
`
}
</section>
`;
container.querySelectorAll<HTMLInputElement>("[data-range]").forEach((input) => {
input.addEventListener("change", (event) => {
void renderOverview(container, (event.target as HTMLInputElement).value);
});
});
} catch (error) {
renderError(container, error, () => void renderOverview(container, range));
}
}
function rangeOption(value: string, label: string, selected: string): string {
return `
<label>
<input data-range name="overview-range" type="radio" value="${value}" ${selected === value ? "checked" : ""} />
<span>${label}</span>
</label>
`;
}
function metric(label: string, value: number, hint: string): string {
return `
<article class="metric-card">
<p>${label}</p>
<strong>${formatNumber(value)}</strong>
<span>${hint}</span>
</article>
`;
}
+97
View File
@@ -0,0 +1,97 @@
import { adminApi } from "../api/client";
import { funnelChart } from "../components/charts";
import { renderEmpty, renderError, renderLoading } from "../components/ui";
import { escapeHtml, formatNumber } from "../lib/format";
export async function renderReferrals(
container: HTMLElement,
range = "30d",
): Promise<void> {
renderLoading(container, "加载裂变数据");
try {
const data = await adminApi.referrals(range);
container.innerHTML = `
<div class="page-heading">
<div>
<div class="eyebrow">增长分析</div>
<h1>裂变与排行</h1>
<p>奖励以有效使用为前提,不以注册量代替真实转化。</p>
</div>
<fieldset class="segmented-control">
<legend class="sr-only">统计周期</legend>
${rangeOption("7d", "7 天", range)}
${rangeOption("30d", "30 天", range)}
${rangeOption("90d", "90 天", range)}
</fieldset>
</div>
<section class="metric-grid metric-grid--two" aria-label="邀请资格状态">
<article class="metric-card">
<p>待资格确认</p>
<strong>${formatNumber(data.pendingBindings)}</strong>
<span>等待有效使用</span>
</article>
<article class="metric-card">
<p>不符合奖励条件</p>
<strong>${formatNumber(data.ineligibleBindings)}</strong>
<span>未发放积分</span>
</article>
</section>
<div class="two-column">
<section class="panel">
<div class="panel-heading"><div><h2>裂变漏斗</h2><p>从分享至有效使用</p></div></div>
${funnelChart(data.funnel)}
</section>
<section class="panel">
<div class="panel-heading"><div><h2>邀请排行</h2><p>按有效邀请数排序</p></div></div>
${
data.ranking.length === 0
? renderEmpty("当前周期暂无排行数据")
: `
<div class="table-wrap">
<table>
<caption class="sr-only">有效邀请用户排行</caption>
<thead><tr><th scope="col">名次</th><th scope="col">用户 ID</th><th scope="col">邀请</th><th scope="col">有效</th><th scope="col">奖励积分</th></tr></thead>
<tbody>
${data.ranking
.map(
(item, index) => `
<tr>
<td><span class="rank rank--${index + 1}">${index + 1}</span></td>
<td class="mono">${escapeHtml(item.userId)}</td>
<td>${formatNumber(item.invited)}</td>
<td>${formatNumber(item.qualified)}</td>
<td>${formatNumber(item.creditsEarned)}</td>
</tr>
`,
)
.join("")}
</tbody>
</table>
</div>
`
}
</section>
</div>
`;
container.querySelectorAll<HTMLInputElement>("[data-range]").forEach((input) => {
input.addEventListener("change", (event) => {
void renderReferrals(
container,
(event.target as HTMLInputElement).value,
);
});
});
} catch (error) {
renderError(container, error, () => void renderReferrals(container, range));
}
}
function rangeOption(value: string, label: string, selected: string): string {
return `
<label>
<input data-range name="referral-range" type="radio" value="${value}" ${selected === value ? "checked" : ""} />
<span>${label}</span>
</label>
`;
}
+585
View File
@@ -0,0 +1,585 @@
import { adminApi, ApiError } from "../api/client";
import type {
AdminOperator,
AdminOperatorProvisioning,
AdminRole,
AdminSecuritySummary,
} from "../api/types";
import {
renderError,
renderLoading,
setButtonBusy,
showToast,
} from "../components/ui";
import { escapeHtml, formatDateTime } from "../lib/format";
export async function renderSecurity(
container: HTMLElement,
currentUsername: string,
): Promise<void> {
renderLoading(container, "加载安全中心");
try {
const [page, summary] = await Promise.all([
adminApi.operators(),
adminApi.operatorSummary(),
]);
const operators = [...page.items];
container.innerHTML = securityTemplate(
operators,
currentUsername,
summary,
page.nextCursor,
);
bindSecurityActions(container, operators, currentUsername);
bindOperatorPagination(
container,
operators,
currentUsername,
page.nextCursor,
);
} catch (error) {
renderError(container, error, () => {
void renderSecurity(container, currentUsername);
});
}
}
function securityTemplate(
operators: AdminOperator[],
currentUsername: string,
summary: AdminSecuritySummary,
nextCursor?: string,
): string {
return `
<div class="page-heading">
<div>
<div class="eyebrow">访问控制</div>
<h1>安全中心</h1>
<p>管理运营人员、角色、登录锁定、会话与双重认证。</p>
</div>
<button class="button button--primary" data-create-operator>添加管理员</button>
</div>
<aside class="security-notice" aria-label="安全说明">
<strong>双重边界已启用</strong>
<span>访问仍需受信客户端证书;所有管理员变更都会写入不可变审计日志。</span>
</aside>
<section class="metric-grid metric-grid--three" aria-label="安全指标">
${securityMetric("已启用管理员", summary.enabledOperators, "可登录账户")}
${securityMetric("已锁定账户", summary.lockedOperators, "等待解锁或锁定到期")}
${securityMetric("活动会话", summary.activeSessions, "尚未过期且未撤销")}
</section>
<section class="panel">
<div class="panel-heading">
<div><h2>管理员</h2><p data-operator-count>已加载 ${operators.length} 个账户</p></div>
</div>
<div class="table-wrap">
<table>
<caption class="sr-only">管理员账户与安全状态</caption>
<thead>
<tr><th scope="col">管理员</th><th scope="col">角色</th><th scope="col">状态</th><th scope="col">最近登录</th><th scope="col">操作</th></tr>
</thead>
<tbody data-operator-body>
${operators.map((operator) => operatorRow(operator, currentUsername)).join("")}
</tbody>
</table>
</div>
${
nextCursor
? '<div class="pagination-actions"><button class="button button--secondary" data-operator-more aria-describedby="operator-pagination-status">加载更多管理员</button></div>'
: ""
}
<p class="sr-only" id="operator-pagination-status" data-operator-status role="status" aria-live="polite"></p>
</section>
${operatorFormDialog()}
${credentialResetDialog()}
`;
}
function securityMetric(label: string, value: number, hint: string): string {
return `
<article class="metric-card">
<span>${escapeHtml(label)}</span>
<strong>${value.toLocaleString("zh-CN")}</strong>
<small>${escapeHtml(hint)}</small>
</article>
`;
}
function operatorRow(
operator: AdminOperator,
currentUsername: string,
): string {
const current = operator.username === currentUsername;
const locked = Boolean(
operator.lockedUntil && new Date(operator.lockedUntil).getTime() > Date.now(),
);
return `
<tr>
<td>
<strong>${escapeHtml(operator.username)} ${current ? '<span class="badge">当前</span>' : ""}</strong>
<div class="subtle mono">${escapeHtml(operator.operatorId)}</div>
</td>
<td>${roleLabel(operator.role)}</td>
<td>
<span class="badge ${operator.enabled ? "badge--success" : "badge--rejected"}">${operator.enabled ? "已启用" : "已停用"}</span>
${locked ? '<span class="badge badge--rejected">已锁定</span>' : ""}
</td>
<td>${formatDateTime(operator.lastLoginAt)}</td>
<td>
<div class="row-actions" data-operator-id="${escapeHtml(operator.operatorId)}">
${
locked
? '<button class="button button--small button--secondary" data-operator-action="unlock">解锁</button>'
: ""
}
<button class="button button--small button--secondary" data-operator-action="sessions">撤销会话</button>
<button class="button button--small button--secondary" data-operator-action="reset">重置凭据</button>
${
operator.enabled
? `<button class="button button--small button--danger" data-operator-action="disable" ${current ? "disabled" : ""}>停用</button>`
: '<button class="button button--small button--secondary" data-operator-action="enable">启用</button>'
}
</div>
</td>
</tr>
`;
}
function bindOperatorPagination(
container: HTMLElement,
operators: AdminOperator[],
currentUsername: string,
initialCursor?: string,
): void {
const button =
container.querySelector<HTMLButtonElement>("[data-operator-more]");
const body =
container.querySelector<HTMLTableSectionElement>("[data-operator-body]");
const count = container.querySelector<HTMLElement>("[data-operator-count]");
const status = container.querySelector<HTMLElement>("[data-operator-status]");
if (!button || !body || !initialCursor) return;
let cursor: string | undefined = initialCursor;
button.addEventListener("click", async () => {
if (!cursor) return;
setButtonBusy(button, true, "加载中…");
try {
const page = await adminApi.operators(cursor);
body.insertAdjacentHTML(
"beforeend",
page.items.map((operator) => operatorRow(operator, currentUsername)).join(""),
);
operators.push(...page.items);
cursor = page.nextCursor;
if (count) count.textContent = `已加载 ${operators.length} 个账户`;
if (status) {
status.textContent = cursor
? `已加载 ${page.items.length} 个更多管理员`
: `已加载 ${page.items.length} 个管理员,全部账户已加载`;
}
if (!cursor) {
button.closest(".pagination-actions")?.remove();
} else {
setButtonBusy(button, false);
button.focus();
}
} catch (error) {
showToast(errorMessage(error, "加载管理员失败"), "error");
setButtonBusy(button, false);
button.focus();
}
});
}
function operatorFormDialog(): string {
return `
<dialog class="dialog" data-operator-dialog aria-labelledby="operator-dialog-title">
<form class="dialog-card form-stack" data-operator-form novalidate>
<div class="eyebrow">访问授权</div>
<h2 id="operator-dialog-title">添加管理员</h2>
<p class="muted">创建后,TOTP 密钥只显示一次。</p>
<label><span>用户名</span><input name="username" autocomplete="off" minlength="3" maxlength="64" pattern="[A-Za-z0-9][A-Za-z0-9._@-]{2,63}" required /></label>
<label>
<span>角色</span>
<select name="role" required>
<option value="ANALYST">分析员 · 仅统计</option>
<option value="SUPPORT">支持人员 · 用户与流水只读</option>
<option value="SUPER_ADMIN">超级管理员 · 完整权限</option>
</select>
</label>
<label><span>初始密码</span><input name="password" type="password" autocomplete="new-password" minlength="12" maxlength="128" required /></label>
<label><span>确认密码</span><input name="passwordConfirmation" type="password" autocomplete="new-password" minlength="12" maxlength="128" required /></label>
<p class="form-error" data-operator-error role="alert"></p>
<div class="dialog-actions">
<button class="button button--secondary" type="button" data-close-operator>取消</button>
<button class="button button--primary" type="submit">创建并生成 TOTP</button>
</div>
</form>
</dialog>
`;
}
function credentialResetDialog(): string {
return `
<dialog class="dialog" data-reset-dialog aria-labelledby="reset-dialog-title">
<form class="dialog-card form-stack" data-reset-form novalidate>
<div class="eyebrow">高风险操作</div>
<h2 id="reset-dialog-title">重置登录凭据</h2>
<p>将为 <strong data-reset-username></strong> 重置密码和 TOTP,并立即撤销其全部会话。</p>
<label><span>新密码</span><input name="password" type="password" autocomplete="new-password" minlength="12" maxlength="128" required /></label>
<label><span>确认新密码</span><input name="passwordConfirmation" type="password" autocomplete="new-password" minlength="12" maxlength="128" required /></label>
<p class="form-error" data-reset-error role="alert"></p>
<div class="dialog-actions">
<button class="button button--secondary" type="button" data-close-reset>取消</button>
<button class="button button--danger" type="submit">重置并撤销会话</button>
</div>
</form>
</dialog>
`;
}
function bindSecurityActions(
container: HTMLElement,
operators: AdminOperator[],
currentUsername: string,
): void {
const createDialog =
container.querySelector<HTMLDialogElement>("[data-operator-dialog]");
const createForm =
container.querySelector<HTMLFormElement>("[data-operator-form]");
container
.querySelector<HTMLButtonElement>("[data-create-operator]")
?.addEventListener("click", () => {
createForm?.reset();
clearError(createForm, "[data-operator-error]");
createDialog?.showModal();
createForm?.querySelector<HTMLInputElement>('input[name="username"]')?.focus();
});
container
.querySelector<HTMLButtonElement>("[data-close-operator]")
?.addEventListener("click", () => createDialog?.close());
createForm?.addEventListener("submit", (event) => {
event.preventDefault();
void createOperator(container, createDialog, createForm, currentUsername);
});
const resetDialog =
container.querySelector<HTMLDialogElement>("[data-reset-dialog]");
const resetForm = container.querySelector<HTMLFormElement>("[data-reset-form]");
container
.querySelector<HTMLButtonElement>("[data-close-reset]")
?.addEventListener("click", () => resetDialog?.close());
resetForm?.addEventListener("submit", (event) => {
event.preventDefault();
void resetCredentials(container, resetDialog, resetForm, currentUsername);
});
container.querySelector("tbody")?.addEventListener("click", (event) => {
const button = (event.target as HTMLElement).closest<HTMLButtonElement>(
"[data-operator-action]",
);
const operatorId = button?.closest<HTMLElement>("[data-operator-id]")?.dataset
.operatorId;
const operator = operators.find((item) => item.operatorId === operatorId);
if (!button || !operator) return;
void handleOperatorAction(
container,
button,
operator,
resetDialog,
resetForm,
currentUsername,
);
});
}
async function createOperator(
container: HTMLElement,
dialog: HTMLDialogElement | null,
form: HTMLFormElement,
currentUsername: string,
): Promise<void> {
const data = new FormData(form);
const username = data.get("username")?.toString().trim() ?? "";
const role = data.get("role")?.toString() as AdminRole;
const password = data.get("password")?.toString() ?? "";
const confirmation = data.get("passwordConfirmation")?.toString() ?? "";
const errorNode = form.querySelector<HTMLElement>("[data-operator-error]");
const submit = form.querySelector<HTMLButtonElement>('button[type="submit"]');
if (
!/^[A-Za-z0-9][A-Za-z0-9._@-]{2,63}$/.test(username) ||
!["SUPER_ADMIN", "SUPPORT", "ANALYST"].includes(role) ||
password.length < 12 ||
password !== confirmation ||
!submit
) {
if (errorNode) errorNode.textContent = "请检查用户名、角色及两次输入的密码";
return;
}
setButtonBusy(submit, true, "创建中…");
clearError(form, "[data-operator-error]");
try {
const provisioning = await adminApi.createOperator({
username,
password,
role,
});
form.reset();
dialog?.close();
await showProvisioning(container, provisioning, "管理员已创建");
await renderSecurity(container, currentUsername);
} catch (error) {
if (errorNode) errorNode.textContent = errorMessage(error, "创建管理员失败");
setButtonBusy(submit, false);
}
}
async function handleOperatorAction(
container: HTMLElement,
button: HTMLButtonElement,
operator: AdminOperator,
resetDialog: HTMLDialogElement | null,
resetForm: HTMLFormElement | null,
currentUsername: string,
): Promise<void> {
const action = button.dataset.operatorAction;
if (action === "reset" && resetDialog && resetForm) {
resetForm.reset();
resetForm.dataset.operatorId = operator.operatorId;
const username = resetForm.querySelector<HTMLElement>("[data-reset-username]");
if (username) username.textContent = operator.username;
clearError(resetForm, "[data-reset-error]");
resetDialog.showModal();
resetForm.querySelector<HTMLInputElement>('input[name="password"]')?.focus();
return;
}
const confirmation = actionConfirmation(action, operator.username);
if (!confirmation || !(await confirmAction(container, confirmation))) return;
setButtonBusy(button, true);
try {
if (action === "enable") {
await adminApi.setOperatorEnabled(operator.operatorId, true);
} else if (action === "disable") {
await adminApi.setOperatorEnabled(operator.operatorId, false);
} else if (action === "unlock") {
await adminApi.unlockOperator(operator.operatorId);
} else if (action === "sessions") {
await adminApi.revokeOperatorSessions(operator.operatorId);
}
showToast("安全设置已更新", "success");
await renderSecurity(container, currentUsername);
} catch (error) {
showToast(errorMessage(error, "安全设置更新失败"), "error");
setButtonBusy(button, false);
}
}
async function resetCredentials(
container: HTMLElement,
dialog: HTMLDialogElement | null,
form: HTMLFormElement,
currentUsername: string,
): Promise<void> {
const operatorId = form.dataset.operatorId;
const data = new FormData(form);
const password = data.get("password")?.toString() ?? "";
const confirmation = data.get("passwordConfirmation")?.toString() ?? "";
const errorNode = form.querySelector<HTMLElement>("[data-reset-error]");
const submit = form.querySelector<HTMLButtonElement>('button[type="submit"]');
if (!operatorId || password.length < 12 || password !== confirmation || !submit) {
if (errorNode) errorNode.textContent = "请输入至少 12 位且两次一致的新密码";
return;
}
setButtonBusy(submit, true, "重置中…");
clearError(form, "[data-reset-error]");
try {
const provisioning = await adminApi.resetOperatorCredentials(
operatorId,
password,
);
form.reset();
dialog?.close();
await showProvisioning(container, provisioning, "登录凭据已重置");
await renderSecurity(container, currentUsername);
} catch (error) {
if (errorNode) errorNode.textContent = errorMessage(error, "凭据重置失败");
setButtonBusy(submit, false);
}
}
interface Confirmation {
title: string;
message: string;
label: string;
dangerous?: boolean;
}
function actionConfirmation(
action: string | undefined,
username: string,
): Confirmation | null {
const target = `${username}`;
if (action === "disable") {
return {
title: "停用管理员?",
message: `${target} 将无法登录,全部活动会话会立即失效。`,
label: "停用管理员",
dangerous: true,
};
}
if (action === "enable") {
return {
title: "启用管理员?",
message: `${target} 将恢复登录权限。`,
label: "确认启用",
};
}
if (action === "unlock") {
return {
title: "解除登录锁定?",
message: `${target} 可以立即重新尝试登录。`,
label: "确认解锁",
};
}
if (action === "sessions") {
return {
title: "撤销全部会话?",
message: `${target} 已登录的所有设备都需要重新认证。`,
label: "撤销会话",
dangerous: true,
};
}
return null;
}
function confirmAction(
container: HTMLElement,
confirmation: Confirmation,
): Promise<boolean> {
return new Promise((resolve) => {
const dialog = document.createElement("dialog");
dialog.className = "dialog";
dialog.setAttribute("aria-labelledby", "confirm-action-title");
dialog.innerHTML = `
<form method="dialog" class="dialog-card">
<div class="eyebrow">确认操作</div>
<h2 id="confirm-action-title">${escapeHtml(confirmation.title)}</h2>
<p>${escapeHtml(confirmation.message)}</p>
<div class="dialog-actions">
<button class="button button--secondary" value="cancel">取消</button>
<button class="button ${confirmation.dangerous ? "button--danger" : "button--primary"}" value="confirm">${escapeHtml(confirmation.label)}</button>
</div>
</form>
`;
container.append(dialog);
dialog.addEventListener(
"close",
() => {
const confirmed = dialog.returnValue === "confirm";
dialog.remove();
resolve(confirmed);
},
{ once: true },
);
dialog.showModal();
});
}
function showProvisioning(
container: HTMLElement,
provisioning: AdminOperatorProvisioning,
title: string,
): Promise<void> {
return new Promise((resolve) => {
const dialog = document.createElement("dialog");
dialog.className = "dialog dialog--wide";
dialog.setAttribute("aria-labelledby", "provisioning-title");
dialog.innerHTML = `
<div class="dialog-card provisioning-card">
<div class="eyebrow">仅显示一次</div>
<h2 id="provisioning-title">${escapeHtml(title)}</h2>
<p>请立即把 TOTP 密钥交给对应管理员,并确认已添加到认证器。</p>
<div class="secret-panel">
<span>Base32 密钥</span>
<code>${escapeHtml(provisioning.totpSecret)}</code>
<button class="button button--secondary" type="button" data-copy-secret>复制密钥</button>
</div>
<div class="dialog-actions dialog-actions--spread">
<a class="button button--secondary" href="${escapeHtml(provisioning.otpauthUri)}">在认证器中打开</a>
<button class="button button--secondary" type="button" data-copy-uri>复制配置链接</button>
</div>
<label class="confirmation-check">
<input type="checkbox" data-provisioning-saved />
<span>我已安全保存密钥,理解关闭后无法再次查看</span>
</label>
<button class="button button--primary button--wide" type="button" data-close-provisioning disabled>完成</button>
</div>
`;
const close = dialog.querySelector<HTMLButtonElement>("[data-close-provisioning]");
dialog
.querySelector<HTMLInputElement>("[data-provisioning-saved]")
?.addEventListener("change", (event) => {
if (close) close.disabled = !(event.target as HTMLInputElement).checked;
});
dialog.addEventListener("cancel", (event) => event.preventDefault());
dialog
.querySelector<HTMLButtonElement>("[data-copy-secret]")
?.addEventListener("click", () => {
void copySensitiveValue(provisioning.totpSecret, "TOTP 密钥");
});
dialog
.querySelector<HTMLButtonElement>("[data-copy-uri]")
?.addEventListener("click", () => {
void copySensitiveValue(provisioning.otpauthUri, "认证器配置链接");
});
close?.addEventListener("click", () => dialog.close());
dialog.addEventListener(
"close",
() => {
dialog.remove();
resolve();
},
{ once: true },
);
container.append(dialog);
dialog.showModal();
});
}
async function copySensitiveValue(value: string, label: string): Promise<void> {
try {
await navigator.clipboard.writeText(value);
showToast(`${label}已复制,请妥善保管`, "success");
} catch {
showToast(`无法复制${label},请手动选择`, "error");
}
}
function clearError(
container: ParentNode | null,
selector: string,
): void {
const node = container?.querySelector<HTMLElement>(selector);
if (node) node.textContent = "";
}
function errorMessage(error: unknown, fallback: string): string {
return error instanceof ApiError ? error.message : fallback;
}
function roleLabel(role: AdminRole): string {
const labels: Record<AdminRole, string> = {
SUPER_ADMIN: "超级管理员",
SUPPORT: "支持人员",
ANALYST: "分析员",
};
return labels[role];
}
+454
View File
@@ -0,0 +1,454 @@
import { adminApi, ApiError } from "../api/client";
import type {
AdminRole,
LedgerEntry,
PageResult,
UserDetail,
UserSummary,
UserUsageAggregate,
} from "../api/types";
import {
renderEmpty,
renderError,
renderLoading,
setButtonBusy,
showToast,
} from "../components/ui";
import {
createIdempotencyKey,
escapeHtml,
formatDateTime,
formatNumber,
formatSignedCredits,
statusLabel,
} from "../lib/format";
export function renderUsers(container: HTMLElement, role: AdminRole): void {
container.innerHTML = `
<div class="page-heading">
<div>
<div class="eyebrow">账户管理</div>
<h1>用户查询</h1>
<p>按内部用户 ID 精确查询,不展示 Apple 身份标识。</p>
</div>
</div>
<section class="panel">
<form class="search-form" data-search-form>
<label class="search-box">
<span class="sr-only">内部用户 ID</span>
<span aria-hidden="true">⌕</span>
<input name="query" type="search" placeholder="输入完整内部用户 ID" autocomplete="off" maxlength="36" required />
</label>
<button class="button button--primary" type="submit">搜索</button>
</form>
<div data-results>${renderEmpty("输入查询条件开始搜索")}</div>
</section>
`;
const form = container.querySelector<HTMLFormElement>("[data-search-form]");
form?.addEventListener("submit", (event) => {
event.preventDefault();
const search = new FormData(form).get("query")?.toString().trim() ?? "";
if (search) void searchUsers(container, search, role);
});
}
async function searchUsers(
container: HTMLElement,
search: string,
role: AdminRole,
): Promise<void> {
const results = container.querySelector<HTMLElement>("[data-results]");
if (!results) return;
renderLoading(results, "搜索用户");
try {
const page = await adminApi.users(search);
if (page.items.length === 0) {
results.innerHTML = renderEmpty("未找到匹配用户");
return;
}
results.innerHTML = `
<div class="table-wrap">
<table>
<caption class="sr-only">用户查询结果</caption>
<thead><tr><th scope="col">用户</th><th scope="col">状态</th><th scope="col">积分余额</th><th scope="col">注册时间</th><th scope="col">操作</th></tr></thead>
<tbody>
${page.items.map(userRow).join("")}
</tbody>
</table>
</div>
`;
results.querySelectorAll<HTMLButtonElement>("[data-user-id]").forEach(
(button) => {
button.addEventListener("click", () => {
const userId = button.dataset.userId;
if (userId) void renderUserDetail(container, userId, role);
});
},
);
} catch (error) {
renderError(results, error, () => void searchUsers(container, search, role));
}
}
function userRow(user: UserSummary): string {
return `
<tr>
<td>
<strong>${escapeHtml(user.displayName || "未命名用户")}</strong>
<div class="subtle mono">${escapeHtml(user.userId)}</div>
</td>
<td><span class="badge badge--${escapeHtml(user.status)}">${statusLabel(user.status)}</span></td>
<td>${formatNumber(user.creditBalance)}</td>
<td>${formatDateTime(user.createdAt)}</td>
<td><button class="button button--small button--secondary" data-user-id="${escapeHtml(user.userId)}" aria-label="查看 ${escapeHtml(user.displayName || user.userId)}">查看</button></td>
</tr>
`;
}
async function renderUserDetail(
container: HTMLElement,
userId: string,
role: AdminRole,
): Promise<void> {
renderLoading(container, "加载用户详情");
try {
const [user, ledger] = await Promise.all([
adminApi.user(userId),
adminApi.ledger(userId),
]);
container.innerHTML = detailTemplate(user, ledger, role);
container
.querySelector<HTMLButtonElement>("[data-back]")
?.addEventListener("click", () => {
renderUsers(container, role);
container.querySelector<HTMLInputElement>('input[name="query"]')?.focus();
});
bindLedgerPagination(container, user, ledger.nextCursor);
if (role === "SUPER_ADMIN") bindGrantDialog(container, user, role);
const heading = container.querySelector<HTMLElement>("h1");
if (heading) {
heading.tabIndex = -1;
heading.focus({ preventScroll: true });
}
} catch (error) {
renderError(container, error, () =>
void renderUserDetail(container, userId, role),
);
}
}
function detailTemplate(
user: UserDetail,
ledger: PageResult<LedgerEntry>,
role: AdminRole,
): string {
const usage = user.usage ?? [];
const usageRequests = usage.reduce((total, item) => total + item.requests, 0);
const chargedCredits = usage.reduce(
(total, item) => total + item.chargedCredits,
0,
);
const referral = user.referral;
const inviterUserId = referral?.inviterUserId ?? user.referredByUserId;
const qualifiedUsage = user.qualifiedUsage || usageRequests > 0;
return `
<div class="page-heading page-heading--detail">
<div>
<button class="back-link" data-back>← 返回用户查询</button>
<div class="eyebrow">用户详情</div>
<h1>${escapeHtml(user.displayName || "未命名用户")}</h1>
<p class="mono">${escapeHtml(user.userId)}</p>
</div>
${
role === "SUPER_ADMIN"
? '<button class="button button--primary" data-open-grant>人工赠送积分</button>'
: ""
}
</div>
<section class="detail-grid">
<article class="panel profile-card">
<div class="profile-header">
<div class="avatar" aria-hidden="true">${escapeHtml((user.displayName || "用").slice(0, 1))}</div>
<div><strong>${escapeHtml(user.displayName || "未命名用户")}</strong><span class="badge badge--${escapeHtml(user.status)}">${statusLabel(user.status)}</span></div>
</div>
<dl class="detail-list">
<div><dt>积分余额</dt><dd class="credit-value">${formatNumber(user.creditBalance)}</dd></div>
<div><dt>注册时间</dt><dd>${formatDateTime(user.createdAt)}</dd></div>
<div><dt>最近活跃</dt><dd>${formatDateTime(user.lastActiveAt)}</dd></div>
<div><dt>有效使用</dt><dd>${qualifiedUsage ? "已达成" : "未达成"}${usage.length > 0 ? ` · ${formatNumber(usageRequests)} 次请求` : ""}</dd></div>
${
usage.length > 0
? `<div><dt>累计消耗</dt><dd>${formatNumber(chargedCredits)} 积分</dd></div>`
: ""
}
<div><dt>邀请码</dt><dd class="mono">${escapeHtml(user.referralCode || "—")}</dd></div>
<div><dt>邀请来源</dt><dd class="mono">${escapeHtml(inviterUserId || "—")}</dd></div>
${
referral
? `<div><dt>邀请成效</dt><dd>${formatNumber(referral.rewardedInvites)} / ${formatNumber(referral.invitedUsers)} 已奖励</dd></div>`
: ""
}
</dl>
</article>
<section class="panel">
<div class="panel-heading"><div><h2>积分流水</h2><p>不可变账本记录</p></div></div>
${
ledger.items.length === 0
? renderEmpty("暂无积分流水")
: `
<div class="table-wrap">
<table>
<caption class="sr-only">${escapeHtml(user.displayName || user.userId)} 的积分流水</caption>
<thead><tr><th scope="col">时间</th><th scope="col">类型</th><th scope="col">变动</th><th scope="col">结余</th><th scope="col">原因</th></tr></thead>
<tbody data-ledger-body>${ledgerRows(ledger.items)}</tbody>
</table>
</div>
${
ledger.nextCursor
? '<div class="pagination-actions"><button class="button button--secondary" data-ledger-more aria-describedby="ledger-pagination-status">加载更多流水</button></div>'
: ""
}
<p class="sr-only" id="ledger-pagination-status" data-ledger-status role="status" aria-live="polite"></p>
`
}
</section>
${usagePanel(usage)}
</section>
${role === "SUPER_ADMIN" ? grantDialog(user) : ""}
`;
}
function ledgerRows(entries: LedgerEntry[]): string {
return entries
.map(
(entry) => `
<tr>
<td>${formatDateTime(entry.createdAt)}</td>
<td>${escapeHtml(statusLabel(entry.type))}</td>
<td class="${entry.amount >= 0 ? "positive" : "negative"}">${formatSignedCredits(entry.amount)}</td>
<td>${formatNumber(entry.balanceAfter)}</td>
<td>${escapeHtml(entry.reasonCode)}</td>
</tr>
`,
)
.join("");
}
function usagePanel(usage: UserUsageAggregate[]): string {
if (usage.length === 0) return "";
return `
<section class="panel detail-panel--wide">
<div class="panel-heading"><div><h2>使用统计</h2><p>按使用类型汇总,不包含用户内容</p></div></div>
<div class="table-wrap">
<table>
<caption class="sr-only">用户使用统计</caption>
<thead><tr><th scope="col">类型</th><th scope="col">请求</th><th scope="col">消耗积分</th><th scope="col">语音时长(毫秒)</th><th scope="col">输入 Token</th><th scope="col">输出 Token</th></tr></thead>
<tbody>
${usage
.map(
(item) => `
<tr>
<td><code>${escapeHtml(item.kind)}</code></td>
<td>${formatNumber(item.requests)}</td>
<td>${formatNumber(item.chargedCredits)}</td>
<td>${formatNumber(item.asrMillis)}</td>
<td>${formatNumber(item.inputTokens)}</td>
<td>${formatNumber(item.outputTokens)}</td>
</tr>
`,
)
.join("")}
</tbody>
</table>
</div>
</section>
`;
}
function bindLedgerPagination(
container: HTMLElement,
user: UserDetail,
initialCursor?: string,
): void {
const button =
container.querySelector<HTMLButtonElement>("[data-ledger-more]");
const body =
container.querySelector<HTMLTableSectionElement>("[data-ledger-body]");
const status = container.querySelector<HTMLElement>("[data-ledger-status]");
if (!button || !body || !initialCursor) return;
let cursor: string | undefined = initialCursor;
button.addEventListener("click", async () => {
if (!cursor) return;
setButtonBusy(button, true, "加载中…");
try {
const page = await adminApi.ledger(user.userId, cursor);
body.insertAdjacentHTML("beforeend", ledgerRows(page.items));
cursor = page.nextCursor;
if (status) {
status.textContent = cursor
? `已加载 ${formatNumber(page.items.length)} 条更多流水`
: `已加载 ${formatNumber(page.items.length)} 条流水,全部记录已加载`;
}
if (!cursor) {
button.closest(".pagination-actions")?.remove();
} else {
setButtonBusy(button, false);
button.focus();
}
} catch (error) {
showToast(
error instanceof ApiError ? error.message : "加载积分流水失败",
"error",
);
setButtonBusy(button, false);
button.focus();
}
});
}
function grantDialog(user: UserDetail): string {
return `
<dialog class="dialog" data-grant-dialog aria-labelledby="grant-dialog-title">
<form method="dialog" class="dialog-card" data-grant-form>
<div data-grant-inputs>
<div class="eyebrow">高风险操作</div>
<h2 id="grant-dialog-title">人工赠送积分</h2>
<p class="muted">赠送将写入不可变账本,并记录管理员审计日志。</p>
<label><span>用户 ID</span><input value="${escapeHtml(user.userId)}" disabled /></label>
<label><span>赠送积分</span><input name="amount" type="number" inputmode="numeric" min="1" max="100000" step="1" aria-describedby="grant-input-error" required /></label>
<label><span>赠送原因</span><textarea name="reason" minlength="4" maxlength="200" placeholder="请填写可审计的业务原因" aria-describedby="grant-input-error" required></textarea></label>
<p class="form-error" id="grant-input-error" data-error role="alert"></p>
<div class="dialog-actions">
<button class="button button--secondary" value="cancel">取消</button>
<button class="button button--primary" type="button" data-review-grant>下一步</button>
</div>
</div>
<div data-grant-confirm hidden>
<div class="confirm-icon" aria-hidden="true">!</div>
<h2 data-confirm-title tabindex="-1">确认赠送?</h2>
<p>将向 <strong>${escapeHtml(user.displayName || user.userId)}</strong> 赠送 <strong data-confirm-amount></strong> 积分。</p>
<p class="confirm-reason" data-confirm-reason></p>
<p class="form-error" id="grant-confirm-error" data-confirm-error role="alert"></p>
<div class="dialog-actions">
<button class="button button--secondary" type="button" data-edit-grant>返回修改</button>
<button class="button button--danger" type="button" data-submit-grant aria-describedby="grant-confirm-error">确认赠送</button>
</div>
</div>
</form>
</dialog>
`;
}
function bindGrantDialog(
container: HTMLElement,
user: UserDetail,
role: AdminRole,
): void {
const dialog = container.querySelector<HTMLDialogElement>("[data-grant-dialog]");
const form = container.querySelector<HTMLFormElement>("[data-grant-form]");
const inputs = container.querySelector<HTMLElement>("[data-grant-inputs]");
const confirm = container.querySelector<HTMLElement>("[data-grant-confirm]");
if (!dialog || !form || !inputs || !confirm) return;
let idempotencyKey = createIdempotencyKey();
let outcomeUnknown = false;
container
.querySelector<HTMLButtonElement>("[data-open-grant]")
?.addEventListener("click", () => {
idempotencyKey = createIdempotencyKey();
outcomeUnknown = false;
form.reset();
inputs.hidden = false;
confirm.hidden = true;
form.querySelectorAll<HTMLElement>(".form-error").forEach((node) => {
node.textContent = "";
});
dialog.showModal();
form.querySelector<HTMLInputElement>('input[name="amount"]')?.focus();
});
dialog.addEventListener("cancel", (event) => {
if (!outcomeUnknown) return;
event.preventDefault();
const errorNode = form.querySelector<HTMLElement>("[data-confirm-error]");
if (errorNode) {
errorNode.textContent = "赠送结果尚未确认,请使用当前窗口安全重试";
}
});
container
.querySelector<HTMLButtonElement>("[data-review-grant]")
?.addEventListener("click", () => {
const data = new FormData(form);
const amount = Number(data.get("amount"));
const reason = data.get("reason")?.toString().trim() ?? "";
const errorNode = form.querySelector<HTMLElement>("[data-error]");
if (!Number.isSafeInteger(amount) || amount < 1 || amount > 100_000) {
if (errorNode) errorNode.textContent = "积分必须是 1 至 100,000 的整数";
return;
}
if (reason.length < 4) {
if (errorNode) errorNode.textContent = "请填写至少 4 个字符的赠送原因";
return;
}
if (errorNode) errorNode.textContent = "";
const amountNode = form.querySelector<HTMLElement>("[data-confirm-amount]");
const reasonNode = form.querySelector<HTMLElement>("[data-confirm-reason]");
if (amountNode) amountNode.textContent = formatNumber(amount);
if (reasonNode) reasonNode.textContent = `原因:${reason}`;
inputs.hidden = true;
confirm.hidden = false;
form.querySelector<HTMLElement>("[data-confirm-title]")?.focus();
});
container
.querySelector<HTMLButtonElement>("[data-edit-grant]")
?.addEventListener("click", () => {
if (outcomeUnknown) {
const errorNode = form.querySelector<HTMLElement>("[data-confirm-error]");
if (errorNode) {
errorNode.textContent = "结果尚未确认,不能修改本次赠送内容;请直接重试";
}
return;
}
confirm.hidden = true;
inputs.hidden = false;
form.querySelector<HTMLInputElement>('input[name="amount"]')?.focus();
});
const submit = container.querySelector<HTMLButtonElement>("[data-submit-grant]");
submit?.addEventListener("click", async () => {
const data = new FormData(form);
const amount = Number(data.get("amount"));
const reason = data.get("reason")?.toString().trim() ?? "";
const errorNode = form.querySelector<HTMLElement>("[data-confirm-error]");
setButtonBusy(submit, true, "赠送中…");
if (errorNode) errorNode.textContent = "";
try {
await adminApi.grantCredits({
userId: user.userId,
amount,
reason,
idempotencyKey,
});
dialog.close();
showToast("积分赠送成功,账本已更新", "success");
await renderUserDetail(container, user.userId, role);
} catch (error) {
const message =
error instanceof ApiError ? error.message : "赠送失败,请重试";
outcomeUnknown =
!(error instanceof ApiError) || error.status === 0 || error.status >= 500;
if (errorNode) {
errorNode.textContent = outcomeUnknown
? "赠送结果尚未确认;重试会复用同一请求,不会重复到账"
: message;
}
setButtonBusy(submit, false);
}
});
}
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { funnelChart, trendChart } from "../components/charts";
describe("accessible charts", () => {
it("provides a titled SVG and complete data table for trends", () => {
const html = trendChart([
{ date: "2026-08-16", registrations: 12, creditsUsed: 345 },
]);
expect(html).toContain('aria-labelledby="trend-chart-title trend-chart-description"');
expect(html).toContain("<caption>新增用户与积分消耗趋势完整数据</caption>");
expect(html).toContain("<td>345</td>");
});
it("renders the funnel without CSP-sensitive inline styles", () => {
const html = funnelChart([
{ label: "访问", count: 20 },
{ label: "注册", count: 10 },
]);
expect(html).toContain("<progress");
expect(html).not.toContain('style="');
});
});
+192
View File
@@ -0,0 +1,192 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { adminApi, ApiError, setCsrfToken } from "../api/client";
describe("adminApi", () => {
afterEach(() => {
vi.unstubAllGlobals();
setCsrfToken();
});
it("会话恢复不依赖 csrfToken,且所有请求都携带同源凭据", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
authenticated: true,
operatorName: "owner",
role: "SUPER_ADMIN",
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
);
vi.stubGlobal("fetch", fetchMock);
const session = await adminApi.session();
expect(fetchMock).toHaveBeenCalledOnce();
expect(session).toEqual({
authenticated: true,
operatorName: "owner",
role: "SUPER_ADMIN",
});
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
credentials: "include",
});
});
it("积分赠送携带 CSRF 与幂等请求头", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({ transactionId: "tx-1", balanceAfter: 120 }),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
);
vi.stubGlobal("fetch", fetchMock);
setCsrfToken("csrf-test");
await adminApi.grantCredits({
userId: "user-1",
amount: 20,
reason: "客服补偿",
idempotencyKey: "grant-1",
});
const request = fetchMock.mock.calls[0]?.[1] as RequestInit;
const headers = request.headers as Headers;
expect(headers.get("X-CSRF-Token")).toBe("csrf-test");
expect(headers.get("Idempotency-Key")).toBe("grant-1");
expect(request.credentials).toBe("include");
});
it("登录请求不依赖已有会话 CSRF", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
operatorName: "owner",
role: "SUPER_ADMIN",
csrfToken: "new-csrf",
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
);
vi.stubGlobal("fetch", fetchMock);
const response = await adminApi.login(
"owner",
"a-strong-password",
"123456",
);
const request = fetchMock.mock.calls[0]?.[1] as RequestInit;
expect((request.headers as Headers).has("X-CSRF-Token")).toBe(false);
expect(response.csrfToken).toBe("new-csrf");
});
it("流水与管理员列表把 cursor 安全传入查询参数", async () => {
const fetchMock = vi.fn().mockImplementation(async () => {
return new Response(JSON.stringify({ items: [] }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
});
vi.stubGlobal("fetch", fetchMock);
await adminApi.ledger("user/with space", "ledger+/=");
await adminApi.operators("operator+/=");
expect(fetchMock.mock.calls[0]?.[0]).toBe(
"/v1/admin/users/user%2Fwith%20space/ledger?cursor=ledger%2B%2F%3D",
);
expect(fetchMock.mock.calls[1]?.[0]).toBe(
"/v1/admin/operators?cursor=operator%2B%2F%3D",
);
});
it("缺少 CSRF 时在发送变更请求前失败", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
await expect(adminApi.logout()).rejects.toMatchObject({
code: "CSRF_TOKEN_MISSING",
status: 403,
});
expect(fetchMock).not.toHaveBeenCalled();
});
it("服务错误转换为稳定 ApiError", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response(JSON.stringify({ code: "RATE_LIMITED" }), {
status: 429,
headers: { "Content-Type": "application/json" },
}),
),
);
const promise = adminApi.overview("30d");
await expect(promise).rejects.toBeInstanceOf(ApiError);
await expect(promise).rejects.toMatchObject({
code: "RATE_LIMITED",
message: "操作过于频繁,请稍后再试",
status: 429,
});
});
it("幂等冲突显示可操作的审计提示", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response(JSON.stringify({ code: "IDEMPOTENCY_CONFLICT" }), {
status: 409,
headers: { "Content-Type": "application/json" },
}),
),
);
setCsrfToken("csrf-test");
await expect(
adminApi.grantCredits({
userId: "user-1",
amount: 20,
reason: "客服补偿",
idempotencyKey: "grant-conflict-1",
}),
).rejects.toMatchObject({
code: "IDEMPOTENCY_CONFLICT",
message: "该赠送请求与已有记录冲突,请核对审计日志",
status: 409,
});
});
it("创建管理员携带 CSRF 并返回一次性 TOTP 配置", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
operatorId: "operator-1",
totpSecret: "JBSWY3DPEHPK3PXP",
otpauthUri: "otpauth://totp/OSG:operator",
}),
{ status: 201, headers: { "Content-Type": "application/json" } },
),
);
vi.stubGlobal("fetch", fetchMock);
setCsrfToken("csrf-test");
const result = await adminApi.createOperator({
username: "support",
password: "a-strong-password",
role: "SUPPORT",
});
const request = fetchMock.mock.calls[0]?.[1] as RequestInit;
expect((request.headers as Headers).get("X-CSRF-Token")).toBe("csrf-test");
expect(request.body).toBe(
JSON.stringify({
username: "support",
password: "a-strong-password",
role: "SUPPORT",
}),
);
expect(result.totpSecret).toBe("JBSWY3DPEHPK3PXP");
});
});
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import {
escapeHtml,
formatDateTime,
formatSignedCredits,
statusLabel,
} from "../lib/format";
describe("format helpers", () => {
it("转义服务端文本以阻止 HTML 注入", () => {
expect(escapeHtml('<img src=x onerror="alert(1)">')).toBe(
"&lt;img src=x onerror=&quot;alert(1)&quot;&gt;",
);
});
it("无效日期显示占位符", () => {
expect(formatDateTime("not-a-date")).toBe("—");
expect(formatDateTime()).toBe("—");
});
it("积分变动保留明确正负号", () => {
expect(formatSignedCredits(15)).toBe("+15");
expect(formatSignedCredits(-8)).toBe("-8");
});
it("未知状态保持原值", () => {
expect(statusLabel("custom")).toBe("custom");
});
});
+191
View File
@@ -0,0 +1,191 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { adminApi } from "../api/client";
import type {
AdminOperator,
AdminSecuritySummary,
UserDetail,
UserSummary,
} from "../api/types";
import { renderSecurity } from "../pages/security";
import { renderUsers } from "../pages/users";
const userId = "11111111-1111-4111-8111-111111111111";
const userSummary: UserSummary = {
userId,
displayName: "测试用户",
status: "active",
creditBalance: 120,
createdAt: "2026-08-01T08:00:00Z",
};
const userDetail: UserDetail = {
...userSummary,
lastActiveAt: "2026-08-16T08:00:00Z",
qualifiedUsage: true,
referralCode: "OSG-TEST",
usage: [
{
kind: "ASR",
requests: 3,
chargedCredits: 18,
asrMillis: 12_000,
inputTokens: 0,
outputTokens: 0,
},
],
referral: {
inviterUserId: "22222222-2222-4222-8222-222222222222",
invitedUsers: 4,
rewardedInvites: 2,
},
};
afterEach(() => {
vi.restoreAllMocks();
document.body.replaceChildren();
});
describe("用户页", () => {
it("支持人员可查看详情但不显示人工赠送", async () => {
mockUserRequests();
vi.spyOn(adminApi, "ledger").mockResolvedValue({ items: [] });
const container = document.createElement("main");
document.body.append(container);
await openUserDetail(container, "SUPPORT");
expect(container.querySelector("[data-open-grant]")).toBeNull();
expect(container.querySelector("[data-grant-dialog]")).toBeNull();
});
it("超级管理员可赠送,并展示 usage/referral 与游标流水", async () => {
mockUserRequests();
vi.spyOn(adminApi, "ledger")
.mockResolvedValueOnce({
items: [
{
entryId: "ledger-1",
type: "grant",
amount: 100,
balanceAfter: 100,
reasonCode: "SIGNUP_TRIAL",
createdAt: "2026-08-01T08:00:00Z",
},
],
nextCursor: "ledger-next",
})
.mockResolvedValueOnce({
items: [
{
entryId: "ledger-2",
type: "settle",
amount: -18,
balanceAfter: 82,
reasonCode: "USAGE_SETTLE",
createdAt: "2026-08-02T08:00:00Z",
},
],
});
const container = document.createElement("main");
document.body.append(container);
await openUserDetail(container, "SUPER_ADMIN");
expect(container.querySelector("[data-open-grant]")).not.toBeNull();
expect(container.textContent).toContain("OSG-TEST");
expect(container.textContent).toContain("3 次请求");
expect(container.textContent).toContain("18 积分");
expect(container.textContent).toContain("2 / 4 已奖励");
expect(container.textContent).toContain(
"22222222-2222-4222-8222-222222222222",
);
container.querySelector<HTMLButtonElement>("[data-ledger-more]")?.click();
await vi.waitFor(() => {
expect(adminApi.ledger).toHaveBeenNthCalledWith(
2,
userId,
"ledger-next",
);
expect(container.querySelectorAll("[data-ledger-body] tr")).toHaveLength(2);
});
expect(container.querySelector("[data-ledger-more]")).toBeNull();
expect(container.querySelector("[data-ledger-status]")?.textContent).toContain(
"全部记录已加载",
);
});
});
describe("安全中心", () => {
it("按后端 nextCursor 加载更多管理员", async () => {
const firstOperator = operator("operator-1", "owner");
const secondOperator = operator("operator-2", "support");
vi.spyOn(adminApi, "operators")
.mockResolvedValueOnce({
items: [firstOperator],
nextCursor: "operator-next",
})
.mockResolvedValueOnce({ items: [secondOperator] });
vi.spyOn(adminApi, "operatorSummary").mockResolvedValue(summary());
const container = document.createElement("main");
document.body.append(container);
await renderSecurity(container, "owner");
container.querySelector<HTMLButtonElement>("[data-operator-more]")?.click();
await vi.waitFor(() => {
expect(adminApi.operators).toHaveBeenNthCalledWith(2, "operator-next");
expect(container.querySelectorAll("[data-operator-body] tr")).toHaveLength(2);
});
expect(container.querySelector("[data-operator-count]")?.textContent).toBe(
"已加载 2 个账户",
);
expect(container.querySelector("[data-operator-more]")).toBeNull();
expect(
container.querySelector("[data-operator-status]")?.textContent,
).toContain("全部账户已加载");
});
});
function mockUserRequests(): void {
vi.spyOn(adminApi, "users").mockResolvedValue({ items: [userSummary] });
vi.spyOn(adminApi, "user").mockResolvedValue(userDetail);
}
async function openUserDetail(
container: HTMLElement,
role: "SUPER_ADMIN" | "SUPPORT",
): Promise<void> {
renderUsers(container, role);
const input = container.querySelector<HTMLInputElement>('input[name="query"]');
const form = container.querySelector<HTMLFormElement>("[data-search-form]");
if (!input || !form) throw new Error("用户查询表单未渲染");
input.value = userId;
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
await vi.waitFor(() => {
expect(container.querySelector("[data-user-id]")).not.toBeNull();
});
container.querySelector<HTMLButtonElement>("[data-user-id]")?.click();
await vi.waitFor(() => {
expect(container.querySelector("h1")?.textContent).toBe("测试用户");
});
}
function operator(operatorId: string, username: string): AdminOperator {
return {
operatorId,
username,
role: username === "owner" ? "SUPER_ADMIN" : "SUPPORT",
enabled: true,
failedLoginCount: 0,
createdAt: "2026-08-01T08:00:00Z",
updatedAt: "2026-08-01T08:00:00Z",
};
}
function summary(): AdminSecuritySummary {
return {
enabledOperators: 2,
lockedOperators: 0,
activeSessions: 1,
};
}
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"types": ["node"],
"strict": true,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"resolveJsonModule": true,
"isolatedModules": true,
"verbatimModuleSyntax": true
},
"include": ["src", "vite.config.ts"]
}
+19
View File
@@ -0,0 +1,19 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
base: "/admin/",
server: {
proxy: {
"/v1/admin": {
target: "http://localhost:8080",
changeOrigin: true,
},
},
},
build: {
target: "es2022",
},
test: {
environment: "jsdom",
},
});
+60 -1
View File
@@ -20,6 +20,50 @@ application {
mainClass.set("io.ktor.server.netty.EngineMain") mainClass.set("io.ktor.server.netty.EngineMain")
} }
val adminWebDirectory = layout.projectDirectory.dir("admin-web")
val adminWebInstall = tasks.register<Exec>("adminWebInstall") {
workingDir(adminWebDirectory)
inputs.files(
adminWebDirectory.file("package.json"),
adminWebDirectory.file("package-lock.json"),
)
outputs.dir(adminWebDirectory.dir("node_modules"))
commandLine("npm", "ci", "--no-audit", "--no-fund")
}
val adminWebBuild = tasks.register<Exec>("adminWebBuild") {
dependsOn(adminWebInstall)
workingDir(adminWebDirectory)
inputs.files(
adminWebDirectory.file("index.html"),
adminWebDirectory.file("tsconfig.json"),
adminWebDirectory.file("vite.config.ts"),
)
inputs.dir(adminWebDirectory.dir("src"))
outputs.dir(adminWebDirectory.dir("dist"))
commandLine("npm", "run", "build")
}
val adminWebTest = tasks.register<Exec>("adminWebTest") {
dependsOn(adminWebInstall)
workingDir(adminWebDirectory)
inputs.files(
adminWebDirectory.file("tsconfig.json"),
adminWebDirectory.file("vite.config.ts"),
)
inputs.dir(adminWebDirectory.dir("src"))
commandLine("npm", "run", "test")
}
val adminWebResources = tasks.register<Sync>("adminWebResources") {
dependsOn(adminWebBuild)
from(adminWebDirectory.dir("dist"))
into(layout.buildDirectory.dir("generated/admin-web/admin"))
}
sourceSets {
main {
resources.srcDir(layout.buildDirectory.dir("generated/admin-web"))
}
}
kotlin { kotlin {
jvmToolchain(21) jvmToolchain(21)
compilerOptions { compilerOptions {
@@ -40,7 +84,10 @@ dependencies {
implementation("io.ktor:ktor-server-auth-jvm:$ktorVersion") implementation("io.ktor:ktor-server-auth-jvm:$ktorVersion")
implementation("io.ktor:ktor-server-auth-jwt-jvm:$ktorVersion") implementation("io.ktor:ktor-server-auth-jwt-jvm:$ktorVersion")
implementation("io.ktor:ktor-server-status-pages-jvm:$ktorVersion") implementation("io.ktor:ktor-server-status-pages-jvm:$ktorVersion")
implementation("io.ktor:ktor-server-call-logging-jvm:$ktorVersion") implementation("io.ktor:ktor-server-call-logging-jvm:$ktorVersion") {
// Jansi is Windows-only console coloring here; excluding it keeps /tmp noexec-safe.
exclude(group = "org.fusesource.jansi", module = "jansi")
}
implementation("io.ktor:ktor-server-rate-limit-jvm:$ktorVersion") implementation("io.ktor:ktor-server-rate-limit-jvm:$ktorVersion")
implementation("io.ktor:ktor-server-websockets-jvm:$ktorVersion") implementation("io.ktor:ktor-server-websockets-jvm:$ktorVersion")
implementation("io.ktor:ktor-server-forwarded-header-jvm:$ktorVersion") implementation("io.ktor:ktor-server-forwarded-header-jvm:$ktorVersion")
@@ -85,6 +132,7 @@ dependencies {
} }
tasks.test { tasks.test {
dependsOn(adminWebTest)
useJUnitPlatform() useJUnitPlatform()
testLogging { testLogging {
events("failed", "skipped") events("failed", "skipped")
@@ -93,6 +141,17 @@ tasks.test {
finalizedBy(tasks.jacocoTestReport) finalizedBy(tasks.jacocoTestReport)
} }
tasks.processResources {
dependsOn(adminWebResources)
}
tasks.register<JavaExec>("generateAdminCredentials") {
group = "deployment"
description = "Generate private admin bootstrap and operator handoff files"
classpath = sourceSets["main"].runtimeClasspath
mainClass.set("com.osglab.account.tools.AdminCredentialGenerator")
}
jacoco { jacoco {
toolVersion = "0.8.15" toolVersion = "0.8.15"
} }
+621 -1
View File
@@ -412,12 +412,345 @@ paths:
"200": { description: Bilingual HTML landing page } "200": { description: Bilingual HTML landing page }
"404": { description: Invalid, unknown, or expired invitation } "404": { description: Invalid, unknown, or expired invitation }
"503": { description: Invitation lookup is temporarily unavailable } "503": { description: Invitation lookup is temporarily unavailable }
/v1/admin/auth/session:
get:
security:
- adminMtls: []
summary: Check the current administrator session
responses:
"200":
description: Authenticated or anonymous session state
content:
application/json:
schema: { $ref: "#/components/schemas/AdminSessionState" }
"404": { description: Verified administrator client certificate is absent }
/v1/admin/auth/login:
post:
security:
- adminMtls: []
summary: Authenticate an administrator with password and TOTP
requestBody:
required: true
content:
application/json:
schema:
type: object
additionalProperties: false
required: [username, password, totpCode]
properties:
username: { type: string, minLength: 3, maxLength: 64 }
password: { type: string, minLength: 1, maxLength: 1024 }
totpCode: { type: string, pattern: "^[0-9]{6}$" }
responses:
"200":
description: Secure session and CSRF cookies created
content:
application/json:
schema: { $ref: "#/components/schemas/AdminLoginResponse" }
"401": { description: Credentials are invalid }
"429": { description: Login is locked or rate limited }
/v1/admin/auth/logout:
post:
security:
- adminMtls: []
adminSession: []
summary: Revoke the current administrator session
parameters:
- $ref: "#/components/parameters/AdminCsrf"
responses:
"204": { description: Session revoked }
"401": { description: Session is invalid }
/v1/admin/overview:
get:
security:
- adminMtls: []
adminSession: []
summary: Return registration, activity, and credit overview statistics
parameters:
- $ref: "#/components/parameters/AdminRange"
responses:
"200":
description: Overview statistics
content:
application/json:
schema: { $ref: "#/components/schemas/AdminOverview" }
"400": { description: Range is invalid }
"401": { description: Session is invalid }
/v1/admin/referrals:
get:
security:
- adminMtls: []
adminSession: []
summary: Return referral funnel and ranking statistics
parameters:
- $ref: "#/components/parameters/AdminRange"
responses:
"200":
description: Referral statistics
content:
application/json:
schema: { $ref: "#/components/schemas/AdminReferralOverview" }
"400": { description: Range is invalid }
"401": { description: Session is invalid }
/v1/admin/users:
get:
security:
- adminMtls: []
adminSession: []
summary: List users or search by exact internal user ID
parameters:
- name: q
in: query
schema: { type: string, maxLength: 36 }
- name: cursor
in: query
schema: { type: string, maxLength: 256 }
- $ref: "#/components/parameters/Limit"
responses:
"200":
description: Privacy-minimized user summaries
content:
application/json:
schema: { $ref: "#/components/schemas/AdminUserPage" }
"400": { description: Query or cursor is malformed }
"401": { description: Session is invalid }
"403": { description: ANALYST role cannot access user records }
/v1/admin/users/{userId}:
get:
security:
- adminMtls: []
adminSession: []
summary: Return privacy-minimized user details
parameters:
- $ref: "#/components/parameters/AdminUserId"
responses:
"200":
description: User details
content:
application/json:
schema: { $ref: "#/components/schemas/AdminUserDetail" }
"403": { description: ANALYST role cannot access user records }
"404": { description: User was not found }
/v1/admin/users/{userId}/ledger:
get:
security:
- adminMtls: []
adminSession: []
summary: Return the immutable credit ledger for a user
parameters:
- $ref: "#/components/parameters/AdminUserId"
- name: cursor
in: query
schema: { type: string, maxLength: 256 }
- $ref: "#/components/parameters/Limit"
responses:
"200":
description: Credit ledger entries ordered by creation time and entry ID
content:
application/json:
schema: { $ref: "#/components/schemas/AdminLedgerPage" }
"400": { description: Cursor is malformed }
"403": { description: ANALYST role cannot access credit ledger records }
"404": { description: User was not found }
/v1/admin/credits/grants:
post:
security:
- adminMtls: []
adminSession: []
summary: Grant integer credits through an idempotent ledger transaction
parameters:
- $ref: "#/components/parameters/AdminCsrf"
- $ref: "#/components/parameters/IdempotencyKey"
requestBody:
required: true
content:
application/json:
schema:
type: object
additionalProperties: false
required: [userId, amount, reason]
properties:
userId: { type: string, format: uuid }
amount: { type: integer, format: int64, minimum: 1 }
reason: { type: string, minLength: 4, maxLength: 200 }
responses:
"200":
description: Grant applied or replayed
content:
application/json:
schema: { $ref: "#/components/schemas/AdminGrantResponse" }
"400": { description: Grant input or idempotency key is invalid }
"403": { description: CSRF or role authorization failed }
"404": { description: Target user was not found }
"409": { description: Idempotency key conflicts with another grant }
/v1/admin/operators/summary:
get:
security:
- adminMtls: []
adminSession: []
summary: Return administrator and active-session security indicators
responses:
"200":
description: Security indicators
content:
application/json:
schema:
type: object
additionalProperties: false
required: [enabledOperators, lockedOperators, activeSessions]
properties:
enabledOperators: { type: integer, minimum: 0 }
lockedOperators: { type: integer, minimum: 0 }
activeSessions: { type: integer, format: int64, minimum: 0 }
"403": { description: INSUFFICIENT_PERMISSION; SUPER_ADMIN is required }
/v1/admin/operators:
get:
security:
- adminMtls: []
adminSession: []
summary: List administrator operators
parameters:
- name: cursor
in: query
schema: { type: string, maxLength: 256 }
- $ref: "#/components/parameters/Limit"
responses:
"200":
description: Operators ordered by creation time
content:
application/json:
schema: { $ref: "#/components/schemas/AdminOperatorPage" }
"403": { description: INSUFFICIENT_PERMISSION; SUPER_ADMIN is required }
"400": { description: Cursor or limit is malformed }
post:
security:
- adminMtls: []
adminSession: []
summary: Create an operator and return TOTP provisioning data once
parameters:
- $ref: "#/components/parameters/AdminCsrf"
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/AdminOperatorCreateRequest" }
responses:
"201":
description: Operator created; plaintext TOTP material is returned only here
content:
application/json:
schema: { $ref: "#/components/schemas/AdminOperatorProvisioning" }
"400": { description: VALIDATION_ERROR }
"403": { description: CSRF_INVALID, ORIGIN_INVALID, or INSUFFICIENT_PERMISSION }
"409": { description: ADMIN_USERNAME_CONFLICT }
/v1/admin/operators/{operatorId}/enable:
post:
security:
- adminMtls: []
adminSession: []
summary: Enable an operator
parameters:
- $ref: "#/components/parameters/AdminOperatorId"
- $ref: "#/components/parameters/AdminCsrf"
responses:
"204": { description: Operator enabled and action audited }
"403": { description: CSRF_INVALID, ORIGIN_INVALID, or INSUFFICIENT_PERMISSION }
"404": { description: ADMIN_OPERATOR_NOT_FOUND }
/v1/admin/operators/{operatorId}/disable:
post:
security:
- adminMtls: []
adminSession: []
summary: Disable an operator and atomically revoke all active sessions
parameters:
- $ref: "#/components/parameters/AdminOperatorId"
- $ref: "#/components/parameters/AdminCsrf"
responses:
"204": { description: Operator disabled and sessions revoked }
"403": { description: CSRF_INVALID, ORIGIN_INVALID, or INSUFFICIENT_PERMISSION }
"404": { description: ADMIN_OPERATOR_NOT_FOUND }
"409": { description: CANNOT_DISABLE_SELF or LAST_SUPER_ADMIN_REQUIRED }
/v1/admin/operators/{operatorId}/unlock:
post:
security:
- adminMtls: []
adminSession: []
summary: Clear an operator login lock
parameters:
- $ref: "#/components/parameters/AdminOperatorId"
- $ref: "#/components/parameters/AdminCsrf"
responses:
"204": { description: Operator unlocked }
"403": { description: CSRF_INVALID, ORIGIN_INVALID, or INSUFFICIENT_PERMISSION }
"404": { description: ADMIN_OPERATOR_NOT_FOUND }
/v1/admin/operators/{operatorId}/credentials/reset:
post:
security:
- adminMtls: []
adminSession: []
summary: Reset password and TOTP, atomically revoking all active sessions
parameters:
- $ref: "#/components/parameters/AdminOperatorId"
- $ref: "#/components/parameters/AdminCsrf"
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/AdminOperatorPasswordRequest" }
responses:
"200":
description: Credentials reset; plaintext TOTP material is returned only here
content:
application/json:
schema: { $ref: "#/components/schemas/AdminOperatorProvisioning" }
"400": { description: VALIDATION_ERROR }
"403": { description: CSRF_INVALID, ORIGIN_INVALID, or INSUFFICIENT_PERMISSION }
"404": { description: ADMIN_OPERATOR_NOT_FOUND }
/v1/admin/operators/{operatorId}/sessions/revoke:
post:
security:
- adminMtls: []
adminSession: []
summary: Revoke every active session for an operator
parameters:
- $ref: "#/components/parameters/AdminOperatorId"
- $ref: "#/components/parameters/AdminCsrf"
responses:
"204": { description: All active sessions revoked }
"403": { description: CSRF_INVALID, ORIGIN_INVALID, or INSUFFICIENT_PERMISSION }
"404": { description: ADMIN_OPERATOR_NOT_FOUND }
/v1/admin/audit:
get:
security:
- adminMtls: []
adminSession: []
summary: Return append-only administrator audit events
parameters:
- name: cursor
in: query
schema: { type: string, maxLength: 256 }
- $ref: "#/components/parameters/Limit"
responses:
"200":
description: Recent audit events
content:
application/json:
schema: { $ref: "#/components/schemas/AdminAuditPage" }
"400": { description: Cursor is malformed }
"403": { description: Super-administrator role is required }
components: components:
securitySchemes: securitySchemes:
bearerAuth: bearerAuth:
type: http type: http
scheme: bearer scheme: bearer
bearerFormat: JWT bearerFormat: JWT
adminMtls:
type: mutualTLS
description: Client certificate issued by the dedicated administrator CA.
adminSession:
type: apiKey
in: cookie
name: osg_admin_session
parameters: parameters:
Limit: Limit:
name: limit name: limit
@@ -432,7 +765,26 @@ components:
name: Idempotency-Key name: Idempotency-Key
in: header in: header
required: true required: true
schema: { type: string, minLength: 1, maxLength: 255 } schema: { type: string, minLength: 8, maxLength: 128 }
AdminCsrf:
name: X-CSRF-Token
in: header
required: true
schema: { type: string, minLength: 32, maxLength: 512 }
AdminRange:
name: range
in: query
schema: { type: string, enum: [7d, 30d, 90d], default: 30d }
AdminUserId:
name: userId
in: path
required: true
schema: { type: string, format: uuid }
AdminOperatorId:
name: operatorId
in: path
required: true
schema: { type: string, format: uuid }
responses: responses:
HealthUp: HealthUp:
description: Service is healthy description: Service is healthy
@@ -454,6 +806,274 @@ components:
application/json: application/json:
schema: { $ref: "#/components/schemas/GatewayError" } schema: { $ref: "#/components/schemas/GatewayError" }
schemas: schemas:
AdminSessionState:
type: object
additionalProperties: false
required: [authenticated]
properties:
authenticated: { type: boolean }
operatorName: { type: ["string", "null"], minLength: 3, maxLength: 64 }
role:
type: ["string", "null"]
enum: [SUPER_ADMIN, SUPPORT, ANALYST, null]
description: CSRF material is intentionally not reconstructed or returned by session checks.
AdminLoginResponse:
type: object
additionalProperties: false
required: [operatorName, role, csrfToken]
properties:
operatorName: { type: string, minLength: 3, maxLength: 64 }
role: { type: string, enum: [SUPER_ADMIN, SUPPORT, ANALYST] }
csrfToken:
type: string
minLength: 32
maxLength: 512
description: Returned once for the new session; the CSRF cookie is the reload fallback.
AdminUsageAggregate:
type: object
additionalProperties: false
required: [kind, requests, chargedCredits, asrMillis, inputTokens, outputTokens]
properties:
kind: { type: string }
requests: { type: integer, format: int64, minimum: 0 }
chargedCredits: { type: integer, format: int64, minimum: 0 }
asrMillis: { type: integer, format: int64, minimum: 0 }
inputTokens: { type: integer, format: int64, minimum: 0 }
outputTokens: { type: integer, format: int64, minimum: 0 }
AdminTrendPoint:
type: object
additionalProperties: false
required: [date, registrations, creditsUsed]
properties:
date: { type: string, format: date }
registrations: { type: integer, format: int64, minimum: 0 }
creditsUsed: { type: integer, format: int64, minimum: 0 }
AdminOverview:
type: object
additionalProperties: false
required:
- totalUsers
- activeUsers
- newUsers
- totalCreditBalance
- creditsGranted
- creditsUsed
- trend
- usage
properties:
totalUsers: { type: integer, format: int64, minimum: 0 }
activeUsers: { type: integer, format: int64, minimum: 0 }
newUsers: { type: integer, format: int64, minimum: 0 }
totalCreditBalance: { type: integer, format: int64, minimum: 0 }
creditsGranted: { type: integer, format: int64, minimum: 0 }
creditsUsed: { type: integer, format: int64, minimum: 0 }
trend:
type: array
items: { $ref: "#/components/schemas/AdminTrendPoint" }
usage:
type: array
items: { $ref: "#/components/schemas/AdminUsageAggregate" }
AdminFunnelStep:
type: object
additionalProperties: false
required: [label, count]
properties:
label:
type: string
enum: [邀请码创建, 成功绑定, 有效使用并奖励, 待资格确认, 不符合奖励条件]
count: { type: integer, format: int64, minimum: 0 }
AdminReferralRank:
type: object
additionalProperties: false
required: [userId, invited, qualified, creditsEarned]
properties:
userId: { type: string, format: uuid }
invited: { type: integer, format: int64, minimum: 0 }
qualified: { type: integer, format: int64, minimum: 0 }
creditsEarned: { type: integer, format: int64, minimum: 0 }
AdminReferralOverview:
type: object
additionalProperties: false
required: [pendingBindings, ineligibleBindings, funnel, ranking]
properties:
pendingBindings: { type: integer, format: int64, minimum: 0 }
ineligibleBindings: { type: integer, format: int64, minimum: 0 }
funnel:
type: array
items: { $ref: "#/components/schemas/AdminFunnelStep" }
ranking:
type: array
items: { $ref: "#/components/schemas/AdminReferralRank" }
AdminUserSummary:
type: object
additionalProperties: false
required: [userId, displayName, status, creditBalance, createdAt]
properties:
userId: { type: string, format: uuid }
displayName: { type: string }
status: { type: string, enum: [active, suspended, closed] }
creditBalance: { type: integer, format: int64, minimum: 0 }
createdAt: { type: string, format: date-time }
AdminUserPage:
type: object
additionalProperties: false
required: [items]
properties:
items:
type: array
items: { $ref: "#/components/schemas/AdminUserSummary" }
nextCursor: { type: ["string", "null"] }
AdminUserReferral:
type: object
additionalProperties: false
required: [invitedUsers, rewardedInvites]
properties:
inviterUserId: { type: ["string", "null"], format: uuid }
invitedUsers: { type: integer, format: int64, minimum: 0 }
rewardedInvites: { type: integer, format: int64, minimum: 0 }
AdminUserDetail:
type: object
additionalProperties: false
required:
- userId
- displayName
- status
- creditBalance
- createdAt
- qualifiedUsage
- usage
- referral
properties:
userId: { type: string, format: uuid }
displayName: { type: string }
status: { type: string, enum: [active, suspended, closed] }
creditBalance: { type: integer, format: int64, minimum: 0 }
createdAt: { type: string, format: date-time }
lastActiveAt: { type: ["string", "null"], format: date-time }
qualifiedUsage: { type: boolean }
referralCode: { type: ["string", "null"] }
referredByUserId: { type: ["string", "null"], format: uuid }
usage:
type: array
items: { $ref: "#/components/schemas/AdminUsageAggregate" }
referral: { $ref: "#/components/schemas/AdminUserReferral" }
AdminLedgerEntry:
type: object
additionalProperties: false
required: [entryId, type, amount, balanceAfter, reasonCode, createdAt]
properties:
entryId: { type: string, format: uuid }
type: { type: string, enum: [grant, reserve, settle, refund, adjustment] }
amount: { type: integer, format: int64 }
balanceAfter: { type: integer, format: int64, minimum: 0 }
reasonCode: { type: string }
createdAt: { type: string, format: date-time }
AdminLedgerPage:
type: object
additionalProperties: false
required: [items]
properties:
items:
type: array
items: { $ref: "#/components/schemas/AdminLedgerEntry" }
nextCursor: { type: ["string", "null"] }
AdminGrantResponse:
type: object
additionalProperties: false
required: [transactionId, balanceAfter]
properties:
transactionId: { type: string, format: uuid }
balanceAfter: { type: integer, format: int64, minimum: 0 }
AdminAudit:
type: object
additionalProperties: false
required: [auditId, operatorName, action, targetType, targetId, result, createdAt]
properties:
auditId: { type: string, format: uuid }
operatorName: { type: string }
action: { type: string }
targetType: { type: string }
targetId: { type: string }
requestId: { type: ["string", "null"] }
result: { type: string, enum: [success, rejected] }
createdAt: { type: string, format: date-time }
AdminAuditPage:
type: object
additionalProperties: false
required: [items]
properties:
items:
type: array
items: { $ref: "#/components/schemas/AdminAudit" }
nextCursor: { type: ["string", "null"] }
AdminOperator:
type: object
additionalProperties: false
required:
- operatorId
- username
- role
- enabled
- failedLoginCount
- createdAt
- updatedAt
properties:
operatorId: { type: string, format: uuid }
username:
type: string
pattern: "^[a-z0-9][a-z0-9._@-]{2,63}$"
role: { type: string, enum: [SUPER_ADMIN, SUPPORT, ANALYST] }
enabled: { type: boolean }
failedLoginCount: { type: integer, minimum: 0 }
lockedUntil: { type: ["string", "null"], format: date-time }
lastLoginAt: { type: ["string", "null"], format: date-time }
createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time }
AdminOperatorPage:
type: object
additionalProperties: false
required: [items]
properties:
items:
type: array
items: { $ref: "#/components/schemas/AdminOperator" }
nextCursor: { type: ["string", "null"] }
AdminOperatorCreateRequest:
type: object
additionalProperties: false
required: [username, password, role]
properties:
username:
type: string
minLength: 3
maxLength: 64
pattern: "^[A-Za-z0-9][A-Za-z0-9._@-]{2,63}$"
password: { type: string, minLength: 12, maxLength: 1024 }
role: { type: string, enum: [SUPER_ADMIN, SUPPORT, ANALYST] }
AdminOperatorPasswordRequest:
type: object
additionalProperties: false
required: [password]
properties:
password: { type: string, minLength: 12, maxLength: 1024 }
AdminOperatorProvisioning:
type: object
additionalProperties: false
required: [operatorId, totpSecret, otpauthUri]
properties:
operatorId: { type: string, format: uuid }
operator:
oneOf:
- $ref: "#/components/schemas/AdminOperator"
- type: "null"
totpSecret:
type: string
pattern: "^[A-Z2-7]{32}$"
description: 160-bit Base32 secret returned once; never persisted in plaintext.
otpauthUri:
type: string
pattern: "^otpauth://totp/"
description: Provisioning URI returned once; never persisted.
AppleSignInRequest: AppleSignInRequest:
type: object type: object
additionalProperties: false additionalProperties: false
@@ -7,6 +7,27 @@ import com.osglab.account.common.security.SessionJwt
import com.osglab.account.common.security.installSessionAuthentication import com.osglab.account.common.security.installSessionAuthentication
import com.osglab.account.config.AppConfig import com.osglab.account.config.AppConfig
import com.osglab.account.config.DatabaseFactory import com.osglab.account.config.DatabaseFactory
import com.osglab.account.features.admin.grants.services.AdminGrantService
import com.osglab.account.features.admin.repositories.AdminRepository
import com.osglab.account.features.admin.repositories.ExposedAdminRepository
import com.osglab.account.features.admin.routes.adminApiRoutes
import com.osglab.account.features.admin.routes.adminWebRoutes
import com.osglab.account.features.admin.security.AdminPasswordHasher
import com.osglab.account.features.admin.security.AdminTotpVerifier
import com.osglab.account.features.admin.security.BouncyCastleArgon2idPasswordHasher
import com.osglab.account.features.admin.security.HmacTotpVerifier
import com.osglab.account.features.admin.services.AdminAuthService
import com.osglab.account.features.admin.services.AdminAuditService
import com.osglab.account.features.admin.services.AdminBootstrapConfig
import com.osglab.account.features.admin.services.AdminBootstrapService
import com.osglab.account.features.admin.services.AdminOperatorService
import com.osglab.account.features.admin.services.AdminSessionService
import com.osglab.account.features.admin.stats.repositories.AdminStatsRepository
import com.osglab.account.features.admin.stats.repositories.ExposedAdminStatsRepository
import com.osglab.account.features.admin.stats.services.AdminStatsService
import com.osglab.account.features.admin.users.repositories.AdminUsersRepository
import com.osglab.account.features.admin.users.repositories.ExposedAdminUsersRepository
import com.osglab.account.features.admin.users.services.AdminUsersService
import com.osglab.account.features.account.AccountRepository import com.osglab.account.features.account.AccountRepository
import com.osglab.account.features.account.AccountReauthenticator import com.osglab.account.features.account.AccountReauthenticator
import com.osglab.account.features.account.AccountService import com.osglab.account.features.account.AccountService
@@ -120,6 +141,7 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.koin.core.module.Module import org.koin.core.module.Module
import org.koin.dsl.module import org.koin.dsl.module
import org.koin.ktor.ext.getKoin import org.koin.ktor.ext.getKoin
@@ -180,6 +202,12 @@ fun Application.module() {
register(PUBLIC_RATE_LIMIT) { register(PUBLIC_RATE_LIMIT) {
rateLimiter(limit = 120, refillPeriod = 1.minutes) rateLimiter(limit = 120, refillPeriod = 1.minutes)
} }
register(ADMIN_AUTH_RATE_LIMIT) {
rateLimiter(limit = 5, refillPeriod = 1.minutes)
}
register(ADMIN_API_RATE_LIMIT) {
rateLimiter(limit = 60, refillPeriod = 1.minutes)
}
} }
installApiStatusPages() installApiStatusPages()
@@ -191,6 +219,19 @@ fun Application.module() {
val koin = getKoin() val koin = getKoin()
// Fail startup before accepting traffic if migrations or database connectivity fail. // Fail startup before accepting traffic if migrations or database connectivity fail.
koin.get<DatabaseFactory>().database koin.get<DatabaseFactory>().database
if (appConfig.admin.bootstrapEnabled) {
runBlocking {
koin.get<AdminBootstrapService>().initialize(
AdminBootstrapConfig(
enabled = true,
operatorId = appConfig.admin.bootstrapOperatorId,
username = appConfig.admin.bootstrapUsername,
passwordHash = appConfig.admin.bootstrapPasswordHash,
totpSecretBase32 = appConfig.admin.bootstrapTotpSecretBase32,
),
)
}
}
val sessionAuthenticator = koin.get<SessionAccessAuthenticator>() val sessionAuthenticator = koin.get<SessionAccessAuthenticator>()
installSessionAuthentication(sessionAuthenticator::authenticate) installSessionAuthentication(sessionAuthenticator::authenticate)
val asrStreaming = if (appConfig.providers.volcengine.credentialsAvailable) { val asrStreaming = if (appConfig.providers.volcengine.credentialsAvailable) {
@@ -220,6 +261,15 @@ fun Application.module() {
} catch (_: Exception) { } catch (_: Exception) {
// Durable settlement state is retried without logging provider data. // Durable settlement state is retried without logging provider data.
} }
if (appConfig.admin.enabled) {
try {
koin.get<AdminSessionService>().cleanupInactive()
} catch (exception: CancellationException) {
throw exception
} catch (_: Exception) {
// Expired sessions are retried in bounded batches on the next cycle.
}
}
delay(60_000) delay(60_000)
} }
} }
@@ -253,6 +303,21 @@ fun Application.module() {
configureInviteWebRoutes(koin.get(), koin.get()) configureInviteWebRoutes(koin.get(), koin.get())
integrityRoutes(koin.get()) integrityRoutes(koin.get())
} }
if (appConfig.admin.enabled) {
adminWebRoutes()
rateLimit(ADMIN_API_RATE_LIMIT) {
adminApiRoutes(
config = appConfig,
authService = koin.get(),
sessionService = koin.get(),
statsService = koin.get(),
usersService = koin.get(),
grantService = koin.get(),
operatorService = koin.get(),
auditService = koin.get(),
)
}
}
} }
} }
@@ -296,6 +361,33 @@ fun accountServerModule(config: AppConfig): Module = module {
single { SessionJwt(config.session) } single { SessionJwt(config.session) }
single { FieldEncryptor(config.encryption.key) } single { FieldEncryptor(config.encryption.key) }
single { IdentityFingerprint(config.antiAbuse.identityHmacKey) } single { IdentityFingerprint(config.antiAbuse.identityHmacKey) }
single<AdminRepository> { ExposedAdminRepository(get()) }
single<AdminPasswordHasher> { BouncyCastleArgon2idPasswordHasher() }
single<AdminTotpVerifier> { HmacTotpVerifier() }
single {
val dummyPassword = "invalid-admin-password-constant-work".toCharArray()
try {
AdminAuthService(
repository = get(),
passwordHasher = get(),
dummyPasswordHash = get<AdminPasswordHasher>().hash(dummyPassword),
totpVerifier = get(),
fieldEncryptor = get(),
sessionTtl = Duration.ofHours(config.admin.sessionHours),
)
} finally {
dummyPassword.fill('\u0000')
}
}
single { AdminSessionService(get()) }
single { AdminBootstrapService(get(), get()) }
single { AdminOperatorService(get(), get(), get()) }
single { AdminAuditService(get()) }
single<AdminStatsRepository> { ExposedAdminStatsRepository(get()) }
single { AdminStatsService(get()) }
single<AdminUsersRepository> { ExposedAdminUsersRepository(get()) }
single { AdminUsersService(get()) }
single { AdminGrantService(get()) }
single<AppleJwksProvider> { single<AppleJwksProvider> {
RemoteAppleJwksProvider(get(), config.apple.jwksUrl) RemoteAppleJwksProvider(get(), config.apple.jwksUrl)
} }
@@ -513,3 +605,5 @@ private val AUTH_RATE_LIMIT = RateLimitName("auth")
private val ACCOUNT_RATE_LIMIT = RateLimitName("account") private val ACCOUNT_RATE_LIMIT = RateLimitName("account")
private val GATEWAY_RATE_LIMIT = RateLimitName("gateway") private val GATEWAY_RATE_LIMIT = RateLimitName("gateway")
private val PUBLIC_RATE_LIMIT = RateLimitName("public") private val PUBLIC_RATE_LIMIT = RateLimitName("public")
private val ADMIN_AUTH_RATE_LIMIT = RateLimitName("admin-auth")
private val ADMIN_API_RATE_LIMIT = RateLimitName("admin-api")
@@ -3,6 +3,7 @@ package com.osglab.account.config
import io.ktor.server.config.ApplicationConfig import io.ktor.server.config.ApplicationConfig
import java.net.URI import java.net.URI
import java.util.Base64 import java.util.Base64
import java.util.UUID
data class AppConfig( data class AppConfig(
val environment: Environment, val environment: Environment,
@@ -17,6 +18,7 @@ data class AppConfig(
val credits: CreditsConfig, val credits: CreditsConfig,
val providers: ProvidersConfig, val providers: ProvidersConfig,
val integrity: IntegrityConfig, val integrity: IntegrityConfig,
val admin: AdminConfig = AdminConfig(),
) { ) {
val isProduction: Boolean = environment == Environment.PRODUCTION val isProduction: Boolean = environment == Environment.PRODUCTION
@@ -119,6 +121,41 @@ data class AppConfig(
300, 300,
), ),
) )
val adminEnabled = config.booleanOrDefault("app.admin.enabled", false)
val adminBootstrapEnabled = config.booleanOrDefault(
"app.admin.bootstrapEnabled",
false,
)
require(!adminBootstrapEnabled || adminEnabled) {
"app.admin.bootstrapEnabled requires app.admin.enabled"
}
val admin = AdminConfig(
enabled = adminEnabled,
bootstrapEnabled = adminBootstrapEnabled,
bootstrapOperatorId = config.optionalValue("app.admin.bootstrapOperatorId")
?.let {
runCatching { UUID.fromString(it) }.getOrElse { cause ->
throw ConfigValidationException(
"app.admin.bootstrapOperatorId must be a UUID",
cause,
)
}
},
bootstrapUsername = config.optionalValue("app.admin.bootstrapUsername"),
bootstrapPasswordHash = config.optionalLiteralSecret(
"app.admin.bootstrapPasswordHash",
production && adminBootstrapEnabled,
),
bootstrapTotpSecretBase32 = config.optionalSecret(
"app.admin.bootstrapTotpSecretBase32",
production && adminBootstrapEnabled,
),
sessionHours = config.positiveLong("app.admin.sessionHours", 8),
maximumManualGrant = config.positiveLong(
"app.admin.maximumManualGrant",
100_000,
),
)
require(session.hmacSecret.size >= MIN_HMAC_SECRET_BYTES) { require(session.hmacSecret.size >= MIN_HMAC_SECRET_BYTES) {
"app.session.secret must contain at least $MIN_HMAC_SECRET_BYTES bytes" "app.session.secret must contain at least $MIN_HMAC_SECRET_BYTES bytes"
@@ -199,6 +236,26 @@ data class AppConfig(
require(!production || apple.clientId == APP_ATTEST_BUNDLE_ID) { require(!production || apple.clientId == APP_ATTEST_BUNDLE_ID) {
"Production Apple client ID must be $APP_ATTEST_BUNDLE_ID" "Production Apple client ID must be $APP_ATTEST_BUNDLE_ID"
} }
require(admin.sessionHours in 1..24) {
"app.admin.sessionHours must be between 1 and 24"
}
require(admin.maximumManualGrant in 1..100_000_000) {
"app.admin.maximumManualGrant must be between 1 and 100000000"
}
if (admin.bootstrapEnabled) {
requireNotNull(admin.bootstrapOperatorId) {
"app.admin.bootstrapOperatorId is required when admin bootstrap is enabled"
}
require(!admin.bootstrapUsername.isNullOrBlank()) {
"app.admin.bootstrapUsername is required when admin bootstrap is enabled"
}
require(!admin.bootstrapPasswordHash.isNullOrBlank()) {
"app.admin.bootstrapPasswordHash is required when admin bootstrap is enabled"
}
require(!admin.bootstrapTotpSecretBase32.isNullOrBlank()) {
"app.admin.bootstrapTotpSecretBase32 is required when admin bootstrap is enabled"
}
}
if (production) { if (production) {
requireExactAppleEndpoint(apple.jwksUrl, "/auth/keys", "JWKS") requireExactAppleEndpoint(apple.jwksUrl, "/auth/keys", "JWKS")
requireExactAppleEndpoint(apple.tokenUrl, "/auth/token", "token") requireExactAppleEndpoint(apple.tokenUrl, "/auth/token", "token")
@@ -239,6 +296,7 @@ data class AppConfig(
credits = credits, credits = credits,
providers = providers, providers = providers,
integrity = integrity, integrity = integrity,
admin = admin,
) )
} }
} }
@@ -335,6 +393,17 @@ data class IntegrityConfig(
val appAttestBundleId: String = APP_ATTEST_BUNDLE_ID, val appAttestBundleId: String = APP_ATTEST_BUNDLE_ID,
) )
data class AdminConfig(
val enabled: Boolean = false,
val bootstrapEnabled: Boolean = false,
val bootstrapOperatorId: UUID? = null,
val bootstrapUsername: String? = null,
val bootstrapPasswordHash: String? = null,
val bootstrapTotpSecretBase32: String? = null,
val sessionHours: Long = 8,
val maximumManualGrant: Long = 100_000,
)
enum class IntegrityPolicy { enum class IntegrityPolicy {
MONITOR, MONITOR,
ENFORCE; ENFORCE;
@@ -393,6 +462,19 @@ private fun ApplicationConfig.optionalSecret(path: String, production: Boolean):
return value?.takeUnless(String::isPlaceholder) return value?.takeUnless(String::isPlaceholder)
} }
private fun ApplicationConfig.optionalLiteralSecret(path: String, production: Boolean): String? {
val value = propertyOrNull(path)?.getString()?.trim()?.takeIf(String::isNotEmpty)
val placeholder = value?.let {
it.contains("replace-with", ignoreCase = true) ||
it.contains("change-me", ignoreCase = true) ||
it.contains("\${")
} == true
if (production && (value == null || placeholder)) {
throw ConfigValidationException("Production secret is missing or uses a placeholder: $path")
}
return value?.takeUnless { placeholder }
}
private fun String.isPlaceholder(): Boolean = private fun String.isPlaceholder(): Boolean =
PLACEHOLDER_MARKERS.any { marker -> contains(marker, ignoreCase = true) } PLACEHOLDER_MARKERS.any { marker -> contains(marker, ignoreCase = true) }
@@ -419,6 +501,15 @@ private fun ApplicationConfig.boolean(path: String): Boolean =
} }
} }
private fun ApplicationConfig.booleanOrDefault(path: String, default: Boolean): Boolean =
propertyOrNull(path)?.getString()?.trim()?.takeIf(String::isNotEmpty)?.let {
when (it.lowercase()) {
"true" -> true
"false" -> false
else -> throw ConfigValidationException("$path must be true or false")
}
} ?: default
private fun ApplicationConfig.base64Key(path: String, production: Boolean): ByteArray { private fun ApplicationConfig.base64Key(path: String, production: Boolean): ByteArray {
val encoded = secret(path, production) val encoded = secret(path, production)
return try { return try {
@@ -0,0 +1,27 @@
package com.osglab.account.features.admin.grants.models
import kotlinx.serialization.Serializable
import java.util.UUID
data class ManualGrantCommand(
val operatorId: UUID,
val userId: UUID,
val credits: Long,
val reason: String,
val requestId: String? = null,
val idempotencyKey: String,
)
@Serializable
data class AdminCreditGrantDto(
val id: String,
val operatorId: String,
val userId: String,
val credits: Long,
val reason: String,
val ledgerEntryId: String,
val auditLogId: String,
val balanceAfter: Long,
val createdAt: String,
val replayed: Boolean,
)
@@ -0,0 +1,84 @@
package com.osglab.account.features.admin.grants.repositories
import com.osglab.account.features.admin.models.NewAdminAuditEvent
import com.osglab.account.features.admin.repositories.AdminAuditLogTable
import com.osglab.account.features.credits.domain.ManualCreditGrant
import org.jetbrains.exposed.v1.core.ResultRow
import org.jetbrains.exposed.v1.core.Table
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.javatime.timestamp
import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.selectAll
import java.util.UUID
interface AdminCreditGrantRepository {
fun findByIdempotencyKey(idempotencyKey: String): ManualCreditGrant?
fun insertAudit(event: NewAdminAuditEvent)
fun insert(grant: ManualCreditGrant)
}
internal object AdminCreditGrantsTable : Table("admin_credit_grants") {
val id = varchar("id", 36)
val operatorId = varchar("operator_id", 36)
val accountId = varchar("account_id", 36)
val amount = long("amount")
val reason = varchar("reason", 500)
val idempotencyKey = varchar("idempotency_key", 128)
val ledgerEntryId = varchar("ledger_entry_id", 36)
val auditLogId = varchar("audit_log_id", 36)
val createdAt = timestamp("created_at")
override val primaryKey = PrimaryKey(id)
}
internal object ExposedAdminCreditGrantRepository : AdminCreditGrantRepository {
override fun findByIdempotencyKey(
idempotencyKey: String,
): ManualCreditGrant? =
AdminCreditGrantsTable
.selectAll()
.where { AdminCreditGrantsTable.idempotencyKey eq idempotencyKey }
.singleOrNull()
?.toManualCreditGrant()
override fun insertAudit(event: NewAdminAuditEvent) {
AdminAuditLogTable.insert {
it[id] = event.id.toString()
it[actorOperatorId] = event.actorOperatorId?.toString()
it[action] = event.action.name
it[outcome] = event.outcome.name
it[targetType] = event.targetType
it[targetId] = event.targetId
it[requestId] = event.requestId
it[occurredAt] = event.occurredAt
}
}
override fun insert(grant: ManualCreditGrant) {
AdminCreditGrantsTable.insert {
it[id] = grant.id.toString()
it[operatorId] = grant.operatorId.toString()
it[accountId] = grant.userId.toString()
it[amount] = grant.amount
it[reason] = grant.reason
it[idempotencyKey] = grant.idempotencyKey
it[ledgerEntryId] = grant.ledgerEntryId.toString()
it[auditLogId] = grant.auditLogId.toString()
it[createdAt] = grant.createdAt
}
}
}
private fun ResultRow.toManualCreditGrant() = ManualCreditGrant(
id = UUID.fromString(this[AdminCreditGrantsTable.id]),
operatorId = UUID.fromString(this[AdminCreditGrantsTable.operatorId]),
userId = UUID.fromString(this[AdminCreditGrantsTable.accountId]),
amount = this[AdminCreditGrantsTable.amount],
reason = this[AdminCreditGrantsTable.reason],
idempotencyKey = this[AdminCreditGrantsTable.idempotencyKey],
ledgerEntryId = UUID.fromString(this[AdminCreditGrantsTable.ledgerEntryId]),
auditLogId = UUID.fromString(this[AdminCreditGrantsTable.auditLogId]),
createdAt = this[AdminCreditGrantsTable.createdAt],
)
@@ -0,0 +1,32 @@
package com.osglab.account.features.admin.grants.services
import com.osglab.account.features.admin.grants.models.AdminCreditGrantDto
import com.osglab.account.features.admin.grants.models.ManualGrantCommand
import com.osglab.account.features.credits.services.CreditOperations
class AdminGrantService(
private val credits: CreditOperations,
) {
suspend fun grant(command: ManualGrantCommand): AdminCreditGrantDto {
val result = credits.grantManual(
operatorId = command.operatorId,
userId = command.userId,
credits = command.credits,
reason = command.reason,
requestId = command.requestId,
idempotencyKey = command.idempotencyKey,
)
return AdminCreditGrantDto(
id = result.grant.id.toString(),
operatorId = result.grant.operatorId.toString(),
userId = result.grant.userId.toString(),
credits = result.grant.amount,
reason = result.grant.reason,
ledgerEntryId = result.grant.ledgerEntryId.toString(),
auditLogId = result.grant.auditLogId.toString(),
balanceAfter = result.balanceAfter,
createdAt = result.grant.createdAt.toString(),
replayed = result.replayed,
)
}
}
@@ -0,0 +1,189 @@
package com.osglab.account.features.admin.models
import java.time.Instant
import java.util.UUID
enum class AdminRole {
SUPER_ADMIN,
SUPPORT,
ANALYST,
}
data class AdminOperatorRecord(
val id: UUID,
val normalizedUsername: String,
val role: AdminRole,
val lockState: AdminLockState,
val disabledAt: Instant?,
val lastLoginAt: Instant?,
val createdAt: Instant,
val updatedAt: Instant,
)
data class AdminOperatorCursor(
val createdAt: Instant,
val id: UUID,
)
data class NewAdminOperator(
val id: UUID,
val normalizedUsername: String,
val passwordHash: String,
val encryptedTotpSecret: String,
val role: AdminRole,
val createdAt: Instant,
) {
init {
require(normalizedUsername.isNotBlank())
require(passwordHash.isNotBlank())
require(encryptedTotpSecret.isNotBlank())
}
}
data class AdminLockState(
val failedLoginCount: Int,
val lockedUntil: Instant?,
) {
init {
require(failedLoginCount >= 0)
}
fun isLockedAt(now: Instant): Boolean = lockedUntil?.isAfter(now) == true
}
/**
* Authentication-only record. Its string representation deliberately excludes
* password and TOTP material.
*/
class AdminOperatorAuthRecord(
val id: UUID,
val normalizedUsername: String,
val passwordHash: String,
val encryptedTotpSecret: String,
val role: AdminRole,
val lockState: AdminLockState,
val disabledAt: Instant?,
) {
override fun toString(): String =
"AdminOperatorAuthRecord(id=$id, normalizedUsername=$normalizedUsername, " +
"role=$role, lockState=$lockState, disabled=${disabledAt != null})"
}
data class NewAdminSession(
val id: UUID,
val operatorId: UUID,
val tokenHash: String,
val csrfTokenHash: String,
val createdAt: Instant,
val expiresAt: Instant,
) {
init {
require(expiresAt.isAfter(createdAt))
}
}
/**
* Repository session record. Token digests are omitted from logs and errors.
*/
class AdminSessionRecord(
val id: UUID,
val operatorId: UUID,
val normalizedUsername: String,
val role: AdminRole,
val csrfTokenHash: String,
val expiresAt: Instant,
) {
override fun toString(): String =
"AdminSessionRecord(id=$id, operatorId=$operatorId, " +
"normalizedUsername=$normalizedUsername, role=$role, expiresAt=$expiresAt)"
}
data class AdminPrincipal(
val operatorId: UUID,
val sessionId: UUID,
val normalizedUsername: String,
val role: AdminRole,
)
enum class AdminAuditAction {
LOGIN_SUCCEEDED,
LOGIN_FAILED,
SESSION_REVOKED,
OPERATOR_CREATED,
OPERATOR_ENABLED,
OPERATOR_DISABLED,
OPERATOR_UNLOCKED,
OPERATOR_CREDENTIALS_RESET,
OPERATOR_SESSIONS_REVOKED,
MANUAL_CREDIT_GRANTED,
}
enum class AdminAuditOutcome {
SUCCESS,
DENIED,
}
data class NewAdminAuditEvent(
val id: UUID = UUID.randomUUID(),
val actorOperatorId: UUID?,
val action: AdminAuditAction,
val outcome: AdminAuditOutcome,
val targetType: String? = null,
val targetId: String? = null,
val requestId: String? = null,
val occurredAt: Instant,
) {
init {
require((targetType == null) == (targetId == null))
require(targetType == null || targetType.isNotBlank())
require(targetId == null || targetId.isNotBlank())
require(requestId == null || requestId.isNotBlank())
}
}
data class AdminAuditRecord(
val id: UUID,
val actorOperatorId: UUID?,
val action: AdminAuditAction,
val outcome: AdminAuditOutcome,
val targetType: String?,
val targetId: String?,
val requestId: String?,
val occurredAt: Instant,
)
data class AdminAuditCursor(
val occurredAt: Instant,
val id: UUID,
)
/**
* Raw credentials are returned once and must only be transported in secure,
* HttpOnly/Secure cookies. They are never persisted by this service.
*/
class AdminSessionCredentials(
val sessionToken: String,
val csrfToken: String,
val expiresAt: Instant,
) {
override fun toString(): String =
"AdminSessionCredentials(sessionToken=[REDACTED], csrfToken=[REDACTED], expiresAt=$expiresAt)"
}
sealed interface AdminLoginResult {
data class Authenticated(
val principal: AdminPrincipal,
val credentials: AdminSessionCredentials,
) : AdminLoginResult
data class Locked(val retryAt: Instant) : AdminLoginResult
data object InvalidCredentials : AdminLoginResult
}
enum class AdminOperatorMutationResult {
SUCCESS,
NOT_FOUND,
USERNAME_CONFLICT,
LAST_SUPER_ADMIN,
}
@@ -0,0 +1,641 @@
package com.osglab.account.features.admin.repositories
import com.osglab.account.config.DatabaseFactory
import com.osglab.account.features.admin.models.AdminAuditCursor
import com.osglab.account.features.admin.models.AdminLockState
import com.osglab.account.features.admin.models.AdminAuditAction
import com.osglab.account.features.admin.models.AdminAuditOutcome
import com.osglab.account.features.admin.models.AdminAuditRecord
import com.osglab.account.features.admin.models.AdminOperatorAuthRecord
import com.osglab.account.features.admin.models.AdminOperatorCursor
import com.osglab.account.features.admin.models.AdminOperatorMutationResult
import com.osglab.account.features.admin.models.AdminOperatorRecord
import com.osglab.account.features.admin.models.AdminRole
import com.osglab.account.features.admin.models.AdminSessionRecord
import com.osglab.account.features.admin.models.NewAdminAuditEvent
import com.osglab.account.features.admin.models.NewAdminOperator
import com.osglab.account.features.admin.models.NewAdminSession
import org.jetbrains.exposed.v1.core.ResultRow
import org.jetbrains.exposed.v1.core.SortOrder
import org.jetbrains.exposed.v1.core.Table
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.core.greater
import org.jetbrains.exposed.v1.core.inList
import org.jetbrains.exposed.v1.core.isNotNull
import org.jetbrains.exposed.v1.core.isNull
import org.jetbrains.exposed.v1.core.less
import org.jetbrains.exposed.v1.core.lessEq
import org.jetbrains.exposed.v1.core.or
import org.jetbrains.exposed.v1.javatime.timestamp
import org.jetbrains.exposed.v1.jdbc.andWhere
import org.jetbrains.exposed.v1.jdbc.deleteWhere
import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.insertIgnore
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.update
import java.time.Instant
import java.util.UUID
internal object AdminOperatorsTable : Table("admin_operators") {
val id = varchar("id", 36)
val username = varchar("username", 64).uniqueIndex()
val passwordHash = varchar("password_hash", 255)
val encryptedTotpSecret = text("encrypted_totp_secret")
val role = varchar("role", 32)
val failedLoginCount = integer("failed_login_count")
val lockedUntil = timestamp("locked_until").nullable()
val lastTotpCounter = long("last_totp_counter").nullable()
val lastLoginAt = timestamp("last_login_at").nullable()
val disabledAt = timestamp("disabled_at").nullable()
val createdAt = timestamp("created_at")
val updatedAt = timestamp("updated_at")
override val primaryKey = PrimaryKey(id)
}
internal object AdminSessionsTable : Table("admin_sessions") {
val id = varchar("id", 36)
val operatorId = varchar("operator_id", 36).index()
val tokenHash = char("token_hash", 64).uniqueIndex()
val csrfTokenHash = char("csrf_token_hash", 64)
val createdAt = timestamp("created_at")
val expiresAt = timestamp("expires_at").index()
val revokedAt = timestamp("revoked_at").nullable()
override val primaryKey = PrimaryKey(id)
}
internal object AdminAuditLogTable : Table("admin_audit_log") {
val id = varchar("id", 36)
val actorOperatorId = varchar("actor_operator_id", 36).nullable().index()
val action = varchar("action", 64)
val outcome = varchar("outcome", 32)
val targetType = varchar("target_type", 64).nullable()
val targetId = varchar("target_id", 128).nullable()
val requestId = varchar("request_id", 128).nullable()
val occurredAt = timestamp("occurred_at").index()
override val primaryKey = PrimaryKey(id)
}
interface AdminRepository {
suspend fun createOperatorIfAbsent(operator: NewAdminOperator): Boolean
suspend fun createOperator(
operator: NewAdminOperator,
auditEvent: NewAdminAuditEvent,
): AdminOperatorMutationResult
suspend fun listOperators(): List<AdminOperatorRecord>
suspend fun listOperatorsPage(
limit: Int,
before: AdminOperatorCursor? = null,
): List<AdminOperatorRecord>
suspend fun countActiveSessions(now: Instant): Long
suspend fun findOperator(operatorId: UUID): AdminOperatorRecord?
suspend fun setOperatorEnabled(
operatorId: UUID,
enabled: Boolean,
now: Instant,
auditEvent: NewAdminAuditEvent,
): AdminOperatorMutationResult
suspend fun unlockOperator(
operatorId: UUID,
now: Instant,
auditEvent: NewAdminAuditEvent,
): AdminOperatorMutationResult
suspend fun resetOperatorCredentials(
operatorId: UUID,
passwordHash: String,
encryptedTotpSecret: String,
now: Instant,
auditEvent: NewAdminAuditEvent,
): AdminOperatorMutationResult
suspend fun revokeOperatorSessions(
operatorId: UUID,
now: Instant,
auditEvent: NewAdminAuditEvent,
): AdminOperatorMutationResult
suspend fun findOperatorForAuthentication(normalizedUsername: String): AdminOperatorAuthRecord?
/**
* Executes the transformation while holding the operator row lock.
*/
suspend fun updateLockState(
operatorId: UUID,
now: Instant,
auditEvent: NewAdminAuditEvent,
transform: (AdminLockState) -> AdminLockState,
): AdminLockState?
/**
* Atomically consumes a newer TOTP counter, clears the lock state, and
* creates the session. A null result means the operator became unavailable
* or the counter was already consumed.
*/
suspend fun createSessionIfTotpCounterFresh(
session: NewAdminSession,
totpCounter: Long,
now: Instant,
auditEvent: NewAdminAuditEvent,
): AdminSessionRecord?
suspend fun findActiveSessionByTokenHash(tokenHash: String, now: Instant): AdminSessionRecord?
suspend fun revokeSessionByTokenHash(
tokenHash: String,
now: Instant,
auditEvent: NewAdminAuditEvent,
): AdminSessionRecord?
suspend fun purgeInactiveSessions(cutoff: Instant, limit: Int): Int
suspend fun appendAudit(event: NewAdminAuditEvent)
suspend fun listAudit(
limit: Int,
before: AdminAuditCursor? = null,
): List<AdminAuditRecord>
}
class ExposedAdminRepository(
private val databaseFactory: DatabaseFactory,
) : AdminRepository {
override suspend fun createOperatorIfAbsent(operator: NewAdminOperator): Boolean =
databaseFactory.query {
insertOperatorIgnoringConflict(operator)
}
override suspend fun createOperator(
operator: NewAdminOperator,
auditEvent: NewAdminAuditEvent,
): AdminOperatorMutationResult = databaseFactory.query {
val result = if (insertOperatorIgnoringConflict(operator)) {
AdminOperatorMutationResult.SUCCESS
} else {
AdminOperatorMutationResult.USERNAME_CONFLICT
}
insertAudit(auditEvent.withResult(result))
result
}
override suspend fun listOperators(): List<AdminOperatorRecord> =
databaseFactory.query {
AdminOperatorsTable.selectAll()
.orderBy(
AdminOperatorsTable.createdAt to SortOrder.ASC,
AdminOperatorsTable.id to SortOrder.ASC,
)
.map(ResultRow::toOperatorRecord)
}
override suspend fun listOperatorsPage(
limit: Int,
before: AdminOperatorCursor?,
): List<AdminOperatorRecord> =
databaseFactory.query {
require(limit in 1..101)
val query = AdminOperatorsTable.selectAll()
if (before != null) {
query.andWhere {
(AdminOperatorsTable.createdAt greater before.createdAt) or
(
(AdminOperatorsTable.createdAt eq before.createdAt) and
(AdminOperatorsTable.id greater before.id.toString())
)
}
}
query
.orderBy(
AdminOperatorsTable.createdAt to SortOrder.ASC,
AdminOperatorsTable.id to SortOrder.ASC,
)
.limit(limit)
.map(ResultRow::toOperatorRecord)
}
override suspend fun countActiveSessions(now: Instant): Long =
databaseFactory.query {
AdminSessionsTable.selectAll()
.where {
AdminSessionsTable.revokedAt.isNull() and
(AdminSessionsTable.expiresAt greater now)
}
.count()
}
override suspend fun findOperator(operatorId: UUID): AdminOperatorRecord? =
databaseFactory.query {
AdminOperatorsTable.selectAll()
.where { AdminOperatorsTable.id eq operatorId.toString() }
.limit(1)
.singleOrNull()
?.toOperatorRecord()
}
override suspend fun setOperatorEnabled(
operatorId: UUID,
enabled: Boolean,
now: Instant,
auditEvent: NewAdminAuditEvent,
): AdminOperatorMutationResult = databaseFactory.query {
if (!enabled) {
// Lock every enabled super-administrator in deterministic order so
// concurrent disables cannot both observe themselves as non-last.
AdminOperatorsTable.selectAll()
.where {
(AdminOperatorsTable.role eq AdminRole.SUPER_ADMIN.name) and
AdminOperatorsTable.disabledAt.isNull()
}
.orderBy(AdminOperatorsTable.id to SortOrder.ASC)
.forUpdate()
.toList()
}
val row = AdminOperatorsTable.selectAll()
.where { AdminOperatorsTable.id eq operatorId.toString() }
.forUpdate()
.singleOrNull()
val result = when {
row == null -> AdminOperatorMutationResult.NOT_FOUND
!enabled &&
row[AdminOperatorsTable.disabledAt] == null &&
row[AdminOperatorsTable.role] == AdminRole.SUPER_ADMIN.name &&
enabledSuperAdministratorCount() <= 1 -> AdminOperatorMutationResult.LAST_SUPER_ADMIN
else -> {
AdminOperatorsTable.update({ AdminOperatorsTable.id eq operatorId.toString() }) {
it[disabledAt] = if (enabled) null else now
it[updatedAt] = now
}
if (!enabled) revokeActiveSessions(operatorId, now)
AdminOperatorMutationResult.SUCCESS
}
}
insertAudit(auditEvent.withResult(result))
result
}
override suspend fun unlockOperator(
operatorId: UUID,
now: Instant,
auditEvent: NewAdminAuditEvent,
): AdminOperatorMutationResult = databaseFactory.query {
val row = lockOperator(operatorId)
val result = if (row == null) {
AdminOperatorMutationResult.NOT_FOUND
} else {
AdminOperatorsTable.update({ AdminOperatorsTable.id eq operatorId.toString() }) {
it[failedLoginCount] = 0
it[lockedUntil] = null
it[updatedAt] = now
}
AdminOperatorMutationResult.SUCCESS
}
insertAudit(auditEvent.withResult(result))
result
}
override suspend fun resetOperatorCredentials(
operatorId: UUID,
passwordHash: String,
encryptedTotpSecret: String,
now: Instant,
auditEvent: NewAdminAuditEvent,
): AdminOperatorMutationResult = databaseFactory.query {
require(passwordHash.isNotBlank())
require(encryptedTotpSecret.isNotBlank())
val row = lockOperator(operatorId)
val result = if (row == null) {
AdminOperatorMutationResult.NOT_FOUND
} else {
AdminOperatorsTable.update({ AdminOperatorsTable.id eq operatorId.toString() }) {
it[AdminOperatorsTable.passwordHash] = passwordHash
it[AdminOperatorsTable.encryptedTotpSecret] = encryptedTotpSecret
it[failedLoginCount] = 0
it[lockedUntil] = null
it[lastTotpCounter] = null
it[updatedAt] = now
}
revokeActiveSessions(operatorId, now)
AdminOperatorMutationResult.SUCCESS
}
insertAudit(auditEvent.withResult(result))
result
}
override suspend fun revokeOperatorSessions(
operatorId: UUID,
now: Instant,
auditEvent: NewAdminAuditEvent,
): AdminOperatorMutationResult = databaseFactory.query {
val row = lockOperator(operatorId)
val result = if (row == null) {
AdminOperatorMutationResult.NOT_FOUND
} else {
revokeActiveSessions(operatorId, now)
AdminOperatorMutationResult.SUCCESS
}
insertAudit(auditEvent.withResult(result))
result
}
override suspend fun findOperatorForAuthentication(
normalizedUsername: String,
): AdminOperatorAuthRecord? = databaseFactory.query {
AdminOperatorsTable.selectAll()
.where { AdminOperatorsTable.username eq normalizedUsername }
.limit(1)
.singleOrNull()
?.toAuthRecord()
}
override suspend fun updateLockState(
operatorId: UUID,
now: Instant,
auditEvent: NewAdminAuditEvent,
transform: (AdminLockState) -> AdminLockState,
): AdminLockState? = databaseFactory.query {
val row = AdminOperatorsTable.selectAll()
.where { AdminOperatorsTable.id eq operatorId.toString() }
.forUpdate()
.singleOrNull()
?: return@query null
if (row[AdminOperatorsTable.disabledAt] != null) return@query null
val next = transform(
AdminLockState(
failedLoginCount = row[AdminOperatorsTable.failedLoginCount],
lockedUntil = row[AdminOperatorsTable.lockedUntil],
),
)
AdminOperatorsTable.update({ AdminOperatorsTable.id eq operatorId.toString() }) {
it[failedLoginCount] = next.failedLoginCount
it[lockedUntil] = next.lockedUntil
it[updatedAt] = now
}
insertAudit(auditEvent)
next
}
override suspend fun createSessionIfTotpCounterFresh(
session: NewAdminSession,
totpCounter: Long,
now: Instant,
auditEvent: NewAdminAuditEvent,
): AdminSessionRecord? = databaseFactory.query {
require(totpCounter >= 0)
val operator = AdminOperatorsTable.selectAll()
.where { AdminOperatorsTable.id eq session.operatorId.toString() }
.forUpdate()
.singleOrNull()
?: return@query null
if (operator[AdminOperatorsTable.disabledAt] != null) return@query null
if (operator[AdminOperatorsTable.lockedUntil]?.isAfter(now) == true) return@query null
if (operator[AdminOperatorsTable.lastTotpCounter]?.let { it >= totpCounter } == true) {
return@query null
}
AdminOperatorsTable.update({ AdminOperatorsTable.id eq session.operatorId.toString() }) {
it[lastTotpCounter] = totpCounter
it[failedLoginCount] = 0
it[lockedUntil] = null
it[lastLoginAt] = now
it[updatedAt] = now
}
AdminSessionsTable.insert {
it[id] = session.id.toString()
it[operatorId] = session.operatorId.toString()
it[tokenHash] = session.tokenHash
it[csrfTokenHash] = session.csrfTokenHash
it[createdAt] = session.createdAt
it[expiresAt] = session.expiresAt
}
insertAudit(auditEvent)
AdminSessionRecord(
id = session.id,
operatorId = session.operatorId,
normalizedUsername = operator[AdminOperatorsTable.username],
role = AdminRole.valueOf(operator[AdminOperatorsTable.role]),
csrfTokenHash = session.csrfTokenHash,
expiresAt = session.expiresAt,
)
}
override suspend fun findActiveSessionByTokenHash(
tokenHash: String,
now: Instant,
): AdminSessionRecord? = databaseFactory.query {
val session = AdminSessionsTable.selectAll()
.where {
(AdminSessionsTable.tokenHash eq tokenHash) and
AdminSessionsTable.revokedAt.isNull() and
(AdminSessionsTable.expiresAt greater now)
}
.limit(1)
.singleOrNull()
?: return@query null
val operator = activeOperator(session[AdminSessionsTable.operatorId]) ?: return@query null
session.toSessionRecord(
role = AdminRole.valueOf(operator[AdminOperatorsTable.role]),
normalizedUsername = operator[AdminOperatorsTable.username],
)
}
override suspend fun revokeSessionByTokenHash(
tokenHash: String,
now: Instant,
auditEvent: NewAdminAuditEvent,
): AdminSessionRecord? = databaseFactory.query {
val session = AdminSessionsTable.selectAll()
.where {
(AdminSessionsTable.tokenHash eq tokenHash) and
AdminSessionsTable.revokedAt.isNull()
}
.forUpdate()
.singleOrNull()
?: return@query null
val operator = AdminOperatorsTable.selectAll()
.where { AdminOperatorsTable.id eq session[AdminSessionsTable.operatorId] }
.singleOrNull()
?: return@query null
AdminSessionsTable.update({ AdminSessionsTable.id eq session[AdminSessionsTable.id] }) {
it[revokedAt] = now
}
insertAudit(auditEvent)
session.toSessionRecord(
role = AdminRole.valueOf(operator[AdminOperatorsTable.role]),
normalizedUsername = operator[AdminOperatorsTable.username],
)
}
override suspend fun purgeInactiveSessions(cutoff: Instant, limit: Int): Int =
databaseFactory.query {
require(limit in 1..1_000)
val candidateIds = AdminSessionsTable.selectAll()
.where {
(AdminSessionsTable.expiresAt lessEq cutoff) or
(
AdminSessionsTable.revokedAt.isNotNull() and
(AdminSessionsTable.revokedAt lessEq cutoff)
)
}
.orderBy(
AdminSessionsTable.expiresAt to SortOrder.ASC,
AdminSessionsTable.id to SortOrder.ASC,
)
.limit(limit)
.map { it[AdminSessionsTable.id] }
if (candidateIds.isEmpty()) {
0
} else {
AdminSessionsTable.deleteWhere {
AdminSessionsTable.id inList candidateIds
}
}
}
override suspend fun appendAudit(event: NewAdminAuditEvent) {
databaseFactory.query {
insertAudit(event)
}
}
override suspend fun listAudit(
limit: Int,
before: AdminAuditCursor?,
): List<AdminAuditRecord> =
databaseFactory.query {
require(limit in 1..101)
val query = AdminAuditLogTable.selectAll()
if (before != null) {
query.andWhere {
(AdminAuditLogTable.occurredAt less before.occurredAt) or
(
(AdminAuditLogTable.occurredAt eq before.occurredAt) and
(AdminAuditLogTable.id less before.id.toString())
)
}
}
query
.orderBy(
AdminAuditLogTable.occurredAt to SortOrder.DESC,
AdminAuditLogTable.id to SortOrder.DESC,
)
.limit(limit)
.map {
AdminAuditRecord(
id = UUID.fromString(it[AdminAuditLogTable.id]),
actorOperatorId = it[AdminAuditLogTable.actorOperatorId]?.let(UUID::fromString),
action = AdminAuditAction.valueOf(it[AdminAuditLogTable.action]),
outcome = AdminAuditOutcome.valueOf(it[AdminAuditLogTable.outcome]),
targetType = it[AdminAuditLogTable.targetType],
targetId = it[AdminAuditLogTable.targetId],
requestId = it[AdminAuditLogTable.requestId],
occurredAt = it[AdminAuditLogTable.occurredAt],
)
}
}
private fun insertAudit(event: NewAdminAuditEvent) {
AdminAuditLogTable.insert {
it[id] = event.id.toString()
it[actorOperatorId] = event.actorOperatorId?.toString()
it[action] = event.action.name
it[outcome] = event.outcome.name
it[targetType] = event.targetType
it[targetId] = event.targetId
it[requestId] = event.requestId
it[occurredAt] = event.occurredAt
}
}
private fun insertOperatorIgnoringConflict(operator: NewAdminOperator): Boolean =
AdminOperatorsTable.insertIgnore {
it[id] = operator.id.toString()
it[username] = operator.normalizedUsername
it[passwordHash] = operator.passwordHash
it[encryptedTotpSecret] = operator.encryptedTotpSecret
it[role] = operator.role.name
it[failedLoginCount] = 0
it[lockedUntil] = null
it[lastTotpCounter] = null
it[lastLoginAt] = null
it[disabledAt] = null
it[createdAt] = operator.createdAt
it[updatedAt] = operator.createdAt
}.insertedCount > 0
private fun lockOperator(operatorId: UUID): ResultRow? =
AdminOperatorsTable.selectAll()
.where { AdminOperatorsTable.id eq operatorId.toString() }
.forUpdate()
.singleOrNull()
private fun enabledSuperAdministratorCount(): Int =
AdminOperatorsTable.selectAll()
.where {
(AdminOperatorsTable.role eq AdminRole.SUPER_ADMIN.name) and
AdminOperatorsTable.disabledAt.isNull()
}
.count()
.toInt()
private fun revokeActiveSessions(operatorId: UUID, now: Instant) {
AdminSessionsTable.update({
(AdminSessionsTable.operatorId eq operatorId.toString()) and
AdminSessionsTable.revokedAt.isNull()
}) {
it[revokedAt] = now
}
}
private fun activeOperator(operatorId: String): ResultRow? =
AdminOperatorsTable.selectAll()
.where {
(AdminOperatorsTable.id eq operatorId) and
AdminOperatorsTable.disabledAt.isNull()
}
.limit(1)
.singleOrNull()
}
private fun NewAdminAuditEvent.withResult(
result: AdminOperatorMutationResult,
): NewAdminAuditEvent = copy(
outcome = if (result == AdminOperatorMutationResult.SUCCESS) {
AdminAuditOutcome.SUCCESS
} else {
AdminAuditOutcome.DENIED
},
)
private fun ResultRow.toOperatorRecord(): AdminOperatorRecord = AdminOperatorRecord(
id = UUID.fromString(this[AdminOperatorsTable.id]),
normalizedUsername = this[AdminOperatorsTable.username],
role = AdminRole.valueOf(this[AdminOperatorsTable.role]),
lockState = AdminLockState(
failedLoginCount = this[AdminOperatorsTable.failedLoginCount],
lockedUntil = this[AdminOperatorsTable.lockedUntil],
),
disabledAt = this[AdminOperatorsTable.disabledAt],
lastLoginAt = this[AdminOperatorsTable.lastLoginAt],
createdAt = this[AdminOperatorsTable.createdAt],
updatedAt = this[AdminOperatorsTable.updatedAt],
)
private fun ResultRow.toAuthRecord(): AdminOperatorAuthRecord = AdminOperatorAuthRecord(
id = UUID.fromString(this[AdminOperatorsTable.id]),
normalizedUsername = this[AdminOperatorsTable.username],
passwordHash = this[AdminOperatorsTable.passwordHash],
encryptedTotpSecret = this[AdminOperatorsTable.encryptedTotpSecret],
role = AdminRole.valueOf(this[AdminOperatorsTable.role]),
lockState = AdminLockState(
failedLoginCount = this[AdminOperatorsTable.failedLoginCount],
lockedUntil = this[AdminOperatorsTable.lockedUntil],
),
disabledAt = this[AdminOperatorsTable.disabledAt],
)
private fun ResultRow.toSessionRecord(
role: AdminRole,
normalizedUsername: String,
): AdminSessionRecord = AdminSessionRecord(
id = UUID.fromString(this[AdminSessionsTable.id]),
operatorId = UUID.fromString(this[AdminSessionsTable.operatorId]),
normalizedUsername = normalizedUsername,
role = role,
csrfTokenHash = this[AdminSessionsTable.csrfTokenHash],
expiresAt = this[AdminSessionsTable.expiresAt],
)
@@ -0,0 +1,887 @@
package com.osglab.account.features.admin.routes
import com.osglab.account.config.AppConfig
import com.osglab.account.features.admin.grants.models.ManualGrantCommand
import com.osglab.account.features.admin.grants.services.AdminGrantService
import com.osglab.account.features.admin.models.AdminLoginResult
import com.osglab.account.features.admin.models.AdminPrincipal
import com.osglab.account.features.admin.models.AdminRole
import com.osglab.account.features.admin.models.AdminOperatorRecord
import com.osglab.account.features.admin.services.AdminAuditCursorException
import com.osglab.account.features.admin.services.AdminAuditService
import com.osglab.account.features.admin.services.AdminAuthService
import com.osglab.account.features.admin.services.AdminOperatorCredentials
import com.osglab.account.features.admin.services.AdminOperatorCursorException
import com.osglab.account.features.admin.services.AdminOperatorErrorCode
import com.osglab.account.features.admin.services.AdminOperatorException
import com.osglab.account.features.admin.services.AdminOperatorService
import com.osglab.account.features.admin.services.AdminSessionService
import com.osglab.account.features.admin.stats.models.AdminStatsDto
import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto
import com.osglab.account.features.admin.stats.services.AdminStatsService
import com.osglab.account.features.admin.users.models.AdminUserDetailDto
import com.osglab.account.features.admin.users.models.AdminUserLedgerEntryDto
import com.osglab.account.features.admin.users.models.AdminUserReferralDto
import com.osglab.account.features.admin.users.models.AdminUserSummaryDto
import com.osglab.account.features.admin.users.services.AdminUserNotFoundException
import com.osglab.account.features.admin.users.services.AdminUsersService
import com.osglab.account.features.credits.domain.CreditConflict
import com.osglab.account.features.credits.domain.CreditNotFound
import com.osglab.account.features.credits.domain.InvalidCreditRequest
import io.ktor.http.Cookie
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.ApplicationCall
import io.ktor.server.application.call
import io.ktor.server.http.content.staticResources
import io.ktor.server.plugins.BadRequestException
import io.ktor.server.plugins.ratelimit.RateLimitName
import io.ktor.server.plugins.ratelimit.rateLimit
import io.ktor.server.request.header
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.delete
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import io.ktor.server.routing.route
import kotlinx.serialization.Serializable
import java.time.Clock
import java.time.Duration
import java.util.UUID
fun Route.adminWebRoutes() {
staticResources("/admin", "admin", index = "index.html")
}
fun Route.adminApiRoutes(
config: AppConfig,
authService: AdminAuthService,
sessionService: AdminSessionService,
statsService: AdminStatsService,
usersService: AdminUsersService,
grantService: AdminGrantService,
operatorService: AdminOperatorService,
auditService: AdminAuditService,
clock: Clock = Clock.systemUTC(),
) {
route("/v1/admin") {
rateLimit(ADMIN_AUTH_RATE_LIMIT) {
route("/auth") {
get("/session") {
if (!call.requireVerifiedAdminEdge()) return@get
val principal = call.currentPrincipal(sessionService)
call.respond(
AdminSessionResponse(
authenticated = principal != null,
operatorName = principal?.normalizedUsername,
role = principal?.role?.name,
),
)
}
post("/login") {
if (!call.requireVerifiedAdminEdge() || !call.requireSameOrigin(config)) return@post
val request = call.receive<AdminLoginRequest>()
val password = request.password.toCharArray()
val result = try {
authService.login(
username = request.username,
password = password,
totpCode = request.totpCode,
requestId = call.request.header("X-Request-ID"),
)
} finally {
password.fill('\u0000')
}
when (result) {
is AdminLoginResult.Authenticated -> {
call.setAdminCookies(config, result.credentials.sessionToken, result.credentials.csrfToken)
call.respond(
AdminLoginResponse(
operatorName = result.principal.normalizedUsername,
role = result.principal.role.name,
csrfToken = result.credentials.csrfToken,
),
)
}
is AdminLoginResult.Locked -> call.respond(
HttpStatusCode.TooManyRequests,
AdminErrorResponse("RATE_LIMITED"),
)
AdminLoginResult.InvalidCredentials -> call.respond(
HttpStatusCode.Unauthorized,
AdminErrorResponse("INVALID_CREDENTIALS"),
)
}
}
post("/logout") {
if (!call.requireVerifiedAdminEdge() || !call.requireSameOrigin(config)) return@post
val sessionToken = call.request.cookies[SESSION_COOKIE]
val csrfToken = call.request.header(CSRF_HEADER)
if (
sessionToken == null ||
csrfToken == null ||
!sessionService.revoke(
sessionToken,
csrfToken,
call.request.header("X-Request-ID"),
)
) {
call.respond(HttpStatusCode.Unauthorized, AdminErrorResponse("UNAUTHORIZED"))
return@post
}
call.clearAdminCookies(config)
call.respond(HttpStatusCode.NoContent)
}
}
}
get("/overview") {
if (call.requirePrincipal(sessionService) == null) return@get
val stats = statsService.getRange(call.request.queryParameters["range"], clock)
?: run {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
return@get
}
call.respond(stats.toOverviewResponse())
}
get("/referrals") {
if (call.requirePrincipal(sessionService) == null) return@get
val stats = statsService.getRange(call.request.queryParameters["range"], clock)
?: run {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
return@get
}
call.respond(stats.toReferralResponse())
}
get("/users") {
if (
call.requireRole(
sessionService,
setOf(AdminRole.SUPER_ADMIN, AdminRole.SUPPORT),
) == null
) return@get
val limit = call.pageLimit(maximum = 100) ?: run {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
return@get
}
val query = call.request.queryParameters["q"]?.trim().orEmpty()
val page = try {
if (query.isEmpty()) {
usersService.list(
limit = limit,
cursor = call.request.queryParameters["cursor"],
)
} else {
usersService.searchByInternalId(query)
}
} catch (_: IllegalArgumentException) {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
return@get
}
call.respond(
PageResponse(
items = page.items.map(AdminUserSummaryDto::toUserSummaryResponse),
nextCursor = page.nextCursor,
),
)
}
get("/users/{userId}") {
if (
call.requireRole(
sessionService,
setOf(AdminRole.SUPER_ADMIN, AdminRole.SUPPORT),
) == null
) return@get
val userId = call.uuidPathParameter("userId") ?: return@get
try {
call.respond(usersService.detail(userId).toUserDetailResponse())
} catch (_: AdminUserNotFoundException) {
call.respond(HttpStatusCode.NotFound, AdminErrorResponse("USER_NOT_FOUND"))
}
}
get("/users/{userId}/ledger") {
if (
call.requireRole(
sessionService,
setOf(AdminRole.SUPER_ADMIN, AdminRole.SUPPORT),
) == null
) return@get
val userId = call.uuidPathParameter("userId") ?: return@get
val limit = call.pageLimit(maximum = 100, default = 100) ?: run {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
return@get
}
try {
val page = usersService.ledger(
userId = userId,
limit = limit,
cursor = call.request.queryParameters["cursor"],
)
call.respond(
PageResponse(
page.items.map(AdminUserLedgerEntryDto::toLedgerResponse),
page.nextCursor,
),
)
} catch (_: AdminUserNotFoundException) {
call.respond(HttpStatusCode.NotFound, AdminErrorResponse("USER_NOT_FOUND"))
} catch (_: IllegalArgumentException) {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
}
}
post("/credits/grants") {
val principal = call.requireMutationPrincipal(config, sessionService) ?: return@post
if (principal.role != AdminRole.SUPER_ADMIN) {
call.respond(HttpStatusCode.Forbidden, AdminErrorResponse("INSUFFICIENT_PERMISSION"))
return@post
}
val request = call.receive<AdminGrantRequest>()
val idempotencyKey = call.request.header("Idempotency-Key")
val userId = runCatching { UUID.fromString(request.userId) }.getOrNull()
if (
userId == null ||
request.amount !in 1..config.admin.maximumManualGrant ||
request.reason.trim().length !in 4..200 ||
idempotencyKey == null ||
idempotencyKey.length !in 8..128
) {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
return@post
}
val result = try {
grantService.grant(
ManualGrantCommand(
operatorId = principal.operatorId,
userId = userId,
credits = request.amount,
reason = request.reason.trim(),
requestId = call.request.header("X-Request-ID"),
idempotencyKey = idempotencyKey,
),
)
} catch (_: CreditNotFound) {
call.respond(HttpStatusCode.NotFound, AdminErrorResponse("USER_NOT_FOUND"))
return@post
} catch (_: CreditConflict) {
call.respond(HttpStatusCode.Conflict, AdminErrorResponse("IDEMPOTENCY_CONFLICT"))
return@post
} catch (_: InvalidCreditRequest) {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
return@post
}
call.respond(
AdminGrantResponse(
transactionId = result.ledgerEntryId,
balanceAfter = result.balanceAfter,
),
)
}
get("/operators/summary") {
val principal = call.requirePrincipal(sessionService) ?: return@get
try {
val summary = operatorService.summary(principal)
call.respond(
AdminSecuritySummaryResponse(
enabledOperators = summary.enabledOperators,
lockedOperators = summary.lockedOperators,
activeSessions = summary.activeSessions,
),
)
} catch (exception: AdminOperatorException) {
call.respondOperatorError(exception)
}
}
get("/operators") {
val principal = call.requirePrincipal(sessionService) ?: return@get
val limit = call.pageLimit(maximum = 100) ?: run {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
return@get
}
try {
val page = operatorService.listPage(
actor = principal,
cursor = call.request.queryParameters["cursor"],
limit = limit,
)
call.respond(
PageResponse(
items = page.items.map(AdminOperatorRecord::toResponse),
nextCursor = page.nextCursor,
),
)
} catch (_: AdminOperatorCursorException) {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
} catch (exception: AdminOperatorException) {
call.respondOperatorError(exception)
}
}
post("/operators") {
val principal = call.requireMutationPrincipal(config, sessionService) ?: return@post
val request = call.receiveAdminRequest<AdminOperatorCreateRequest>() ?: return@post
val password = request.password.toCharArray()
try {
val created = operatorService.create(
actor = principal,
username = request.username,
password = password,
roleName = request.role,
requestId = call.request.header("X-Request-ID"),
)
call.respond(HttpStatusCode.Created, created.toProvisioningResponse())
} catch (exception: AdminOperatorException) {
call.respondOperatorError(exception)
} finally {
password.fill('\u0000')
}
}
post("/operators/{operatorId}/enable") {
val principal = call.requireMutationPrincipal(config, sessionService) ?: return@post
val operatorId = call.uuidPathParameter("operatorId") ?: return@post
try {
operatorService.setEnabled(
principal,
operatorId,
enabled = true,
call.request.header("X-Request-ID"),
)
call.respond(HttpStatusCode.NoContent)
} catch (exception: AdminOperatorException) {
call.respondOperatorError(exception)
}
}
post("/operators/{operatorId}/disable") {
val principal = call.requireMutationPrincipal(config, sessionService) ?: return@post
val operatorId = call.uuidPathParameter("operatorId") ?: return@post
try {
operatorService.setEnabled(
principal,
operatorId,
enabled = false,
call.request.header("X-Request-ID"),
)
call.respond(HttpStatusCode.NoContent)
} catch (exception: AdminOperatorException) {
call.respondOperatorError(exception)
}
}
post("/operators/{operatorId}/unlock") {
val principal = call.requireMutationPrincipal(config, sessionService) ?: return@post
val operatorId = call.uuidPathParameter("operatorId") ?: return@post
try {
operatorService.unlock(
principal,
operatorId,
call.request.header("X-Request-ID"),
)
call.respond(HttpStatusCode.NoContent)
} catch (exception: AdminOperatorException) {
call.respondOperatorError(exception)
}
}
post("/operators/{operatorId}/credentials/reset") {
val principal = call.requireMutationPrincipal(config, sessionService) ?: return@post
val operatorId = call.uuidPathParameter("operatorId") ?: return@post
val request = call.receiveAdminRequest<AdminOperatorPasswordRequest>() ?: return@post
val password = request.password.toCharArray()
try {
val credentials = operatorService.resetCredentials(
principal,
operatorId,
password,
call.request.header("X-Request-ID"),
)
call.respond(credentials.toProvisioningResponse(operatorId))
} catch (exception: AdminOperatorException) {
call.respondOperatorError(exception)
} finally {
password.fill('\u0000')
}
}
post("/operators/{operatorId}/sessions/revoke") {
val principal = call.requireMutationPrincipal(config, sessionService) ?: return@post
val operatorId = call.uuidPathParameter("operatorId") ?: return@post
try {
operatorService.revokeSessions(
principal,
operatorId,
call.request.header("X-Request-ID"),
)
call.respond(HttpStatusCode.NoContent)
} catch (exception: AdminOperatorException) {
call.respondOperatorError(exception)
}
}
get("/audit") {
val principal = call.requirePrincipal(sessionService) ?: return@get
val limit = call.pageLimit(maximum = 100) ?: run {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
return@get
}
val page = try {
auditService.list(
actor = principal,
cursor = call.request.queryParameters["cursor"],
limit = limit,
)
} catch (exception: AdminOperatorException) {
call.respondOperatorError(exception)
return@get
} catch (_: AdminAuditCursorException) {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
return@get
}
call.respond(
PageResponse(
items = page.items.map { item ->
val audit = item.record
AdminAuditResponse(
auditId = audit.id.toString(),
operatorName = item.operatorName,
action = audit.action.name,
targetType = audit.targetType ?: "NONE",
targetId = audit.targetId ?: "",
requestId = audit.requestId,
result = if (audit.outcome.name == "SUCCESS") "success" else "rejected",
createdAt = audit.occurredAt.toString(),
)
},
nextCursor = page.nextCursor,
),
)
}
}
}
private suspend fun AdminStatsService.getRange(
range: String?,
clock: Clock,
): AdminStatsDto? {
val days = when (range) {
null, "30d" -> 30L
"7d" -> 7L
"90d" -> 90L
else -> return null
}
val until = clock.instant()
return get(until.minus(Duration.ofDays(days)), until)
}
private suspend fun ApplicationCall.requirePrincipal(
sessions: AdminSessionService,
): AdminPrincipal? {
if (!requireVerifiedAdminEdge()) return null
val principal = currentPrincipal(sessions)
if (principal == null) {
respond(HttpStatusCode.Unauthorized, AdminErrorResponse("UNAUTHORIZED"))
}
return principal
}
private suspend fun ApplicationCall.requireRole(
sessions: AdminSessionService,
allowedRoles: Set<AdminRole>,
): AdminPrincipal? {
val principal = requirePrincipal(sessions) ?: return null
if (principal.role !in allowedRoles) {
respond(HttpStatusCode.Forbidden, AdminErrorResponse("INSUFFICIENT_PERMISSION"))
return null
}
return principal
}
private suspend fun ApplicationCall.requireMutationPrincipal(
config: AppConfig,
sessions: AdminSessionService,
): AdminPrincipal? {
if (!requireVerifiedAdminEdge() || !requireSameOrigin(config)) return null
val sessionToken = request.cookies[SESSION_COOKIE]
val csrfToken = request.header(CSRF_HEADER)
val principal = if (sessionToken != null && csrfToken != null) {
sessions.authenticateMutation(sessionToken, csrfToken)
} else {
null
}
if (principal == null) {
respond(HttpStatusCode.Forbidden, AdminErrorResponse("CSRF_INVALID"))
}
return principal
}
private suspend fun ApplicationCall.requireVerifiedAdminEdge(): Boolean {
if (request.header(MTLS_HEADER) == MTLS_VERIFIED) return true
respond(HttpStatusCode.NotFound)
return false
}
private suspend fun ApplicationCall.requireSameOrigin(config: AppConfig): Boolean {
if (request.header(HttpHeaders.Origin) == config.publicBaseUrl) return true
respond(HttpStatusCode.Forbidden, AdminErrorResponse("ORIGIN_INVALID"))
return false
}
private suspend fun ApplicationCall.currentPrincipal(
sessions: AdminSessionService,
): AdminPrincipal? = request.cookies[SESSION_COOKIE]?.let { sessions.authenticate(it) }
private suspend fun ApplicationCall.uuidPathParameter(name: String): UUID? {
val value = parameters[name]
val parsed = value?.let { runCatching { UUID.fromString(it) }.getOrNull() }
if (parsed == null) respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
return parsed
}
private fun ApplicationCall.pageLimit(
maximum: Int,
default: Int = 50,
): Int? {
val raw = request.queryParameters["limit"] ?: return default
return raw.toIntOrNull()?.takeIf { it in 1..maximum }
}
private suspend inline fun <reified T : Any> ApplicationCall.receiveAdminRequest(): T? =
try {
receive<T>()
} catch (_: BadRequestException) {
respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
null
}
private suspend fun ApplicationCall.respondOperatorError(exception: AdminOperatorException) {
val status = when (exception.code) {
AdminOperatorErrorCode.VALIDATION_ERROR -> HttpStatusCode.BadRequest
AdminOperatorErrorCode.INSUFFICIENT_PERMISSION -> HttpStatusCode.Forbidden
AdminOperatorErrorCode.ADMIN_OPERATOR_NOT_FOUND -> HttpStatusCode.NotFound
AdminOperatorErrorCode.ADMIN_USERNAME_CONFLICT,
AdminOperatorErrorCode.CANNOT_DISABLE_SELF,
AdminOperatorErrorCode.LAST_SUPER_ADMIN_REQUIRED,
-> HttpStatusCode.Conflict
}
respond(status, AdminErrorResponse(exception.code.name))
}
private fun ApplicationCall.setAdminCookies(config: AppConfig, sessionToken: String, csrfToken: String) {
response.cookies.append(adminCookie(SESSION_COOKIE, sessionToken, config, httpOnly = true))
response.cookies.append(adminCookie(CSRF_COOKIE, csrfToken, config, httpOnly = false))
}
private fun ApplicationCall.clearAdminCookies(config: AppConfig) {
response.cookies.append(adminCookie(SESSION_COOKIE, "", config, httpOnly = true, maxAge = 0))
response.cookies.append(adminCookie(CSRF_COOKIE, "", config, httpOnly = false, maxAge = 0))
}
private fun adminCookie(
name: String,
value: String,
config: AppConfig,
httpOnly: Boolean,
maxAge: Int? = null,
): Cookie = Cookie(
name = name,
value = value,
path = "/",
maxAge = maxAge,
secure = config.isProduction,
httpOnly = httpOnly,
extensions = mapOf("SameSite" to "Strict"),
)
private fun AdminStatsDto.toOverviewResponse(): AdminOverviewResponse {
val consumedByDate = creditFlow.associateBy { it.date }
return AdminOverviewResponse(
totalUsers = overview.totalUsers,
activeUsers = overview.activeUsers,
newUsers = overview.registrations,
totalCreditBalance = overview.totalCreditBalance,
creditsGranted = overview.issuedCredits,
creditsUsed = overview.consumedCredits,
trend = registrationTrend.map {
AdminTrendResponse(
date = it.date,
registrations = it.registrations,
creditsUsed = consumedByDate[it.date]?.consumedCredits ?: 0,
)
},
usage = usage,
)
}
private fun AdminStatsDto.toReferralResponse(): AdminReferralResponse =
AdminReferralResponse(
pendingBindings = referralFunnel.pendingBindings,
ineligibleBindings = referralFunnel.ineligibleBindings,
funnel = listOf(
AdminFunnelResponse("邀请码创建", referralFunnel.codesCreated),
AdminFunnelResponse("成功绑定", referralFunnel.bindings),
AdminFunnelResponse("有效使用并奖励", referralFunnel.rewardedBindings),
AdminFunnelResponse("待资格确认", referralFunnel.pendingBindings),
AdminFunnelResponse("不符合奖励条件", referralFunnel.ineligibleBindings),
),
ranking = referralRanking.map {
AdminReferralRankResponse(
userId = it.userId,
invited = it.invitedUsers,
qualified = it.rewardedUsers,
creditsEarned = it.earnedCredits,
)
},
)
private fun AdminUserSummaryDto.toUserSummaryResponse(): AdminUserSummaryResponse =
AdminUserSummaryResponse(
userId = id,
displayName = "用户 ${id.take(8)}",
status = if (antiAbuseRestricted) "suspended" else "active",
creditBalance = creditBalance,
createdAt = createdAt,
)
private fun AdminUserDetailDto.toUserDetailResponse(): AdminUserDetailResponse =
AdminUserDetailResponse(
summary = summary.toUserSummaryResponse(),
lastActiveAt = summary.lastActiveAt,
qualifiedUsage = summary.usageRequests > 0,
referralCode = referralCode,
referredByUserId = referral.inviterUserId,
usage = usage,
referral = referral,
)
private fun AdminUserLedgerEntryDto.toLedgerResponse(): AdminLedgerResponse =
AdminLedgerResponse(
entryId = id,
type = when (type) {
"USAGE_RESERVE" -> "reserve"
"USAGE_SETTLE" -> "settle"
"USAGE_RELEASE", "USAGE_REFUND" -> "refund"
"SIGNUP_TRIAL", "MANUAL_GRANT", "REFERRAL_INVITER", "REFERRAL_INVITEE",
"STOREKIT_PURCHASE", "SUBSCRIPTION_GRANT" -> "grant"
else -> "adjustment"
},
amount = amountDelta,
balanceAfter = balanceAfter,
reasonCode = type,
createdAt = createdAt,
)
private fun AdminOperatorRecord.toResponse(): AdminOperatorResponse =
AdminOperatorResponse(
operatorId = id.toString(),
username = normalizedUsername,
role = role.name,
enabled = disabledAt == null,
failedLoginCount = lockState.failedLoginCount,
lockedUntil = lockState.lockedUntil?.toString(),
lastLoginAt = lastLoginAt?.toString(),
createdAt = createdAt.toString(),
updatedAt = updatedAt.toString(),
)
private fun AdminOperatorCredentials.toProvisioningResponse(
fallbackOperatorId: UUID? = null,
): AdminOperatorProvisioningResponse = AdminOperatorProvisioningResponse(
operator = operator?.toResponse(),
operatorId = operator?.id?.toString() ?: requireNotNull(fallbackOperatorId).toString(),
totpSecret = totpSecret,
otpauthUri = otpauthUri,
)
@Serializable
private data class AdminLoginRequest(
val username: String,
val password: String,
val totpCode: String,
)
@Serializable
private data class AdminLoginResponse(
val operatorName: String,
val role: String,
val csrfToken: String,
)
@Serializable
private data class AdminSessionResponse(
val authenticated: Boolean,
val operatorName: String? = null,
val role: String? = null,
)
@Serializable
private data class AdminErrorResponse(val code: String)
@Serializable
private data class AdminGrantRequest(val userId: String, val amount: Long, val reason: String)
@Serializable
private data class AdminGrantResponse(val transactionId: String, val balanceAfter: Long)
@Serializable
private data class AdminOperatorCreateRequest(
val username: String,
val password: String,
val role: String,
)
@Serializable
private data class AdminOperatorPasswordRequest(val password: String)
@Serializable
private data class AdminOperatorResponse(
val operatorId: String,
val username: String,
val role: String,
val enabled: Boolean,
val failedLoginCount: Int,
val lockedUntil: String?,
val lastLoginAt: String?,
val createdAt: String,
val updatedAt: String,
)
@Serializable
private data class AdminOperatorProvisioningResponse(
val operatorId: String,
val operator: AdminOperatorResponse? = null,
val totpSecret: String,
val otpauthUri: String,
)
@Serializable
private data class AdminSecuritySummaryResponse(
val enabledOperators: Int,
val lockedOperators: Int,
val activeSessions: Long,
)
@Serializable
private data class PageResponse<T>(val items: List<T>, val nextCursor: String? = null)
@Serializable
private data class AdminOverviewResponse(
val totalUsers: Long,
val activeUsers: Long,
val newUsers: Long,
val totalCreditBalance: Long,
val creditsGranted: Long,
val creditsUsed: Long,
val trend: List<AdminTrendResponse>,
val usage: List<AdminUsageAggregateDto>,
)
@Serializable
private data class AdminTrendResponse(
val date: String,
val registrations: Long,
val creditsUsed: Long,
)
@Serializable
private data class AdminReferralResponse(
val pendingBindings: Long,
val ineligibleBindings: Long,
val funnel: List<AdminFunnelResponse>,
val ranking: List<AdminReferralRankResponse>,
)
@Serializable
private data class AdminFunnelResponse(val label: String, val count: Long)
@Serializable
private data class AdminReferralRankResponse(
val userId: String,
val invited: Long,
val qualified: Long,
val creditsEarned: Long,
)
@Serializable
private data class AdminUserSummaryResponse(
val userId: String,
val displayName: String,
val status: String,
val creditBalance: Long,
val createdAt: String,
)
@Serializable
private data class AdminUserDetailResponse(
val userId: String,
val displayName: String,
val status: String,
val creditBalance: Long,
val createdAt: String,
val lastActiveAt: String?,
val qualifiedUsage: Boolean,
val referralCode: String?,
val referredByUserId: String?,
val usage: List<AdminUsageAggregateDto>,
val referral: AdminUserReferralDto,
) {
constructor(
summary: AdminUserSummaryResponse,
lastActiveAt: String?,
qualifiedUsage: Boolean,
referralCode: String?,
referredByUserId: String?,
usage: List<AdminUsageAggregateDto>,
referral: AdminUserReferralDto,
) : this(
userId = summary.userId,
displayName = summary.displayName,
status = summary.status,
creditBalance = summary.creditBalance,
createdAt = summary.createdAt,
lastActiveAt = lastActiveAt,
qualifiedUsage = qualifiedUsage,
referralCode = referralCode,
referredByUserId = referredByUserId,
usage = usage,
referral = referral,
)
}
@Serializable
private data class AdminLedgerResponse(
val entryId: String,
val type: String,
val amount: Long,
val balanceAfter: Long,
val reasonCode: String,
val createdAt: String,
)
@Serializable
private data class AdminAuditResponse(
val auditId: String,
val operatorName: String,
val action: String,
val targetType: String,
val targetId: String,
val requestId: String? = null,
val result: String,
val createdAt: String,
)
private const val MTLS_HEADER = "X-OSG-mTLS-Verified"
private const val MTLS_VERIFIED = "SUCCESS"
private const val SESSION_COOKIE = "osg_admin_session"
private const val CSRF_COOKIE = "osg_admin_csrf"
private const val CSRF_HEADER = "X-CSRF-Token"
private val ADMIN_AUTH_RATE_LIMIT = RateLimitName("admin-auth")
@@ -0,0 +1,20 @@
package com.osglab.account.features.admin.security
import com.osglab.account.common.security.TokenHash
/**
* Validates a double-submit CSRF token against the digest bound to the server
* session. Cookie/header extraction remains a route-layer concern.
*/
class AdminCsrfVerifier {
fun verify(presentedToken: String, expectedHash: String): Boolean {
if (presentedToken.isBlank() || presentedToken.length > MAX_TOKEN_LENGTH) return false
if (!SHA256_HEX.matches(expectedHash)) return false
return TokenHash.matches(presentedToken, expectedHash)
}
private companion object {
const val MAX_TOKEN_LENGTH = 512
val SHA256_HEX = Regex("^[0-9a-f]{64}$")
}
}
@@ -0,0 +1,131 @@
package com.osglab.account.features.admin.security
import org.bouncycastle.crypto.generators.Argon2BytesGenerator
import org.bouncycastle.crypto.params.Argon2Parameters
import java.security.MessageDigest
import java.security.SecureRandom
import java.util.Base64
interface AdminPasswordHasher {
fun hash(password: CharArray): String
fun verify(password: CharArray, encodedHash: String): Boolean
}
data class Argon2idConfig(
val memoryKb: Int = 65_536,
val iterations: Int = 3,
val parallelism: Int = 1,
val saltBytes: Int = 16,
val hashBytes: Int = 32,
) {
init {
require(memoryKb in MIN_MEMORY_KB..MAX_MEMORY_KB)
require(iterations in 1..MAX_ITERATIONS)
require(parallelism in 1..MAX_PARALLELISM)
require(memoryKb >= parallelism * 8)
require(saltBytes in 16..MAX_SALT_BYTES)
require(hashBytes in 16..MAX_HASH_BYTES)
}
private companion object {
const val MIN_MEMORY_KB = 8
const val MAX_MEMORY_KB = 262_144
const val MAX_ITERATIONS = 10
const val MAX_PARALLELISM = 16
const val MAX_SALT_BYTES = 64
const val MAX_HASH_BYTES = 64
}
}
/**
* PHC-compatible Argon2id password hashing backed by the project's existing
* Bouncy Castle provider. Verification bounds parsed work factors to prevent a
* malformed database value from causing excessive CPU or memory consumption.
*/
class BouncyCastleArgon2idPasswordHasher(
private val config: Argon2idConfig = Argon2idConfig(),
private val secureRandom: SecureRandom = SecureRandom(),
) : AdminPasswordHasher {
override fun hash(password: CharArray): String {
requirePasswordLength(password)
val salt = ByteArray(config.saltBytes).also(secureRandom::nextBytes)
val digest = derive(password, salt, config)
return try {
val encoder = Base64.getEncoder().withoutPadding()
"\$argon2id\$v=19\$m=${config.memoryKb},t=${config.iterations},p=${config.parallelism}\$" +
"${encoder.encodeToString(salt)}\$${encoder.encodeToString(digest)}"
} finally {
salt.fill(0)
digest.fill(0)
}
}
override fun verify(password: CharArray, encodedHash: String): Boolean {
if (password.isEmpty() || password.size > MAX_PASSWORD_CHARS) return false
val parsed = runCatching { parse(encodedHash) }.getOrNull() ?: return false
val actual = runCatching { derive(password, parsed.salt, parsed.config) }.getOrNull()
?: return false
return try {
MessageDigest.isEqual(actual, parsed.digest)
} finally {
actual.fill(0)
parsed.salt.fill(0)
parsed.digest.fill(0)
}
}
private fun derive(password: CharArray, salt: ByteArray, parameters: Argon2idConfig): ByteArray {
val generator = Argon2BytesGenerator()
generator.init(
Argon2Parameters.Builder(Argon2Parameters.ARGON2_id)
.withVersion(Argon2Parameters.ARGON2_VERSION_13)
.withMemoryAsKB(parameters.memoryKb)
.withIterations(parameters.iterations)
.withParallelism(parameters.parallelism)
.withSalt(salt)
.build(),
)
return ByteArray(parameters.hashBytes).also { generator.generateBytes(password, it) }
}
private fun parse(encodedHash: String): ParsedHash {
require(encodedHash.length <= MAX_ENCODED_HASH_CHARS)
val parts = encodedHash.split('$')
require(parts.size == 6 && parts[0].isEmpty())
require(parts[1] == "argon2id" && parts[2] == "v=19")
val parameterComponents = parts[3].split(',')
require(parameterComponents.size == 3)
val values = parameterComponents.associate { component ->
val pair = component.split('=', limit = 2)
require(pair.size == 2)
pair[0] to pair[1].toInt()
}
require(values.keys == setOf("m", "t", "p"))
val salt = Base64.getDecoder().decode(parts[4])
val digest = Base64.getDecoder().decode(parts[5])
val parsedConfig = Argon2idConfig(
memoryKb = requireNotNull(values["m"]),
iterations = requireNotNull(values["t"]),
parallelism = requireNotNull(values["p"]),
saltBytes = salt.size,
hashBytes = digest.size,
)
return ParsedHash(parsedConfig, salt, digest)
}
private fun requirePasswordLength(password: CharArray) {
require(password.isNotEmpty()) { "Password must not be empty" }
require(password.size <= MAX_PASSWORD_CHARS) { "Password exceeds maximum length" }
}
private data class ParsedHash(
val config: Argon2idConfig,
val salt: ByteArray,
val digest: ByteArray,
)
private companion object {
const val MAX_PASSWORD_CHARS = 1_024
const val MAX_ENCODED_HASH_CHARS = 512
}
}
@@ -0,0 +1,185 @@
package com.osglab.account.features.admin.security
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer
import java.security.MessageDigest
import java.security.SecureRandom
import java.time.Instant
import java.net.URLEncoder
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
fun interface AdminTotpVerifier {
/**
* Returns the accepted moving counter. Persisting this counter with a
* compare-and-set prevents reuse of an otherwise valid code.
*/
fun verify(secret: ByteArray, code: String, now: Instant): Long?
}
class HmacTotpVerifier(
private val digits: Int = 6,
private val periodSeconds: Long = 30,
private val allowedWindow: Int = 1,
private val algorithm: String = "HmacSHA1",
) : AdminTotpVerifier {
init {
require(digits in 6..8)
require(periodSeconds in 15..120)
require(allowedWindow in 0..10)
require(algorithm in SUPPORTED_ALGORITHMS)
}
override fun verify(secret: ByteArray, code: String, now: Instant): Long? {
if (secret.size !in MIN_SECRET_BYTES..MAX_SECRET_BYTES) return null
if (code.length != digits || code.any { it !in '0'..'9' }) return null
if (now.epochSecond < 0) return null
val currentCounter = now.epochSecond / periodSeconds
var acceptedCounter: Long? = null
for (offset in -allowedWindow..allowedWindow) {
val candidate = currentCounter + offset
if (candidate < 0) continue
val expected = generateForCounter(secret, candidate)
if (
MessageDigest.isEqual(
code.toByteArray(Charsets.US_ASCII),
expected.toByteArray(Charsets.US_ASCII),
)
) {
// Prefer the newest matching counter if a short code collides
// within the configured window.
acceptedCounter = candidate
}
}
return acceptedCounter
}
fun generate(secret: ByteArray, at: Instant): String {
require(secret.size in MIN_SECRET_BYTES..MAX_SECRET_BYTES)
require(at.epochSecond >= 0)
return generateForCounter(secret, at.epochSecond / periodSeconds)
}
internal fun generateForCounter(secret: ByteArray, counter: Long): String {
require(secret.size in MIN_SECRET_BYTES..MAX_SECRET_BYTES)
require(counter >= 0)
val mac = Mac.getInstance(algorithm)
mac.init(SecretKeySpec(secret, algorithm))
val digest = mac.doFinal(ByteBuffer.allocate(Long.SIZE_BYTES).putLong(counter).array())
val offset = digest.last().toInt() and 0x0f
val binary = ((digest[offset].toInt() and 0x7f) shl 24) or
((digest[offset + 1].toInt() and 0xff) shl 16) or
((digest[offset + 2].toInt() and 0xff) shl 8) or
(digest[offset + 3].toInt() and 0xff)
return (binary % POWERS_OF_TEN[digits]).toString().padStart(digits, '0')
}
private companion object {
val SUPPORTED_ALGORITHMS = setOf("HmacSHA1", "HmacSHA256", "HmacSHA512")
val POWERS_OF_TEN = intArrayOf(1, 10, 100, 1_000, 10_000, 100_000, 1_000_000, 10_000_000, 100_000_000)
const val MIN_SECRET_BYTES = 16
const val MAX_SECRET_BYTES = 64
}
}
object Base32TotpSecret {
fun encode(secret: ByteArray): String {
require(secret.size in MIN_SECRET_BYTES..MAX_SECRET_BYTES)
val result = StringBuilder((secret.size * 8 + 4) / 5)
var buffer = 0
var bufferedBits = 0
secret.forEach { byte ->
buffer = (buffer shl Byte.SIZE_BITS) or (byte.toInt() and 0xff)
bufferedBits += Byte.SIZE_BITS
while (bufferedBits >= 5) {
bufferedBits -= 5
result.append(ALPHABET[(buffer shr bufferedBits) and 0x1f])
}
buffer = if (bufferedBits == 0) 0 else buffer and ((1 shl bufferedBits) - 1)
}
if (bufferedBits > 0) {
result.append(ALPHABET[(buffer shl (5 - bufferedBits)) and 0x1f])
}
return result.toString()
}
fun decode(encoded: String): ByteArray {
val normalized = encoded
.trim()
.replace(" ", "")
.uppercase()
.trimEnd('=')
require(normalized.isNotEmpty()) { "TOTP secret must not be empty" }
val output = ByteArrayOutputStream()
var buffer = 0
var bufferedBits = 0
normalized.forEach { character ->
val value = ALPHABET.indexOf(character)
require(value >= 0) { "TOTP secret is not valid Base32" }
buffer = (buffer shl 5) or value
bufferedBits += 5
if (bufferedBits >= Byte.SIZE_BITS) {
bufferedBits -= Byte.SIZE_BITS
output.write((buffer shr bufferedBits) and 0xff)
buffer = if (bufferedBits == 0) 0 else buffer and ((1 shl bufferedBits) - 1)
}
}
require(buffer == 0) { "TOTP secret has non-zero trailing bits" }
return output.toByteArray().also {
require(it.size in MIN_SECRET_BYTES..MAX_SECRET_BYTES) {
"TOTP secret must contain 128 to 512 bits"
}
}
}
private const val ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
private const val MIN_SECRET_BYTES = 16
private const val MAX_SECRET_BYTES = 64
}
class AdminTotpProvisioning(
val secretBase32: String,
val otpauthUri: String,
) {
override fun toString(): String =
"AdminTotpProvisioning(secretBase32=[REDACTED], otpauthUri=[REDACTED])"
}
fun interface AdminTotpSecretGenerator {
fun generate(normalizedUsername: String): AdminTotpProvisioning
}
class SecureAdminTotpSecretGenerator(
private val secureRandom: SecureRandom = SecureRandom(),
private val issuer: String = "OSGKeyboard",
) : AdminTotpSecretGenerator {
init {
require(issuer.isNotBlank())
}
override fun generate(normalizedUsername: String): AdminTotpProvisioning {
require(normalizedUsername.isNotBlank())
val rawSecret = ByteArray(SECRET_BYTES).also(secureRandom::nextBytes)
val secret = try {
Base32TotpSecret.encode(rawSecret)
} finally {
rawSecret.fill(0)
}
val encodedIssuer = encodeUriComponent(issuer)
val encodedLabel = encodeUriComponent("$issuer:$normalizedUsername")
return AdminTotpProvisioning(
secretBase32 = secret,
otpauthUri = "otpauth://totp/$encodedLabel?secret=$secret&issuer=$encodedIssuer" +
"&algorithm=SHA1&digits=6&period=30",
)
}
private fun encodeUriComponent(value: String): String =
URLEncoder.encode(value, Charsets.UTF_8).replace("+", "%20")
private companion object {
const val SECRET_BYTES = 20
}
}
@@ -0,0 +1,99 @@
package com.osglab.account.features.admin.services
import com.osglab.account.features.admin.models.AdminAuditCursor
import com.osglab.account.features.admin.models.AdminAuditRecord
import com.osglab.account.features.admin.models.AdminPrincipal
import com.osglab.account.features.admin.models.AdminRole
import com.osglab.account.features.admin.repositories.AdminRepository
import java.nio.charset.StandardCharsets
import java.time.Instant
import java.util.Base64
import java.util.UUID
class AdminAuditCursorException : RuntimeException("Invalid admin audit cursor")
data class AdminAuditItem(
val record: AdminAuditRecord,
val operatorName: String,
)
data class AdminAuditPage(
val items: List<AdminAuditItem>,
val nextCursor: String?,
)
class AdminAuditService(
private val repository: AdminRepository,
) {
suspend fun list(
actor: AdminPrincipal,
cursor: String?,
limit: Int = DEFAULT_PAGE_SIZE,
): AdminAuditPage {
if (actor.role != AdminRole.SUPER_ADMIN) {
throw AdminOperatorException(AdminOperatorErrorCode.INSUFFICIENT_PERMISSION)
}
require(limit in 1..MAX_PAGE_SIZE)
val decodedCursor = cursor?.let(::decodeCursor)
val records = repository.listAudit(limit + 1, decodedCursor)
val pageRecords = records.take(limit)
val operatorNames = repository.listOperators().associate {
it.id to it.normalizedUsername
}
return AdminAuditPage(
items = pageRecords.map { record ->
AdminAuditItem(
record = record,
operatorName = record.actorOperatorId?.let(operatorNames::get)
?: record.actorOperatorId?.toString()
?: "system",
)
},
nextCursor = if (records.size > limit) {
pageRecords.lastOrNull()?.let(::encodeCursor)
} else {
null
},
)
}
private fun decodeCursor(value: String): AdminAuditCursor {
if (value.length !in 1..MAX_CURSOR_LENGTH) throw AdminAuditCursorException()
return runCatching {
val decoded = String(
Base64.getUrlDecoder().decode(value),
StandardCharsets.UTF_8,
)
val parts = decoded.split(':', limit = 3)
require(parts.size == 3)
AdminAuditCursor(
occurredAt = Instant.ofEpochSecond(
parts[0].toLong(),
parts[1].toLong(),
),
id = UUID.fromString(parts[2]),
)
}.getOrElse {
throw AdminAuditCursorException()
}
}
private fun encodeCursor(record: AdminAuditRecord): String {
val payload = buildString {
append(record.occurredAt.epochSecond)
append(':')
append(record.occurredAt.nano)
append(':')
append(record.id)
}
return Base64.getUrlEncoder().withoutPadding().encodeToString(
payload.toByteArray(StandardCharsets.UTF_8),
)
}
private companion object {
const val DEFAULT_PAGE_SIZE = 50
const val MAX_PAGE_SIZE = 100
const val MAX_CURSOR_LENGTH = 256
}
}
@@ -0,0 +1,236 @@
package com.osglab.account.features.admin.services
import com.osglab.account.common.security.FieldEncryptor
import com.osglab.account.common.security.SecureTokenGenerator
import com.osglab.account.common.security.Sha256SecureTokenGenerator
import com.osglab.account.common.security.TokenHash
import com.osglab.account.features.admin.models.AdminAuditAction
import com.osglab.account.features.admin.models.AdminAuditOutcome
import com.osglab.account.features.admin.models.AdminLockState
import com.osglab.account.features.admin.models.AdminLoginResult
import com.osglab.account.features.admin.models.AdminPrincipal
import com.osglab.account.features.admin.models.AdminSessionCredentials
import com.osglab.account.features.admin.models.NewAdminAuditEvent
import com.osglab.account.features.admin.models.NewAdminSession
import com.osglab.account.features.admin.repositories.AdminRepository
import com.osglab.account.features.admin.security.AdminPasswordHasher
import com.osglab.account.features.admin.security.AdminTotpVerifier
import com.osglab.account.features.admin.security.Base32TotpSecret
import java.time.Clock
import java.time.Duration
import java.time.Instant
import java.util.UUID
class AdminLoginLockPolicy(
private val maxFailedAttempts: Int = 5,
private val lockDuration: Duration = Duration.ofMinutes(15),
) {
init {
require(maxFailedAttempts in 2..20)
require(!lockDuration.isNegative && !lockDuration.isZero)
require(lockDuration <= Duration.ofHours(24))
}
fun afterFailure(current: AdminLockState, now: Instant): AdminLockState {
if (current.isLockedAt(now)) return current
val failuresBeforeAttempt = if (
current.lockedUntil != null && !current.lockedUntil.isAfter(now)
) {
0
} else {
current.failedLoginCount
}
val failures = failuresBeforeAttempt + 1
return if (failures >= maxFailedAttempts) {
AdminLockState(failedLoginCount = 0, lockedUntil = now.plus(lockDuration))
} else {
AdminLockState(failedLoginCount = failures, lockedUntil = null)
}
}
}
class AdminAuthService(
private val repository: AdminRepository,
private val passwordHasher: AdminPasswordHasher,
private val dummyPasswordHash: String,
private val totpVerifier: AdminTotpVerifier,
private val fieldEncryptor: FieldEncryptor,
sessionTtl: Duration,
private val lockPolicy: AdminLoginLockPolicy = AdminLoginLockPolicy(),
private val tokenGenerator: SecureTokenGenerator = Sha256SecureTokenGenerator(),
private val clock: Clock = Clock.systemUTC(),
) {
private val sessionTtl = sessionTtl.also {
require(it in MIN_SESSION_TTL..MAX_SESSION_TTL)
}
init {
require(dummyPasswordHash.isNotBlank())
}
suspend fun login(
username: String,
password: CharArray,
totpCode: String,
requestId: String? = null,
): AdminLoginResult {
val normalizedRequestId = validateRequestId(requestId)
val normalizedUsername = normalizeAdminUsername(username)
?: return AdminLoginResult.InvalidCredentials
if (password.isEmpty() || password.size > MAX_PASSWORD_CHARS) {
return AdminLoginResult.InvalidCredentials
}
val now = clock.instant()
val operator = repository.findOperatorForAuthentication(normalizedUsername)
val passwordMatches = passwordHasher.verify(
password,
operator?.passwordHash ?: dummyPasswordHash,
)
if (operator == null) {
repository.appendAudit(
loginAudit(null, AdminAuditOutcome.DENIED, now, normalizedRequestId),
)
return AdminLoginResult.InvalidCredentials
}
if (operator.disabledAt != null) {
repository.appendAudit(
loginAudit(operator.id, AdminAuditOutcome.DENIED, now, normalizedRequestId),
)
return AdminLoginResult.InvalidCredentials
}
if (operator.lockState.isLockedAt(now)) {
// Password verification above keeps locked and unknown users on
// the same expensive hashing path.
repository.appendAudit(
loginAudit(operator.id, AdminAuditOutcome.DENIED, now, normalizedRequestId),
)
return AdminLoginResult.Locked(requireNotNull(operator.lockState.lockedUntil))
}
if (!passwordMatches) {
return failAuthentication(operator.id, now, normalizedRequestId)
}
val acceptedCounter = verifyTotp(operator.id, operator.encryptedTotpSecret, totpCode, now)
?: return failAuthentication(operator.id, now, normalizedRequestId)
val rawSessionToken = tokenGenerator.newRefreshToken()
val rawCsrfToken = tokenGenerator.newRefreshToken()
val session = NewAdminSession(
id = UUID.randomUUID(),
operatorId = operator.id,
tokenHash = TokenHash.sha256(rawSessionToken),
csrfTokenHash = TokenHash.sha256(rawCsrfToken),
createdAt = now,
expiresAt = now.plus(sessionTtl),
)
val created = repository.createSessionIfTotpCounterFresh(
session = session,
totpCounter = acceptedCounter,
now = now,
auditEvent = loginAudit(
operator.id,
AdminAuditOutcome.SUCCESS,
now,
normalizedRequestId,
),
) ?: run {
repository.appendAudit(
loginAudit(operator.id, AdminAuditOutcome.DENIED, now, normalizedRequestId),
)
return AdminLoginResult.InvalidCredentials
}
return AdminLoginResult.Authenticated(
principal = AdminPrincipal(
operatorId = created.operatorId,
sessionId = created.id,
normalizedUsername = created.normalizedUsername,
role = created.role,
),
credentials = AdminSessionCredentials(
sessionToken = rawSessionToken,
csrfToken = rawCsrfToken,
expiresAt = created.expiresAt,
),
)
}
private suspend fun failAuthentication(
operatorId: UUID,
now: Instant,
requestId: String?,
): AdminLoginResult {
val state = repository.updateLockState(
operatorId = operatorId,
now = now,
auditEvent = loginAudit(operatorId, AdminAuditOutcome.DENIED, now, requestId),
) {
lockPolicy.afterFailure(it, now)
}
if (state == null) {
repository.appendAudit(
loginAudit(operatorId, AdminAuditOutcome.DENIED, now, requestId),
)
return AdminLoginResult.InvalidCredentials
}
return state.lockedUntil
?.takeIf { it.isAfter(now) }
?.let(AdminLoginResult::Locked)
?: AdminLoginResult.InvalidCredentials
}
private fun verifyTotp(
operatorId: UUID,
encryptedSecret: String,
code: String,
now: Instant,
): Long? {
val secret = runCatching {
Base32TotpSecret.decode(
fieldEncryptor.decrypt(encryptedSecret, adminTotpContext(operatorId)),
)
}.getOrNull() ?: return null
return try {
totpVerifier.verify(secret, code, now)
} finally {
secret.fill(0)
}
}
private fun loginAudit(
operatorId: UUID?,
outcome: AdminAuditOutcome,
now: Instant,
requestId: String?,
) = NewAdminAuditEvent(
actorOperatorId = operatorId,
action = if (outcome == AdminAuditOutcome.SUCCESS) {
AdminAuditAction.LOGIN_SUCCEEDED
} else {
AdminAuditAction.LOGIN_FAILED
},
outcome = outcome,
targetType = operatorId?.let { OPERATOR_TARGET },
targetId = operatorId?.toString(),
requestId = requestId,
occurredAt = now,
)
private fun validateRequestId(requestId: String?): String? {
val normalized = requestId?.trim() ?: return null
return normalized.takeIf {
it.length in 1..MAX_REQUEST_ID_LENGTH && REQUEST_ID.matches(it)
}
}
private companion object {
val MIN_SESSION_TTL: Duration = Duration.ofMinutes(5)
val MAX_SESSION_TTL: Duration = Duration.ofHours(24)
const val MAX_PASSWORD_CHARS = 1_024
const val MAX_REQUEST_ID_LENGTH = 128
const val OPERATOR_TARGET = "ADMIN_OPERATOR"
val REQUEST_ID = Regex("^[A-Za-z0-9._:-]+$")
}
}
fun adminTotpContext(operatorId: UUID): String = "admin-totp-secret:$operatorId"
@@ -0,0 +1,65 @@
package com.osglab.account.features.admin.services
import com.osglab.account.common.security.FieldEncryptor
import com.osglab.account.features.admin.models.AdminRole
import com.osglab.account.features.admin.models.NewAdminOperator
import com.osglab.account.features.admin.repositories.AdminRepository
import com.osglab.account.features.admin.security.Base32TotpSecret
import java.time.Clock
import java.util.UUID
data class AdminBootstrapConfig(
val enabled: Boolean,
val operatorId: UUID?,
val username: String?,
val passwordHash: String?,
val totpSecretBase32: String?,
) {
init {
if (enabled) {
requireNotNull(operatorId) { "Admin bootstrap operator ID is required" }
require(!username.isNullOrBlank()) { "Admin bootstrap username is required" }
require(passwordHash?.startsWith("\$argon2id\$v=19\$") == true) {
"Admin bootstrap password must be an Argon2id PHC hash"
}
require(!totpSecretBase32.isNullOrBlank()) { "Admin bootstrap TOTP secret is required" }
}
}
}
class AdminBootstrapService(
private val repository: AdminRepository,
private val fieldEncryptor: FieldEncryptor,
private val clock: Clock = Clock.systemUTC(),
) {
suspend fun initialize(config: AdminBootstrapConfig): Boolean {
if (!config.enabled) return false
val operatorId = requireNotNull(config.operatorId)
val username = requireNotNull(
normalizeAdminUsername(requireNotNull(config.username)),
) { "Admin bootstrap username is invalid" }
val totpSecret = requireNotNull(config.totpSecretBase32).trim().uppercase()
Base32TotpSecret.decode(totpSecret).fill(0)
val created = repository.createOperatorIfAbsent(
NewAdminOperator(
id = operatorId,
normalizedUsername = username,
passwordHash = requireNotNull(config.passwordHash),
encryptedTotpSecret = fieldEncryptor.encrypt(
totpSecret,
adminTotpContext(operatorId),
),
role = AdminRole.SUPER_ADMIN,
createdAt = clock.instant(),
),
)
if (!created) {
val existing = repository.findOperatorForAuthentication(username)
require(existing?.id == operatorId) {
"Admin bootstrap configuration does not match the existing operator"
}
}
return created
}
}
@@ -0,0 +1,414 @@
package com.osglab.account.features.admin.services
import com.osglab.account.common.security.FieldEncryptor
import com.osglab.account.features.admin.models.AdminAuditAction
import com.osglab.account.features.admin.models.AdminAuditOutcome
import com.osglab.account.features.admin.models.AdminOperatorMutationResult
import com.osglab.account.features.admin.models.AdminOperatorCursor
import com.osglab.account.features.admin.models.AdminOperatorRecord
import com.osglab.account.features.admin.models.AdminPrincipal
import com.osglab.account.features.admin.models.AdminRole
import com.osglab.account.features.admin.models.NewAdminAuditEvent
import com.osglab.account.features.admin.models.NewAdminOperator
import com.osglab.account.features.admin.repositories.AdminRepository
import com.osglab.account.features.admin.security.AdminPasswordHasher
import com.osglab.account.features.admin.security.AdminTotpProvisioning
import com.osglab.account.features.admin.security.AdminTotpSecretGenerator
import com.osglab.account.features.admin.security.SecureAdminTotpSecretGenerator
import java.time.Clock
import java.time.Instant
import java.nio.charset.StandardCharsets
import java.util.Base64
import java.util.Locale
import java.util.UUID
enum class AdminOperatorErrorCode {
VALIDATION_ERROR,
INSUFFICIENT_PERMISSION,
ADMIN_OPERATOR_NOT_FOUND,
ADMIN_USERNAME_CONFLICT,
CANNOT_DISABLE_SELF,
LAST_SUPER_ADMIN_REQUIRED,
}
class AdminOperatorException(
val code: AdminOperatorErrorCode,
) : RuntimeException(code.name)
class AdminOperatorCursorException : RuntimeException("Invalid admin operator cursor")
class AdminOperatorCredentials(
val operator: AdminOperatorRecord?,
val totpSecret: String,
val otpauthUri: String,
) {
override fun toString(): String =
"AdminOperatorCredentials(operator=${operator?.id}, totpSecret=[REDACTED], otpauthUri=[REDACTED])"
}
data class AdminSecuritySummary(
val enabledOperators: Int,
val lockedOperators: Int,
val activeSessions: Long,
)
data class AdminOperatorPage(
val items: List<AdminOperatorRecord>,
val nextCursor: String?,
)
class AdminOperatorService(
private val repository: AdminRepository,
private val passwordHasher: AdminPasswordHasher,
private val fieldEncryptor: FieldEncryptor,
private val totpSecretGenerator: AdminTotpSecretGenerator = SecureAdminTotpSecretGenerator(),
private val clock: Clock = Clock.systemUTC(),
) {
suspend fun list(actor: AdminPrincipal): List<AdminOperatorRecord> {
requireSuperAdministrator(actor)
return repository.listOperators()
}
suspend fun listPage(
actor: AdminPrincipal,
cursor: String?,
limit: Int = DEFAULT_PAGE_SIZE,
): AdminOperatorPage {
requireSuperAdministrator(actor)
if (limit !in 1..MAX_PAGE_SIZE) {
throw AdminOperatorCursorException()
}
val decodedCursor = cursor?.let(::decodeCursor)
val records = repository.listOperatorsPage(limit + 1, decodedCursor)
val items = records.take(limit)
return AdminOperatorPage(
items = items,
nextCursor = if (records.size > limit) items.lastOrNull()?.let(::encodeCursor) else null,
)
}
suspend fun summary(actor: AdminPrincipal): AdminSecuritySummary {
requireSuperAdministrator(actor)
val now = clock.instant()
val operators = repository.listOperators()
return AdminSecuritySummary(
enabledOperators = operators.count { it.disabledAt == null },
lockedOperators = operators.count {
it.lockState.lockedUntil?.isAfter(now) == true
},
activeSessions = repository.countActiveSessions(now),
)
}
suspend fun create(
actor: AdminPrincipal,
username: String,
password: CharArray,
roleName: String,
requestId: String? = null,
): AdminOperatorCredentials = try {
createInternal(actor, username, password, roleName, requestId)
} finally {
password.fill('\u0000')
}
suspend fun setEnabled(
actor: AdminPrincipal,
operatorId: UUID,
enabled: Boolean,
requestId: String? = null,
) {
val action = if (enabled) {
AdminAuditAction.OPERATOR_ENABLED
} else {
AdminAuditAction.OPERATOR_DISABLED
}
authorizeMutation(actor, action, operatorId, requestId)
if (!enabled && actor.operatorId == operatorId) {
auditDenied(actor, action, operatorId, requestId)
throw AdminOperatorException(AdminOperatorErrorCode.CANNOT_DISABLE_SELF)
}
mapMutationResult(
repository.setOperatorEnabled(
operatorId = operatorId,
enabled = enabled,
now = clock.instant(),
auditEvent = audit(actor, action, operatorId, requestId),
),
)
}
suspend fun unlock(
actor: AdminPrincipal,
operatorId: UUID,
requestId: String? = null,
) {
authorizeMutation(actor, AdminAuditAction.OPERATOR_UNLOCKED, operatorId, requestId)
mapMutationResult(
repository.unlockOperator(
operatorId = operatorId,
now = clock.instant(),
auditEvent = audit(actor, AdminAuditAction.OPERATOR_UNLOCKED, operatorId, requestId),
),
)
}
suspend fun resetCredentials(
actor: AdminPrincipal,
operatorId: UUID,
password: CharArray,
requestId: String? = null,
): AdminOperatorCredentials = try {
resetCredentialsInternal(actor, operatorId, password, requestId)
} finally {
password.fill('\u0000')
}
suspend fun revokeSessions(
actor: AdminPrincipal,
operatorId: UUID,
requestId: String? = null,
) {
authorizeMutation(actor, AdminAuditAction.OPERATOR_SESSIONS_REVOKED, operatorId, requestId)
mapMutationResult(
repository.revokeOperatorSessions(
operatorId = operatorId,
now = clock.instant(),
auditEvent = audit(
actor,
AdminAuditAction.OPERATOR_SESSIONS_REVOKED,
operatorId,
requestId,
),
),
)
}
private suspend fun createInternal(
actor: AdminPrincipal,
username: String,
password: CharArray,
roleName: String,
requestId: String?,
): AdminOperatorCredentials {
authorizeMutation(actor, AdminAuditAction.OPERATOR_CREATED, null, requestId)
val normalizedUsername = normalizeAdminUsername(username)
val role = parseRole(roleName)
if (normalizedUsername == null || role == null || !validPassword(password)) {
auditDenied(
actor,
AdminAuditAction.OPERATOR_CREATED,
null,
requestId,
safeUsernameTarget(username),
)
throw AdminOperatorException(AdminOperatorErrorCode.VALIDATION_ERROR)
}
val operatorId = UUID.randomUUID()
val now = clock.instant()
val provisioning = totpSecretGenerator.generate(normalizedUsername)
val operator = NewAdminOperator(
id = operatorId,
normalizedUsername = normalizedUsername,
passwordHash = passwordHasher.hash(password),
encryptedTotpSecret = fieldEncryptor.encrypt(
provisioning.secretBase32,
adminTotpContext(operatorId),
),
role = role,
createdAt = now,
)
mapMutationResult(
repository.createOperator(
operator,
audit(actor, AdminAuditAction.OPERATOR_CREATED, operatorId, requestId),
),
)
return credentialsFromProvisioning(
operator = operator.toRecord(),
provisioning = provisioning,
)
}
private suspend fun resetCredentialsInternal(
actor: AdminPrincipal,
operatorId: UUID,
password: CharArray,
requestId: String?,
): AdminOperatorCredentials {
authorizeMutation(actor, AdminAuditAction.OPERATOR_CREDENTIALS_RESET, operatorId, requestId)
if (!validPassword(password)) {
auditDenied(actor, AdminAuditAction.OPERATOR_CREDENTIALS_RESET, operatorId, requestId)
throw AdminOperatorException(AdminOperatorErrorCode.VALIDATION_ERROR)
}
val current = repository.findOperator(operatorId)
if (current == null) {
auditDenied(actor, AdminAuditAction.OPERATOR_CREDENTIALS_RESET, operatorId, requestId)
throw AdminOperatorException(AdminOperatorErrorCode.ADMIN_OPERATOR_NOT_FOUND)
}
val provisioning = totpSecretGenerator.generate(current.normalizedUsername)
val result = repository.resetOperatorCredentials(
operatorId = operatorId,
passwordHash = passwordHasher.hash(password),
encryptedTotpSecret = fieldEncryptor.encrypt(
provisioning.secretBase32,
adminTotpContext(operatorId),
),
now = clock.instant(),
auditEvent = audit(
actor,
AdminAuditAction.OPERATOR_CREDENTIALS_RESET,
operatorId,
requestId,
),
)
mapMutationResult(result)
return credentialsFromProvisioning(null, provisioning)
}
private fun requireSuperAdministrator(actor: AdminPrincipal) {
if (actor.role != AdminRole.SUPER_ADMIN) {
throw AdminOperatorException(AdminOperatorErrorCode.INSUFFICIENT_PERMISSION)
}
}
private suspend fun authorizeMutation(
actor: AdminPrincipal,
action: AdminAuditAction,
operatorId: UUID?,
requestId: String?,
) {
if (actor.role != AdminRole.SUPER_ADMIN) {
auditDenied(actor, action, operatorId, requestId)
throw AdminOperatorException(AdminOperatorErrorCode.INSUFFICIENT_PERMISSION)
}
}
private suspend fun auditDenied(
actor: AdminPrincipal,
action: AdminAuditAction,
operatorId: UUID?,
requestId: String?,
targetOverride: String? = null,
) {
repository.appendAudit(
audit(
actor = actor,
action = action,
operatorId = operatorId,
requestId = requestId,
outcome = AdminAuditOutcome.DENIED,
targetOverride = targetOverride,
),
)
}
private fun audit(
actor: AdminPrincipal,
action: AdminAuditAction,
operatorId: UUID?,
requestId: String?,
outcome: AdminAuditOutcome = AdminAuditOutcome.SUCCESS,
targetOverride: String? = null,
) = NewAdminAuditEvent(
actorOperatorId = actor.operatorId,
action = action,
outcome = outcome,
targetType = OPERATOR_TARGET,
targetId = targetOverride ?: operatorId?.toString() ?: "NEW",
requestId = normalizeAdminRequestId(requestId),
occurredAt = clock.instant(),
)
private fun mapMutationResult(result: AdminOperatorMutationResult) {
when (result) {
AdminOperatorMutationResult.SUCCESS -> Unit
AdminOperatorMutationResult.NOT_FOUND ->
throw AdminOperatorException(AdminOperatorErrorCode.ADMIN_OPERATOR_NOT_FOUND)
AdminOperatorMutationResult.USERNAME_CONFLICT ->
throw AdminOperatorException(AdminOperatorErrorCode.ADMIN_USERNAME_CONFLICT)
AdminOperatorMutationResult.LAST_SUPER_ADMIN ->
throw AdminOperatorException(AdminOperatorErrorCode.LAST_SUPER_ADMIN_REQUIRED)
}
}
private fun validPassword(password: CharArray): Boolean =
password.size in MIN_PASSWORD_CHARS..MAX_PASSWORD_CHARS
private fun parseRole(roleName: String): AdminRole? =
runCatching { AdminRole.valueOf(roleName) }.getOrNull()
private fun safeUsernameTarget(username: String): String =
username.trim().lowercase(Locale.ROOT).take(MAX_AUDIT_TARGET_CHARS).ifBlank { "INVALID" }
private fun NewAdminOperator.toRecord() = AdminOperatorRecord(
id = id,
normalizedUsername = normalizedUsername,
role = role,
lockState = com.osglab.account.features.admin.models.AdminLockState(0, null),
disabledAt = null,
lastLoginAt = null,
createdAt = createdAt,
updatedAt = createdAt,
)
private fun credentialsFromProvisioning(
operator: AdminOperatorRecord?,
provisioning: AdminTotpProvisioning,
) = AdminOperatorCredentials(
operator = operator,
totpSecret = provisioning.secretBase32,
otpauthUri = provisioning.otpauthUri,
)
private companion object {
const val DEFAULT_PAGE_SIZE = 50
const val MAX_PAGE_SIZE = 100
const val MAX_CURSOR_LENGTH = 256
const val MIN_PASSWORD_CHARS = 12
const val MAX_PASSWORD_CHARS = 1_024
const val MAX_AUDIT_TARGET_CHARS = 128
const val OPERATOR_TARGET = "ADMIN_OPERATOR"
}
private fun decodeCursor(value: String): AdminOperatorCursor {
if (value.length !in 1..MAX_CURSOR_LENGTH) throw AdminOperatorCursorException()
return runCatching {
val decoded = String(
Base64.getUrlDecoder().decode(value),
StandardCharsets.UTF_8,
)
val parts = decoded.split(':', limit = 3)
require(parts.size == 3)
AdminOperatorCursor(
createdAt = Instant.ofEpochSecond(parts[0].toLong(), parts[1].toLong()),
id = UUID.fromString(parts[2]),
)
}.getOrElse {
throw AdminOperatorCursorException()
}
}
private fun encodeCursor(record: AdminOperatorRecord): String {
val payload = "${record.createdAt.epochSecond}:${record.createdAt.nano}:${record.id}"
return Base64.getUrlEncoder().withoutPadding().encodeToString(
payload.toByteArray(StandardCharsets.UTF_8),
)
}
}
fun normalizeAdminUsername(value: String): String? {
val normalized = value.trim().lowercase(Locale.ROOT)
return normalized.takeIf { ADMIN_USERNAME.matches(it) }
}
fun normalizeAdminRequestId(value: String?): String? {
val normalized = value?.trim() ?: return null
return normalized.takeIf {
it.length <= 128 && ADMIN_REQUEST_ID.matches(it)
}
}
private val ADMIN_USERNAME = Regex("^[a-z0-9][a-z0-9._@-]{2,63}$")
private val ADMIN_REQUEST_ID = Regex("^[A-Za-z0-9._:-]+$")
@@ -0,0 +1,94 @@
package com.osglab.account.features.admin.services
import com.osglab.account.common.security.TokenHash
import com.osglab.account.features.admin.models.AdminAuditAction
import com.osglab.account.features.admin.models.AdminAuditOutcome
import com.osglab.account.features.admin.models.AdminPrincipal
import com.osglab.account.features.admin.models.AdminSessionRecord
import com.osglab.account.features.admin.models.NewAdminAuditEvent
import com.osglab.account.features.admin.repositories.AdminRepository
import com.osglab.account.features.admin.security.AdminCsrfVerifier
import java.time.Clock
import java.time.Duration
class AdminSessionService(
private val repository: AdminRepository,
private val csrfVerifier: AdminCsrfVerifier = AdminCsrfVerifier(),
private val clock: Clock = Clock.systemUTC(),
) {
suspend fun authenticate(sessionToken: String): AdminPrincipal? =
findSession(sessionToken)?.toPrincipal()
suspend fun authenticateMutation(
sessionToken: String,
csrfToken: String,
): AdminPrincipal? {
val session = findSession(sessionToken) ?: return null
if (!csrfVerifier.verify(csrfToken, session.csrfTokenHash)) return null
return session.toPrincipal()
}
suspend fun revoke(
sessionToken: String,
csrfToken: String,
requestId: String? = null,
): Boolean {
val tokenHash = hashValidToken(sessionToken) ?: return false
val now = clock.instant()
val active = repository.findActiveSessionByTokenHash(tokenHash, now) ?: return false
if (!csrfVerifier.verify(csrfToken, active.csrfTokenHash)) return false
val event = NewAdminAuditEvent(
actorOperatorId = active.operatorId,
action = AdminAuditAction.SESSION_REVOKED,
outcome = AdminAuditOutcome.SUCCESS,
targetType = SESSION_TARGET,
targetId = active.id.toString(),
requestId = validateRequestId(requestId),
occurredAt = now,
)
return repository.revokeSessionByTokenHash(tokenHash, now, event) != null
}
suspend fun cleanupInactive(
retention: Duration = DEFAULT_INACTIVE_RETENTION,
limit: Int = DEFAULT_CLEANUP_BATCH_SIZE,
): Int {
require(!retention.isNegative && !retention.isZero)
require(limit in 1..MAX_CLEANUP_BATCH_SIZE)
return repository.purgeInactiveSessions(clock.instant().minus(retention), limit)
}
private suspend fun findSession(sessionToken: String): AdminSessionRecord? {
val tokenHash = hashValidToken(sessionToken) ?: return null
return repository.findActiveSessionByTokenHash(tokenHash, clock.instant())
}
private fun hashValidToken(token: String): String? {
if (token.isBlank() || token.length > MAX_TOKEN_LENGTH) return null
return TokenHash.sha256(token)
}
private fun validateRequestId(requestId: String?): String? {
val normalized = requestId?.trim() ?: return null
return normalized.takeIf {
it.length in 1..MAX_REQUEST_ID_LENGTH && REQUEST_ID.matches(it)
}
}
private fun AdminSessionRecord.toPrincipal() = AdminPrincipal(
operatorId = operatorId,
sessionId = id,
normalizedUsername = normalizedUsername,
role = role,
)
private companion object {
const val MAX_TOKEN_LENGTH = 512
const val MAX_REQUEST_ID_LENGTH = 128
const val DEFAULT_CLEANUP_BATCH_SIZE = 500
const val MAX_CLEANUP_BATCH_SIZE = 1_000
const val SESSION_TARGET = "ADMIN_SESSION"
val DEFAULT_INACTIVE_RETENTION: Duration = Duration.ofDays(7)
val REQUEST_ID = Regex("^[A-Za-z0-9._:-]+$")
}
}
@@ -0,0 +1,70 @@
package com.osglab.account.features.admin.stats.models
import kotlinx.serialization.Serializable
@Serializable
data class AdminStatsPeriodDto(
val from: String,
val until: String,
)
@Serializable
data class AdminOverviewDto(
val totalUsers: Long,
val registrations: Long,
val activeUsers: Long,
val totalCreditBalance: Long,
val issuedCredits: Long,
val consumedCredits: Long,
)
@Serializable
data class AdminRegistrationPointDto(
val date: String,
val registrations: Long,
)
@Serializable
data class AdminCreditFlowPointDto(
val date: String,
val issuedCredits: Long,
val consumedCredits: Long,
)
@Serializable
data class AdminReferralFunnelDto(
val codesCreated: Long,
val bindings: Long,
val rewardedBindings: Long,
val pendingBindings: Long,
val ineligibleBindings: Long,
)
@Serializable
data class AdminReferralRankDto(
val userId: String,
val invitedUsers: Long,
val rewardedUsers: Long,
val earnedCredits: Long,
)
@Serializable
data class AdminUsageAggregateDto(
val kind: String,
val requests: Long,
val chargedCredits: Long,
val asrMillis: Long,
val inputTokens: Long,
val outputTokens: Long,
)
@Serializable
data class AdminStatsDto(
val period: AdminStatsPeriodDto,
val overview: AdminOverviewDto,
val registrationTrend: List<AdminRegistrationPointDto>,
val creditFlow: List<AdminCreditFlowPointDto>,
val referralFunnel: AdminReferralFunnelDto,
val referralRanking: List<AdminReferralRankDto>,
val usage: List<AdminUsageAggregateDto>,
)
@@ -0,0 +1,334 @@
package com.osglab.account.features.admin.stats.repositories
import com.osglab.account.config.DatabaseFactory
import com.osglab.account.features.admin.stats.models.AdminOverviewDto
import com.osglab.account.features.admin.stats.models.AdminReferralFunnelDto
import com.osglab.account.features.admin.stats.models.AdminReferralRankDto
import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto
import org.jetbrains.exposed.v1.core.IColumnType
import org.jetbrains.exposed.v1.javatime.JavaInstantColumnType
import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager
import java.math.BigDecimal
import java.sql.ResultSet
import java.time.Instant
import java.time.LocalDate
data class AdminStatsRange(
val from: Instant,
val until: Instant,
) {
init {
require(from < until) { "Admin statistics range must be non-empty" }
}
}
data class AdminStatsSnapshot(
val overview: AdminOverviewDto,
val registrationsByDate: Map<LocalDate, Long>,
val issuedCreditsByDate: Map<LocalDate, Long>,
val consumedCreditsByDate: Map<LocalDate, Long>,
val referralFunnel: AdminReferralFunnelDto,
val referralRanking: List<AdminReferralRankDto>,
val usage: List<AdminUsageAggregateDto>,
)
fun interface AdminStatsRepository {
suspend fun load(range: AdminStatsRange): AdminStatsSnapshot
}
internal data class AdminStatsAggregates(
val overview: AdminOverviewDto,
val registrationsByDate: Map<LocalDate, Long>,
val issuedCreditsByDate: Map<LocalDate, Long>,
val consumedCreditsByDate: Map<LocalDate, Long>,
val referralFunnel: AdminReferralFunnelDto,
val referralBindingsByInviter: List<ReferralBindingAggregateRow>,
val referralCreditsByInviter: Map<String, Long>,
val usage: List<AdminUsageAggregateDto>,
)
internal data class ReferralBindingAggregateRow(
val inviterUserId: String,
val invitedUsers: Long,
val rewardedUsers: Long,
)
class ExposedAdminStatsRepository(
private val databaseFactory: DatabaseFactory,
) : AdminStatsRepository {
override suspend fun load(range: AdminStatsRange): AdminStatsSnapshot =
databaseFactory.query {
assembleAdminStats(loadAggregates(range))
}
private fun loadAggregates(range: AdminStatsRange): AdminStatsAggregates =
AdminStatsAggregates(
overview = loadOverview(range),
registrationsByDate = loadDailyAggregates(
"""
SELECT DATE(created_at) AS aggregate_date, COUNT(*) AS aggregate_value
FROM accounts
WHERE created_at >= ? AND created_at < ?
GROUP BY DATE(created_at)
""",
range,
),
issuedCreditsByDate = loadDailyAggregates(
"""
SELECT DATE(created_at) AS aggregate_date,
COALESCE(SUM(amount_delta), 0) AS aggregate_value
FROM credit_ledger
WHERE created_at >= ? AND created_at < ?
AND amount_delta > 0
AND entry_type IN (
'SIGNUP_TRIAL', 'MANUAL_GRANT', 'REFERRAL_INVITER',
'REFERRAL_INVITEE', 'STOREKIT_PURCHASE', 'SUBSCRIPTION_GRANT'
)
GROUP BY DATE(created_at)
""",
range,
),
consumedCreditsByDate = loadDailyAggregates(
"""
SELECT DATE(created_at) AS aggregate_date,
COALESCE(SUM(charged_credits), 0) AS aggregate_value
FROM credit_usage_records
WHERE created_at >= ? AND created_at < ?
GROUP BY DATE(created_at)
""",
range,
),
referralFunnel = loadReferralFunnel(range),
referralBindingsByInviter = loadReferralBindingsByInviter(range),
referralCreditsByInviter = loadReferralCreditsByInviter(range),
usage = loadUsage(range),
)
private fun loadOverview(range: AdminStatsRange): AdminOverviewDto =
querySingle(
"""
SELECT
(SELECT COUNT(*) FROM accounts) AS total_users,
(
SELECT COUNT(*)
FROM accounts
WHERE created_at >= ? AND created_at < ?
) AS registrations,
(
SELECT COUNT(DISTINCT user_id)
FROM credit_usage_records
WHERE created_at >= ? AND created_at < ?
) AS active_users,
(
SELECT COALESCE(SUM(balance), 0)
FROM credit_accounts
) AS total_credit_balance,
(
SELECT COALESCE(SUM(amount_delta), 0)
FROM credit_ledger
WHERE created_at >= ? AND created_at < ?
AND amount_delta > 0
AND entry_type IN (
'SIGNUP_TRIAL', 'MANUAL_GRANT', 'REFERRAL_INVITER',
'REFERRAL_INVITEE', 'STOREKIT_PURCHASE', 'SUBSCRIPTION_GRANT'
)
) AS issued_credits,
(
SELECT COALESCE(SUM(charged_credits), 0)
FROM credit_usage_records
WHERE created_at >= ? AND created_at < ?
) AS consumed_credits
""",
range.arguments(repetitions = 4),
) { result ->
AdminOverviewDto(
totalUsers = result.exactLong("total_users"),
registrations = result.exactLong("registrations"),
activeUsers = result.exactLong("active_users"),
totalCreditBalance = result.exactLong("total_credit_balance"),
issuedCredits = result.exactLong("issued_credits"),
consumedCredits = result.exactLong("consumed_credits"),
)
}
private fun loadReferralFunnel(range: AdminStatsRange): AdminReferralFunnelDto =
querySingle(
"""
SELECT
(
SELECT COUNT(*)
FROM referral_codes
WHERE created_at >= ? AND created_at < ?
) AS codes_created,
(
SELECT COUNT(*)
FROM referral_bindings
WHERE bound_at >= ? AND bound_at < ?
) AS bindings,
(
SELECT COUNT(*)
FROM referral_bindings
WHERE reward_status = 'REWARDED'
AND rewarded_at >= ? AND rewarded_at < ?
) AS rewarded_bindings,
(
SELECT COUNT(*)
FROM referral_bindings
WHERE reward_status = 'PENDING'
AND bound_at >= ? AND bound_at < ?
) AS pending_bindings,
(
SELECT COUNT(*)
FROM referral_bindings
WHERE reward_status = 'INELIGIBLE_BUDGET'
AND bound_at >= ? AND bound_at < ?
) AS ineligible_bindings
""",
range.arguments(repetitions = 5),
) { result ->
AdminReferralFunnelDto(
codesCreated = result.exactLong("codes_created"),
bindings = result.exactLong("bindings"),
rewardedBindings = result.exactLong("rewarded_bindings"),
pendingBindings = result.exactLong("pending_bindings"),
ineligibleBindings = result.exactLong("ineligible_bindings"),
)
}
private fun loadReferralBindingsByInviter(
range: AdminStatsRange,
): List<ReferralBindingAggregateRow> =
queryRows(
"""
SELECT
inviter_user_id,
SUM(CASE WHEN bound_at >= ? AND bound_at < ? THEN 1 ELSE 0 END) AS invited_users,
SUM(
CASE
WHEN reward_status = 'REWARDED'
AND rewarded_at >= ? AND rewarded_at < ?
THEN 1 ELSE 0
END
) AS rewarded_users
FROM referral_bindings
WHERE (bound_at >= ? AND bound_at < ?)
OR (
reward_status = 'REWARDED'
AND rewarded_at >= ? AND rewarded_at < ?
)
GROUP BY inviter_user_id
""",
range.arguments(repetitions = 4),
) { result ->
ReferralBindingAggregateRow(
inviterUserId = result.getString("inviter_user_id"),
invitedUsers = result.exactLong("invited_users"),
rewardedUsers = result.exactLong("rewarded_users"),
)
}
private fun loadReferralCreditsByInviter(range: AdminStatsRange): Map<String, Long> =
queryRows(
"""
SELECT user_id, COALESCE(SUM(amount_delta), 0) AS earned_credits
FROM credit_ledger
WHERE created_at >= ? AND created_at < ?
AND entry_type = 'REFERRAL_INVITER'
AND amount_delta > 0
GROUP BY user_id
""",
range.arguments(),
) { result ->
result.getString("user_id") to result.exactLong("earned_credits")
}.toMap()
private fun loadUsage(range: AdminStatsRange): List<AdminUsageAggregateDto> =
queryRows(
"""
SELECT
usage_kind,
COUNT(*) AS requests,
COALESCE(SUM(charged_credits), 0) AS charged_credits,
COALESCE(SUM(asr_millis), 0) AS asr_millis,
COALESCE(SUM(input_tokens), 0) AS input_tokens,
COALESCE(SUM(output_tokens), 0) AS output_tokens
FROM credit_usage_records
WHERE created_at >= ? AND created_at < ?
GROUP BY usage_kind
ORDER BY usage_kind
""",
range.arguments(),
) { result ->
AdminUsageAggregateDto(
kind = result.getString("usage_kind"),
requests = result.exactLong("requests"),
chargedCredits = result.exactLong("charged_credits"),
asrMillis = result.exactLong("asr_millis"),
inputTokens = result.exactLong("input_tokens"),
outputTokens = result.exactLong("output_tokens"),
)
}
private fun loadDailyAggregates(sql: String, range: AdminStatsRange): Map<LocalDate, Long> =
queryRows(sql, range.arguments()) { result ->
result.getObject("aggregate_date", LocalDate::class.java) to
result.exactLong("aggregate_value")
}.toMap()
}
internal fun assembleAdminStats(aggregates: AdminStatsAggregates): AdminStatsSnapshot =
AdminStatsSnapshot(
overview = aggregates.overview,
registrationsByDate = aggregates.registrationsByDate,
issuedCreditsByDate = aggregates.issuedCreditsByDate,
consumedCreditsByDate = aggregates.consumedCreditsByDate,
referralFunnel = aggregates.referralFunnel,
referralRanking = aggregates.referralBindingsByInviter.map { binding ->
AdminReferralRankDto(
userId = binding.inviterUserId,
invitedUsers = binding.invitedUsers,
rewardedUsers = binding.rewardedUsers,
earnedCredits = aggregates.referralCreditsByInviter[binding.inviterUserId] ?: 0,
)
}.sortedWith(
compareByDescending<AdminReferralRankDto>(AdminReferralRankDto::invitedUsers)
.thenByDescending(AdminReferralRankDto::rewardedUsers)
.thenBy(AdminReferralRankDto::userId),
),
usage = aggregates.usage.sortedBy(AdminUsageAggregateDto::kind),
)
private fun AdminStatsRange.arguments(
repetitions: Int = 1,
): List<Pair<IColumnType<*>, Any?>> = buildList {
repeat(repetitions) {
add(INSTANT_COLUMN_TYPE to from)
add(INSTANT_COLUMN_TYPE to until)
}
}
private fun <T> querySingle(
sql: String,
arguments: List<Pair<IColumnType<*>, Any?>>,
transform: (ResultSet) -> T,
): T = queryRows(sql, arguments, transform).single()
private fun <T> queryRows(
sql: String,
arguments: List<Pair<IColumnType<*>, Any?>>,
transform: (ResultSet) -> T,
): List<T> = TransactionManager.current().exec(sql.trimIndent(), arguments) { result ->
buildList {
while (result.next()) {
add(transform(result))
}
}
} ?: emptyList()
private fun ResultSet.exactLong(column: String): Long =
requireNotNull(getBigDecimal(column)) { "Aggregate column $column must not be null" }
.toExactLong()
internal fun BigDecimal.toExactLong(): Long = longValueExact()
private val INSTANT_COLUMN_TYPE = JavaInstantColumnType()
@@ -0,0 +1,57 @@
package com.osglab.account.features.admin.stats.services
import com.osglab.account.features.admin.stats.models.AdminCreditFlowPointDto
import com.osglab.account.features.admin.stats.models.AdminRegistrationPointDto
import com.osglab.account.features.admin.stats.models.AdminStatsDto
import com.osglab.account.features.admin.stats.models.AdminStatsPeriodDto
import com.osglab.account.features.admin.stats.repositories.AdminStatsRange
import com.osglab.account.features.admin.stats.repositories.AdminStatsRepository
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneOffset
class AdminStatsService(
private val repository: AdminStatsRepository,
) {
suspend fun get(
from: Instant,
until: Instant,
referralRankLimit: Int = 20,
): AdminStatsDto {
require(from < until) { "Statistics range must be non-empty" }
require(referralRankLimit in 1..100) { "Referral rank limit must be between 1 and 100" }
val range = AdminStatsRange(from, until)
val snapshot = repository.load(range)
val dates = utcDates(range)
return AdminStatsDto(
period = AdminStatsPeriodDto(from.toString(), until.toString()),
overview = snapshot.overview,
registrationTrend = dates.map { date ->
AdminRegistrationPointDto(
date = date.toString(),
registrations = snapshot.registrationsByDate[date] ?: 0,
)
},
creditFlow = dates.map { date ->
AdminCreditFlowPointDto(
date = date.toString(),
issuedCredits = snapshot.issuedCreditsByDate[date] ?: 0,
consumedCredits = snapshot.consumedCreditsByDate[date] ?: 0,
)
},
referralFunnel = snapshot.referralFunnel,
referralRanking = snapshot.referralRanking.take(referralRankLimit),
usage = snapshot.usage,
)
}
}
private fun utcDates(range: AdminStatsRange): List<LocalDate> {
val dates = mutableListOf<LocalDate>()
var date = range.from.atZone(ZoneOffset.UTC).toLocalDate()
while (date.atStartOfDay(ZoneOffset.UTC).toInstant() < range.until) {
dates += date
date = date.plusDays(1)
}
return dates
}
@@ -0,0 +1,56 @@
package com.osglab.account.features.admin.users.models
import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto
import kotlinx.serialization.Serializable
@Serializable
data class AdminUserSummaryDto(
val id: String,
val createdAt: String,
val antiAbuseRestricted: Boolean,
val creditBalance: Long,
val consumedCredits: Long,
val manualGrantedCredits: Long,
val usageRequests: Long,
val lastActiveAt: String?,
val invitedUsers: Long,
val rewardedInvites: Long,
)
@Serializable
data class AdminUserPageDto(
val items: List<AdminUserSummaryDto>,
val nextCursor: String?,
)
@Serializable
data class AdminUserLedgerEntryDto(
val id: String,
val type: String,
val amountDelta: Long,
val balanceAfter: Long,
val referenceId: String?,
val createdAt: String,
)
@Serializable
data class AdminUserLedgerPageDto(
val items: List<AdminUserLedgerEntryDto>,
val nextCursor: String?,
)
@Serializable
data class AdminUserReferralDto(
val inviterUserId: String?,
val invitedUsers: Long,
val rewardedInvites: Long,
)
@Serializable
data class AdminUserDetailDto(
val summary: AdminUserSummaryDto,
val usage: List<AdminUsageAggregateDto>,
val referral: AdminUserReferralDto,
val recentLedger: List<AdminUserLedgerEntryDto>,
val referralCode: String? = null,
)
@@ -0,0 +1,332 @@
package com.osglab.account.features.admin.users.repositories
import com.osglab.account.config.DatabaseFactory
import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto
import com.osglab.account.features.admin.users.models.AdminUserDetailDto
import com.osglab.account.features.admin.users.models.AdminUserLedgerEntryDto
import com.osglab.account.features.admin.users.models.AdminUserReferralDto
import com.osglab.account.features.admin.users.models.AdminUserSummaryDto
import com.osglab.account.features.credits.domain.LedgerEntryType
import com.osglab.account.features.credits.domain.UsageKind
import com.osglab.account.features.referrals.domain.ReferralRewardStatus
import org.jetbrains.exposed.v1.core.ResultRow
import org.jetbrains.exposed.v1.core.SortOrder
import org.jetbrains.exposed.v1.core.Table
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.core.inList
import org.jetbrains.exposed.v1.core.less
import org.jetbrains.exposed.v1.core.or
import org.jetbrains.exposed.v1.javatime.timestamp
import org.jetbrains.exposed.v1.jdbc.selectAll
import java.time.Instant
import java.util.UUID
data class AdminUserCursor(
val createdAt: Instant,
val userId: UUID,
)
data class AdminUserLedgerCursor(
val createdAt: Instant,
val ledgerEntryId: UUID,
)
interface AdminUsersRepository {
suspend fun list(limit: Int, cursor: AdminUserCursor?): List<AdminUserSummaryDto>
suspend fun exists(userId: UUID): Boolean
suspend fun findDetail(userId: UUID, ledgerLimit: Int): AdminUserDetailDto?
suspend fun listLedger(
userId: UUID,
limit: Int,
cursor: AdminUserLedgerCursor?,
): List<AdminUserLedgerEntryDto>
}
class ExposedAdminUsersRepository(
private val databaseFactory: DatabaseFactory,
) : AdminUsersRepository {
override suspend fun list(
limit: Int,
cursor: AdminUserCursor?,
): List<AdminUserSummaryDto> = databaseFactory.query {
val query = AdminUsersAccountsTable.selectAll()
if (cursor != null) {
query.where {
(AdminUsersAccountsTable.createdAt less cursor.createdAt) or
(
(AdminUsersAccountsTable.createdAt eq cursor.createdAt) and
(AdminUsersAccountsTable.id less cursor.userId.toString())
)
}
}
val accountRows = query
.orderBy(
AdminUsersAccountsTable.createdAt to SortOrder.DESC,
AdminUsersAccountsTable.id to SortOrder.DESC,
)
.limit(limit)
.toList()
val support = loadSupport(accountRows.map { it.userId() }.toSet())
accountRows.map { it.toSummary(support) }
}
override suspend fun exists(userId: UUID): Boolean = databaseFactory.query {
AdminUsersAccountsTable.selectAll()
.where { AdminUsersAccountsTable.id eq userId.toString() }
.limit(1)
.any()
}
override suspend fun findDetail(
userId: UUID,
ledgerLimit: Int,
): AdminUserDetailDto? = databaseFactory.query {
val account = AdminUsersAccountsTable.selectAll()
.where { AdminUsersAccountsTable.id eq userId.toString() }
.singleOrNull()
?: return@query null
val support = loadSupport(setOf(userId))
val invitations = support.bindings.filter { it.inviterUserId == userId }
val inviter = support.bindings.singleOrNull { it.inviteeUserId == userId }?.inviterUserId
val usage = support.usage.filter { it.userId == userId }
.groupBy(UserUsageRow::kind)
.map { (kind, records) ->
AdminUsageAggregateDto(
kind = kind.name,
requests = records.size.toLong(),
chargedCredits = records.exactSumOf(UserUsageRow::chargedCredits),
asrMillis = records.exactSumOf { it.asrMillis ?: 0 },
inputTokens = records.exactSumOf { it.inputTokens ?: 0 },
outputTokens = records.exactSumOf { it.outputTokens ?: 0 },
)
}
.sortedBy(AdminUsageAggregateDto::kind)
val recentLedger = support.ledger.filter { it.userId == userId }
.sortedWith(
compareByDescending<UserLedgerRow>(UserLedgerRow::createdAt)
.thenByDescending { it.id.toString() },
)
.take(ledgerLimit)
.map(UserLedgerRow::toDto)
AdminUserDetailDto(
summary = account.toSummary(support),
referralCode = findReferralCode(userId),
usage = usage,
referral = AdminUserReferralDto(
inviterUserId = inviter?.toString(),
invitedUsers = invitations.size.toLong(),
rewardedInvites = invitations.count {
it.status == ReferralRewardStatus.REWARDED
}.toLong(),
),
recentLedger = recentLedger,
)
}
override suspend fun listLedger(
userId: UUID,
limit: Int,
cursor: AdminUserLedgerCursor?,
): List<AdminUserLedgerEntryDto> = databaseFactory.query {
val query = AdminUsersCreditLedgerTable.selectAll()
if (cursor == null) {
query.where { AdminUsersCreditLedgerTable.userId eq userId.toString() }
} else {
query.where {
(AdminUsersCreditLedgerTable.userId eq userId.toString()) and
(
(AdminUsersCreditLedgerTable.createdAt less cursor.createdAt) or
(
(AdminUsersCreditLedgerTable.createdAt eq cursor.createdAt) and
(AdminUsersCreditLedgerTable.id less cursor.ledgerEntryId.toString())
)
)
}
}
query.orderBy(
AdminUsersCreditLedgerTable.createdAt to SortOrder.DESC,
AdminUsersCreditLedgerTable.id to SortOrder.DESC,
)
.limit(limit)
.map { it.toUserLedgerRow().toDto() }
}
}
private data class UserSupportRows(
val balances: Map<UUID, Long>,
val ledger: List<UserLedgerRow>,
val usage: List<UserUsageRow>,
val bindings: List<UserReferralBindingRow>,
)
private data class UserLedgerRow(
val id: UUID,
val userId: UUID,
val type: LedgerEntryType,
val amountDelta: Long,
val balanceAfter: Long,
val referenceId: UUID?,
val createdAt: Instant,
)
private data class UserUsageRow(
val userId: UUID,
val kind: UsageKind,
val asrMillis: Long?,
val inputTokens: Long?,
val outputTokens: Long?,
val chargedCredits: Long,
val createdAt: Instant,
)
private data class UserReferralBindingRow(
val inviterUserId: UUID,
val inviteeUserId: UUID,
val status: ReferralRewardStatus,
)
private fun loadSupport(userIds: Set<UUID>): UserSupportRows {
if (userIds.isEmpty()) return UserSupportRows(emptyMap(), emptyList(), emptyList(), emptyList())
val ids = userIds.map(UUID::toString)
return UserSupportRows(
balances = AdminUsersCreditAccountsTable.selectAll()
.where { AdminUsersCreditAccountsTable.userId inList ids }
.map { UUID.fromString(it[AdminUsersCreditAccountsTable.userId]) to it[AdminUsersCreditAccountsTable.balance] }
.toMap(),
ledger = AdminUsersCreditLedgerTable.selectAll()
.where { AdminUsersCreditLedgerTable.userId inList ids }
.map(ResultRow::toUserLedgerRow),
usage = AdminUsersCreditUsageTable.selectAll()
.where { AdminUsersCreditUsageTable.userId inList ids }
.map(ResultRow::toUserUsageRow),
bindings = AdminUsersReferralBindingsTable.selectAll()
.where {
(AdminUsersReferralBindingsTable.inviterUserId inList ids) or
(AdminUsersReferralBindingsTable.inviteeUserId inList ids)
}
.map(ResultRow::toUserReferralBindingRow),
)
}
private fun findReferralCode(userId: UUID): String? =
AdminUsersReferralCodesTable.selectAll()
.where { AdminUsersReferralCodesTable.ownerUserId eq userId.toString() }
.orderBy(
AdminUsersReferralCodesTable.createdAt to SortOrder.DESC,
AdminUsersReferralCodesTable.id to SortOrder.DESC,
)
.limit(1)
.singleOrNull()
?.get(AdminUsersReferralCodesTable.code)
private fun ResultRow.toSummary(support: UserSupportRows): AdminUserSummaryDto {
val userId = userId()
val usage = support.usage.filter { it.userId == userId }
val ledger = support.ledger.filter { it.userId == userId }
val invitations = support.bindings.filter { it.inviterUserId == userId }
return AdminUserSummaryDto(
id = userId.toString(),
createdAt = this[AdminUsersAccountsTable.createdAt].toString(),
antiAbuseRestricted = this[AdminUsersAccountsTable.antiAbuseRestricted],
creditBalance = support.balances[userId] ?: 0,
consumedCredits = usage.exactSumOf(UserUsageRow::chargedCredits),
manualGrantedCredits = ledger.filter { it.type == LedgerEntryType.MANUAL_GRANT }
.exactSumOf(UserLedgerRow::amountDelta),
usageRequests = usage.size.toLong(),
lastActiveAt = usage.maxOfOrNull(UserUsageRow::createdAt)?.toString(),
invitedUsers = invitations.size.toLong(),
rewardedInvites = invitations.count {
it.status == ReferralRewardStatus.REWARDED
}.toLong(),
)
}
private object AdminUsersAccountsTable : Table("accounts") {
val id = varchar("id", 36)
val antiAbuseRestricted = bool("anti_abuse_restricted")
val createdAt = timestamp("created_at")
override val primaryKey = PrimaryKey(id)
}
private object AdminUsersCreditAccountsTable : Table("credit_accounts") {
val userId = varchar("user_id", 36)
val balance = long("balance")
override val primaryKey = PrimaryKey(userId)
}
private object AdminUsersCreditLedgerTable : Table("credit_ledger") {
val id = varchar("id", 36)
val userId = varchar("user_id", 36)
val entryType = enumerationByName<LedgerEntryType>("entry_type", 32)
val amountDelta = long("amount_delta")
val balanceAfter = long("balance_after")
val referenceId = varchar("reference_id", 36).nullable()
val createdAt = timestamp("created_at")
}
private object AdminUsersCreditUsageTable : Table("credit_usage_records") {
val userId = varchar("user_id", 36)
val usageKind = enumerationByName<UsageKind>("usage_kind", 8)
val asrMillis = long("asr_millis").nullable()
val inputTokens = long("input_tokens").nullable()
val outputTokens = long("output_tokens").nullable()
val chargedCredits = long("charged_credits")
val createdAt = timestamp("created_at")
}
private object AdminUsersReferralBindingsTable : Table("referral_bindings") {
val inviterUserId = varchar("inviter_user_id", 36)
val inviteeUserId = varchar("invitee_user_id", 36)
val rewardStatus = enumerationByName<ReferralRewardStatus>("reward_status", 24)
}
private object AdminUsersReferralCodesTable : Table("referral_codes") {
val id = varchar("id", 36)
val ownerUserId = varchar("owner_user_id", 36)
val code = varchar("code", 32)
val createdAt = timestamp("created_at")
}
private fun ResultRow.userId(): UUID = UUID.fromString(this[AdminUsersAccountsTable.id])
private fun ResultRow.toUserLedgerRow() = UserLedgerRow(
id = UUID.fromString(this[AdminUsersCreditLedgerTable.id]),
userId = UUID.fromString(this[AdminUsersCreditLedgerTable.userId]),
type = this[AdminUsersCreditLedgerTable.entryType],
amountDelta = this[AdminUsersCreditLedgerTable.amountDelta],
balanceAfter = this[AdminUsersCreditLedgerTable.balanceAfter],
referenceId = this[AdminUsersCreditLedgerTable.referenceId]?.let(UUID::fromString),
createdAt = this[AdminUsersCreditLedgerTable.createdAt],
)
private fun UserLedgerRow.toDto() = AdminUserLedgerEntryDto(
id = id.toString(),
type = type.name,
amountDelta = amountDelta,
balanceAfter = balanceAfter,
referenceId = referenceId?.toString(),
createdAt = createdAt.toString(),
)
private fun ResultRow.toUserUsageRow() = UserUsageRow(
userId = UUID.fromString(this[AdminUsersCreditUsageTable.userId]),
kind = this[AdminUsersCreditUsageTable.usageKind],
asrMillis = this[AdminUsersCreditUsageTable.asrMillis],
inputTokens = this[AdminUsersCreditUsageTable.inputTokens],
outputTokens = this[AdminUsersCreditUsageTable.outputTokens],
chargedCredits = this[AdminUsersCreditUsageTable.chargedCredits],
createdAt = this[AdminUsersCreditUsageTable.createdAt],
)
private fun ResultRow.toUserReferralBindingRow() = UserReferralBindingRow(
inviterUserId = UUID.fromString(this[AdminUsersReferralBindingsTable.inviterUserId]),
inviteeUserId = UUID.fromString(this[AdminUsersReferralBindingsTable.inviteeUserId]),
status = this[AdminUsersReferralBindingsTable.rewardStatus],
)
private inline fun <T> Iterable<T>.exactSumOf(value: (T) -> Long): Long =
fold(0L) { total, item -> Math.addExact(total, value(item)) }
@@ -0,0 +1,135 @@
package com.osglab.account.features.admin.users.services
import com.osglab.account.features.admin.users.models.AdminUserDetailDto
import com.osglab.account.features.admin.users.models.AdminUserLedgerPageDto
import com.osglab.account.features.admin.users.models.AdminUserPageDto
import com.osglab.account.features.admin.users.repositories.AdminUserCursor
import com.osglab.account.features.admin.users.repositories.AdminUserLedgerCursor
import com.osglab.account.features.admin.users.repositories.AdminUsersRepository
import java.nio.charset.StandardCharsets
import java.time.Instant
import java.util.Base64
import java.util.UUID
class AdminUserNotFoundException : RuntimeException("Admin user view does not exist")
class AdminUsersService(
private val repository: AdminUsersRepository,
) {
suspend fun searchByInternalId(query: String): AdminUserPageDto {
val userId = runCatching { UUID.fromString(query.trim()) }.getOrNull()
?: return AdminUserPageDto(emptyList(), null)
val user = repository.findDetail(userId, ledgerLimit = 1)?.summary
return AdminUserPageDto(user?.let(::listOf) ?: emptyList(), null)
}
suspend fun list(
limit: Int = 50,
cursor: String? = null,
): AdminUserPageDto {
require(limit in 1..100) { "User page limit must be between 1 and 100" }
val decodedCursor = cursor?.let(AdminUserCursorCodec::decode)
val results = repository.list(limit + 1, decodedCursor)
val hasMore = results.size > limit
val items = results.take(limit)
val nextCursor = if (hasMore) {
val last = items.last()
AdminUserCursorCodec.encode(
AdminUserCursor(
createdAt = Instant.parse(last.createdAt),
userId = UUID.fromString(last.id),
),
)
} else {
null
}
return AdminUserPageDto(items, nextCursor)
}
suspend fun detail(
userId: UUID,
ledgerLimit: Int = 50,
): AdminUserDetailDto {
require(ledgerLimit in 1..100) { "Ledger limit must be between 1 and 100" }
return repository.findDetail(userId, ledgerLimit) ?: throw AdminUserNotFoundException()
}
suspend fun ledger(
userId: UUID,
limit: Int = 50,
cursor: String? = null,
): AdminUserLedgerPageDto {
require(limit in 1..100) { "Ledger page limit must be between 1 and 100" }
val decodedCursor = cursor?.let(AdminUserLedgerCursorCodec::decode)
if (!repository.exists(userId)) throw AdminUserNotFoundException()
val results = repository.listLedger(userId, limit + 1, decodedCursor)
val hasMore = results.size > limit
val items = results.take(limit)
val nextCursor = if (hasMore) {
val last = items.last()
AdminUserLedgerCursorCodec.encode(
AdminUserLedgerCursor(
createdAt = Instant.parse(last.createdAt),
ledgerEntryId = UUID.fromString(last.id),
),
)
} else {
null
}
return AdminUserLedgerPageDto(items, nextCursor)
}
}
internal object AdminUserCursorCodec {
fun encode(cursor: AdminUserCursor): String {
val value = "${cursor.createdAt}|${cursor.userId}"
return Base64.getUrlEncoder().withoutPadding()
.encodeToString(value.toByteArray(StandardCharsets.UTF_8))
}
fun decode(value: String): AdminUserCursor {
require(value.length in 1..256) { "User cursor is invalid" }
return try {
val decoded = String(
Base64.getUrlDecoder().decode(value),
StandardCharsets.UTF_8,
)
val parts = decoded.split('|')
require(parts.size == 2)
AdminUserCursor(
createdAt = Instant.parse(parts[0]),
userId = UUID.fromString(parts[1]),
)
} catch (failure: IllegalArgumentException) {
throw IllegalArgumentException("User cursor is invalid", failure)
}
}
}
internal object AdminUserLedgerCursorCodec {
private const val INVALID_CURSOR_MESSAGE = "User ledger cursor is invalid"
fun encode(cursor: AdminUserLedgerCursor): String {
val value = "${cursor.createdAt}|${cursor.ledgerEntryId}"
return Base64.getUrlEncoder().withoutPadding()
.encodeToString(value.toByteArray(StandardCharsets.UTF_8))
}
fun decode(value: String): AdminUserLedgerCursor {
require(value.length in 1..256) { INVALID_CURSOR_MESSAGE }
return try {
val decoded = String(
Base64.getUrlDecoder().decode(value),
StandardCharsets.UTF_8,
)
val parts = decoded.split('|')
require(parts.size == 2)
AdminUserLedgerCursor(
createdAt = Instant.parse(parts[0]),
ledgerEntryId = UUID.fromString(parts[1]),
)
} catch (failure: IllegalArgumentException) {
throw IllegalArgumentException(INVALID_CURSOR_MESSAGE, failure)
}
}
}
@@ -62,6 +62,24 @@ data class LedgerEntry(
val createdAt: Instant, val createdAt: Instant,
) )
data class ManualCreditGrant(
val id: UUID,
val operatorId: UUID,
val userId: UUID,
val amount: Long,
val reason: String,
val idempotencyKey: String,
val ledgerEntryId: UUID,
val auditLogId: UUID,
val createdAt: Instant,
)
data class ManualCreditGrantResult(
val grant: ManualCreditGrant,
val balanceAfter: Long,
val replayed: Boolean,
)
/** /**
* Billing metadata only. Provider input, audio, prompts and responses must * Billing metadata only. Provider input, audio, prompts and responses must
* never be persisted in a usage record. * never be persisted in a usage record.
@@ -1,5 +1,6 @@
package com.osglab.account.features.credits.repositories package com.osglab.account.features.credits.repositories
import com.osglab.account.features.admin.grants.repositories.AdminCreditGrantRepository
import com.osglab.account.features.credits.domain.CreditAccount import com.osglab.account.features.credits.domain.CreditAccount
import com.osglab.account.features.credits.domain.CreditRateVersion import com.osglab.account.features.credits.domain.CreditRateVersion
import com.osglab.account.features.credits.domain.CreditReservation import com.osglab.account.features.credits.domain.CreditReservation
@@ -11,6 +12,8 @@ import java.time.Instant
import java.util.UUID import java.util.UUID
interface CreditsRepository { interface CreditsRepository {
fun accountExists(userId: UUID): Boolean
fun createAccountIfAbsent(userId: UUID, now: Instant) fun createAccountIfAbsent(userId: UUID, now: Instant)
fun lockAccount(userId: UUID): CreditAccount fun lockAccount(userId: UUID): CreditAccount
@@ -48,6 +51,7 @@ interface CreditsRepository {
interface BillingUnitOfWork { interface BillingUnitOfWork {
val credits: CreditsRepository val credits: CreditsRepository
val referrals: ReferralsRepository val referrals: ReferralsRepository
val adminCreditGrants: AdminCreditGrantRepository
} }
interface BillingTransactionRunner { interface BillingTransactionRunner {
@@ -1,5 +1,7 @@
package com.osglab.account.features.credits.repositories package com.osglab.account.features.credits.repositories
import com.osglab.account.features.admin.grants.repositories.AdminCreditGrantRepository
import com.osglab.account.features.admin.grants.repositories.ExposedAdminCreditGrantRepository
import com.osglab.account.features.credits.domain.CreditAccount import com.osglab.account.features.credits.domain.CreditAccount
import com.osglab.account.features.credits.domain.CreditNotFound import com.osglab.account.features.credits.domain.CreditNotFound
import com.osglab.account.features.credits.domain.CreditRateVersion import com.osglab.account.features.credits.domain.CreditRateVersion
@@ -31,6 +33,12 @@ import org.jetbrains.exposed.v1.jdbc.update
import java.time.Instant import java.time.Instant
import java.util.UUID import java.util.UUID
private object Accounts : Table("accounts") {
val id = varchar("id", 36)
override val primaryKey = PrimaryKey(id)
}
private object CreditAccounts : Table("credit_accounts") { private object CreditAccounts : Table("credit_accounts") {
val userId = varchar("user_id", 36) val userId = varchar("user_id", 36)
val balance = long("balance") val balance = long("balance")
@@ -175,9 +183,17 @@ class ExposedBillingTransactionRunner(
private object ExposedBillingUnitOfWork : BillingUnitOfWork { private object ExposedBillingUnitOfWork : BillingUnitOfWork {
override val credits: CreditsRepository = ExposedCreditsRepository override val credits: CreditsRepository = ExposedCreditsRepository
override val referrals: ReferralsRepository = ExposedReferralsRepository override val referrals: ReferralsRepository = ExposedReferralsRepository
override val adminCreditGrants: AdminCreditGrantRepository = ExposedAdminCreditGrantRepository
} }
private object ExposedCreditsRepository : CreditsRepository { private object ExposedCreditsRepository : CreditsRepository {
override fun accountExists(userId: UUID): Boolean =
Accounts
.selectAll()
.where { Accounts.id eq userId.toString() }
.limit(1)
.singleOrNull() != null
override fun createAccountIfAbsent(userId: UUID, now: Instant) { override fun createAccountIfAbsent(userId: UUID, now: Instant) {
CreditAccounts.insertIgnore { CreditAccounts.insertIgnore {
it[CreditAccounts.userId] = userId.toString() it[CreditAccounts.userId] = userId.toString()
@@ -1,5 +1,8 @@
package com.osglab.account.features.credits.services package com.osglab.account.features.credits.services
import com.osglab.account.features.admin.models.AdminAuditAction
import com.osglab.account.features.admin.models.AdminAuditOutcome
import com.osglab.account.features.admin.models.NewAdminAuditEvent
import com.osglab.account.features.credits.domain.CreditAccount import com.osglab.account.features.credits.domain.CreditAccount
import com.osglab.account.features.credits.domain.CreditConflict import com.osglab.account.features.credits.domain.CreditConflict
import com.osglab.account.features.credits.domain.CreditCostCalculator import com.osglab.account.features.credits.domain.CreditCostCalculator
@@ -11,6 +14,8 @@ import com.osglab.account.features.credits.domain.InsufficientCredits
import com.osglab.account.features.credits.domain.InvalidCreditRequest import com.osglab.account.features.credits.domain.InvalidCreditRequest
import com.osglab.account.features.credits.domain.LedgerEntry import com.osglab.account.features.credits.domain.LedgerEntry
import com.osglab.account.features.credits.domain.LedgerEntryType import com.osglab.account.features.credits.domain.LedgerEntryType
import com.osglab.account.features.credits.domain.ManualCreditGrant
import com.osglab.account.features.credits.domain.ManualCreditGrantResult
import com.osglab.account.features.credits.domain.ReservationStatus import com.osglab.account.features.credits.domain.ReservationStatus
import com.osglab.account.features.credits.domain.ReservationStateRules import com.osglab.account.features.credits.domain.ReservationStateRules
import com.osglab.account.features.credits.domain.UsageMeasurement import com.osglab.account.features.credits.domain.UsageMeasurement
@@ -21,6 +26,7 @@ import com.osglab.account.features.referrals.domain.ReferralBinding
import com.osglab.account.features.referrals.domain.ReferralRewardStatus import com.osglab.account.features.referrals.domain.ReferralRewardStatus
import java.time.Clock import java.time.Clock
import java.time.Instant import java.time.Instant
import java.security.MessageDigest
import java.util.UUID import java.util.UUID
data class ReferralRewardConfig( data class ReferralRewardConfig(
@@ -52,6 +58,15 @@ interface CreditOperations {
idempotencyKey: String, idempotencyKey: String,
): CreditAccount ): CreditAccount
suspend fun grantManual(
operatorId: UUID,
userId: UUID,
credits: Long,
reason: String,
requestId: String?,
idempotencyKey: String,
): ManualCreditGrantResult
suspend fun reserve( suspend fun reserve(
userId: UUID, userId: UUID,
provider: String, provider: String,
@@ -146,6 +161,96 @@ class CreditService(
} }
} }
override suspend fun grantManual(
operatorId: UUID,
userId: UUID,
credits: Long,
reason: String,
requestId: String?,
idempotencyKey: String,
): ManualCreditGrantResult {
if (credits <= 0) throw InvalidCreditRequest("Manual grant credits must be positive")
val normalizedReason = validatedText(reason, "Manual grant reason", 1, 500)
val normalizedRequestId = validatedAdminRequestId(requestId)
val key = manualGrantIdempotencyKey(idempotencyKey)
return transactions.inTransaction { unit ->
if (!unit.credits.accountExists(userId)) {
throw CreditNotFound("Manual grant target does not exist")
}
val now = clock.instant()
unit.credits.createAccountIfAbsent(userId, now)
val account = unit.credits.lockAccount(userId)
unit.adminCreditGrants.findByIdempotencyKey(key)?.let { existing ->
requireIdempotentManualGrant(
existing,
operatorId,
userId,
credits,
normalizedReason,
)
val ledger = unit.credits.findLedgerEntry(userId, key)
?: throw CreditConflict("Manual grant audit exists without its ledger entry")
requireIdempotentLedger(
ledger,
LedgerEntryType.MANUAL_GRANT,
credits,
existing.id,
)
if (ledger.id != existing.ledgerEntryId) {
throw CreditConflict("Manual grant audit does not match its ledger entry")
}
return@inTransaction ManualCreditGrantResult(
grant = existing,
balanceAfter = ledger.balanceAfter,
replayed = true,
)
}
ensureUnusedLedgerKey(unit, userId, key)
val grantId = newId()
val ledgerEntryId = newId()
val auditLogId = newId()
val updated = applyLedgerDelta(
unit = unit,
account = account,
delta = credits,
type = LedgerEntryType.MANUAL_GRANT,
key = key,
referenceId = grantId,
now = now,
entryId = ledgerEntryId,
)
val grant = ManualCreditGrant(
id = grantId,
operatorId = operatorId,
userId = userId,
amount = credits,
reason = normalizedReason,
idempotencyKey = key,
ledgerEntryId = ledgerEntryId,
auditLogId = auditLogId,
createdAt = now,
)
unit.adminCreditGrants.insertAudit(
NewAdminAuditEvent(
id = auditLogId,
actorOperatorId = operatorId,
action = AdminAuditAction.MANUAL_CREDIT_GRANTED,
outcome = AdminAuditOutcome.SUCCESS,
targetType = "ACCOUNT",
targetId = userId.toString(),
requestId = normalizedRequestId,
occurredAt = now,
),
)
unit.adminCreditGrants.insert(grant)
ManualCreditGrantResult(
grant = grant,
balanceAfter = updated.balance,
replayed = false,
)
}
}
override suspend fun reserve( override suspend fun reserve(
userId: UUID, userId: UUID,
provider: String, provider: String,
@@ -503,6 +608,7 @@ class CreditService(
key: String, key: String,
referenceId: UUID?, referenceId: UUID?,
now: Instant, now: Instant,
entryId: UUID = newId(),
): CreditAccount { ): CreditAccount {
val newBalance = try { val newBalance = try {
Math.addExact(account.balance, delta) Math.addExact(account.balance, delta)
@@ -512,7 +618,7 @@ class CreditService(
if (newBalance < 0) throw InsufficientCredits(account.balance, -delta) if (newBalance < 0) throw InsufficientCredits(account.balance, -delta)
unit.credits.insertLedgerEntry( unit.credits.insertLedgerEntry(
LedgerEntry( LedgerEntry(
id = newId(), id = entryId,
userId = account.userId, userId = account.userId,
type = type, type = type,
amountDelta = delta, amountDelta = delta,
@@ -539,6 +645,22 @@ class CreditService(
} }
} }
private fun requireIdempotentManualGrant(
existing: ManualCreditGrant,
expectedOperatorId: UUID,
expectedUserId: UUID,
expectedAmount: Long,
expectedReason: String,
) {
if (existing.operatorId != expectedOperatorId ||
existing.userId != expectedUserId ||
existing.amount != expectedAmount ||
existing.reason != expectedReason
) {
throw CreditConflict("Idempotency key was already used with different manual grant parameters")
}
}
private fun calculatePositiveCost( private fun calculatePositiveCost(
rate: CreditRateVersion, rate: CreditRateVersion,
usage: UsageMeasurement, usage: UsageMeasurement,
@@ -557,6 +679,38 @@ class CreditService(
return normalized return normalized
} }
private fun validatedText(
value: String,
label: String,
minimumLength: Int,
maximumLength: Int,
): String {
val normalized = value.trim()
if (normalized.length !in minimumLength..maximumLength) {
throw InvalidCreditRequest(
"$label must contain $minimumLength to $maximumLength characters",
)
}
return normalized
}
private fun manualGrantIdempotencyKey(value: String): String {
val normalized = validatedIdempotencyKey(value)
val digest = MessageDigest.getInstance("SHA-256")
.digest(normalized.toByteArray(Charsets.UTF_8))
.joinToString("") { byte -> "%02x".format(byte.toInt() and 0xff) }
return "internal:manual:$digest"
}
private fun validatedAdminRequestId(value: String?): String? {
if (value == null) return null
val normalized = value.trim()
if (normalized.length !in 1..128 || !ADMIN_REQUEST_ID.matches(normalized)) {
throw InvalidCreditRequest("Admin request ID is invalid")
}
return normalized
}
private fun addOrNull(left: Long, right: Long): Long? = private fun addOrNull(left: Long, right: Long): Long? =
try { try {
Math.addExact(left, right) Math.addExact(left, right)
@@ -569,4 +723,8 @@ class CreditService(
val inviterCredits: Long, val inviterCredits: Long,
val inviteeCredits: Long, val inviteeCredits: Long,
) )
private companion object {
val ADMIN_REQUEST_ID = Regex("^[A-Za-z0-9._:-]+$")
}
} }
@@ -0,0 +1,104 @@
package com.osglab.account.tools
import com.osglab.account.features.admin.security.BouncyCastleArgon2idPasswordHasher
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.StandardOpenOption.CREATE_NEW
import java.nio.file.attribute.PosixFilePermission
import java.security.SecureRandom
import java.util.Base64
import java.util.UUID
/**
* Generates one bootstrap operator without printing credentials to stdout.
* Both output files are created with owner-only permissions and must not exist.
*/
object AdminCredentialGenerator {
@JvmStatic
fun main(arguments: Array<String>) {
require(arguments.size == 3) {
"Usage: <username> <runtime-env-output> <operator-handoff-output>"
}
val username = arguments[0].trim().lowercase()
require(USERNAME.matches(username)) { "Username must match ${USERNAME.pattern}" }
val runtimeOutput = Path.of(arguments[1]).toAbsolutePath()
val handoffOutput = Path.of(arguments[2]).toAbsolutePath()
require(runtimeOutput != handoffOutput) { "Output paths must be different" }
require(Files.notExists(runtimeOutput) && Files.notExists(handoffOutput)) {
"Output files must not already exist"
}
val random = SecureRandom()
val password = Base64.getUrlEncoder().withoutPadding()
.encodeToString(ByteArray(24).also(random::nextBytes))
val passwordChars = password.toCharArray()
val passwordHash = try {
BouncyCastleArgon2idPasswordHasher(secureRandom = random).hash(passwordChars)
} finally {
passwordChars.fill('\u0000')
}
val totpSecret = base32(ByteArray(20).also(random::nextBytes))
val operatorId = UUID.randomUUID()
writePrivate(
runtimeOutput,
"""
# One-time admin bootstrap values. Never commit, upload, or screenshot.
# After the first successful startup, set ADMIN_BOOTSTRAP_ENABLED=false
# and permanently remove all ADMIN_BOOTSTRAP_* credential values.
ADMIN_ENABLED=true
ADMIN_BOOTSTRAP_ENABLED=true
ADMIN_BOOTSTRAP_OPERATOR_ID=$operatorId
ADMIN_BOOTSTRAP_USERNAME=$username
ADMIN_BOOTSTRAP_PASSWORD_HASH='$passwordHash'
ADMIN_BOOTSTRAP_TOTP_SECRET_BASE32=$totpSecret
ADMIN_SESSION_HOURS=8
ADMIN_MAXIMUM_MANUAL_GRANT=100000
""".trimIndent() + "\n",
)
writePrivate(
handoffOutput,
"""
OSG 运营后台初始管理员
用户名:$username
密码:$password
TOTP 密钥:$totpSecret
认证器 URIotpauth://totp/OSG%20Admin:$username?secret=$totpSecret&issuer=OSG%20Admin&algorithm=SHA1&digits=6&period=30
仅保存在受信设备。首次部署成功后,请将密码录入密码管理器,并删除本文件。
""".trimIndent() + "\n",
)
}
private fun writePrivate(path: Path, content: String) {
Files.writeString(path, content, CREATE_NEW)
runCatching {
Files.setPosixFilePermissions(
path,
setOf(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE),
)
}
}
private fun base32(bytes: ByteArray): String {
val output = StringBuilder((bytes.size * 8 + 4) / 5)
var buffer = 0
var bufferedBits = 0
bytes.forEach { byte ->
buffer = (buffer shl 8) or (byte.toInt() and 0xff)
bufferedBits += 8
while (bufferedBits >= 5) {
bufferedBits -= 5
output.append(BASE32_ALPHABET[(buffer shr bufferedBits) and 0x1f])
buffer = if (bufferedBits == 0) 0 else buffer and ((1 shl bufferedBits) - 1)
}
}
if (bufferedBits > 0) {
output.append(BASE32_ALPHABET[(buffer shl (5 - bufferedBits)) and 0x1f])
}
return output.toString()
}
private val USERNAME = Regex("^[a-z0-9][a-z0-9._@-]{2,63}$")
private const val BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
}
+9
View File
@@ -30,6 +30,15 @@ app:
antiAbuse: antiAbuse:
identityHmacKeyBase64: "$IDENTITY_HMAC_KEY" identityHmacKeyBase64: "$IDENTITY_HMAC_KEY"
tombstoneRetentionDays: "$IDENTITY_TOMBSTONE_RETENTION_DAYS:365" tombstoneRetentionDays: "$IDENTITY_TOMBSTONE_RETENTION_DAYS:365"
admin:
enabled: "$ADMIN_ENABLED:false"
bootstrapEnabled: "$ADMIN_BOOTSTRAP_ENABLED:false"
bootstrapOperatorId: "$ADMIN_BOOTSTRAP_OPERATOR_ID:"
bootstrapUsername: "$ADMIN_BOOTSTRAP_USERNAME:"
bootstrapPasswordHash: "$ADMIN_BOOTSTRAP_PASSWORD_HASH:"
bootstrapTotpSecretBase32: "$ADMIN_BOOTSTRAP_TOTP_SECRET_BASE32:"
sessionHours: "$ADMIN_SESSION_HOURS:8"
maximumManualGrant: "$ADMIN_MAXIMUM_MANUAL_GRANT:100000"
apple: apple:
teamId: "$APPLE_TEAM_ID:" teamId: "$APPLE_TEAM_ID:"
keyId: "$APPLE_KEY_ID:" keyId: "$APPLE_KEY_ID:"
@@ -0,0 +1,107 @@
CREATE TABLE admin_operators (
id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
username VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,
password_hash VARCHAR(255) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
encrypted_totp_secret TEXT NOT NULL,
role VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
failed_login_count INT NOT NULL DEFAULT 0,
locked_until DATETIME(6) NULL,
last_totp_counter BIGINT NULL,
last_login_at DATETIME(6) NULL,
disabled_at DATETIME(6) NULL,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (id),
UNIQUE KEY uk_admin_operators_username (username),
INDEX idx_admin_operators_status_created (disabled_at, created_at),
CONSTRAINT chk_admin_operators_role
CHECK (role IN ('SUPER_ADMIN', 'SUPPORT', 'ANALYST')),
CONSTRAINT chk_admin_operators_failed_logins
CHECK (failed_login_count >= 0),
CONSTRAINT chk_admin_operators_totp_counter
CHECK (last_totp_counter IS NULL OR last_totp_counter >= 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE admin_sessions (
id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
operator_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
token_hash CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
csrf_token_hash CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
expires_at DATETIME(6) NOT NULL,
revoked_at DATETIME(6) NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_admin_sessions_token_hash (token_hash),
INDEX idx_admin_sessions_operator_active (operator_id, revoked_at, expires_at),
INDEX idx_admin_sessions_expiry (expires_at),
CONSTRAINT fk_admin_sessions_operator
FOREIGN KEY (operator_id) REFERENCES admin_operators (id) ON DELETE RESTRICT,
CONSTRAINT chk_admin_sessions_expiry
CHECK (expires_at > created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE admin_audit_log (
id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
actor_operator_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL,
action VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
outcome VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
target_type VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NULL,
target_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL,
request_id VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NULL,
occurred_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (id),
INDEX idx_admin_audit_occurred (occurred_at, id),
INDEX idx_admin_audit_action_occurred (action, occurred_at),
INDEX idx_admin_audit_actor_occurred (actor_operator_id, occurred_at),
INDEX idx_admin_audit_target (target_type, target_id, occurred_at),
CONSTRAINT fk_admin_audit_actor
FOREIGN KEY (actor_operator_id) REFERENCES admin_operators (id) ON DELETE RESTRICT,
CONSTRAINT chk_admin_audit_target_pair CHECK (
(target_type IS NULL AND target_id IS NULL)
OR (target_type IS NOT NULL AND target_id IS NOT NULL)
)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE admin_credit_grants (
id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
operator_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
account_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
amount BIGINT NOT NULL,
reason VARCHAR(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
idempotency_key VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
ledger_entry_id CHAR(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,
audit_log_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (id),
UNIQUE KEY uk_admin_credit_grants_idempotency (idempotency_key),
UNIQUE KEY uk_admin_credit_grants_ledger (ledger_entry_id),
UNIQUE KEY uk_admin_credit_grants_audit (audit_log_id),
INDEX idx_admin_credit_grants_account_created (account_id, created_at),
INDEX idx_admin_credit_grants_operator_created (operator_id, created_at),
INDEX idx_admin_credit_grants_created (created_at),
CONSTRAINT fk_admin_credit_grants_operator
FOREIGN KEY (operator_id) REFERENCES admin_operators (id) ON DELETE RESTRICT,
CONSTRAINT fk_admin_credit_grants_ledger
FOREIGN KEY (ledger_entry_id) REFERENCES credit_ledger (id) ON DELETE RESTRICT,
CONSTRAINT fk_admin_credit_grants_audit
FOREIGN KEY (audit_log_id) REFERENCES admin_audit_log (id) ON DELETE RESTRICT,
CONSTRAINT chk_admin_credit_grants_amount CHECK (amount > 0),
CONSTRAINT chk_admin_credit_grants_reason
CHECK (CHAR_LENGTH(TRIM(reason)) BETWEEN 1 AND 500),
CONSTRAINT chk_admin_credit_grants_idempotency
CHECK (CHAR_LENGTH(idempotency_key) BETWEEN 8 AND 128)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- account_id intentionally has no foreign key: account deletion must preserve
-- the immutable, pseudonymized grant and ledger records.
-- Dashboard queries aggregate by creation time and state. These indexes avoid
-- full scans without changing existing business data or ledger semantics.
CREATE INDEX idx_accounts_created ON accounts (created_at);
CREATE INDEX idx_credit_ledger_type_created ON credit_ledger (entry_type, created_at);
CREATE INDEX idx_credit_usage_created ON credit_usage_records (created_at);
CREATE INDEX idx_referral_bindings_status_bound
ON referral_bindings (reward_status, bound_at);
-- admin_audit_log and admin_credit_grants are append-only. The production
-- runtime database role must receive SELECT/INSERT only on these tables.
@@ -34,6 +34,48 @@ class AppConfigTest : FunSpec({
config.database.migrationUsername shouldBe "test_migrator" config.database.migrationUsername shouldBe "test_migrator"
} }
test("production accepts enabled admin bootstrap with Argon2 PHC hash") {
val config = validProductionConfig().apply {
put("app.admin.enabled", "true")
put("app.admin.bootstrapEnabled", "true")
put("app.admin.bootstrapOperatorId", "2c031def-4517-4fde-b592-5db3a3eefdf6")
put("app.admin.bootstrapUsername", "owner")
put(
"app.admin.bootstrapPasswordHash",
"\$argon2id\$v=19\$m=65536,t=3,p=1\$c2FsdHNhbHRzYWx0c2FsdA\$aGFzaGhhc2hoYXNoaGFzaGhhc2hoYXNoaGFzaA",
)
put("app.admin.bootstrapTotpSecretBase32", "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP")
}
val admin = AppConfig.from(config).admin
admin.enabled shouldBe true
admin.bootstrapEnabled shouldBe true
}
test("production accepts established admin without bootstrap credentials") {
val config = validProductionConfig().apply {
put("app.admin.enabled", "true")
put("app.admin.bootstrapEnabled", "false")
}
val admin = AppConfig.from(config).admin
admin.enabled shouldBe true
admin.bootstrapEnabled shouldBe false
admin.bootstrapTotpSecretBase32 shouldBe null
}
test("admin bootstrap cannot be enabled while admin routes are disabled") {
val config = validProductionConfig().apply {
put("app.admin.enabled", "false")
put("app.admin.bootstrapEnabled", "true")
}
shouldThrow<IllegalArgumentException> {
AppConfig.from(config)
}.message.orEmpty() shouldContain "bootstrapEnabled requires"
}
test("production fails fast when Apple signing credentials are missing") { test("production fails fast when Apple signing credentials are missing") {
val config = validProductionConfig().apply { val config = validProductionConfig().apply {
put("app.apple.keyId", "") put("app.apple.keyId", "")
@@ -0,0 +1,426 @@
package com.osglab.account.features.admin
import com.osglab.account.features.admin.models.AdminAuditCursor
import com.osglab.account.features.admin.models.AdminAuditRecord
import com.osglab.account.features.admin.models.AdminLockState
import com.osglab.account.features.admin.models.AdminOperatorAuthRecord
import com.osglab.account.features.admin.models.AdminOperatorCursor
import com.osglab.account.features.admin.models.AdminOperatorMutationResult
import com.osglab.account.features.admin.models.AdminOperatorRecord
import com.osglab.account.features.admin.models.AdminRole
import com.osglab.account.features.admin.models.AdminSessionRecord
import com.osglab.account.features.admin.models.NewAdminAuditEvent
import com.osglab.account.features.admin.models.NewAdminOperator
import com.osglab.account.features.admin.models.NewAdminSession
import com.osglab.account.features.admin.repositories.AdminRepository
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.time.Instant
import java.util.UUID
internal class InMemoryAdminRepository(
val operatorId: UUID = UUID.randomUUID(),
var username: String = "admin@example.com",
var passwordHash: String = "valid-password-hash",
var encryptedTotpSecret: String,
var role: AdminRole = AdminRole.SUPER_ADMIN,
) : AdminRepository {
private val mutex = Mutex()
var lockState = AdminLockState(0, null)
var disabledAt: Instant? = null
var lastTotpCounter: Long? = null
val sessions = mutableMapOf<String, AdminSessionRecord>()
val revokedTokenHashes = mutableSetOf<String>()
private val revokedAtByTokenHash = mutableMapOf<String, Instant>()
val audits = mutableListOf<NewAdminAuditEvent>()
private val additionalOperators = linkedMapOf<UUID, MutableOperator>()
override suspend fun createOperatorIfAbsent(operator: NewAdminOperator): Boolean =
mutex.withLock {
if (operator.normalizedUsername == username || usernameExists(operator.normalizedUsername)) {
false
} else {
additionalOperators[operator.id] = MutableOperator(operator)
true
}
}
override suspend fun createOperator(
operator: NewAdminOperator,
auditEvent: NewAdminAuditEvent,
): AdminOperatorMutationResult = mutex.withLock {
val result = if (operator.normalizedUsername == username || usernameExists(operator.normalizedUsername)) {
AdminOperatorMutationResult.USERNAME_CONFLICT
} else {
additionalOperators[operator.id] = MutableOperator(operator)
AdminOperatorMutationResult.SUCCESS
}
audits += auditEvent.forResult(result)
result
}
override suspend fun listOperators(): List<AdminOperatorRecord> = mutex.withLock {
listOf(baseOperatorRecord()) + additionalOperators.values.map(MutableOperator::toRecord)
}
override suspend fun listOperatorsPage(
limit: Int,
before: AdminOperatorCursor?,
): List<AdminOperatorRecord> = mutex.withLock {
require(limit in 1..101)
(listOf(baseOperatorRecord()) + additionalOperators.values.map(MutableOperator::toRecord))
.asSequence()
.filter {
before == null ||
it.createdAt.isAfter(before.createdAt) ||
(it.createdAt == before.createdAt && it.id.toString() > before.id.toString())
}
.sortedWith(
compareBy<AdminOperatorRecord> { it.createdAt }
.thenBy { it.id.toString() },
)
.take(limit)
.toList()
}
override suspend fun countActiveSessions(now: Instant): Long = mutex.withLock {
sessions.count { (tokenHash, session) ->
tokenHash !in revokedTokenHashes &&
session.expiresAt.isAfter(now) &&
operatorDisabledAt(session.operatorId) == null
}.toLong()
}
override suspend fun findOperator(operatorId: UUID): AdminOperatorRecord? = mutex.withLock {
if (operatorId == this.operatorId) {
baseOperatorRecord()
} else {
additionalOperators[operatorId]?.toRecord()
}
}
override suspend fun setOperatorEnabled(
operatorId: UUID,
enabled: Boolean,
now: Instant,
auditEvent: NewAdminAuditEvent,
): AdminOperatorMutationResult = mutex.withLock {
val targetRole = when (operatorId) {
this.operatorId -> role
else -> additionalOperators[operatorId]?.operator?.role
}
val result = when {
targetRole == null -> AdminOperatorMutationResult.NOT_FOUND
!enabled &&
operatorDisabledAt(operatorId) == null &&
targetRole == AdminRole.SUPER_ADMIN &&
enabledSuperAdministrators() <= 1 ->
AdminOperatorMutationResult.LAST_SUPER_ADMIN
else -> {
if (operatorId == this.operatorId) {
disabledAt = if (enabled) null else now
} else {
additionalOperators.getValue(operatorId).apply {
disabledAt = if (enabled) null else now
updatedAt = now
}
}
if (!enabled) revokeSessionsFor(operatorId, now)
AdminOperatorMutationResult.SUCCESS
}
}
audits += auditEvent.forResult(result)
result
}
override suspend fun unlockOperator(
operatorId: UUID,
now: Instant,
auditEvent: NewAdminAuditEvent,
): AdminOperatorMutationResult = mutex.withLock {
val result = when (operatorId) {
this.operatorId -> {
lockState = AdminLockState(0, null)
AdminOperatorMutationResult.SUCCESS
}
in additionalOperators -> {
additionalOperators.getValue(operatorId).apply {
lockState = AdminLockState(0, null)
updatedAt = now
}
AdminOperatorMutationResult.SUCCESS
}
else -> AdminOperatorMutationResult.NOT_FOUND
}
audits += auditEvent.forResult(result)
result
}
override suspend fun resetOperatorCredentials(
operatorId: UUID,
passwordHash: String,
encryptedTotpSecret: String,
now: Instant,
auditEvent: NewAdminAuditEvent,
): AdminOperatorMutationResult = mutex.withLock {
val result = when (operatorId) {
this.operatorId -> {
this.passwordHash = passwordHash
this.encryptedTotpSecret = encryptedTotpSecret
lockState = AdminLockState(0, null)
lastTotpCounter = null
revokeSessionsFor(operatorId, now)
AdminOperatorMutationResult.SUCCESS
}
in additionalOperators -> {
additionalOperators.getValue(operatorId).apply {
this.passwordHash = passwordHash
this.encryptedTotpSecret = encryptedTotpSecret
lockState = AdminLockState(0, null)
lastTotpCounter = null
updatedAt = now
}
revokeSessionsFor(operatorId, now)
AdminOperatorMutationResult.SUCCESS
}
else -> AdminOperatorMutationResult.NOT_FOUND
}
audits += auditEvent.forResult(result)
result
}
override suspend fun revokeOperatorSessions(
operatorId: UUID,
now: Instant,
auditEvent: NewAdminAuditEvent,
): AdminOperatorMutationResult = mutex.withLock {
val result = if (operatorId == this.operatorId || operatorId in additionalOperators) {
revokeSessionsFor(operatorId, now)
AdminOperatorMutationResult.SUCCESS
} else {
AdminOperatorMutationResult.NOT_FOUND
}
audits += auditEvent.forResult(result)
result
}
override suspend fun findOperatorForAuthentication(
normalizedUsername: String,
): AdminOperatorAuthRecord? = mutex.withLock {
if (normalizedUsername == username) {
authRecord()
} else {
additionalOperators.values
.firstOrNull { it.operator.normalizedUsername == normalizedUsername }
?.toAuthRecord()
}
}
override suspend fun updateLockState(
operatorId: UUID,
now: Instant,
auditEvent: NewAdminAuditEvent,
transform: (AdminLockState) -> AdminLockState,
): AdminLockState? = mutex.withLock {
if (operatorId != this.operatorId || disabledAt != null) return@withLock null
transform(lockState).also {
lockState = it
audits += auditEvent
}
}
override suspend fun createSessionIfTotpCounterFresh(
session: NewAdminSession,
totpCounter: Long,
now: Instant,
auditEvent: NewAdminAuditEvent,
): AdminSessionRecord? = mutex.withLock {
if (session.operatorId != operatorId || disabledAt != null || lockState.isLockedAt(now)) {
return@withLock null
}
if (lastTotpCounter?.let { it >= totpCounter } == true) return@withLock null
lastTotpCounter = totpCounter
lockState = AdminLockState(0, null)
AdminSessionRecord(
id = session.id,
operatorId = operatorId,
normalizedUsername = username,
role = role,
csrfTokenHash = session.csrfTokenHash,
expiresAt = session.expiresAt,
).also {
sessions[session.tokenHash] = it
audits += auditEvent
}
}
override suspend fun findActiveSessionByTokenHash(
tokenHash: String,
now: Instant,
): AdminSessionRecord? = mutex.withLock {
if (disabledAt != null || tokenHash in revokedTokenHashes) return@withLock null
sessions[tokenHash]?.takeIf { it.expiresAt.isAfter(now) }
}
override suspend fun revokeSessionByTokenHash(
tokenHash: String,
now: Instant,
auditEvent: NewAdminAuditEvent,
): AdminSessionRecord? = mutex.withLock {
if (tokenHash in revokedTokenHashes) return@withLock null
sessions[tokenHash]?.also {
revokedTokenHashes += tokenHash
revokedAtByTokenHash[tokenHash] = now
audits += auditEvent
}
}
override suspend fun purgeInactiveSessions(cutoff: Instant, limit: Int): Int =
mutex.withLock {
require(limit in 1..1_000)
val candidates = sessions.entries.asSequence()
.filter { (tokenHash, session) ->
!session.expiresAt.isAfter(cutoff) ||
revokedAtByTokenHash[tokenHash]?.isAfter(cutoff) == false
}
.sortedWith(
compareBy<Map.Entry<String, AdminSessionRecord>> { it.value.expiresAt }
.thenBy { it.value.id.toString() },
)
.take(limit)
.map(Map.Entry<String, AdminSessionRecord>::key)
.toList()
candidates.forEach {
sessions.remove(it)
revokedTokenHashes.remove(it)
revokedAtByTokenHash.remove(it)
}
candidates.size
}
override suspend fun appendAudit(event: NewAdminAuditEvent) {
mutex.withLock {
audits += event
}
}
override suspend fun listAudit(
limit: Int,
before: AdminAuditCursor?,
): List<AdminAuditRecord> =
mutex.withLock {
audits.asSequence()
.filter {
before == null ||
it.occurredAt.isBefore(before.occurredAt) ||
(
it.occurredAt == before.occurredAt &&
it.id.toString() < before.id.toString()
)
}
.sortedWith(
compareByDescending<NewAdminAuditEvent> { it.occurredAt }
.thenByDescending { it.id.toString() },
)
.take(limit)
.map {
AdminAuditRecord(
id = it.id,
actorOperatorId = it.actorOperatorId,
action = it.action,
outcome = it.outcome,
targetType = it.targetType,
targetId = it.targetId,
requestId = it.requestId,
occurredAt = it.occurredAt,
)
}
.toList()
}
suspend fun seedSession(tokenHash: String, session: AdminSessionRecord) {
mutex.withLock {
sessions[tokenHash] = session
}
}
private fun authRecord() = AdminOperatorAuthRecord(
id = operatorId,
normalizedUsername = username,
passwordHash = passwordHash,
encryptedTotpSecret = encryptedTotpSecret,
role = role,
lockState = lockState,
disabledAt = disabledAt,
)
private fun baseOperatorRecord() = AdminOperatorRecord(
id = operatorId,
normalizedUsername = username,
role = role,
lockState = lockState,
disabledAt = disabledAt,
lastLoginAt = null,
createdAt = Instant.EPOCH,
updatedAt = Instant.EPOCH,
)
private fun usernameExists(candidate: String): Boolean =
additionalOperators.values.any { it.operator.normalizedUsername == candidate }
private fun enabledSuperAdministrators(): Int =
(if (role == AdminRole.SUPER_ADMIN && disabledAt == null) 1 else 0) +
additionalOperators.values.count {
it.operator.role == AdminRole.SUPER_ADMIN && it.disabledAt == null
}
private fun operatorDisabledAt(operatorId: UUID): Instant? =
if (operatorId == this.operatorId) disabledAt else additionalOperators[operatorId]?.disabledAt
private fun revokeSessionsFor(operatorId: UUID, now: Instant) {
sessions.filterValues { it.operatorId == operatorId }.keys.forEach {
revokedTokenHashes += it
revokedAtByTokenHash[it] = now
}
}
private class MutableOperator(
val operator: NewAdminOperator,
var passwordHash: String = operator.passwordHash,
var encryptedTotpSecret: String = operator.encryptedTotpSecret,
var lockState: AdminLockState = AdminLockState(0, null),
var lastTotpCounter: Long? = null,
var disabledAt: Instant? = null,
var updatedAt: Instant = operator.createdAt,
) {
fun toAuthRecord() = AdminOperatorAuthRecord(
id = operator.id,
normalizedUsername = operator.normalizedUsername,
passwordHash = passwordHash,
encryptedTotpSecret = encryptedTotpSecret,
role = operator.role,
lockState = lockState,
disabledAt = disabledAt,
)
fun toRecord() = AdminOperatorRecord(
id = operator.id,
normalizedUsername = operator.normalizedUsername,
role = operator.role,
lockState = lockState,
disabledAt = disabledAt,
lastLoginAt = null,
createdAt = operator.createdAt,
updatedAt = updatedAt,
)
}
}
private fun NewAdminAuditEvent.forResult(result: AdminOperatorMutationResult): NewAdminAuditEvent =
copy(
outcome = if (result == AdminOperatorMutationResult.SUCCESS) {
com.osglab.account.features.admin.models.AdminAuditOutcome.SUCCESS
} else {
com.osglab.account.features.admin.models.AdminAuditOutcome.DENIED
},
)
@@ -0,0 +1,246 @@
package com.osglab.account.features.admin.repositories
import com.osglab.account.config.DatabaseConfig
import com.osglab.account.config.DatabaseFactory
import com.osglab.account.features.admin.models.AdminAuditAction
import com.osglab.account.features.admin.models.AdminAuditOutcome
import com.osglab.account.features.admin.models.AdminOperatorMutationResult
import com.osglab.account.features.admin.models.AdminRole
import com.osglab.account.features.admin.models.NewAdminAuditEvent
import com.osglab.account.features.admin.models.NewAdminOperator
import com.osglab.account.features.admin.models.NewAdminSession
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import org.opentest4j.TestAbortedException
import org.testcontainers.DockerClientFactory
import org.testcontainers.containers.MySQLContainer
import java.time.Duration
import java.time.Instant
import java.util.UUID
class AdminOperatorRepositoryIntegrationTest : FunSpec({
test("row locks preserve one enabled super administrator under concurrent disables") {
withAdminRepositories { first, second ->
val now = Instant.parse("2026-08-17T00:00:00Z")
val firstId = UUID.randomUUID()
val secondId = UUID.randomUUID()
first.createOperatorIfAbsent(
newOperator(firstId, "owner-${firstId.toString().take(8)}", AdminRole.SUPER_ADMIN, now),
)
first.createOperatorIfAbsent(
newOperator(secondId, "owner-${secondId.toString().take(8)}", AdminRole.SUPER_ADMIN, now),
)
val results = coroutineScope {
listOf(
async {
first.setOperatorEnabled(
firstId,
enabled = false,
now = now,
auditEvent = audit(secondId, AdminAuditAction.OPERATOR_DISABLED, firstId, now),
)
},
async {
second.setOperatorEnabled(
secondId,
enabled = false,
now = now,
auditEvent = audit(firstId, AdminAuditAction.OPERATOR_DISABLED, secondId, now),
)
},
).awaitAll()
}
results shouldContainExactlyInAnyOrder listOf(
AdminOperatorMutationResult.SUCCESS,
AdminOperatorMutationResult.LAST_SUPER_ADMIN,
)
first.listOperators().count {
it.role == AdminRole.SUPER_ADMIN && it.disabledAt == null
} shouldBe 1
first.listAudit(10).count {
it.action == AdminAuditAction.OPERATOR_DISABLED &&
it.outcome == AdminAuditOutcome.DENIED
} shouldBe 1
}
}
test("credential reset atomically revokes active sessions") {
withAdminRepositories { repository, _ ->
val now = Instant.parse("2026-08-17T00:00:00Z")
val ownerId = UUID.randomUUID()
val targetId = UUID.randomUUID()
val ownerUsername = "owner-${ownerId.toString().take(8)}"
val targetUsername = "target-${targetId.toString().take(8)}"
repository.createOperatorIfAbsent(
newOperator(ownerId, ownerUsername, AdminRole.SUPER_ADMIN, now),
)
repository.createOperatorIfAbsent(
newOperator(targetId, targetUsername, AdminRole.SUPPORT, now),
)
val session = NewAdminSession(
id = UUID.randomUUID(),
operatorId = targetId,
tokenHash = "a".repeat(64),
csrfTokenHash = "b".repeat(64),
createdAt = now,
expiresAt = now.plus(Duration.ofHours(1)),
)
repository.createSessionIfTotpCounterFresh(
session = session,
totpCounter = 1,
now = now,
auditEvent = audit(targetId, AdminAuditAction.LOGIN_SUCCEEDED, targetId, now),
)
(repository.findActiveSessionByTokenHash(session.tokenHash, now) != null) shouldBe true
repository.resetOperatorCredentials(
operatorId = targetId,
passwordHash = "new-password-hash",
encryptedTotpSecret = "new-encrypted-secret",
now = now.plusSeconds(1),
auditEvent = audit(
ownerId,
AdminAuditAction.OPERATOR_CREDENTIALS_RESET,
targetId,
now.plusSeconds(1),
),
) shouldBe AdminOperatorMutationResult.SUCCESS
repository.findActiveSessionByTokenHash(session.tokenHash, now.plusSeconds(1)) shouldBe null
repository.findOperatorForAuthentication(targetUsername)?.run {
passwordHash shouldBe "new-password-hash"
encryptedTotpSecret shouldBe "new-encrypted-secret"
lockState.failedLoginCount shouldBe 0
lockState.lockedUntil shouldBe null
}
}
}
test("inactive session cleanup is bounded and preserves active sessions") {
withAdminRepositories { repository, _ ->
val now = Instant.parse("2026-08-17T00:00:00Z")
val operatorId = UUID.randomUUID()
repository.createOperatorIfAbsent(
newOperator(operatorId, "cleanup-${operatorId.toString().take(8)}", AdminRole.SUPPORT, now),
)
val oldTokens = (1L..3L).map { counter ->
val tokenHash = counter.toString().repeat(64).take(64)
repository.createSessionIfTotpCounterFresh(
session = NewAdminSession(
id = UUID.randomUUID(),
operatorId = operatorId,
tokenHash = tokenHash,
csrfTokenHash = counter.plus(3).toString().repeat(64).take(64),
createdAt = now.minus(Duration.ofDays(10)),
expiresAt = now.minus(Duration.ofDays(9)),
),
totpCounter = counter,
now = now.minus(Duration.ofDays(10)),
auditEvent = audit(
operatorId,
AdminAuditAction.LOGIN_SUCCEEDED,
operatorId,
now.minus(Duration.ofDays(10)),
),
)
tokenHash
}
val activeToken = "f".repeat(64)
repository.createSessionIfTotpCounterFresh(
session = NewAdminSession(
id = UUID.randomUUID(),
operatorId = operatorId,
tokenHash = activeToken,
csrfTokenHash = "e".repeat(64),
createdAt = now,
expiresAt = now.plus(Duration.ofHours(1)),
),
totpCounter = 4,
now = now,
auditEvent = audit(operatorId, AdminAuditAction.LOGIN_SUCCEEDED, operatorId, now),
)
repository.purgeInactiveSessions(now.minus(Duration.ofDays(7)), limit = 2) shouldBe 2
repository.purgeInactiveSessions(now.minus(Duration.ofDays(7)), limit = 2) shouldBe 1
repository.purgeInactiveSessions(now.minus(Duration.ofDays(7)), limit = 2) shouldBe 0
oldTokens.forEach {
repository.findActiveSessionByTokenHash(it, now) shouldBe null
}
(repository.findActiveSessionByTokenHash(activeToken, now) != null) shouldBe true
}
}
})
private suspend fun withAdminRepositories(
block: suspend (ExposedAdminRepository, ExposedAdminRepository) -> Unit,
) {
val externalJdbcUrl = System.getenv("TEST_MYSQL_JDBC_URL")?.takeIf(String::isNotBlank)
if (externalJdbcUrl == null && !DockerClientFactory.instance().isDockerAvailable) {
throw TestAbortedException("Docker is unavailable; MySQL integration test skipped")
}
val mysql = if (externalJdbcUrl == null) {
AdminMySqlContainer("mysql:8.4")
.withDatabaseName("osg_admin_repository_test")
.withUsername("test")
.withPassword("test")
.also(AdminMySqlContainer::start)
} else {
null
}
val config = DatabaseConfig(
jdbcUrl = externalJdbcUrl ?: requireNotNull(mysql).jdbcUrl,
username = System.getenv("TEST_MYSQL_USER")?.takeIf(String::isNotBlank)
?: mysql?.username
?: "root",
password = System.getenv("TEST_MYSQL_PASSWORD") ?: mysql?.password ?: "",
maximumPoolSize = 4,
)
val firstFactory = DatabaseFactory(config)
val secondFactory = DatabaseFactory(config)
try {
firstFactory.database
secondFactory.database
block(ExposedAdminRepository(firstFactory), ExposedAdminRepository(secondFactory))
} finally {
secondFactory.close()
firstFactory.close()
mysql?.stop()
}
}
private fun newOperator(
id: UUID,
username: String,
role: AdminRole,
now: Instant,
) = NewAdminOperator(
id = id,
normalizedUsername = username,
passwordHash = "password-hash",
encryptedTotpSecret = "encrypted-secret",
role = role,
createdAt = now,
)
private fun audit(
actorId: UUID,
action: AdminAuditAction,
targetId: UUID,
now: Instant,
) = NewAdminAuditEvent(
actorOperatorId = actorId,
action = action,
outcome = AdminAuditOutcome.SUCCESS,
targetType = "ADMIN_OPERATOR",
targetId = targetId.toString(),
occurredAt = now,
)
private class AdminMySqlContainer(image: String) :
MySQLContainer<AdminMySqlContainer>(image)
@@ -0,0 +1,331 @@
package com.osglab.account.features.admin.routes
import com.osglab.account.config.AdminConfig
import com.osglab.account.config.AppConfig
import com.osglab.account.features.admin.grants.services.AdminGrantService
import com.osglab.account.features.admin.models.AdminPrincipal
import com.osglab.account.features.admin.models.AdminRole
import com.osglab.account.features.admin.services.AdminAuditService
import com.osglab.account.features.admin.services.AdminAuthService
import com.osglab.account.features.admin.services.AdminOperatorService
import com.osglab.account.features.admin.services.AdminOperatorErrorCode
import com.osglab.account.features.admin.services.AdminOperatorException
import com.osglab.account.features.admin.services.AdminSessionService
import com.osglab.account.features.admin.stats.services.AdminStatsService
import com.osglab.account.features.admin.users.services.AdminUsersService
import com.osglab.account.features.credits.domain.CreditConflict
import com.osglab.account.features.credits.domain.CreditNotFound
import com.osglab.account.features.credits.domain.InvalidCreditRequest
import io.kotest.matchers.string.shouldContain
import io.ktor.client.statement.bodyAsText
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.install
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.plugins.ratelimit.RateLimit
import io.ktor.server.plugins.ratelimit.RateLimitName
import io.ktor.server.routing.routing
import io.ktor.server.testing.testApplication
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.serialization.json.Json
import java.util.UUID
import kotlin.time.Duration.Companion.minutes
import kotlin.test.Test
import kotlin.test.assertEquals
class AdminRoutesTest {
@Test
fun `admin api is hidden without verified edge header`() = testApplication {
application { installAdminTestRoutes() }
val response = client.get("/v1/admin/auth/session")
assertEquals(HttpStatusCode.NotFound, response.status)
}
@Test
fun `verified edge can check anonymous session`() = testApplication {
application { installAdminTestRoutes() }
val response = client.get("/v1/admin/auth/session") {
header("X-OSG-mTLS-Verified", "SUCCESS")
}
assertEquals(HttpStatusCode.OK, response.status)
response.bodyAsText() shouldContain """"authenticated":false"""
}
@Test
fun `authenticated session exposes role for client-side capability navigation`() = testApplication {
application {
installAdminTestRoutes(
sessionService = sessionFixture(AdminRole.SUPER_ADMIN),
)
}
val response = client.get("/v1/admin/auth/session") {
header("X-OSG-mTLS-Verified", "SUCCESS")
header(HttpHeaders.Cookie, "osg_admin_session=session-token")
}
assertEquals(HttpStatusCode.OK, response.status)
response.bodyAsText() shouldContain """"operatorName":"operator""""
response.bodyAsText() shouldContain """"role":"SUPER_ADMIN""""
}
@Test
fun `login rejects requests without exact same origin`() = testApplication {
val authService = mockk<AdminAuthService>(relaxed = true)
application { installAdminTestRoutes(authService) }
val response = client.post("/v1/admin/auth/login") {
header("X-OSG-mTLS-Verified", "SUCCESS")
header(HttpHeaders.Origin, "https://attacker.example")
contentType(ContentType.Application.Json)
setBody("""{"username":"owner","password":"not-a-real-password","totpCode":"123456"}""")
}
assertEquals(HttpStatusCode.Forbidden, response.status)
coVerify(exactly = 0) { authService.login(any(), any(), any(), any()) }
}
@Test
fun `admin web resources are embedded`() = testApplication {
application {
routing { adminWebRoutes() }
}
val response = client.get("/admin/")
assertEquals(HttpStatusCode.OK, response.status)
response.bodyAsText() shouldContain "OSG 运营后台"
response.bodyAsText() shouldContain """/admin/assets/"""
}
@Test
fun `manual grant maps missing user to stable admin error`() = testApplication {
val fixture = grantRouteFixture(CreditNotFound("missing"))
application {
installAdminTestRoutes(
sessionService = fixture.first,
grantService = fixture.second,
)
}
val response = client.postGrant()
assertEquals(HttpStatusCode.NotFound, response.status)
response.bodyAsText() shouldContain """"code":"USER_NOT_FOUND""""
}
@Test
fun `manual grant maps idempotency conflict to conflict`() = testApplication {
val fixture = grantRouteFixture(CreditConflict("conflict"))
application {
installAdminTestRoutes(
sessionService = fixture.first,
grantService = fixture.second,
)
}
val response = client.postGrant()
assertEquals(HttpStatusCode.Conflict, response.status)
response.bodyAsText() shouldContain """"code":"IDEMPOTENCY_CONFLICT""""
}
@Test
fun `manual grant maps invalid domain request to validation error`() = testApplication {
val fixture = grantRouteFixture(InvalidCreditRequest("invalid"))
application {
installAdminTestRoutes(
sessionService = fixture.first,
grantService = fixture.second,
)
}
val response = client.postGrant()
assertEquals(HttpStatusCode.BadRequest, response.status)
response.bodyAsText() shouldContain """"code":"VALIDATION_ERROR""""
}
@Test
fun `operator list maps non-super authorization to stable forbidden response`() = testApplication {
val sessionService = sessionFixture(AdminRole.SUPPORT)
val operatorService = mockk<AdminOperatorService>()
coEvery { operatorService.listPage(any(), any(), any()) } throws
AdminOperatorException(AdminOperatorErrorCode.INSUFFICIENT_PERMISSION)
application {
installAdminTestRoutes(
sessionService = sessionService,
operatorService = operatorService,
)
}
val response = client.get("/v1/admin/operators") {
header("X-OSG-mTLS-Verified", "SUCCESS")
header(HttpHeaders.Cookie, "osg_admin_session=session-token")
}
assertEquals(HttpStatusCode.Forbidden, response.status)
response.bodyAsText() shouldContain """"code":"INSUFFICIENT_PERMISSION""""
}
@Test
fun `analyst cannot access user records`() = testApplication {
application {
installAdminTestRoutes(
sessionService = sessionFixture(AdminRole.ANALYST),
)
}
val response = client.get("/v1/admin/users") {
header("X-OSG-mTLS-Verified", "SUCCESS")
header(HttpHeaders.Cookie, "osg_admin_session=session-token")
}
assertEquals(HttpStatusCode.Forbidden, response.status)
response.bodyAsText() shouldContain """"code":"INSUFFICIENT_PERMISSION""""
}
@Test
fun `operator creation maps normalized username conflict to 409`() = testApplication {
val sessionService = sessionFixture(AdminRole.SUPER_ADMIN)
val operatorService = mockk<AdminOperatorService>()
coEvery {
operatorService.create(any(), any(), any(), any(), any())
} throws AdminOperatorException(AdminOperatorErrorCode.ADMIN_USERNAME_CONFLICT)
application {
installAdminTestRoutes(
sessionService = sessionService,
operatorService = operatorService,
)
}
val response = client.post("/v1/admin/operators") {
header("X-OSG-mTLS-Verified", "SUCCESS")
header(HttpHeaders.Origin, "https://account.osglab.com")
header(HttpHeaders.Cookie, "osg_admin_session=session-token")
header("X-CSRF-Token", "csrf-token")
contentType(ContentType.Application.Json)
setBody(
"""{"username":"owner","password":"long-enough-password","role":"SUPPORT"}""",
)
}
assertEquals(HttpStatusCode.Conflict, response.status)
response.bodyAsText() shouldContain """"code":"ADMIN_USERNAME_CONFLICT""""
}
@Test
fun `operator creation maps malformed body to stable validation error`() = testApplication {
application {
installAdminTestRoutes(
sessionService = sessionFixture(AdminRole.SUPER_ADMIN),
)
}
val response = client.post("/v1/admin/operators") {
header("X-OSG-mTLS-Verified", "SUCCESS")
header(HttpHeaders.Origin, "https://account.osglab.com")
header(HttpHeaders.Cookie, "osg_admin_session=session-token")
header("X-CSRF-Token", "csrf-token")
contentType(ContentType.Application.Json)
setBody("""{"username":"owner","password":"long-enough-password"}""")
}
assertEquals(HttpStatusCode.BadRequest, response.status)
response.bodyAsText() shouldContain """"code":"VALIDATION_ERROR""""
}
}
private fun io.ktor.server.application.Application.installAdminTestRoutes(
authService: AdminAuthService = mockk(relaxed = true),
sessionService: AdminSessionService = mockk(relaxed = true),
grantService: AdminGrantService = mockk(relaxed = true),
operatorService: AdminOperatorService = mockk(relaxed = true),
auditService: AdminAuditService = mockk(relaxed = true),
) {
install(ContentNegotiation) {
json(Json { explicitNulls = false })
}
install(RateLimit) {
register(RateLimitName("admin-auth")) {
rateLimiter(limit = 20, refillPeriod = 1.minutes)
}
}
val config = mockk<AppConfig> {
every { publicBaseUrl } returns "https://account.osglab.com"
every { isProduction } returns false
every { admin } returns AdminConfig()
}
routing {
adminApiRoutes(
config = config,
authService = authService,
sessionService = sessionService,
statsService = mockk<AdminStatsService>(relaxed = true),
usersService = mockk<AdminUsersService>(relaxed = true),
grantService = grantService,
operatorService = operatorService,
auditService = auditService,
)
}
}
private fun grantRouteFixture(
failure: RuntimeException,
): Pair<AdminSessionService, AdminGrantService> {
val sessionService = mockk<AdminSessionService>()
val grantService = mockk<AdminGrantService>()
coEvery {
sessionService.authenticateMutation("session-token", "csrf-token")
} returns AdminPrincipal(
operatorId = UUID.fromString("5d98fe09-da98-45b4-9466-f3f779dc1a4b"),
sessionId = UUID.fromString("c0271d1b-e5c8-4ca4-8409-1ab7ed148a34"),
normalizedUsername = "owner",
role = AdminRole.SUPER_ADMIN,
)
coEvery { grantService.grant(any()) } throws failure
return sessionService to grantService
}
private fun sessionFixture(role: AdminRole): AdminSessionService =
mockk<AdminSessionService>().also {
coEvery { it.authenticate("session-token") } returns AdminPrincipal(
operatorId = UUID.fromString("5d98fe09-da98-45b4-9466-f3f779dc1a4b"),
sessionId = UUID.fromString("c0271d1b-e5c8-4ca4-8409-1ab7ed148a34"),
normalizedUsername = "operator",
role = role,
)
coEvery { it.authenticateMutation("session-token", "csrf-token") } returns AdminPrincipal(
operatorId = UUID.fromString("5d98fe09-da98-45b4-9466-f3f779dc1a4b"),
sessionId = UUID.fromString("c0271d1b-e5c8-4ca4-8409-1ab7ed148a34"),
normalizedUsername = "operator",
role = role,
)
}
private suspend fun io.ktor.client.HttpClient.postGrant() =
post("/v1/admin/credits/grants") {
header("X-OSG-mTLS-Verified", "SUCCESS")
header(HttpHeaders.Origin, "https://account.osglab.com")
header(HttpHeaders.Cookie, "osg_admin_session=session-token")
header("X-CSRF-Token", "csrf-token")
header("Idempotency-Key", "grant-request-1")
contentType(ContentType.Application.Json)
setBody(
"""{"userId":"5a33af2f-a878-43c0-8315-31729402b7cd","amount":100,"reason":"support credit"}""",
)
}
@@ -0,0 +1,37 @@
package com.osglab.account.features.admin.security
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldStartWith
class AdminPasswordHasherTest : FunSpec({
val hasher = BouncyCastleArgon2idPasswordHasher(
Argon2idConfig(memoryKb = 1_024, iterations = 2, parallelism = 1),
)
test("Argon2id hashes verify the correct password and use unique salts") {
val password = "correct horse battery staple".toCharArray()
val first = hasher.hash(password)
val second = hasher.hash(password)
first.shouldStartWith("\$argon2id\$v=19\$")
(first != second) shouldBe true
hasher.verify(password, first) shouldBe true
hasher.verify("wrong password".toCharArray(), first) shouldBe false
}
test("verification rejects malformed and excessive work factors") {
hasher.verify("password".toCharArray(), "not-a-phc-hash") shouldBe false
hasher.verify(
"password".toCharArray(),
"\$argon2id\$v=19\$m=999999999,t=3,p=1\$MTIzNDU2Nzg5MDEyMzQ1Ng\$" +
"MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY",
) shouldBe false
}
test("hashing rejects empty and oversized passwords") {
shouldThrow<IllegalArgumentException> { hasher.hash(charArrayOf()) }
shouldThrow<IllegalArgumentException> { hasher.hash(CharArray(1_025) { 'a' }) }
}
})
@@ -0,0 +1,54 @@
package com.osglab.account.features.admin.security
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.kotest.matchers.string.shouldNotContain
import java.time.Instant
class AdminTotpTest : FunSpec({
val secret = "12345678901234567890".toByteArray()
test("TOTP generation matches RFC 6238 SHA-1 vectors") {
val verifier = HmacTotpVerifier(digits = 8, allowedWindow = 0)
verifier.generate(secret, Instant.ofEpochSecond(59)) shouldBe "94287082"
verifier.generate(secret, Instant.ofEpochSecond(1_111_111_109)) shouldBe "07081804"
}
test("verification accepts only the configured adjacent time window") {
val verifier = HmacTotpVerifier(digits = 6, allowedWindow = 1)
val previousStep = Instant.ofEpochSecond(1_700_000_010)
val currentStep = previousStep.plusSeconds(30)
val code = verifier.generate(secret, previousStep)
verifier.verify(secret, code, currentStep) shouldBe previousStep.epochSecond / 30
verifier.verify(secret, code, currentStep.plusSeconds(30)) shouldBe null
}
test("verification rejects malformed codes and secrets") {
val verifier = HmacTotpVerifier()
verifier.verify(secret, "12345x", Instant.EPOCH.plusSeconds(60)) shouldBe null
verifier.verify(ByteArray(15), "123456", Instant.EPOCH.plusSeconds(60)) shouldBe null
}
test("Base32 decoder accepts canonical secrets and rejects trailing bits") {
Base32TotpSecret.decode("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ")
.contentEquals(secret) shouldBe true
shouldThrow<IllegalArgumentException> {
Base32TotpSecret.decode("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQB")
}
}
test("provisioning generator returns a 160-bit secret without exposing it in logs") {
val provisioning = SecureAdminTotpSecretGenerator().generate("support.agent")
provisioning.secretBase32.length shouldBe 32
Base32TotpSecret.decode(provisioning.secretBase32).size shouldBe 20
provisioning.otpauthUri shouldContain "otpauth://totp/OSGKeyboard%3Asupport.agent"
provisioning.otpauthUri shouldContain "secret=${provisioning.secretBase32}"
provisioning.toString() shouldNotContain provisioning.secretBase32
}
})
@@ -0,0 +1,87 @@
package com.osglab.account.features.admin.services
import com.osglab.account.features.admin.models.AdminAuditAction
import com.osglab.account.features.admin.models.AdminAuditOutcome
import com.osglab.account.features.admin.models.AdminAuditRecord
import com.osglab.account.features.admin.models.AdminPrincipal
import com.osglab.account.features.admin.models.AdminRole
import com.osglab.account.features.admin.repositories.AdminRepository
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import java.time.Instant
import java.util.UUID
class AdminAuditServiceTest : FunSpec({
test("page cursor continues after the final visible audit record") {
val repository = mockk<AdminRepository>()
val service = AdminAuditService(repository)
val records = listOf(
auditRecord("2026-08-17T00:00:03Z"),
auditRecord("2026-08-17T00:00:02Z"),
auditRecord("2026-08-17T00:00:01Z"),
)
coEvery { repository.listAudit(3, null) } returns records
coEvery { repository.listOperators() } returns emptyList()
val firstPage = service.list(superAdministrator(), null, limit = 2)
firstPage.items.size shouldBe 2
firstPage.nextCursor.isNullOrBlank() shouldBe false
coEvery { repository.listAudit(3, any()) } returns emptyList()
service.list(superAdministrator(), firstPage.nextCursor, limit = 2)
coVerify {
repository.listAudit(
3,
match {
it.occurredAt == records[1].occurredAt &&
it.id == records[1].id
},
)
}
}
test("malformed cursor is rejected before querying audit records") {
val repository = mockk<AdminRepository>()
val service = AdminAuditService(repository)
shouldThrow<AdminAuditCursorException> {
service.list(superAdministrator(), "not-a-valid-cursor")
}
coVerify(exactly = 0) { repository.listAudit(any(), any()) }
}
test("non-super administrator cannot list audit records") {
val repository = mockk<AdminRepository>()
val service = AdminAuditService(repository)
val support = superAdministrator().copy(role = AdminRole.SUPPORT)
shouldThrow<AdminOperatorException> {
service.list(support, null)
}.code shouldBe AdminOperatorErrorCode.INSUFFICIENT_PERMISSION
}
})
private fun auditRecord(occurredAt: String) = AdminAuditRecord(
id = UUID.randomUUID(),
actorOperatorId = UUID.randomUUID(),
action = AdminAuditAction.LOGIN_SUCCEEDED,
outcome = AdminAuditOutcome.SUCCESS,
targetType = "ADMIN_OPERATOR",
targetId = "target",
requestId = "request-1",
occurredAt = Instant.parse(occurredAt),
)
private fun superAdministrator() = AdminPrincipal(
operatorId = UUID.randomUUID(),
sessionId = UUID.randomUUID(),
normalizedUsername = "owner",
role = AdminRole.SUPER_ADMIN,
)
@@ -0,0 +1,226 @@
package com.osglab.account.features.admin.services
import com.osglab.account.common.security.FieldEncryptor
import com.osglab.account.common.security.SecureTokenGenerator
import com.osglab.account.common.security.TokenHash
import com.osglab.account.features.admin.InMemoryAdminRepository
import com.osglab.account.features.admin.models.AdminAuditAction
import com.osglab.account.features.admin.models.AdminAuditOutcome
import com.osglab.account.features.admin.models.AdminLockState
import com.osglab.account.features.admin.models.AdminLoginResult
import com.osglab.account.features.admin.security.AdminPasswordHasher
import com.osglab.account.features.admin.security.HmacTotpVerifier
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import java.time.Clock
import java.time.Duration
import java.time.Instant
import java.time.ZoneId
import java.time.ZoneOffset
import java.util.UUID
import java.util.concurrent.atomic.AtomicInteger
class AdminAuthServiceTest : FunSpec({
test("successful login persists only token hashes and records an audit event") {
val fixture = authFixture()
val code = fixture.totp.generate(TOTP_SECRET_BYTES, fixture.clock.instant())
val result = fixture.service.login(
username = " ADMIN@EXAMPLE.COM ",
password = CORRECT_PASSWORD.toCharArray(),
totpCode = code,
requestId = "request-1",
) as AdminLoginResult.Authenticated
val sessionHash = TokenHash.sha256(result.credentials.sessionToken)
fixture.repository.sessions.containsKey(sessionHash) shouldBe true
fixture.repository.sessions.containsKey(result.credentials.sessionToken) shouldBe false
TokenHash.matches(
result.credentials.csrfToken,
fixture.repository.sessions.getValue(sessionHash).csrfTokenHash,
) shouldBe true
fixture.repository.audits.single().action shouldBe AdminAuditAction.LOGIN_SUCCEEDED
fixture.repository.audits.single().outcome shouldBe AdminAuditOutcome.SUCCESS
result.credentials.toString().contains(result.credentials.sessionToken) shouldBe false
}
test("same TOTP counter is accepted once under concurrent login") {
val fixture = authFixture()
val code = fixture.totp.generate(TOTP_SECRET_BYTES, fixture.clock.instant())
val results = coroutineScope {
List(2) {
async {
fixture.service.login(
username = "admin@example.com",
password = CORRECT_PASSWORD.toCharArray(),
totpCode = code,
)
}
}.awaitAll()
}
results.count { it is AdminLoginResult.Authenticated } shouldBe 1
results.count { it is AdminLoginResult.InvalidCredentials } shouldBe 1
fixture.repository.sessions.size shouldBe 1
}
test("failed credentials lock at the threshold") {
val fixture = authFixture(
lockPolicy = AdminLoginLockPolicy(
maxFailedAttempts = 3,
lockDuration = Duration.ofMinutes(10),
),
)
repeat(2) {
fixture.service.login(
"admin@example.com",
"wrong".toCharArray(),
"000000",
) shouldBe AdminLoginResult.InvalidCredentials
}
val third = fixture.service.login(
"admin@example.com",
"wrong".toCharArray(),
"000000",
)
third shouldBe AdminLoginResult.Locked(fixture.clock.instant().plusSeconds(600))
fixture.repository.lockState.failedLoginCount shouldBe 0
}
test("lock expiry boundary starts a fresh failure sequence") {
val policy = AdminLoginLockPolicy(
maxFailedAttempts = 3,
lockDuration = Duration.ofMinutes(10),
)
val lockEndsAt = Instant.parse("2026-08-16T01:00:00Z")
val next = policy.afterFailure(
AdminLockState(failedLoginCount = 0, lockedUntil = lockEndsAt),
lockEndsAt,
)
next shouldBe AdminLockState(failedLoginCount = 1, lockedUntil = null)
}
test("unknown users execute dummy password verification") {
val fixture = authFixture()
var verifiedHash: String? = null
val service = fixture.serviceWithHasher(
object : AdminPasswordHasher {
override fun hash(password: CharArray): String = error("Not used")
override fun verify(password: CharArray, encodedHash: String): Boolean {
verifiedHash = encodedHash
return false
}
},
)
service.login("unknown@example.com", "guess".toCharArray(), "000000") shouldBe
AdminLoginResult.InvalidCredentials
verifiedHash shouldBe DUMMY_HASH
}
test("invalid external request ID is omitted instead of failing login") {
val fixture = authFixture()
val code = fixture.totp.generate(TOTP_SECRET_BYTES, fixture.clock.instant())
val result = fixture.service.login(
username = "admin@example.com",
password = CORRECT_PASSWORD.toCharArray(),
totpCode = code,
requestId = "invalid request id with spaces",
)
(result is AdminLoginResult.Authenticated) shouldBe true
fixture.repository.audits.single().requestId shouldBe null
}
})
private data class AuthFixture(
val repository: InMemoryAdminRepository,
val service: AdminAuthService,
val totp: HmacTotpVerifier,
val encryptor: FieldEncryptor,
val clock: MutableClock,
val lockPolicy: AdminLoginLockPolicy,
val tokenGenerator: SecureTokenGenerator,
) {
fun serviceWithHasher(hasher: AdminPasswordHasher) = AdminAuthService(
repository = repository,
passwordHasher = hasher,
dummyPasswordHash = DUMMY_HASH,
totpVerifier = totp,
fieldEncryptor = encryptor,
sessionTtl = Duration.ofHours(1),
lockPolicy = lockPolicy,
tokenGenerator = tokenGenerator,
clock = clock,
)
}
private fun authFixture(
lockPolicy: AdminLoginLockPolicy = AdminLoginLockPolicy(),
): AuthFixture {
val operatorId = UUID.randomUUID()
val encryptor = FieldEncryptor(ByteArray(32) { 7 })
val encryptedSecret = encryptor.encrypt(TOTP_SECRET_BASE32, adminTotpContext(operatorId))
val repository = InMemoryAdminRepository(
operatorId = operatorId,
encryptedTotpSecret = encryptedSecret,
)
val totp = HmacTotpVerifier()
val clock = MutableClock(Instant.parse("2026-08-16T00:00:10Z"))
val tokenGenerator = CountingTokenGenerator()
val fixture = AuthFixture(
repository = repository,
service = AdminAuthService(
repository = repository,
passwordHasher = TestPasswordHasher,
dummyPasswordHash = DUMMY_HASH,
totpVerifier = totp,
fieldEncryptor = encryptor,
sessionTtl = Duration.ofHours(1),
lockPolicy = lockPolicy,
tokenGenerator = tokenGenerator,
clock = clock,
),
totp = totp,
encryptor = encryptor,
clock = clock,
lockPolicy = lockPolicy,
tokenGenerator = tokenGenerator,
)
return fixture
}
private data object TestPasswordHasher : AdminPasswordHasher {
override fun hash(password: CharArray): String = error("Not used")
override fun verify(password: CharArray, encodedHash: String): Boolean =
encodedHash == "valid-password-hash" && password.concatToString() == CORRECT_PASSWORD
}
private class CountingTokenGenerator : SecureTokenGenerator {
private val counter = AtomicInteger()
override fun newRefreshToken(): String = "test-token-${counter.incrementAndGet()}"
}
private class MutableClock(
var current: Instant,
) : Clock() {
override fun getZone(): ZoneId = ZoneOffset.UTC
override fun withZone(zone: ZoneId): Clock = this
override fun instant(): Instant = current
}
private const val CORRECT_PASSWORD = "correct-password"
private const val DUMMY_HASH = "dummy-password-hash"
private const val TOTP_SECRET_BASE32 = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
private val TOTP_SECRET_BYTES = "12345678901234567890".toByteArray()
@@ -0,0 +1,54 @@
package com.osglab.account.features.admin.services
import com.osglab.account.common.security.FieldEncryptor
import com.osglab.account.features.admin.models.NewAdminOperator
import com.osglab.account.features.admin.repositories.AdminRepository
import io.kotest.matchers.shouldBe
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import io.mockk.slot
import kotlinx.coroutines.runBlocking
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
import java.util.UUID
import kotlin.test.Test
class AdminBootstrapServiceTest {
@Test
fun `bootstrap encrypts TOTP secret and creates owner idempotently`() = runBlocking {
val repository = mockk<AdminRepository>()
val operator = slot<NewAdminOperator>()
coEvery { repository.createOperatorIfAbsent(capture(operator)) } returns true
val encryptor = FieldEncryptor(ByteArray(32) { 9 })
val service = AdminBootstrapService(
repository,
encryptor,
Clock.fixed(Instant.parse("2026-08-16T00:00:00Z"), ZoneOffset.UTC),
)
val operatorId = UUID.fromString("2c031def-4517-4fde-b592-5db3a3eefdf6")
service.initialize(
AdminBootstrapConfig(
enabled = true,
operatorId = operatorId,
username = "Owner",
passwordHash = VALID_PASSWORD_HASH,
totpSecretBase32 = TOTP_SECRET,
),
) shouldBe true
operator.captured.normalizedUsername shouldBe "owner"
operator.captured.passwordHash shouldBe VALID_PASSWORD_HASH
encryptor.decrypt(
operator.captured.encryptedTotpSecret,
adminTotpContext(operatorId),
) shouldBe TOTP_SECRET
coVerify(exactly = 1) { repository.createOperatorIfAbsent(any()) }
}
}
private const val TOTP_SECRET = "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP"
private const val VALID_PASSWORD_HASH =
"\$argon2id\$v=19\$m=65536,t=3,p=1\$c2FsdA\$aGFzaA"
@@ -0,0 +1,284 @@
package com.osglab.account.features.admin.services
import com.osglab.account.common.security.FieldEncryptor
import com.osglab.account.features.admin.InMemoryAdminRepository
import com.osglab.account.features.admin.models.AdminAuditAction
import com.osglab.account.features.admin.models.AdminAuditOutcome
import com.osglab.account.features.admin.models.AdminLockState
import com.osglab.account.features.admin.models.AdminOperatorRecord
import com.osglab.account.features.admin.models.AdminPrincipal
import com.osglab.account.features.admin.models.AdminRole
import com.osglab.account.features.admin.models.AdminSessionRecord
import com.osglab.account.features.admin.models.NewAdminOperator
import com.osglab.account.features.admin.security.AdminPasswordHasher
import com.osglab.account.features.admin.security.AdminTotpProvisioning
import com.osglab.account.features.admin.security.AdminTotpSecretGenerator
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.collections.shouldContainExactly
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldNotContain
import java.time.Clock
import java.time.Duration
import java.time.Instant
import java.time.ZoneOffset
import java.util.UUID
class AdminOperatorServiceTest : FunSpec({
test("super administrator creates normalized operator with one-time encrypted TOTP") {
val fixture = operatorFixture()
val password = "long-enough-password".toCharArray()
val created = fixture.service.create(
fixture.owner,
" Support.Agent ",
password,
"SUPPORT",
"operator-create-1",
)
created.operator?.normalizedUsername shouldBe "support.agent"
created.operator?.role shouldBe AdminRole.SUPPORT
created.totpSecret shouldBe TOTP_SECRET
created.toString() shouldNotContain TOTP_SECRET
password.all { it == '\u0000' } shouldBe true
val stored = fixture.repository.findOperatorForAuthentication("support.agent")
fixture.encryptor.decrypt(
requireNotNull(stored).encryptedTotpSecret,
adminTotpContext(stored.id),
) shouldBe TOTP_SECRET
fixture.repository.audits.last().run {
action shouldBe AdminAuditAction.OPERATOR_CREATED
outcome shouldBe AdminAuditOutcome.SUCCESS
}
}
test("duplicate normalized username returns stable conflict and denied audit") {
val fixture = operatorFixture()
fixture.service.create(
fixture.owner,
"duplicate",
"long-enough-password".toCharArray(),
"ANALYST",
)
shouldThrow<AdminOperatorException> {
fixture.service.create(
fixture.owner,
" DUPLICATE ",
"another-long-password".toCharArray(),
"SUPPORT",
)
}.code shouldBe AdminOperatorErrorCode.ADMIN_USERNAME_CONFLICT
fixture.repository.audits.last().outcome shouldBe AdminAuditOutcome.DENIED
}
test("self disable is rejected and audited") {
val fixture = operatorFixture()
shouldThrow<AdminOperatorException> {
fixture.service.setEnabled(fixture.owner, fixture.owner.operatorId, enabled = false)
}.code shouldBe AdminOperatorErrorCode.CANNOT_DISABLE_SELF
fixture.repository.disabledAt shouldBe null
fixture.repository.audits.single().run {
action shouldBe AdminAuditAction.OPERATOR_DISABLED
outcome shouldBe AdminAuditOutcome.DENIED
}
}
test("repository rejects disabling the final enabled super administrator") {
val fixture = operatorFixture()
val secondSuper = fixture.seedOperator("second-owner", AdminRole.SUPER_ADMIN)
fixture.service.setEnabled(fixture.owner, secondSuper, enabled = false)
val disabledPrincipal = fixture.owner.copy(operatorId = secondSuper)
shouldThrow<AdminOperatorException> {
fixture.service.setEnabled(disabledPrincipal, fixture.owner.operatorId, enabled = false)
}.code shouldBe AdminOperatorErrorCode.LAST_SUPER_ADMIN_REQUIRED
fixture.repository.disabledAt shouldBe null
fixture.repository.audits.last().outcome shouldBe AdminAuditOutcome.DENIED
}
test("credential reset clears lock state and revokes every target session") {
val fixture = operatorFixture()
val targetId = fixture.seedOperator("reset-target", AdminRole.SUPPORT)
val session = AdminSessionRecord(
id = UUID.randomUUID(),
operatorId = targetId,
normalizedUsername = "reset-target",
role = AdminRole.SUPPORT,
csrfTokenHash = "csrf-hash",
expiresAt = fixture.clock.instant().plus(Duration.ofHours(1)),
)
fixture.repository.seedSession("target-session-hash", session)
val password = "replacement-password".toCharArray()
val credentials = fixture.service.resetCredentials(
fixture.owner,
targetId,
password,
"credentials-reset-1",
)
credentials.totpSecret shouldBe TOTP_SECRET
password.all { it == '\u0000' } shouldBe true
fixture.repository.revokedTokenHashes shouldBe setOf("target-session-hash")
fixture.repository.findOperatorForAuthentication("reset-target")?.lockState shouldBe
AdminLockState(0, null)
fixture.repository.audits.last().run {
action shouldBe AdminAuditAction.OPERATOR_CREDENTIALS_RESET
outcome shouldBe AdminAuditOutcome.SUCCESS
}
}
test("security summary counts enabled operators and active sessions") {
val fixture = operatorFixture()
val targetId = fixture.seedOperator("support-summary", AdminRole.SUPPORT)
fixture.repository.seedSession(
"summary-session-hash",
AdminSessionRecord(
id = UUID.randomUUID(),
operatorId = targetId,
normalizedUsername = "support-summary",
role = AdminRole.SUPPORT,
csrfTokenHash = "csrf-hash",
expiresAt = fixture.clock.instant().plus(Duration.ofHours(1)),
),
)
val summary = fixture.service.summary(fixture.owner)
summary.enabledOperators shouldBe 2
summary.lockedOperators shouldBe 0
summary.activeSessions shouldBe 1
}
test("non-super administrator cannot mutate operators and denial is audited") {
val fixture = operatorFixture()
val support = fixture.owner.copy(role = AdminRole.SUPPORT)
shouldThrow<AdminOperatorException> {
fixture.service.unlock(support, fixture.owner.operatorId)
}.code shouldBe AdminOperatorErrorCode.INSUFFICIENT_PERMISSION
fixture.repository.audits.single().outcome shouldBe AdminAuditOutcome.DENIED
}
test("username role and minimum password boundaries are validated") {
val fixture = operatorFixture()
val invalidRequests = listOf(
Triple("ab", "long-enough-password", "SUPPORT"),
Triple("valid-name", "elevenchars", "SUPPORT"),
Triple("valid-name", "long-enough-password", "OWNER"),
)
invalidRequests.forEach { (username, password, role) ->
shouldThrow<AdminOperatorException> {
fixture.service.create(
fixture.owner,
username,
password.toCharArray(),
role,
)
}.code shouldBe AdminOperatorErrorCode.VALIDATION_ERROR
}
fixture.repository.listOperators() shouldHaveSize 1
fixture.repository.audits.all { it.outcome == AdminAuditOutcome.DENIED } shouldBe true
}
test("operator cursor pagination is stable across equal timestamps") {
val fixture = operatorFixture()
fixture.seedOperator("operator-a", AdminRole.SUPPORT)
fixture.seedOperator("operator-b", AdminRole.ANALYST)
fixture.seedOperator("operator-c", AdminRole.SUPPORT)
val expected = fixture.repository.listOperators()
.sortedWith(compareBy<AdminOperatorRecord> { it.createdAt }.thenBy { it.id.toString() })
val first = fixture.service.listPage(fixture.owner, cursor = null, limit = 2)
val second = fixture.service.listPage(
fixture.owner,
cursor = requireNotNull(first.nextCursor),
limit = 2,
)
(first.items + second.items).map { it.id } shouldContainExactly expected.map { it.id }
second.nextCursor shouldBe null
}
test("invalid operator cursor returns a stable cursor error") {
val fixture = operatorFixture()
shouldThrow<AdminOperatorCursorException> {
fixture.service.listPage(fixture.owner, cursor = "not-a-cursor")
}
}
})
private data class OperatorFixture(
val repository: InMemoryAdminRepository,
val service: AdminOperatorService,
val owner: AdminPrincipal,
val encryptor: FieldEncryptor,
val clock: Clock,
) {
suspend fun seedOperator(username: String, role: AdminRole): UUID {
val id = UUID.randomUUID()
repository.createOperatorIfAbsent(
NewAdminOperator(
id = id,
normalizedUsername = username,
passwordHash = "seed-hash",
encryptedTotpSecret = "seed-encrypted-secret",
role = role,
createdAt = clock.instant(),
),
)
return id
}
}
private fun operatorFixture(): OperatorFixture {
val ownerId = UUID.randomUUID()
val encryptor = FieldEncryptor(ByteArray(32) { 4 })
val repository = InMemoryAdminRepository(
operatorId = ownerId,
encryptedTotpSecret = "owner-encrypted-secret",
)
val clock = Clock.fixed(Instant.parse("2026-08-17T00:00:00Z"), ZoneOffset.UTC)
val generator = AdminTotpSecretGenerator {
AdminTotpProvisioning(
secretBase32 = TOTP_SECRET,
otpauthUri = "otpauth://totp/OSGKeyboard:test?secret=$TOTP_SECRET",
)
}
return OperatorFixture(
repository = repository,
service = AdminOperatorService(
repository = repository,
passwordHasher = RecordingPasswordHasher,
fieldEncryptor = encryptor,
totpSecretGenerator = generator,
clock = clock,
),
owner = AdminPrincipal(
operatorId = ownerId,
sessionId = UUID.randomUUID(),
normalizedUsername = "owner",
role = AdminRole.SUPER_ADMIN,
),
encryptor = encryptor,
clock = clock,
)
}
private data object RecordingPasswordHasher : AdminPasswordHasher {
override fun hash(password: CharArray): String = "hash:${password.concatToString()}"
override fun verify(password: CharArray, encodedHash: String): Boolean = false
}
private const val TOTP_SECRET = "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP"
@@ -0,0 +1,131 @@
package com.osglab.account.features.admin.services
import com.osglab.account.common.security.TokenHash
import com.osglab.account.features.admin.InMemoryAdminRepository
import com.osglab.account.features.admin.models.AdminAuditAction
import com.osglab.account.features.admin.models.AdminRole
import com.osglab.account.features.admin.models.AdminSessionRecord
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import java.time.Clock
import java.time.Duration
import java.time.Instant
import java.time.ZoneId
import java.time.ZoneOffset
import java.util.UUID
class AdminSessionServiceTest : FunSpec({
test("active session authenticates and mutation requires matching CSRF token") {
val fixture = sessionFixture()
fixture.service.authenticate(SESSION_TOKEN)?.sessionId shouldBe fixture.sessionId
fixture.service.authenticateMutation(SESSION_TOKEN, CSRF_TOKEN)?.sessionId shouldBe
fixture.sessionId
fixture.service.authenticateMutation(SESSION_TOKEN, "wrong-csrf") shouldBe null
}
test("session is expired at the exact expiry boundary") {
val fixture = sessionFixture()
fixture.clock.current = fixture.expiresAt
fixture.service.authenticate(SESSION_TOKEN) shouldBe null
}
test("revocation is idempotently denied after the first success") {
val fixture = sessionFixture()
fixture.service.revoke(SESSION_TOKEN, "wrong-csrf", "request-2") shouldBe false
fixture.service.revoke(SESSION_TOKEN, CSRF_TOKEN, "request-2") shouldBe true
fixture.service.authenticate(SESSION_TOKEN) shouldBe null
fixture.service.revoke(SESSION_TOKEN, CSRF_TOKEN, "request-2") shouldBe false
fixture.repository.audits.single().action shouldBe AdminAuditAction.SESSION_REVOKED
}
test("malformed token is rejected before repository lookup") {
val fixture = sessionFixture()
fixture.service.authenticate("") shouldBe null
fixture.service.authenticate("x".repeat(513)) shouldBe null
}
test("invalid external request ID is omitted when revoking a session") {
val fixture = sessionFixture()
fixture.service.revoke(
SESSION_TOKEN,
CSRF_TOKEN,
"invalid request id with spaces",
) shouldBe true
fixture.repository.audits.single().requestId shouldBe null
}
test("cleanup removes only sessions inactive beyond retention") {
val fixture = sessionFixture()
val oldToken = "expired-session-token"
fixture.repository.seedSession(
TokenHash.sha256(oldToken),
AdminSessionRecord(
id = UUID.randomUUID(),
operatorId = fixture.repository.operatorId,
normalizedUsername = "admin@example.com",
role = AdminRole.SUPPORT,
csrfTokenHash = TokenHash.sha256(CSRF_TOKEN),
expiresAt = fixture.clock.instant().minus(Duration.ofDays(8)),
),
)
fixture.service.cleanupInactive(Duration.ofDays(7), limit = 10) shouldBe 1
fixture.repository.sessions.size shouldBe 1
fixture.service.authenticate(SESSION_TOKEN)?.sessionId shouldBe fixture.sessionId
}
})
private data class SessionFixture(
val repository: InMemoryAdminRepository,
val service: AdminSessionService,
val clock: SessionTestClock,
val sessionId: UUID,
val expiresAt: Instant,
)
private suspend fun sessionFixture(): SessionFixture {
val operatorId = UUID.randomUUID()
val repository = InMemoryAdminRepository(
operatorId = operatorId,
encryptedTotpSecret = "not-used",
)
val now = Instant.parse("2026-08-16T00:00:00Z")
val clock = SessionTestClock(now)
val sessionId = UUID.randomUUID()
val expiresAt = now.plusSeconds(60)
repository.seedSession(
TokenHash.sha256(SESSION_TOKEN),
AdminSessionRecord(
id = sessionId,
operatorId = operatorId,
normalizedUsername = "admin@example.com",
role = AdminRole.SUPPORT,
csrfTokenHash = TokenHash.sha256(CSRF_TOKEN),
expiresAt = expiresAt,
),
)
return SessionFixture(
repository = repository,
service = AdminSessionService(repository, clock = clock),
clock = clock,
sessionId = sessionId,
expiresAt = expiresAt,
)
}
private class SessionTestClock(
var current: Instant,
) : Clock() {
override fun getZone(): ZoneId = ZoneOffset.UTC
override fun withZone(zone: ZoneId): Clock = this
override fun instant(): Instant = current
}
private const val SESSION_TOKEN = "opaque-session-token"
private const val CSRF_TOKEN = "opaque-csrf-token"
@@ -0,0 +1,71 @@
package com.osglab.account.features.admin.stats
import com.osglab.account.config.DatabaseConfig
import com.osglab.account.config.DatabaseFactory
import com.osglab.account.features.admin.stats.repositories.AdminStatsRange
import com.osglab.account.features.admin.stats.repositories.ExposedAdminStatsRepository
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldBeEmpty
import io.kotest.matchers.longs.shouldBeExactly
import io.kotest.matchers.shouldBe
import org.opentest4j.TestAbortedException
import org.testcontainers.DockerClientFactory
import org.testcontainers.containers.MySQLContainer
import java.time.Instant
class AdminStatsRepositoryIntegrationTest : FunSpec({
test("MySQL executes every aggregate query without loading entity rows") {
val externalJdbcUrl = System.getenv("TEST_MYSQL_JDBC_URL")?.takeIf(String::isNotBlank)
if (externalJdbcUrl == null && !DockerClientFactory.instance().isDockerAvailable) {
throw TestAbortedException("Docker is unavailable; MySQL integration test skipped")
}
val mysql = if (externalJdbcUrl == null) {
StatsMySqlContainer("mysql:8.4")
.withDatabaseName("osg_admin_stats_test")
.withUsername("test")
.withPassword("test")
.also(StatsMySqlContainer::start)
} else {
null
}
val factory = DatabaseFactory(
DatabaseConfig(
jdbcUrl = externalJdbcUrl ?: requireNotNull(mysql).jdbcUrl,
username = System.getenv("TEST_MYSQL_USER")?.takeIf(String::isNotBlank)
?: mysql?.username
?: "root",
password = System.getenv("TEST_MYSQL_PASSWORD") ?: mysql?.password ?: "",
maximumPoolSize = 2,
),
)
try {
factory.database
val snapshot = ExposedAdminStatsRepository(factory).load(
AdminStatsRange(
from = Instant.parse("2026-08-10T12:00:00Z"),
until = Instant.parse("2026-08-17T12:00:00Z"),
),
)
// A dedicated container starts empty, so every scalar and grouped aggregate is explicit.
if (mysql != null) {
snapshot.overview.totalUsers shouldBeExactly 0
snapshot.overview.totalCreditBalance shouldBeExactly 0
snapshot.overview.activeUsers shouldBeExactly 0
snapshot.referralFunnel.pendingBindings shouldBeExactly 0
snapshot.referralFunnel.ineligibleBindings shouldBeExactly 0
snapshot.registrationsByDate shouldBe emptyMap()
snapshot.referralRanking.shouldBeEmpty()
snapshot.usage.shouldBeEmpty()
}
} finally {
factory.close()
mysql?.stop()
}
}
})
private class StatsMySqlContainer(image: String) :
MySQLContainer<StatsMySqlContainer>(image)
@@ -0,0 +1,162 @@
package com.osglab.account.features.admin.stats
import com.osglab.account.features.admin.stats.models.AdminOverviewDto
import com.osglab.account.features.admin.stats.models.AdminReferralFunnelDto
import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto
import com.osglab.account.features.admin.stats.repositories.AdminStatsAggregates
import com.osglab.account.features.admin.stats.repositories.AdminStatsRange
import com.osglab.account.features.admin.stats.repositories.AdminStatsRepository
import com.osglab.account.features.admin.stats.repositories.AdminStatsSnapshot
import com.osglab.account.features.admin.stats.repositories.ReferralBindingAggregateRow
import com.osglab.account.features.admin.stats.repositories.assembleAdminStats
import com.osglab.account.features.admin.stats.repositories.toExactLong
import com.osglab.account.features.admin.stats.services.AdminStatsService
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.longs.shouldBeExactly
import io.kotest.matchers.shouldBe
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.encodeToJsonElement
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.math.BigDecimal
import java.time.Duration
import java.time.Instant
import java.time.LocalDate
class AdminStatsRepositoryTest : FunSpec({
val from = Instant.parse("2026-08-15T00:00:00Z")
val until = Instant.parse("2026-08-17T00:00:00Z")
test("aggregated rows preserve referral ranking and usage output") {
val snapshot = assembleAdminStats(
AdminStatsAggregates(
overview = AdminOverviewDto(
totalUsers = 20,
registrations = 4,
activeUsers = 3,
totalCreditBalance = 500,
issuedCredits = 130,
consumedCredits = 25,
),
registrationsByDate = mapOf(LocalDate.parse("2026-08-15") to 4),
issuedCreditsByDate = mapOf(LocalDate.parse("2026-08-15") to 130),
consumedCreditsByDate = mapOf(LocalDate.parse("2026-08-16") to 25),
referralFunnel = AdminReferralFunnelDto(
codesCreated = 5,
bindings = 7,
rewardedBindings = 4,
pendingBindings = 2,
ineligibleBindings = 1,
),
referralBindingsByInviter = listOf(
ReferralBindingAggregateRow("user-b", invitedUsers = 3, rewardedUsers = 1),
ReferralBindingAggregateRow("user-c", invitedUsers = 0, rewardedUsers = 1),
ReferralBindingAggregateRow("user-a", invitedUsers = 3, rewardedUsers = 2),
),
referralCreditsByInviter = mapOf(
"user-a" to 60,
"user-b" to 30,
"ledger-only-user" to 90,
),
usage = listOf(
AdminUsageAggregateDto(
kind = "LLM",
requests = 2,
chargedCredits = 20,
asrMillis = 0,
inputTokens = 100,
outputTokens = 50,
),
AdminUsageAggregateDto(
kind = "ASR",
requests = 1,
chargedCredits = 5,
asrMillis = 500,
inputTokens = 0,
outputTokens = 0,
),
),
),
)
snapshot.referralFunnel.pendingBindings shouldBeExactly 2
snapshot.referralFunnel.ineligibleBindings shouldBeExactly 1
snapshot.referralRanking.map { it.userId } shouldBe listOf("user-a", "user-b", "user-c")
snapshot.referralRanking.first().earnedCredits shouldBeExactly 60
snapshot.referralRanking.last().let {
it.invitedUsers shouldBeExactly 0
it.rewardedUsers shouldBeExactly 1
it.earnedCredits shouldBeExactly 0
}
snapshot.referralRanking.none { it.userId == "ledger-only-user" } shouldBe true
snapshot.usage.map { it.kind } shouldBe listOf("ASR", "LLM")
snapshot.usage.first().asrMillis shouldBeExactly 500
snapshot.usage.last().inputTokens shouldBeExactly 100
}
test("service keeps exact 7 30 and 90 day buckets and serializes new outputs") {
val snapshot = AdminStatsSnapshot(
overview = AdminOverviewDto(0, 0, 0, 0, 0, 0),
registrationsByDate = emptyMap(),
issuedCreditsByDate = emptyMap(),
consumedCreditsByDate = emptyMap(),
referralFunnel = AdminReferralFunnelDto(
codesCreated = 0,
bindings = 0,
rewardedBindings = 0,
pendingBindings = 3,
ineligibleBindings = 2,
),
referralRanking = emptyList(),
usage = listOf(
AdminUsageAggregateDto(
kind = "ASR",
requests = 4,
chargedCredits = 12,
asrMillis = 1_200,
inputTokens = 0,
outputTokens = 0,
),
),
)
val capturedRanges = mutableListOf<AdminStatsRange>()
val service = AdminStatsService(
AdminStatsRepository { range ->
capturedRanges += range
snapshot
},
)
listOf(7L, 30L, 90L).forEach { days ->
val result = service.get(until.minus(Duration.ofDays(days)), until)
result.registrationTrend shouldHaveSize days.toInt()
result.creditFlow shouldHaveSize days.toInt()
capturedRanges.last() shouldBe AdminStatsRange(
until.minus(Duration.ofDays(days)),
until,
)
}
val result = service.get(from, until)
val json = Json.encodeToJsonElement(result).jsonObject
json["referralFunnel"]!!.jsonObject["pendingBindings"]!!.jsonPrimitive.content shouldBe "3"
json["referralFunnel"]!!.jsonObject["ineligibleBindings"]!!.jsonPrimitive.content shouldBe "2"
json["usage"]!!.jsonArray.single().jsonObject["requests"]!!.jsonPrimitive.content shouldBe "4"
}
test("database decimal aggregates require an exact Long representation") {
BigDecimal.valueOf(Long.MAX_VALUE).toExactLong() shouldBeExactly Long.MAX_VALUE
BigDecimal.valueOf(Long.MIN_VALUE).toExactLong() shouldBeExactly Long.MIN_VALUE
shouldThrow<ArithmeticException> {
BigDecimal.valueOf(Long.MAX_VALUE).add(BigDecimal.ONE).toExactLong()
}
shouldThrow<ArithmeticException> {
BigDecimal("1.5").toExactLong()
}
}
})
@@ -0,0 +1,229 @@
package com.osglab.account.features.admin.users
import com.osglab.account.features.admin.users.models.AdminUserDetailDto
import com.osglab.account.features.admin.users.models.AdminUserLedgerEntryDto
import com.osglab.account.features.admin.users.models.AdminUserReferralDto
import com.osglab.account.features.admin.users.models.AdminUserSummaryDto
import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto
import com.osglab.account.features.admin.users.repositories.AdminUserCursor
import com.osglab.account.features.admin.users.repositories.AdminUserLedgerCursor
import com.osglab.account.features.admin.users.repositories.AdminUsersRepository
import com.osglab.account.features.admin.users.services.AdminUserNotFoundException
import com.osglab.account.features.admin.users.services.AdminUsersService
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import java.time.Instant
import java.util.UUID
class AdminUsersServiceTest : FunSpec({
test("detail preserves referral code usage referral and recent ledger data") {
val userId = UUID.randomUUID()
val detail = AdminUserDetailDto(
summary = summary(userId, Instant.parse("2026-08-15T00:00:00Z")),
referralCode = "OSG-REFERRAL",
usage = listOf(
AdminUsageAggregateDto(
kind = "LLM",
requests = 2,
chargedCredits = 8,
asrMillis = 0,
inputTokens = 20,
outputTokens = 10,
),
),
referral = AdminUserReferralDto(
inviterUserId = UUID.randomUUID().toString(),
invitedUsers = 3,
rewardedInvites = 2,
),
recentLedger = listOf(
ledgerEntry(
id = UUID.randomUUID(),
createdAt = Instant.parse("2026-08-15T01:00:00Z"),
),
),
)
val service = AdminUsersService(
PagingUsersRepository(
users = emptyList(),
details = mapOf(userId to detail),
),
)
service.detail(userId) shouldBe detail
}
test("ledger returns a page and continuation cursor") {
val userId = UUID.randomUUID()
val entries = listOf(
ledgerEntry(
UUID.fromString("ffffffff-ffff-ffff-ffff-ffffffffffff"),
Instant.parse("2026-08-15T03:00:00Z"),
),
ledgerEntry(
UUID.fromString("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"),
Instant.parse("2026-08-15T02:00:00Z"),
),
ledgerEntry(
UUID.fromString("dddddddd-dddd-dddd-dddd-dddddddddddd"),
Instant.parse("2026-08-15T01:00:00Z"),
),
)
val service = AdminUsersService(
PagingUsersRepository(emptyList(), ledger = mapOf(userId to entries)),
)
val page = service.ledger(userId, limit = 2)
page.items shouldBe entries.take(2)
page.nextCursor.shouldNotBeNull()
}
test("ledger cursor pagination is stable for equal timestamps") {
val createdAt = Instant.parse("2026-08-15T00:00:00Z")
val ids = listOf(
UUID.fromString("ffffffff-ffff-ffff-ffff-ffffffffffff"),
UUID.fromString("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"),
UUID.fromString("dddddddd-dddd-dddd-dddd-dddddddddddd"),
)
val userId = UUID.randomUUID()
val entries = ids.map { ledgerEntry(it, createdAt) }
val repository = PagingUsersRepository(
users = emptyList(),
ledger = mapOf(userId to entries),
)
val service = AdminUsersService(repository)
val first = service.ledger(userId, limit = 2)
val second = service.ledger(
userId,
limit = 2,
cursor = first.nextCursor.shouldNotBeNull(),
)
first.items.map { it.id } shouldBe ids.take(2).map(UUID::toString)
second.items.map { it.id } shouldBe listOf(ids.last().toString())
second.nextCursor shouldBe null
}
test("invalid ledger cursor throws a stable illegal argument exception") {
val service = AdminUsersService(PagingUsersRepository(emptyList()))
val failure = shouldThrow<IllegalArgumentException> {
service.ledger(UUID.randomUUID(), cursor = "not-a-cursor")
}
failure.message shouldBe "User ledger cursor is invalid"
}
test("ledger last page has no continuation cursor") {
val userId = UUID.randomUUID()
val entries = listOf(
ledgerEntry(UUID.randomUUID(), Instant.parse("2026-08-15T02:00:00Z")),
ledgerEntry(UUID.randomUUID(), Instant.parse("2026-08-15T01:00:00Z")),
)
val service = AdminUsersService(
PagingUsersRepository(emptyList(), ledger = mapOf(userId to entries)),
)
val page = service.ledger(userId, limit = 2)
page.items shouldHaveSize 2
page.nextCursor shouldBe null
}
test("ledger rejects a missing user instead of returning an empty page") {
val service = AdminUsersService(PagingUsersRepository(emptyList()))
shouldThrow<AdminUserNotFoundException> {
service.ledger(UUID.randomUUID())
}
}
test("invalid user cursor and missing detail fail without returning user data") {
val service = AdminUsersService(PagingUsersRepository(emptyList()))
shouldThrow<IllegalArgumentException> {
service.list(cursor = "not-a-cursor")
}
shouldThrow<AdminUserNotFoundException> {
service.detail(UUID.randomUUID())
}
}
})
private class PagingUsersRepository(
private val users: List<AdminUserSummaryDto>,
private val details: Map<UUID, AdminUserDetailDto> = emptyMap(),
private val ledger: Map<UUID, List<AdminUserLedgerEntryDto>> = emptyMap(),
) : AdminUsersRepository {
override suspend fun list(
limit: Int,
cursor: AdminUserCursor?,
): List<AdminUserSummaryDto> =
users.filter {
cursor == null ||
Instant.parse(it.createdAt) < cursor.createdAt ||
(
Instant.parse(it.createdAt) == cursor.createdAt &&
UUID.fromString(it.id).toString() < cursor.userId.toString()
)
}.take(limit)
override suspend fun findDetail(
userId: UUID,
ledgerLimit: Int,
): AdminUserDetailDto? = details[userId]
override suspend fun exists(userId: UUID): Boolean =
userId in details || userId in ledger || users.any { it.id == userId.toString() }
override suspend fun listLedger(
userId: UUID,
limit: Int,
cursor: AdminUserLedgerCursor?,
): List<AdminUserLedgerEntryDto> =
ledger[userId].orEmpty()
.filter {
val createdAt = Instant.parse(it.createdAt)
cursor == null ||
createdAt < cursor.createdAt ||
(
createdAt == cursor.createdAt &&
it.id < cursor.ledgerEntryId.toString()
)
}
.sortedWith(
compareByDescending<AdminUserLedgerEntryDto> { Instant.parse(it.createdAt) }
.thenByDescending(AdminUserLedgerEntryDto::id),
)
.take(limit)
}
private fun summary(id: UUID, createdAt: Instant) = AdminUserSummaryDto(
id = id.toString(),
createdAt = createdAt.toString(),
antiAbuseRestricted = false,
creditBalance = 0,
consumedCredits = 0,
manualGrantedCredits = 0,
usageRequests = 0,
lastActiveAt = null,
invitedUsers = 0,
rewardedInvites = 0,
)
private fun ledgerEntry(
id: UUID,
createdAt: Instant,
) = AdminUserLedgerEntryDto(
id = id.toString(),
type = "MANUAL_GRANT",
amountDelta = 10,
balanceAfter = 10,
referenceId = null,
createdAt = createdAt.toString(),
)
@@ -80,6 +80,161 @@ class CreditServiceTest : FunSpec({
store.ledger.filter { it.type == LedgerEntryType.SIGNUP_TRIAL } shouldHaveSize 1 store.ledger.filter { it.type == LedgerEntryType.SIGNUP_TRIAL } shouldHaveSize 1
} }
test("manual grant appends one linked audit and ledger entry") {
val store = storeWithRates(now)
val service = service(store, now)
val userId = UUID.randomUUID()
store.registeredUsers += userId
val result = service.grantManual(
operatorId = TEST_OPERATOR_ID,
userId = userId,
credits = 250,
reason = "Customer support adjustment",
requestId = "support-ticket-1042",
idempotencyKey = "manual-grant-key-001",
)
result.replayed shouldBe false
result.balanceAfter shouldBeExactly 250
store.balance(userId) shouldBeExactly 250
store.manualGrants.single() shouldBe result.grant
store.adminAudits.single().id shouldBe result.grant.auditLogId
store.ledger.single { it.type == LedgerEntryType.MANUAL_GRANT }.let { ledger ->
ledger.referenceId shouldBe result.grant.id
ledger.id shouldBe result.grant.ledgerEntryId
}
}
test("manual grant replay is stable and rejects changed parameters") {
val store = storeWithRates(now)
val service = service(store, now)
val userId = UUID.randomUUID()
store.registeredUsers += userId
val first = service.grantManual(
TEST_OPERATOR_ID,
userId,
100,
"Retention credit",
"support-ticket-1043",
"manual-grant-key-002",
)
val replay = service.grantManual(
TEST_OPERATOR_ID,
userId,
100,
"Retention credit",
"support-ticket-1043",
"manual-grant-key-002",
)
replay.grant shouldBe first.grant
replay.replayed shouldBe true
shouldThrow<CreditConflict> {
service.grantManual(
TEST_OPERATOR_ID,
userId,
101,
"Retention credit",
"support-ticket-1043",
"manual-grant-key-002",
)
}
store.balance(userId) shouldBeExactly 100
store.manualGrants shouldHaveSize 1
store.ledger.filter { it.type == LedgerEntryType.MANUAL_GRANT } shouldHaveSize 1
}
test("manual grant validates positive amount and rolls back an audit failure") {
val store = storeWithRates(now)
val service = service(store, now)
val userId = UUID.randomUUID()
store.registeredUsers += userId
shouldThrow<InvalidCreditRequest> {
service.grantManual(
TEST_OPERATOR_ID,
userId,
0,
"Invalid adjustment",
"support-ticket-1044",
"manual-grant-key-003",
)
}
store.failNextManualGrantInsert = true
shouldThrow<IllegalStateException> {
service.grantManual(
TEST_OPERATOR_ID,
userId,
50,
"Rollback adjustment",
"support-ticket-1045",
"manual-grant-key-004",
)
}
store.balance(userId) shouldBeExactly 0
store.manualGrants shouldHaveSize 0
store.adminAudits shouldHaveSize 0
store.ledger.filter { it.type == LedgerEntryType.MANUAL_GRANT } shouldHaveSize 0
}
test("concurrent manual grant replay credits exactly once") {
val store = storeWithRates(now)
val service = service(store, now)
val userId = UUID.randomUUID()
store.registeredUsers += userId
val results = coroutineScope {
List(8) {
async(Dispatchers.Default) {
service.grantManual(
TEST_OPERATOR_ID,
userId,
75,
"Concurrent adjustment",
"support-ticket-1046",
"manual-grant-key-005",
)
}
}.awaitAll()
}
results.map { it.grant.id }.distinct() shouldHaveSize 1
store.balance(userId) shouldBeExactly 75
store.manualGrants shouldHaveSize 1
store.ledger.filter { it.type == LedgerEntryType.MANUAL_GRANT } shouldHaveSize 1
}
test("manual grant supports the maximum balance and rejects overflow") {
val store = storeWithRates(now)
val service = service(store, now)
val userId = UUID.randomUUID()
store.registeredUsers += userId
service.grantManual(
TEST_OPERATOR_ID,
userId,
Long.MAX_VALUE,
"Maximum supported adjustment",
"support-ticket-1047",
"manual-grant-key-006",
)
shouldThrow<InvalidCreditRequest> {
service.grantManual(
TEST_OPERATOR_ID,
userId,
1,
"Overflow adjustment",
"support-ticket-1048",
"manual-grant-key-007",
)
}
store.balance(userId) shouldBeExactly Long.MAX_VALUE
store.manualGrants shouldHaveSize 1
}
test("balance overflow rolls back without appending a ledger entry") { test("balance overflow rolls back without appending a ledger entry") {
val store = storeWithRates(now) val store = storeWithRates(now)
val service = service(store, now) val service = service(store, now)
@@ -574,3 +729,5 @@ private fun llmRate(now: Instant) = CreditRateVersion(
outputCreditsNumerator = 3, outputCreditsNumerator = 3,
outputTokensDenominator = 1_000, outputTokensDenominator = 1_000,
) )
private val TEST_OPERATOR_ID = UUID.fromString("11111111-1111-1111-1111-111111111111")
@@ -1,11 +1,14 @@
package com.osglab.account.features.credits package com.osglab.account.features.credits
import com.osglab.account.features.admin.grants.repositories.AdminCreditGrantRepository
import com.osglab.account.features.admin.models.NewAdminAuditEvent
import com.osglab.account.features.credits.domain.CreditAccount import com.osglab.account.features.credits.domain.CreditAccount
import com.osglab.account.features.credits.domain.CreditNotFound import com.osglab.account.features.credits.domain.CreditNotFound
import com.osglab.account.features.credits.domain.CreditRateVersion import com.osglab.account.features.credits.domain.CreditRateVersion
import com.osglab.account.features.credits.domain.CreditReservation import com.osglab.account.features.credits.domain.CreditReservation
import com.osglab.account.features.credits.domain.CreditUsageRecord import com.osglab.account.features.credits.domain.CreditUsageRecord
import com.osglab.account.features.credits.domain.LedgerEntry import com.osglab.account.features.credits.domain.LedgerEntry
import com.osglab.account.features.credits.domain.ManualCreditGrant
import com.osglab.account.features.credits.domain.UsageKind import com.osglab.account.features.credits.domain.UsageKind
import com.osglab.account.features.credits.repositories.BillingTransactionRunner import com.osglab.account.features.credits.repositories.BillingTransactionRunner
import com.osglab.account.features.credits.repositories.BillingUnitOfWork import com.osglab.account.features.credits.repositories.BillingUnitOfWork
@@ -25,7 +28,10 @@ import kotlin.concurrent.withLock
class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork { class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork {
private val lock = ReentrantLock() private val lock = ReentrantLock()
private val accounts = mutableMapOf<UUID, CreditAccount>() private val accounts = mutableMapOf<UUID, CreditAccount>()
val registeredUsers = mutableSetOf<UUID>()
val ledger = mutableListOf<LedgerEntry>() val ledger = mutableListOf<LedgerEntry>()
val manualGrants = mutableListOf<ManualCreditGrant>()
val adminAudits = mutableListOf<NewAdminAuditEvent>()
val usageRecords = mutableListOf<CreditUsageRecord>() val usageRecords = mutableListOf<CreditUsageRecord>()
val reservations = mutableMapOf<UUID, CreditReservation>() val reservations = mutableMapOf<UUID, CreditReservation>()
val rates = mutableMapOf<UUID, CreditRateVersion>() val rates = mutableMapOf<UUID, CreditRateVersion>()
@@ -56,11 +62,17 @@ class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork {
override val credits: CreditsRepository = Credits() override val credits: CreditsRepository = Credits()
override val referrals: ReferralsRepository = Referrals() override val referrals: ReferralsRepository = Referrals()
override val adminCreditGrants: AdminCreditGrantRepository = AdminCreditGrants()
var failNextManualGrantInsert = false
override suspend fun <T> inTransaction(block: (BillingUnitOfWork) -> T): T = override suspend fun <T> inTransaction(block: (BillingUnitOfWork) -> T): T =
lock.withLock { lock.withLock {
val accountSnapshot = accounts.toMap() val accountSnapshot = accounts.toMap()
val registeredUserSnapshot = registeredUsers.toSet()
val ledgerSnapshot = ledger.toList() val ledgerSnapshot = ledger.toList()
val manualGrantSnapshot = manualGrants.toList()
val adminAuditSnapshot = adminAudits.toList()
val usageSnapshot = usageRecords.toList() val usageSnapshot = usageRecords.toList()
val reservationSnapshot = reservations.toMap() val reservationSnapshot = reservations.toMap()
val codeSnapshot = codes.toMap() val codeSnapshot = codes.toMap()
@@ -70,7 +82,10 @@ class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork {
block(this) block(this)
} catch (failure: Throwable) { } catch (failure: Throwable) {
accounts.replaceWith(accountSnapshot) accounts.replaceWith(accountSnapshot)
registeredUsers.replaceWith(registeredUserSnapshot)
ledger.replaceWith(ledgerSnapshot) ledger.replaceWith(ledgerSnapshot)
manualGrants.replaceWith(manualGrantSnapshot)
adminAudits.replaceWith(adminAuditSnapshot)
usageRecords.replaceWith(usageSnapshot) usageRecords.replaceWith(usageSnapshot)
reservations.replaceWith(reservationSnapshot) reservations.replaceWith(reservationSnapshot)
codes.replaceWith(codeSnapshot) codes.replaceWith(codeSnapshot)
@@ -83,6 +98,9 @@ class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork {
fun balance(userId: UUID): Long = lock.withLock { accounts[userId]?.balance ?: 0 } fun balance(userId: UUID): Long = lock.withLock { accounts[userId]?.balance ?: 0 }
private inner class Credits : CreditsRepository { private inner class Credits : CreditsRepository {
override fun accountExists(userId: UUID): Boolean =
userId in registeredUsers || userId in accounts
override fun createAccountIfAbsent(userId: UUID, now: Instant) { override fun createAccountIfAbsent(userId: UUID, now: Instant) {
accounts.putIfAbsent(userId, CreditAccount(userId, 0, now)) accounts.putIfAbsent(userId, CreditAccount(userId, 0, now))
} }
@@ -157,6 +175,28 @@ class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork {
} }
} }
private inner class AdminCreditGrants : AdminCreditGrantRepository {
override fun findByIdempotencyKey(
idempotencyKey: String,
): ManualCreditGrant? =
manualGrants.singleOrNull { it.idempotencyKey == idempotencyKey }
override fun insertAudit(event: NewAdminAuditEvent) {
check(adminAudits.none { it.id == event.id })
adminAudits += event
}
override fun insert(grant: ManualCreditGrant) {
if (failNextManualGrantInsert) {
failNextManualGrantInsert = false
error("Simulated manual grant audit failure")
}
check(findByIdempotencyKey(grant.idempotencyKey) == null)
check(manualGrants.none { it.id == grant.id || it.ledgerEntryId == grant.ledgerEntryId })
manualGrants += grant
}
}
private inner class Referrals : ReferralsRepository { private inner class Referrals : ReferralsRepository {
override fun findCodeByOwner(ownerUserId: UUID, campaignId: UUID?): ReferralCode? = override fun findCodeByOwner(ownerUserId: UUID, campaignId: UUID?): ReferralCode? =
codes.values codes.values
@@ -238,6 +278,11 @@ private fun <K, V> MutableMap<K, V>.replaceWith(snapshot: Map<K, V>) {
putAll(snapshot) putAll(snapshot)
} }
private fun <T> MutableSet<T>.replaceWith(snapshot: Set<T>) {
clear()
addAll(snapshot)
}
private fun <T> MutableList<T>.replaceWith(snapshot: List<T>) { private fun <T> MutableList<T>.replaceWith(snapshot: List<T>) {
clear() clear()
addAll(snapshot) addAll(snapshot)
@@ -20,6 +20,7 @@ import com.osglab.account.features.referrals.services.UserRegistrationTimeProvid
import io.kotest.assertions.throwables.shouldThrow import io.kotest.assertions.throwables.shouldThrow
import io.kotest.assertions.withClue import io.kotest.assertions.withClue
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.ints.shouldBeExactly import io.kotest.matchers.ints.shouldBeExactly
import io.kotest.matchers.longs.shouldBeExactly import io.kotest.matchers.longs.shouldBeExactly
import io.kotest.matchers.nulls.shouldBeNull import io.kotest.matchers.nulls.shouldBeNull
@@ -208,8 +209,21 @@ class MySqlSecurityIntegrationTest : FunSpec({
ReferralRewardConfig(inviterCredits = 30, inviteeCredits = 30), ReferralRewardConfig(inviterCredits = 30, inviteeCredits = 30),
) )
val concurrentUser = UUID.randomUUID() val concurrentUser = UUID.randomUUID()
val adminOperator = UUID.randomUUID()
connection().use { connection().use {
insertAccount(it, concurrentUser, "concurrent-sub", identity.ofAppleSubject("concurrent-sub")) insertAccount(it, concurrentUser, "concurrent-sub", identity.ofAppleSubject("concurrent-sub"))
it.createStatement().use { statement ->
statement.executeUpdate(
"""
INSERT INTO admin_operators (
id, username, password_hash, encrypted_totp_secret, role
) VALUES (
'$adminOperator', 'integration-admin', 'unused-hash',
'unused-secret', 'SUPER_ADMIN'
)
""".trimIndent(),
)
}
} }
credits.grantSignupTrial(concurrentUser, 100, "integration-signup-concurrent") credits.grantSignupTrial(concurrentUser, 100, "integration-signup-concurrent")
val reservationResults = coroutineScope { val reservationResults = coroutineScope {
@@ -236,6 +250,34 @@ class MySqlSecurityIntegrationTest : FunSpec({
reservationResults.count { it.isSuccess } shouldBeExactly 1 reservationResults.count { it.isSuccess } shouldBeExactly 1
} }
credits.getAccount(concurrentUser).balance shouldBeExactly 40 credits.getAccount(concurrentUser).balance shouldBeExactly 40
val manualGrantResults = coroutineScope {
List(4) {
async(Dispatchers.Default) {
credits.grantManual(
operatorId = adminOperator,
userId = concurrentUser,
credits = 25,
reason = "Integration support adjustment",
requestId = "integration-audit-reference",
idempotencyKey = "integration-manual-grant",
)
}
}.awaitAll()
}
manualGrantResults.map { it.grant.id }.distinct() shouldHaveSize 1
credits.getAccount(concurrentUser).balance shouldBeExactly 65
connection().use { connection ->
count(
connection,
"admin_credit_grants",
"account_id = '$concurrentUser'",
) shouldBeExactly 1
count(
connection,
"credit_ledger",
"user_id = '$concurrentUser' AND entry_type = 'MANUAL_GRANT'",
) shouldBeExactly 1
}
val inviter = UUID.randomUUID() val inviter = UUID.randomUUID()
val invitee = UUID.randomUUID() val invitee = UUID.randomUUID()
@@ -0,0 +1,29 @@
package com.osglab.account.tools
import io.kotest.matchers.string.shouldContain
import java.nio.file.Files
import kotlin.test.Test
import kotlin.test.assertFailsWith
class AdminCredentialGeneratorTest {
@Test
fun `generator creates separate runtime and operator files without overwriting`() {
val directory = Files.createTempDirectory("admin-credentials-test")
val runtime = directory.resolve("runtime.env")
val handoff = directory.resolve("handoff.txt")
AdminCredentialGenerator.main(
arrayOf("Owner", runtime.toString(), handoff.toString()),
)
Files.readString(runtime) shouldContain "ADMIN_BOOTSTRAP_ENABLED=true"
Files.readString(runtime) shouldContain "ADMIN_BOOTSTRAP_USERNAME=owner"
Files.readString(runtime) shouldContain "ADMIN_BOOTSTRAP_PASSWORD_HASH='\$argon2id\$"
Files.readString(handoff) shouldContain "认证器 URIotpauth://totp/"
assertFailsWith<IllegalArgumentException> {
AdminCredentialGenerator.main(
arrayOf("owner", runtime.toString(), handoff.toString()),
)
}
}
}