From c97b3c6c3b83294a90bd7dead08142eafc867950 Mon Sep 17 00:00:00 2001 From: icurfer Date: Wed, 14 Jan 2026 20:59:37 +0900 Subject: [PATCH] =?UTF-8?q?v0.0.21=20|=20NHN=20Cloud=20=EB=A9=80=ED=8B=B0?= =?UTF-8?q?=20=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8=20=EC=A7=80=EC=9B=90=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NHNCloudProject 모델 추가 (사용자별 여러 프로젝트 관리) - 프로젝트 목록/추가/삭제/활성화 API 추가 - 프로젝트별 비밀번호 복호화 API 추가 Co-Authored-By: Claude Opus 4.5 --- users/migrations/0010_nhncloudproject.py | 36 ++++ users/models.py | 55 ++++++ users/urls.py | 12 +- users/views.py | 210 ++++++++++++++++++++++- version | 2 +- 5 files changed, 311 insertions(+), 4 deletions(-) create mode 100644 users/migrations/0010_nhncloudproject.py diff --git a/users/migrations/0010_nhncloudproject.py b/users/migrations/0010_nhncloudproject.py new file mode 100644 index 0000000..2224edd --- /dev/null +++ b/users/migrations/0010_nhncloudproject.py @@ -0,0 +1,36 @@ +# Generated by Django 4.2.14 on 2026-01-14 11:59 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0009_add_nhn_cloud_credentials'), + ] + + operations = [ + migrations.CreateModel( + name='NHNCloudProject', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, verbose_name='프로젝트 별칭')), + ('tenant_id', models.CharField(max_length=64, verbose_name='NHN Cloud Tenant ID')), + ('username', models.EmailField(max_length=254, verbose_name='NHN Cloud Username')), + ('encrypted_password', models.BinaryField(verbose_name='NHN Cloud API Password (암호화)')), + ('storage_account', models.CharField(blank=True, max_length=128, null=True, verbose_name='Storage Account')), + ('is_active', models.BooleanField(default=False, verbose_name='활성 프로젝트')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='nhn_projects', to=settings.AUTH_USER_MODEL, verbose_name='사용자')), + ], + options={ + 'verbose_name': 'NHN Cloud 프로젝트', + 'verbose_name_plural': 'NHN Cloud 프로젝트', + 'ordering': ['-is_active', '-created_at'], + 'unique_together': {('user', 'tenant_id')}, + }, + ), + ] diff --git a/users/models.py b/users/models.py index 184057e..7ea3e75 100644 --- a/users/models.py +++ b/users/models.py @@ -142,3 +142,58 @@ class CustomUser(AbstractBaseUser, PermissionsMixin): self.save(update_fields=[ 'nhn_tenant_id', 'nhn_username', 'encrypted_nhn_api_password', 'nhn_storage_account' ]) + + +# ============================================ +# NHN Cloud 프로젝트 (멀티 프로젝트 지원) +# ============================================ + +class NHNCloudProject(models.Model): + """ + 사용자별 NHN Cloud 프로젝트 관리 (멀티 프로젝트 지원) + """ + user = models.ForeignKey( + CustomUser, + on_delete=models.CASCADE, + related_name='nhn_projects', + verbose_name="사용자" + ) + name = models.CharField(max_length=100, verbose_name="프로젝트 별칭") + tenant_id = models.CharField(max_length=64, verbose_name="NHN Cloud Tenant ID") + username = models.EmailField(verbose_name="NHN Cloud Username") + encrypted_password = models.BinaryField(verbose_name="NHN Cloud API Password (암호화)") + storage_account = models.CharField(max_length=128, blank=True, null=True, verbose_name="Storage Account") + is_active = models.BooleanField(default=False, verbose_name="활성 프로젝트") + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = "NHN Cloud 프로젝트" + verbose_name_plural = "NHN Cloud 프로젝트" + unique_together = ['user', 'tenant_id'] # 동일 사용자가 같은 tenant 중복 등록 방지 + ordering = ['-is_active', '-created_at'] + + def __str__(self): + return f"{self.name} ({self.tenant_id})" + + def get_encryption_key(self) -> bytes: + """SECRET_KEY 기반 Fernet 키 생성""" + hashed = hashlib.sha256(settings.SECRET_KEY.encode()).digest() + return base64.urlsafe_b64encode(hashed[:32]) + + def encrypt_password(self, password: str) -> bytes: + """비밀번호 암호화""" + cipher = Fernet(self.get_encryption_key()) + return cipher.encrypt(password.encode()) + + def decrypt_password(self) -> str: + """비밀번호 복호화""" + if self.encrypted_password: + cipher = Fernet(self.get_encryption_key()) + return cipher.decrypt(self.encrypted_password).decode() + return "" + + def save_credentials(self, password: str): + """자격증명 저장 (비밀번호 암호화)""" + self.encrypted_password = self.encrypt_password(password) + self.save() diff --git a/users/urls.py b/users/urls.py index f4d0b3b..2587c33 100644 --- a/users/urls.py +++ b/users/urls.py @@ -3,7 +3,10 @@ from .views import ( RegisterView, MeView, CustomTokenObtainPairView, SSHKeyUploadView, SSHKeyInfoView, SSHKeyRetrieveView, UserListView, UserUpdateView, - NHNCloudCredentialsView, NHNCloudPasswordView + NHNCloudCredentialsView, NHNCloudPasswordView, + # NHN Cloud 멀티 프로젝트 지원 + NHNCloudProjectListView, NHNCloudProjectDetailView, + NHNCloudProjectActivateView, NHNCloudProjectPasswordView, ) from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView, TokenVerifyView from .views_jwks import jwks_view # django-jwks @@ -22,7 +25,12 @@ urlpatterns = [ # 관리자용 사용자 관리 API path('users/', UserListView.as_view(), name='user_list'), path('users//', UserUpdateView.as_view(), name='user_update'), - # NHN Cloud 자격증명 API + # NHN Cloud 자격증명 API (기존 - 단일 프로젝트 호환) path('nhn-cloud/', NHNCloudCredentialsView.as_view(), name='nhn_cloud_credentials'), path('nhn-cloud/password/', NHNCloudPasswordView.as_view(), name='nhn_cloud_password'), + # NHN Cloud 프로젝트 API (멀티 프로젝트 지원) + path('nhn-cloud/projects/', NHNCloudProjectListView.as_view(), name='nhn_cloud_projects'), + path('nhn-cloud/projects//', NHNCloudProjectDetailView.as_view(), name='nhn_cloud_project_detail'), + path('nhn-cloud/projects//activate/', NHNCloudProjectActivateView.as_view(), name='nhn_cloud_project_activate'), + path('nhn-cloud/projects//password/', NHNCloudProjectPasswordView.as_view(), name='nhn_cloud_project_password'), ] diff --git a/users/views.py b/users/views.py index 865949b..704960d 100644 --- a/users/views.py +++ b/users/views.py @@ -8,7 +8,7 @@ from rest_framework.permissions import IsAuthenticated, BasePermission from rest_framework_simplejwt.views import TokenObtainPairView from rest_framework import generics from .serializers import RegisterSerializer, CustomTokenObtainPairSerializer, UserListSerializer -from .models import CustomUser +from .models import CustomUser, NHNCloudProject logger = logging.getLogger(__name__) tracer = trace.get_tracer(__name__) # ✅ 트레이서 생성 @@ -430,3 +430,211 @@ class NHNCloudPasswordView(APIView): f"[NHN PASSWORD GET] user={email} | status=fail | reason=exception | IP={ip} | UA={ua}" ) return Response({"error": f"복호화 실패: {str(e)}"}, status=500) + + +# ============================================ +# NHN Cloud 프로젝트 관리 API (멀티 프로젝트 지원) +# ============================================ + +class NHNCloudProjectListView(APIView): + """NHN Cloud 프로젝트 목록 조회 및 추가""" + permission_classes = [IsAuthenticated] + + def get(self, request): + """프로젝트 목록 조회""" + with tracer.start_as_current_span("NHNCloudProjectListView GET") as span: + email, ip, ua = get_request_info(request) + projects = NHNCloudProject.objects.filter(user=request.user) + + logger.debug(f"[NHN PROJECT LIST] user={email} | count={projects.count()} | IP={ip} | UA={ua}") + span.add_event("NHN projects listed", attributes={"email": email, "count": projects.count()}) + + data = [{ + "id": p.id, + "name": p.name, + "tenant_id": p.tenant_id, + "username": p.username, + "storage_account": p.storage_account or "", + "is_active": p.is_active, + "created_at": p.created_at.isoformat(), + } for p in projects] + + return Response(data) + + def post(self, request): + """프로젝트 추가""" + with tracer.start_as_current_span("NHNCloudProjectListView POST") as span: + email, ip, ua = get_request_info(request) + + name = request.data.get("name") + tenant_id = request.data.get("tenant_id") + username = request.data.get("username") + password = request.data.get("password") + storage_account = request.data.get("storage_account", "") + + if not name or not tenant_id or not username or not password: + logger.warning( + f"[NHN PROJECT CREATE] user={email} | status=fail | reason=missing_fields | IP={ip} | UA={ua}" + ) + return Response( + {"error": "name, tenant_id, username, password는 필수입니다."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # 중복 체크 + if NHNCloudProject.objects.filter(user=request.user, tenant_id=tenant_id).exists(): + logger.warning( + f"[NHN PROJECT CREATE] user={email} | status=fail | reason=duplicate | IP={ip} | UA={ua}" + ) + return Response( + {"error": "이미 등록된 Tenant ID입니다."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + # 첫 프로젝트면 자동 활성화 + is_first = not NHNCloudProject.objects.filter(user=request.user).exists() + + project = NHNCloudProject( + user=request.user, + name=name, + tenant_id=tenant_id, + username=username, + storage_account=storage_account, + is_active=is_first, + ) + project.save_credentials(password) + + logger.info( + f"[NHN PROJECT CREATE] user={email} | project={name} | is_active={is_first} | IP={ip} | UA={ua}" + ) + span.add_event("NHN project created", attributes={"email": email, "project": name}) + + return Response({ + "id": project.id, + "name": project.name, + "tenant_id": project.tenant_id, + "username": project.username, + "storage_account": project.storage_account or "", + "is_active": project.is_active, + "created_at": project.created_at.isoformat(), + }, status=status.HTTP_201_CREATED) + + except Exception as e: + logger.exception( + f"[NHN PROJECT CREATE] user={email} | status=fail | reason=exception | IP={ip} | UA={ua}" + ) + return Response({"error": f"프로젝트 생성 실패: {str(e)}"}, status=500) + + +class NHNCloudProjectDetailView(APIView): + """NHN Cloud 프로젝트 상세 (삭제)""" + permission_classes = [IsAuthenticated] + + def get_project(self, request, project_id): + """프로젝트 조회 (본인 것만)""" + try: + return NHNCloudProject.objects.get(id=project_id, user=request.user) + except NHNCloudProject.DoesNotExist: + return None + + def delete(self, request, project_id): + """프로젝트 삭제""" + with tracer.start_as_current_span("NHNCloudProjectDetailView DELETE") as span: + email, ip, ua = get_request_info(request) + + project = self.get_project(request, project_id) + if not project: + logger.warning( + f"[NHN PROJECT DELETE] user={email} | status=fail | reason=not_found | IP={ip} | UA={ua}" + ) + return Response({"error": "프로젝트를 찾을 수 없습니다."}, status=404) + + project_name = project.name + was_active = project.is_active + project.delete() + + # 삭제된 프로젝트가 활성이었으면 다른 프로젝트 활성화 + if was_active: + other_project = NHNCloudProject.objects.filter(user=request.user).first() + if other_project: + other_project.is_active = True + other_project.save(update_fields=['is_active']) + + logger.info( + f"[NHN PROJECT DELETE] user={email} | project={project_name} | IP={ip} | UA={ua}" + ) + span.add_event("NHN project deleted", attributes={"email": email, "project": project_name}) + + return Response(status=status.HTTP_204_NO_CONTENT) + + +class NHNCloudProjectActivateView(APIView): + """NHN Cloud 프로젝트 활성화""" + permission_classes = [IsAuthenticated] + + def patch(self, request, project_id): + """프로젝트 활성화 (기존 활성 해제)""" + with tracer.start_as_current_span("NHNCloudProjectActivateView PATCH") as span: + email, ip, ua = get_request_info(request) + + try: + project = NHNCloudProject.objects.get(id=project_id, user=request.user) + except NHNCloudProject.DoesNotExist: + logger.warning( + f"[NHN PROJECT ACTIVATE] user={email} | status=fail | reason=not_found | IP={ip} | UA={ua}" + ) + return Response({"error": "프로젝트를 찾을 수 없습니다."}, status=404) + + # 기존 활성 프로젝트 비활성화 + NHNCloudProject.objects.filter(user=request.user, is_active=True).update(is_active=False) + + # 새 프로젝트 활성화 + project.is_active = True + project.save(update_fields=['is_active']) + + logger.info( + f"[NHN PROJECT ACTIVATE] user={email} | project={project.name} | IP={ip} | UA={ua}" + ) + span.add_event("NHN project activated", attributes={"email": email, "project": project.name}) + + return Response({ + "message": "프로젝트가 활성화되었습니다.", + "id": project.id, + "name": project.name, + }) + + +class NHNCloudProjectPasswordView(APIView): + """NHN Cloud 프로젝트 비밀번호 조회 (복호화)""" + permission_classes = [IsAuthenticated] + + def get(self, request, project_id): + """복호화된 비밀번호 조회""" + with tracer.start_as_current_span("NHNCloudProjectPasswordView GET") as span: + email, ip, ua = get_request_info(request) + + try: + project = NHNCloudProject.objects.get(id=project_id, user=request.user) + except NHNCloudProject.DoesNotExist: + logger.warning( + f"[NHN PROJECT PASSWORD] user={email} | status=fail | reason=not_found | IP={ip} | UA={ua}" + ) + return Response({"error": "프로젝트를 찾을 수 없습니다."}, status=404) + + try: + decrypted_password = project.decrypt_password() + logger.info(f"[NHN PROJECT PASSWORD] user={email} | project={project.name} | IP={ip} | UA={ua}") + span.add_event("NHN project password retrieved", attributes={"email": email, "project": project.name}) + + return Response({ + "tenant_id": project.tenant_id, + "username": project.username, + "password": decrypted_password, + "storage_account": project.storage_account or "", + }) + except Exception as e: + logger.exception( + f"[NHN PROJECT PASSWORD] user={email} | status=fail | reason=exception | IP={ip} | UA={ua}" + ) + return Response({"error": f"복호화 실패: {str(e)}"}, status=500) diff --git a/version b/version index 3ff45f3..ad3e98d 100644 --- a/version +++ b/version @@ -1 +1 @@ -v0.0.20 \ No newline at end of file +v0.0.21