fix(gateway): Profile 포괄 운영 권한 제거

This commit is contained in:
2026-08-17 16:07:46 +00:00
parent c2fccb58ab
commit b49332f5b9
16 changed files with 220 additions and 108 deletions
+15 -9
View File
@@ -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;
};
+65 -67
View File
@@ -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,
+24 -3
View File
@@ -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({
+2 -2
View File
@@ -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',
});
});