merge: Profile 포괄 운영 권한 제거를 main에 반영
This commit is contained in:
@@ -18,9 +18,9 @@ const turnDaemonAdminProcedure = authedProcedure.use(({ ctx, next }) => {
|
|||||||
roles.includes('superuser') ||
|
roles.includes('superuser') ||
|
||||||
roles.includes('admin') ||
|
roles.includes('admin') ||
|
||||||
roles.includes('admin.superuser') ||
|
roles.includes('admin.superuser') ||
|
||||||
roles.includes('admin.profiles.manage') ||
|
roles.includes('admin.profiles.runtime') ||
|
||||||
roles.includes('admin.profiles.manage:*') ||
|
roles.includes('admin.profiles.runtime:*') ||
|
||||||
roles.includes(`admin.profiles.manage:${profileName}`);
|
roles.includes(`admin.profiles.runtime:${profileName}`);
|
||||||
if (!canManageProfile) {
|
if (!canManageProfile) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'FORBIDDEN',
|
code: 'FORBIDDEN',
|
||||||
|
|||||||
@@ -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 () => {
|
it('rejects unauthenticated turn daemon control', async () => {
|
||||||
const caller = appRouter.createCaller(buildContext({ auth: null }));
|
const caller = appRouter.createCaller(buildContext({ auth: null }));
|
||||||
|
|
||||||
|
|||||||
@@ -38,13 +38,6 @@ export const ADMIN_CAPABILITIES: readonly AdminCapabilityDefinition[] = [
|
|||||||
risk: 'HIGH',
|
risk: 'HIGH',
|
||||||
scope: 'GLOBAL',
|
scope: 'GLOBAL',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
permission: 'admin.profiles.manage',
|
|
||||||
label: 'Profile 전체 운영 (호환)',
|
|
||||||
description: '기존 운영자를 위한 포괄 권한입니다. 새 역할에는 세분화 권한을 사용합니다.',
|
|
||||||
risk: 'CRITICAL',
|
|
||||||
scope: 'PROFILE',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
permission: 'admin.profiles.runtime',
|
permission: 'admin.profiles.runtime',
|
||||||
label: 'Profile 실행 관리',
|
label: 'Profile 실행 관리',
|
||||||
@@ -126,17 +119,30 @@ export const resolveAdminActionCapability = (path: string, rawInput?: unknown):
|
|||||||
if (action === 'RESUME') return 'admin.resume.when-stopped';
|
if (action === 'RESUME') return 'admin.resume.when-stopped';
|
||||||
if (action === 'UPDATE_RUNTIME_SETTINGS') return 'admin.profiles.runtime';
|
if (action === 'UPDATE_RUNTIME_SETTINGS') return 'admin.profiles.runtime';
|
||||||
if (action === 'OPEN_SURVEY') return 'admin.survey.open';
|
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.requestDeploy')) return 'admin.profiles.deploy';
|
||||||
if (path.endsWith('.operations.requestReset')) return 'admin.scenarios.reset';
|
if (path.endsWith('.operations.requestReset')) return 'admin.scenarios.reset';
|
||||||
if (path.endsWith('.operations.requestRuntime')) return 'admin.profiles.runtime';
|
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')) {
|
if (path.endsWith('.profiles.listScenarios')) {
|
||||||
const sourceMode =
|
const sourceMode =
|
||||||
rawInput && typeof rawInput === 'object' ? (rawInput as { sourceMode?: unknown }).sourceMode : undefined;
|
rawInput && typeof rawInput === 'object' ? (rawInput as { sourceMode?: unknown }).sourceMode : undefined;
|
||||||
return sourceMode === undefined || sourceMode === 'CURRENT' ? 'admin.scenarios.reset' : 'admin.profiles.deploy';
|
return sourceMode === undefined || sourceMode === 'CURRENT' ? 'admin.scenarios.reset' : 'admin.profiles.deploy';
|
||||||
}
|
}
|
||||||
if (path.includes('.operations.') || path.includes('.profiles.')) return 'admin.profiles.manage';
|
|
||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ const ADMIN_ROLE_SUPERUSER = 'admin.superuser';
|
|||||||
const ROLE_SUPERUSER = 'superuser';
|
const ROLE_SUPERUSER = 'superuser';
|
||||||
const ROLE_ADMIN_USERS = 'admin.users.manage';
|
const ROLE_ADMIN_USERS = 'admin.users.manage';
|
||||||
const ROLE_ADMIN_USERS_CREATE = 'admin.users.create';
|
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_RUNTIME = 'admin.profiles.runtime';
|
||||||
const ROLE_ADMIN_PROFILE_SETTINGS = 'admin.profiles.settings';
|
const ROLE_ADMIN_PROFILE_SETTINGS = 'admin.profiles.settings';
|
||||||
const ROLE_ADMIN_PROFILE_DEPLOY = 'admin.profiles.deploy';
|
const ROLE_ADMIN_PROFILE_DEPLOY = 'admin.profiles.deploy';
|
||||||
@@ -153,21 +152,6 @@ const hasScopedPermission = (adminAuth: AdminAuthContext, permission: string, pr
|
|||||||
return adminAuth.roles.some((role: string) => roleMatchesScope(role, permission, profileName));
|
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 splitRoleScope = (role: string): { permission: string; scope?: string } => {
|
||||||
const separator = role.indexOf(':');
|
const separator = role.indexOf(':');
|
||||||
if (separator < 0) {
|
if (separator < 0) {
|
||||||
@@ -241,6 +225,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 => {
|
const assertTargetUserManageable = (adminAuth: AdminAuthContext, target: { id: string; roles: string[] }): void => {
|
||||||
if (!adminAuth.isSuperuser && target.roles.some(isRootAdminRole)) {
|
if (!adminAuth.isSuperuser && target.roles.some(isRootAdminRole)) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
@@ -374,12 +368,6 @@ const userCreateProcedure = adminProcedure.use(({ ctx, next }) => {
|
|||||||
return 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 releaseAdminProcedure = adminProcedure.use(({ ctx, next }) => {
|
||||||
const adminAuth = requireAdminAuth(ctx);
|
const adminAuth = requireAdminAuth(ctx);
|
||||||
assertPermission(adminAuth, ROLE_ADMIN_RELEASES);
|
assertPermission(adminAuth, ROLE_ADMIN_RELEASES);
|
||||||
@@ -1165,12 +1153,12 @@ export const adminRouter = router({
|
|||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const adminAuth = requireAdminAuth(ctx);
|
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') {
|
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) {
|
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);
|
const profile = await ctx.profiles.getProfile(input.profileName);
|
||||||
if (!profile) {
|
if (!profile) {
|
||||||
@@ -1291,7 +1279,7 @@ export const adminRouter = router({
|
|||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const adminAuth = requireAdminAuth(ctx);
|
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);
|
const profile = await ctx.profiles.getProfile(input.profileName);
|
||||||
if (!profile) {
|
if (!profile) {
|
||||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.' });
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.' });
|
||||||
@@ -1347,7 +1335,7 @@ export const adminRouter = router({
|
|||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const adminAuth = requireAdminAuth(ctx);
|
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);
|
const profile = await ctx.profiles.getProfile(input.profileName);
|
||||||
if (!profile) {
|
if (!profile) {
|
||||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.' });
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.' });
|
||||||
@@ -1376,13 +1364,13 @@ export const adminRouter = router({
|
|||||||
if (!previous) {
|
if (!previous) {
|
||||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Operation not found.' });
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Operation not found.' });
|
||||||
}
|
}
|
||||||
const permissions =
|
const permission =
|
||||||
previous.type === 'RESET'
|
previous.type === 'RESET'
|
||||||
? [ROLE_ADMIN_PROFILES, ROLE_ADMIN_SCENARIO_RESET]
|
? ROLE_ADMIN_SCENARIO_RESET
|
||||||
: previous.type === 'DEPLOY'
|
: previous.type === 'DEPLOY'
|
||||||
? [ROLE_ADMIN_PROFILES, ROLE_ADMIN_PROFILE_DEPLOY]
|
? ROLE_ADMIN_PROFILE_DEPLOY
|
||||||
: [ROLE_ADMIN_PROFILES, ROLE_ADMIN_PROFILE_RUNTIME];
|
: ROLE_ADMIN_PROFILE_RUNTIME;
|
||||||
assertAnyPermission(adminAuth, permissions, previous.profileName);
|
assertPermission(adminAuth, permission, previous.profileName);
|
||||||
const cancelled = await ctx.profiles.cancelOperation(input.id);
|
const cancelled = await ctx.profiles.cancelOperation(input.id);
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
@@ -1398,24 +1386,20 @@ export const adminRouter = router({
|
|||||||
if (!previous) {
|
if (!previous) {
|
||||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Operation not found.' });
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Operation not found.' });
|
||||||
}
|
}
|
||||||
const permissions =
|
const permission =
|
||||||
previous.type === 'RESET'
|
previous.type === 'RESET'
|
||||||
? [ROLE_ADMIN_PROFILES, ROLE_ADMIN_SCENARIO_RESET]
|
? ROLE_ADMIN_SCENARIO_RESET
|
||||||
: previous.type === 'DEPLOY'
|
: previous.type === 'DEPLOY'
|
||||||
? [ROLE_ADMIN_PROFILES, ROLE_ADMIN_PROFILE_DEPLOY]
|
? ROLE_ADMIN_PROFILE_DEPLOY
|
||||||
: [ROLE_ADMIN_PROFILES, ROLE_ADMIN_PROFILE_RUNTIME];
|
: ROLE_ADMIN_PROFILE_RUNTIME;
|
||||||
assertAnyPermission(adminAuth, permissions, previous.profileName);
|
assertPermission(adminAuth, permission, previous.profileName);
|
||||||
if (previous.type === 'RESET') {
|
if (previous.type === 'RESET') {
|
||||||
const payload = readMetaObject(previous.payload);
|
const payload = readMetaObject(previous.payload);
|
||||||
if (payload.requestedSource !== 'CURRENT') {
|
if (payload.requestedSource !== 'CURRENT') {
|
||||||
assertAnyPermission(
|
assertPermission(adminAuth, ROLE_ADMIN_PROFILE_DEPLOY, previous.profileName);
|
||||||
adminAuth,
|
|
||||||
[ROLE_ADMIN_PROFILES, ROLE_ADMIN_PROFILE_DEPLOY],
|
|
||||||
previous.profileName
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (previous.scheduledAt) {
|
if (previous.scheduledAt) {
|
||||||
assertAnyPermission(adminAuth, [ROLE_ADMIN_PROFILES, ROLE_RESET_SCHEDULE], previous.profileName);
|
assertPermission(adminAuth, ROLE_RESET_SCHEDULE, previous.profileName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -1593,7 +1577,7 @@ export const adminRouter = router({
|
|||||||
.input(z.object({ profileName: z.string().min(1) }))
|
.input(z.object({ profileName: z.string().min(1) }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const adminAuth = requireAdminAuth(ctx);
|
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);
|
const profile = await ctx.profiles.getProfile(input.profileName);
|
||||||
if (!profile) {
|
if (!profile) {
|
||||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.' });
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.' });
|
||||||
@@ -1688,11 +1672,7 @@ export const adminRouter = router({
|
|||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'profileName is required.' });
|
throw new TRPCError({ code: 'BAD_REQUEST', message: 'profileName is required.' });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
assertAnyPermission(
|
assertPermission(adminAuth, ROLE_ADMIN_SCENARIO_RESET, input.profileName);
|
||||||
adminAuth,
|
|
||||||
[ROLE_ADMIN_PROFILES, ROLE_ADMIN_SCENARIO_RESET],
|
|
||||||
input.profileName
|
|
||||||
);
|
|
||||||
const profile = await ctx.profiles.getProfile(input.profileName);
|
const profile = await ctx.profiles.getProfile(input.profileName);
|
||||||
if (!profile) throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.' });
|
if (!profile) throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.' });
|
||||||
const parsedScenarioId =
|
const parsedScenarioId =
|
||||||
@@ -1707,9 +1687,9 @@ export const adminRouter = router({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (input?.profileName) {
|
} else if (input?.profileName) {
|
||||||
assertAnyPermission(adminAuth, [ROLE_ADMIN_PROFILES, ROLE_ADMIN_PROFILE_DEPLOY], input.profileName);
|
assertPermission(adminAuth, ROLE_ADMIN_PROFILE_DEPLOY, input.profileName);
|
||||||
} else {
|
} else {
|
||||||
assertAnyPermission(adminAuth, [ROLE_ADMIN_PROFILES, ROLE_ADMIN_PROFILE_DEPLOY]);
|
assertPermission(adminAuth, ROLE_ADMIN_PROFILE_DEPLOY);
|
||||||
}
|
}
|
||||||
const scenarios = !gitRef
|
const scenarios = !gitRef
|
||||||
? await listScenarioPreviews()
|
? await listScenarioPreviews()
|
||||||
@@ -1724,7 +1704,7 @@ export const adminRouter = router({
|
|||||||
isCurrent: currentScenarioId === scenario.id,
|
isCurrent: currentScenarioId === scenario.id,
|
||||||
}));
|
}));
|
||||||
}),
|
}),
|
||||||
upsert: profileAdminProcedure
|
upsert: adminProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
profile: z.string().regex(/^[a-z0-9-]{1,32}$/),
|
profile: z.string().regex(/^[a-z0-9-]{1,32}$/),
|
||||||
@@ -1743,6 +1723,12 @@ export const adminRouter = router({
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.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';
|
const status = input.status ?? 'STOPPED';
|
||||||
return ctx.profiles.upsertProfile({
|
return ctx.profiles.upsertProfile({
|
||||||
profile: input.profile,
|
profile: input.profile,
|
||||||
@@ -1757,7 +1743,7 @@ export const adminRouter = router({
|
|||||||
buildCommitSha: input.buildCommitSha,
|
buildCommitSha: input.buildCommitSha,
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
setStatus: profileAdminProcedure
|
setStatus: adminProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
profileName: z.string().min(1),
|
profileName: z.string().min(1),
|
||||||
@@ -1769,6 +1755,11 @@ export const adminRouter = router({
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.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)) {
|
if (input.status === 'RESERVED' && (!input.preopenAt || !input.openAt)) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'BAD_REQUEST',
|
code: 'BAD_REQUEST',
|
||||||
@@ -1806,11 +1797,7 @@ export const adminRouter = router({
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
assertAnyPermission(
|
assertPermission(requireAdminAuth(ctx), ROLE_ADMIN_PROFILE_SETTINGS, input.profileName);
|
||||||
requireAdminAuth(ctx),
|
|
||||||
[ROLE_ADMIN_PROFILES, ROLE_ADMIN_PROFILE_SETTINGS],
|
|
||||||
input.profileName
|
|
||||||
);
|
|
||||||
const profile = await ctx.profiles.getProfile(input.profileName);
|
const profile = await ctx.profiles.getProfile(input.profileName);
|
||||||
if (!profile) {
|
if (!profile) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
@@ -1822,7 +1809,7 @@ export const adminRouter = router({
|
|||||||
const nextMeta = applyMetaPatch(meta, input.patch);
|
const nextMeta = applyMetaPatch(meta, input.patch);
|
||||||
return ctx.profiles.updateMeta(input.profileName, nextMeta);
|
return ctx.profiles.updateMeta(input.profileName, nextMeta);
|
||||||
}),
|
}),
|
||||||
install: profileAdminProcedure
|
install: adminProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
profileName: z.string().min(1),
|
profileName: z.string().min(1),
|
||||||
@@ -1832,6 +1819,7 @@ export const adminRouter = router({
|
|||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const adminAuth = requireAdminAuth(ctx);
|
const adminAuth = requireAdminAuth(ctx);
|
||||||
|
assertAllPermissions(adminAuth, [ROLE_ADMIN_SCENARIO_RESET, ROLE_ADMIN_PROFILE_DEPLOY]);
|
||||||
const profile = await ctx.profiles.getProfile(input.profileName);
|
const profile = await ctx.profiles.getProfile(input.profileName);
|
||||||
if (!profile) {
|
if (!profile) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
@@ -1907,6 +1895,9 @@ export const adminRouter = router({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const scheduledAt = openAt ? (preopenAt ?? openAt).toISOString() : null;
|
const scheduledAt = openAt ? (preopenAt ?? openAt).toISOString() : null;
|
||||||
|
if (scheduledAt) {
|
||||||
|
assertPermission(adminAuth, ROLE_RESET_SCHEDULE);
|
||||||
|
}
|
||||||
const action = scheduledAt ? 'RESET_SCHEDULED' : 'RESET_NOW';
|
const action = scheduledAt ? 'RESET_SCHEDULED' : 'RESET_NOW';
|
||||||
const actionRecord = {
|
const actionRecord = {
|
||||||
action,
|
action,
|
||||||
@@ -1954,7 +1945,7 @@ export const adminRouter = router({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
installNow: profileAdminProcedure
|
installNow: adminProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
profileName: z.string().min(1),
|
profileName: z.string().min(1),
|
||||||
@@ -1964,6 +1955,7 @@ export const adminRouter = router({
|
|||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const adminAuth = requireAdminAuth(ctx);
|
const adminAuth = requireAdminAuth(ctx);
|
||||||
|
assertAllPermissions(adminAuth, [ROLE_ADMIN_SCENARIO_RESET, ROLE_ADMIN_PROFILE_DEPLOY]);
|
||||||
const profile = await ctx.profiles.getProfile(input.profileName);
|
const profile = await ctx.profiles.getProfile(input.profileName);
|
||||||
if (!profile) {
|
if (!profile) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
@@ -2088,9 +2080,9 @@ export const adminRouter = router({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const canManageProfiles = hasAnyScopedPermission(
|
const canManageProfiles = hasScopedPermission(
|
||||||
adminAuth,
|
adminAuth,
|
||||||
[ROLE_ADMIN_PROFILES, ROLE_ADMIN_PROFILE_RUNTIME],
|
ROLE_ADMIN_PROFILE_RUNTIME,
|
||||||
profile.profileName
|
profile.profileName
|
||||||
);
|
);
|
||||||
const canResume =
|
const canResume =
|
||||||
@@ -2232,7 +2224,7 @@ export const adminRouter = router({
|
|||||||
}
|
}
|
||||||
return { ok: true, action: actionRecord };
|
return { ok: true, action: actionRecord };
|
||||||
}),
|
}),
|
||||||
requestBuild: profileAdminProcedure
|
requestBuild: adminProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
profileName: z.string().min(1),
|
profileName: z.string().min(1),
|
||||||
@@ -2240,6 +2232,7 @@ export const adminRouter = router({
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
assertPermission(requireAdminAuth(ctx), ROLE_ADMIN_PROFILE_DEPLOY);
|
||||||
const requestedAt = new Date().toISOString();
|
const requestedAt = new Date().toISOString();
|
||||||
const result = await ctx.profiles.updateBuildStatus(input.profileName, 'QUEUED', {
|
const result = await ctx.profiles.updateBuildStatus(input.profileName, 'QUEUED', {
|
||||||
requestedAt,
|
requestedAt,
|
||||||
@@ -2248,19 +2241,24 @@ export const adminRouter = router({
|
|||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
}),
|
}),
|
||||||
setBuildStatus: profileAdminProcedure
|
setBuildStatus: adminProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
profileName: z.string().min(1),
|
profileName: z.string().min(1),
|
||||||
status: zBuildStatus,
|
status: zBuildStatus,
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => ctx.profiles.updateBuildStatus(input.profileName, input.status)),
|
.mutation(async ({ ctx, input }) => {
|
||||||
reconcileNow: profileAdminProcedure.mutation(async ({ ctx }) => {
|
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();
|
await ctx.orchestrator.reconcileNow();
|
||||||
return { ok: true };
|
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();
|
const result = await ctx.orchestrator.cleanupStaleWorkspaces();
|
||||||
return {
|
return {
|
||||||
removed: result.removed,
|
removed: result.removed,
|
||||||
|
|||||||
@@ -369,7 +369,7 @@ describe('admin profile navigation API', () => {
|
|||||||
async () => {
|
async () => {
|
||||||
throw new Error('not used');
|
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([
|
await expect(harness.caller.admin.profiles.listNavigation()).resolves.toEqual([
|
||||||
@@ -775,9 +775,11 @@ describe('admin operation API', () => {
|
|||||||
{ adminRoles: ['admin.scenarios.reset:che:2'], firstUserIsAdmin: false }
|
{ 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.objectContaining({ permission: 'admin.scenarios.reset', scopes: ['che:2'] })
|
||||||
);
|
);
|
||||||
|
expect(capabilities).not.toContainEqual(expect.objectContaining({ permission: 'admin.profiles.manage' }));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -940,7 +942,7 @@ describe('gateway release API', () => {
|
|||||||
async () => {
|
async () => {
|
||||||
throw new Error('not used');
|
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' });
|
await expect(harness.caller.admin.releases.gatewayState()).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
||||||
@@ -1566,6 +1568,25 @@ describe('Gateway administrator account controls', () => {
|
|||||||
expect((await harness.users.findById(target.id))?.roles).toEqual(['user']);
|
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 () => {
|
it('extends an unverified local account grace period and flushes active sessions', async () => {
|
||||||
const harness = await buildCaller(unusedCreateOperation);
|
const harness = await buildCaller(unusedCreateOperation);
|
||||||
const target = await harness.users.createUser({
|
const target = await harness.users.createUser({
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ describe('readReleaseManifest', () => {
|
|||||||
|
|
||||||
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
|
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
|
||||||
controllerProtocol: RELEASE_CONTROLLER_PROTOCOL,
|
controllerProtocol: RELEASE_CONTROLLER_PROTOCOL,
|
||||||
gatewaySchemaHead: '20260817000000_add_password_reset_required',
|
gatewaySchemaHead: '20260817002000_remove_profile_manage_capability',
|
||||||
gameSchemaHead: '20260817001000_add_dedicated_legacy_archive',
|
gameSchemaHead: '20260817001000_add_dedicated_legacy_archive',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -51,10 +51,10 @@ const installFixture = async (page: Page) => {
|
|||||||
scope: 'GLOBAL',
|
scope: 'GLOBAL',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
permission: 'admin.profiles.manage',
|
permission: 'admin.profiles.runtime',
|
||||||
label: 'Profile 운영',
|
label: 'Profile 실행 관리',
|
||||||
description: '지정 profile을 관리합니다.',
|
description: '지정 profile의 시작, 정지와 실행 상태를 관리합니다.',
|
||||||
risk: 'CRITICAL',
|
risk: 'HIGH',
|
||||||
scope: 'PROFILE',
|
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('navigation', { name: '사용자 관리 기능' })).toBeVisible();
|
||||||
await expect(page.getByRole('heading', { name: '비밀번호 리셋' })).toBeHidden();
|
await expect(page.getByRole('heading', { name: '비밀번호 리셋' })).toBeHidden();
|
||||||
await page.getByRole('button', { name: /접근 · 권한/ }).click();
|
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 expect(page.getByRole('cell', { name: 'che:default' })).toBeVisible();
|
||||||
await page.screenshot({ path: testInfo.outputPath('gateway-admin-account-controls-desktop.png'), fullPage: true });
|
await page.screenshot({ path: testInfo.outputPath('gateway-admin-account-controls-desktop.png'), fullPage: true });
|
||||||
await page.getByPlaceholder('권한·제재·복구·탈퇴 조치 사유 (필수)').fill('본인 확인 처리 중');
|
await page.getByPlaceholder('권한·제재·복구·탈퇴 조치 사유 (필수)').fill('본인 확인 처리 중');
|
||||||
|
|||||||
@@ -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 }) => {
|
test('scoped administrators see the same navigation while ordinary users do not', async ({ browser }) => {
|
||||||
const scopedContext = await browser.newContext();
|
const scopedContext = await browser.newContext();
|
||||||
const scopedPage = await scopedContext.newPage();
|
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 scopedPage.goto('lobby');
|
||||||
await expect(scopedPage.getByRole('link', { name: '관리자 페이지' })).toBeVisible();
|
await expect(scopedPage.getByRole('link', { name: '관리자 페이지' })).toBeVisible();
|
||||||
await scopedPage.getByRole('link', { name: '관리자 페이지' }).click();
|
await scopedPage.getByRole('link', { name: '관리자 페이지' }).click();
|
||||||
|
|||||||
@@ -534,7 +534,7 @@ const rolesStatus = ref('');
|
|||||||
const capabilities = ref<AdminCapability[]>([]);
|
const capabilities = ref<AdminCapability[]>([]);
|
||||||
const hasCapability = (permission: string, profileName?: string): boolean =>
|
const hasCapability = (permission: string, profileName?: string): boolean =>
|
||||||
capabilities.value.some((entry) => {
|
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;
|
if (!profileName || entry.scope === 'GLOBAL') return true;
|
||||||
return !entry.scopes?.length || entry.scopes.includes('*') || entry.scopes.includes(profileName);
|
return !entry.scopes?.length || entry.scopes.includes('*') || entry.scopes.includes(profileName);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ const gatewayReleaseLogEmptyMessage = computed(() => {
|
|||||||
});
|
});
|
||||||
const hasCapability = (permission: string): boolean =>
|
const hasCapability = (permission: string): boolean =>
|
||||||
capabilities.value.some((entry) => {
|
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;
|
if (!props.profileName) return true;
|
||||||
return !entry.scopes?.length || entry.scopes.includes('*') || entry.scopes.includes(props.profileName);
|
return !entry.scopes?.length || entry.scopes.includes('*') || entry.scopes.includes(props.profileName);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -80,7 +80,6 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로
|
|||||||
| `admin.profiles.deploy:<name>` | DB를 유지하는 Git 버전 업데이트, 초기화와 새 버전 결합 |
|
| `admin.profiles.deploy:<name>` | DB를 유지하는 Git 버전 업데이트, 초기화와 새 버전 결합 |
|
||||||
| `admin.scenarios.reset:<name>` | 현재 배포 버전으로 시나리오 초기화 |
|
| `admin.scenarios.reset:<name>` | 현재 배포 버전으로 시나리오 초기화 |
|
||||||
| `admin.reset.schedule:<name>` | 허용된 시나리오 초기화를 미래 시각에 예약 |
|
| `admin.reset.schedule:<name>` | 허용된 시나리오 초기화를 미래 시각에 예약 |
|
||||||
| `admin.profiles.manage:<name>` | 기존 역할 호환용 포괄 권한 |
|
|
||||||
| `admin.releases.manage` | profile과 분리된 Gateway control plane 배포·rollback |
|
| `admin.releases.manage` | profile과 분리된 Gateway control plane 배포·rollback |
|
||||||
|
|
||||||
기존 상태 화면의 `즉시 리셋`·`리셋 예약` 버튼은 실제 DB 초기화 operation과
|
기존 상태 화면의 `즉시 리셋`·`리셋 예약` 버튼은 실제 DB 초기화 operation과
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ Profile 화면은 `/gateway/admin/servers/:profileName/version`과
|
|||||||
`/gateway/admin/servers/:profileName/scenario`, Gateway 화면은
|
`/gateway/admin/servers/:profileName/scenario`, Gateway 화면은
|
||||||
`/gateway/admin/releases`입니다. 이전 `/gateway/admin/server-operations`는
|
`/gateway/admin/releases`입니다. 이전 `/gateway/admin/server-operations`는
|
||||||
호환성을 위해 서버 목록으로 이동합니다. Profile 작업은 runtime/settings/deploy/reset
|
호환성을 위해 서버 목록으로 이동합니다. Profile 작업은 runtime/settings/deploy/reset
|
||||||
capability로 분리되며 기존 `admin.profiles.manage`는 포괄 호환 권한입니다. Gateway 전체 릴리스에는
|
capability로 분리되며 포괄 운영 권한은 사용하지 않습니다. Gateway 전체 릴리스에는
|
||||||
profile 범위 권한과 별개인 전역 `admin.releases.manage` 권한이 필요합니다.
|
profile 범위 권한과 별개인 전역 `admin.releases.manage` 권한이 필요합니다.
|
||||||
일반 사용자와 권한이 없는 관리자는 Gateway 릴리스 영역을 사용할 수 없습니다.
|
일반 사용자와 권한이 없는 관리자는 Gateway 릴리스 영역을 사용할 수 없습니다.
|
||||||
|
|
||||||
|
|||||||
+45
@@ -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:%'
|
||||||
|
);
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"formatVersion": 1,
|
"formatVersion": 1,
|
||||||
"controllerProtocol": 2,
|
"controllerProtocol": 2,
|
||||||
"gatewaySchemaHead": "20260817000000_add_password_reset_required",
|
"gatewaySchemaHead": "20260817002000_remove_profile_manage_capability",
|
||||||
"gameSchemaHead": "20260817001000_add_dedicated_legacy_archive",
|
"gameSchemaHead": "20260817001000_add_dedicated_legacy_archive",
|
||||||
"components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"]
|
"components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,15 +33,15 @@ const asObject = (value: JsonValue): Record<string, JsonValue> =>
|
|||||||
const asStringArray = (value: JsonValue | undefined): string[] =>
|
const asStringArray = (value: JsonValue | undefined): string[] =>
|
||||||
Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [];
|
Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [];
|
||||||
|
|
||||||
const legacyAclRoleMap: Record<string, string> = {
|
const legacyAclRoleMap: Record<string, readonly string[]> = {
|
||||||
openClose: 'admin.profiles.manage',
|
openClose: ['admin.profiles.runtime'],
|
||||||
reset: 'admin.reset.schedule',
|
reset: ['admin.scenarios.reset', 'admin.reset.schedule'],
|
||||||
update: 'admin.profiles.manage',
|
update: ['admin.profiles.deploy'],
|
||||||
fullUpdate: 'admin.profiles.manage',
|
fullUpdate: ['admin.profiles.deploy'],
|
||||||
vote: 'admin.survey.open',
|
vote: ['admin.survey.open'],
|
||||||
globalNotice: 'admin.notice.manage',
|
globalNotice: ['admin.notice.manage'],
|
||||||
notice: 'admin.notice.manage',
|
notice: ['admin.notice.manage'],
|
||||||
blockGeneral: 'admin.users.manage',
|
blockGeneral: ['admin.users.manage'],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const mapLegacyRoles = (grade: number, rawAcl: JsonValue): string[] => {
|
export const mapLegacyRoles = (grade: number, rawAcl: JsonValue): string[] => {
|
||||||
@@ -49,7 +49,10 @@ export const mapLegacyRoles = (grade: number, rawAcl: JsonValue): string[] => {
|
|||||||
if (grade >= 7) {
|
if (grade >= 7) {
|
||||||
roles.add('superuser');
|
roles.add('superuser');
|
||||||
} else if (grade === 6) {
|
} 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.notice.manage');
|
||||||
roles.add('admin.reset.schedule');
|
roles.add('admin.reset.schedule');
|
||||||
roles.add('admin.resume.when-stopped');
|
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 [profile, permissions] of Object.entries(asObject(rawAcl))) {
|
||||||
for (const permission of asStringArray(permissions)) {
|
for (const permission of asStringArray(permissions)) {
|
||||||
const mapped = legacyAclRoleMap[permission];
|
const mappedRoles = legacyAclRoleMap[permission];
|
||||||
if (mapped) {
|
if (mappedRoles) {
|
||||||
roles.add(`${mapped}:${profile}:default`);
|
for (const mappedRole of mappedRoles) {
|
||||||
|
roles.add(`${mappedRole}:${profile}:default`);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
roles.add(`legacy.acl.${permission}:${profile}`);
|
roles.add(`legacy.acl.${permission}:${profile}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,22 @@ describe('legacy database transforms', () => {
|
|||||||
expect(mapLegacyRoles(1, { che: ['reset', 'notice'] })).toEqual([
|
expect(mapLegacyRoles(1, { che: ['reset', 'notice'] })).toEqual([
|
||||||
'admin.notice.manage:che:default',
|
'admin.notice.manage:che:default',
|
||||||
'admin.reset.schedule: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',
|
'user',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user