feat(gateway): add admin user directory

This commit is contained in:
2026-08-08 17:03:58 +00:00
parent 0ef8b5ea20
commit d59e516643
7 changed files with 491 additions and 66 deletions
+15
View File
@@ -381,6 +381,14 @@ const zUserLookupInput = z
message: 'id, username, or email must be provided.',
});
const zUserListInput = z
.object({
query: z.string().trim().max(100).optional(),
limit: z.number().int().min(1).max(100).default(30),
cursor: z.string().uuid().optional(),
})
.optional();
const zServerRestriction = z.object({
blockedFeatures: z.array(z.string().min(1)).optional(),
until: z.string().datetime().nullable().optional(),
@@ -584,6 +592,13 @@ export const adminRouter = router({
getLocalAccountStatus: adminProcedure.query(({ ctx }) => ({
enabled: (ctx as GatewayApiContext).adminLocalAccountEnabled,
})),
list: userAdminProcedure.input(zUserListInput).query(({ ctx, input }) =>
ctx.users.listForAdmin({
query: input?.query,
limit: input?.limit ?? 30,
cursor: input?.cursor,
})
),
createLocal: userCreateProcedure.input(zLocalAccountInput).mutation(async ({ ctx, input }) => {
const gatewayCtx = ctx as GatewayApiContext;
assertLocalAccountEnabled(gatewayCtx);
@@ -2,12 +2,26 @@ import { randomUUID } from 'node:crypto';
import { createSimplePasswordHasher, type PasswordHasher } from './passwordHasher.js';
import type {
AdminUserListItem,
CreateUserInput,
SpecialAccountAccessGrantRecord,
UserIconRecord,
UserRecord,
UserRepository,
} from './userRepository.js';
import { hasActiveUserSanction } from './userRepository.js';
const toAdminUserListItem = (user: UserRecord): AdminUserListItem => ({
id: user.id,
username: user.username,
displayName: user.displayName,
email: user.email,
oauthType: user.oauthType,
roles: [...user.roles],
hasActiveSanction: hasActiveUserSanction(user.sanctions),
deleteAfter: user.deleteAfter,
createdAt: user.createdAt,
});
// 유저 데이터 저장소를 메모리로 대체한 임시 구현.
export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimplePasswordHasher()): UserRepository => {
@@ -56,6 +70,31 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimp
async findByEmail(email: string): Promise<UserRecord | null> {
return usersByEmail.get(email.toLowerCase()) ?? null;
},
async listForAdmin(input) {
const query = input.query?.trim().toLocaleLowerCase() ?? '';
const matching = [...usersByName.values()]
.filter((user) => {
if (!query) return true;
return [user.username, user.displayName, user.email ?? '', user.id].some((value) =>
value.toLocaleLowerCase().includes(query)
);
})
.sort((left, right) => {
const byCreatedAt = right.createdAt.localeCompare(left.createdAt);
return byCreatedAt === 0 ? right.id.localeCompare(left.id) : byCreatedAt;
});
const startIndex = input.cursor
? Math.max(0, matching.findIndex((user) => user.id === input.cursor) + 1)
: 0;
const page = matching.slice(startIndex, startIndex + input.limit + 1);
const hasNextPage = page.length > input.limit;
const users = page.slice(0, input.limit);
return {
users: users.map(toAdminUserListItem),
total: matching.length,
nextCursor: hasNextPage ? users.at(-1)?.id : undefined,
};
},
async createUser(input: CreateUserInput): Promise<UserRecord> {
if (usersByName.has(input.username)) {
throw new Error('User already exists.');
@@ -2,6 +2,7 @@ import { GatewayPrisma, type GatewayPrismaClient } from '@sammo-ts/infra';
import { createSimplePasswordHasher, type PasswordHasher } from './passwordHasher.js';
import type {
AdminUserListItem,
CreateUserInput,
SpecialAccountAccessGrantRecord,
UserIconRecord,
@@ -10,6 +11,21 @@ import type {
UserRepository,
UserSanctions,
} from './userRepository.js';
import { hasActiveUserSanction } from './userRepository.js';
const toAdminUserListItem = (user: UserRecord): AdminUserListItem => {
return {
id: user.id,
username: user.username,
displayName: user.displayName,
email: user.email,
oauthType: user.oauthType,
roles: user.roles,
hasActiveSanction: hasActiveUserSanction(user.sanctions),
deleteAfter: user.deleteAfter,
createdAt: user.createdAt,
};
};
const readStringArray = (value: unknown): string[] => {
if (!Array.isArray(value)) {
@@ -195,6 +211,35 @@ export const createPostgresUserRepository = (
});
return row ? mapUser(row) : null;
},
async listForAdmin(input) {
const query = input.query?.trim();
const where = query
? {
OR: [
{ loginId: { contains: query, mode: 'insensitive' as const } },
{ displayName: { contains: query, mode: 'insensitive' as const } },
{ email: { contains: query, mode: 'insensitive' as const } },
{ id: { contains: query, mode: 'insensitive' as const } },
],
}
: undefined;
const [rows, total] = await Promise.all([
prisma.appUser.findMany({
where,
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
take: input.limit + 1,
...(input.cursor ? { cursor: { id: input.cursor }, skip: 1 } : {}),
}),
prisma.appUser.count({ where }),
]);
const hasNextPage = rows.length > input.limit;
const page = rows.slice(0, input.limit);
return {
users: page.map(mapUser).map(toAdminUserListItem),
total,
nextCursor: hasNextPage ? page.at(-1)?.id : undefined,
};
},
async createUser(input: CreateUserInput): Promise<UserRecord> {
const password = await hasher.hash(input.password);
const oauthType = input.oauth?.type ?? 'NONE';
@@ -73,6 +73,24 @@ export interface PublicUser {
createdAt: string;
}
export interface AdminUserListItem {
id: string;
username: string;
displayName: string;
email?: string;
oauthType: 'NONE' | 'KAKAO';
roles: string[];
hasActiveSanction: boolean;
deleteAfter?: string;
createdAt: string;
}
export interface AdminUserListResult {
users: AdminUserListItem[];
total: number;
nextCursor?: string;
}
export interface UserSanctions {
bannedUntil?: string;
mutedUntil?: string;
@@ -91,6 +109,19 @@ export interface UserServerRestriction {
notes?: string;
}
export const hasActiveUserSanction = (sanctions: UserSanctions, now = Date.now()): boolean => {
const hasActiveGlobalSanction = [sanctions.bannedUntil, sanctions.mutedUntil, sanctions.suspendedUntil].some(
(value) => value !== undefined && new Date(value).getTime() > now
);
if (hasActiveGlobalSanction || (sanctions.flags?.length ?? 0) > 0) {
return true;
}
return Object.values(sanctions.serverRestrictions ?? {}).some((restriction) => {
if ((restriction.blockedFeatures?.length ?? 0) === 0) return false;
return restriction.until === undefined || new Date(restriction.until).getTime() > now;
});
};
export const toPublicUser = (user: UserRecord): PublicUser => ({
id: user.id,
username: user.username,
@@ -124,6 +155,7 @@ export interface UserRepository {
findByDisplayName(displayName: string): Promise<UserRecord | null>;
findByOauthId(type: 'KAKAO', oauthId: string): Promise<UserRecord | null>;
findByEmail(email: string): Promise<UserRecord | null>;
listForAdmin(input: { query?: string; limit: number; cursor?: string }): Promise<AdminUserListResult>;
createUser(input: CreateUserInput): Promise<UserRecord>;
verifyPassword(user: UserRecord, password: string): Promise<boolean>;
updatePassword(userId: string, password: string): Promise<void>;
+36 -4
View File
@@ -846,6 +846,39 @@ describe('Gateway administrator account controls', () => {
throw new Error('not used');
};
it('lists accounts before exact lookup and supports partial search with cursor pagination', async () => {
const { caller, users } = await buildCaller(unusedCreateOperation);
await users.createUser({
username: 'alpha-user',
password: 'secretpass',
displayName: 'Pilot Alpha',
});
await users.createUser({
username: 'kakao-user',
password: 'secretpass',
displayName: 'Kakao Member',
oauth: {
type: 'KAKAO',
id: 'kakao-directory-id',
email: 'pilot@example.test',
info: {},
},
});
const search = await caller.admin.users.list({ query: 'pilot', limit: 30 });
expect(search.total).toBe(2);
expect(search.users.map((user) => user.username).sort()).toEqual(['alpha-user', 'kakao-user']);
expect(search.users[0]).not.toHaveProperty('oauthId');
const firstPage = await caller.admin.users.list({ limit: 1 });
expect(firstPage.total).toBe(3);
expect(firstPage.users).toHaveLength(1);
expect(firstPage.nextCursor).toBeTruthy();
const secondPage = await caller.admin.users.list({ limit: 1, cursor: firstPage.nextCursor });
expect(secondPage.users).toHaveLength(1);
expect(secondPage.users[0]?.id).not.toBe(firstPage.users[0]?.id);
});
it('records sanitized STARTED and SUCCEEDED events and exposes target history', async () => {
const harness = await buildCaller(unusedCreateOperation);
const target = await harness.users.createUser({
@@ -954,10 +987,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 () => {