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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.DS_Store
|
||||||
@@ -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>
|
||||||
Generated
+2238
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" },
|
||||||
|
),
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
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)}`;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -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
@@ -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="');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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(
|
||||||
|
"<img src=x onerror="alert(1)">",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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,
|
||||||
|
)
|
||||||
+84
@@ -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],
|
||||||
|
)
|
||||||
+32
@@ -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>,
|
||||||
|
)
|
||||||
+334
@@ -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,
|
||||||
|
)
|
||||||
+332
@@ -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)) }
|
||||||
+135
@@ -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.
|
||||||
|
|||||||
+4
@@ -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 {
|
||||||
|
|||||||
+16
@@ -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
|
||||||
|
认证器 URI:otpauth://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"
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
},
|
||||||
|
)
|
||||||
+246
@@ -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()
|
||||||
+54
@@ -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"
|
||||||
+284
@@ -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"
|
||||||
+131
@@ -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"
|
||||||
+71
@@ -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 "认证器 URI:otpauth://totp/"
|
||||||
|
assertFailsWith<IllegalArgumentException> {
|
||||||
|
AdminCredentialGenerator.main(
|
||||||
|
arrayOf("owner", runtime.toString(), handoff.toString()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user