feat(auth, deployment): id,pw or OIDC auth / docker, k8s deployment docs
This commit is contained in:
parent
9f3b4a4613
commit
d9a132c824
41 changed files with 2352 additions and 237 deletions
26
.env.example
Normal file
26
.env.example
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# Core runtime
|
||||
SERVER_PORT=3000
|
||||
DB_DIR=./data
|
||||
TZ=UTC
|
||||
|
||||
# Admin auth
|
||||
ADMIN_AUTH_MODE=both
|
||||
ADMIN_USERNAME=admin
|
||||
# Example for password "change-me": sha256$4d7c51b1efe9047b4978a321b9b4d7c0b4d6a8d4f1f6f7e3b6a5a7b2f4f7d246
|
||||
ADMIN_PASSWORD_HASH=sha256$4d7c51b1efe9047b4978a321b9b4d7c0b4d6a8d4f1f6f7e3b6a5a7b2f4f7d246
|
||||
ADMIN_SESSION_SECRET=replace-with-a-long-random-secret
|
||||
ADMIN_SESSION_TTL_HOURS=12
|
||||
ADMIN_API_TOKEN_TTL_DAYS=30
|
||||
|
||||
# Admin gateway / proxy hardening
|
||||
CORS_ORIGINS=http://localhost:3002,http://127.0.0.1:3002
|
||||
ADMIN_TRUSTED_PROXY_IPS=
|
||||
|
||||
# OpenID Connect
|
||||
OIDC_ISSUER_URL=
|
||||
OIDC_CLIENT_ID=
|
||||
OIDC_CLIENT_SECRET=
|
||||
OIDC_REDIRECT_URI=
|
||||
OIDC_ALLOWED_EMAILS=
|
||||
# Optional override. Defaults to "openid profile email"
|
||||
OIDC_SCOPES=openid profile email
|
||||
24
AGENTS.md
24
AGENTS.md
|
|
@ -28,6 +28,8 @@ scripts/ 개발 스크립트
|
|||
|
||||
**인증**: `Authorization: Bearer <api_key>` → `auth.ts` 미들웨어 → 사용자 식별 + 권한 로드. 이 키는 라우터 접속용이며, 업스트림 요청에는 전달되지 않는다. 업스트림 `Authorization`은 `backends.api_key`가 있을 때만 해당 값으로 주입된다.
|
||||
|
||||
**관리자 인증**: 관리자 프론트와 `/admin/**` API는 별도 관리자 인증을 사용한다. 브라우저는 서버사이드 세션 + `HttpOnly` 쿠키, 자동화는 관리자 API 토큰을 사용하며, 인증 모드는 `ADMIN_AUTH_MODE=env|oidc|both` 로 제어한다.
|
||||
|
||||
**요청 흐름**:
|
||||
```
|
||||
Client → Auth → Script(onRequest) → RouterService → Backend → Script(onResponse) → Response
|
||||
|
|
@ -38,8 +40,12 @@ Client → Auth → Script(onRequest) → RouterService → Backend → Script(o
|
|||
|
||||
**Database**: `DB_DIR` 하위에 `core.db` (users, backends, permissions, user_scripts), `analytics.db` (usage_stats, backend_metrics), `request_logs/request_logs_YYYY-MM.db` (상세 요청 로그)
|
||||
|
||||
**관리자 세션/토큰 저장**: `core.db` 안에 `admin_sessions`, `admin_api_tokens` 테이블이 생성되며, 관리자 로그인 세션과 자동화용 관리자 토큰을 저장한다.
|
||||
|
||||
**상세 로그 조회**: `month` 또는 `date`를 지정하면 해당 월 DB 1개만 조회한다. 둘 다 없으면 최신 월부터 월별 DB를 순차 조회하며 `offset`/`limit`을 적용한다.
|
||||
|
||||
**배포 분리**: 권장 운영 배포는 public/admin 게이트웨이 2개 진입점이다. public gateway는 `/v1/**`, `/health`만 외부 공개하고, admin gateway는 내부망/VPN에서만 관리자 프론트와 `/admin/**`를 제공한다.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
|
|
@ -54,21 +60,35 @@ pnpm run bench # 벤치마크 실행
|
|||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `SERVER_PORT` | 3000 | Express 서버 포트 |
|
||||
| `CLIENT_PORT` | 3001 | Vite 개발 서버 포트 |
|
||||
| `DB_DIR` | ./data | DB 루트 경로 (`core.db`, `analytics.db`, `request_logs/` 생성 위치) |
|
||||
| `TZ` | UTC | 일/월 경계 계산용 타임존. 저장 timestamp는 UTC |
|
||||
| `ADMIN_PASSWORD` | (필수) | 어드민 비밀번호 |
|
||||
| `CORS_ORIGINS` | localhost origins | 허용 CORS 오리진 |
|
||||
| `ADMIN_AUTH_MODE` | `both` | 관리자 로그인 모드 (`env`, `oidc`, `both`) |
|
||||
| `ADMIN_USERNAME` | `admin` 예시 | ENV 관리자 로그인 아이디 |
|
||||
| `ADMIN_PASSWORD_HASH` | (권장 필수) | 관리자 비밀번호 hash (`sha256$...` 또는 `scrypt$...`) |
|
||||
| `ADMIN_SESSION_SECRET` | (필수) | 관리자 세션/토큰 hash salt 용 비밀값 |
|
||||
| `ADMIN_SESSION_TTL_HOURS` | `12` | 관리자 세션 만료 시간 |
|
||||
| `ADMIN_API_TOKEN_TTL_DAYS` | `30` | 관리자 API 토큰 만료 일수 |
|
||||
| `ADMIN_TRUSTED_PROXY_IPS` | empty | 관리자 경로 접근을 허용할 프록시 IP 목록 |
|
||||
| `OIDC_ISSUER_URL` | empty | OpenID Connect issuer URL |
|
||||
| `OIDC_CLIENT_ID` | empty | OIDC client id |
|
||||
| `OIDC_CLIENT_SECRET` | empty | OIDC client secret |
|
||||
| `OIDC_REDIRECT_URI` | empty | OIDC callback URL |
|
||||
| `OIDC_ALLOWED_EMAILS` | empty | 관리자 접근을 허용할 이메일 목록 |
|
||||
| `OIDC_SCOPES` | `openid profile email` | OIDC authorization scope |
|
||||
|
||||
## Detailed Docs
|
||||
|
||||
클라이언트 중심
|
||||
- [docs/client.md](docs/client.md) — 클라이언트 구조, 라우트, 컴포넌트
|
||||
- [docs/frontend-design.md](docs/frontend-design.md) — 프론트엔드 디자인 가이드
|
||||
- [docs/admin-auth.md](docs/admin-auth.md) — 관리자 인증, 세션, CSRF, 관리자 토큰
|
||||
- [docs/oidc.md](docs/oidc.md) — OpenID Connect 설정과 allowlist 정책
|
||||
|
||||
서버 중심
|
||||
- [docs/server.md](docs/server.md) — 서버 구조, 서비스, 모델, 의존성
|
||||
- [docs/database.md](docs/database.md) — DB 테이블 스키마 전체
|
||||
- [docs/api.md](docs/api.md) — API 엔드포인트 레퍼런스
|
||||
- [docs/k8s-traefik.md](docs/k8s-traefik.md) — Traefik IngressRoute, 내부망 IP allowlist 기반 Kubernetes 배포 예시
|
||||
- [docs/scripts.md](docs/scripts.md) — Script Engine 사용법, 타입, 예제
|
||||
- [docs/benchmarks.md](docs/benchmarks.md) — benchmark CLI 사용법
|
||||
|
|
|
|||
10
Dockerfile
10
Dockerfile
|
|
@ -49,9 +49,15 @@ EXPOSE 3000
|
|||
|
||||
CMD ["node", "server/dist/server/src/index.js"]
|
||||
|
||||
FROM nginx:1.28-alpine AS client
|
||||
FROM nginx:1.28-alpine AS admin-gateway
|
||||
|
||||
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY docker/admin-nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/client/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
FROM nginx:1.28-alpine AS public-gateway
|
||||
|
||||
COPY docker/public-nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Router, Route } from '@solidjs/router';
|
||||
import { Show } from 'solid-js';
|
||||
import { Dashboard } from './routes/Dashboard';
|
||||
import { Users } from './routes/Users';
|
||||
import { Backends } from './routes/Backends';
|
||||
|
|
@ -6,17 +7,41 @@ import { Permissions } from './routes/Permissions';
|
|||
import { Analytics } from './routes/Analytics';
|
||||
import { DetailLogs } from './routes/DetailLogs';
|
||||
import { Scripts } from './routes/Scripts';
|
||||
import { AuthProvider, useAuth } from './auth';
|
||||
import { LoginGate } from './components/LoginGate';
|
||||
import { Panel } from './ui';
|
||||
|
||||
function AuthenticatedApp() {
|
||||
const auth = useAuth();
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={!auth.loading()}
|
||||
fallback={
|
||||
<div class="auth-screen">
|
||||
<Panel class="auth-screen__panel" title="Loading Admin Session" description="Restoring the current administrator session." />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show when={auth.session()?.authenticated} fallback={<LoginGate />}>
|
||||
<Router>
|
||||
<Route path="/" component={Dashboard} />
|
||||
<Route path="/users" component={Users} />
|
||||
<Route path="/backends" component={Backends} />
|
||||
<Route path="/permissions" component={Permissions} />
|
||||
<Route path="/analytics" component={Analytics} />
|
||||
<Route path="/detail-logs" component={DetailLogs} />
|
||||
<Route path="/scripts" component={Scripts} />
|
||||
</Router>
|
||||
</Show>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Router>
|
||||
<Route path="/" component={Dashboard} />
|
||||
<Route path="/users" component={Users} />
|
||||
<Route path="/backends" component={Backends} />
|
||||
<Route path="/permissions" component={Permissions} />
|
||||
<Route path="/analytics" component={Analytics} />
|
||||
<Route path="/detail-logs" component={DetailLogs} />
|
||||
<Route path="/scripts" component={Scripts} />
|
||||
</Router>
|
||||
<AuthProvider>
|
||||
<AuthenticatedApp />
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,29 +1,89 @@
|
|||
import type { User, Backend, Permission, RequestLogPage, UsageStats, BackendMetrics, UserScript, CreateScriptData, UpdateScriptData } from '../types';
|
||||
import type {
|
||||
User,
|
||||
Backend,
|
||||
Permission,
|
||||
RequestLogPage,
|
||||
UsageStats,
|
||||
BackendMetrics,
|
||||
UserScript,
|
||||
CreateScriptData,
|
||||
UpdateScriptData,
|
||||
AdminApiTokenSummary,
|
||||
AdminSessionResponse,
|
||||
} from '../types';
|
||||
|
||||
const API_BASE = '/api';
|
||||
const API_BASE = '';
|
||||
let csrfToken: string | null = null;
|
||||
let unauthorizedHandler: (() => void) | null = null;
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export function setAdminCsrfToken(nextToken: string | null) {
|
||||
csrfToken = nextToken;
|
||||
}
|
||||
|
||||
export function setUnauthorizedHandler(handler: (() => void) | null) {
|
||||
unauthorizedHandler = handler;
|
||||
}
|
||||
|
||||
async function fetchJson<T>(url: string, options: RequestInit = {}): Promise<T> {
|
||||
const isUnsafeMethod = !['GET', 'HEAD', 'OPTIONS'].includes((options.method ?? 'GET').toUpperCase());
|
||||
const nextHeaders: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers as Record<string, string> | undefined),
|
||||
};
|
||||
|
||||
if (isUnsafeMethod && url.startsWith('/admin') && csrfToken) {
|
||||
nextHeaders['X-CSRF-Token'] = csrfToken;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
...nextHeaders,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Request failed' }));
|
||||
throw new Error(error.error || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return {} as T;
|
||||
}
|
||||
|
||||
return response.json();
|
||||
const payload = await response.json().catch(() => ({ error: 'Request failed' }));
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 && !url.endsWith('/admin/auth/session')) {
|
||||
unauthorizedHandler?.();
|
||||
}
|
||||
throw new ApiError(response.status, payload.error || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
auth: {
|
||||
getSession: (): Promise<AdminSessionResponse> => fetchJson<AdminSessionResponse>(`${API_BASE}/admin/auth/session`),
|
||||
login: (username: string, password: string): Promise<AdminSessionResponse> =>
|
||||
fetchJson<AdminSessionResponse>(`${API_BASE}/admin/auth/login`, { method: 'POST', body: JSON.stringify({ username, password }) }),
|
||||
logout: (): Promise<void> => fetchJson<void>(`${API_BASE}/admin/auth/logout`, { method: 'POST' }),
|
||||
beginOidc: (next: string = window.location.pathname) => {
|
||||
const search = new URLSearchParams({ next });
|
||||
window.location.href = `${API_BASE}/admin/auth/oidc/start?${search.toString()}`;
|
||||
},
|
||||
getTokens: (): Promise<AdminApiTokenSummary[]> => fetchJson<AdminApiTokenSummary[]>(`${API_BASE}/admin/auth/tokens`),
|
||||
createToken: (name: string, expiresInDays?: number): Promise<{ token: string; record: AdminApiTokenSummary }> =>
|
||||
fetchJson(`${API_BASE}/admin/auth/tokens`, { method: 'POST', body: JSON.stringify({ name, expiresInDays }) }),
|
||||
deleteToken: (id: number): Promise<void> => fetchJson<void>(`${API_BASE}/admin/auth/tokens/${id}`, { method: 'DELETE' }),
|
||||
},
|
||||
users: {
|
||||
getAll: (): Promise<User[]> => fetchJson<User[]>(`${API_BASE}/admin/users`),
|
||||
getById: (id: number): Promise<User> => fetchJson<User>(`${API_BASE}/admin/users/${id}`),
|
||||
|
|
|
|||
74
client/src/auth.tsx
Normal file
74
client/src/auth.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { createContext, createSignal, onMount, useContext, type Accessor, type JSX, type ParentComponent } from 'solid-js';
|
||||
import type { AdminSessionResponse } from './types';
|
||||
import { api, setAdminCsrfToken, setUnauthorizedHandler } from './api/client';
|
||||
|
||||
interface AuthContextValue {
|
||||
session: Accessor<AdminSessionResponse | null>;
|
||||
loading: Accessor<boolean>;
|
||||
refreshSession: () => Promise<AdminSessionResponse>;
|
||||
login: (username: string, password: string) => Promise<AdminSessionResponse>;
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue>();
|
||||
|
||||
function unauthenticatedState(previous: AdminSessionResponse | null): AdminSessionResponse {
|
||||
return {
|
||||
authenticated: false,
|
||||
authMode: previous?.authMode ?? 'both',
|
||||
csrfToken: null,
|
||||
principal: null,
|
||||
};
|
||||
}
|
||||
|
||||
export const AuthProvider: ParentComponent<{ children: JSX.Element }> = (props) => {
|
||||
const [session, setSession] = createSignal<AdminSessionResponse | null>(null);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
|
||||
const refreshSession = async () => {
|
||||
const nextSession = await api.auth.getSession();
|
||||
setSession(nextSession);
|
||||
setAdminCsrfToken(nextSession.csrfToken);
|
||||
setLoading(false);
|
||||
return nextSession;
|
||||
};
|
||||
|
||||
const login = async (username: string, password: string) => {
|
||||
const nextSession = await api.auth.login(username, password);
|
||||
setSession(nextSession);
|
||||
setAdminCsrfToken(nextSession.csrfToken);
|
||||
return nextSession;
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
await api.auth.logout();
|
||||
setSession((previous) => unauthenticatedState(previous));
|
||||
setAdminCsrfToken(null);
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
setUnauthorizedHandler(() => {
|
||||
setSession((previous) => unauthenticatedState(previous));
|
||||
setAdminCsrfToken(null);
|
||||
});
|
||||
|
||||
void refreshSession().catch(() => {
|
||||
setSession({ authenticated: false, authMode: 'both', csrfToken: null, principal: null });
|
||||
setLoading(false);
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ session, loading, refreshSession, login, logout }}>
|
||||
{props.children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('Auth context is not available');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
61
client/src/components/LoginGate.tsx
Normal file
61
client/src/components/LoginGate.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { createSignal, Show, type Component } from 'solid-js';
|
||||
import { Alert, Button, Panel, TextField } from '../ui';
|
||||
import { useAuth } from '../auth';
|
||||
import { api, ApiError } from '../api/client';
|
||||
|
||||
export const LoginGate: Component = () => {
|
||||
const auth = useAuth();
|
||||
const [username, setUsername] = createSignal('');
|
||||
const [password, setPassword] = createSignal('');
|
||||
const [submitting, setSubmitting] = createSignal(false);
|
||||
const [errorMessage, setErrorMessage] = createSignal<string | null>(null);
|
||||
|
||||
const handleSubmit = async (event: Event) => {
|
||||
event.preventDefault();
|
||||
setSubmitting(true);
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
await auth.login(username().trim(), password());
|
||||
setPassword('');
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof ApiError ? error.message : 'Admin login failed.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const authMode = () => auth.session()?.authMode ?? 'both';
|
||||
const envEnabled = () => authMode() === 'env' || authMode() === 'both';
|
||||
const oidcEnabled = () => authMode() === 'oidc' || authMode() === 'both';
|
||||
|
||||
return (
|
||||
<div class="auth-screen">
|
||||
<Panel class="auth-screen__panel" title="Admin Authentication" description="Sign in through the internal admin gateway before accessing router operations.">
|
||||
<div class="ui-stack">
|
||||
<Show when={errorMessage()}>
|
||||
{(message) => <Alert tone="danger">{message()}</Alert>}
|
||||
</Show>
|
||||
|
||||
<Show when={envEnabled()}>
|
||||
<form class="ui-form" onSubmit={(event) => void handleSubmit(event)}>
|
||||
<TextField label="Username" value={username()} onInput={(event) => setUsername(event.currentTarget.value)} />
|
||||
<TextField type="password" label="Password" value={password()} onInput={(event) => setPassword(event.currentTarget.value)} />
|
||||
<Button type="submit" variant="primary" disabled={submitting()}>
|
||||
{submitting() ? 'Signing In...' : 'Sign In'}
|
||||
</Button>
|
||||
</form>
|
||||
</Show>
|
||||
|
||||
<Show when={oidcEnabled()}>
|
||||
<div class="ui-stack ui-stack--tight">
|
||||
<p class="ui-subtitle">Single sign-on is available through the configured OpenID provider.</p>
|
||||
<Button onClick={() => api.auth.beginOidc()} disabled={submitting()}>
|
||||
Continue With OpenID
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,14 +1,41 @@
|
|||
import { createResource, type Component } from 'solid-js';
|
||||
import { createResource, createSignal, Show, type Component } from 'solid-js';
|
||||
import { api } from '../api/client';
|
||||
import { Layout } from '../components/Layout';
|
||||
import { Button, DataGrid, EmptyState, PageHeader, Panel, StatusBadge, SummaryStrip } from '../ui';
|
||||
import { useAuth } from '../auth';
|
||||
import { Alert, Button, DataGrid, EmptyState, PageHeader, Panel, StatusBadge, SummaryStrip, TextField } from '../ui';
|
||||
|
||||
export const Dashboard: Component = () => {
|
||||
const auth = useAuth();
|
||||
const [data, { refetch }] = createResource(async () => ({
|
||||
users: await api.users.getAll(),
|
||||
backends: await api.backends.getAll(),
|
||||
recentRequests: await api.analytics.getRequests({ limit: 10 }),
|
||||
}));
|
||||
const [tokens, { refetch: refetchTokens }] = createResource(() => api.auth.getTokens());
|
||||
const [tokenName, setTokenName] = createSignal('');
|
||||
const [lastIssuedToken, setLastIssuedToken] = createSignal<string | null>(null);
|
||||
const [tokenError, setTokenError] = createSignal<string | null>(null);
|
||||
|
||||
const createToken = async () => {
|
||||
try {
|
||||
const response = await api.auth.createToken(tokenName().trim() || `${auth.session()?.principal?.displayName ?? 'admin'} token`);
|
||||
setLastIssuedToken(response.token);
|
||||
setTokenName('');
|
||||
setTokenError(null);
|
||||
await refetchTokens();
|
||||
} catch (error) {
|
||||
setTokenError(error instanceof Error ? error.message : 'Failed to create admin token.');
|
||||
}
|
||||
};
|
||||
|
||||
const deleteToken = async (tokenId: number) => {
|
||||
try {
|
||||
await api.auth.deleteToken(tokenId);
|
||||
await refetchTokens();
|
||||
} catch (error) {
|
||||
setTokenError(error instanceof Error ? error.message : 'Failed to revoke admin token.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
|
|
@ -54,6 +81,44 @@ export const Dashboard: Component = () => {
|
|||
<EmptyState title="No requests yet" description="Traffic will appear here once authenticated users send requests through the router." />
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<Panel title="Admin API Tokens" description="Issue service tokens for automation without exposing the browser session.">
|
||||
<div class="ui-stack ui-stack--tight">
|
||||
<Show when={tokenError()}>
|
||||
{(message) => <Alert tone="danger">{message()}</Alert>}
|
||||
</Show>
|
||||
<Show when={lastIssuedToken()}>
|
||||
{(token) => <Alert tone="success">Copy this token now: {token()}</Alert>}
|
||||
</Show>
|
||||
<div class="ui-form">
|
||||
<TextField
|
||||
label="Token Name"
|
||||
value={tokenName()}
|
||||
placeholder="e.g. CI deploy automation"
|
||||
onInput={(event) => setTokenName(event.currentTarget.value)}
|
||||
/>
|
||||
<Button onClick={() => void createToken()}>Create Admin Token</Button>
|
||||
</div>
|
||||
<DataGrid
|
||||
rows={tokens() ?? []}
|
||||
columns={[
|
||||
{ id: 'name', header: 'Name', cell: (token) => <span>{token.name}</span> },
|
||||
{ id: 'provider', header: 'Provider', cell: (token) => <StatusBadge tone="neutral">{token.provider}</StatusBadge> },
|
||||
{ id: 'prefix', header: 'Prefix', mono: true, cell: (token) => <span>{token.token_prefix}</span> },
|
||||
{ id: 'expires_at', header: 'Expires', cell: (token) => <span>{new Date(token.expires_at).toLocaleString()}</span> },
|
||||
{ id: 'last_used_at', header: 'Last Used', cell: (token) => <span>{token.last_used_at ? new Date(token.last_used_at).toLocaleString() : '-'}</span> },
|
||||
]}
|
||||
getRowKey={(token) => token.id}
|
||||
loading={tokens.loading}
|
||||
emptyMessage="No admin tokens issued yet."
|
||||
rowActions={(token) => (
|
||||
<Button variant="danger" onClick={() => void deleteToken(token.id)}>
|
||||
Revoke
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -107,3 +107,36 @@ export type UpdateScriptData = {
|
|||
script_code?: string;
|
||||
is_active?: boolean;
|
||||
};
|
||||
|
||||
export type AdminAuthMode = 'env' | 'oidc' | 'both';
|
||||
|
||||
export type AdminPrincipal = {
|
||||
provider: 'env' | 'oidc';
|
||||
subject: string;
|
||||
username?: string;
|
||||
email?: string;
|
||||
displayName: string;
|
||||
};
|
||||
|
||||
export type AdminSessionResponse = {
|
||||
authenticated: boolean;
|
||||
authMode: AdminAuthMode;
|
||||
csrfToken: string | null;
|
||||
principal: AdminPrincipal | null;
|
||||
};
|
||||
|
||||
export type AdminApiTokenSummary = {
|
||||
id: number;
|
||||
name: string;
|
||||
provider: 'env' | 'oidc';
|
||||
subject: string;
|
||||
username?: string;
|
||||
email?: string;
|
||||
display_name: string;
|
||||
token_prefix: string;
|
||||
expires_at: string;
|
||||
last_used_at?: string;
|
||||
revoked_at?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { A, useLocation } from '@solidjs/router';
|
||||
import { ChartColumn, FileCode, LayoutDashboard, Logs, Moon, Server, ShieldCheck, Sun, Users } from 'lucide-solid';
|
||||
import { ChartColumn, FileCode, LayoutDashboard, LogOut, Logs, Moon, Server, ShieldCheck, Sun, Users } from 'lucide-solid';
|
||||
import { For, createMemo, createSignal, onCleanup, onMount, type JSX, type ParentComponent } from 'solid-js';
|
||||
import SnakegroundBg from '../../components/SnakegroundBg';
|
||||
import { useAuth } from '../../auth';
|
||||
import { IconButton } from '../primitives/IconButton';
|
||||
import { cn } from '../lib/cn';
|
||||
import type { ThemeMode } from '../tokens';
|
||||
|
|
@ -24,6 +25,7 @@ const THEME_STORAGE_KEY = 'kyush-theme';
|
|||
|
||||
export const AppShell: ParentComponent<AppShellProps> = (props) => {
|
||||
const location = useLocation();
|
||||
const auth = useAuth();
|
||||
const [themeMode, setThemeMode] = createSignal<ThemeMode>('system');
|
||||
const [systemPrefersDark, setSystemPrefersDark] = createSignal(false);
|
||||
|
||||
|
|
@ -101,12 +103,22 @@ export const AppShell: ParentComponent<AppShellProps> = (props) => {
|
|||
</nav>
|
||||
|
||||
<div class="nav-rail__footer">
|
||||
<div class="nav-rail__session">
|
||||
<p class="nav-rail__session-name">{auth.session()?.principal?.displayName ?? 'Admin'}</p>
|
||||
<p class="nav-rail__session-meta">{auth.session()?.principal?.email ?? auth.session()?.principal?.subject ?? ''}</p>
|
||||
</div>
|
||||
<IconButton
|
||||
class="nav-rail__theme-toggle"
|
||||
icon={resolvedTheme() === 'dark' ? <Sun /> : <Moon />}
|
||||
label={resolvedTheme() === 'dark' ? 'Light Mode' : 'Dark Mode'}
|
||||
onClick={toggleTheme}
|
||||
/>
|
||||
<IconButton
|
||||
class="nav-rail__theme-toggle"
|
||||
icon={<LogOut />}
|
||||
label="Sign Out"
|
||||
onClick={() => void auth.logout()}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { cn } from '../lib/cn';
|
|||
interface TextFieldProps extends ParentProps {
|
||||
label: string;
|
||||
value?: string;
|
||||
type?: string;
|
||||
placeholder?: string;
|
||||
description?: string;
|
||||
errorMessage?: string;
|
||||
|
|
@ -29,6 +30,7 @@ export function TextField(props: TextFieldProps) {
|
|||
) : (
|
||||
<KTextField.Input
|
||||
class="ui-input"
|
||||
type={props.type ?? 'text'}
|
||||
value={props.value}
|
||||
placeholder={props.placeholder}
|
||||
onInput={props.onInput as JSX.EventHandlerUnion<HTMLInputElement, InputEvent>}
|
||||
|
|
|
|||
|
|
@ -83,6 +83,26 @@
|
|||
.nav-rail__footer {
|
||||
margin-top: auto;
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.nav-rail__session {
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
|
||||
.nav-rail__session-name,
|
||||
.nav-rail__session-meta {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.nav-rail__session-meta {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-1);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.nav-rail__theme-toggle {
|
||||
|
|
|
|||
|
|
@ -49,6 +49,17 @@
|
|||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.auth-screen {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: var(--space-6);
|
||||
}
|
||||
|
||||
.auth-screen__panel {
|
||||
width: min(520px, 100%);
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.script-editor {
|
||||
min-height: 520px;
|
||||
|
|
|
|||
|
|
@ -6,10 +6,9 @@ export default defineConfig({
|
|||
server: {
|
||||
port: 3002,
|
||||
proxy: {
|
||||
'/api': {
|
||||
'/admin': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -60,3 +60,44 @@ CREATE INDEX IF NOT EXISTS idx_user_scripts_type ON user_scripts(script_type);
|
|||
CREATE INDEX IF NOT EXISTS idx_user_scripts_active ON user_scripts(is_active);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_scripts_target_user ON user_scripts(target_user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_scripts_target_backend ON user_scripts(target_backend_id);
|
||||
|
||||
-- Admin sessions table
|
||||
CREATE TABLE IF NOT EXISTS admin_sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_token_hash TEXT UNIQUE NOT NULL,
|
||||
provider TEXT NOT NULL CHECK(provider IN ('env', 'oidc')),
|
||||
subject TEXT NOT NULL,
|
||||
username TEXT,
|
||||
email TEXT,
|
||||
display_name TEXT NOT NULL,
|
||||
csrf_token TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
last_used_at TEXT,
|
||||
revoked_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_admin_sessions_subject ON admin_sessions(subject);
|
||||
CREATE INDEX IF NOT EXISTS idx_admin_sessions_expires_at ON admin_sessions(expires_at);
|
||||
|
||||
-- Admin API tokens table
|
||||
CREATE TABLE IF NOT EXISTS admin_api_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
token_hash TEXT UNIQUE NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
provider TEXT NOT NULL CHECK(provider IN ('env', 'oidc')),
|
||||
subject TEXT NOT NULL,
|
||||
username TEXT,
|
||||
email TEXT,
|
||||
display_name TEXT NOT NULL,
|
||||
token_prefix TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
last_used_at TEXT,
|
||||
revoked_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_admin_api_tokens_subject ON admin_api_tokens(subject);
|
||||
CREATE INDEX IF NOT EXISTS idx_admin_api_tokens_expires_at ON admin_api_tokens(expires_at);
|
||||
|
|
|
|||
|
|
@ -3,14 +3,24 @@ services:
|
|||
build:
|
||||
context: .
|
||||
target: server
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
SERVER_PORT: 3000
|
||||
DB_DIR: /data
|
||||
TZ: ${TZ:-UTC}
|
||||
CORS_ORIGINS: http://localhost:3002,http://127.0.0.1:3002
|
||||
ADMIN_AUTH_MODE: ${ADMIN_AUTH_MODE:-both}
|
||||
ADMIN_USERNAME: ${ADMIN_USERNAME:-}
|
||||
ADMIN_PASSWORD_HASH: ${ADMIN_PASSWORD_HASH:-}
|
||||
ADMIN_SESSION_SECRET: ${ADMIN_SESSION_SECRET:-change-me}
|
||||
ADMIN_SESSION_TTL_HOURS: ${ADMIN_SESSION_TTL_HOURS:-12}
|
||||
ADMIN_API_TOKEN_TTL_DAYS: ${ADMIN_API_TOKEN_TTL_DAYS:-30}
|
||||
OIDC_ISSUER_URL: ${OIDC_ISSUER_URL:-}
|
||||
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-}
|
||||
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-}
|
||||
OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:-}
|
||||
OIDC_ALLOWED_EMAILS: ${OIDC_ALLOWED_EMAILS:-}
|
||||
ADMIN_TRUSTED_PROXY_IPS: ${ADMIN_TRUSTED_PROXY_IPS:-}
|
||||
volumes:
|
||||
- router-data:/data
|
||||
restart: unless-stopped
|
||||
|
|
@ -20,14 +30,29 @@ services:
|
|||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
client:
|
||||
public-gateway:
|
||||
build:
|
||||
context: .
|
||||
target: client
|
||||
target: public-gateway
|
||||
depends_on:
|
||||
- server
|
||||
ports:
|
||||
- "3002:80"
|
||||
- "3000:80"
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q -O /dev/null http://localhost/health || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
admin-gateway:
|
||||
build:
|
||||
context: .
|
||||
target: admin-gateway
|
||||
depends_on:
|
||||
- server
|
||||
ports:
|
||||
- "127.0.0.1:3002:80"
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q -O /dev/null http://localhost/ || exit 1"]
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ server {
|
|||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://server:3000/;
|
||||
location /admin/ {
|
||||
proxy_pass http://server:3000/admin/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
26
docker/public-nginx.conf
Normal file
26
docker/public-nginx.conf
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
location = /health {
|
||||
proxy_pass http://server:3000/health;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /v1/ {
|
||||
proxy_pass http://server:3000/v1/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 404;
|
||||
}
|
||||
}
|
||||
132
docs/admin-auth.md
Normal file
132
docs/admin-auth.md
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
# Admin Authentication
|
||||
|
||||
관리자 영역은 `/admin/**` API와 관리자 프론트엔드 전체를 포함한다.
|
||||
`/v1/**` 와 `/health` 는 기존 사용자 API 키 인증 또는 공개 엔드포인트 계약을 유지한다.
|
||||
|
||||
## Authentication Modes
|
||||
|
||||
`ADMIN_AUTH_MODE` 로 관리자 로그인 방식을 제어한다.
|
||||
|
||||
| Mode | Description |
|
||||
|------|-------------|
|
||||
| `env` | ENV 기반 관리자 아이디/비밀번호 로그인만 허용 |
|
||||
| `oidc` | OpenID Connect 로그인만 허용 |
|
||||
| `both` | ENV 로그인과 OIDC 로그인을 모두 허용 |
|
||||
|
||||
기본 추천값은 `both`.
|
||||
|
||||
## Protected Surface
|
||||
|
||||
아래 경로는 관리자 인증이 필요하다.
|
||||
|
||||
- `/admin/users`
|
||||
- `/admin/backends`
|
||||
- `/admin/permissions`
|
||||
- `/admin/scripts`
|
||||
- `/admin/analytics/*`
|
||||
- `/admin/health`
|
||||
|
||||
예외 공개 경로:
|
||||
|
||||
- `POST /admin/auth/login`
|
||||
- `GET /admin/auth/session`
|
||||
- `GET /admin/auth/oidc/start`
|
||||
- `GET /admin/auth/oidc/callback`
|
||||
|
||||
## Session Model
|
||||
|
||||
브라우저 로그인은 서버사이드 세션 + `HttpOnly` 쿠키를 사용한다.
|
||||
|
||||
- 쿠키 이름: `kyush_admin_session`
|
||||
- `SameSite=Lax`
|
||||
- `Secure`: production 환경에서 활성화
|
||||
- 세션 TTL: `ADMIN_SESSION_TTL_HOURS`
|
||||
|
||||
세션 데이터는 `core.db` 의 `admin_sessions` 테이블에 저장된다.
|
||||
|
||||
## CSRF
|
||||
|
||||
세션 기반 관리자 요청은 CSRF 보호를 적용한다.
|
||||
|
||||
- `GET /admin/auth/session` 응답에 `csrfToken` 이 포함된다.
|
||||
- 프론트엔드는 `POST`, `PUT`, `DELETE` 요청마다 `X-CSRF-Token` 헤더를 보낸다.
|
||||
- Bearer 관리자 API 토큰 요청에는 CSRF를 적용하지 않는다.
|
||||
|
||||
## Admin API Tokens
|
||||
|
||||
자동화나 서비스 연동은 관리자 API 토큰을 사용할 수 있다.
|
||||
|
||||
- 발급 API: `POST /admin/auth/tokens`
|
||||
- 조회 API: `GET /admin/auth/tokens`
|
||||
- 폐기 API: `DELETE /admin/auth/tokens/:id`
|
||||
|
||||
토큰은 최초 발급 시 1회만 원문이 반환된다.
|
||||
DB에는 hash 와 prefix 만 저장된다.
|
||||
|
||||
대상 테이블:
|
||||
|
||||
- `admin_api_tokens`
|
||||
|
||||
## ENV Login
|
||||
|
||||
ENV 로그인은 아래 설정이 필요하다.
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `ADMIN_USERNAME` | 관리자 로그인 아이디 |
|
||||
| `ADMIN_PASSWORD_HASH` | 비밀번호 hash |
|
||||
| `ADMIN_SESSION_SECRET` | 세션/토큰 hash salt 및 비밀값 |
|
||||
|
||||
현재 구현은 아래 hash 형식을 지원한다.
|
||||
|
||||
- `sha256$<hex>`
|
||||
- `scrypt$<salt_hex>$<derived_hex>`
|
||||
|
||||
`ADMIN_PASSWORD_HASH` 는 평문 비밀번호 대신 hash 값으로만 저장하는 것을 전제로 한다.
|
||||
|
||||
## Admin Session API
|
||||
|
||||
### `GET /admin/auth/session`
|
||||
|
||||
현재 관리자 세션 정보를 반환한다.
|
||||
|
||||
```json
|
||||
{
|
||||
"authenticated": true,
|
||||
"authMode": "both",
|
||||
"csrfToken": "base64url-token",
|
||||
"principal": {
|
||||
"provider": "env",
|
||||
"subject": "env:admin",
|
||||
"username": "admin",
|
||||
"displayName": "admin"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /admin/auth/login`
|
||||
|
||||
요청 본문:
|
||||
|
||||
```json
|
||||
{
|
||||
"username": "admin",
|
||||
"password": "your-password"
|
||||
}
|
||||
```
|
||||
|
||||
성공 시 세션 쿠키를 발급하고 `AdminSessionResponse` 를 반환한다.
|
||||
|
||||
### `POST /admin/auth/logout`
|
||||
|
||||
현재 세션을 무효화하고 세션 쿠키를 제거한다.
|
||||
|
||||
## Deployment Notes
|
||||
|
||||
운영 배포는 관리자 영역과 공개 라우팅을 분리하는 구성이 권장된다.
|
||||
|
||||
- public gateway: `/v1/**`, `/health`
|
||||
- admin gateway: 관리자 SPA + `/admin/**`
|
||||
|
||||
관리자 프론트는 내부 전용 origin 에서 `/admin/**` 상대 경로만 호출한다.
|
||||
이 구조로 브라우저 CORS 복잡도를 줄이고 관리자 라우팅을 외부 공개하지 않을 수 있다.
|
||||
27
docs/api.md
27
docs/api.md
|
|
@ -9,7 +9,7 @@
|
|||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/health` | 서버 상태 확인 (status, timestamp) |
|
||||
| GET | `/admin/health` | Admin 라우터 상태 확인 (status, timestamp) |
|
||||
| GET | `/admin/health` | 관리자 인증이 필요한 Admin 라우터 상태 확인 (status, timestamp) |
|
||||
|
||||
## OpenAI-Compatible Proxy (인증 필요)
|
||||
|
||||
|
|
@ -20,8 +20,27 @@
|
|||
| POST | `/v1/chat/completions` | Chat completions 프록시 (스크립트 적용, 분석 로깅) |
|
||||
| GET | `/v1/models` | 사용 가능한 모델 목록 |
|
||||
|
||||
`/v1/**`는 기존 사용자 API 키 인증을 유지하며 관리자 인증과 분리된다.
|
||||
|
||||
## Admin API
|
||||
|
||||
`/admin/**`는 기본적으로 관리자 인증이 필요하다. 브라우저는 세션 쿠키, 자동화는 `Authorization: Bearer <admin_api_token>` 방식으로 접근한다.
|
||||
|
||||
세션 기반 요청에서 `POST`, `PUT`, `DELETE`를 호출할 때는 `GET /admin/auth/session`에서 받은 CSRF 토큰을 `X-CSRF-Token` 헤더로 함께 보내야 한다.
|
||||
|
||||
### Auth
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/admin/auth/session` | 현재 관리자 로그인 상태, principal, auth mode, CSRF 토큰 조회 |
|
||||
| POST | `/admin/auth/login` | ENV 관리자 계정 로그인 후 세션 쿠키 발급 |
|
||||
| POST | `/admin/auth/logout` | 현재 관리자 세션 종료 |
|
||||
| GET | `/admin/auth/oidc/start` | OIDC 로그인 시작 |
|
||||
| GET | `/admin/auth/oidc/callback` | OIDC code exchange 후 세션 생성, 관리자 화면으로 redirect |
|
||||
| GET | `/admin/auth/tokens` | 현재 관리자 principal이 발급한 API 토큰 목록 조회 |
|
||||
| POST | `/admin/auth/tokens` | 새 관리자 API 토큰 발급 |
|
||||
| DELETE | `/admin/auth/tokens/:id` | 관리자 API 토큰 폐기 |
|
||||
|
||||
### Users
|
||||
|
||||
| Method | Path | Description |
|
||||
|
|
@ -51,7 +70,7 @@
|
|||
| GET | `/admin/permissions/user/:userId` | 사용자별 권한 조회 |
|
||||
| GET | `/admin/permissions/backend/:backendId` | 백엔드별 권한 조회 |
|
||||
| POST | `/admin/permissions` | 권한 부여 (user_id, backend_id) |
|
||||
| DELETE | `/admin/permissions?user_id=X&backend_id=Y` | 권한 삭제 |
|
||||
| DELETE | `/admin/permissions?user_id=X&backend_id=Y` | 권한 해제 |
|
||||
|
||||
### Scripts
|
||||
|
||||
|
|
@ -77,3 +96,7 @@
|
|||
| GET | `/admin/analytics/metrics` | backendId, days | 백엔드 성능 메트릭 |
|
||||
|
||||
상세 로그는 `users.detail_logging=1` 또는 `backends.detail_logging=1`일 때만 request/response header/body가 저장된다.
|
||||
|
||||
참고:
|
||||
- 관리자 인증과 세션/토큰 정책은 [docs/admin-auth.md](./admin-auth.md) 참고
|
||||
- OpenID Connect 설정은 [docs/oidc.md](./oidc.md) 참고
|
||||
|
|
|
|||
|
|
@ -1,29 +1,31 @@
|
|||
# Client (Solid.js + Vite)
|
||||
|
||||
Admin 대시보드. 진입점: `client/src/index.tsx`
|
||||
Admin 대시보드 진입점: `client/src/index.tsx`
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
client/src/
|
||||
index.tsx # DOM 렌더링 진입점
|
||||
App.tsx # 라우터 정의 (7개 라우트)
|
||||
App.tsx # 관리자 인증 부트스트랩 + 라우트 정의
|
||||
auth.tsx # 관리자 세션 컨텍스트, session bootstrap, login/logout/token helpers
|
||||
api/
|
||||
client.ts # Admin API 클라이언트 (users, backends, permissions, scripts, analytics)
|
||||
client.ts # Admin API 클라이언트 (credentials: include, /admin same-origin 호출)
|
||||
types/
|
||||
index.ts # TypeScript 타입 정의
|
||||
routes/
|
||||
Dashboard.tsx # 홈 — 운영 요약 스트립 + 최근 요청 테이블
|
||||
Users.tsx # 사용자 관리 — CRUD, API 키 재발급, 검색, 상세 로깅 토글
|
||||
Backends.tsx # 백엔드 관리 — CRUD (name, base_url, api_key, detail_logging, is_active)
|
||||
Permissions.tsx # 권한 관리 — user-backend 매핑, 추가/회수
|
||||
Analytics.tsx # 분석 — 최근 요청, 사용량 집계, 백엔드 메트릭 패널
|
||||
DetailLogs.tsx # 상세 로그 탐색 — 월/일/검색/사용자/백엔드 필터 + 페이징 + 페이로드 인스펙터
|
||||
Scripts.tsx # 스크립트 관리 — 목록, 요약, 편집기, 테스트, 활성화/비활성화
|
||||
Dashboard.tsx # 메인 운영 요약 스트립 + 최근 요청 테이블 + 관리자 토큰 관리
|
||||
Users.tsx # 사용자 관리, CRUD, API 키 재발급, 개별 상세 로그 토글
|
||||
Backends.tsx # 백엔드 관리, CRUD (name, base_url, api_key, detail_logging, is_active)
|
||||
Permissions.tsx # 권한 관리, user-backend 매핑, 추가/해제
|
||||
Analytics.tsx # 분석 탭, 최근 요청, 사용량 통계, 백엔드 메트릭 차트/표
|
||||
DetailLogs.tsx # 상세 로그 검색 뷰, 텍스트 검색, 사용자/백엔드 필터 + 페이지 + 페이로드 인스펙터
|
||||
Scripts.tsx # 스크립트 관리, 목록, 요약, 편집기, 테스트, 활성/비활성화
|
||||
components/
|
||||
Layout.tsx # AppShell 래퍼
|
||||
EditModal.tsx # 레거시 범용 편집 모달
|
||||
ScriptEditor.tsx # Monaco 에디터 래퍼 (TypeScript 하이라이팅)
|
||||
ScriptEditor.tsx # Monaco 에디터 래퍼 (TypeScript 하이라이트)
|
||||
LoginGate.tsx # 로그인 화면, ENV 로그인 폼, OIDC 시작 버튼
|
||||
ui/
|
||||
index.ts # primitives/patterns 재수출
|
||||
primitives/ # Button, Dialog, Select, Tabs, TextField 등
|
||||
|
|
@ -41,24 +43,26 @@ client/src/
|
|||
| `/backends` | Backends | 백엔드 관리 |
|
||||
| `/permissions` | Permissions | 권한 관리 |
|
||||
| `/analytics` | Analytics | 집계 기반 분석 대시보드 |
|
||||
| `/detail-logs` | DetailLogs | 상세 요청 로그 탐색/인스펙션 |
|
||||
| `/detail-logs` | DetailLogs | 상세 요청 로그 검색/인스펙션 |
|
||||
| `/scripts` | Scripts | 스크립트 관리 |
|
||||
|
||||
모든 관리자 라우트는 로그인 게이트 아래에서만 렌더링된다.
|
||||
|
||||
## Styling
|
||||
|
||||
공통 UI 레이어는 `client/src/ui/styles.css` 에서 시작하며, 내부적으로 `tokens.css`, `base.css`, `layout.css`, `patterns.css`, `pages.css` 를 불러온다.
|
||||
공통 UI 레이어는 `client/src/ui/styles.css` 에서 시작하고, 내부적으로 `tokens.css`, `base.css`, `layout.css`, `patterns.css`, `pages.css` 를 불러온다.
|
||||
|
||||
- `@kobalte/core` 기반 primitive wrapper와 공통 pattern을 사용한다.
|
||||
- 페이지는 `AppShell`, `PageHeader`, `Panel`, `DataGrid`, `SummaryStrip`, `FormDialog`, `ConfirmDialog` 조합으로 구성된다.
|
||||
- Storybook(`client/.storybook`)에서 같은 스타일 레이어를 사용해 workbench를 유지한다.
|
||||
- 라이트/다크 테마와 dense 콘솔형 레이아웃을 기본 전제로 한다.
|
||||
- Storybook(`client/.storybook`)에서 같은 스타일 레이어를 사용하는 workbench를 운영한다.
|
||||
- 라이트 테마와 dense 콘솔형 레이아웃을 기본 전제로 둔다.
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Package | Purpose |
|
||||
|---------|---------|
|
||||
| solid-js | UI 프레임워크 |
|
||||
| @solidjs/router | 클라이언트 사이드 라우팅 |
|
||||
| @solidjs/router | 클라이언트 사이드 라우터 |
|
||||
| @kobalte/core | headless UI primitive |
|
||||
| lucide-solid | 아이콘 세트 |
|
||||
| solid-monaco | Monaco 에디터 통합 |
|
||||
|
|
@ -66,10 +70,21 @@ client/src/
|
|||
|
||||
## Dev Server
|
||||
|
||||
포트: 3002 (vite.config.ts), API 프록시: `/api` → `http://localhost:3000`
|
||||
포트: 3002 (`vite.config.ts`), 개발 중 API 프록시 `/admin` → `http://localhost:3000`
|
||||
|
||||
운영 배포에서는 관리자 프론트가 admin gateway 뒤에서 same-origin `/admin/**`를 호출한다. 브라우저가 내부 서버 주소를 직접 알 필요는 없다.
|
||||
|
||||
## Admin Auth Notes
|
||||
|
||||
- 앱 시작 시 `GET /admin/auth/session`으로 현재 로그인 상태와 CSRF 토큰을 불러온다.
|
||||
- 인증되지 않은 상태에서는 관리자 화면 대신 `LoginGate`가 렌더링된다.
|
||||
- ENV 로그인 폼과 OIDC 로그인 버튼을 함께 제공한다.
|
||||
- 세션 기반 변경 요청은 `X-CSRF-Token` 헤더를 자동으로 포함한다.
|
||||
- 401 응답은 재로그인 흐름으로, 403 응답은 권한 오류 표시로 처리한다.
|
||||
- 관리자 API 토큰 생성/폐기는 Dashboard에서 수행한다.
|
||||
|
||||
## Analytics Notes
|
||||
|
||||
- `Analytics` 화면은 최근 요청/사용량/메트릭의 집계 뷰를 보여준다.
|
||||
- 상세 요청 로그 탐색은 별도 `DetailLogs` 화면에서 처리한다.
|
||||
- `Analytics` 화면은 최근 요청/사용량 메트릭의 집계 뷰를 보여준다.
|
||||
- 상세 요청 로그 검색은 별도 `DetailLogs` 화면에서 처리한다.
|
||||
- analytics API 클라이언트는 `month`, `date`, `q`, `limit`, `offset`, `userId`, `backendId`, `endpoint`, `detailLogged` 필터를 지원한다.
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ DB는 `DB_DIR` 하위에 분리 저장된다.
|
|||
|
||||
---
|
||||
|
||||
## Core Database (core.db)
|
||||
## Core Database (`core.db`)
|
||||
|
||||
### users
|
||||
|
||||
|
|
@ -17,7 +17,7 @@ DB는 `DB_DIR` 하위에 분리 저장된다.
|
|||
| name | TEXT | NOT NULL |
|
||||
| email | TEXT | |
|
||||
| is_active | BOOLEAN | DEFAULT 1 |
|
||||
| detail_logging | INTEGER | DEFAULT 0 |
|
||||
| detail_logging | INTEGER | NOT NULL DEFAULT 0 |
|
||||
| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP |
|
||||
| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP |
|
||||
|
||||
|
|
@ -32,22 +32,23 @@ Indexes: `idx_users_api_key(api_key)`
|
|||
| base_url | TEXT | NOT NULL |
|
||||
| api_key | TEXT | |
|
||||
| is_active | BOOLEAN | DEFAULT 1 |
|
||||
| detail_logging | INTEGER | DEFAULT 0 |
|
||||
| detail_logging | INTEGER | NOT NULL DEFAULT 0 |
|
||||
| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP |
|
||||
| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP |
|
||||
|
||||
### permissions
|
||||
|
||||
users와 backends의 many-to-many 관계.
|
||||
`users`와 `backends`의 many-to-many 관계.
|
||||
|
||||
| Column | Type | Constraints |
|
||||
|--------|------|-------------|
|
||||
| id | INTEGER | PRIMARY KEY AUTOINCREMENT |
|
||||
| user_id | INTEGER | NOT NULL, FK → users(id) |
|
||||
| backend_id | INTEGER | NOT NULL, FK → backends(id) |
|
||||
| user_id | INTEGER | NOT NULL, FK → `users(id)` |
|
||||
| backend_id | INTEGER | NOT NULL, FK → `backends(id)` |
|
||||
| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP |
|
||||
|
||||
Unique: `(user_id, backend_id)`
|
||||
|
||||
Indexes: `idx_permissions_user(user_id)`, `idx_permissions_backend(backend_id)`
|
||||
|
||||
### user_scripts
|
||||
|
|
@ -56,9 +57,9 @@ Indexes: `idx_permissions_user(user_id)`, `idx_permissions_backend(backend_id)`
|
|||
|--------|------|-------------|
|
||||
| id | INTEGER | PRIMARY KEY AUTOINCREMENT |
|
||||
| name | TEXT | UNIQUE NOT NULL |
|
||||
| script_type | TEXT | NOT NULL, CHECK IN ('per-user-backend', 'per-backend', 'per-user') |
|
||||
| target_user_id | INTEGER | FK → users(id) |
|
||||
| target_backend_id | INTEGER | FK → backends(id) |
|
||||
| script_type | TEXT | NOT NULL, CHECK IN (`'per-user-backend'`, `'per-backend'`, `'per-user'`) |
|
||||
| target_user_id | INTEGER | FK → `users(id)` |
|
||||
| target_backend_id | INTEGER | FK → `backends(id)` |
|
||||
| script_code | TEXT | NOT NULL |
|
||||
| is_active | BOOLEAN | DEFAULT 1 |
|
||||
| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP |
|
||||
|
|
@ -66,15 +67,58 @@ Indexes: `idx_permissions_user(user_id)`, `idx_permissions_backend(backend_id)`
|
|||
|
||||
Indexes: `idx_user_scripts_type`, `idx_user_scripts_active`, `idx_user_scripts_target_user`, `idx_user_scripts_target_backend`
|
||||
|
||||
---
|
||||
### admin_sessions
|
||||
|
||||
## Analytics Database (analytics.db)
|
||||
|
||||
### usage_stats
|
||||
관리자 브라우저 로그인 세션 저장소. 세션 쿠키 원문은 저장하지 않고 `session_token_hash`를 저장한다.
|
||||
|
||||
| Column | Type | Constraints |
|
||||
|--------|------|-------------|
|
||||
일별 집계 테이블. 날짜 경계는 `TZ`, 저장 timestamp는 UTC 기준.
|
||||
| id | INTEGER | PRIMARY KEY AUTOINCREMENT |
|
||||
| session_token_hash | TEXT | UNIQUE NOT NULL |
|
||||
| provider | TEXT | NOT NULL, CHECK IN (`'env'`, `'oidc'`) |
|
||||
| subject | TEXT | NOT NULL |
|
||||
| username | TEXT | |
|
||||
| email | TEXT | |
|
||||
| display_name | TEXT | NOT NULL |
|
||||
| csrf_token | TEXT | NOT NULL |
|
||||
| expires_at | TEXT | NOT NULL |
|
||||
| last_used_at | TEXT | |
|
||||
| revoked_at | TEXT | |
|
||||
| created_at | TEXT | NOT NULL |
|
||||
| updated_at | TEXT | NOT NULL |
|
||||
|
||||
Indexes: `idx_admin_sessions_subject(subject)`, `idx_admin_sessions_expires_at(expires_at)`
|
||||
|
||||
### admin_api_tokens
|
||||
|
||||
자동화/서비스 연동용 관리자 API 토큰 저장소. DB에는 토큰 원문이 아니라 `token_hash`와 `token_prefix`만 저장한다.
|
||||
|
||||
| Column | Type | Constraints |
|
||||
|--------|------|-------------|
|
||||
| id | INTEGER | PRIMARY KEY AUTOINCREMENT |
|
||||
| token_hash | TEXT | UNIQUE NOT NULL |
|
||||
| name | TEXT | NOT NULL |
|
||||
| provider | TEXT | NOT NULL, CHECK IN (`'env'`, `'oidc'`) |
|
||||
| subject | TEXT | NOT NULL |
|
||||
| username | TEXT | |
|
||||
| email | TEXT | |
|
||||
| display_name | TEXT | NOT NULL |
|
||||
| token_prefix | TEXT | NOT NULL |
|
||||
| expires_at | TEXT | NOT NULL |
|
||||
| last_used_at | TEXT | |
|
||||
| revoked_at | TEXT | |
|
||||
| created_at | TEXT | NOT NULL |
|
||||
| updated_at | TEXT | NOT NULL |
|
||||
|
||||
Indexes: `idx_admin_api_tokens_subject(subject)`, `idx_admin_api_tokens_expires_at(expires_at)`
|
||||
|
||||
---
|
||||
|
||||
## Analytics Database (`analytics.db`)
|
||||
|
||||
일별 집계 테이블. 날짜 경계는 `TZ`, 저장 timestamp는 UTC 기준이다.
|
||||
|
||||
### usage_stats
|
||||
|
||||
| Column | Type | Constraints |
|
||||
|--------|------|-------------|
|
||||
|
|
@ -86,6 +130,7 @@ Indexes: `idx_user_scripts_type`, `idx_user_scripts_active`, `idx_user_scripts_t
|
|||
| total_tokens | INTEGER | DEFAULT 0 |
|
||||
|
||||
Unique: `(user_id, backend_id, date)`
|
||||
|
||||
Indexes: `idx_usage_stats_user`, `idx_usage_stats_date`
|
||||
|
||||
### backend_metrics
|
||||
|
|
@ -104,6 +149,7 @@ Indexes: `idx_usage_stats_user`, `idx_usage_stats_date`
|
|||
| success_rate | REAL | DEFAULT 1.0 |
|
||||
|
||||
Unique: `(backend_id, date)`
|
||||
|
||||
Indexes: `idx_backend_metrics_backend`, `idx_backend_metrics_date`
|
||||
|
||||
## Monthly Request Logs (`request_logs/request_logs_YYYY-MM.db`)
|
||||
|
|
@ -126,7 +172,7 @@ Indexes: `idx_backend_metrics_backend`, `idx_backend_metrics_date`
|
|||
| status_code | INTEGER | |
|
||||
| response_time_ms | INTEGER | |
|
||||
| error_message | TEXT | |
|
||||
| detail_logged | INTEGER | DEFAULT 0 |
|
||||
| detail_logged | INTEGER | NOT NULL DEFAULT 0 |
|
||||
| local_date | TEXT | `TZ` 기준 `YYYY-MM-DD` |
|
||||
| request_headers | TEXT | JSON 문자열 |
|
||||
| request_body | TEXT | JSON 또는 raw 문자열 |
|
||||
|
|
@ -134,4 +180,4 @@ Indexes: `idx_backend_metrics_backend`, `idx_backend_metrics_date`
|
|||
| response_body | TEXT | JSON 또는 raw 문자열 |
|
||||
| created_at | TEXT | UTC ISO timestamp |
|
||||
|
||||
Indexes: `created_at`, `local_date`, `user_id`, `backend_id`, `endpoint`, `detail_logged`
|
||||
Indexes: `idx_request_logs_created_at`, `idx_request_logs_local_date`, `idx_request_logs_user`, `idx_request_logs_backend`, `idx_request_logs_endpoint`, `idx_request_logs_detail_logged`
|
||||
|
|
|
|||
387
docs/k8s-traefik.md
Normal file
387
docs/k8s-traefik.md
Normal file
|
|
@ -0,0 +1,387 @@
|
|||
# Kubernetes Deployment with Traefik
|
||||
|
||||
`kyush-llm-router`를 Kubernetes에 배포하면서, 외부에는 라우터 API만 공개하고 관리자 프론트와 `/admin/**`는 내부망에서만 접근하도록 구성하는 예시다.
|
||||
|
||||
이 문서는 ingress controller로 Traefik을 사용하고, 관리자 경로에는 `IngressRoute`와 `Middleware.ipAllowList`를 적용하는 상황을 가정한다.
|
||||
|
||||
## Goals
|
||||
|
||||
- 공개 진입점
|
||||
- `/v1/**`
|
||||
- `/health`
|
||||
- 내부 전용 진입점
|
||||
- 관리자 프론트
|
||||
- `/admin/**`
|
||||
- 브라우저 기준 관리자 프론트와 관리자 API는 same-origin으로 동작
|
||||
- Traefik에서 admin host에 IP allowlist를 적용
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Traefik CRD가 이미 설치되어 있다.
|
||||
- 예시는 `apiVersion: traefik.io/v1alpha1` 기준이다.
|
||||
- public host는 `router.example.com`, admin host는 `router-admin.internal.example.com` 을 사용한다.
|
||||
- admin host는 사내망, VPN, 프라이빗 DNS 같은 내부 경로에서만 해석되거나 접근된다.
|
||||
- 앱 이미지는 역할별로 분리되어 있다고 가정한다.
|
||||
- `server` 이미지: Express 서버
|
||||
- `admin-client` 이미지: 관리자 프론트 정적 파일을 nginx로 서빙하는 이미지
|
||||
|
||||
## Recommended Topology
|
||||
|
||||
```text
|
||||
Internet
|
||||
-> Traefik
|
||||
-> public IngressRoute
|
||||
-> router-server Service:3000
|
||||
|
||||
Internal network / VPN
|
||||
-> Traefik
|
||||
-> admin frontend IngressRoute + ipAllowList middleware
|
||||
-> admin-client Service:80
|
||||
|
||||
Admin frontend
|
||||
-> same-origin /admin/**
|
||||
-> admin api IngressRoute + ipAllowList middleware
|
||||
-> router-server Service:3000
|
||||
```
|
||||
|
||||
핵심은 public/admin을 서로 다른 host로 분리하고, admin 쪽만 Traefik middleware로 한 번 더 제한하는 것이다.
|
||||
|
||||
## Environment And Secrets
|
||||
|
||||
아래 예시는 서버에 필요한 주요 ENV만 담는다.
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: router-server-secret
|
||||
namespace: llm-router
|
||||
type: Opaque
|
||||
stringData:
|
||||
ADMIN_PASSWORD_HASH: "scrypt$..."
|
||||
ADMIN_SESSION_SECRET: "replace-with-long-random-secret"
|
||||
OIDC_CLIENT_SECRET: ""
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: router-server-config
|
||||
namespace: llm-router
|
||||
data:
|
||||
NODE_ENV: "production"
|
||||
SERVER_PORT: "3000"
|
||||
DB_DIR: "/data"
|
||||
TZ: "Asia/Seoul"
|
||||
CORS_ORIGINS: "https://router-admin.internal.example.com"
|
||||
ADMIN_AUTH_MODE: "both"
|
||||
ADMIN_USERNAME: "admin"
|
||||
ADMIN_SESSION_TTL_HOURS: "12"
|
||||
ADMIN_API_TOKEN_TTL_DAYS: "30"
|
||||
ADMIN_TRUSTED_PROXY_IPS: "10.0.0.0/8,192.168.0.0/16"
|
||||
OIDC_ISSUER_URL: ""
|
||||
OIDC_CLIENT_ID: ""
|
||||
OIDC_REDIRECT_URI: "https://router-admin.internal.example.com/admin/auth/oidc/callback"
|
||||
OIDC_ALLOWED_EMAILS: "admin1@example.com,admin2@example.com"
|
||||
OIDC_SCOPES: "openid profile email"
|
||||
```
|
||||
|
||||
## Server Deployment
|
||||
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: router-server
|
||||
namespace: llm-router
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: router-server
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: router-server
|
||||
spec:
|
||||
containers:
|
||||
- name: server
|
||||
image: ghcr.io/example/kyush-llm-router-server:latest
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: router-server-config
|
||||
- secretRef:
|
||||
name: router-server-secret
|
||||
volumeMounts:
|
||||
- name: router-data
|
||||
mountPath: /data
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 3000
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 3000
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 20
|
||||
volumes:
|
||||
- name: router-data
|
||||
persistentVolumeClaim:
|
||||
claimName: router-data
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: router-server
|
||||
namespace: llm-router
|
||||
spec:
|
||||
selector:
|
||||
app: router-server
|
||||
ports:
|
||||
- name: http
|
||||
port: 3000
|
||||
targetPort: 3000
|
||||
```
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: router-data
|
||||
namespace: llm-router
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 10Gi
|
||||
```
|
||||
|
||||
## Admin Frontend Deployment
|
||||
|
||||
정적 파일 서빙이라면 nginx를 사용해도 충분하다. 여기서는 빌드된 관리자 프론트를 nginx가 서빙하는 전용 이미지를 사용한다고 가정한다.
|
||||
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: admin-client
|
||||
namespace: llm-router
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: admin-client
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: admin-client
|
||||
spec:
|
||||
containers:
|
||||
- name: admin-client
|
||||
image: ghcr.io/example/kyush-llm-router-admin:latest
|
||||
ports:
|
||||
- containerPort: 80
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: 80
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: admin-client
|
||||
namespace: llm-router
|
||||
spec:
|
||||
selector:
|
||||
app: admin-client
|
||||
ports:
|
||||
- name: http
|
||||
port: 80
|
||||
targetPort: 80
|
||||
```
|
||||
|
||||
중요한 점은 nginx를 ingress 대신 내부 정적 파일 서버로만 쓰고, 외부 라우팅과 접근 제어는 Traefik이 담당하게 두는 것이다.
|
||||
|
||||
## Traefik Middleware
|
||||
|
||||
관리자용 host에만 내부망 IP allowlist를 적용한다.
|
||||
|
||||
```yaml
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: Middleware
|
||||
metadata:
|
||||
name: admin-ip-allowlist
|
||||
namespace: llm-router
|
||||
spec:
|
||||
ipAllowList:
|
||||
sourceRange:
|
||||
- 10.0.0.0/8
|
||||
- 172.16.0.0/12
|
||||
- 192.168.0.0/16
|
||||
- 100.64.0.0/10
|
||||
ipStrategy:
|
||||
depth: 1
|
||||
```
|
||||
|
||||
`ipStrategy.depth` 는 Traefik 앞단에 L4/L7 프록시가 하나 더 있는 환경에서만 맞춰야 한다. 직접 노출된 Traefik이라면 생략하는 편이 안전하다.
|
||||
|
||||
## Public IngressRoute
|
||||
|
||||
외부 공개는 `/v1` 과 `/health` 만 노출한다.
|
||||
|
||||
```yaml
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: IngressRoute
|
||||
metadata:
|
||||
name: router-public
|
||||
namespace: llm-router
|
||||
spec:
|
||||
entryPoints:
|
||||
- websecure
|
||||
routes:
|
||||
- match: Host(`router.example.com`) && (PathPrefix(`/v1`) || Path(`/health`))
|
||||
kind: Rule
|
||||
services:
|
||||
- name: router-server
|
||||
port: 3000
|
||||
tls:
|
||||
secretName: router-example-com-tls
|
||||
```
|
||||
|
||||
이 라우트에는 `/admin` 이나 관리자 프론트 경로를 넣지 않는다.
|
||||
|
||||
## Admin Frontend IngressRoute
|
||||
|
||||
관리자 프론트는 내부 전용 host로 따로 분리한다.
|
||||
|
||||
```yaml
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: IngressRoute
|
||||
metadata:
|
||||
name: router-admin-frontend
|
||||
namespace: llm-router
|
||||
spec:
|
||||
entryPoints:
|
||||
- websecure
|
||||
routes:
|
||||
- match: Host(`router-admin.internal.example.com`) && Path(`/`)
|
||||
kind: Rule
|
||||
middlewares:
|
||||
- name: admin-ip-allowlist
|
||||
services:
|
||||
- name: admin-client
|
||||
port: 80
|
||||
- match: Host(`router-admin.internal.example.com`) && PathPrefix(`/assets`)
|
||||
kind: Rule
|
||||
middlewares:
|
||||
- name: admin-ip-allowlist
|
||||
services:
|
||||
- name: admin-client
|
||||
port: 80
|
||||
tls:
|
||||
secretName: router-admin-internal-tls
|
||||
```
|
||||
|
||||
단일 페이지 앱 경로 재작성은 admin-client 이미지 내부 nginx 설정에서 처리하는 편이 단순하다.
|
||||
|
||||
## Admin API IngressRoute
|
||||
|
||||
관리자 프론트가 same-origin `/admin/**` 를 직접 호출할 수 있도록 같은 host 아래에서 관리자 API를 서버로 보낸다.
|
||||
|
||||
```yaml
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: IngressRoute
|
||||
metadata:
|
||||
name: router-admin-api
|
||||
namespace: llm-router
|
||||
spec:
|
||||
entryPoints:
|
||||
- websecure
|
||||
routes:
|
||||
- match: Host(`router-admin.internal.example.com`) && PathPrefix(`/admin`)
|
||||
kind: Rule
|
||||
middlewares:
|
||||
- name: admin-ip-allowlist
|
||||
services:
|
||||
- name: router-server
|
||||
port: 3000
|
||||
tls:
|
||||
secretName: router-admin-internal-tls
|
||||
```
|
||||
|
||||
이 방식이면 prefix 제거용 middleware가 필요 없고, 브라우저 요청:
|
||||
|
||||
```text
|
||||
GET https://router-admin.internal.example.com/admin/auth/session
|
||||
```
|
||||
|
||||
은 서버에 그대로 아래처럼 전달된다.
|
||||
|
||||
```text
|
||||
GET /admin/auth/session
|
||||
```
|
||||
|
||||
## Optional Hardening
|
||||
|
||||
- `router-server` Service를 `ClusterIP`로만 두고 외부 노출은 Traefik만 담당하게 한다.
|
||||
- `NetworkPolicy`로 Traefik namespace에서만 `router-server` 와 `admin-client` 에 접근 가능하게 제한한다.
|
||||
- 서버의 `ADMIN_TRUSTED_PROXY_IPS` 에 Traefik Pod CIDR 또는 Service CIDR 범위를 넣어 추가 방어선을 둔다.
|
||||
- admin host는 공인 DNS에 올리지 않고 split-horizon DNS 또는 내부 DNS로만 배포한다.
|
||||
- `/health` 와 `/admin/health` 의 공개 범위를 운영 정책에 맞게 다시 점검한다.
|
||||
|
||||
## Minimal NetworkPolicy Example
|
||||
|
||||
```yaml
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: router-server-ingress
|
||||
namespace: llm-router
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app: router-server
|
||||
policyTypes:
|
||||
- Ingress
|
||||
ingress:
|
||||
- from:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: traefik
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 3000
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: admin-client-ingress
|
||||
namespace: llm-router
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app: admin-client
|
||||
policyTypes:
|
||||
- Ingress
|
||||
ingress:
|
||||
- from:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: traefik
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 80
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- 이 문서는 예시이며, 실제 이미지 이름, TLS secret 이름, CIDR 범위, namespace 이름은 환경에 맞게 바꿔야 한다.
|
||||
- Traefik CRD 버전에 따라 구버전 클러스터는 `traefik.containo.us/v1alpha1` 를 사용할 수 있다. 현재 예시는 `traefik.io/v1alpha1` 기준이다.
|
||||
- IP allowlist는 강한 방어선이지만, 관리자 인증 자체를 대체하지 않는다. 현재 서버의 관리자 세션/OIDC/토큰 인증은 그대로 유지해야 한다.
|
||||
65
docs/oidc.md
Normal file
65
docs/oidc.md
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
# OpenID Connect Setup
|
||||
|
||||
관리자 인증은 generic OpenID Connect discovery 기반으로 동작한다.
|
||||
특정 공급자 전용 분기 없이 issuer metadata 와 authorization code flow 를 사용한다.
|
||||
|
||||
## Required Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `ADMIN_AUTH_MODE` | `oidc` 또는 `both` |
|
||||
| `ADMIN_SESSION_SECRET` | state, nonce, session 보호용 비밀값 |
|
||||
| `OIDC_ISSUER_URL` | issuer URL |
|
||||
| `OIDC_CLIENT_ID` | client id |
|
||||
| `OIDC_CLIENT_SECRET` | client secret |
|
||||
| `OIDC_REDIRECT_URI` | callback URL |
|
||||
| `OIDC_ALLOWED_EMAILS` | 관리자 허용 이메일 목록 |
|
||||
| `OIDC_SCOPES` | 기본값 `openid profile email` |
|
||||
|
||||
## Minimal Example
|
||||
|
||||
```env
|
||||
ADMIN_AUTH_MODE=both
|
||||
ADMIN_SESSION_SECRET=replace-with-long-random-secret
|
||||
OIDC_ISSUER_URL=https://your-issuer.example.com
|
||||
OIDC_CLIENT_ID=your-client-id
|
||||
OIDC_CLIENT_SECRET=your-client-secret
|
||||
OIDC_REDIRECT_URI=http://localhost:3002/admin/auth/oidc/callback
|
||||
OIDC_ALLOWED_EMAILS=admin1@example.com,admin2@example.com
|
||||
OIDC_SCOPES=openid profile email
|
||||
```
|
||||
|
||||
## Production Example
|
||||
|
||||
```env
|
||||
ADMIN_AUTH_MODE=both
|
||||
ADMIN_SESSION_SECRET=replace-with-long-random-secret
|
||||
OIDC_ISSUER_URL=https://auth.example.com/realms/main
|
||||
OIDC_CLIENT_ID=kyush-router-admin
|
||||
OIDC_CLIENT_SECRET=replace-with-client-secret
|
||||
OIDC_REDIRECT_URI=https://admin.internal.example.com/admin/auth/oidc/callback
|
||||
OIDC_ALLOWED_EMAILS=admin1@example.com,admin2@example.com
|
||||
OIDC_SCOPES=openid profile email
|
||||
```
|
||||
|
||||
## Flow
|
||||
|
||||
1. 브라우저가 `GET /admin/auth/oidc/start` 로 이동한다.
|
||||
2. 서버는 discovery metadata 를 읽고 authorization endpoint 로 redirect 한다.
|
||||
3. IdP 로그인 후 `OIDC_REDIRECT_URI` 로 callback 된다.
|
||||
4. 서버는 code exchange 를 수행하고 ID token / userinfo 에서 principal 을 구성한다.
|
||||
5. 이메일이 `OIDC_ALLOWED_EMAILS` 에 포함되면 관리자 세션을 생성한다.
|
||||
6. 이후 브라우저는 세션 쿠키로 `/admin/**` 를 호출한다.
|
||||
|
||||
## Allowlist Policy
|
||||
|
||||
- 관리자 승인은 이메일 allowlist 로 제한한다.
|
||||
- allowlist 에 없는 계정은 인증에 성공해도 관리자 권한을 얻지 못한다.
|
||||
- 이메일 비교는 운영 중 표기 흔들림을 막기 위해 소문자 정규화를 전제로 하는 편이 좋다.
|
||||
|
||||
## Notes
|
||||
|
||||
- `OIDC_REDIRECT_URI` 는 실제 브라우저가 접근하는 관리자 origin 기준이어야 한다.
|
||||
- 관리자 프론트가 same-origin `/admin/**` 를 사용하므로 callback 도 같은 origin 아래 두는 구성이 가장 단순하다.
|
||||
- `OIDC_ALLOWED_EMAILS` 가 비어 있으면 운영 환경에서는 사실상 관리자 승인이 열려버릴 수 있으므로 명시적으로 설정하는 편이 안전하다.
|
||||
- OIDC 는 관리자 인증 수단일 뿐이며, 내부망 접근 제어와 세션/토큰 정책을 대체하지 않는다.
|
||||
|
|
@ -6,56 +6,73 @@
|
|||
|
||||
```
|
||||
server/src/
|
||||
index.ts # Express 앱 팩토리 (CORS, 라우트 마운트, health 엔드포인트)
|
||||
index.ts # Express 엔트리포인트(CORS, 라우터 마운트, health 핸들러, 관리자 인증 적용)
|
||||
config/
|
||||
db-paths.ts # DB_DIR 기준 파일 경로 계산
|
||||
database.ts # Core SQLite 연결 및 스키마 초기화
|
||||
analytics-db.ts # Analytics SQLite 연결 및 스키마 초기화
|
||||
request-logs-db.ts # 월별 request_logs SQLite 연결 및 스키마 초기화
|
||||
db-paths.ts # DB_DIR 기준 파일 경로 계산
|
||||
database.ts # Core SQLite 연결 및 스키마 초기화
|
||||
analytics-db.ts # Analytics SQLite 연결 및 스키마 초기화
|
||||
request-logs-db.ts # 월별 request_logs SQLite 연결 및 스키마 초기화
|
||||
admin-auth.ts # 관리자 인증 ENV 파싱, auth mode / OIDC / TTL 설정
|
||||
models/
|
||||
User.ts # 사용자 CRUD (create, findById, findByApiKey, update, delete, regenerateApiKey)
|
||||
Backend.ts # 백엔드 CRUD (create, findById, findAll, update, delete)
|
||||
Permission.ts # 권한 관리 (user-backend 매핑)
|
||||
Script.ts # 스크립트 CRUD (타입별 필터링, 활성화/비활성화)
|
||||
User.ts # 사용자 CRUD (create, findById, findByApiKey, update, delete, regenerateApiKey)
|
||||
Backend.ts # 백엔드 CRUD (create, findById, findAll, update, delete)
|
||||
Permission.ts # 권한 관리 (user-backend 매핑)
|
||||
Script.ts # 스크립트 CRUD (타입별 필터링, 활성화/비활성화)
|
||||
AdminSession.ts # 관리자 세션 저장/조회/만료 처리
|
||||
AdminApiToken.ts # 관리자 API 토큰 발급/조회/폐기
|
||||
routes/
|
||||
auth.ts # Bearer 토큰 인증 미들웨어 (API 키 검증, 권한 로드)
|
||||
api.ts # OpenAI 호환 프록시 엔드포인트 (/v1/chat/completions, /v1/models)
|
||||
admin.ts # Admin CRUD 엔드포인트 (users, backends, permissions, /admin/health, /admin/scripts 마운트)
|
||||
scripts.ts # Script 관리/테스트 엔드포인트
|
||||
analytics.ts # Analytics 조회 엔드포인트
|
||||
auth.ts # Bearer 토큰 인증 미들웨어 (사용자 API 키 검증, 권한 로드)
|
||||
api.ts # OpenAI 호환 프록시 핸들러 (/v1/chat/completions, /v1/models)
|
||||
admin-auth.ts # 관리자 로그인, 세션, OIDC, 관리자 토큰 API
|
||||
admin.ts # Admin CRUD 핸들러 (users, backends, permissions, /admin/health, /admin/scripts 마운트)
|
||||
scripts.ts # Script 관리/테스트 핸들러
|
||||
analytics.ts # Analytics 조회 핸들러
|
||||
services/
|
||||
RouterService.ts # 활성 백엔드 선택, HTTP 요청 포워딩, header/body 정규화
|
||||
AnalyticsService.ts # 일별 사용량/메트릭 집계 + request_logs 조회
|
||||
RouterService.ts # 활성 백엔드 선택, HTTP 요청 포워딩, header/body 정규화
|
||||
AnalyticsService.ts # 일별 사용량 메트릭 집계 + request_logs 조회
|
||||
RequestLogService.ts # 월별 request_logs 기록/조회
|
||||
ScriptEngine.ts # 스크립트 체인 오케스트레이션 (onRequest/onResponse 훅 적용)
|
||||
ScriptExecutor.ts # isolated-vm 기반 스크립트 컴파일/실행 (5s timeout, 50MB memory)
|
||||
ScriptEngine.ts # 스크립트 체인 오케스트레이션 (onRequest/onResponse 적용)
|
||||
ScriptExecutor.ts # isolated-vm 기반 스크립트 컴파일/실행 (5s timeout, 50MB memory)
|
||||
utils/
|
||||
apiKey.ts # API 키 생성 (sk-{timestamp}-{random}, crypto.randomBytes)
|
||||
logger.ts # 컬러 콘솔 로거
|
||||
time.ts # TZ 기준 날짜/월 계산, UTC timestamp 생성
|
||||
apiKey.ts # API 키 생성 (sk-{timestamp}-{random}, crypto.randomBytes)
|
||||
adminAuth.ts # 관리자 principal 추출, 세션/토큰 인증 미들웨어, CSRF 검증
|
||||
adminSecurity.ts # 비밀번호 hash 검증, 토큰 생성/hash, OIDC state/nonce 처리
|
||||
logger.ts # 컬러 콘솔 로거
|
||||
time.ts # TZ 기준 날짜/월 계산, UTC timestamp 생성
|
||||
```
|
||||
|
||||
## Request Flow
|
||||
|
||||
```
|
||||
Client → auth.ts (API 키 검증, 권한 로드)
|
||||
→ RouterService.selectBackend (허용된 활성 백엔드 중 1개 선택)
|
||||
→ ScriptEngine.applyOnRequestScripts (요청 변조)
|
||||
→ RouterService.forwardRequest (백엔드 프록시)
|
||||
→ ScriptEngine.applyOnResponseScripts (응답 컨텍스트 후처리/검사)
|
||||
→ AnalyticsService.logRequest (집계 + 월별 request_logs 기록)
|
||||
→ Response
|
||||
```text
|
||||
Client -> auth.ts (사용자 API 키 검증, 권한 로드)
|
||||
-> RouterService.selectBackend (허용된 활성 백엔드 중 1개 선택)
|
||||
-> ScriptEngine.applyOnRequestScripts (요청 변조)
|
||||
-> RouterService.forwardRequest (백엔드로 프록시)
|
||||
-> ScriptEngine.applyOnResponseScripts (응답 컨텍스트 후처리 결과)
|
||||
-> AnalyticsService.logRequest (집계 + 월별 request_logs 기록)
|
||||
-> Response
|
||||
```
|
||||
|
||||
참고:
|
||||
- 라우트 마운트는 `server/src/index.ts` 에서 직접 수행한다.
|
||||
- `onResponse` 훅은 실행되지만, 현재 구현에서는 훅 반환값을 최종 HTTP 응답에 다시 반영하지 않는다.
|
||||
- 라우터 마운트는 `server/src/index.ts` 에서 직접 수행한다.
|
||||
- `/admin/**` 는 별도 관리자 인증 레이어를 거친 뒤 각 CRUD/analytics 라우트로 들어간다.
|
||||
- `/admin/auth/*` 만 관리자 영역 내 공개 예외 엔드포인트다.
|
||||
- `onResponse` 훅은 실행되지만 현재 구현에서는 반환값을 최종 HTTP 응답에 다시 반영하지 않는다.
|
||||
|
||||
## Time & Storage
|
||||
|
||||
- DB 루트는 `DB_DIR`로 설정한다. 파일은 `core.db`, `analytics.db`, `request_logs/request_logs_YYYY-MM.db` 구조로 생성된다.
|
||||
- 일/월 경계 계산은 `TZ` 기준으로 수행한다.
|
||||
- 저장되는 timestamp 문자열은 UTC ISO 형식으로 통일한다.
|
||||
- `core.db`에는 관리자 세션과 관리자 API 토큰을 위한 `admin_sessions`, `admin_api_tokens` 테이블도 생성된다.
|
||||
- 일/월 경계 계산은 `TZ` 기준으로 수행된다.
|
||||
- 저장되는 timestamp 문자열은 UTC ISO 형식으로 통일된다.
|
||||
|
||||
## Admin Auth Notes
|
||||
|
||||
- 관리자 브라우저 인증은 서버사이드 세션 + `HttpOnly` 쿠키를 사용한다.
|
||||
- 자동화용 관리자 인증은 Bearer 관리자 API 토큰을 사용한다.
|
||||
- 세션 기반 `POST/PUT/DELETE` 요청에는 CSRF 검사가 적용된다.
|
||||
- OIDC는 generic discovery 방식으로 동작하며, 허용 이메일은 `OIDC_ALLOWED_EMAILS`로 제한한다.
|
||||
- 권장 배포는 public/admin 게이트웨이 분리 구조다. public 쪽은 `/v1/**`, `/health`만 노출하고 admin 쪽은 관리자 프론트와 `/admin/**`를 same-origin으로 제공한다.
|
||||
|
||||
## Dependencies
|
||||
|
||||
|
|
@ -71,14 +88,17 @@ Client → auth.ts (API 키 검증, 권한 로드)
|
|||
## Tests
|
||||
|
||||
통합 테스트: `server/tests/integration/`
|
||||
- `api.test.ts` — 인증, 인가, 프록시 엔드포인트
|
||||
- `admin.test.ts` — Admin CRUD
|
||||
- `routing.test.ts` — 백엔드 선택, 요청 포워딩
|
||||
- `scripts.test.ts` — 스크립트 생성, 실행, 훅
|
||||
- `api.test.ts` - 인증, 에러, 프록시 핸들러
|
||||
- `admin-auth.test.ts` - 관리자 로그인, 세션, CSRF, 관리자 토큰
|
||||
- `admin.test.ts` - Admin CRUD
|
||||
- `routing.test.ts` - 백엔드 선택, 요청 포워딩
|
||||
- `scripts.test.ts` - 스크립트 생성, 실행, 검증
|
||||
|
||||
테스트 유틸: `server/tests/utils/` (`testApp.ts`, `mockBackend.ts`)
|
||||
테스트 유틸: `server/tests/utils/` (`testApp.ts`, `mockBackend.ts`, `adminClient.ts`)
|
||||
|
||||
벤치마크: `server/benchmarks/` (`index.ts`, `runner.ts`, `scenarios.ts`, `report.ts`, `stats.ts`)
|
||||
|
||||
사용 문서:
|
||||
- [docs/benchmarks.md](./benchmarks.md) - benchmark CLI usage, modes, output, caveats
|
||||
- [docs/admin-auth.md](./admin-auth.md) - 관리자 인증, 세션, CSRF, 관리자 토큰
|
||||
- [docs/oidc.md](./oidc.md) - OpenID Connect 설정과 allowlist 정책
|
||||
|
|
|
|||
82
server/src/config/admin-auth.ts
Normal file
82
server/src/config/admin-auth.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { createHash } from 'crypto';
|
||||
import { AdminAuthMode } from '../../../shared/types';
|
||||
|
||||
function normalizeAuthMode(value?: string): AdminAuthMode {
|
||||
if (value === 'env' || value === 'oidc' || value === 'both') {
|
||||
return value;
|
||||
}
|
||||
return 'both';
|
||||
}
|
||||
|
||||
function parseList(value?: string): string[] {
|
||||
return (value ?? '')
|
||||
.split(',')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function getAdminAuthMode(): AdminAuthMode {
|
||||
return normalizeAuthMode(process.env.ADMIN_AUTH_MODE);
|
||||
}
|
||||
|
||||
export function isEnvAdminEnabled(): boolean {
|
||||
const mode = getAdminAuthMode();
|
||||
return mode === 'env' || mode === 'both';
|
||||
}
|
||||
|
||||
export function isOidcEnabled(): boolean {
|
||||
const mode = getAdminAuthMode();
|
||||
return mode === 'oidc' || mode === 'both';
|
||||
}
|
||||
|
||||
export function getAdminUsername(): string | null {
|
||||
return process.env.ADMIN_USERNAME?.trim() || null;
|
||||
}
|
||||
|
||||
export function getAdminPasswordHash(): string | null {
|
||||
return process.env.ADMIN_PASSWORD_HASH?.trim() || null;
|
||||
}
|
||||
|
||||
export function getAdminSessionSecret(): string {
|
||||
return process.env.ADMIN_SESSION_SECRET?.trim() || 'development-admin-session-secret';
|
||||
}
|
||||
|
||||
export function getAdminSessionTtlHours(): number {
|
||||
const parsed = Number(process.env.ADMIN_SESSION_TTL_HOURS ?? 12);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 12;
|
||||
}
|
||||
|
||||
export function getAdminApiTokenTtlDays(): number {
|
||||
const parsed = Number(process.env.ADMIN_API_TOKEN_TTL_DAYS ?? 30);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 30;
|
||||
}
|
||||
|
||||
export function getCookieSecure(): boolean {
|
||||
return process.env.NODE_ENV === 'production';
|
||||
}
|
||||
|
||||
export function getAllowedOidcEmails(): string[] {
|
||||
return parseList(process.env.OIDC_ALLOWED_EMAILS).map((entry) => entry.toLowerCase());
|
||||
}
|
||||
|
||||
export function getTrustedProxyIps(): string[] {
|
||||
return parseList(process.env.ADMIN_TRUSTED_PROXY_IPS);
|
||||
}
|
||||
|
||||
export function getOidcConfig() {
|
||||
return {
|
||||
issuerUrl: process.env.OIDC_ISSUER_URL?.trim() || '',
|
||||
clientId: process.env.OIDC_CLIENT_ID?.trim() || '',
|
||||
clientSecret: process.env.OIDC_CLIENT_SECRET?.trim() || '',
|
||||
redirectUri: process.env.OIDC_REDIRECT_URI?.trim() || '',
|
||||
scopes: process.env.OIDC_SCOPES?.trim() || 'openid profile email',
|
||||
};
|
||||
}
|
||||
|
||||
export function hashOpaqueToken(token: string): string {
|
||||
return createHash('sha256')
|
||||
.update(getAdminSessionSecret())
|
||||
.update(':')
|
||||
.update(token)
|
||||
.digest('hex');
|
||||
}
|
||||
|
|
@ -4,8 +4,10 @@ import dotenv from 'dotenv';
|
|||
import path from 'path';
|
||||
|
||||
import adminRoutes from './routes/admin';
|
||||
import adminAuthRoutes from './routes/admin-auth';
|
||||
import apiRoutes from './routes/api';
|
||||
import analyticsRoutes from './routes/analytics';
|
||||
import { requireAdminAccess, requireSessionCsrf } from './utils/adminAuth';
|
||||
import { logger } from './utils/logger';
|
||||
import { getUtcTimestamp } from './utils/time';
|
||||
|
||||
|
|
@ -18,7 +20,7 @@ export function createServer(): Application {
|
|||
|
||||
const corsOrigins = process.env.CORS_ORIGINS
|
||||
? process.env.CORS_ORIGINS.split(',').map(origin => origin.trim())
|
||||
: ['http://localhost:5173', 'http://localhost:3001'];
|
||||
: ['http://localhost:5173', 'http://localhost:3001', 'http://localhost:3002', 'http://127.0.0.1:3002'];
|
||||
|
||||
app.use(cors({
|
||||
origin: corsOrigins,
|
||||
|
|
@ -26,9 +28,10 @@ export function createServer(): Application {
|
|||
}));
|
||||
app.use(express.json());
|
||||
|
||||
app.use('/admin', adminRoutes);
|
||||
app.use('/admin/auth', adminAuthRoutes);
|
||||
app.use('/admin/analytics', requireAdminAccess, requireSessionCsrf, analyticsRoutes);
|
||||
app.use('/admin', requireAdminAccess, requireSessionCsrf, adminRoutes);
|
||||
app.use('/v1', apiRoutes);
|
||||
app.use('/admin/analytics', analyticsRoutes);
|
||||
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok', timestamp: getUtcTimestamp() });
|
||||
|
|
|
|||
100
server/src/models/AdminApiToken.ts
Normal file
100
server/src/models/AdminApiToken.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { AdminApiTokenSummary, AdminPrincipal } from '../../../shared/types';
|
||||
import { getDb } from '../config/database';
|
||||
import { getUtcTimestamp } from '../utils/time';
|
||||
|
||||
export interface AdminApiTokenRecord extends AdminApiTokenSummary {
|
||||
token_hash: string;
|
||||
}
|
||||
|
||||
export class AdminApiTokenModel {
|
||||
static create(data: {
|
||||
tokenHash: string;
|
||||
tokenPrefix: string;
|
||||
name: string;
|
||||
principal: AdminPrincipal;
|
||||
expiresAt: string;
|
||||
}): AdminApiTokenRecord {
|
||||
const timestamp = getUtcTimestamp();
|
||||
const result = getDb().prepare(`
|
||||
INSERT INTO admin_api_tokens (
|
||||
token_hash, name, provider, subject, username, email, display_name, token_prefix,
|
||||
expires_at, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
data.tokenHash,
|
||||
data.name,
|
||||
data.principal.provider,
|
||||
data.principal.subject,
|
||||
data.principal.username ?? null,
|
||||
data.principal.email ?? null,
|
||||
data.principal.displayName,
|
||||
data.tokenPrefix,
|
||||
data.expiresAt,
|
||||
timestamp,
|
||||
timestamp,
|
||||
);
|
||||
|
||||
return this.findById(Number(result.lastInsertRowid))!;
|
||||
}
|
||||
|
||||
static findById(id: number): AdminApiTokenRecord | undefined {
|
||||
return this.maybeRow(getDb().prepare('SELECT * FROM admin_api_tokens WHERE id = ?').get(id));
|
||||
}
|
||||
|
||||
static findByTokenHash(tokenHash: string): AdminApiTokenRecord | undefined {
|
||||
this.deleteExpired();
|
||||
return this.maybeRow(
|
||||
getDb().prepare(`
|
||||
SELECT * FROM admin_api_tokens
|
||||
WHERE token_hash = ? AND revoked_at IS NULL AND expires_at > ?
|
||||
`).get(tokenHash, getUtcTimestamp())
|
||||
);
|
||||
}
|
||||
|
||||
static listBySubject(subject: string): AdminApiTokenSummary[] {
|
||||
this.deleteExpired();
|
||||
return getDb().prepare(`
|
||||
SELECT id, name, provider, subject, username, email, display_name, token_prefix,
|
||||
expires_at, last_used_at, revoked_at, created_at, updated_at
|
||||
FROM admin_api_tokens
|
||||
WHERE subject = ? AND revoked_at IS NULL AND expires_at > ?
|
||||
ORDER BY created_at DESC
|
||||
`).all(subject, getUtcTimestamp()) as AdminApiTokenSummary[];
|
||||
}
|
||||
|
||||
static touch(id: number): void {
|
||||
const timestamp = getUtcTimestamp();
|
||||
getDb().prepare('UPDATE admin_api_tokens SET last_used_at = ?, updated_at = ? WHERE id = ?')
|
||||
.run(timestamp, timestamp, id);
|
||||
}
|
||||
|
||||
static revoke(id: number): boolean {
|
||||
const timestamp = getUtcTimestamp();
|
||||
const result = getDb().prepare(`
|
||||
UPDATE admin_api_tokens
|
||||
SET revoked_at = ?, updated_at = ?
|
||||
WHERE id = ? AND revoked_at IS NULL
|
||||
`).run(timestamp, timestamp, id);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
static revokeForSubject(id: number, subject: string): boolean {
|
||||
const timestamp = getUtcTimestamp();
|
||||
const result = getDb().prepare(`
|
||||
UPDATE admin_api_tokens
|
||||
SET revoked_at = ?, updated_at = ?
|
||||
WHERE id = ? AND subject = ? AND revoked_at IS NULL
|
||||
`).run(timestamp, timestamp, id, subject);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
static deleteExpired(): void {
|
||||
getDb().prepare('DELETE FROM admin_api_tokens WHERE expires_at <= ? OR revoked_at IS NOT NULL')
|
||||
.run(getUtcTimestamp());
|
||||
}
|
||||
|
||||
private static maybeRow(row: any): AdminApiTokenRecord | undefined {
|
||||
if (!row) return undefined;
|
||||
return row as AdminApiTokenRecord;
|
||||
}
|
||||
}
|
||||
85
server/src/models/AdminSession.ts
Normal file
85
server/src/models/AdminSession.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { AdminPrincipal } from '../../../shared/types';
|
||||
import { getDb } from '../config/database';
|
||||
import { getUtcTimestamp } from '../utils/time';
|
||||
|
||||
export interface AdminSessionRecord {
|
||||
id: number;
|
||||
session_token_hash: string;
|
||||
provider: 'env' | 'oidc';
|
||||
subject: string;
|
||||
username?: string;
|
||||
email?: string;
|
||||
display_name: string;
|
||||
csrf_token: string;
|
||||
expires_at: string;
|
||||
last_used_at?: string;
|
||||
revoked_at?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export class AdminSessionModel {
|
||||
static create(data: {
|
||||
sessionTokenHash: string;
|
||||
principal: AdminPrincipal;
|
||||
csrfToken: string;
|
||||
expiresAt: string;
|
||||
}): AdminSessionRecord {
|
||||
const timestamp = getUtcTimestamp();
|
||||
const result = getDb().prepare(`
|
||||
INSERT INTO admin_sessions (
|
||||
session_token_hash, provider, subject, username, email, display_name,
|
||||
csrf_token, expires_at, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
data.sessionTokenHash,
|
||||
data.principal.provider,
|
||||
data.principal.subject,
|
||||
data.principal.username ?? null,
|
||||
data.principal.email ?? null,
|
||||
data.principal.displayName,
|
||||
data.csrfToken,
|
||||
data.expiresAt,
|
||||
timestamp,
|
||||
timestamp,
|
||||
);
|
||||
|
||||
return this.findById(Number(result.lastInsertRowid))!;
|
||||
}
|
||||
|
||||
static findById(id: number): AdminSessionRecord | undefined {
|
||||
return this.maybeRow(getDb().prepare('SELECT * FROM admin_sessions WHERE id = ?').get(id));
|
||||
}
|
||||
|
||||
static findByTokenHash(sessionTokenHash: string): AdminSessionRecord | undefined {
|
||||
this.deleteExpired();
|
||||
return this.maybeRow(
|
||||
getDb().prepare(`
|
||||
SELECT * FROM admin_sessions
|
||||
WHERE session_token_hash = ? AND revoked_at IS NULL AND expires_at > ?
|
||||
`).get(sessionTokenHash, getUtcTimestamp())
|
||||
);
|
||||
}
|
||||
|
||||
static touch(id: number): void {
|
||||
const timestamp = getUtcTimestamp();
|
||||
getDb().prepare('UPDATE admin_sessions SET last_used_at = ?, updated_at = ? WHERE id = ?')
|
||||
.run(timestamp, timestamp, id);
|
||||
}
|
||||
|
||||
static revoke(id: number): void {
|
||||
const timestamp = getUtcTimestamp();
|
||||
getDb().prepare('UPDATE admin_sessions SET revoked_at = ?, updated_at = ? WHERE id = ? AND revoked_at IS NULL')
|
||||
.run(timestamp, timestamp, id);
|
||||
}
|
||||
|
||||
static deleteExpired(): void {
|
||||
getDb().prepare('DELETE FROM admin_sessions WHERE expires_at <= ? OR revoked_at IS NOT NULL')
|
||||
.run(getUtcTimestamp());
|
||||
}
|
||||
|
||||
private static maybeRow(row: any): AdminSessionRecord | undefined {
|
||||
if (!row) return undefined;
|
||||
return row as AdminSessionRecord;
|
||||
}
|
||||
}
|
||||
291
server/src/routes/admin-auth.ts
Normal file
291
server/src/routes/admin-auth.ts
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
import { Router, Request, Response } from 'express';
|
||||
import { AdminPrincipal, AdminSessionResponse } from '../../../shared/types';
|
||||
import { AdminApiTokenModel } from '../models/AdminApiToken';
|
||||
import { AdminSessionModel } from '../models/AdminSession';
|
||||
import {
|
||||
getAdminApiTokenTtlDays,
|
||||
getAdminAuthMode,
|
||||
getAdminSessionTtlHours,
|
||||
getAdminUsername,
|
||||
getAllowedOidcEmails,
|
||||
getOidcConfig,
|
||||
isEnvAdminEnabled,
|
||||
isOidcEnabled,
|
||||
} from '../config/admin-auth';
|
||||
import { AdminRequest, requireAdminAccess, requireSessionCsrf, resolveAdminAuth } from '../utils/adminAuth';
|
||||
import {
|
||||
clearAdminSessionCookie,
|
||||
createCsrfToken,
|
||||
generateOpaqueToken,
|
||||
hashAdminToken,
|
||||
issueAdminSessionCookie,
|
||||
tokenPrefix,
|
||||
verifyAdminPassword,
|
||||
} from '../utils/adminSecurity';
|
||||
|
||||
const router: Router = Router();
|
||||
const oidcStateStore = new Map<string, { next: string; expiresAt: number }>();
|
||||
|
||||
function isSafeNextPath(value?: string): string {
|
||||
if (!value || !value.startsWith('/') || value.startsWith('//') || value.startsWith('/api/')) {
|
||||
return '/';
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function buildSessionResponse(req: AdminRequest): AdminSessionResponse {
|
||||
return {
|
||||
authenticated: !!req.adminAuth,
|
||||
authMode: getAdminAuthMode(),
|
||||
csrfToken: req.adminAuth?.method === 'session' ? req.adminAuth.csrfToken ?? null : null,
|
||||
principal: req.adminAuth?.principal ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function createAdminSession(res: Response, principal: AdminPrincipal): AdminSessionResponse {
|
||||
const sessionToken = generateOpaqueToken('adm_sess');
|
||||
const csrfToken = createCsrfToken();
|
||||
const ttlHours = getAdminSessionTtlHours();
|
||||
const expiresAt = new Date(Date.now() + ttlHours * 60 * 60 * 1000).toISOString();
|
||||
|
||||
AdminSessionModel.create({
|
||||
sessionTokenHash: hashAdminToken(sessionToken),
|
||||
principal,
|
||||
csrfToken,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
issueAdminSessionCookie(res, sessionToken, ttlHours * 60 * 60 * 1000);
|
||||
return {
|
||||
authenticated: true,
|
||||
authMode: getAdminAuthMode(),
|
||||
csrfToken,
|
||||
principal,
|
||||
};
|
||||
}
|
||||
|
||||
router.get('/session', (req: AdminRequest, res: Response) => {
|
||||
resolveAdminAuth(req);
|
||||
res.json(buildSessionResponse(req));
|
||||
});
|
||||
|
||||
router.post('/login', (req: Request, res: Response) => {
|
||||
if (!isEnvAdminEnabled()) {
|
||||
res.status(404).json({ error: 'ENV admin login is disabled' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { username, password } = req.body as { username?: string; password?: string };
|
||||
const configuredUsername = getAdminUsername();
|
||||
|
||||
if (!configuredUsername || !username || !password) {
|
||||
res.status(401).json({ error: 'Invalid admin credentials' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (username !== configuredUsername || !verifyAdminPassword(password)) {
|
||||
res.status(401).json({ error: 'Invalid admin credentials' });
|
||||
return;
|
||||
}
|
||||
|
||||
const principal: AdminPrincipal = {
|
||||
provider: 'env',
|
||||
subject: `env:${configuredUsername}`,
|
||||
username: configuredUsername,
|
||||
displayName: configuredUsername,
|
||||
};
|
||||
|
||||
res.json(createAdminSession(res, principal));
|
||||
});
|
||||
|
||||
router.post('/logout', requireAdminAccess, requireSessionCsrf, (req: AdminRequest, res: Response) => {
|
||||
if (req.adminAuth?.sessionId) {
|
||||
AdminSessionModel.revoke(req.adminAuth.sessionId);
|
||||
}
|
||||
|
||||
clearAdminSessionCookie(res);
|
||||
res.status(204).send();
|
||||
});
|
||||
|
||||
router.get('/oidc/start', async (req: Request, res: Response) => {
|
||||
if (!isOidcEnabled()) {
|
||||
res.status(404).json({ error: 'OIDC is disabled' });
|
||||
return;
|
||||
}
|
||||
|
||||
const oidc = getOidcConfig();
|
||||
if (!oidc.issuerUrl || !oidc.clientId || !oidc.redirectUri) {
|
||||
res.status(500).json({ error: 'OIDC is not configured' });
|
||||
return;
|
||||
}
|
||||
|
||||
const state = generateOpaqueToken('oidc_state');
|
||||
const next = isSafeNextPath(typeof req.query.next === 'string' ? req.query.next : '/');
|
||||
oidcStateStore.set(state, { next, expiresAt: Date.now() + 10 * 60 * 1000 });
|
||||
|
||||
try {
|
||||
const discoveryResponse = await fetch(`${oidc.issuerUrl.replace(/\/$/, '')}/.well-known/openid-configuration`);
|
||||
if (!discoveryResponse.ok) {
|
||||
throw new Error('Failed to load OIDC discovery document');
|
||||
}
|
||||
|
||||
const discovery = await discoveryResponse.json() as { authorization_endpoint: string };
|
||||
const redirect = new URL(discovery.authorization_endpoint);
|
||||
redirect.searchParams.set('client_id', oidc.clientId);
|
||||
redirect.searchParams.set('response_type', 'code');
|
||||
redirect.searchParams.set('scope', oidc.scopes);
|
||||
redirect.searchParams.set('redirect_uri', oidc.redirectUri);
|
||||
redirect.searchParams.set('state', state);
|
||||
res.redirect(redirect.toString());
|
||||
} catch (error) {
|
||||
oidcStateStore.delete(state);
|
||||
res.status(502).json({ error: error instanceof Error ? error.message : 'OIDC discovery failed' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/oidc/callback', async (req: Request, res: Response) => {
|
||||
if (!isOidcEnabled()) {
|
||||
res.status(404).json({ error: 'OIDC is disabled' });
|
||||
return;
|
||||
}
|
||||
|
||||
const state = typeof req.query.state === 'string' ? req.query.state : '';
|
||||
const code = typeof req.query.code === 'string' ? req.query.code : '';
|
||||
const stateRecord = oidcStateStore.get(state);
|
||||
oidcStateStore.delete(state);
|
||||
|
||||
if (!stateRecord || stateRecord.expiresAt < Date.now() || !code) {
|
||||
res.status(400).json({ error: 'Invalid OIDC callback state' });
|
||||
return;
|
||||
}
|
||||
|
||||
const oidc = getOidcConfig();
|
||||
try {
|
||||
const discoveryResponse = await fetch(`${oidc.issuerUrl.replace(/\/$/, '')}/.well-known/openid-configuration`);
|
||||
if (!discoveryResponse.ok) {
|
||||
throw new Error('Failed to load OIDC discovery document');
|
||||
}
|
||||
|
||||
const discovery = await discoveryResponse.json() as {
|
||||
token_endpoint: string;
|
||||
userinfo_endpoint?: string;
|
||||
};
|
||||
|
||||
const tokenResponse = await fetch(discovery.token_endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
client_id: oidc.clientId,
|
||||
client_secret: oidc.clientSecret,
|
||||
redirect_uri: oidc.redirectUri,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
throw new Error('Failed to exchange OIDC authorization code');
|
||||
}
|
||||
|
||||
const tokenPayload = await tokenResponse.json() as { access_token?: string; id_token?: string };
|
||||
|
||||
let email = '';
|
||||
let subject = '';
|
||||
let displayName = '';
|
||||
|
||||
if (discovery.userinfo_endpoint && tokenPayload.access_token) {
|
||||
const userInfoResponse = await fetch(discovery.userinfo_endpoint, {
|
||||
headers: { Authorization: `Bearer ${tokenPayload.access_token}` },
|
||||
});
|
||||
if (userInfoResponse.ok) {
|
||||
const userInfo = await userInfoResponse.json() as {
|
||||
email?: string;
|
||||
sub?: string;
|
||||
name?: string;
|
||||
preferred_username?: string;
|
||||
};
|
||||
email = userInfo.email ?? '';
|
||||
subject = userInfo.sub ?? '';
|
||||
displayName = userInfo.name ?? userInfo.preferred_username ?? email ?? subject;
|
||||
}
|
||||
}
|
||||
|
||||
if ((!email || !subject) && tokenPayload.id_token) {
|
||||
const parts = tokenPayload.id_token.split('.');
|
||||
if (parts.length >= 2) {
|
||||
const claims = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')) as {
|
||||
email?: string;
|
||||
sub?: string;
|
||||
name?: string;
|
||||
preferred_username?: string;
|
||||
};
|
||||
email = email || claims.email || '';
|
||||
subject = subject || claims.sub || '';
|
||||
displayName = displayName || claims.name || claims.preferred_username || email || subject;
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedEmail = email.toLowerCase();
|
||||
if (!normalizedEmail || !subject || !getAllowedOidcEmails().includes(normalizedEmail)) {
|
||||
res.status(403).json({ error: 'OIDC account is not allowed for admin access' });
|
||||
return;
|
||||
}
|
||||
|
||||
const principal: AdminPrincipal = {
|
||||
provider: 'oidc',
|
||||
subject: `oidc:${oidc.issuerUrl}:${subject}`,
|
||||
email: normalizedEmail,
|
||||
displayName: displayName || normalizedEmail,
|
||||
};
|
||||
|
||||
createAdminSession(res, principal);
|
||||
res.redirect(stateRecord.next);
|
||||
} catch (error) {
|
||||
res.status(502).json({ error: error instanceof Error ? error.message : 'OIDC authentication failed' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/tokens', requireAdminAccess, (req: AdminRequest, res: Response) => {
|
||||
res.json(AdminApiTokenModel.listBySubject(req.adminAuth!.principal.subject));
|
||||
});
|
||||
|
||||
router.post('/tokens', requireAdminAccess, requireSessionCsrf, (req: AdminRequest, res: Response) => {
|
||||
const { name, expiresInDays } = req.body as { name?: string; expiresInDays?: number };
|
||||
const trimmedName = name?.trim();
|
||||
if (!trimmedName) {
|
||||
res.status(400).json({ error: 'Token name is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const ttlDays = Number.isFinite(expiresInDays) && Number(expiresInDays) > 0
|
||||
? Number(expiresInDays)
|
||||
: getAdminApiTokenTtlDays();
|
||||
const token = generateOpaqueToken('adm_tok');
|
||||
const record = AdminApiTokenModel.create({
|
||||
tokenHash: hashAdminToken(token),
|
||||
tokenPrefix: tokenPrefix(token),
|
||||
name: trimmedName,
|
||||
principal: req.adminAuth!.principal,
|
||||
expiresAt: new Date(Date.now() + ttlDays * 24 * 60 * 60 * 1000).toISOString(),
|
||||
});
|
||||
|
||||
res.status(201).json({ token, record });
|
||||
});
|
||||
|
||||
router.delete('/tokens/:id', requireAdminAccess, requireSessionCsrf, (req: AdminRequest, res: Response) => {
|
||||
const tokenId = Number(req.params.id);
|
||||
if (!Number.isFinite(tokenId)) {
|
||||
res.status(400).json({ error: 'Invalid token id' });
|
||||
return;
|
||||
}
|
||||
|
||||
const success = AdminApiTokenModel.revokeForSubject(tokenId, req.adminAuth!.principal.subject);
|
||||
if (!success) {
|
||||
res.status(404).json({ error: 'Admin API token not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(204).send();
|
||||
});
|
||||
|
||||
export default router;
|
||||
107
server/src/utils/adminAuth.ts
Normal file
107
server/src/utils/adminAuth.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { NextFunction, Request, Response } from 'express';
|
||||
import { AdminPrincipal } from '../../../shared/types';
|
||||
import { getTrustedProxyIps } from '../config/admin-auth';
|
||||
import { AdminApiTokenModel } from '../models/AdminApiToken';
|
||||
import { AdminSessionModel } from '../models/AdminSession';
|
||||
import { getSessionTokenFromCookies, hashAdminToken } from './adminSecurity';
|
||||
|
||||
export interface AdminRequest extends Request {
|
||||
adminAuth?: {
|
||||
principal: AdminPrincipal;
|
||||
method: 'session' | 'token';
|
||||
csrfToken?: string;
|
||||
sessionId?: number;
|
||||
tokenId?: number;
|
||||
};
|
||||
}
|
||||
|
||||
function toPrincipal(data: { provider: 'env' | 'oidc'; subject: string; username?: string; email?: string; display_name: string }): AdminPrincipal {
|
||||
return {
|
||||
provider: data.provider,
|
||||
subject: data.subject,
|
||||
username: data.username,
|
||||
email: data.email,
|
||||
displayName: data.display_name,
|
||||
};
|
||||
}
|
||||
|
||||
function passesTrustedProxyGuard(req: Request): boolean {
|
||||
const allowedIps = getTrustedProxyIps();
|
||||
if (allowedIps.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const remoteIp = req.ip || req.socket.remoteAddress || '';
|
||||
return allowedIps.includes(remoteIp);
|
||||
}
|
||||
|
||||
export function resolveAdminAuth(req: AdminRequest): void {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
const bearerToken = authHeader.slice('Bearer '.length).trim();
|
||||
const adminToken = AdminApiTokenModel.findByTokenHash(hashAdminToken(bearerToken));
|
||||
if (adminToken) {
|
||||
AdminApiTokenModel.touch(adminToken.id);
|
||||
req.adminAuth = {
|
||||
principal: toPrincipal(adminToken),
|
||||
method: 'token',
|
||||
tokenId: adminToken.id,
|
||||
};
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const sessionToken = getSessionTokenFromCookies(req.headers.cookie);
|
||||
if (!sessionToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
const session = AdminSessionModel.findByTokenHash(hashAdminToken(sessionToken));
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
|
||||
AdminSessionModel.touch(session.id);
|
||||
req.adminAuth = {
|
||||
principal: toPrincipal(session),
|
||||
method: 'session',
|
||||
csrfToken: session.csrf_token,
|
||||
sessionId: session.id,
|
||||
};
|
||||
}
|
||||
|
||||
export function requireAdminAccess(req: AdminRequest, res: Response, next: NextFunction): void {
|
||||
if (!passesTrustedProxyGuard(req)) {
|
||||
res.status(403).json({ error: 'Admin access is restricted to trusted proxy IPs' });
|
||||
return;
|
||||
}
|
||||
|
||||
resolveAdminAuth(req);
|
||||
if (!req.adminAuth) {
|
||||
res.status(401).json({ error: 'Admin authentication required' });
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
export function requireSessionCsrf(req: AdminRequest, res: Response, next: NextFunction): void {
|
||||
const unsafeMethod = !['GET', 'HEAD', 'OPTIONS'].includes(req.method.toUpperCase());
|
||||
if (!unsafeMethod) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.adminAuth?.method !== 'session') {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const csrfHeader = req.get('X-CSRF-Token');
|
||||
if (!csrfHeader || csrfHeader !== req.adminAuth.csrfToken) {
|
||||
res.status(403).json({ error: 'Invalid CSRF token' });
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
116
server/src/utils/adminSecurity.ts
Normal file
116
server/src/utils/adminSecurity.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { createHash, randomBytes, scryptSync, timingSafeEqual } from 'crypto';
|
||||
import { Response } from 'express';
|
||||
import { getCookieSecure, getAdminPasswordHash, getAdminSessionTtlHours, hashOpaqueToken } from '../config/admin-auth';
|
||||
|
||||
const SESSION_COOKIE_NAME = 'kyush_admin_session';
|
||||
|
||||
export function getSessionCookieName(): string {
|
||||
return SESSION_COOKIE_NAME;
|
||||
}
|
||||
|
||||
export function generateOpaqueToken(prefix: string): string {
|
||||
return `${prefix}_${randomBytes(24).toString('base64url')}`;
|
||||
}
|
||||
|
||||
export function createCsrfToken(): string {
|
||||
return randomBytes(24).toString('base64url');
|
||||
}
|
||||
|
||||
export function hashAdminToken(token: string): string {
|
||||
return hashOpaqueToken(token);
|
||||
}
|
||||
|
||||
export function tokenPrefix(token: string): string {
|
||||
return token.slice(0, 12);
|
||||
}
|
||||
|
||||
export function issueAdminSessionCookie(res: Response, sessionToken: string, maxAgeMs: number): void {
|
||||
const parts = [
|
||||
`${SESSION_COOKIE_NAME}=${encodeURIComponent(sessionToken)}`,
|
||||
'Path=/',
|
||||
'HttpOnly',
|
||||
'SameSite=Lax',
|
||||
`Max-Age=${Math.max(1, Math.floor(maxAgeMs / 1000))}`,
|
||||
];
|
||||
|
||||
if (getCookieSecure()) {
|
||||
parts.push('Secure');
|
||||
}
|
||||
|
||||
res.append('Set-Cookie', parts.join('; '));
|
||||
}
|
||||
|
||||
export function clearAdminSessionCookie(res: Response): void {
|
||||
const parts = [
|
||||
`${SESSION_COOKIE_NAME}=`,
|
||||
'Path=/',
|
||||
'HttpOnly',
|
||||
'SameSite=Lax',
|
||||
'Max-Age=0',
|
||||
];
|
||||
|
||||
if (getCookieSecure()) {
|
||||
parts.push('Secure');
|
||||
}
|
||||
|
||||
res.append('Set-Cookie', parts.join('; '));
|
||||
}
|
||||
|
||||
export function parseCookies(cookieHeader?: string): Record<string, string> {
|
||||
if (!cookieHeader) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return cookieHeader.split(';').reduce<Record<string, string>>((acc, pair) => {
|
||||
const separatorIndex = pair.indexOf('=');
|
||||
if (separatorIndex === -1) return acc;
|
||||
const key = pair.slice(0, separatorIndex).trim();
|
||||
const value = pair.slice(separatorIndex + 1).trim();
|
||||
if (key) {
|
||||
acc[key] = decodeURIComponent(value);
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
export function getSessionTokenFromCookies(cookieHeader?: string): string | null {
|
||||
const cookies = parseCookies(cookieHeader);
|
||||
return cookies[SESSION_COOKIE_NAME] || null;
|
||||
}
|
||||
|
||||
export function verifyAdminPassword(password: string): boolean {
|
||||
const storedHash = getAdminPasswordHash();
|
||||
if (!storedHash) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (storedHash.startsWith('sha256$')) {
|
||||
const expected = storedHash.slice('sha256$'.length);
|
||||
const actual = createHash('sha256').update(password).digest('hex');
|
||||
return safeStringEqual(actual, expected);
|
||||
}
|
||||
|
||||
if (storedHash.startsWith('scrypt$')) {
|
||||
const [, saltHex, expectedHex] = storedHash.split('$');
|
||||
if (!saltHex || !expectedHex) {
|
||||
return false;
|
||||
}
|
||||
const derived = scryptSync(password, Buffer.from(saltHex, 'hex'), expectedHex.length / 2);
|
||||
return timingSafeEqual(derived, Buffer.from(expectedHex, 'hex'));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function computeExpiry(hours: number = getAdminSessionTtlHours()): string {
|
||||
return new Date(Date.now() + hours * 60 * 60 * 1000).toISOString();
|
||||
}
|
||||
|
||||
function safeStringEqual(left: string, right: string): boolean {
|
||||
const leftBuffer = Buffer.from(left);
|
||||
const rightBuffer = Buffer.from(right);
|
||||
if (leftBuffer.length !== rightBuffer.length) {
|
||||
return false;
|
||||
}
|
||||
return timingSafeEqual(leftBuffer, rightBuffer);
|
||||
}
|
||||
36
server/tests/integration/admin-auth.test.ts
Normal file
36
server/tests/integration/admin-auth.test.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { createTestApp } from '../utils/testApp';
|
||||
import { createAdminClient } from '../utils/adminClient';
|
||||
|
||||
describe('Admin Authentication', () => {
|
||||
let app: ReturnType<typeof createTestApp>;
|
||||
|
||||
beforeAll(() => {
|
||||
app = createTestApp();
|
||||
});
|
||||
|
||||
it('should reject unauthenticated admin access', async () => {
|
||||
const response = await request(app).get('/admin/users');
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Admin authentication required');
|
||||
});
|
||||
|
||||
it('should establish an admin session through ENV login', async () => {
|
||||
const admin = await createAdminClient(app);
|
||||
const response = await admin.get('/admin/auth/session');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.authenticated).toBe(true);
|
||||
expect(response.body.principal.provider).toBe('env');
|
||||
expect(typeof response.body.csrfToken).toBe('string');
|
||||
});
|
||||
|
||||
it('should require CSRF for session-based writes', async () => {
|
||||
const agent = request.agent(app);
|
||||
await agent.post('/admin/auth/login').send({ username: 'admin', password: 'password' }).expect(200);
|
||||
|
||||
const response = await agent.post('/admin/users').send({ name: 'Blocked By Csrf' });
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body.error).toBe('Invalid CSRF token');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,17 +1,22 @@
|
|||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { createTestApp } from '../utils/testApp';
|
||||
import { createAdminClient } from '../utils/adminClient';
|
||||
|
||||
let app: ReturnType<typeof createTestApp>;
|
||||
let admin: Awaited<ReturnType<typeof createAdminClient>>;
|
||||
|
||||
beforeAll(() => {
|
||||
app = createTestApp();
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
admin = await createAdminClient(app);
|
||||
});
|
||||
|
||||
describe('Admin API - User Management', () => {
|
||||
describe('GET /admin/users', () => {
|
||||
it('should return empty array initially', async () => {
|
||||
const response = await request(app).get('/admin/users');
|
||||
const response = await admin.get('/admin/users');
|
||||
expect(response.status).toBe(200);
|
||||
expect(Array.isArray(response.body)).toBe(true);
|
||||
});
|
||||
|
|
@ -20,7 +25,7 @@ describe('Admin API - User Management', () => {
|
|||
describe('POST /admin/users', () => {
|
||||
it('should create a new user', async () => {
|
||||
const userData = { name: 'Test User', email: 'test@example.com' };
|
||||
const response = await request(app).post('/admin/users').send(userData);
|
||||
const response = await admin.post('/admin/users').send(userData);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body).toHaveProperty('id');
|
||||
|
|
@ -31,7 +36,7 @@ describe('Admin API - User Management', () => {
|
|||
});
|
||||
|
||||
it('should return 400 if name is missing', async () => {
|
||||
const response = await request(app).post('/admin/users').send({ email: 'test@example.com' });
|
||||
const response = await admin.post('/admin/users').send({ email: 'test@example.com' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toHaveProperty('error');
|
||||
|
|
@ -42,12 +47,12 @@ describe('Admin API - User Management', () => {
|
|||
let userId: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
const response = await request(app).post('/admin/users').send({ name: 'User for Get' });
|
||||
const response = await admin.post('/admin/users').send({ name: 'User for Get' });
|
||||
userId = response.body.id;
|
||||
});
|
||||
|
||||
it('should return a user by id', async () => {
|
||||
const response = await request(app).get(`/admin/users/${userId}`);
|
||||
const response = await admin.get(`/admin/users/${userId}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.id).toBe(userId);
|
||||
|
|
@ -55,7 +60,7 @@ describe('Admin API - User Management', () => {
|
|||
});
|
||||
|
||||
it('should return 404 for non-existent user', async () => {
|
||||
const response = await request(app).get('/admin/users/99999');
|
||||
const response = await admin.get('/admin/users/99999');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toHaveProperty('error');
|
||||
|
|
@ -66,12 +71,12 @@ describe('Admin API - User Management', () => {
|
|||
let userId: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
const response = await request(app).post('/admin/users').send({ name: 'User for Update' });
|
||||
const response = await admin.post('/admin/users').send({ name: 'User for Update' });
|
||||
userId = response.body.id;
|
||||
});
|
||||
|
||||
it('should update user', async () => {
|
||||
const response = await request(app)
|
||||
const response = await admin
|
||||
.put(`/admin/users/${userId}`)
|
||||
.send({ name: 'Updated Name', email: 'updated@example.com' });
|
||||
|
||||
|
|
@ -81,7 +86,7 @@ describe('Admin API - User Management', () => {
|
|||
});
|
||||
|
||||
it('should return 404 for non-existent user', async () => {
|
||||
const response = await request(app).put('/admin/users/99999').send({ name: 'Test' });
|
||||
const response = await admin.put('/admin/users/99999').send({ name: 'Test' });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
|
@ -92,13 +97,13 @@ describe('Admin API - User Management', () => {
|
|||
let oldApiKey: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const response = await request(app).post('/admin/users').send({ name: 'User for Key Regen' });
|
||||
const response = await admin.post('/admin/users').send({ name: 'User for Key Regen' });
|
||||
userId = response.body.id;
|
||||
oldApiKey = response.body.api_key;
|
||||
});
|
||||
|
||||
it('should regenerate API key', async () => {
|
||||
const response = await request(app).post(`/admin/users/${userId}/regenerate-api-key`);
|
||||
const response = await admin.post(`/admin/users/${userId}/regenerate-api-key`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.api_key).toMatch(/^sk-/);
|
||||
|
|
@ -110,18 +115,18 @@ describe('Admin API - User Management', () => {
|
|||
let userId: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
const response = await request(app).post('/admin/users').send({ name: 'User for Delete' });
|
||||
const response = await admin.post('/admin/users').send({ name: 'User for Delete' });
|
||||
userId = response.body.id;
|
||||
});
|
||||
|
||||
it('should delete a user', async () => {
|
||||
const response = await request(app).delete(`/admin/users/${userId}`);
|
||||
const response = await admin.delete(`/admin/users/${userId}`);
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
});
|
||||
|
||||
it('should return 404 for already deleted user', async () => {
|
||||
const response = await request(app).delete(`/admin/users/${userId}`);
|
||||
const response = await admin.delete(`/admin/users/${userId}`);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
|
@ -131,7 +136,7 @@ describe('Admin API - User Management', () => {
|
|||
describe('Admin API - Backend Management', () => {
|
||||
describe('GET /admin/backends', () => {
|
||||
it('should return empty array initially', async () => {
|
||||
const response = await request(app).get('/admin/backends');
|
||||
const response = await admin.get('/admin/backends');
|
||||
expect(response.status).toBe(200);
|
||||
expect(Array.isArray(response.body)).toBe(true);
|
||||
});
|
||||
|
|
@ -144,7 +149,7 @@ describe('Admin API - Backend Management', () => {
|
|||
base_url: 'http://localhost:8000/v1',
|
||||
api_key: 'backend-key-123'
|
||||
};
|
||||
const response = await request(app).post('/admin/backends').send(backendData);
|
||||
const response = await admin.post('/admin/backends').send(backendData);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body).toHaveProperty('id');
|
||||
|
|
@ -154,7 +159,7 @@ describe('Admin API - Backend Management', () => {
|
|||
});
|
||||
|
||||
it('should return 400 if name or base_url is missing', async () => {
|
||||
const response = await request(app).post('/admin/backends').send({ name: 'Test' });
|
||||
const response = await admin.post('/admin/backends').send({ name: 'Test' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toHaveProperty('error');
|
||||
|
|
@ -165,7 +170,7 @@ describe('Admin API - Backend Management', () => {
|
|||
let backendId: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
const response = await request(app).post('/admin/backends').send({
|
||||
const response = await admin.post('/admin/backends').send({
|
||||
name: 'Backend for Get',
|
||||
base_url: 'http://localhost:8001/v1'
|
||||
});
|
||||
|
|
@ -173,14 +178,14 @@ describe('Admin API - Backend Management', () => {
|
|||
});
|
||||
|
||||
it('should return a backend by id', async () => {
|
||||
const response = await request(app).get(`/admin/backends/${backendId}`);
|
||||
const response = await admin.get(`/admin/backends/${backendId}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.id).toBe(backendId);
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent backend', async () => {
|
||||
const response = await request(app).get('/admin/backends/99999');
|
||||
const response = await admin.get('/admin/backends/99999');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
|
@ -190,7 +195,7 @@ describe('Admin API - Backend Management', () => {
|
|||
let backendId: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
const response = await request(app).post('/admin/backends').send({
|
||||
const response = await admin.post('/admin/backends').send({
|
||||
name: 'Backend for Update',
|
||||
base_url: 'http://localhost:8002/v1'
|
||||
});
|
||||
|
|
@ -198,7 +203,7 @@ describe('Admin API - Backend Management', () => {
|
|||
});
|
||||
|
||||
it('should update backend', async () => {
|
||||
const response = await request(app)
|
||||
const response = await admin
|
||||
.put(`/admin/backends/${backendId}`)
|
||||
.send({ name: 'Updated Backend', is_active: false });
|
||||
|
||||
|
|
@ -208,7 +213,7 @@ describe('Admin API - Backend Management', () => {
|
|||
});
|
||||
|
||||
it('should return 404 for non-existent backend', async () => {
|
||||
const response = await request(app).put('/admin/backends/99999').send({ name: 'Test' });
|
||||
const response = await admin.put('/admin/backends/99999').send({ name: 'Test' });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
|
@ -218,7 +223,7 @@ describe('Admin API - Backend Management', () => {
|
|||
let backendId: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
const response = await request(app).post('/admin/backends').send({
|
||||
const response = await admin.post('/admin/backends').send({
|
||||
name: 'Backend for Delete',
|
||||
base_url: 'http://localhost:8003/v1'
|
||||
});
|
||||
|
|
@ -226,13 +231,13 @@ describe('Admin API - Backend Management', () => {
|
|||
});
|
||||
|
||||
it('should delete a backend', async () => {
|
||||
const response = await request(app).delete(`/admin/backends/${backendId}`);
|
||||
const response = await admin.delete(`/admin/backends/${backendId}`);
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
});
|
||||
|
||||
it('should return 404 for already deleted backend', async () => {
|
||||
const response = await request(app).delete(`/admin/backends/${backendId}`);
|
||||
const response = await admin.delete(`/admin/backends/${backendId}`);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
|
@ -244,10 +249,10 @@ describe('Admin API - Permission Management', () => {
|
|||
let backendId: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
const userResponse = await request(app).post('/admin/users').send({ name: 'User for Permission' });
|
||||
const userResponse = await admin.post('/admin/users').send({ name: 'User for Permission' });
|
||||
userId = userResponse.body.id;
|
||||
|
||||
const backendResponse = await request(app).post('/admin/backends').send({
|
||||
const backendResponse = await admin.post('/admin/backends').send({
|
||||
name: 'Backend for Permission',
|
||||
base_url: 'http://localhost:8004/v1'
|
||||
});
|
||||
|
|
@ -256,7 +261,7 @@ describe('Admin API - Permission Management', () => {
|
|||
|
||||
describe('GET /admin/permissions', () => {
|
||||
it('should return empty array initially', async () => {
|
||||
const response = await request(app).get('/admin/permissions');
|
||||
const response = await admin.get('/admin/permissions');
|
||||
expect(response.status).toBe(200);
|
||||
expect(Array.isArray(response.body)).toBe(true);
|
||||
});
|
||||
|
|
@ -264,7 +269,7 @@ describe('Admin API - Permission Management', () => {
|
|||
|
||||
describe('POST /admin/permissions', () => {
|
||||
it('should create a new permission', async () => {
|
||||
const response = await request(app)
|
||||
const response = await admin
|
||||
.post('/admin/permissions')
|
||||
.send({ user_id: userId, backend_id: backendId });
|
||||
|
||||
|
|
@ -275,7 +280,7 @@ describe('Admin API - Permission Management', () => {
|
|||
});
|
||||
|
||||
it('should return 409 if permission already exists', async () => {
|
||||
const response = await request(app)
|
||||
const response = await admin
|
||||
.post('/admin/permissions')
|
||||
.send({ user_id: userId, backend_id: backendId });
|
||||
|
||||
|
|
@ -284,7 +289,7 @@ describe('Admin API - Permission Management', () => {
|
|||
});
|
||||
|
||||
it('should return 400 if user_id or backend_id is missing', async () => {
|
||||
const response = await request(app).post('/admin/permissions').send({ user_id: userId });
|
||||
const response = await admin.post('/admin/permissions').send({ user_id: userId });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
|
@ -292,7 +297,7 @@ describe('Admin API - Permission Management', () => {
|
|||
|
||||
describe('GET /admin/permissions/user/:userId', () => {
|
||||
it('should return permissions for user', async () => {
|
||||
const response = await request(app).get(`/admin/permissions/user/${userId}`);
|
||||
const response = await admin.get(`/admin/permissions/user/${userId}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(Array.isArray(response.body)).toBe(true);
|
||||
|
|
@ -302,7 +307,7 @@ describe('Admin API - Permission Management', () => {
|
|||
|
||||
describe('GET /admin/permissions/backend/:backendId', () => {
|
||||
it('should return permissions for backend', async () => {
|
||||
const response = await request(app).get(`/admin/permissions/backend/${backendId}`);
|
||||
const response = await admin.get(`/admin/permissions/backend/${backendId}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(Array.isArray(response.body)).toBe(true);
|
||||
|
|
@ -312,14 +317,14 @@ describe('Admin API - Permission Management', () => {
|
|||
|
||||
describe('DELETE /admin/permissions', () => {
|
||||
it('should delete a permission', async () => {
|
||||
const response = await request(app)
|
||||
const response = await admin
|
||||
.delete(`/admin/permissions?user_id=${userId}&backend_id=${backendId}`);
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
});
|
||||
|
||||
it('should return 404 for already deleted permission', async () => {
|
||||
const response = await request(app)
|
||||
const response = await admin
|
||||
.delete(`/admin/permissions?user_id=${userId}&backend_id=${backendId}`);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
|
|
|
|||
|
|
@ -3,9 +3,11 @@ import request from 'supertest';
|
|||
import { createTestApp } from '../utils/testApp';
|
||||
import { initDb } from '../../src/config/database';
|
||||
import { RequestLogService } from '../../src/services/RequestLogService';
|
||||
import { createAdminClient } from '../utils/adminClient';
|
||||
|
||||
describe('Auth & Proxy API', () => {
|
||||
let app: ReturnType<typeof createTestApp>;
|
||||
let admin: Awaited<ReturnType<typeof createAdminClient>>;
|
||||
let userApiKey: string;
|
||||
let backendId: number;
|
||||
|
||||
|
|
@ -15,20 +17,24 @@ describe('Auth & Proxy API', () => {
|
|||
app = createTestApp();
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
admin = await createAdminClient(app);
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
// Create a user
|
||||
const userResponse = await request(app).post('/admin/users').send({ name: 'Test User for API' });
|
||||
const userResponse = await admin.post('/admin/users').send({ name: 'Test User for API' });
|
||||
userApiKey = userResponse.body.api_key;
|
||||
|
||||
// Create a backend
|
||||
const backendResponse = await request(app).post('/admin/backends').send({
|
||||
const backendResponse = await admin.post('/admin/backends').send({
|
||||
name: 'Backend for API Test',
|
||||
base_url: 'http://localhost:8005/v1'
|
||||
});
|
||||
backendId = backendResponse.body.id;
|
||||
|
||||
// Grant permission
|
||||
await request(app)
|
||||
await admin
|
||||
.post('/admin/permissions')
|
||||
.send({ user_id: userResponse.body.id, backend_id: backendId });
|
||||
});
|
||||
|
|
@ -82,7 +88,7 @@ describe('Auth & Proxy API', () => {
|
|||
describe('GET /v1/models without permission', () => {
|
||||
it('should return 403 for user without backend permission', async () => {
|
||||
// Create a user without permissions
|
||||
const userResponse = await request(app).post('/admin/users').send({ name: 'User Without Permission' });
|
||||
const userResponse = await admin.post('/admin/users').send({ name: 'User Without Permission' });
|
||||
const invalidApiKey = userResponse.body.api_key;
|
||||
|
||||
const response = await request(app)
|
||||
|
|
@ -106,7 +112,7 @@ describe('Auth & Proxy API', () => {
|
|||
});
|
||||
|
||||
// Check analytics
|
||||
const analyticsResponse = await request(app).get('/admin/analytics/requests?limit=10');
|
||||
const analyticsResponse = await admin.get('/admin/analytics/requests?limit=10');
|
||||
|
||||
expect(analyticsResponse.status).toBe(200);
|
||||
expect(Array.isArray(analyticsResponse.body.rows)).toBe(true);
|
||||
|
|
@ -145,8 +151,8 @@ describe('Auth & Proxy API', () => {
|
|||
created_at: '2026-03-20T10:00:00.000Z',
|
||||
});
|
||||
|
||||
const firstPage = await request(app).get('/admin/analytics/requests?limit=1&offset=0&q=cross-month-test-marker');
|
||||
const secondPage = await request(app).get('/admin/analytics/requests?limit=1&offset=1&q=cross-month-test-marker');
|
||||
const firstPage = await admin.get('/admin/analytics/requests?limit=1&offset=0&q=cross-month-test-marker');
|
||||
const secondPage = await admin.get('/admin/analytics/requests?limit=1&offset=1&q=cross-month-test-marker');
|
||||
|
||||
expect(firstPage.status).toBe(200);
|
||||
expect(secondPage.status).toBe(200);
|
||||
|
|
|
|||
|
|
@ -3,28 +3,34 @@ import request from 'supertest';
|
|||
import { createTestApp } from '../utils/testApp';
|
||||
import { createMockBackend } from '../utils/mockBackend';
|
||||
import { initDb } from '../../src/config/database';
|
||||
import { createAdminClient } from '../utils/adminClient';
|
||||
|
||||
describe('Permission-based Routing', () => {
|
||||
let app: ReturnType<typeof createTestApp>;
|
||||
let admin: Awaited<ReturnType<typeof createAdminClient>>;
|
||||
|
||||
beforeAll(() => {
|
||||
initDb();
|
||||
app = createTestApp();
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
admin = await createAdminClient(app);
|
||||
});
|
||||
|
||||
describe('Scenario 1: Authorized backend routing', () => {
|
||||
it('should route to authorized backend (auth passes, backend may fail)', async () => {
|
||||
const userResponse = await request(app).post('/admin/users').send({ name: 'Auth User 1-1' });
|
||||
const userResponse = await admin.post('/admin/users').send({ name: 'Auth User 1-1' });
|
||||
const userApiKey = userResponse.body.api_key;
|
||||
const userId = userResponse.body.id;
|
||||
|
||||
const backendResponse = await request(app).post('/admin/backends').send({
|
||||
const backendResponse = await admin.post('/admin/backends').send({
|
||||
name: 'Auth Backend 1-1',
|
||||
base_url: 'http://localhost:8000/v1'
|
||||
});
|
||||
const backendId = backendResponse.body.id;
|
||||
|
||||
await request(app)
|
||||
await admin
|
||||
.post('/admin/permissions')
|
||||
.send({ user_id: userId, backend_id: backendId });
|
||||
|
||||
|
|
@ -44,25 +50,25 @@ describe('Permission-based Routing', () => {
|
|||
let backendBId: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
const userAResponse = await request(app).post('/admin/users').send({ name: 'User A 2-2' });
|
||||
const userAResponse = await admin.post('/admin/users').send({ name: 'User A 2-2' });
|
||||
userAApiKey = userAResponse.body.api_key;
|
||||
|
||||
const userBResponse = await request(app).post('/admin/users').send({ name: 'User B 2-2' });
|
||||
const userBResponse = await admin.post('/admin/users').send({ name: 'User B 2-2' });
|
||||
userBApiKey = userBResponse.body.api_key;
|
||||
const userBId = userBResponse.body.id;
|
||||
|
||||
const backendAResponse = await request(app).post('/admin/backends').send({
|
||||
await admin.post('/admin/backends').send({
|
||||
name: 'Backend A 2-2',
|
||||
base_url: 'http://localhost:8001/v1'
|
||||
});
|
||||
|
||||
const backendBResponse = await request(app).post('/admin/backends').send({
|
||||
const backendBResponse = await admin.post('/admin/backends').send({
|
||||
name: 'Backend B 2-2',
|
||||
base_url: 'http://localhost:8002/v1'
|
||||
});
|
||||
backendBId = backendBResponse.body.id;
|
||||
|
||||
await request(app)
|
||||
await admin
|
||||
.post('/admin/permissions')
|
||||
.send({ user_id: userBId, backend_id: backendBId });
|
||||
});
|
||||
|
|
@ -89,7 +95,7 @@ describe('Permission-based Routing', () => {
|
|||
|
||||
describe('Scenario 3: User without any permissions', () => {
|
||||
it('should return 403 when user has no permissions', async () => {
|
||||
const userResponse = await request(app).post('/admin/users').send({ name: 'No Permission User 3-3' });
|
||||
const userResponse = await admin.post('/admin/users').send({ name: 'No Permission User 3-3' });
|
||||
const apiKey = userResponse.body.api_key;
|
||||
|
||||
const response = await request(app)
|
||||
|
|
@ -105,35 +111,40 @@ describe('Permission-based Routing', () => {
|
|||
|
||||
describe('Multi-backend Routing', () => {
|
||||
let app: ReturnType<typeof createTestApp>;
|
||||
let admin: Awaited<ReturnType<typeof createAdminClient>>;
|
||||
|
||||
beforeAll(() => {
|
||||
initDb();
|
||||
app = createTestApp();
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
admin = await createAdminClient(app);
|
||||
});
|
||||
|
||||
describe('Scenario 4: Random selection from multiple backends', () => {
|
||||
it('should route to different backends across multiple requests', async () => {
|
||||
const userResponse = await request(app).post('/admin/users').send({ name: 'Multi Backend User 4-4' });
|
||||
const userResponse = await admin.post('/admin/users').send({ name: 'Multi Backend User 4-4' });
|
||||
const userApiKey = userResponse.body.api_key;
|
||||
const userId = userResponse.body.id;
|
||||
|
||||
const backend1Response = await request(app).post('/admin/backends').send({
|
||||
const backend1Response = await admin.post('/admin/backends').send({
|
||||
name: 'Multi Backend 4-4-1',
|
||||
base_url: 'http://localhost:8010/v1'
|
||||
});
|
||||
const backend1Id = backend1Response.body.id;
|
||||
|
||||
const backend2Response = await request(app).post('/admin/backends').send({
|
||||
const backend2Response = await admin.post('/admin/backends').send({
|
||||
name: 'Multi Backend 4-4-2',
|
||||
base_url: 'http://localhost:8011/v1'
|
||||
});
|
||||
const backend2Id = backend2Response.body.id;
|
||||
|
||||
await request(app)
|
||||
await admin
|
||||
.post('/admin/permissions')
|
||||
.send({ user_id: userId, backend_id: backend1Id });
|
||||
|
||||
await request(app)
|
||||
await admin
|
||||
.post('/admin/permissions')
|
||||
.send({ user_id: userId, backend_id: backend2Id });
|
||||
|
||||
|
|
@ -152,51 +163,56 @@ describe('Multi-backend Routing', () => {
|
|||
|
||||
describe('Inactive Backend Routing', () => {
|
||||
let app: ReturnType<typeof createTestApp>;
|
||||
let admin: Awaited<ReturnType<typeof createAdminClient>>;
|
||||
|
||||
beforeAll(() => {
|
||||
initDb();
|
||||
app = createTestApp();
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
admin = await createAdminClient(app);
|
||||
});
|
||||
|
||||
describe('Scenario 5: Inactive backends excluded', () => {
|
||||
beforeAll(async () => {
|
||||
// Deactivate all existing backends before this test
|
||||
const allBackendsResponse = await request(app).get('/admin/backends');
|
||||
const allBackendsResponse = await admin.get('/admin/backends');
|
||||
const allBackends = allBackendsResponse.body;
|
||||
for (const backend of allBackends) {
|
||||
if (backend.is_active) {
|
||||
await request(app).put(`/admin/backends/${backend.id}`).send({ is_active: false });
|
||||
await admin.put(`/admin/backends/${backend.id}`).send({ is_active: false });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Re-activate all backends after this test
|
||||
const allBackendsResponse = await request(app).get('/admin/backends');
|
||||
const allBackendsResponse = await admin.get('/admin/backends');
|
||||
const allBackends = allBackendsResponse.body;
|
||||
for (const backend of allBackends) {
|
||||
if (!backend.is_active) {
|
||||
await request(app).put(`/admin/backends/${backend.id}`).send({ is_active: true });
|
||||
await admin.put(`/admin/backends/${backend.id}`).send({ is_active: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should return 403 when only inactive backends are available', async () => {
|
||||
const userResponse = await request(app).post('/admin/users').send({ name: 'Inactive Test User 5-5-5' });
|
||||
const userResponse = await admin.post('/admin/users').send({ name: 'Inactive Test User 5-5-5' });
|
||||
const userApiKey = userResponse.body.api_key;
|
||||
const userId = userResponse.body.id;
|
||||
|
||||
// Create backend first (default is_active=true)
|
||||
const backendResponse = await request(app).post('/admin/backends').send({
|
||||
const backendResponse = await admin.post('/admin/backends').send({
|
||||
name: 'Inactive Backend 5-5-5',
|
||||
base_url: 'http://localhost:8020/v1'
|
||||
});
|
||||
const inactiveBackendId = backendResponse.body.id;
|
||||
|
||||
// Then deactivate it
|
||||
await request(app).put(`/admin/backends/${inactiveBackendId}`).send({ is_active: false });
|
||||
await admin.put(`/admin/backends/${inactiveBackendId}`).send({ is_active: false });
|
||||
|
||||
await request(app)
|
||||
await admin
|
||||
.post('/admin/permissions')
|
||||
.send({ user_id: userId, backend_id: inactiveBackendId });
|
||||
|
||||
|
|
@ -213,6 +229,7 @@ describe('Inactive Backend Routing', () => {
|
|||
|
||||
describe('OpenAI Compatible Backend Integration', () => {
|
||||
let app: ReturnType<typeof createTestApp>;
|
||||
let admin: Awaited<ReturnType<typeof createAdminClient>>;
|
||||
let mockServer: any;
|
||||
let mockPort: number;
|
||||
|
||||
|
|
@ -221,13 +238,17 @@ describe('OpenAI Compatible Backend Integration', () => {
|
|||
app = createTestApp();
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
admin = await createAdminClient(app);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Re-activate all backends after tests
|
||||
const allBackendsResponse = await request(app).get('/admin/backends');
|
||||
const allBackendsResponse = await admin.get('/admin/backends');
|
||||
const allBackends = allBackendsResponse.body;
|
||||
for (const backend of allBackends) {
|
||||
if (!backend.is_active) {
|
||||
await request(app).put(`/admin/backends/${backend.id}`).send({ is_active: true });
|
||||
await admin.put(`/admin/backends/${backend.id}`).send({ is_active: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -242,11 +263,11 @@ describe('OpenAI Compatible Backend Integration', () => {
|
|||
describe('Scenario 6: Mock backend with successful response', () => {
|
||||
it('should proxy request to mock backend and return response', async () => {
|
||||
// First, deactivate all existing backends to ensure only our mock backend is selected
|
||||
const allBackendsResponse = await request(app).get('/admin/backends');
|
||||
const allBackendsResponse = await admin.get('/admin/backends');
|
||||
const allBackends = allBackendsResponse.body;
|
||||
for (const backend of allBackends) {
|
||||
if (backend.is_active) {
|
||||
await request(app).put(`/admin/backends/${backend.id}`).send({ is_active: false });
|
||||
await admin.put(`/admin/backends/${backend.id}`).send({ is_active: false });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -254,17 +275,17 @@ describe('OpenAI Compatible Backend Integration', () => {
|
|||
mockServer = server;
|
||||
mockPort = port;
|
||||
|
||||
const userResponse = await request(app).post('/admin/users').send({ name: 'Mock Integration User 6-6' });
|
||||
const userResponse = await admin.post('/admin/users').send({ name: 'Mock Integration User 6-6' });
|
||||
const userApiKey = userResponse.body.api_key;
|
||||
const userId = userResponse.body.id;
|
||||
|
||||
const backendResponse = await request(app).post('/admin/backends').send({
|
||||
const backendResponse = await admin.post('/admin/backends').send({
|
||||
name: 'Mock Backend 6-6',
|
||||
base_url: `http://localhost:${mockPort}`
|
||||
});
|
||||
const backendId = backendResponse.body.id;
|
||||
|
||||
await request(app)
|
||||
await admin
|
||||
.post('/admin/permissions')
|
||||
.send({ user_id: userId, backend_id: backendId });
|
||||
|
||||
|
|
@ -279,7 +300,7 @@ describe('OpenAI Compatible Backend Integration', () => {
|
|||
// Re-activate backends for other tests
|
||||
for (const backend of allBackends) {
|
||||
if (backend.is_active) {
|
||||
await request(app).put(`/admin/backends/${backend.id}`).send({ is_active: true });
|
||||
await admin.put(`/admin/backends/${backend.id}`).send({ is_active: true });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -300,18 +321,18 @@ describe('OpenAI Compatible Backend Integration', () => {
|
|||
mockServer = server;
|
||||
mockPort = port;
|
||||
|
||||
const userResponse = await request(app).post('/admin/users').send({ name: 'Auth Rewrite User 6-6' });
|
||||
const userResponse = await admin.post('/admin/users').send({ name: 'Auth Rewrite User 6-6' });
|
||||
const userApiKey = userResponse.body.api_key;
|
||||
const userId = userResponse.body.id;
|
||||
|
||||
const backendResponse = await request(app).post('/admin/backends').send({
|
||||
const backendResponse = await admin.post('/admin/backends').send({
|
||||
name: 'Auth Rewrite Backend 6-6',
|
||||
base_url: `http://localhost:${mockPort}`,
|
||||
api_key: 'upstream-secret-key',
|
||||
});
|
||||
const backendId = backendResponse.body.id;
|
||||
|
||||
await request(app)
|
||||
await admin
|
||||
.post('/admin/permissions')
|
||||
.send({ user_id: userId, backend_id: backendId });
|
||||
|
||||
|
|
@ -332,11 +353,11 @@ describe('OpenAI Compatible Backend Integration', () => {
|
|||
describe('Scenario 7: Models endpoint routing', () => {
|
||||
it('should proxy models request to mock backend', async () => {
|
||||
// First, deactivate all existing backends to ensure only our mock backend is selected
|
||||
const allBackendsResponse = await request(app).get('/admin/backends');
|
||||
const allBackendsResponse = await admin.get('/admin/backends');
|
||||
const allBackends = allBackendsResponse.body;
|
||||
for (const backend of allBackends) {
|
||||
if (backend.is_active) {
|
||||
await request(app).put(`/admin/backends/${backend.id}`).send({ is_active: false });
|
||||
await admin.put(`/admin/backends/${backend.id}`).send({ is_active: false });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -346,17 +367,17 @@ describe('OpenAI Compatible Backend Integration', () => {
|
|||
mockServer = server;
|
||||
mockPort = port;
|
||||
|
||||
const userResponse = await request(app).post('/admin/users').send({ name: 'Models Test User 7-7' });
|
||||
const userResponse = await admin.post('/admin/users').send({ name: 'Models Test User 7-7' });
|
||||
const userApiKey = userResponse.body.api_key;
|
||||
const userId = userResponse.body.id;
|
||||
|
||||
const backendResponse = await request(app).post('/admin/backends').send({
|
||||
const backendResponse = await admin.post('/admin/backends').send({
|
||||
name: 'Models Backend 7-7',
|
||||
base_url: `http://localhost:${port}`
|
||||
});
|
||||
const backendId = backendResponse.body.id;
|
||||
|
||||
await request(app)
|
||||
await admin
|
||||
.post('/admin/permissions')
|
||||
.send({ user_id: userId, backend_id: backendId });
|
||||
|
||||
|
|
@ -367,7 +388,7 @@ describe('OpenAI Compatible Backend Integration', () => {
|
|||
// Re-activate backends for other tests
|
||||
for (const backend of allBackends) {
|
||||
if (backend.is_active) {
|
||||
await request(app).put(`/admin/backends/${backend.id}`).send({ is_active: true });
|
||||
await admin.put(`/admin/backends/${backend.id}`).send({ is_active: true });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -378,7 +399,7 @@ describe('OpenAI Compatible Backend Integration', () => {
|
|||
});
|
||||
|
||||
it('should return 403 for models when user has no permissions', async () => {
|
||||
const userResponse = await request(app).post('/admin/users').send({ name: 'No Permission Models User 7-7' });
|
||||
const userResponse = await admin.post('/admin/users').send({ name: 'No Permission Models User 7-7' });
|
||||
const userApiKey = userResponse.body.api_key;
|
||||
|
||||
const response = await request(app)
|
||||
|
|
@ -399,17 +420,17 @@ describe('OpenAI Compatible Backend Integration', () => {
|
|||
mockServer = server;
|
||||
mockPort = port;
|
||||
|
||||
const userResponse = await request(app).post('/admin/users').send({ name: 'No Upstream Auth User 7-7' });
|
||||
const userResponse = await admin.post('/admin/users').send({ name: 'No Upstream Auth User 7-7' });
|
||||
const userApiKey = userResponse.body.api_key;
|
||||
const userId = userResponse.body.id;
|
||||
|
||||
const backendResponse = await request(app).post('/admin/backends').send({
|
||||
const backendResponse = await admin.post('/admin/backends').send({
|
||||
name: 'No Upstream Auth Backend 7-7',
|
||||
base_url: `http://localhost:${port}`,
|
||||
});
|
||||
const backendId = backendResponse.body.id;
|
||||
|
||||
await request(app)
|
||||
await admin
|
||||
.post('/admin/permissions')
|
||||
.send({ user_id: userId, backend_id: backendId });
|
||||
|
||||
|
|
|
|||
|
|
@ -2,9 +2,11 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|||
import request from 'supertest';
|
||||
import { createTestApp } from '../utils/testApp';
|
||||
import { initDb } from '../../src/config/database';
|
||||
import { createAdminClient } from '../utils/adminClient';
|
||||
|
||||
describe('Script API Endpoints', () => {
|
||||
let app: ReturnType<typeof createTestApp>;
|
||||
let admin: Awaited<ReturnType<typeof createAdminClient>>;
|
||||
let userId: number;
|
||||
let backendId: number;
|
||||
let scriptId: number;
|
||||
|
|
@ -14,12 +16,16 @@ describe('Script API Endpoints', () => {
|
|||
app = createTestApp();
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
admin = await createAdminClient(app);
|
||||
});
|
||||
|
||||
// Setup: Create user and backend for testing
|
||||
beforeAll(async () => {
|
||||
const userResponse = await request(app).post('/admin/users').send({ name: 'Script Test User' });
|
||||
const userResponse = await admin.post('/admin/users').send({ name: 'Script Test User' });
|
||||
userId = userResponse.body.id;
|
||||
|
||||
const backendResponse = await request(app).post('/admin/backends').send({
|
||||
const backendResponse = await admin.post('/admin/backends').send({
|
||||
name: 'Script Test Backend',
|
||||
base_url: 'http://localhost:8006/v1'
|
||||
});
|
||||
|
|
@ -28,13 +34,13 @@ describe('Script API Endpoints', () => {
|
|||
|
||||
afterAll(async () => {
|
||||
// Cleanup: Delete created resources
|
||||
await request(app).delete(`/admin/users/${userId}`);
|
||||
await request(app).delete(`/admin/backends/${backendId}`);
|
||||
await admin.delete(`/admin/users/${userId}`);
|
||||
await admin.delete(`/admin/backends/${backendId}`);
|
||||
});
|
||||
|
||||
describe('GET /admin/scripts', () => {
|
||||
it('should return empty array initially', async () => {
|
||||
const response = await request(app).get('/admin/scripts');
|
||||
const response = await admin.get('/admin/scripts');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(Array.isArray(response.body)).toBe(true);
|
||||
|
|
@ -62,7 +68,7 @@ export const onResponse = (context) => {
|
|||
is_active: true
|
||||
};
|
||||
|
||||
const response = await request(app).post('/admin/scripts').send(scriptData);
|
||||
const response = await admin.post('/admin/scripts').send(scriptData);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body).toHaveProperty('id');
|
||||
|
|
@ -88,7 +94,7 @@ export const onRequest = (context) => {
|
|||
is_active: true
|
||||
};
|
||||
|
||||
const response = await request(app).post('/admin/scripts').send(scriptData);
|
||||
const response = await admin.post('/admin/scripts').send(scriptData);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.script_type).toBe(scriptData.script_type);
|
||||
|
|
@ -108,7 +114,7 @@ export const onResponse = (context) => {
|
|||
is_active: true
|
||||
};
|
||||
|
||||
const response = await request(app).post('/admin/scripts').send(scriptData);
|
||||
const response = await admin.post('/admin/scripts').send(scriptData);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.script_type).toBe(scriptData.script_type);
|
||||
|
|
@ -116,7 +122,7 @@ export const onResponse = (context) => {
|
|||
});
|
||||
|
||||
it('should return 400 if name is missing', async () => {
|
||||
const response = await request(app).post('/admin/scripts').send({
|
||||
const response = await admin.post('/admin/scripts').send({
|
||||
script_type: 'per-backend',
|
||||
target_backend_id: backendId,
|
||||
script_code: 'export const onRequest = (context) => context;'
|
||||
|
|
@ -127,7 +133,7 @@ export const onResponse = (context) => {
|
|||
});
|
||||
|
||||
it('should return 400 if script_code is missing', async () => {
|
||||
const response = await request(app).post('/admin/scripts').send({
|
||||
const response = await admin.post('/admin/scripts').send({
|
||||
name: 'Missing Code',
|
||||
script_type: 'per-backend',
|
||||
target_backend_id: backendId
|
||||
|
|
@ -137,7 +143,7 @@ export const onResponse = (context) => {
|
|||
});
|
||||
|
||||
it('should return 400 if script_type is missing', async () => {
|
||||
const response = await request(app).post('/admin/scripts').send({
|
||||
const response = await admin.post('/admin/scripts').send({
|
||||
name: 'Missing Target Type',
|
||||
script_code: 'export const onRequest = (context) => context;'
|
||||
});
|
||||
|
|
@ -146,7 +152,7 @@ export const onResponse = (context) => {
|
|||
});
|
||||
|
||||
it('should return 400 for per-user-backend without user_id and backend_id', async () => {
|
||||
const response = await request(app).post('/admin/scripts').send({
|
||||
const response = await admin.post('/admin/scripts').send({
|
||||
name: 'Invalid Per-User-Backend',
|
||||
script_code: 'export const onRequest = (context) => context;',
|
||||
script_type: 'per-user-backend'
|
||||
|
|
@ -163,7 +169,7 @@ export const onResponse = (context) => {
|
|||
target_backend_id: backendId
|
||||
};
|
||||
|
||||
const response = await request(app).post('/admin/scripts').send(scriptData);
|
||||
const response = await admin.post('/admin/scripts').send(scriptData);
|
||||
|
||||
// Code is saved, but will fail at execution time
|
||||
expect(response.status).toBe(201);
|
||||
|
|
@ -174,7 +180,7 @@ export const onResponse = (context) => {
|
|||
let testScriptId: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
const response = await request(app).post('/admin/scripts').send({
|
||||
const response = await admin.post('/admin/scripts').send({
|
||||
name: 'Script for Get Test',
|
||||
script_code: 'export const onRequest = (context) => context;',
|
||||
script_type: 'per-backend',
|
||||
|
|
@ -184,11 +190,11 @@ export const onResponse = (context) => {
|
|||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await request(app).delete(`/admin/scripts/${testScriptId}`);
|
||||
await admin.delete(`/admin/scripts/${testScriptId}`);
|
||||
});
|
||||
|
||||
it('should return a script by id', async () => {
|
||||
const response = await request(app).get(`/admin/scripts/${testScriptId}`);
|
||||
const response = await admin.get(`/admin/scripts/${testScriptId}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.id).toBe(testScriptId);
|
||||
|
|
@ -197,7 +203,7 @@ export const onResponse = (context) => {
|
|||
});
|
||||
|
||||
it('should return 404 for non-existent script', async () => {
|
||||
const response = await request(app).get('/admin/scripts/99999');
|
||||
const response = await admin.get('/admin/scripts/99999');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toHaveProperty('error');
|
||||
|
|
@ -208,7 +214,7 @@ export const onResponse = (context) => {
|
|||
let testScriptId: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
const response = await request(app).post('/admin/scripts').send({
|
||||
const response = await admin.post('/admin/scripts').send({
|
||||
name: 'Script for Update Test',
|
||||
script_code: 'export const onRequest = (context) => context;',
|
||||
script_type: 'per-backend',
|
||||
|
|
@ -218,11 +224,11 @@ export const onResponse = (context) => {
|
|||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await request(app).delete(`/admin/scripts/${testScriptId}`);
|
||||
await admin.delete(`/admin/scripts/${testScriptId}`);
|
||||
});
|
||||
|
||||
it('should update script name', async () => {
|
||||
const response = await request(app)
|
||||
const response = await admin
|
||||
.put(`/admin/scripts/${testScriptId}`)
|
||||
.send({
|
||||
name: 'Updated Script Name'
|
||||
|
|
@ -233,7 +239,7 @@ export const onResponse = (context) => {
|
|||
});
|
||||
|
||||
it('should update script code', async () => {
|
||||
const response = await request(app)
|
||||
const response = await admin
|
||||
.put(`/admin/scripts/${testScriptId}`)
|
||||
.send({
|
||||
script_code: 'export const onResponse = async (context) => context;'
|
||||
|
|
@ -244,7 +250,7 @@ export const onResponse = (context) => {
|
|||
});
|
||||
|
||||
it('should toggle is_active', async () => {
|
||||
const response = await request(app)
|
||||
const response = await admin
|
||||
.put(`/admin/scripts/${testScriptId}`)
|
||||
.send({ is_active: false });
|
||||
|
||||
|
|
@ -253,13 +259,13 @@ export const onResponse = (context) => {
|
|||
});
|
||||
|
||||
it('should return 404 for non-existent script', async () => {
|
||||
const response = await request(app).put('/admin/scripts/99999').send({ name: 'Test' });
|
||||
const response = await admin.put('/admin/scripts/99999').send({ name: 'Test' });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it('should accept invalid JavaScript code (validation happens at execution time)', async () => {
|
||||
const response = await request(app)
|
||||
const response = await admin
|
||||
.put(`/admin/scripts/${testScriptId}`)
|
||||
.send({ script_code: 'invalid javascript {{{' });
|
||||
|
||||
|
|
@ -272,7 +278,7 @@ export const onResponse = (context) => {
|
|||
let testScriptId: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
const response = await request(app).post('/admin/scripts').send({
|
||||
const response = await admin.post('/admin/scripts').send({
|
||||
name: 'Script for Test',
|
||||
script_code: `
|
||||
export const onRequest = (context) => {
|
||||
|
|
@ -290,7 +296,7 @@ export const onResponse = (context) => {
|
|||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await request(app).delete(`/admin/scripts/${testScriptId}`);
|
||||
await admin.delete(`/admin/scripts/${testScriptId}`);
|
||||
});
|
||||
|
||||
it('should load and validate script syntax', async () => {
|
||||
|
|
@ -309,7 +315,7 @@ export const onResponse = (context) => {
|
|||
}
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
const response = await admin
|
||||
.post(`/admin/scripts/${testScriptId}/test`)
|
||||
.send(testPayload);
|
||||
|
||||
|
|
@ -319,7 +325,7 @@ export const onResponse = (context) => {
|
|||
});
|
||||
|
||||
it('should return 404 for non-existent script', async () => {
|
||||
const response = await request(app)
|
||||
const response = await admin
|
||||
.post('/admin/scripts/99999/test')
|
||||
.send({ request: {} });
|
||||
|
||||
|
|
@ -331,7 +337,7 @@ export const onResponse = (context) => {
|
|||
let testScriptId: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
const response = await request(app).post('/admin/scripts').send({
|
||||
const response = await admin.post('/admin/scripts').send({
|
||||
name: 'Script for Delete',
|
||||
script_code: 'export const onRequest = (context) => context;',
|
||||
script_type: 'per-backend',
|
||||
|
|
@ -341,13 +347,13 @@ export const onResponse = (context) => {
|
|||
});
|
||||
|
||||
it('should delete a script', async () => {
|
||||
const response = await request(app).delete(`/admin/scripts/${testScriptId}`);
|
||||
const response = await admin.delete(`/admin/scripts/${testScriptId}`);
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
});
|
||||
|
||||
it('should return 404 for already deleted script', async () => {
|
||||
const response = await request(app).delete(`/admin/scripts/${testScriptId}`);
|
||||
const response = await admin.delete(`/admin/scripts/${testScriptId}`);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
|
@ -359,7 +365,7 @@ export const onResponse = (context) => {
|
|||
|
||||
beforeAll(async () => {
|
||||
// Create script that modifies requests
|
||||
const scriptResponse = await request(app).post('/admin/scripts').send({
|
||||
const scriptResponse = await admin.post('/admin/scripts').send({
|
||||
name: 'Integration Test Script',
|
||||
script_code: `
|
||||
export const onRequest = (context) => {
|
||||
|
|
@ -380,16 +386,16 @@ export const onResponse = (context) => {
|
|||
testScriptId = scriptResponse.body.id;
|
||||
|
||||
// Create user with permission
|
||||
const userResponse = await request(app).post('/admin/users').send({ name: 'Integration User' });
|
||||
const userResponse = await admin.post('/admin/users').send({ name: 'Integration User' });
|
||||
userApiKey = userResponse.body.api_key;
|
||||
|
||||
await request(app)
|
||||
await admin
|
||||
.post('/admin/permissions')
|
||||
.send({ user_id: userResponse.body.id, backend_id: backendId });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await request(app).delete(`/admin/scripts/${testScriptId}`);
|
||||
await admin.delete(`/admin/scripts/${testScriptId}`);
|
||||
// Note: User cleanup handled in other tests
|
||||
});
|
||||
|
||||
|
|
@ -409,7 +415,7 @@ export const onResponse = (context) => {
|
|||
expect(response.status).toBe(502); // Backend unreachable
|
||||
|
||||
// Check that request was logged with script execution
|
||||
const analyticsResponse = await request(app).get('/admin/analytics/requests?limit=10');
|
||||
const analyticsResponse = await admin.get('/admin/analytics/requests?limit=10');
|
||||
const loggedRequest = analyticsResponse.body.rows.find((r: any) =>
|
||||
r.user_id === parseInt(userApiKey.split('-')[1]) || r.endpoint === '/v1/chat/completions'
|
||||
);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,11 @@ const TEST_DB_DIR = path.join(__dirname, '..', 'data', `test-db-${workerId}`);
|
|||
|
||||
process.env.DB_DIR = TEST_DB_DIR;
|
||||
process.env.TZ = 'Asia/Seoul';
|
||||
process.env.ADMIN_AUTH_MODE = 'env';
|
||||
process.env.ADMIN_USERNAME = 'admin';
|
||||
process.env.ADMIN_PASSWORD_HASH = 'sha256$5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8';
|
||||
process.env.ADMIN_SESSION_SECRET = 'test-admin-session-secret';
|
||||
process.env.ADMIN_SESSION_TTL_HOURS = '12';
|
||||
|
||||
beforeAll(() => {
|
||||
if (fs.existsSync(TEST_DB_DIR)) {
|
||||
|
|
|
|||
22
server/tests/utils/adminClient.ts
Normal file
22
server/tests/utils/adminClient.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import request from 'supertest';
|
||||
|
||||
export async function createAdminClient(app: Parameters<typeof request.agent>[0]) {
|
||||
const agent = request.agent(app);
|
||||
|
||||
await agent
|
||||
.post('/admin/auth/login')
|
||||
.send({ username: 'admin', password: 'password' })
|
||||
.expect(200);
|
||||
|
||||
const sessionResponse = await agent.get('/admin/auth/session').expect(200);
|
||||
const csrfToken = sessionResponse.body.csrfToken as string;
|
||||
|
||||
return {
|
||||
agent,
|
||||
csrfToken,
|
||||
get: (url: string) => agent.get(url),
|
||||
post: (url: string) => agent.post(url).set('X-CSRF-Token', csrfToken),
|
||||
put: (url: string) => agent.put(url).set('X-CSRF-Token', csrfToken),
|
||||
delete: (url: string) => agent.delete(url).set('X-CSRF-Token', csrfToken),
|
||||
};
|
||||
}
|
||||
|
|
@ -2,11 +2,13 @@ import express from 'express';
|
|||
import cors from 'cors';
|
||||
import { initDb } from '../../src/config/database';
|
||||
import { initAnalyticsDb } from '../../src/config/analytics-db';
|
||||
import adminAuthRoutes from '../../src/routes/admin-auth';
|
||||
import adminRoutes from '../../src/routes/admin';
|
||||
import apiRoutes from '../../src/routes/api';
|
||||
import analyticsRoutes from '../../src/routes/analytics';
|
||||
import { initRequestLogsDb } from '../../src/config/request-logs-db';
|
||||
import { getUtcTimestamp } from '../../src/utils/time';
|
||||
import { requireAdminAccess, requireSessionCsrf } from '../../src/utils/adminAuth';
|
||||
|
||||
export function createTestApp() {
|
||||
// Initialize both databases
|
||||
|
|
@ -19,9 +21,10 @@ export function createTestApp() {
|
|||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
app.use('/admin', adminRoutes);
|
||||
app.use('/admin/auth', adminAuthRoutes);
|
||||
app.use('/admin/analytics', requireAdminAccess, requireSessionCsrf, analyticsRoutes);
|
||||
app.use('/admin', requireAdminAccess, requireSessionCsrf, adminRoutes);
|
||||
app.use('/v1', apiRoutes);
|
||||
app.use('/admin/analytics', analyticsRoutes);
|
||||
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok', timestamp: getUtcTimestamp() });
|
||||
|
|
|
|||
|
|
@ -181,6 +181,39 @@ export interface UpdateScriptData {
|
|||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export type AdminAuthMode = 'env' | 'oidc' | 'both';
|
||||
|
||||
export interface AdminPrincipal {
|
||||
provider: 'env' | 'oidc';
|
||||
subject: string;
|
||||
username?: string;
|
||||
email?: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export interface AdminSessionResponse {
|
||||
authenticated: boolean;
|
||||
authMode: AdminAuthMode;
|
||||
csrfToken: string | null;
|
||||
principal: AdminPrincipal | null;
|
||||
}
|
||||
|
||||
export interface AdminApiTokenSummary {
|
||||
id: number;
|
||||
name: string;
|
||||
provider: 'env' | 'oidc';
|
||||
subject: string;
|
||||
username?: string;
|
||||
email?: string;
|
||||
display_name: string;
|
||||
token_prefix: string;
|
||||
expires_at: string;
|
||||
last_used_at?: string;
|
||||
revoked_at?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializable script context data that can be transferred across isolate boundaries
|
||||
* via structured clone ({ copy: true }).
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue