From b49332f5b96fc3dfca7480a2553741e6f9dcc06f Mon Sep 17 00:00:00 2001 From: hided62 Date: Mon, 17 Aug 2026 16:07:46 +0000 Subject: [PATCH] =?UTF-8?q?fix(gateway):=20Profile=20=ED=8F=AC=EA=B4=84=20?= =?UTF-8?q?=EC=9A=B4=EC=98=81=20=EA=B6=8C=ED=95=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/game-api/src/router/turnDaemon/index.ts | 6 +- app/game-api/test/router.test.ts | 20 +++ app/gateway-api/src/adminCapabilities.ts | 24 ++-- app/gateway-api/src/adminRouter.ts | 132 +++++++++--------- app/gateway-api/test/adminOperations.test.ts | 27 +++- app/gateway-api/test/releaseManifest.test.ts | 4 +- .../e2e/admin-account-controls.spec.ts | 10 +- .../e2e/lobby-admin-navigation.spec.ts | 2 +- app/gateway-frontend/src/views/AdminView.vue | 2 +- .../src/views/ServerOperationsView.vue | 2 +- docs/admin-console.md | 1 - docs/release-operations.md | 2 +- .../migration.sql | 45 ++++++ release-manifest.json | 4 +- tools/legacy-db-migration/src/transform.ts | 31 ++-- .../test/transform.test.ts | 16 +++ 16 files changed, 220 insertions(+), 108 deletions(-) create mode 100644 packages/infra/prisma/gateway-migrations/20260817000000_remove_profile_manage_capability/migration.sql diff --git a/app/game-api/src/router/turnDaemon/index.ts b/app/game-api/src/router/turnDaemon/index.ts index 5ba45d6a..4f425289 100644 --- a/app/game-api/src/router/turnDaemon/index.ts +++ b/app/game-api/src/router/turnDaemon/index.ts @@ -18,9 +18,9 @@ const turnDaemonAdminProcedure = authedProcedure.use(({ ctx, next }) => { roles.includes('superuser') || roles.includes('admin') || roles.includes('admin.superuser') || - roles.includes('admin.profiles.manage') || - roles.includes('admin.profiles.manage:*') || - roles.includes(`admin.profiles.manage:${profileName}`); + roles.includes('admin.profiles.runtime') || + roles.includes('admin.profiles.runtime:*') || + roles.includes(`admin.profiles.runtime:${profileName}`); if (!canManageProfile) { throw new TRPCError({ code: 'FORBIDDEN', diff --git a/app/game-api/test/router.test.ts b/app/game-api/test/router.test.ts index 7de59fe7..13460138 100644 --- a/app/game-api/test/router.test.ts +++ b/app/game-api/test/router.test.ts @@ -803,6 +803,26 @@ describe('appRouter', () => { }); }); + it('accepts the scoped profile runtime permission for turn daemon control', async () => { + const auth = buildAuth(); + auth.user.roles = ['admin.profiles.runtime:che:default']; + const caller = appRouter.createCaller(buildContext({ auth })); + + await expect(caller.turnDaemon.run({ reason: 'manual' })).resolves.toMatchObject({ accepted: true }); + await expect(caller.turnDaemon.pause()).resolves.toMatchObject({ accepted: true }); + await expect(caller.turnDaemon.resume()).resolves.toMatchObject({ accepted: true }); + await expect(caller.turnDaemon.status()).resolves.toBeDefined(); + }); + + it('rejects the removed umbrella profile permission for turn daemon control', async () => { + const auth = buildAuth(); + auth.user.roles = ['admin.profiles.manage:che:default']; + const caller = appRouter.createCaller(buildContext({ auth })); + + await expect(caller.turnDaemon.run({ reason: 'manual' })).rejects.toMatchObject({ code: 'FORBIDDEN' }); + await expect(caller.turnDaemon.status()).rejects.toMatchObject({ code: 'FORBIDDEN' }); + }); + it('rejects unauthenticated turn daemon control', async () => { const caller = appRouter.createCaller(buildContext({ auth: null })); diff --git a/app/gateway-api/src/adminCapabilities.ts b/app/gateway-api/src/adminCapabilities.ts index fc72ace6..3f6155cf 100644 --- a/app/gateway-api/src/adminCapabilities.ts +++ b/app/gateway-api/src/adminCapabilities.ts @@ -38,13 +38,6 @@ export const ADMIN_CAPABILITIES: readonly AdminCapabilityDefinition[] = [ risk: 'HIGH', scope: 'GLOBAL', }, - { - permission: 'admin.profiles.manage', - label: 'Profile 전체 운영 (호환)', - description: '기존 운영자를 위한 포괄 권한입니다. 새 역할에는 세분화 권한을 사용합니다.', - risk: 'CRITICAL', - scope: 'PROFILE', - }, { permission: 'admin.profiles.runtime', label: 'Profile 실행 관리', @@ -125,17 +118,30 @@ export const resolveAdminActionCapability = (path: string, rawInput?: unknown): if (action === 'RESET_SCHEDULED') return 'admin.reset.schedule'; if (action === 'RESUME') return 'admin.resume.when-stopped'; if (action === 'OPEN_SURVEY') return 'admin.survey.open'; + return 'admin.profiles.runtime'; } 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.upsert') || path.endsWith('.profiles.updateMeta')) return 'admin.profiles.settings'; + if (path.endsWith('.profiles.setStatus') || path.endsWith('.profiles.reconcileNow')) { + return 'admin.profiles.runtime'; + } + if (path.endsWith('.profiles.install') || path.endsWith('.profiles.installNow')) { + return 'admin.scenarios.reset'; + } + if ( + path.endsWith('.profiles.requestBuild') || + path.endsWith('.profiles.setBuildStatus') || + path.endsWith('.profiles.cleanupWorkspaces') + ) { + return 'admin.profiles.deploy'; + } 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 66ec86f1..a55df86b 100644 --- a/app/gateway-api/src/adminRouter.ts +++ b/app/gateway-api/src/adminRouter.ts @@ -55,7 +55,6 @@ const ADMIN_ROLE_SUPERUSER = 'admin.superuser'; 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'; @@ -152,21 +151,6 @@ 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) { @@ -240,6 +224,16 @@ const assertPermission = (adminAuth: AdminAuthContext, permission: string, profi }); }; +const assertAllPermissions = ( + adminAuth: AdminAuthContext, + permissions: readonly string[], + profileName?: string +): void => { + for (const permission of permissions) { + assertPermission(adminAuth, permission, profileName); + } +}; + const assertTargetUserManageable = (adminAuth: AdminAuthContext, target: { id: string; roles: string[] }): void => { if (!adminAuth.isSuperuser && target.roles.some(isRootAdminRole)) { throw new TRPCError({ @@ -373,12 +367,6 @@ const userCreateProcedure = adminProcedure.use(({ ctx, next }) => { return next(); }); -const profileAdminProcedure = adminProcedure.use(({ ctx, next }) => { - const adminAuth = requireAdminAuth(ctx); - assertPermission(adminAuth, ROLE_ADMIN_PROFILES); - return next(); -}); - const releaseAdminProcedure = adminProcedure.use(({ ctx, next }) => { const adminAuth = requireAdminAuth(ctx); assertPermission(adminAuth, ROLE_ADMIN_RELEASES); @@ -1140,12 +1128,12 @@ export const adminRouter = router({ ) .mutation(async ({ ctx, input }) => { const adminAuth = requireAdminAuth(ctx); - assertAnyPermission(adminAuth, [ROLE_ADMIN_PROFILES, ROLE_ADMIN_SCENARIO_RESET], input.profileName); + assertPermission(adminAuth, ROLE_ADMIN_SCENARIO_RESET, input.profileName); if (input.sourceMode !== 'CURRENT') { - assertAnyPermission(adminAuth, [ROLE_ADMIN_PROFILES, ROLE_ADMIN_PROFILE_DEPLOY], input.profileName); + assertPermission(adminAuth, ROLE_ADMIN_PROFILE_DEPLOY, input.profileName); } if (input.scheduledAt) { - assertAnyPermission(adminAuth, [ROLE_ADMIN_PROFILES, ROLE_RESET_SCHEDULE], input.profileName); + assertPermission(adminAuth, ROLE_RESET_SCHEDULE, input.profileName); } const profile = await ctx.profiles.getProfile(input.profileName); if (!profile) { @@ -1266,7 +1254,7 @@ export const adminRouter = router({ ) .mutation(async ({ ctx, input }) => { const adminAuth = requireAdminAuth(ctx); - assertAnyPermission(adminAuth, [ROLE_ADMIN_PROFILES, ROLE_ADMIN_PROFILE_DEPLOY], input.profileName); + assertPermission(adminAuth, 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.' }); @@ -1322,7 +1310,7 @@ export const adminRouter = router({ ) .mutation(async ({ ctx, input }) => { const adminAuth = requireAdminAuth(ctx); - assertAnyPermission(adminAuth, [ROLE_ADMIN_PROFILES, ROLE_ADMIN_PROFILE_RUNTIME], input.profileName); + assertPermission(adminAuth, 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.' }); @@ -1351,13 +1339,13 @@ export const adminRouter = router({ if (!previous) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Operation not found.' }); } - const permissions = + const permission = previous.type === 'RESET' - ? [ROLE_ADMIN_PROFILES, ROLE_ADMIN_SCENARIO_RESET] + ? 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); + ? ROLE_ADMIN_PROFILE_DEPLOY + : ROLE_ADMIN_PROFILE_RUNTIME; + assertPermission(adminAuth, permission, previous.profileName); const cancelled = await ctx.profiles.cancelOperation(input.id); if (!cancelled) { throw new TRPCError({ @@ -1373,24 +1361,20 @@ export const adminRouter = router({ if (!previous) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Operation not found.' }); } - const permissions = + const permission = previous.type === 'RESET' - ? [ROLE_ADMIN_PROFILES, ROLE_ADMIN_SCENARIO_RESET] + ? 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); + ? ROLE_ADMIN_PROFILE_DEPLOY + : ROLE_ADMIN_PROFILE_RUNTIME; + assertPermission(adminAuth, permission, 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 - ); + assertPermission(adminAuth, ROLE_ADMIN_PROFILE_DEPLOY, previous.profileName); } if (previous.scheduledAt) { - assertAnyPermission(adminAuth, [ROLE_ADMIN_PROFILES, ROLE_RESET_SCHEDULE], previous.profileName); + assertPermission(adminAuth, ROLE_RESET_SCHEDULE, previous.profileName); } } try { @@ -1568,7 +1552,7 @@ export const adminRouter = router({ .input(z.object({ profileName: z.string().min(1) })) .query(async ({ ctx, input }) => { const adminAuth = requireAdminAuth(ctx); - assertAnyPermission(adminAuth, [ROLE_ADMIN_PROFILES, ROLE_ADMIN_SCENARIO_RESET], input.profileName); + assertPermission(adminAuth, 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.' }); @@ -1660,11 +1644,7 @@ export const adminRouter = router({ throw new TRPCError({ code: 'BAD_REQUEST', message: 'profileName is required.' }); } } else { - assertAnyPermission( - adminAuth, - [ROLE_ADMIN_PROFILES, ROLE_ADMIN_SCENARIO_RESET], - input.profileName - ); + assertPermission(adminAuth, 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.' }); const parsedScenarioId = @@ -1679,9 +1659,9 @@ export const adminRouter = router({ } } } else if (input?.profileName) { - assertAnyPermission(adminAuth, [ROLE_ADMIN_PROFILES, ROLE_ADMIN_PROFILE_DEPLOY], input.profileName); + assertPermission(adminAuth, ROLE_ADMIN_PROFILE_DEPLOY, input.profileName); } else { - assertAnyPermission(adminAuth, [ROLE_ADMIN_PROFILES, ROLE_ADMIN_PROFILE_DEPLOY]); + assertPermission(adminAuth, ROLE_ADMIN_PROFILE_DEPLOY); } const scenarios = !gitRef ? await listScenarioPreviews() @@ -1696,7 +1676,7 @@ export const adminRouter = router({ isCurrent: currentScenarioId === scenario.id, })); }), - upsert: profileAdminProcedure + upsert: adminProcedure .input( z.object({ profile: z.string().regex(/^[a-z0-9-]{1,32}$/), @@ -1715,6 +1695,12 @@ export const adminRouter = router({ }) ) .mutation(async ({ ctx, input }) => { + assertAllPermissions(requireAdminAuth(ctx), [ + ROLE_ADMIN_PROFILE_RUNTIME, + ROLE_ADMIN_PROFILE_SETTINGS, + ROLE_ADMIN_PROFILE_DEPLOY, + ROLE_ADMIN_SCENARIO_RESET, + ]); const status = input.status ?? 'STOPPED'; return ctx.profiles.upsertProfile({ profile: input.profile, @@ -1729,7 +1715,7 @@ export const adminRouter = router({ buildCommitSha: input.buildCommitSha, }); }), - setStatus: profileAdminProcedure + setStatus: adminProcedure .input( z.object({ profileName: z.string().min(1), @@ -1741,6 +1727,11 @@ export const adminRouter = router({ }) ) .mutation(async ({ ctx, input }) => { + const adminAuth = requireAdminAuth(ctx); + assertPermission(adminAuth, ROLE_ADMIN_PROFILE_RUNTIME); + if (input.buildCommitSha) { + assertPermission(adminAuth, ROLE_ADMIN_PROFILE_DEPLOY); + } if (input.status === 'RESERVED' && (!input.preopenAt || !input.openAt)) { throw new TRPCError({ code: 'BAD_REQUEST', @@ -1778,11 +1769,7 @@ export const adminRouter = router({ }) ) .mutation(async ({ ctx, input }) => { - assertAnyPermission( - requireAdminAuth(ctx), - [ROLE_ADMIN_PROFILES, ROLE_ADMIN_PROFILE_SETTINGS], - input.profileName - ); + assertPermission(requireAdminAuth(ctx), ROLE_ADMIN_PROFILE_SETTINGS, input.profileName); const profile = await ctx.profiles.getProfile(input.profileName); if (!profile) { throw new TRPCError({ @@ -1794,7 +1781,7 @@ export const adminRouter = router({ const nextMeta = applyMetaPatch(meta, input.patch); return ctx.profiles.updateMeta(input.profileName, nextMeta); }), - install: profileAdminProcedure + install: adminProcedure .input( z.object({ profileName: z.string().min(1), @@ -1804,6 +1791,7 @@ export const adminRouter = router({ ) .mutation(async ({ ctx, input }) => { const adminAuth = requireAdminAuth(ctx); + assertAllPermissions(adminAuth, [ROLE_ADMIN_SCENARIO_RESET, ROLE_ADMIN_PROFILE_DEPLOY]); const profile = await ctx.profiles.getProfile(input.profileName); if (!profile) { throw new TRPCError({ @@ -1879,6 +1867,9 @@ export const adminRouter = router({ } const scheduledAt = openAt ? (preopenAt ?? openAt).toISOString() : null; + if (scheduledAt) { + assertPermission(adminAuth, ROLE_RESET_SCHEDULE); + } const action = scheduledAt ? 'RESET_SCHEDULED' : 'RESET_NOW'; const actionRecord = { action, @@ -1926,7 +1917,7 @@ export const adminRouter = router({ }); } }), - installNow: profileAdminProcedure + installNow: adminProcedure .input( z.object({ profileName: z.string().min(1), @@ -1936,6 +1927,7 @@ export const adminRouter = router({ ) .mutation(async ({ ctx, input }) => { const adminAuth = requireAdminAuth(ctx); + assertAllPermissions(adminAuth, [ROLE_ADMIN_SCENARIO_RESET, ROLE_ADMIN_PROFILE_DEPLOY]); const profile = await ctx.profiles.getProfile(input.profileName); if (!profile) { throw new TRPCError({ @@ -2046,9 +2038,9 @@ export const adminRouter = router({ }); } - const canManageProfiles = hasAnyScopedPermission( + const canManageProfiles = hasScopedPermission( adminAuth, - [ROLE_ADMIN_PROFILES, ROLE_ADMIN_PROFILE_RUNTIME], + ROLE_ADMIN_PROFILE_RUNTIME, profile.profileName ); const canResume = @@ -2172,7 +2164,7 @@ export const adminRouter = router({ } return { ok: true, action: actionRecord }; }), - requestBuild: profileAdminProcedure + requestBuild: adminProcedure .input( z.object({ profileName: z.string().min(1), @@ -2180,6 +2172,7 @@ export const adminRouter = router({ }) ) .mutation(async ({ ctx, input }) => { + assertPermission(requireAdminAuth(ctx), ROLE_ADMIN_PROFILE_DEPLOY); const requestedAt = new Date().toISOString(); const result = await ctx.profiles.updateBuildStatus(input.profileName, 'QUEUED', { requestedAt, @@ -2188,19 +2181,24 @@ export const adminRouter = router({ }); return result; }), - setBuildStatus: profileAdminProcedure + setBuildStatus: adminProcedure .input( z.object({ profileName: z.string().min(1), status: zBuildStatus, }) ) - .mutation(async ({ ctx, input }) => ctx.profiles.updateBuildStatus(input.profileName, input.status)), - reconcileNow: profileAdminProcedure.mutation(async ({ ctx }) => { + .mutation(async ({ ctx, input }) => { + assertPermission(requireAdminAuth(ctx), ROLE_ADMIN_PROFILE_DEPLOY); + return ctx.profiles.updateBuildStatus(input.profileName, input.status); + }), + reconcileNow: adminProcedure.mutation(async ({ ctx }) => { + assertPermission(requireAdminAuth(ctx), ROLE_ADMIN_PROFILE_RUNTIME); await ctx.orchestrator.reconcileNow(); return { ok: true }; }), - cleanupWorkspaces: profileAdminProcedure.mutation(async ({ ctx }) => { + cleanupWorkspaces: adminProcedure.mutation(async ({ ctx }) => { + assertPermission(requireAdminAuth(ctx), ROLE_ADMIN_PROFILE_DEPLOY); const result = await ctx.orchestrator.cleanupStaleWorkspaces(); return { removed: result.removed, diff --git a/app/gateway-api/test/adminOperations.test.ts b/app/gateway-api/test/adminOperations.test.ts index d31e599d..528243b2 100644 --- a/app/gateway-api/test/adminOperations.test.ts +++ b/app/gateway-api/test/adminOperations.test.ts @@ -357,7 +357,7 @@ describe('admin profile navigation API', () => { async () => { throw new Error('not used'); }, - { adminRoles: ['admin.profiles.manage:che:2'], firstUserIsAdmin: false } + { adminRoles: ['admin.profiles.settings:che:2'], firstUserIsAdmin: false } ); await expect(harness.caller.admin.profiles.listNavigation()).resolves.toEqual([ @@ -737,9 +737,11 @@ describe('admin operation API', () => { { adminRoles: ['admin.scenarios.reset:che:2'], firstUserIsAdmin: false } ); - await expect(harness.caller.admin.capabilities.list()).resolves.toContainEqual( + const capabilities = await harness.caller.admin.capabilities.list(); + expect(capabilities).toContainEqual( expect.objectContaining({ permission: 'admin.scenarios.reset', scopes: ['che:2'] }) ); + expect(capabilities).not.toContainEqual(expect.objectContaining({ permission: 'admin.profiles.manage' })); }); }); @@ -902,7 +904,7 @@ describe('gateway release API', () => { async () => { throw new Error('not used'); }, - { adminRoles: ['admin.profiles.manage:che:2'], firstUserIsAdmin: false } + { adminRoles: ['admin.profiles.runtime:che:2'], firstUserIsAdmin: false } ); await expect(harness.caller.admin.releases.gatewayState()).rejects.toMatchObject({ code: 'FORBIDDEN' }); @@ -1459,6 +1461,25 @@ describe('Gateway administrator account controls', () => { expect((await harness.users.findById(target.id))?.roles).toEqual(['user']); }); + it('rejects the removed umbrella profile capability', async () => { + const harness = await buildCaller(unusedCreateOperation); + const target = await harness.users.createUser({ + username: 'removed-profile-capability-target', + password: 'secretpass', + displayName: 'Removed Profile Capability Target', + }); + + await expect( + harness.caller.admin.users.updateRoles({ + userId: target.id, + roles: ['admin.profiles.manage:che:default'], + mode: 'grant', + reason: '제거한 포괄 권한 거부 확인', + }) + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + expect((await harness.users.findById(target.id))?.roles).toEqual(['user']); + }); + it('extends an unverified local account grace period and flushes active sessions', async () => { const harness = await buildCaller(unusedCreateOperation); const target = await harness.users.createUser({ diff --git a/app/gateway-api/test/releaseManifest.test.ts b/app/gateway-api/test/releaseManifest.test.ts index b707a5d7..5d4ff871 100644 --- a/app/gateway-api/test/releaseManifest.test.ts +++ b/app/gateway-api/test/releaseManifest.test.ts @@ -38,8 +38,8 @@ describe('readReleaseManifest', () => { await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({ controllerProtocol: RELEASE_CONTROLLER_PROTOCOL, - gatewaySchemaHead: '20260813000000_split_gateway_profile_identity', - gameSchemaHead: '20260816000000_add_read_model_change_journal', + gatewaySchemaHead: '20260817000000_remove_profile_manage_capability', + gameSchemaHead: '20260817000000_add_general_access_batch', }); }); diff --git a/app/gateway-frontend/e2e/admin-account-controls.spec.ts b/app/gateway-frontend/e2e/admin-account-controls.spec.ts index f3c586d3..cdb45e28 100644 --- a/app/gateway-frontend/e2e/admin-account-controls.spec.ts +++ b/app/gateway-frontend/e2e/admin-account-controls.spec.ts @@ -51,10 +51,10 @@ const installFixture = async (page: Page) => { scope: 'GLOBAL', }, { - permission: 'admin.profiles.manage', - label: 'Profile 운영', - description: '지정 profile을 관리합니다.', - risk: 'CRITICAL', + permission: 'admin.profiles.runtime', + label: 'Profile 실행 관리', + description: '지정 profile의 시작, 정지와 실행 상태를 관리합니다.', + risk: 'HIGH', scope: 'PROFILE', }, { @@ -217,6 +217,8 @@ test('operates OAuth grace and scheduled deletion with reasoned audit history', await expect(page.getByRole('navigation', { name: '사용자 관리 기능' })).toBeVisible(); await expect(page.getByRole('heading', { name: '비밀번호 리셋' })).toBeHidden(); await page.getByRole('button', { name: /접근 · 권한/ }).click(); + await expect(page.getByRole('option', { name: /Profile 전체 운영/ })).toHaveCount(0); + await expect(page.getByRole('option', { name: /Profile 실행 관리/ })).toHaveCount(1); await expect(page.getByRole('cell', { name: 'che:default' })).toBeVisible(); await page.screenshot({ path: testInfo.outputPath('gateway-admin-account-controls-desktop.png'), fullPage: true }); await page.getByPlaceholder('권한·제재·복구·탈퇴 조치 사유 (필수)').fill('본인 확인 처리 중'); diff --git a/app/gateway-frontend/e2e/lobby-admin-navigation.spec.ts b/app/gateway-frontend/e2e/lobby-admin-navigation.spec.ts index 0fe291fd..923bd48b 100644 --- a/app/gateway-frontend/e2e/lobby-admin-navigation.spec.ts +++ b/app/gateway-frontend/e2e/lobby-admin-navigation.spec.ts @@ -219,7 +219,7 @@ test('legacy server operations URL keeps query parameters and redirects to the s test('scoped administrators see the same navigation while ordinary users do not', async ({ browser }) => { const scopedContext = await browser.newContext(); const scopedPage = await scopedContext.newPage(); - await installGatewayFixture(scopedPage, ['admin.profiles.manage:hwe:2']); + await installGatewayFixture(scopedPage, ['admin.profiles.runtime:hwe:2']); await scopedPage.goto('lobby'); await expect(scopedPage.getByRole('link', { name: '관리자 페이지' })).toBeVisible(); await scopedPage.getByRole('link', { name: '관리자 페이지' }).click(); diff --git a/app/gateway-frontend/src/views/AdminView.vue b/app/gateway-frontend/src/views/AdminView.vue index 3d86765f..4b0b1fec 100644 --- a/app/gateway-frontend/src/views/AdminView.vue +++ b/app/gateway-frontend/src/views/AdminView.vue @@ -503,7 +503,7 @@ const rolesStatus = ref(''); const capabilities = ref([]); const hasCapability = (permission: string, profileName?: string): boolean => capabilities.value.some((entry) => { - if (entry.permission !== permission && entry.permission !== 'admin.profiles.manage') return false; + if (entry.permission !== permission) return false; if (!profileName || entry.scope === 'GLOBAL') return true; return !entry.scopes?.length || entry.scopes.includes('*') || entry.scopes.includes(profileName); }); diff --git a/app/gateway-frontend/src/views/ServerOperationsView.vue b/app/gateway-frontend/src/views/ServerOperationsView.vue index c01bdffe..78dcc675 100644 --- a/app/gateway-frontend/src/views/ServerOperationsView.vue +++ b/app/gateway-frontend/src/views/ServerOperationsView.vue @@ -183,7 +183,7 @@ const gatewayReleaseLogEmptyMessage = computed(() => { }); const hasCapability = (permission: string): boolean => capabilities.value.some((entry) => { - if (entry.permission !== permission && entry.permission !== 'admin.profiles.manage') return false; + if (entry.permission !== permission) return false; if (!props.profileName) return true; return !entry.scopes?.length || entry.scopes.includes('*') || entry.scopes.includes(props.profileName); }); diff --git a/docs/admin-console.md b/docs/admin-console.md index ca3fab33..a0cb95e4 100644 --- a/docs/admin-console.md +++ b/docs/admin-console.md @@ -75,7 +75,6 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로 | `admin.profiles.deploy:` | DB를 유지하는 Git 버전 업데이트, 초기화와 새 버전 결합 | | `admin.scenarios.reset:` | 현재 배포 버전으로 시나리오 초기화 | | `admin.reset.schedule:` | 허용된 시나리오 초기화를 미래 시각에 예약 | -| `admin.profiles.manage:` | 기존 역할 호환용 포괄 권한 | | `admin.releases.manage` | profile과 분리된 Gateway control plane 배포·rollback | 기존 상태 화면의 `즉시 리셋`·`리셋 예약` 버튼은 실제 DB 초기화 operation과 diff --git a/docs/release-operations.md b/docs/release-operations.md index 35f16966..ca78de0b 100644 --- a/docs/release-operations.md +++ b/docs/release-operations.md @@ -16,7 +16,7 @@ Profile 화면은 `/gateway/admin/servers/:profileName/version`과 `/gateway/admin/servers/:profileName/scenario`, Gateway 화면은 `/gateway/admin/releases`입니다. 이전 `/gateway/admin/server-operations`는 호환성을 위해 서버 목록으로 이동합니다. Profile 작업은 runtime/settings/deploy/reset -capability로 분리되며 기존 `admin.profiles.manage`는 포괄 호환 권한입니다. Gateway 전체 릴리스에는 +capability로 분리되며 포괄 운영 권한은 사용하지 않습니다. Gateway 전체 릴리스에는 profile 범위 권한과 별개인 전역 `admin.releases.manage` 권한이 필요합니다. 일반 사용자와 권한이 없는 관리자는 Gateway 릴리스 영역을 사용할 수 없습니다. diff --git a/packages/infra/prisma/gateway-migrations/20260817000000_remove_profile_manage_capability/migration.sql b/packages/infra/prisma/gateway-migrations/20260817000000_remove_profile_manage_capability/migration.sql new file mode 100644 index 00000000..b7cbe8ab --- /dev/null +++ b/packages/infra/prisma/gateway-migrations/20260817000000_remove_profile_manage_capability/migration.sql @@ -0,0 +1,45 @@ +-- Replace the former umbrella profile role with the explicit capabilities that +-- preserve its effective operation scope. New grants use only these individual +-- roles, and the update is idempotent because the legacy role is removed. +UPDATE "app_user" AS "user_row" +SET "roles" = ( + SELECT COALESCE( + jsonb_agg(to_jsonb("expanded"."role") ORDER BY "expanded"."role"), + '[]'::jsonb + ) + FROM ( + SELECT DISTINCT "replacement"."role" + FROM jsonb_array_elements_text("user_row"."roles") AS "source"("role") + CROSS JOIN LATERAL unnest( + CASE + WHEN "source"."role" = 'admin.profiles.manage' + OR "source"."role" LIKE 'admin.profiles.manage:%' + THEN ARRAY[ + 'admin.profiles.runtime' || substring( + "source"."role" FROM char_length('admin.profiles.manage') + 1 + ), + 'admin.profiles.settings' || substring( + "source"."role" FROM char_length('admin.profiles.manage') + 1 + ), + 'admin.profiles.deploy' || substring( + "source"."role" FROM char_length('admin.profiles.manage') + 1 + ), + 'admin.scenarios.reset' || substring( + "source"."role" FROM char_length('admin.profiles.manage') + 1 + ), + 'admin.reset.schedule' || substring( + "source"."role" FROM char_length('admin.profiles.manage') + 1 + ) + ] + ELSE ARRAY["source"."role"] + END + ) AS "replacement"("role") + ) AS "expanded" +) +WHERE jsonb_typeof("user_row"."roles") = 'array' + AND EXISTS ( + SELECT 1 + FROM jsonb_array_elements_text("user_row"."roles") AS "existing"("role") + WHERE "existing"."role" = 'admin.profiles.manage' + OR "existing"."role" LIKE 'admin.profiles.manage:%' + ); diff --git a/release-manifest.json b/release-manifest.json index e5fda16b..688b0124 100644 --- a/release-manifest.json +++ b/release-manifest.json @@ -1,7 +1,7 @@ { "formatVersion": 1, "controllerProtocol": 2, - "gatewaySchemaHead": "20260813000000_split_gateway_profile_identity", - "gameSchemaHead": "20260816000000_add_read_model_change_journal", + "gatewaySchemaHead": "20260817000000_remove_profile_manage_capability", + "gameSchemaHead": "20260817000000_add_general_access_batch", "components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"] } diff --git a/tools/legacy-db-migration/src/transform.ts b/tools/legacy-db-migration/src/transform.ts index bf3cef22..e1826362 100644 --- a/tools/legacy-db-migration/src/transform.ts +++ b/tools/legacy-db-migration/src/transform.ts @@ -33,15 +33,15 @@ const asObject = (value: JsonValue): Record => const asStringArray = (value: JsonValue | undefined): string[] => Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []; -const legacyAclRoleMap: Record = { - openClose: 'admin.profiles.manage', - reset: 'admin.reset.schedule', - update: 'admin.profiles.manage', - fullUpdate: 'admin.profiles.manage', - vote: 'admin.survey.open', - globalNotice: 'admin.notice.manage', - notice: 'admin.notice.manage', - blockGeneral: 'admin.users.manage', +const legacyAclRoleMap: Record = { + openClose: ['admin.profiles.runtime'], + reset: ['admin.scenarios.reset', 'admin.reset.schedule'], + update: ['admin.profiles.deploy'], + fullUpdate: ['admin.profiles.deploy'], + vote: ['admin.survey.open'], + globalNotice: ['admin.notice.manage'], + notice: ['admin.notice.manage'], + blockGeneral: ['admin.users.manage'], }; export const mapLegacyRoles = (grade: number, rawAcl: JsonValue): string[] => { @@ -49,7 +49,10 @@ export const mapLegacyRoles = (grade: number, rawAcl: JsonValue): string[] => { if (grade >= 7) { roles.add('superuser'); } else if (grade === 6) { - roles.add('admin.profiles.manage'); + roles.add('admin.profiles.runtime'); + roles.add('admin.profiles.settings'); + roles.add('admin.profiles.deploy'); + roles.add('admin.scenarios.reset'); roles.add('admin.notice.manage'); roles.add('admin.reset.schedule'); roles.add('admin.resume.when-stopped'); @@ -60,9 +63,11 @@ export const mapLegacyRoles = (grade: number, rawAcl: JsonValue): string[] => { for (const [profile, permissions] of Object.entries(asObject(rawAcl))) { for (const permission of asStringArray(permissions)) { - const mapped = legacyAclRoleMap[permission]; - if (mapped) { - roles.add(`${mapped}:${profile}:default`); + const mappedRoles = legacyAclRoleMap[permission]; + if (mappedRoles) { + for (const mappedRole of mappedRoles) { + roles.add(`${mappedRole}:${profile}:default`); + } } else { roles.add(`legacy.acl.${permission}:${profile}`); } diff --git a/tools/legacy-db-migration/test/transform.test.ts b/tools/legacy-db-migration/test/transform.test.ts index 6707e0cd..d7541ef5 100644 --- a/tools/legacy-db-migration/test/transform.test.ts +++ b/tools/legacy-db-migration/test/transform.test.ts @@ -16,6 +16,22 @@ describe('legacy database transforms', () => { expect(mapLegacyRoles(1, { che: ['reset', 'notice'] })).toEqual([ 'admin.notice.manage:che:default', 'admin.reset.schedule:che:default', + 'admin.scenarios.reset:che:default', + 'user', + ]); + expect(mapLegacyRoles(1, { hwe: ['openClose', 'update', 'fullUpdate'] })).toEqual([ + 'admin.profiles.deploy:hwe:default', + 'admin.profiles.runtime:hwe:default', + 'user', + ]); + expect(mapLegacyRoles(6, {})).toEqual([ + 'admin.notice.manage', + 'admin.profiles.deploy', + 'admin.profiles.runtime', + 'admin.profiles.settings', + 'admin.reset.schedule', + 'admin.resume.when-stopped', + 'admin.scenarios.reset', 'user', ]); });