diff --git a/app/gateway-api/src/adminRouter.ts b/app/gateway-api/src/adminRouter.ts index 6aba393..1d68a65 100644 --- a/app/gateway-api/src/adminRouter.ts +++ b/app/gateway-api/src/adminRouter.ts @@ -6,6 +6,7 @@ import { z } from 'zod'; import { procedure, router } from './trpc.js'; import { listScenarioPreviews, resolveGitCommitSha } from './scenario/scenarioCatalog.js'; import type { UserSanctions, UserServerRestriction } from './auth/userRepository.js'; +import { toPublicUser } from './auth/userRepository.js'; import type { AdminAuthContext } from './adminAuth.js'; import type { GatewayApiContext } from './context.js'; import { GATEWAY_BUILD_STATUSES, GATEWAY_PROFILE_STATUSES } from './orchestrator/profileRepository.js'; @@ -41,6 +42,7 @@ const ADMIN_ROLE_PREFIX = 'admin.'; 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_NOTICE = 'admin.notice.manage'; const ROLE_RESET_SCHEDULE = 'admin.reset.schedule'; @@ -142,6 +144,20 @@ const assertPermission = (adminAuth: AdminAuthContext, permission: string, profi }); }; +const canCreateLocalUser = (adminAuth: AdminAuthContext): boolean => + hasScopedPermission(adminAuth, ROLE_ADMIN_USERS_CREATE) || hasScopedPermission(adminAuth, ROLE_ADMIN_USERS); + +// 로컬 계정 임의 생성은 환경 설정이 켜져 있을 때만 허용한다. +const assertLocalAccountEnabled = (ctx: GatewayApiContext): void => { + if (ctx.adminLocalAccountEnabled) { + return; + } + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'Local account provisioning is disabled.', + }); +}; + const adminProcedure = procedure.use(async ({ ctx, next }) => { const adminAuth = await resolveAdminAuth(ctx as GatewayApiContext); return next({ @@ -164,6 +180,17 @@ const userAdminProcedure = adminProcedure.use(({ ctx, next }) => { return next(); }); +const userCreateProcedure = adminProcedure.use(({ ctx, next }) => { + const adminAuth = requireAdminAuth(ctx); + if (!canCreateLocalUser(adminAuth)) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'Permission denied.', + }); + } + return next(); +}); + const profileAdminProcedure = adminProcedure.use(({ ctx, next }) => { const adminAuth = requireAdminAuth(ctx); assertPermission(adminAuth, ROLE_ADMIN_PROFILES); @@ -198,6 +225,12 @@ const zSanctionsPatch = z.object({ serverRestrictions: z.record(z.string(), zServerRestriction.nullable()).nullable().optional(), }); +const zLocalAccountInput = z.object({ + username: z.string().min(2).max(32), + password: z.string().min(6).max(128), + displayName: z.string().min(2).max(40).optional(), +}); + const zInstallAutorun = z.object({ limitMinutes: z.number().int().min(0).max(43200), options: z.array(z.enum(AUTORUN_USER_OPTIONS)), @@ -339,6 +372,34 @@ export const adminRouter = router({ }), }), users: router({ + getLocalAccountStatus: adminProcedure.query(({ ctx }) => ({ + enabled: (ctx as GatewayApiContext).adminLocalAccountEnabled, + })), + createLocal: userCreateProcedure.input(zLocalAccountInput).mutation(async ({ ctx, input }) => { + const gatewayCtx = ctx as GatewayApiContext; + assertLocalAccountEnabled(gatewayCtx); + const existing = await gatewayCtx.users.findByUsername(input.username); + if (existing) { + throw new TRPCError({ + code: 'CONFLICT', + message: 'Username already exists.', + }); + } + try { + const created = await gatewayCtx.users.createUser({ + username: input.username, + password: input.password, + displayName: input.displayName, + }); + return { user: toPublicUser(created) }; + } catch (error) { + throw new TRPCError({ + code: 'CONFLICT', + message: 'Username already exists.', + cause: error, + }); + } + }), lookup: userAdminProcedure.input(zUserLookupInput).query(async ({ ctx, input }) => { const user = input.id ? await ctx.users.findById(input.id) diff --git a/app/gateway-api/src/config.ts b/app/gateway-api/src/config.ts index 7fbe376..0a32949 100644 --- a/app/gateway-api/src/config.ts +++ b/app/gateway-api/src/config.ts @@ -16,6 +16,7 @@ export interface GatewayApiConfig { kakaoAdminKey?: string; kakaoRedirectUri: string; publicBaseUrl: string; + adminLocalAccountEnabled: boolean; orchestratorEnabled: boolean; orchestratorReconcileIntervalMs: number; orchestratorScheduleIntervalMs: number; @@ -80,6 +81,7 @@ export const resolveGatewayApiConfigFromEnv = (env: NodeJS.ProcessEnv = process. kakaoAdminKey: env.KAKAO_ADMIN_KEY, kakaoRedirectUri, publicBaseUrl, + adminLocalAccountEnabled: parseBooleanWithFallback(env.GATEWAY_ADMIN_LOCAL_ACCOUNT_ENABLED, false), orchestratorEnabled: parseBooleanWithFallback(env.GATEWAY_ORCHESTRATOR_ENABLED, false), orchestratorReconcileIntervalMs: parseNumberWithFallback( env.GATEWAY_ORCHESTRATOR_RECONCILE_MS, diff --git a/app/gateway-api/src/context.ts b/app/gateway-api/src/context.ts index f23e6b9..5d600d1 100644 --- a/app/gateway-api/src/context.ts +++ b/app/gateway-api/src/context.ts @@ -18,6 +18,7 @@ export interface GatewayApiContext { kakaoClient: KakaoOAuthClient; oauthSessions: OAuthSessionStore; publicBaseUrl: string; + adminLocalAccountEnabled: boolean; profiles: GatewayProfileRepository; orchestrator: GatewayOrchestratorHandle; profileStatus: GatewayProfileStatusService; @@ -35,6 +36,7 @@ export const createGatewayApiContext = (options: { kakaoClient: KakaoOAuthClient; oauthSessions: OAuthSessionStore; publicBaseUrl: string; + adminLocalAccountEnabled: boolean; profiles: GatewayProfileRepository; orchestrator: GatewayOrchestratorHandle; profileStatus: GatewayProfileStatusService; @@ -49,6 +51,7 @@ export const createGatewayApiContext = (options: { kakaoClient: options.kakaoClient, oauthSessions: options.oauthSessions, publicBaseUrl: options.publicBaseUrl, + adminLocalAccountEnabled: options.adminLocalAccountEnabled, profiles: options.profiles, orchestrator: options.orchestrator, profileStatus: options.profileStatus, diff --git a/app/gateway-api/src/server.ts b/app/gateway-api/src/server.ts index 70ac2fd..460edf9 100644 --- a/app/gateway-api/src/server.ts +++ b/app/gateway-api/src/server.ts @@ -75,6 +75,7 @@ export const createGatewayApiServer = async () => { kakaoClient, oauthSessions, publicBaseUrl: config.publicBaseUrl, + adminLocalAccountEnabled: config.adminLocalAccountEnabled, profiles, orchestrator, profileStatus, diff --git a/app/gateway-api/test/authFlow.test.ts b/app/gateway-api/test/authFlow.test.ts index 5bdb5cd..fbef070 100644 --- a/app/gateway-api/test/authFlow.test.ts +++ b/app/gateway-api/test/authFlow.test.ts @@ -82,6 +82,7 @@ const buildCaller = () => { kakaoClient: kakaoClient as unknown as KakaoOAuthClient, oauthSessions, publicBaseUrl: 'http://localhost', + adminLocalAccountEnabled: false, profiles, orchestrator, profileStatus, diff --git a/app/gateway-frontend/src/views/AdminView.vue b/app/gateway-frontend/src/views/AdminView.vue index 7965c35..a61fcfb 100644 --- a/app/gateway-frontend/src/views/AdminView.vue +++ b/app/gateway-frontend/src/views/AdminView.vue @@ -53,6 +53,14 @@ type AdminUser = { createdAt: string; }; +type AdminPublicUser = { + id: string; + username: string; + displayName: string; + roles: string[]; + createdAt: string; +}; + type AdminProfile = { profileName: string; profile: string; @@ -133,6 +141,16 @@ type AdminClient = { }; }; users: { + getLocalAccountStatus: { + query: () => Promise<{ enabled: boolean }>; + }; + createLocal: { + mutate: (input: { + username: string; + password: string; + displayName?: string; + }) => Promise<{ user: AdminPublicUser }>; + }; lookup: { query: (input: { id?: string; username?: string; email?: string }) => Promise; }; @@ -297,6 +315,16 @@ const userLoading = ref(false); const userError = ref(''); const userResult = ref(null); +const localAccountEnabled = ref(false); +const localAccountStatus = ref(''); +const localAccountResult = ref(''); +const localAccountLoading = ref(false); +const localAccountForm = ref({ + username: '', + password: '', + displayName: '', +}); + const passwordInput = ref(''); const passwordResult = ref(''); const passwordStatus = ref(''); @@ -322,6 +350,18 @@ const forceDeleteStatus = ref(''); const hasUser = computed(() => Boolean(userResult.value)); +const loadLocalAccountStatus = async () => { + localAccountStatus.value = ''; + try { + const result = await adminClient.users.getLocalAccountStatus.query(); + localAccountEnabled.value = result.enabled; + localAccountStatus.value = result.enabled ? '' : 'ENV 설정이 비활성화 상태입니다.'; + } catch (error) { + localAccountEnabled.value = false; + localAccountStatus.value = '로컬 계정 생성 설정 확인 실패'; + } +}; + const loadNotice = async () => { noticeLoading.value = true; try { @@ -908,7 +948,46 @@ const forceDeleteUser = async () => { } }; +const createLocalAccount = async () => { + localAccountStatus.value = ''; + localAccountResult.value = ''; + if (!localAccountEnabled.value) { + localAccountStatus.value = 'ENV 설정이 비활성화 상태입니다.'; + return; + } + const username = localAccountForm.value.username.trim(); + const password = localAccountForm.value.password.trim(); + const displayName = localAccountForm.value.displayName.trim(); + if (!username || !password) { + localAccountStatus.value = '아이디와 비밀번호를 입력하세요.'; + return; + } + localAccountLoading.value = true; + try { + const result = await adminClient.users.createLocal.mutate({ + username, + password, + displayName: displayName || undefined, + }); + localAccountResult.value = `생성됨: ${result.user.username} (${result.user.id})`; + localAccountStatus.value = '로컬 계정 생성 완료'; + localAccountForm.value = { + username: result.user.username, + password: '', + displayName: '', + }; + userLookupMode.value = 'username'; + userLookupValue.value = result.user.username; + await lookupUser(); + } catch (error) { + localAccountStatus.value = '로컬 계정 생성 실패'; + } finally { + localAccountLoading.value = false; + } +}; + onMounted(() => { + void loadLocalAccountStatus(); void loadNotice(); void loadProfiles(); }); @@ -992,6 +1071,52 @@ onMounted(() => { +
+
+

로컬 계정 생성

+ + ENV {{ localAccountEnabled ? 'ON' : 'OFF' }} + +
+
+ 카카오 OAuth 없이 로그인 가능한 계정을 생성합니다. +
+
+ + + + +
+
{{ localAccountStatus }}
+
+ {{ localAccountResult }} +
+
+

비밀번호 리셋