feat(gateway): split server lifecycle administration

This commit is contained in:
2026-08-08 17:10:43 +00:00
parent 09ea198420
commit 4f7dbfd19e
16 changed files with 916 additions and 301 deletions
+39 -2
View File
@@ -40,8 +40,36 @@ export const ADMIN_CAPABILITIES: readonly AdminCapabilityDefinition[] = [
},
{
permission: 'admin.profiles.manage',
label: 'Profile 운영',
description: '지정 profile의 배포, 초기화와 runtime을 관리합니다.',
label: 'Profile 전체 운영 (호환)',
description: '기존 운영자를 위한 포괄 권한입니다. 새 역할에는 세분화 권한을 사용합니다.',
risk: 'CRITICAL',
scope: 'PROFILE',
},
{
permission: 'admin.profiles.runtime',
label: 'Profile 실행 관리',
description: '지정 profile의 시작, 정지와 실행 상태를 관리합니다.',
risk: 'HIGH',
scope: 'PROFILE',
},
{
permission: 'admin.profiles.settings',
label: 'Profile 설정 관리',
description: '지정 profile의 표시 정보와 계정 접근 정책을 변경합니다.',
risk: 'HIGH',
scope: 'PROFILE',
},
{
permission: 'admin.profiles.deploy',
label: 'Profile 버전 배포',
description: '지정 profile의 DB를 유지하면서 코드와 migration을 배포합니다.',
risk: 'CRITICAL',
scope: 'PROFILE',
},
{
permission: 'admin.scenarios.reset',
label: '시나리오 초기화',
description: '지정 profile의 현재 배포 버전으로 게임 DB와 시나리오를 초기화합니다.',
risk: 'CRITICAL',
scope: 'PROFILE',
},
@@ -98,6 +126,15 @@ export const resolveAdminActionCapability = (path: string, rawInput?: unknown):
if (action === 'RESUME') return 'admin.resume.when-stopped';
if (action === 'OPEN_SURVEY') return 'admin.survey.open';
}
if (path.endsWith('.operations.requestDeploy')) return 'admin.profiles.deploy';
if (path.endsWith('.operations.requestReset')) return 'admin.scenarios.reset';
if (path.endsWith('.operations.requestRuntime')) return 'admin.profiles.runtime';
if (path.endsWith('.profiles.updateMeta')) return 'admin.profiles.settings';
if (path.endsWith('.profiles.listScenarios')) {
const sourceMode =
rawInput && typeof rawInput === 'object' ? (rawInput as { sourceMode?: unknown }).sourceMode : undefined;
return sourceMode === undefined || sourceMode === 'CURRENT' ? 'admin.scenarios.reset' : 'admin.profiles.deploy';
}
if (path.includes('.operations.') || path.includes('.profiles.')) return 'admin.profiles.manage';
return undefined;
};
+152 -51
View File
@@ -54,6 +54,10 @@ const ROLE_SUPERUSER = 'superuser';
const ROLE_ADMIN_USERS = 'admin.users.manage';
const ROLE_ADMIN_USERS_CREATE = 'admin.users.create';
const ROLE_ADMIN_PROFILES = 'admin.profiles.manage';
const ROLE_ADMIN_PROFILE_RUNTIME = 'admin.profiles.runtime';
const ROLE_ADMIN_PROFILE_SETTINGS = 'admin.profiles.settings';
const ROLE_ADMIN_PROFILE_DEPLOY = 'admin.profiles.deploy';
const ROLE_ADMIN_SCENARIO_RESET = 'admin.scenarios.reset';
const ROLE_ADMIN_RELEASES = 'admin.releases.manage';
const ROLE_ADMIN_NOTICE = 'admin.notice.manage';
const ROLE_ADMIN_AUDIT = 'admin.audit.read';
@@ -146,6 +150,21 @@ const hasScopedPermission = (adminAuth: AdminAuthContext, permission: string, pr
return adminAuth.roles.some((role: string) => roleMatchesScope(role, permission, profileName));
};
const hasAnyScopedPermission = (
adminAuth: AdminAuthContext,
permissions: readonly string[],
profileName?: string
): boolean => permissions.some((permission) => hasScopedPermission(adminAuth, permission, profileName));
const assertAnyPermission = (
adminAuth: AdminAuthContext,
permissions: readonly string[],
profileName?: string
): void => {
if (hasAnyScopedPermission(adminAuth, permissions, profileName)) return;
throw new TRPCError({ code: 'FORBIDDEN', message: 'Permission denied.' });
};
const splitRoleScope = (role: string): { permission: string; scope?: string } => {
const separator = role.indexOf(':');
if (separator < 0) {
@@ -437,6 +456,7 @@ const zInstallOptions = z.object({
});
const zOperationInstallOptions = zInstallOptions.omit({ gitRef: true });
const zSourceMode = z.enum(['BRANCH', 'COMMIT']);
const zResetSourceMode = z.enum(['CURRENT', 'BRANCH', 'COMMIT']);
type SanctionsPatch = z.infer<typeof zSanctionsPatch>;
@@ -533,7 +553,14 @@ export const adminRouter = router({
const parsed = splitRoleScope(role);
return parsed.permission === entry.permission;
})
);
).map((entry) => {
if (adminAuth.isSuperuser) return { ...entry, scopes: ['*'] };
const scopes = adminAuth.roles
.map(splitRoleScope)
.filter((role) => role.permission === entry.permission)
.map((role) => role.scope ?? '*');
return { ...entry, scopes: Array.from(new Set(scopes)) };
});
}),
}),
audit: router({
@@ -691,14 +718,20 @@ export const adminRouter = router({
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Recovery access must expire.' });
}
if (expiresAt.getTime() > now.getTime() + 90 * 24 * 60 * 60 * 1000) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Recovery access may last at most 90 days.' });
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Recovery access may last at most 90 days.',
});
}
}
const profiles = [...new Set(input.profiles.map((profile) => profile.toLowerCase()))];
if (profiles.length > 0) {
const knownProfiles = await ctx.profiles.listProfiles();
const knownNames = new Set(
knownProfiles.flatMap((profile) => [profile.profile.toLowerCase(), profile.profileName.toLowerCase()])
knownProfiles.flatMap((profile) => [
profile.profile.toLowerCase(),
profile.profileName.toLowerCase(),
])
);
const unknown = profiles.find((profile) => !knownNames.has(profile));
if (unknown) {
@@ -984,19 +1017,19 @@ export const adminRouter = router({
.query(async ({ ctx, input }) => {
const adminAuth = requireAdminAuth(ctx);
if (input?.profileName) {
assertPermission(adminAuth, ROLE_ADMIN_PROFILES, input.profileName);
if (!canReadProfile(adminAuth, input.profileName)) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Permission denied.' });
}
return ctx.profiles.listOperations({
profileName: input.profileName,
limit: input.limit,
});
}
if (hasScopedPermission(adminAuth, ROLE_ADMIN_PROFILES)) {
if (adminAuth.isSuperuser || adminAuth.roles.some((role) => role.endsWith(':*'))) {
return ctx.profiles.listOperations({ limit: input?.limit });
}
const profiles = await ctx.profiles.listProfiles();
const allowed = profiles.filter((profile) =>
hasScopedPermission(adminAuth, ROLE_ADMIN_PROFILES, profile.profileName)
);
const allowed = profiles.filter((profile) => canReadProfile(adminAuth, profile.profileName));
const operations = (
await Promise.all(
allowed.map((profile) =>
@@ -1015,8 +1048,8 @@ export const adminRouter = router({
.input(
z.object({
profileName: z.string().min(1),
sourceMode: zSourceMode,
sourceRef: z.string().min(1).max(128),
sourceMode: zResetSourceMode,
sourceRef: z.string().min(1).max(128).optional(),
install: zOperationInstallOptions,
scheduledAt: z.string().datetime().optional(),
reason: z.string().max(200).optional(),
@@ -1024,7 +1057,13 @@ export const adminRouter = router({
)
.mutation(async ({ ctx, input }) => {
const adminAuth = requireAdminAuth(ctx);
assertPermission(adminAuth, ROLE_ADMIN_PROFILES, input.profileName);
assertAnyPermission(adminAuth, [ROLE_ADMIN_PROFILES, ROLE_ADMIN_SCENARIO_RESET], input.profileName);
if (input.sourceMode !== 'CURRENT') {
assertAnyPermission(adminAuth, [ROLE_ADMIN_PROFILES, ROLE_ADMIN_PROFILE_DEPLOY], input.profileName);
}
if (input.scheduledAt) {
assertAnyPermission(adminAuth, [ROLE_ADMIN_PROFILES, ROLE_RESET_SCHEDULE], input.profileName);
}
const profile = await ctx.profiles.getProfile(input.profileName);
if (!profile) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.' });
@@ -1074,13 +1113,24 @@ export const adminRouter = router({
});
}
let sourceRef = input.sourceRef.trim();
const sourceMode: 'BRANCH' | 'COMMIT' = input.sourceMode === 'CURRENT' ? 'COMMIT' : input.sourceMode;
let sourceRef =
input.sourceMode === 'CURRENT' ? profile.buildCommitSha?.trim() : input.sourceRef?.trim();
if (!sourceRef) {
throw new TRPCError({
code: 'BAD_REQUEST',
message:
input.sourceMode === 'CURRENT'
? 'The profile has no active build commit to reset from.'
: 'sourceRef is required.',
});
}
try {
const resolved =
input.sourceMode === 'BRANCH'
sourceMode === 'BRANCH'
? await resolveGitBranchCommitSha(sourceRef)
: await resolveGitCommitSha(sourceRef);
if (input.sourceMode === 'COMMIT') {
if (sourceMode === 'COMMIT') {
sourceRef = resolved;
}
const scenarios = await listScenarioPreviews({ gitRef: resolved });
@@ -1091,7 +1141,7 @@ export const adminRouter = router({
throw new TRPCError({
code: 'BAD_REQUEST',
message:
input.sourceMode === 'BRANCH'
sourceMode === 'BRANCH'
? 'Branch is invalid or does not contain the scenario.'
: 'Commit is invalid or does not contain the scenario.',
});
@@ -1101,9 +1151,12 @@ export const adminRouter = router({
const operation = await ctx.profiles.createOperation({
profileName: input.profileName,
type: 'RESET',
sourceMode: input.sourceMode,
sourceMode,
sourceRef,
payload: { install: input.install } as GatewayPrisma.JsonObject,
payload: {
install: input.install,
requestedSource: input.sourceMode,
} as GatewayPrisma.JsonObject,
reason: input.reason,
requestedBy: adminAuth.user.id,
scheduledAt: input.scheduledAt,
@@ -1130,7 +1183,7 @@ export const adminRouter = router({
)
.mutation(async ({ ctx, input }) => {
const adminAuth = requireAdminAuth(ctx);
assertPermission(adminAuth, ROLE_ADMIN_PROFILES, input.profileName);
assertAnyPermission(adminAuth, [ROLE_ADMIN_PROFILES, ROLE_ADMIN_PROFILE_DEPLOY], input.profileName);
const profile = await ctx.profiles.getProfile(input.profileName);
if (!profile) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.' });
@@ -1183,7 +1236,7 @@ export const adminRouter = router({
)
.mutation(async ({ ctx, input }) => {
const adminAuth = requireAdminAuth(ctx);
assertPermission(adminAuth, ROLE_ADMIN_PROFILES, input.profileName);
assertAnyPermission(adminAuth, [ROLE_ADMIN_PROFILES, ROLE_ADMIN_PROFILE_RUNTIME], input.profileName);
const profile = await ctx.profiles.getProfile(input.profileName);
if (!profile) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.' });
@@ -1212,7 +1265,13 @@ export const adminRouter = router({
if (!previous) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Operation not found.' });
}
assertPermission(adminAuth, ROLE_ADMIN_PROFILES, previous.profileName);
const permissions =
previous.type === 'RESET'
? [ROLE_ADMIN_PROFILES, ROLE_ADMIN_SCENARIO_RESET]
: previous.type === 'DEPLOY'
? [ROLE_ADMIN_PROFILES, ROLE_ADMIN_PROFILE_DEPLOY]
: [ROLE_ADMIN_PROFILES, ROLE_ADMIN_PROFILE_RUNTIME];
assertAnyPermission(adminAuth, permissions, previous.profileName);
const cancelled = await ctx.profiles.cancelOperation(input.id);
if (!cancelled) {
throw new TRPCError({
@@ -1228,7 +1287,26 @@ export const adminRouter = router({
if (!previous) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Operation not found.' });
}
assertPermission(adminAuth, ROLE_ADMIN_PROFILES, previous.profileName);
const permissions =
previous.type === 'RESET'
? [ROLE_ADMIN_PROFILES, ROLE_ADMIN_SCENARIO_RESET]
: previous.type === 'DEPLOY'
? [ROLE_ADMIN_PROFILES, ROLE_ADMIN_PROFILE_DEPLOY]
: [ROLE_ADMIN_PROFILES, ROLE_ADMIN_PROFILE_RUNTIME];
assertAnyPermission(adminAuth, permissions, previous.profileName);
if (previous.type === 'RESET') {
const payload = readMetaObject(previous.payload);
if (payload.requestedSource !== 'CURRENT') {
assertAnyPermission(
adminAuth,
[ROLE_ADMIN_PROFILES, ROLE_ADMIN_PROFILE_DEPLOY],
previous.profileName
);
}
if (previous.scheduledAt) {
assertAnyPermission(adminAuth, [ROLE_ADMIN_PROFILES, ROLE_RESET_SCHEDULE], previous.profileName);
}
}
try {
const operation = await ctx.profiles.retryOperation(input.id, adminAuth.user.id);
if (!operation) {
@@ -1399,22 +1477,51 @@ export const adminRouter = router({
},
}));
}),
listScenarios: profileAdminProcedure
listScenarios: adminProcedure
.input(
z
.object({
profileName: z.string().min(1).max(64).optional(),
gitRef: z.string().min(1).max(128).optional(),
sourceMode: zSourceMode.optional(),
sourceMode: zResetSourceMode.optional(),
})
.optional()
)
.query(async ({ input }) => {
const gitRef = input?.gitRef?.trim();
.query(async ({ ctx, input }) => {
const adminAuth = requireAdminAuth(ctx);
const sourceMode = input?.sourceMode ?? 'CURRENT';
let gitRef = input?.gitRef?.trim();
if (sourceMode === 'CURRENT') {
if (!input?.profileName) {
if (!adminAuth.isSuperuser) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'profileName is required.' });
}
} else {
assertAnyPermission(
adminAuth,
[ROLE_ADMIN_PROFILES, ROLE_ADMIN_SCENARIO_RESET],
input.profileName
);
const profile = await ctx.profiles.getProfile(input.profileName);
if (!profile) throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.' });
gitRef = profile.buildCommitSha?.trim();
if (!gitRef) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'The profile has no active build commit.',
});
}
}
} else if (input?.profileName) {
assertAnyPermission(adminAuth, [ROLE_ADMIN_PROFILES, ROLE_ADMIN_PROFILE_DEPLOY], input.profileName);
} else {
assertAnyPermission(adminAuth, [ROLE_ADMIN_PROFILES, ROLE_ADMIN_PROFILE_DEPLOY]);
}
if (!gitRef) {
return listScenarioPreviews();
}
const resolved =
input?.sourceMode === 'BRANCH'
sourceMode === 'BRANCH'
? await resolveGitBranchCommitSha(gitRef)
: await resolveGitCommitSha(gitRef);
return listScenarioPreviews({ gitRef: resolved });
@@ -1476,7 +1583,7 @@ export const adminRouter = router({
await ctx.orchestrator.reconcileNow();
return result;
}),
updateMeta: profileAdminProcedure
updateMeta: adminProcedure
.input(
z.object({
profileName: z.string().min(1),
@@ -1493,6 +1600,11 @@ export const adminRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
assertAnyPermission(
requireAdminAuth(ctx),
[ROLE_ADMIN_PROFILES, ROLE_ADMIN_PROFILE_SETTINGS],
input.profileName
);
const profile = await ctx.profiles.getProfile(input.profileName);
if (!profile) {
throw new TRPCError({
@@ -1730,22 +1842,22 @@ export const adminRouter = router({
)
.mutation(async ({ ctx, input }) => {
const adminAuth = requireAdminAuth(ctx);
if (input.action === 'RESET_NOW' || input.action === 'RESET_SCHEDULED') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: '시나리오 초기화는 operations.requestReset을 사용해 주세요.',
});
}
if ((input.action === 'ACCELERATE' || input.action === 'DELAY') && !input.durationMinutes) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'durationMinutes is required for acceleration or delay.',
});
}
if (input.action === 'RESET_SCHEDULED' && !input.scheduledAt) {
if (input.scheduledAt) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'scheduledAt is required for scheduled reset.',
});
}
if (input.action !== 'RESET_SCHEDULED' && input.scheduledAt) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'scheduledAt is supported only for scheduled reset.',
message: 'scheduledAt is supported only by operations.requestReset.',
});
}
const profile = await ctx.profiles.getProfile(input.profileName);
@@ -1756,11 +1868,13 @@ export const adminRouter = router({
});
}
const canManageProfiles = hasScopedPermission(adminAuth, ROLE_ADMIN_PROFILES, profile.profileName);
const canManageProfiles = hasAnyScopedPermission(
adminAuth,
[ROLE_ADMIN_PROFILES, ROLE_ADMIN_PROFILE_RUNTIME],
profile.profileName
);
const canResume =
canManageProfiles || hasScopedPermission(adminAuth, ROLE_RESUME_WHEN_STOPPED, profile.profileName);
const canResetSchedule =
canManageProfiles || hasScopedPermission(adminAuth, ROLE_RESET_SCHEDULE, profile.profileName);
const canOpenSurvey =
canManageProfiles || hasScopedPermission(adminAuth, ROLE_SURVEY_OPEN, profile.profileName);
@@ -1777,19 +1891,6 @@ export const adminRouter = router({
message: 'Resume permission is required.',
});
}
} else if (input.action === 'RESET_SCHEDULED') {
if (profile.status !== 'COMPLETED') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Reset scheduling is allowed only for COMPLETED profiles.',
});
}
if (!canResetSchedule) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Reset scheduling permission is required.',
});
}
} else if (input.action === 'OPEN_SURVEY') {
if (!canOpenSurvey) {
throw new TRPCError({
+140 -5
View File
@@ -61,6 +61,7 @@ const buildCaller = async (
apiPort: 15003,
status: options.initialProfileStatus ?? ('STOPPED' as const),
buildStatus: 'SUCCEEDED' as const,
buildCommitSha: 'HEAD',
meta: {},
createdAt: '2026-07-25T00:00:00.000Z',
updatedAt: '2026-07-25T00:00:00.000Z',
@@ -378,6 +379,141 @@ describe('admin operation API', () => {
});
expect(harness.createdInputs[0]).not.toHaveProperty('payload');
});
it('lets a scenario-only operator reset from the active commit without selecting Git', async () => {
const harness = await buildCaller(
async (input) => ({
id: '55555555-5555-4555-8555-555555555555',
profileName: input.profileName,
type: 'RESET',
status: 'QUEUED',
sourceMode: input.sourceMode,
sourceRef: input.sourceRef,
payload: input.payload ?? {},
requestedBy: input.requestedBy,
createdAt: '2026-08-08T00:00:00.000Z',
updatedAt: '2026-08-08T00:00:00.000Z',
}),
{
adminRoles: ['admin.scenarios.reset:che:2'],
firstUserIsAdmin: false,
profileScenario: '1010',
}
);
await harness.caller.admin.operations.requestReset({
profileName: 'che:2',
sourceMode: 'CURRENT',
install: {
scenarioId: 1010,
turnTermMinutes: 60,
sync: false,
fiction: 1,
extend: false,
blockGeneralCreate: 0,
npcMode: 0,
showImgLevel: 0,
tournamentTrig: false,
joinMode: 'full',
},
reason: 'new season only',
});
expect(harness.createdInputs[0]).toMatchObject({
type: 'RESET',
sourceMode: 'COMMIT',
sourceRef: expect.stringMatching(/^[0-9a-f]{40}$/u),
reason: 'new season only',
});
});
it('does not let a scenario-only operator combine a Git update with reset', async () => {
const harness = await buildCaller(
async () => {
throw new Error('not used');
},
{ adminRoles: ['admin.scenarios.reset:che:2'], firstUserIsAdmin: false }
);
await expect(
harness.caller.admin.operations.requestReset({
profileName: 'che:2',
sourceMode: 'BRANCH',
sourceRef: 'main',
install: {
scenarioId: 1010,
turnTermMinutes: 60,
sync: false,
fiction: 1,
extend: false,
blockGeneralCreate: 0,
npcMode: 0,
showImgLevel: 0,
tournamentTrig: false,
joinMode: 'full',
},
})
).rejects.toMatchObject({ code: 'FORBIDDEN' });
});
it('keeps runtime and DB-preserving deploy permissions independent', async () => {
const harness = await buildCaller(
async (input) => ({
id: '66666666-6666-4666-8666-666666666666',
profileName: input.profileName,
type: input.type,
status: 'QUEUED',
payload: {},
requestedBy: input.requestedBy,
createdAt: '2026-08-08T00:00:00.000Z',
updatedAt: '2026-08-08T00:00:00.000Z',
}),
{ adminRoles: ['admin.profiles.runtime:che:2'], firstUserIsAdmin: false }
);
await expect(
harness.caller.admin.operations.requestRuntime({ profileName: 'che:2', action: 'START' })
).resolves.toMatchObject({ type: 'START' });
await expect(
harness.caller.admin.operations.requestDeploy({
profileName: 'che:2',
sourceMode: 'BRANCH',
sourceRef: 'main',
})
).rejects.toMatchObject({ code: 'FORBIDDEN' });
});
it('lets a settings-only operator change profile policy without runtime control', async () => {
const harness = await buildCaller(
async () => {
throw new Error('not used');
},
{ adminRoles: ['admin.profiles.settings:che:2'], firstUserIsAdmin: false }
);
await harness.caller.admin.profiles.updateMeta({
profileName: 'che:2',
patch: { color: '#112233', localAccountAccessGraceDays: 14 },
reason: 'profile policy delegation',
});
expect(harness.updatedMetas.at(-1)).toMatchObject({ color: '#112233', localAccountAccessGraceDays: 14 });
await expect(
harness.caller.admin.operations.requestRuntime({ profileName: 'che:2', action: 'STOP' })
).rejects.toMatchObject({ code: 'FORBIDDEN' });
});
it('returns the authenticated profile scopes with the capability catalog', async () => {
const harness = await buildCaller(
async () => {
throw new Error('not used');
},
{ adminRoles: ['admin.scenarios.reset:che:2'], firstUserIsAdmin: false }
);
await expect(harness.caller.admin.capabilities.list()).resolves.toContainEqual(
expect.objectContaining({ permission: 'admin.scenarios.reset', scopes: ['che:2'] })
);
});
});
describe('gateway release API', () => {
@@ -637,7 +773,7 @@ describe('admin runtime clock action API', () => {
})
).rejects.toMatchObject({
code: 'BAD_REQUEST',
message: 'scheduledAt is supported only for scheduled reset.',
message: 'scheduledAt is supported only by operations.requestReset.',
});
expect(harness.createdRuntimeActions).toEqual([]);
});
@@ -954,10 +1090,9 @@ describe('Gateway administrator account controls', () => {
})
).resolves.toMatchObject({ id: grant.id, revokedReason: 'Kakao 인증 수단 복구 완료' });
expect(harness.flushes).toContainEqual({ userId: target.id, reason: 'admin-special-access-revoked' });
expect(harness.auditEvents.filter((event) => event.outcome === 'SUCCEEDED').map((event) => event.action)).toEqual([
'admin.users.grantSpecialAccess',
'admin.users.revokeSpecialAccess',
]);
expect(
harness.auditEvents.filter((event) => event.outcome === 'SUCCEEDED').map((event) => event.action)
).toEqual(['admin.users.grantSpecialAccess', 'admin.users.revokeSpecialAccess']);
});
it('requires recovery access to expire within 90 days', async () => {