diff --git a/app/gateway-api/src/adminAuth.ts b/app/gateway-api/src/adminAuth.ts new file mode 100644 index 00000000..fcf67219 --- /dev/null +++ b/app/gateway-api/src/adminAuth.ts @@ -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[]; +} diff --git a/app/gateway-api/src/adminRouter.ts b/app/gateway-api/src/adminRouter.ts index da6e0096..f69f82b1 100644 --- a/app/gateway-api/src/adminRouter.ts +++ b/app/gateway-api/src/adminRouter.ts @@ -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 | 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 => { + const first = await ctx.prisma.appUser.findFirst({ + orderBy: { createdAt: 'asc' }, + select: { id: true }, + }); + return first?.id === userId; +}; + +const resolveAdminAuth = async (ctx: GatewayApiContext): Promise => { + 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, diff --git a/app/gateway-api/src/config.ts b/app/gateway-api/src/config.ts index d5f02fe1..fa2b231c 100644 --- a/app/gateway-api/src/config.ts +++ b/app/gateway-api/src/config.ts @@ -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, diff --git a/app/gateway-api/src/context.ts b/app/gateway-api/src/context.ts index f844a8e9..f23e6b90 100644 --- a/app/gateway-api/src/context.ts +++ b/app/gateway-api/src/context.ts @@ -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; 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; 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, }); diff --git a/app/gateway-api/src/server.ts b/app/gateway-api/src/server.ts index ee9fccfe..93df8c42 100644 --- a/app/gateway-api/src/server.ts +++ b/app/gateway-api/src/server.ts @@ -82,7 +82,6 @@ export const createGatewayApiServer = async () => { profiles, orchestrator, profileStatus, - adminToken: config.adminToken, requestHeaders: req.headers, prisma: postgres.prisma as GatewayPrismaClient, }), diff --git a/app/gateway-frontend/src/utils/trpc.ts b/app/gateway-frontend/src/utils/trpc.ts index 99c0b9c6..3e92dd43 100644 --- a/app/gateway-frontend/src/utils/trpc.ts +++ b/app/gateway-frontend/src/utils/trpc.ts @@ -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({ @@ -13,8 +13,8 @@ export const trpc = createTRPCProxyClient({ httpBatchLink({ url: '/api/trpc', // 실제 환경에 맞게 조정 필요 headers() { - const token = getAdminToken(); - return token ? { 'x-admin-token': token } : {}; + const token = getSessionToken(); + return token ? { 'x-session-token': token } : {}; }, }), ], diff --git a/app/gateway-frontend/src/views/AdminView.vue b/app/gateway-frontend/src/views/AdminView.vue index 3d43307e..1dcdbbdf 100644 --- a/app/gateway-frontend/src/views/AdminView.vue +++ b/app/gateway-frontend/src/views/AdminView.vue @@ -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(() => {
-

관리자 토큰

- {{ adminTokenStatus }} +

관리자 세션 토큰

+ {{ sessionTokenStatus }}
@@ -954,6 +955,12 @@ onMounted(() => { > 리셋 예약 +