import axios from 'axios' import { User } from '@things-factory/auth-base' import { config } from '@things-factory/env' import { LabelStudioRoleMapper, LabelStudioPermissions } from './label-studio-role-mapper.js' import { Domain, getRepository } from '@things-factory/shell' type LabelStudioConfig = { serverUrl: string apiToken: string interfaces: string } // Get Label Studio config from server config file function getLabelStudioConfig(): LabelStudioConfig { return config.get('labelStudio', { serverUrl: '', apiToken: '', interfaces: 'panel,controls,annotations:menu' }) } export interface SyncResult { success: boolean email: string action: 'created' | 'updated' | 'deactivated' | 'skipped' | 'error' lsUserId?: string lsPermissions?: string // 'Admin (Full access)' | 'Staff (Labeling only)' | 'Inactive' error?: string } export interface SyncSummary { total: number created: number updated: number deactivated: number skipped: number errors: number results: SyncResult[] } /** * Label Studio 사용자 프로비저닝 서비스 * * Things-Factory 사용자를 Label Studio에 배치 동기화합니다. */ export class UserProvisioningService { /** * 설정 검증 */ private static validateConfig(): void { const config = getLabelStudioConfig() if (!config.apiToken) { throw new Error('Label Studio API token is not configured') } if (!config.serverUrl) { throw new Error('Label Studio server URL is not configured') } } /** * 단일 사용자 동기화 * * @param domain Things-Factory 도메인 * @param user Things-Factory 사용자 * @returns 동기화 결과 */ static async syncUser(domain: Domain, user: User): Promise { // 설정 검증 this.validateConfig() const config = getLabelStudioConfig() try { // 1. Label Studio 권한 확인 const hasLSPrivilege = (await User.hasPrivilege('label-studio', 'query', domain, user)) || (await User.hasPrivilege('label-studio', 'mutation', domain, user)) if (!hasLSPrivilege) { // Label Studio 권한 없음 → Label Studio에서 비활성화 const deactivated = await this.deactivateUser(user.email, config) return { success: true, email: user.email, action: deactivated ? 'deactivated' : 'skipped' } } // 2. Label Studio 권한 매핑 const lsPermissions = await LabelStudioRoleMapper.mapUserPermissions(domain, user) // 3. Label Studio API로 사용자 생성 또는 업데이트 const result = await this.createOrUpdateLabelStudioUser(user, lsPermissions, config) return { success: true, email: user.email, action: result.created ? 'created' : 'updated', lsUserId: result.id.toString(), lsPermissions: LabelStudioRoleMapper.getPermissionsDescription(lsPermissions) } } catch (error: any) { console.error(`Failed to sync user ${user.email}:`, error.message) return { success: false, email: user.email, action: 'error', error: error.message } } } static async getDomainUsers(domain: Domain): Promise { const qb = getRepository(User).createQueryBuilder('USER') qb.select().andWhere(qb => { const subQuery = qb .subQuery() .select('USERS_DOMAINS.users_id') .from('users_domains', 'USERS_DOMAINS') .where('USERS_DOMAINS.domains_id = :domainId', { domainId: domain.id }) .getQuery() return 'USER.id IN ' + subQuery }) const [items, total] = await qb.getManyAndCount() const foundUsers: User[] = items.map((item: User) => { item.owner = item.id === domain.owner return item }) return foundUsers } /** * 도메인의 모든 사용자 일괄 동기화 * * @param domain Things-Factory 도메인 * @returns 동기화 요약 */ static async syncAllUsers(domain: Domain): Promise { // 설정 검증 this.validateConfig() // 도메인의 모든 활성 사용자 조회 const users = await UserProvisioningService.getDomainUsers(domain) console.log(`🔄 Starting batch sync for ${users.length} users...`) const results: SyncResult[] = [] // 각 사용자 동기화 for (const user of users) { const result = await this.syncUser(domain, user) results.push(result) // API Rate Limiting 방지 await this.sleep(100) } // 요약 생성 const summary: SyncSummary = { total: users.length, created: results.filter(r => r.action === 'created').length, updated: results.filter(r => r.action === 'updated').length, deactivated: results.filter(r => r.action === 'deactivated').length, skipped: results.filter(r => r.action === 'skipped').length, errors: results.filter(r => r.action === 'error').length, results } console.log(`✅ Batch sync completed:`, { total: summary.total, created: summary.created, updated: summary.updated, deactivated: summary.deactivated, skipped: summary.skipped, errors: summary.errors }) return summary } /** * Label Studio API를 통해 사용자 생성 또는 업데이트 */ private static async createOrUpdateLabelStudioUser( user: User, lsPermissions: LabelStudioPermissions, config: LabelStudioConfig ): Promise { const apiUrl = this.buildApiUrl(config.serverUrl, '/api/users') // 이름 파싱 const nameParts = (user.name || user.email).split(' ') const firstName = nameParts[0] || user.email const lastName = nameParts.slice(1).join(' ') || '' try { // 이메일로 기존 사용자 조회 const searchResponse = await axios.get(apiUrl, { headers: { Authorization: `Token ${config.apiToken}` }, params: { email: user.email } }) if (searchResponse.data.results && searchResponse.data.results.length > 0) { // 기존 사용자 업데이트 const existingUser = searchResponse.data.results[0] const updateResponse = await axios.patch( `${apiUrl}/${existingUser.id}/`, { email: user.email, username: user.email, first_name: firstName, last_name: lastName, is_superuser: lsPermissions.is_superuser, is_staff: lsPermissions.is_staff, is_active: lsPermissions.is_active }, { headers: { Authorization: `Token ${config.apiToken}`, 'Content-Type': 'application/json' } } ) return { ...updateResponse.data, created: false } } else { // 새 사용자 생성 const createResponse = await axios.post( apiUrl, { email: user.email, username: user.email, first_name: firstName, last_name: lastName, password: this.generateRandomPassword(), is_superuser: lsPermissions.is_superuser, is_staff: lsPermissions.is_staff, is_active: lsPermissions.is_active }, { headers: { Authorization: `Token ${config.apiToken}`, 'Content-Type': 'application/json' } } ) return { ...createResponse.data, created: true } } } catch (error: any) { console.error(`Label Studio API error for ${user.email}:`, error.response?.data || error.message) throw error } } /** * Label Studio에서 사용자 비활성화 */ private static async deactivateUser(email: string, config: LabelStudioConfig): Promise { const apiUrl = this.buildApiUrl(config.serverUrl, '/api/users') try { // 이메일로 사용자 조회 const searchResponse = await axios.get(apiUrl, { headers: { Authorization: `Token ${config.apiToken}` }, params: { email } }) if (searchResponse.data.results && searchResponse.data.results.length > 0) { const user = searchResponse.data.results[0] // 비활성화 await axios.patch( `${apiUrl}/${user.id}/`, { is_active: false }, { headers: { Authorization: `Token ${config.apiToken}`, 'Content-Type': 'application/json' } } ) return true } return false } catch (error: any) { console.error(`Failed to deactivate user ${email}:`, error.message) return false } } /** * API URL 빌드 */ private static buildApiUrl(serverUrl: string, path: string): string { let url = serverUrl.replace(/\/$/, '') if (!url.startsWith('http://') && !url.startsWith('https://')) { url = `https://${url}` } return `${url}${path}` } /** * 랜덤 비밀번호 생성 (SSO 사용으로 실제로는 사용 안 됨) */ private static generateRandomPassword(): string { return Math.random().toString(36).slice(-16) + Math.random().toString(36).slice(-16) } /** * Sleep 유틸리티 */ private static sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)) } }