feat: implement admin authentication context and update related procedures and tokens

This commit is contained in:
2026-01-03 15:44:27 +00:00
parent e5bd6d89ab
commit 95f263785a
7 changed files with 257 additions and 52 deletions
+9
View File
@@ -0,0 +1,9 @@
import type { GatewaySessionInfo } from './auth/sessionService.js';
import type { UserRecord } from './auth/userRepository.js';
export interface AdminAuthContext {
session: GatewaySessionInfo;
user: UserRecord;
isSuperuser: boolean;
roles: string[];
}
+222 -29
View File
@@ -5,6 +5,8 @@ import { z } from 'zod';
import { procedure, router } from './trpc.js';
import type { UserSanctions, UserServerRestriction } from './auth/userRepository.js';
import type { AdminAuthContext } from './adminAuth.js';
import type { GatewayApiContext } from './context.js';
import {
GATEWAY_BUILD_STATUSES,
GATEWAY_PROFILE_STATUSES,
@@ -21,28 +23,159 @@ const zServerAction = z.enum([
'DELAY',
'RESET_NOW',
'RESET_SCHEDULED',
'OPEN_SURVEY',
'SHUTDOWN',
]);
const adminProcedure = procedure.use(({ ctx, next }) => {
if (!ctx.adminToken) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Admin token is not configured.',
});
}
const ADMIN_ROLE_PREFIX = 'admin.';
const ADMIN_ROLE_SUPERUSER = 'admin.superuser';
const ROLE_SUPERUSER = 'superuser';
const ROLE_ADMIN_USERS = 'admin.users.manage';
const ROLE_ADMIN_PROFILES = 'admin.profiles.manage';
const ROLE_ADMIN_NOTICE = 'admin.notice.manage';
const ROLE_RESET_SCHEDULE = 'admin.reset.schedule';
const ROLE_RESUME_WHEN_STOPPED = 'admin.resume.when-stopped';
const ROLE_SURVEY_OPEN = 'admin.survey.open';
const readSessionToken = (
headers: Record<string, string | string[] | undefined>
): string | null => {
const provided =
ctx.requestHeaders['x-admin-token'] ??
ctx.requestHeaders['authorization'] ??
'';
const token =
Array.isArray(provided) ? provided[0] ?? '' : (provided as string);
if (!token || token !== ctx.adminToken) {
headers['x-session-token'] ?? headers['authorization'] ?? '';
const raw = Array.isArray(provided) ? provided[0] ?? '' : (provided as string);
const token = raw.startsWith('Bearer ') ? raw.slice(7) : raw;
const trimmed = token.trim();
return trimmed ? trimmed : null;
};
const isFirstUser = async (ctx: GatewayApiContext, userId: string): Promise<boolean> => {
const first = await ctx.prisma.appUser.findFirst({
orderBy: { createdAt: 'asc' },
select: { id: true },
});
return first?.id === userId;
};
const resolveAdminAuth = async (ctx: GatewayApiContext): Promise<AdminAuthContext> => {
const token = readSessionToken(ctx.requestHeaders);
if (!token) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'Invalid admin token.',
message: 'Session token is required.',
});
}
const session = await ctx.sessions.getSession(token);
if (!session) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'Session is not valid.',
});
}
const user = await ctx.users.findById(session.userId);
if (!user) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User not found.',
});
}
const roles = user.roles;
const isSuperuser =
roles.includes(ROLE_SUPERUSER) ||
roles.includes(ADMIN_ROLE_SUPERUSER) ||
(await isFirstUser(ctx, session.userId));
const hasAdminRole =
isSuperuser ||
roles.some((role) => role === 'admin' || role.startsWith(ADMIN_ROLE_PREFIX));
if (!hasAdminRole) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Admin permission is required.',
});
}
return {
session,
user,
roles,
isSuperuser,
};
};
const requireAdminAuth = (ctx: { adminAuth?: AdminAuthContext }): AdminAuthContext => {
if (!ctx.adminAuth) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'Admin session is not available.',
});
}
return ctx.adminAuth;
};
const roleMatchesScope = (
role: string,
permission: string,
profileName?: string
): boolean => {
if (role === permission || role === `${permission}:*`) {
return true;
}
if (profileName && role === `${permission}:${profileName}`) {
return true;
}
return false;
};
const hasScopedPermission = (
adminAuth: AdminAuthContext,
permission: string,
profileName?: string
): boolean => {
if (adminAuth.isSuperuser) {
return true;
}
return adminAuth.roles.some((role: string) =>
roleMatchesScope(role, permission, profileName)
);
};
const assertPermission = (
adminAuth: AdminAuthContext,
permission: string,
profileName?: string
): void => {
if (hasScopedPermission(adminAuth, permission, profileName)) {
return;
}
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Permission denied.',
});
};
const adminProcedure = procedure.use(async ({ ctx, next }) => {
const adminAuth = await resolveAdminAuth(ctx as GatewayApiContext);
return next({
ctx: {
...ctx,
adminAuth,
},
});
});
const noticeAdminProcedure = adminProcedure.use(({ ctx, next }) => {
const adminAuth = requireAdminAuth(ctx);
assertPermission(adminAuth, ROLE_ADMIN_NOTICE);
return next();
});
const userAdminProcedure = adminProcedure.use(({ ctx, next }) => {
const adminAuth = requireAdminAuth(ctx);
assertPermission(adminAuth, ROLE_ADMIN_USERS);
return next();
});
const profileAdminProcedure = adminProcedure.use(({ ctx, next }) => {
const adminAuth = requireAdminAuth(ctx);
assertPermission(adminAuth, ROLE_ADMIN_PROFILES);
return next();
});
@@ -176,7 +309,7 @@ export const adminRouter = router({
});
return { notice: setting?.notice ?? '' };
}),
setNotice: adminProcedure
setNotice: noticeAdminProcedure
.input(
z.object({
notice: z.string().max(4000),
@@ -197,7 +330,7 @@ export const adminRouter = router({
}),
}),
users: router({
lookup: adminProcedure.input(zUserLookupInput).query(async ({ ctx, input }) => {
lookup: userAdminProcedure.input(zUserLookupInput).query(async ({ ctx, input }) => {
const user =
input.id
? await ctx.users.findById(input.id)
@@ -221,7 +354,7 @@ export const adminRouter = router({
createdAt: user.createdAt,
};
}),
resetPassword: adminProcedure
resetPassword: userAdminProcedure
.input(
z.object({
userId: z.string().min(1),
@@ -237,7 +370,7 @@ export const adminRouter = router({
);
return { password };
}),
updateRoles: adminProcedure
updateRoles: userAdminProcedure
.input(
z.object({
userId: z.string().min(1),
@@ -277,7 +410,7 @@ export const adminRouter = router({
);
return { roles: nextRoles };
}),
updateSanctions: adminProcedure
updateSanctions: userAdminProcedure
.input(
z.object({
userId: z.string().min(1),
@@ -300,7 +433,7 @@ export const adminRouter = router({
);
return { sanctions: next };
}),
setServerRestriction: adminProcedure
setServerRestriction: userAdminProcedure
.input(
z.object({
userId: z.string().min(1),
@@ -329,7 +462,7 @@ export const adminRouter = router({
);
return { sanctions: next };
}),
resetProfileIcon: adminProcedure
resetProfileIcon: userAdminProcedure
.input(
z.object({
userId: z.string().min(1),
@@ -353,7 +486,7 @@ export const adminRouter = router({
);
return { profileIconResetAt: next.profileIconResetAt };
}),
forceDelete: adminProcedure
forceDelete: userAdminProcedure
.input(
z.object({
userId: z.string().min(1),
@@ -386,7 +519,7 @@ export const adminRouter = router({
},
}));
}),
upsert: adminProcedure
upsert: profileAdminProcedure
.input(
z.object({
profile: z.string().min(1).max(32),
@@ -412,7 +545,7 @@ export const adminRouter = router({
buildCommitSha: input.buildCommitSha,
});
}),
setStatus: adminProcedure
setStatus: profileAdminProcedure
.input(
z.object({
profileName: z.string().min(1),
@@ -448,7 +581,7 @@ export const adminRouter = router({
await ctx.orchestrator.reconcileNow();
return result;
}),
updateMeta: adminProcedure
updateMeta: profileAdminProcedure
.input(
z.object({
profileName: z.string().min(1),
@@ -483,6 +616,7 @@ export const adminRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
const adminAuth = requireAdminAuth(ctx);
if (
(input.action === 'ACCELERATE' || input.action === 'DELAY') &&
!input.durationMinutes
@@ -506,6 +640,65 @@ export const adminRouter = router({
});
}
const canManageProfiles = hasScopedPermission(
adminAuth,
ROLE_ADMIN_PROFILES,
profile.profileName
);
const canResume =
canManageProfiles ||
hasScopedPermission(
adminAuth,
ROLE_RESUME_WHEN_STOPPED,
profile.profileName
);
const canResetSchedule =
canManageProfiles ||
hasScopedPermission(adminAuth, ROLE_RESET_SCHEDULE, profile.profileName);
const canOpenSurvey =
canManageProfiles ||
hasScopedPermission(adminAuth, ROLE_SURVEY_OPEN, profile.profileName);
if (input.action === 'RESUME') {
if (profile.status !== 'STOPPED') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Resume is allowed only for STOPPED profiles.',
});
}
if (!canResume) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Resume permission is required.',
});
}
} else if (input.action === 'RESET_SCHEDULED') {
if (profile.status !== 'COMPLETED') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Reset scheduling is allowed only for COMPLETED profiles.',
});
}
if (!canResetSchedule) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Reset scheduling permission is required.',
});
}
} else if (input.action === 'OPEN_SURVEY') {
if (!canOpenSurvey) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Survey permission is required.',
});
}
} else if (!canManageProfiles) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Profile management permission is required.',
});
}
const statusMap = {
RESUME: 'RUNNING',
PAUSE: 'PAUSED',
@@ -537,7 +730,7 @@ export const adminRouter = router({
await ctx.profiles.updateMeta(input.profileName, nextMeta);
return { ok: true, action: actionRecord };
}),
requestBuild: adminProcedure
requestBuild: profileAdminProcedure
.input(
z.object({
profileName: z.string().min(1),
@@ -557,7 +750,7 @@ export const adminRouter = router({
);
return result;
}),
setBuildStatus: adminProcedure
setBuildStatus: profileAdminProcedure
.input(
z.object({
profileName: z.string().min(1),
@@ -567,11 +760,11 @@ export const adminRouter = router({
.mutation(async ({ ctx, input }) =>
ctx.profiles.updateBuildStatus(input.profileName, input.status)
),
reconcileNow: adminProcedure.mutation(async ({ ctx }) => {
reconcileNow: profileAdminProcedure.mutation(async ({ ctx }) => {
await ctx.orchestrator.reconcileNow();
return { ok: true };
}),
cleanupWorkspaces: adminProcedure.mutation(async ({ ctx }) => {
cleanupWorkspaces: profileAdminProcedure.mutation(async ({ ctx }) => {
const result = await ctx.orchestrator.cleanupStaleWorkspaces();
return {
removed: result.removed,
-2
View File
@@ -15,7 +15,6 @@ export interface GatewayApiConfig {
kakaoAdminKey?: string;
kakaoRedirectUri: string;
publicBaseUrl: string;
adminToken?: string;
orchestratorEnabled: boolean;
orchestratorReconcileIntervalMs: number;
orchestratorScheduleIntervalMs: number;
@@ -105,7 +104,6 @@ export const resolveGatewayApiConfigFromEnv = (
kakaoAdminKey: env.KAKAO_ADMIN_KEY,
kakaoRedirectUri,
publicBaseUrl,
adminToken: env.GATEWAY_ADMIN_TOKEN,
orchestratorEnabled: parseBoolean(env.GATEWAY_ORCHESTRATOR_ENABLED, false),
orchestratorReconcileIntervalMs: parseNumber(
env.GATEWAY_ORCHESTRATOR_RECONCILE_MS,
+2 -3
View File
@@ -7,6 +7,7 @@ import type { GatewayProfileRepository } from './orchestrator/profileRepository.
import type { GatewayOrchestratorHandle } from './orchestrator/gatewayOrchestrator.js';
import type { GatewayProfileStatusService } from './lobby/profileStatusService.js';
import type { GatewayPrismaClient } from '@sammo-ts/infra';
import type { AdminAuthContext } from './adminAuth.js';
export interface GatewayApiContext {
users: UserRepository;
@@ -20,9 +21,9 @@ export interface GatewayApiContext {
profiles: GatewayProfileRepository;
orchestrator: GatewayOrchestratorHandle;
profileStatus: GatewayProfileStatusService;
adminToken?: string;
requestHeaders: Record<string, string | string[] | undefined>;
prisma: GatewayPrismaClient;
adminAuth?: AdminAuthContext;
}
export const createGatewayApiContext = (options: {
@@ -37,7 +38,6 @@ export const createGatewayApiContext = (options: {
profiles: GatewayProfileRepository;
orchestrator: GatewayOrchestratorHandle;
profileStatus: GatewayProfileStatusService;
adminToken?: string;
requestHeaders?: Record<string, string | string[] | undefined>;
prisma: GatewayPrismaClient;
}): GatewayApiContext => ({
@@ -52,7 +52,6 @@ export const createGatewayApiContext = (options: {
profiles: options.profiles,
orchestrator: options.orchestrator,
profileStatus: options.profileStatus,
adminToken: options.adminToken,
requestHeaders: options.requestHeaders ?? {},
prisma: options.prisma,
});
-1
View File
@@ -82,7 +82,6 @@ export const createGatewayApiServer = async () => {
profiles,
orchestrator,
profileStatus,
adminToken: config.adminToken,
requestHeaders: req.headers,
prisma: postgres.prisma as GatewayPrismaClient,
}),
+4 -4
View File
@@ -1,11 +1,11 @@
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '../../../gateway-api/src/router';
const getAdminToken = (): string | null => {
const getSessionToken = (): string | null => {
if (typeof window === 'undefined') {
return null;
}
return window.localStorage.getItem('sammo-admin-token');
return window.localStorage.getItem('sammo-session-token');
};
export const trpc = createTRPCProxyClient<AppRouter>({
@@ -13,8 +13,8 @@ export const trpc = createTRPCProxyClient<AppRouter>({
httpBatchLink({
url: '/api/trpc', // 실제 환경에 맞게 조정 필요
headers() {
const token = getAdminToken();
return token ? { 'x-admin-token': token } : {};
const token = getSessionToken();
return token ? { 'x-session-token': token } : {};
},
}),
],
+20 -13
View File
@@ -69,6 +69,7 @@ type AdminAction =
| 'DELAY'
| 'RESET_NOW'
| 'RESET_SCHEDULED'
| 'OPEN_SURVEY'
| 'SHUTDOWN';
type AdminClient = {
@@ -141,23 +142,23 @@ type AdminClient = {
const adminClient = trpc.admin as unknown as AdminClient;
const adminToken = ref('');
const adminTokenStatus = ref('');
const sessionToken = ref('');
const sessionTokenStatus = ref('');
if (typeof window !== 'undefined') {
adminToken.value = window.localStorage.getItem('sammo-admin-token') ?? '';
sessionToken.value = window.localStorage.getItem('sammo-session-token') ?? '';
}
const saveAdminToken = () => {
const value = adminToken.value.trim();
const saveSessionToken = () => {
const value = sessionToken.value.trim();
if (typeof window !== 'undefined') {
if (value) {
window.localStorage.setItem('sammo-admin-token', value);
window.localStorage.setItem('sammo-session-token', value);
} else {
window.localStorage.removeItem('sammo-admin-token');
window.localStorage.removeItem('sammo-session-token');
}
}
adminTokenStatus.value = value ? '저장됨' : '삭제됨';
sessionTokenStatus.value = value ? '저장됨' : '삭제됨';
};
const noticeDraft = ref('');
@@ -559,19 +560,19 @@ onMounted(() => {
<section class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-3">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold">관리자 토큰</h3>
<span class="text-xs text-zinc-500">{{ adminTokenStatus }}</span>
<h3 class="text-lg font-semibold">관리자 세션 토큰</h3>
<span class="text-xs text-zinc-500">{{ sessionTokenStatus }}</span>
</div>
<div class="flex flex-col md:flex-row gap-3">
<input
v-model="adminToken"
v-model="sessionToken"
type="password"
class="flex-1 bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-yellow-500"
placeholder="GATEWAY_ADMIN_TOKEN 입력"
placeholder="세션 토큰 입력"
/>
<button
class="bg-yellow-600 hover:bg-yellow-500 text-black font-semibold px-4 py-2 rounded"
@click="saveAdminToken"
@click="saveSessionToken"
>
저장
</button>
@@ -954,6 +955,12 @@ onMounted(() => {
>
리셋 예약
</button>
<button
class="bg-teal-700 hover:bg-teal-600 text-white font-semibold px-3 py-2 rounded"
@click="requestProfileAction(profile.profileName, 'OPEN_SURVEY')"
>
설문 오픈
</button>
<button
class="bg-black hover:bg-zinc-800 text-white font-semibold px-3 py-2 rounded col-span-2"
@click="requestProfileAction(profile.profileName, 'SHUTDOWN')"