From 4f7dbfd19e6e11ee05618b49d3dee3f479406597 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sat, 8 Aug 2026 17:10:43 +0000 Subject: [PATCH] feat(gateway): split server lifecycle administration --- README.md | 7 +- app/gateway-api/src/adminCapabilities.ts | 41 +++- app/gateway-api/src/adminRouter.ts | 203 +++++++++++----- app/gateway-api/test/adminOperations.test.ts | 145 +++++++++++- .../e2e/admin-runtime-actions.spec.ts | 40 +++- .../e2e/general-icon-lifecycle.spec.ts | 3 +- .../e2e/hwe-lifecycle.spec.ts | 5 +- .../e2e/lobby-admin-navigation.spec.ts | 53 ++++- .../e2e/server-operations.spec.ts | 72 ++++-- .../src/layouts/AdminConsoleLayout.vue | 125 ++++++++-- app/gateway-frontend/src/router/index.ts | 21 +- .../src/views/AdminOverviewView.vue | 112 ++++++--- app/gateway-frontend/src/views/AdminView.vue | 109 ++++++--- .../src/views/ServerOperationsView.vue | 216 ++++++++++-------- docs/admin-console.md | 44 +++- docs/release-operations.md | 21 +- 16 files changed, 916 insertions(+), 301 deletions(-) diff --git a/README.md b/README.md index 39215b2e..63280eba 100644 --- a/README.md +++ b/README.md @@ -228,9 +228,10 @@ direct-navigation URL을 사용합니다. `/image/*`는 외부 Caddy가 소유 완전한 API·daemon·frontend 배포 bundle은 gateway orchestrator의 commit-worktree build 경로에서 구성합니다. -관리자 화면의 `DB 유지 배포`는 profile의 game migration만 적용하고 현재 -게임 DB를 seed하지 않습니다. `DB 초기화 배포`는 현재 시즌 테이블을 새 -시나리오로 교체하지만 `hall`, `ng_games`, 연감, 과거 장수·국가와 상속 자료는 +각 서버의 `버전 업데이트`는 profile의 game migration만 적용하고 현재 +게임 DB를 seed하지 않습니다. 별도 `시나리오 초기화`는 Git 업데이트 없이 +현재 게시 commit을 기본으로 사용하며, 필요할 때만 새 버전 배포와 결합합니다. +초기화는 현재 시즌 테이블을 새 시나리오로 교체하지만 `hall`, `ng_games`, 연감, 과거 장수·국가와 상속 자료는 보존합니다. Gateway API·frontend·orchestrator는 외부 release-controller가 함께 전환합니다. 설치와 CLI self-upgrade 절차는 [`app/release-controller/README.md`](app/release-controller/README.md)를 확인해 diff --git a/app/gateway-api/src/adminCapabilities.ts b/app/gateway-api/src/adminCapabilities.ts index a126b78e..fc72ace6 100644 --- a/app/gateway-api/src/adminCapabilities.ts +++ b/app/gateway-api/src/adminCapabilities.ts @@ -40,8 +40,36 @@ export const ADMIN_CAPABILITIES: readonly AdminCapabilityDefinition[] = [ }, { permission: 'admin.profiles.manage', - label: 'Profile 운영', - description: '지정 profile의 배포, 초기화와 runtime을 관리합니다.', + label: 'Profile 전체 운영 (호환)', + description: '기존 운영자를 위한 포괄 권한입니다. 새 역할에는 세분화 권한을 사용합니다.', + risk: 'CRITICAL', + scope: 'PROFILE', + }, + { + permission: 'admin.profiles.runtime', + label: 'Profile 실행 관리', + description: '지정 profile의 시작, 정지와 실행 상태를 관리합니다.', + risk: 'HIGH', + scope: 'PROFILE', + }, + { + permission: 'admin.profiles.settings', + label: 'Profile 설정 관리', + description: '지정 profile의 표시 정보와 계정 접근 정책을 변경합니다.', + risk: 'HIGH', + scope: 'PROFILE', + }, + { + permission: 'admin.profiles.deploy', + label: 'Profile 버전 배포', + description: '지정 profile의 DB를 유지하면서 코드와 migration을 배포합니다.', + risk: 'CRITICAL', + scope: 'PROFILE', + }, + { + permission: 'admin.scenarios.reset', + label: '시나리오 초기화', + description: '지정 profile의 현재 배포 버전으로 게임 DB와 시나리오를 초기화합니다.', risk: 'CRITICAL', scope: 'PROFILE', }, @@ -98,6 +126,15 @@ export const resolveAdminActionCapability = (path: string, rawInput?: unknown): if (action === 'RESUME') return 'admin.resume.when-stopped'; if (action === 'OPEN_SURVEY') return 'admin.survey.open'; } + if (path.endsWith('.operations.requestDeploy')) return 'admin.profiles.deploy'; + if (path.endsWith('.operations.requestReset')) return 'admin.scenarios.reset'; + if (path.endsWith('.operations.requestRuntime')) return 'admin.profiles.runtime'; + if (path.endsWith('.profiles.updateMeta')) return 'admin.profiles.settings'; + if (path.endsWith('.profiles.listScenarios')) { + const sourceMode = + rawInput && typeof rawInput === 'object' ? (rawInput as { sourceMode?: unknown }).sourceMode : undefined; + return sourceMode === undefined || sourceMode === 'CURRENT' ? 'admin.scenarios.reset' : 'admin.profiles.deploy'; + } if (path.includes('.operations.') || path.includes('.profiles.')) return 'admin.profiles.manage'; return undefined; }; diff --git a/app/gateway-api/src/adminRouter.ts b/app/gateway-api/src/adminRouter.ts index 563cbeea..bda341e8 100644 --- a/app/gateway-api/src/adminRouter.ts +++ b/app/gateway-api/src/adminRouter.ts @@ -54,6 +54,10 @@ const ROLE_SUPERUSER = 'superuser'; const ROLE_ADMIN_USERS = 'admin.users.manage'; const ROLE_ADMIN_USERS_CREATE = 'admin.users.create'; const ROLE_ADMIN_PROFILES = 'admin.profiles.manage'; +const ROLE_ADMIN_PROFILE_RUNTIME = 'admin.profiles.runtime'; +const ROLE_ADMIN_PROFILE_SETTINGS = 'admin.profiles.settings'; +const ROLE_ADMIN_PROFILE_DEPLOY = 'admin.profiles.deploy'; +const ROLE_ADMIN_SCENARIO_RESET = 'admin.scenarios.reset'; const ROLE_ADMIN_RELEASES = 'admin.releases.manage'; const ROLE_ADMIN_NOTICE = 'admin.notice.manage'; const ROLE_ADMIN_AUDIT = 'admin.audit.read'; @@ -146,6 +150,21 @@ const hasScopedPermission = (adminAuth: AdminAuthContext, permission: string, pr return adminAuth.roles.some((role: string) => roleMatchesScope(role, permission, profileName)); }; +const hasAnyScopedPermission = ( + adminAuth: AdminAuthContext, + permissions: readonly string[], + profileName?: string +): boolean => permissions.some((permission) => hasScopedPermission(adminAuth, permission, profileName)); + +const assertAnyPermission = ( + adminAuth: AdminAuthContext, + permissions: readonly string[], + profileName?: string +): void => { + if (hasAnyScopedPermission(adminAuth, permissions, profileName)) return; + throw new TRPCError({ code: 'FORBIDDEN', message: 'Permission denied.' }); +}; + const splitRoleScope = (role: string): { permission: string; scope?: string } => { const separator = role.indexOf(':'); if (separator < 0) { @@ -437,6 +456,7 @@ const zInstallOptions = z.object({ }); const zOperationInstallOptions = zInstallOptions.omit({ gitRef: true }); const zSourceMode = z.enum(['BRANCH', 'COMMIT']); +const zResetSourceMode = z.enum(['CURRENT', 'BRANCH', 'COMMIT']); type SanctionsPatch = z.infer; @@ -533,7 +553,14 @@ export const adminRouter = router({ const parsed = splitRoleScope(role); return parsed.permission === entry.permission; }) - ); + ).map((entry) => { + if (adminAuth.isSuperuser) return { ...entry, scopes: ['*'] }; + const scopes = adminAuth.roles + .map(splitRoleScope) + .filter((role) => role.permission === entry.permission) + .map((role) => role.scope ?? '*'); + return { ...entry, scopes: Array.from(new Set(scopes)) }; + }); }), }), audit: router({ @@ -691,14 +718,20 @@ export const adminRouter = router({ throw new TRPCError({ code: 'BAD_REQUEST', message: 'Recovery access must expire.' }); } if (expiresAt.getTime() > now.getTime() + 90 * 24 * 60 * 60 * 1000) { - throw new TRPCError({ code: 'BAD_REQUEST', message: 'Recovery access may last at most 90 days.' }); + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Recovery access may last at most 90 days.', + }); } } const profiles = [...new Set(input.profiles.map((profile) => profile.toLowerCase()))]; if (profiles.length > 0) { const knownProfiles = await ctx.profiles.listProfiles(); const knownNames = new Set( - knownProfiles.flatMap((profile) => [profile.profile.toLowerCase(), profile.profileName.toLowerCase()]) + knownProfiles.flatMap((profile) => [ + profile.profile.toLowerCase(), + profile.profileName.toLowerCase(), + ]) ); const unknown = profiles.find((profile) => !knownNames.has(profile)); if (unknown) { @@ -984,19 +1017,19 @@ export const adminRouter = router({ .query(async ({ ctx, input }) => { const adminAuth = requireAdminAuth(ctx); if (input?.profileName) { - assertPermission(adminAuth, ROLE_ADMIN_PROFILES, input.profileName); + if (!canReadProfile(adminAuth, input.profileName)) { + throw new TRPCError({ code: 'FORBIDDEN', message: 'Permission denied.' }); + } return ctx.profiles.listOperations({ profileName: input.profileName, limit: input.limit, }); } - if (hasScopedPermission(adminAuth, ROLE_ADMIN_PROFILES)) { + if (adminAuth.isSuperuser || adminAuth.roles.some((role) => role.endsWith(':*'))) { return ctx.profiles.listOperations({ limit: input?.limit }); } const profiles = await ctx.profiles.listProfiles(); - const allowed = profiles.filter((profile) => - hasScopedPermission(adminAuth, ROLE_ADMIN_PROFILES, profile.profileName) - ); + const allowed = profiles.filter((profile) => canReadProfile(adminAuth, profile.profileName)); const operations = ( await Promise.all( allowed.map((profile) => @@ -1015,8 +1048,8 @@ export const adminRouter = router({ .input( z.object({ profileName: z.string().min(1), - sourceMode: zSourceMode, - sourceRef: z.string().min(1).max(128), + sourceMode: zResetSourceMode, + sourceRef: z.string().min(1).max(128).optional(), install: zOperationInstallOptions, scheduledAt: z.string().datetime().optional(), reason: z.string().max(200).optional(), @@ -1024,7 +1057,13 @@ export const adminRouter = router({ ) .mutation(async ({ ctx, input }) => { const adminAuth = requireAdminAuth(ctx); - assertPermission(adminAuth, ROLE_ADMIN_PROFILES, input.profileName); + assertAnyPermission(adminAuth, [ROLE_ADMIN_PROFILES, ROLE_ADMIN_SCENARIO_RESET], input.profileName); + if (input.sourceMode !== 'CURRENT') { + assertAnyPermission(adminAuth, [ROLE_ADMIN_PROFILES, ROLE_ADMIN_PROFILE_DEPLOY], input.profileName); + } + if (input.scheduledAt) { + assertAnyPermission(adminAuth, [ROLE_ADMIN_PROFILES, ROLE_RESET_SCHEDULE], input.profileName); + } const profile = await ctx.profiles.getProfile(input.profileName); if (!profile) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.' }); @@ -1074,13 +1113,24 @@ export const adminRouter = router({ }); } - let sourceRef = input.sourceRef.trim(); + const sourceMode: 'BRANCH' | 'COMMIT' = input.sourceMode === 'CURRENT' ? 'COMMIT' : input.sourceMode; + let sourceRef = + input.sourceMode === 'CURRENT' ? profile.buildCommitSha?.trim() : input.sourceRef?.trim(); + if (!sourceRef) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: + input.sourceMode === 'CURRENT' + ? 'The profile has no active build commit to reset from.' + : 'sourceRef is required.', + }); + } try { const resolved = - input.sourceMode === 'BRANCH' + sourceMode === 'BRANCH' ? await resolveGitBranchCommitSha(sourceRef) : await resolveGitCommitSha(sourceRef); - if (input.sourceMode === 'COMMIT') { + if (sourceMode === 'COMMIT') { sourceRef = resolved; } const scenarios = await listScenarioPreviews({ gitRef: resolved }); @@ -1091,7 +1141,7 @@ export const adminRouter = router({ throw new TRPCError({ code: 'BAD_REQUEST', message: - input.sourceMode === 'BRANCH' + sourceMode === 'BRANCH' ? 'Branch is invalid or does not contain the scenario.' : 'Commit is invalid or does not contain the scenario.', }); @@ -1101,9 +1151,12 @@ export const adminRouter = router({ const operation = await ctx.profiles.createOperation({ profileName: input.profileName, type: 'RESET', - sourceMode: input.sourceMode, + sourceMode, sourceRef, - payload: { install: input.install } as GatewayPrisma.JsonObject, + payload: { + install: input.install, + requestedSource: input.sourceMode, + } as GatewayPrisma.JsonObject, reason: input.reason, requestedBy: adminAuth.user.id, scheduledAt: input.scheduledAt, @@ -1130,7 +1183,7 @@ export const adminRouter = router({ ) .mutation(async ({ ctx, input }) => { const adminAuth = requireAdminAuth(ctx); - assertPermission(adminAuth, ROLE_ADMIN_PROFILES, input.profileName); + assertAnyPermission(adminAuth, [ROLE_ADMIN_PROFILES, ROLE_ADMIN_PROFILE_DEPLOY], input.profileName); const profile = await ctx.profiles.getProfile(input.profileName); if (!profile) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.' }); @@ -1183,7 +1236,7 @@ export const adminRouter = router({ ) .mutation(async ({ ctx, input }) => { const adminAuth = requireAdminAuth(ctx); - assertPermission(adminAuth, ROLE_ADMIN_PROFILES, input.profileName); + assertAnyPermission(adminAuth, [ROLE_ADMIN_PROFILES, ROLE_ADMIN_PROFILE_RUNTIME], input.profileName); const profile = await ctx.profiles.getProfile(input.profileName); if (!profile) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.' }); @@ -1212,7 +1265,13 @@ export const adminRouter = router({ if (!previous) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Operation not found.' }); } - assertPermission(adminAuth, ROLE_ADMIN_PROFILES, previous.profileName); + const permissions = + previous.type === 'RESET' + ? [ROLE_ADMIN_PROFILES, ROLE_ADMIN_SCENARIO_RESET] + : previous.type === 'DEPLOY' + ? [ROLE_ADMIN_PROFILES, ROLE_ADMIN_PROFILE_DEPLOY] + : [ROLE_ADMIN_PROFILES, ROLE_ADMIN_PROFILE_RUNTIME]; + assertAnyPermission(adminAuth, permissions, previous.profileName); const cancelled = await ctx.profiles.cancelOperation(input.id); if (!cancelled) { throw new TRPCError({ @@ -1228,7 +1287,26 @@ export const adminRouter = router({ if (!previous) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Operation not found.' }); } - assertPermission(adminAuth, ROLE_ADMIN_PROFILES, previous.profileName); + const permissions = + previous.type === 'RESET' + ? [ROLE_ADMIN_PROFILES, ROLE_ADMIN_SCENARIO_RESET] + : previous.type === 'DEPLOY' + ? [ROLE_ADMIN_PROFILES, ROLE_ADMIN_PROFILE_DEPLOY] + : [ROLE_ADMIN_PROFILES, ROLE_ADMIN_PROFILE_RUNTIME]; + assertAnyPermission(adminAuth, permissions, previous.profileName); + if (previous.type === 'RESET') { + const payload = readMetaObject(previous.payload); + if (payload.requestedSource !== 'CURRENT') { + assertAnyPermission( + adminAuth, + [ROLE_ADMIN_PROFILES, ROLE_ADMIN_PROFILE_DEPLOY], + previous.profileName + ); + } + if (previous.scheduledAt) { + assertAnyPermission(adminAuth, [ROLE_ADMIN_PROFILES, ROLE_RESET_SCHEDULE], previous.profileName); + } + } try { const operation = await ctx.profiles.retryOperation(input.id, adminAuth.user.id); if (!operation) { @@ -1399,22 +1477,51 @@ export const adminRouter = router({ }, })); }), - listScenarios: profileAdminProcedure + listScenarios: adminProcedure .input( z .object({ + profileName: z.string().min(1).max(64).optional(), gitRef: z.string().min(1).max(128).optional(), - sourceMode: zSourceMode.optional(), + sourceMode: zResetSourceMode.optional(), }) .optional() ) - .query(async ({ input }) => { - const gitRef = input?.gitRef?.trim(); + .query(async ({ ctx, input }) => { + const adminAuth = requireAdminAuth(ctx); + const sourceMode = input?.sourceMode ?? 'CURRENT'; + let gitRef = input?.gitRef?.trim(); + if (sourceMode === 'CURRENT') { + if (!input?.profileName) { + if (!adminAuth.isSuperuser) { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'profileName is required.' }); + } + } else { + assertAnyPermission( + adminAuth, + [ROLE_ADMIN_PROFILES, ROLE_ADMIN_SCENARIO_RESET], + input.profileName + ); + const profile = await ctx.profiles.getProfile(input.profileName); + if (!profile) throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.' }); + gitRef = profile.buildCommitSha?.trim(); + if (!gitRef) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'The profile has no active build commit.', + }); + } + } + } else if (input?.profileName) { + assertAnyPermission(adminAuth, [ROLE_ADMIN_PROFILES, ROLE_ADMIN_PROFILE_DEPLOY], input.profileName); + } else { + assertAnyPermission(adminAuth, [ROLE_ADMIN_PROFILES, ROLE_ADMIN_PROFILE_DEPLOY]); + } if (!gitRef) { return listScenarioPreviews(); } const resolved = - input?.sourceMode === 'BRANCH' + sourceMode === 'BRANCH' ? await resolveGitBranchCommitSha(gitRef) : await resolveGitCommitSha(gitRef); return listScenarioPreviews({ gitRef: resolved }); @@ -1476,7 +1583,7 @@ export const adminRouter = router({ await ctx.orchestrator.reconcileNow(); return result; }), - updateMeta: profileAdminProcedure + updateMeta: adminProcedure .input( z.object({ profileName: z.string().min(1), @@ -1493,6 +1600,11 @@ export const adminRouter = router({ }) ) .mutation(async ({ ctx, input }) => { + assertAnyPermission( + requireAdminAuth(ctx), + [ROLE_ADMIN_PROFILES, ROLE_ADMIN_PROFILE_SETTINGS], + input.profileName + ); const profile = await ctx.profiles.getProfile(input.profileName); if (!profile) { throw new TRPCError({ @@ -1730,22 +1842,22 @@ export const adminRouter = router({ ) .mutation(async ({ ctx, input }) => { const adminAuth = requireAdminAuth(ctx); + if (input.action === 'RESET_NOW' || input.action === 'RESET_SCHEDULED') { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: '시나리오 초기화는 operations.requestReset을 사용해 주세요.', + }); + } if ((input.action === 'ACCELERATE' || input.action === 'DELAY') && !input.durationMinutes) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'durationMinutes is required for acceleration or delay.', }); } - if (input.action === 'RESET_SCHEDULED' && !input.scheduledAt) { + if (input.scheduledAt) { throw new TRPCError({ code: 'BAD_REQUEST', - message: 'scheduledAt is required for scheduled reset.', - }); - } - if (input.action !== 'RESET_SCHEDULED' && input.scheduledAt) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: 'scheduledAt is supported only for scheduled reset.', + message: 'scheduledAt is supported only by operations.requestReset.', }); } const profile = await ctx.profiles.getProfile(input.profileName); @@ -1756,11 +1868,13 @@ export const adminRouter = router({ }); } - const canManageProfiles = hasScopedPermission(adminAuth, ROLE_ADMIN_PROFILES, profile.profileName); + const canManageProfiles = hasAnyScopedPermission( + adminAuth, + [ROLE_ADMIN_PROFILES, ROLE_ADMIN_PROFILE_RUNTIME], + 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); @@ -1777,19 +1891,6 @@ export const adminRouter = router({ 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({ diff --git a/app/gateway-api/test/adminOperations.test.ts b/app/gateway-api/test/adminOperations.test.ts index f742cd7d..b65dfa0b 100644 --- a/app/gateway-api/test/adminOperations.test.ts +++ b/app/gateway-api/test/adminOperations.test.ts @@ -61,6 +61,7 @@ const buildCaller = async ( apiPort: 15003, status: options.initialProfileStatus ?? ('STOPPED' as const), buildStatus: 'SUCCEEDED' as const, + buildCommitSha: 'HEAD', meta: {}, createdAt: '2026-07-25T00:00:00.000Z', updatedAt: '2026-07-25T00:00:00.000Z', @@ -378,6 +379,141 @@ describe('admin operation API', () => { }); expect(harness.createdInputs[0]).not.toHaveProperty('payload'); }); + + it('lets a scenario-only operator reset from the active commit without selecting Git', async () => { + const harness = await buildCaller( + async (input) => ({ + id: '55555555-5555-4555-8555-555555555555', + profileName: input.profileName, + type: 'RESET', + status: 'QUEUED', + sourceMode: input.sourceMode, + sourceRef: input.sourceRef, + payload: input.payload ?? {}, + requestedBy: input.requestedBy, + createdAt: '2026-08-08T00:00:00.000Z', + updatedAt: '2026-08-08T00:00:00.000Z', + }), + { + adminRoles: ['admin.scenarios.reset:che:2'], + firstUserIsAdmin: false, + profileScenario: '1010', + } + ); + + await harness.caller.admin.operations.requestReset({ + profileName: 'che:2', + sourceMode: 'CURRENT', + install: { + scenarioId: 1010, + turnTermMinutes: 60, + sync: false, + fiction: 1, + extend: false, + blockGeneralCreate: 0, + npcMode: 0, + showImgLevel: 0, + tournamentTrig: false, + joinMode: 'full', + }, + reason: 'new season only', + }); + + expect(harness.createdInputs[0]).toMatchObject({ + type: 'RESET', + sourceMode: 'COMMIT', + sourceRef: expect.stringMatching(/^[0-9a-f]{40}$/u), + reason: 'new season only', + }); + }); + + it('does not let a scenario-only operator combine a Git update with reset', async () => { + const harness = await buildCaller( + async () => { + throw new Error('not used'); + }, + { adminRoles: ['admin.scenarios.reset:che:2'], firstUserIsAdmin: false } + ); + + await expect( + harness.caller.admin.operations.requestReset({ + profileName: 'che:2', + sourceMode: 'BRANCH', + sourceRef: 'main', + install: { + scenarioId: 1010, + turnTermMinutes: 60, + sync: false, + fiction: 1, + extend: false, + blockGeneralCreate: 0, + npcMode: 0, + showImgLevel: 0, + tournamentTrig: false, + joinMode: 'full', + }, + }) + ).rejects.toMatchObject({ code: 'FORBIDDEN' }); + }); + + it('keeps runtime and DB-preserving deploy permissions independent', async () => { + const harness = await buildCaller( + async (input) => ({ + id: '66666666-6666-4666-8666-666666666666', + profileName: input.profileName, + type: input.type, + status: 'QUEUED', + payload: {}, + requestedBy: input.requestedBy, + createdAt: '2026-08-08T00:00:00.000Z', + updatedAt: '2026-08-08T00:00:00.000Z', + }), + { adminRoles: ['admin.profiles.runtime:che:2'], firstUserIsAdmin: false } + ); + + await expect( + harness.caller.admin.operations.requestRuntime({ profileName: 'che:2', action: 'START' }) + ).resolves.toMatchObject({ type: 'START' }); + await expect( + harness.caller.admin.operations.requestDeploy({ + profileName: 'che:2', + sourceMode: 'BRANCH', + sourceRef: 'main', + }) + ).rejects.toMatchObject({ code: 'FORBIDDEN' }); + }); + + it('lets a settings-only operator change profile policy without runtime control', async () => { + const harness = await buildCaller( + async () => { + throw new Error('not used'); + }, + { adminRoles: ['admin.profiles.settings:che:2'], firstUserIsAdmin: false } + ); + + await harness.caller.admin.profiles.updateMeta({ + profileName: 'che:2', + patch: { color: '#112233', localAccountAccessGraceDays: 14 }, + reason: 'profile policy delegation', + }); + expect(harness.updatedMetas.at(-1)).toMatchObject({ color: '#112233', localAccountAccessGraceDays: 14 }); + await expect( + harness.caller.admin.operations.requestRuntime({ profileName: 'che:2', action: 'STOP' }) + ).rejects.toMatchObject({ code: 'FORBIDDEN' }); + }); + + it('returns the authenticated profile scopes with the capability catalog', async () => { + const harness = await buildCaller( + async () => { + throw new Error('not used'); + }, + { adminRoles: ['admin.scenarios.reset:che:2'], firstUserIsAdmin: false } + ); + + await expect(harness.caller.admin.capabilities.list()).resolves.toContainEqual( + expect.objectContaining({ permission: 'admin.scenarios.reset', scopes: ['che:2'] }) + ); + }); }); describe('gateway release API', () => { @@ -637,7 +773,7 @@ describe('admin runtime clock action API', () => { }) ).rejects.toMatchObject({ code: 'BAD_REQUEST', - message: 'scheduledAt is supported only for scheduled reset.', + message: 'scheduledAt is supported only by operations.requestReset.', }); expect(harness.createdRuntimeActions).toEqual([]); }); @@ -954,10 +1090,9 @@ describe('Gateway administrator account controls', () => { }) ).resolves.toMatchObject({ id: grant.id, revokedReason: 'Kakao 인증 수단 복구 완료' }); expect(harness.flushes).toContainEqual({ userId: target.id, reason: 'admin-special-access-revoked' }); - expect(harness.auditEvents.filter((event) => event.outcome === 'SUCCEEDED').map((event) => event.action)).toEqual([ - 'admin.users.grantSpecialAccess', - 'admin.users.revokeSpecialAccess', - ]); + expect( + harness.auditEvents.filter((event) => event.outcome === 'SUCCEEDED').map((event) => event.action) + ).toEqual(['admin.users.grantSpecialAccess', 'admin.users.revokeSpecialAccess']); }); it('requires recovery access to expire within 90 days', async () => { diff --git a/app/gateway-frontend/e2e/admin-runtime-actions.spec.ts b/app/gateway-frontend/e2e/admin-runtime-actions.spec.ts index 247730b4..0e6430db 100644 --- a/app/gateway-frontend/e2e/admin-runtime-actions.spec.ts +++ b/app/gateway-frontend/e2e/admin-runtime-actions.spec.ts @@ -98,6 +98,38 @@ const installFixture = async ( risk: 'CRITICAL', scope: 'GLOBAL', }, + { + permission: 'admin.profiles.runtime', + label: 'Profile 실행 관리', + description: '실행 상태를 관리합니다.', + risk: 'HIGH', + scope: 'PROFILE', + scopes: ['*'], + }, + { + permission: 'admin.profiles.settings', + label: 'Profile 설정 관리', + description: '설정을 관리합니다.', + risk: 'HIGH', + scope: 'PROFILE', + scopes: ['*'], + }, + { + permission: 'admin.profiles.deploy', + label: 'Profile 버전 배포', + description: '버전을 배포합니다.', + risk: 'CRITICAL', + scope: 'PROFILE', + scopes: ['*'], + }, + { + permission: 'admin.scenarios.reset', + label: '시나리오 초기화', + description: '시나리오를 초기화합니다.', + risk: 'CRITICAL', + scope: 'PROFILE', + scopes: ['*'], + }, ]); } if (operation === 'admin.profiles.listScenarios') { @@ -212,7 +244,7 @@ const installFixture = async ( test('reports clock-shift acceptance separately from actual application', async ({ page }) => { const fixture = await installFixture(page, { deferRequest: true, pendingProfileReads: 1 }); await page.goto('admin/servers'); - await expect(page.getByRole('heading', { name: '서버 관리' })).toBeVisible(); + await expect(page.getByRole('heading', { name: '서버 관리', level: 1 })).toBeVisible(); const duration = page.locator('input[type="number"][min="1"][max="1440"]'); const accelerate = page.getByRole('button', { name: '가속', exact: true }); @@ -291,13 +323,13 @@ test('renders an ignored terminal outcome without calling it applied', async ({ await expect(page.getByText(/적용됨|요청 완료/)).toHaveCount(0); }); -test('directs profile deployment to the centralized version page', async ({ page }) => { +test('directs profile deployment to the selected server version tab', async ({ page }) => { await installFixture(page); await page.goto('admin/servers'); - const releaseLink = page.getByRole('link', { name: '버전 업데이트 열기' }); + const releaseLink = page.getByRole('link', { name: '버전 업데이트', exact: true }).last(); await expect(releaseLink).toBeVisible(); - await expect(releaseLink).toHaveAttribute('href', '/gateway/admin/releases'); + await expect(releaseLink).toHaveAttribute('href', '/gateway/admin/servers/hwe%3Adefault/version'); await expect(page.getByRole('button', { name: '설치 적용' })).toHaveCount(0); await page.setViewportSize({ width: 390, height: 844 }); diff --git a/app/gateway-frontend/e2e/general-icon-lifecycle.spec.ts b/app/gateway-frontend/e2e/general-icon-lifecycle.spec.ts index ad20ea31..461fd81a 100644 --- a/app/gateway-frontend/e2e/general-icon-lifecycle.spec.ts +++ b/app/gateway-frontend/e2e/general-icon-lifecycle.spec.ts @@ -27,8 +27,7 @@ const login = async (page: Page, username: string, password: string): Promise page.locator('tbody tr').filter({ hasText: /^hwe섭/ }); const resetScenario = async (page: Page, scenarioId: string, sourceCommit: string): Promise => { - await page.goto('/gateway/admin/releases'); - await page.getByTestId('profile-select').selectOption('hwe:2'); + await page.goto('/gateway/admin/servers/hwe%3A2/scenario'); await page.getByTestId('source-commit').check(); await page.getByTestId('source-ref').fill(sourceCommit); await page.getByTestId('load-scenarios').click(); diff --git a/app/gateway-frontend/e2e/hwe-lifecycle.spec.ts b/app/gateway-frontend/e2e/hwe-lifecycle.spec.ts index 6d6187f4..e5482c2d 100644 --- a/app/gateway-frontend/e2e/hwe-lifecycle.spec.ts +++ b/app/gateway-frontend/e2e/hwe-lifecycle.spec.ts @@ -85,10 +85,9 @@ test('admin resets and opens hwe, then two users create generals and reach main' await login(page, adminUsername, await readPassword('admin')); await page.getByRole('link', { name: '관리자 페이지' }).click(); await expect(page).toHaveURL(/\/gateway\/admin$/); - await page.getByRole('link', { name: '버전 업데이트' }).first().click(); - await expect(page).toHaveURL(/\/gateway\/admin\/releases$/); + await page.goto(`/gateway/admin/servers/${encodeURIComponent(profileKey)}/scenario`); + await expect(page).toHaveURL(/\/gateway\/admin\/servers\/.+\/scenario$/); - await page.getByTestId('profile-select').selectOption(profileKey); const profileStatus = page.getByTestId('selected-profile-status'); if (!skipReset) { await page.getByTestId('source-commit').check(); diff --git a/app/gateway-frontend/e2e/lobby-admin-navigation.spec.ts b/app/gateway-frontend/e2e/lobby-admin-navigation.spec.ts index 07fe6928..1d5dc275 100644 --- a/app/gateway-frontend/e2e/lobby-admin-navigation.spec.ts +++ b/app/gateway-frontend/e2e/lobby-admin-navigation.spec.ts @@ -28,13 +28,30 @@ const installGatewayFixture = async (page: Page, roles: string[]) => { } if ( operation === 'lobby.profiles' || - operation === 'admin.profiles.list' || operation === 'admin.profiles.listScenarios' || operation === 'admin.operations.list' || operation === 'admin.releases.list' ) { return response([]); } + if (operation === 'admin.profiles.list') { + return response( + roles.some((role) => role.includes(':hwe:2')) + ? [ + { + profileName: 'hwe:2', + profile: 'hwe', + scenario: '1010', + status: 'RUNNING', + buildStatus: 'SUCCEEDED', + meta: { korName: '환상서버' }, + runtime: {}, + runtimeActions: [], + }, + ] + : [] + ); + } if (operation === 'admin.releases.gatewayState') { return response({ id: 'gateway', updatedAt: '2026-08-01T00:00:00.000Z' }); } @@ -42,7 +59,26 @@ const installGatewayFixture = async (page: Page, roles: string[]) => { return response({ enabled: true }); } if (operation === 'admin.capabilities.list') { - return response([]); + return response( + roles.includes('superuser') + ? [ + { permission: 'admin.users.manage', scope: 'GLOBAL', scopes: ['*'] }, + { permission: 'admin.profiles.runtime', scope: 'PROFILE', scopes: ['*'] }, + { permission: 'admin.profiles.settings', scope: 'PROFILE', scopes: ['*'] }, + { permission: 'admin.profiles.deploy', scope: 'PROFILE', scopes: ['*'] }, + { permission: 'admin.scenarios.reset', scope: 'PROFILE', scopes: ['*'] }, + { permission: 'admin.releases.manage', scope: 'GLOBAL', scopes: ['*'] }, + { permission: 'admin.notice.manage', scope: 'GLOBAL', scopes: ['*'] }, + { permission: 'admin.audit.read', scope: 'GLOBAL', scopes: ['*'] }, + ] + : [ + { + permission: roles[0]?.split(':')[0], + scope: 'PROFILE', + scopes: ['hwe:2'], + }, + ] + ); } throw new Error(`Unhandled tRPC operation: ${operation}`); }); @@ -86,16 +122,16 @@ test('bootstrap superuser can navigate the administrator workspace from the lobb await writeFile(testInfo.outputPath('admin-overview-mobile-geometry.json'), JSON.stringify(geometry)); await page.screenshot({ path: testInfo.outputPath('admin-overview-mobile-menu.png'), fullPage: true }); - await navigation.getByRole('link', { name: '버전 업데이트' }).click(); + await navigation.getByRole('link', { name: 'Gateway 릴리스' }).click(); await expect(page).toHaveURL(/\/gateway\/admin\/releases$/); - await expect(page.getByRole('heading', { name: '버전 업데이트' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Gateway 릴리스' })).toBeVisible(); }); -test('legacy server operations URL keeps query parameters and redirects to releases', async ({ page }) => { +test('legacy server operations URL keeps query parameters and redirects to the server list', async ({ page }) => { await installGatewayFixture(page, ['superuser']); await page.goto('admin/server-operations?operationId=legacy-operation'); - await expect(page).toHaveURL(/\/gateway\/admin\/releases\?operationId=legacy-operation$/); + await expect(page).toHaveURL(/\/gateway\/admin\/servers\?operationId=legacy-operation$/); }); test('scoped administrators see the same navigation while ordinary users do not', async ({ browser }) => { @@ -104,6 +140,11 @@ test('scoped administrators see the same navigation while ordinary users do not' await installGatewayFixture(scopedPage, ['admin.profiles.manage:hwe:2']); await scopedPage.goto('lobby'); await expect(scopedPage.getByRole('link', { name: '관리자 페이지' })).toBeVisible(); + await scopedPage.getByRole('link', { name: '관리자 페이지' }).click(); + const scopedNavigation = scopedPage.getByRole('navigation', { name: '관리자 메뉴' }); + await expect(scopedNavigation.getByRole('link', { name: '환상서버 (hwe:2)' })).toBeVisible(); + await expect(scopedNavigation.getByRole('link', { name: 'Gateway 릴리스' })).toHaveCount(0); + await expect(scopedNavigation.getByRole('link', { name: '사용자 관리' })).toHaveCount(0); await scopedContext.close(); const userContext = await browser.newContext(); diff --git a/app/gateway-frontend/e2e/server-operations.spec.ts b/app/gateway-frontend/e2e/server-operations.spec.ts index b291be1d..aa456413 100644 --- a/app/gateway-frontend/e2e/server-operations.spec.ts +++ b/app/gateway-frontend/e2e/server-operations.spec.ts @@ -33,6 +33,7 @@ type FixtureState = { }>; runtimeRunning: boolean; requestBodies: Array<{ operation: string; body: unknown }>; + capabilities?: Array<{ permission: string; scope: 'GLOBAL' | 'PROFILE'; scopes: string[] }>; }; const profile = (runtimeRunning: boolean) => ({ @@ -99,6 +100,18 @@ const installFixture = async (page: Page, state: FixtureState) => { if (name === 'admin.profiles.list') { return response([profile(state.runtimeRunning)]); } + if (name === 'admin.capabilities.list') { + return response( + state.capabilities ?? [ + { permission: 'admin.profiles.runtime', scope: 'PROFILE', scopes: ['*'] }, + { permission: 'admin.profiles.settings', scope: 'PROFILE', scopes: ['*'] }, + { permission: 'admin.profiles.deploy', scope: 'PROFILE', scopes: ['*'] }, + { permission: 'admin.scenarios.reset', scope: 'PROFILE', scopes: ['*'] }, + { permission: 'admin.reset.schedule', scope: 'PROFILE', scopes: ['*'] }, + { permission: 'admin.releases.manage', scope: 'GLOBAL', scopes: ['*'] }, + ] + ); + } if (name === 'admin.operations.list') { return response(state.operations); } @@ -218,10 +231,11 @@ test('separates branch and commit semantics and submits a reset from the dedicat await installFixture(page, state); page.on('dialog', (dialog) => dialog.accept()); - await page.goto('admin/releases'); + await page.goto('admin/servers/che%3A2/scenario'); await expect(page.getByTestId('server-operations-page')).toBeVisible(); - await expect(page).toHaveURL(/\/gateway\/admin\/releases$/); - await expect(page.getByTestId('source-help')).toContainText('실제로 시작될 때'); + await expect(page).toHaveURL(/\/gateway\/admin\/servers\/che%3A2\/scenario$/); + await expect(page.getByTestId('source-current')).toBeChecked(); + await expect(page.getByTestId('source-help')).toContainText('현재 서버 커밋'); await expect(page.getByTestId('scenario-select')).toHaveValue('2'); const desktopGeometry = await page @@ -237,6 +251,7 @@ test('separates branch and commit semantics and submits a reset from the dedicat }); expect(desktopGeometry).toHaveLength(2); expect(desktopGeometry[1]!.x).toBeGreaterThan(desktopGeometry[0]!.x); + await page.getByTestId('source-commit').check(); const sourceInput = page.getByTestId('source-ref'); await sourceInput.focus(); const focusedInputStyle = await sourceInput.evaluate((element) => { @@ -256,7 +271,6 @@ test('separates branch and commit semantics and submits a reset from the dedicat ); await page.screenshot({ path: testInfo.outputPath('desktop-operations.png'), fullPage: true }); - await page.getByTestId('source-commit').check(); await expect(page.getByTestId('source-help')).toContainText('전체 SHA로 고정'); await page.getByTestId('source-ref').fill('0123456789abcdef0123456789abcdef01234567'); await page.getByTestId('load-scenarios').click(); @@ -288,31 +302,12 @@ test('separates branch and commit semantics and submits a reset from the dedicat await page.screenshot({ path: testInfo.outputPath('mobile-operations.png'), fullPage: true }); }); -test('starts and stops all runtime roles through the operation controls', async ({ page }) => { - const state: FixtureState = { operations: [], gatewayOperations: [], runtimeRunning: false, requestBodies: [] }; - await installFixture(page, state); - page.on('dialog', (dialog) => dialog.accept()); - - await page.goto('admin/releases'); - await page.getByTestId('start-server').click(); - await expect(page.getByText('시작 작업을 요청했습니다.')).toBeVisible(); - await expect(page.getByText('RUNNING', { exact: true }).first()).toBeVisible(); - - await page.getByTestId('stop-server').click(); - await expect(page.getByText('정지 작업을 요청했습니다.')).toBeVisible(); - await expect(page.getByText('STOPPED', { exact: true }).first()).toBeVisible(); - - const serializedRequests = state.requestBodies.map((entry) => JSON.stringify(entry.body)).join('\n'); - expect(serializedRequests).toContain('"action":"START"'); - expect(serializedRequests).toContain('"action":"STOP"'); -}); - test('separates DB-preserving profile deployment from DB reset', async ({ page }) => { const state: FixtureState = { operations: [], gatewayOperations: [], runtimeRunning: true, requestBodies: [] }; await installFixture(page, state); page.on('dialog', (dialog) => dialog.accept()); - await page.goto('admin/releases'); + await page.goto('admin/servers/che%3A2/version'); await expect(page.getByText('Game frontend')).toBeVisible(); await page.getByTestId('request-deploy').click(); @@ -322,6 +317,33 @@ test('separates DB-preserving profile deployment from DB reset', async ({ page } expect(state.requestBodies.some((entry) => entry.operation === 'admin.operations.requestReset')).toBe(false); }); +test('scenario-only operator resets the current version without Git or Gateway controls', async ({ page }) => { + const state: FixtureState = { + operations: [], + gatewayOperations: [], + runtimeRunning: true, + requestBodies: [], + capabilities: [{ permission: 'admin.scenarios.reset', scope: 'PROFILE', scopes: ['che:2'] }], + }; + await installFixture(page, state); + page.on('dialog', (dialog) => dialog.accept()); + + await page.goto('admin/servers/che%3A2/scenario'); + await expect(page.getByTestId('source-current')).toBeChecked(); + await expect(page.getByTestId('source-branch')).toHaveCount(0); + await expect(page.getByTestId('source-commit')).toHaveCount(0); + await expect(page.getByRole('link', { name: 'Gateway 릴리스' })).toHaveCount(0); + await page.getByTestId('request-reset').click(); + await expect(page.getByText('초기화 작업을 등록했습니다.')).toBeVisible(); + await expect + .poll(() => state.requestBodies.some((entry) => entry.operation === 'admin.operations.requestReset')) + .toBe(true); + + const request = state.requestBodies.find((entry) => entry.operation === 'admin.operations.requestReset'); + expect(JSON.stringify(request?.body)).toContain('"sourceMode":"CURRENT"'); + expect(JSON.stringify(request?.body)).not.toContain('"sourceRef"'); +}); + test('controls gateway deployment and rollback through the external controller queue', async ({ page }, testInfo) => { const state: FixtureState = { operations: [], gatewayOperations: [], runtimeRunning: true, requestBodies: [] }; await installFixture(page, state); @@ -375,7 +397,7 @@ test('renders a failed reset, retries it as a new operation, and reaches success await installFixture(page, state); page.on('dialog', (dialog) => dialog.accept()); - await page.goto('admin/releases'); + await page.goto('admin/servers/che%3A2/scenario'); await expect(page.getByText('FAILED', { exact: true })).toBeVisible(); await expect(page.getByRole('cell', { name: 'fedcba987654', exact: true })).toBeVisible(); const failure = page.getByText(longError); diff --git a/app/gateway-frontend/src/layouts/AdminConsoleLayout.vue b/app/gateway-frontend/src/layouts/AdminConsoleLayout.vue index 83fb468e..5299e1c5 100644 --- a/app/gateway-frontend/src/layouts/AdminConsoleLayout.vue +++ b/app/gateway-frontend/src/layouts/AdminConsoleLayout.vue @@ -1,6 +1,8 @@