From b3eb6df3afb57b11240d71a4ffd28f0ba240844d Mon Sep 17 00:00:00 2001
From: yessjun
Date: Tue, 11 Aug 2026 01:31:47 +0900
Subject: [PATCH 01/11] feat: give every id that crosses the API a public UUID
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Sequential ids in URLs disclose the platform's size and its growth rate:
before V78 the number in a VM or domain URL *was* the row count. Each
exposed table gains a public_id column; the bigint primary keys, the
foreign keys and the id-ordered sorts all stay as they were.
Resolution happens at the gates that already own the masking 404 —
VmAccessService, AdminVmAccess, the grant service and the few single
findById sites where a path id first lands — so nothing below a gate
changes and no refusal wording had to be re-picked. A well-formed bigint
in a path is no longer a well-formed id, so /vms/999999 now answers 422
where it answered 404.
The JWT subject becomes the account's public id and the write-only
org_id claim is dropped. audit_logs.target_id records the target's
public id; a refresh token has no public identity, so those rows carry
the internal id in the detail map instead of in the target column.
The organisation slug goes with the numbers (V78 drops the column): it
existed to name an org in a URL, which the public id now does. Its
unique index was also the only duplicate protection POST /admin/orgs
had, so org names may now repeat.
---
.../pickle/access/ResourceAccessGrant.java | 13 +++
.../access/ResourceAccessGrantRepository.java | 4 +
.../access/ResourceAccessGrantService.java | 53 +++++----
.../access/VmAccessGrantController.java | 13 +--
.../pusan/pickle/access/VmAccessService.java | 7 +-
.../dto/AddResourceAccessGrantRequest.java | 3 +-
.../access/dto/ResourceAccessGrantView.java | 5 +-
.../dto/ResourceAccessListResponse.java | 11 +-
.../pusan/pickle/admin/AdminController.java | 5 +-
.../pickle/admin/AdminDriftController.java | 3 +-
.../pusan/pickle/admin/AdminDriftService.java | 45 +++++---
.../pickle/admin/AdminInventoryService.java | 39 ++++---
.../admin/AdminIpAllocationController.java | 23 ++--
.../pickle/admin/AdminNodeController.java | 5 +-
.../pickle/admin/AdminNodeMetricsService.java | 5 +-
.../pickle/admin/AdminNodeQueryService.java | 2 +-
.../admin/AdminNotificationController.java | 3 +-
.../admin/AdminNotificationService.java | 18 ++--
.../pickle/admin/AdminOsImageController.java | 3 +-
.../pickle/admin/AdminRequestController.java | 11 +-
.../ac/pusan/pickle/admin/AdminService.java | 43 +++-----
.../pickle/admin/AdminSummaryController.java | 5 +-
.../pickle/admin/AdminSummaryService.java | 22 ++--
.../pickle/admin/AdminTaskController.java | 5 +-
.../pusan/pickle/admin/AdminTaskService.java | 26 +++--
.../pickle/admin/AdminUserController.java | 11 +-
.../pickle/admin/AdminUserQueryService.java | 72 +++++++++----
.../pusan/pickle/admin/AdminUserService.java | 19 ++--
.../pusan/pickle/admin/AdminVmController.java | 27 ++---
.../pickle/admin/AdminVmFlavorController.java | 3 +-
.../pickle/admin/AdminVmQueryService.java | 45 +++++---
.../admin/AdminWorkspaceController.java | 5 +-
.../admin/AdminWorkspaceQueryService.java | 36 +++++--
.../pickle/admin/ApprovalContextService.java | 12 ++-
.../pusan/pickle/admin/ApprovalService.java | 35 +++---
.../pickle/admin/CapacityTrendService.java | 3 +-
.../pickle/admin/VmGatewayBlockService.java | 9 +-
.../pusan/pickle/admin/VmPeriodService.java | 8 +-
.../admin/dto/AdminNotificationResponse.java | 5 +-
.../admin/dto/AdminOsImageResponse.java | 11 +-
.../pickle/admin/dto/AdminTaskResponse.java | 15 +--
.../dto/AdminWorkspaceDetailResponse.java | 3 +-
.../dto/AdminWorkspaceMemberResponse.java | 3 +-
.../dto/AdminWorkspaceOptionResponse.java | 3 +-
.../admin/dto/ApprovalContextResponse.java | 9 +-
.../admin/dto/ApproveVmRequestSpec.java | 5 +-
.../pickle/admin/dto/CreateOrgRequest.java | 6 --
.../admin/dto/DriftFindingResponse.java | 13 +--
.../admin/dto/IpAllocationResponse.java | 14 +--
.../admin/dto/IpPoolSummaryResponse.java | 5 +-
.../pickle/admin/dto/NodeLiveResponse.java | 5 +-
.../pickle/admin/dto/NodeSummaryResponse.java | 3 +-
.../pickle/admin/dto/OrgDetailResponse.java | 6 +-
.../dto/SystemDashboardSummaryResponse.java | 5 +-
.../admin/dto/UpdateUserAdminRequest.java | 3 +-
.../admin/dto/UserAdminDetailResponse.java | 5 +-
.../admin/dto/UserAdminViewResponse.java | 5 +-
.../admin/dto/UserStatusChangeResponse.java | 3 +-
.../pickle/admin/dto/VmBriefResponse.java | 5 +-
.../pickle/announcement/Announcement.java | 15 +++
.../AnnouncementQueryService.java | 25 ++++-
.../announcement/AnnouncementRepository.java | 5 +
.../announcement/AnnouncementService.java | 25 +++--
.../dto/AnnouncementCreateRequest.java | 5 +-
.../announcement/dto/AnnouncementView.java | 13 +--
.../pickle/audit/AdminAuditController.java | 3 +-
.../pusan/pickle/audit/AuditQueryService.java | 33 +++---
.../ac/pusan/pickle/audit/AuditService.java | 15 ++-
.../audit/dto/ActivityEntryResponse.java | 4 +-
.../audit/dto/AuditLogViewResponse.java | 7 +-
.../kr/ac/pusan/pickle/auth/AuthService.java | 28 ++---
.../ac/pusan/pickle/auth/ReauthService.java | 4 +-
.../AdminCampusIpRequestController.java | 5 +-
.../pickle/campusip/CampusIpRequest.java | 13 +++
.../campusip/CampusIpRequestController.java | 9 +-
.../campusip/CampusIpRequestRepository.java | 4 +
.../campusip/CampusIpRequestService.java | 100 +++++++++++------
.../dto/AdminCampusIpRequestView.java | 11 +-
.../campusip/dto/CampusIpRequestView.java | 7 +-
.../pusan/pickle/common/error/ErrorCodes.java | 1 -
.../pickle/common/openapi/OpenApiConfig.java | 6 +-
.../kr/ac/pusan/pickle/inventory/Node.java | 13 +++
.../pickle/inventory/NodeRepository.java | 5 +
.../kr/ac/pusan/pickle/inventory/OsImage.java | 13 +++
.../pickle/inventory/OsImageRepository.java | 5 +
.../ac/pusan/pickle/inventory/VmFlavor.java | 13 +++
.../pickle/inventory/VmFlavorRepository.java | 5 +
.../pickle/inventory/dto/OsImageResponse.java | 5 +-
.../inventory/dto/VmFlavorResponse.java | 5 +-
.../kr/ac/pusan/pickle/ipam/IpAllocation.java | 13 +++
.../pickle/ipam/IpAllocationRepository.java | 4 +
.../java/kr/ac/pusan/pickle/ipam/IpPool.java | 13 +++
.../pusan/pickle/ipam/IpPoolRepository.java | 4 +
.../kr/ac/pusan/pickle/mfa/MfaService.java | 6 +-
.../pickle/notification/Notification.java | 13 +++
.../notification/NotificationController.java | 3 +-
.../notification/NotificationRepository.java | 4 +
.../notification/NotificationService.java | 6 +-
.../notification/dto/NotificationView.java | 5 +-
.../java/kr/ac/pusan/pickle/orgs/Org.java | 23 ++--
.../ac/pusan/pickle/orgs/OrgRepository.java | 12 ++-
.../pickle/orgs/dto/OrgSummaryResponse.java | 5 +-
.../pickle/provisioning/DriftFinding.java | 13 +++
.../provisioning/DriftFindingRepository.java | 5 +
.../pickle/provisioning/ProvisioningTask.java | 13 +++
.../ProvisioningTaskRepository.java | 4 +
.../publishing/AdminPublishingController.java | 13 +--
.../publishing/AdminPublishingService.java | 72 ++++++++-----
.../pusan/pickle/publishing/Certificate.java | 13 +++
.../publishing/CertificateRepository.java | 4 +
.../kr/ac/pusan/pickle/publishing/Domain.java | 13 +++
.../pickle/publishing/DomainRepository.java | 4 +
.../publishing/PublicationAssembler.java | 13 +--
.../publishing/PublishingController.java | 13 +--
.../pickle/publishing/PublishingService.java | 81 +++++++++-----
.../kr/ac/pusan/pickle/publishing/Route.java | 13 +++
.../pickle/publishing/RouteRepository.java | 4 +
.../publishing/dto/AdminCertificateView.java | 5 +-
.../publishing/dto/AdminDomainView.java | 9 +-
.../pickle/publishing/dto/AdminRouteView.java | 11 +-
.../publishing/dto/DomainDetailView.java | 5 +-
.../publishing/dto/DomainSummaryView.java | 5 +-
.../relay/AdminPortMappingController.java | 13 +--
.../pickle/relay/AdminPortMappingService.java | 72 ++++++++-----
.../pickle/relay/AdminRelayController.java | 3 +-
.../pusan/pickle/relay/AdminRelayService.java | 11 +-
.../relay/PortForwardingController.java | 9 +-
.../pickle/relay/PortForwardingService.java | 32 +++---
.../kr/ac/pusan/pickle/relay/PortMapping.java | 15 +++
.../pickle/relay/PortMappingRepository.java | 4 +
.../java/kr/ac/pusan/pickle/relay/Relay.java | 13 +++
.../pusan/pickle/relay/RelayRepository.java | 4 +
.../pusan/pickle/relay/RelaySyncService.java | 26 +++--
.../relay/dto/AdminPortMappingResponse.java | 11 +-
.../pickle/relay/dto/AdminRelayView.java | 3 +-
.../pickle/relay/dto/PortForwardingView.java | 3 +-
.../pickle/relay/dto/RelayTokenResponse.java | 3 +-
.../kr/ac/pusan/pickle/request/Request.java | 13 +++
.../pickle/request/RequestAssembler.java | 53 +++++++--
.../pickle/request/RequestController.java | 7 +-
.../pickle/request/RequestRepository.java | 8 +-
.../pusan/pickle/request/RequestService.java | 32 +++---
.../request/dto/CreateRequestRequest.java | 5 +-
.../request/dto/RequestDetailResponse.java | 13 ++-
.../request/dto/RequestReviewResponse.java | 7 +-
.../pickle/request/vm/VmRequestSupport.java | 26 +++--
.../request/vm/dto/CreateVmRequestSpec.java | 5 +-
.../request/vm/dto/VmGrantedSpecResponse.java | 5 +-
.../request/vm/dto/VmRequestSpecResponse.java | 12 ++-
.../pickle/resource/ResourceIdentity.java | 4 +-
.../resource/ResourceIndexController.java | 3 +-
.../pickle/resource/ResourceIndexService.java | 3 +-
.../pickle/resource/ResourceTypeAdapter.java | 11 +-
.../pickle/resource/VmResourceAdapter.java | 20 +++-
.../resource/dto/ResourceSummaryResponse.java | 5 +-
.../pickle/security/AuthenticatedUser.java | 12 ++-
.../security/JwtAuthenticationFilter.java | 10 +-
.../ac/pusan/pickle/security/JwtService.java | 20 ++--
.../ac/pusan/pickle/seed/DevDataSeeder.java | 7 +-
.../pickle/sshgw/SshGatewayRouteService.java | 14 ++-
.../sshgw/SshGatewaySessionService.java | 13 ++-
.../pickle/sshkey/MeSshKeyController.java | 5 +-
.../kr/ac/pusan/pickle/sshkey/UserSshKey.java | 15 +++
.../pickle/sshkey/UserSshKeyRepository.java | 4 +
.../pickle/sshkey/UserSshKeyService.java | 17 +--
.../pusan/pickle/sshkey/dto/SshKeyView.java | 5 +-
.../pickle/terminal/TerminalController.java | 3 +-
.../pickle/terminal/TerminalService.java | 21 +++-
.../ac/pusan/pickle/user/AccountService.java | 4 +-
.../kr/ac/pusan/pickle/user/MeController.java | 13 ++-
.../java/kr/ac/pusan/pickle/user/User.java | 13 +++
.../ac/pusan/pickle/user/UserRepository.java | 4 +
.../pickle/user/dto/UserProfileResponse.java | 12 ++-
.../kr/ac/pusan/pickle/vm/AdminVmAccess.java | 5 +-
src/main/java/kr/ac/pusan/pickle/vm/Vm.java | 13 +++
.../kr/ac/pusan/pickle/vm/VmController.java | 27 ++---
.../ac/pusan/pickle/vm/VmDeletionService.java | 53 ++++-----
.../java/kr/ac/pusan/pickle/vm/VmEvent.java | 13 +++
.../ac/pusan/pickle/vm/VmEventRepository.java | 5 +
.../pusan/pickle/vm/VmLifecycleService.java | 71 ++++++------
.../ac/pusan/pickle/vm/VmMetricsService.java | 3 +-
.../ac/pusan/pickle/vm/VmPasswordService.java | 18 ++--
.../kr/ac/pusan/pickle/vm/VmQueryService.java | 97 ++++++++++++-----
.../kr/ac/pusan/pickle/vm/VmRepository.java | 5 +
.../pickle/vm/dto/VmDeletionResponse.java | 7 +-
.../pusan/pickle/vm/dto/VmDetailResponse.java | 30 +++---
.../pusan/pickle/vm/dto/VmEventResponse.java | 9 +-
.../ac/pusan/pickle/vm/dto/VmReferences.java | 25 +++++
.../pickle/vm/dto/VmSummaryResponse.java | 28 ++---
.../pickle/vmsettings/VmSettingsService.java | 12 ++-
.../ac/pusan/pickle/workspace/Workspace.java | 13 +++
.../pickle/workspace/WorkspaceController.java | 17 +--
.../pickle/workspace/WorkspaceRepository.java | 6 ++
.../pickle/workspace/WorkspaceService.java | 70 ++++++++----
.../dto/WorkspaceDetailResponse.java | 5 +-
.../dto/WorkspaceSummaryResponse.java | 5 +-
.../db/migration/V78__public_ids.sql | 102 ++++++++++++++++++
197 files changed, 2024 insertions(+), 876 deletions(-)
create mode 100644 src/main/java/kr/ac/pusan/pickle/vm/dto/VmReferences.java
create mode 100644 src/main/resources/db/migration/V78__public_ids.sql
diff --git a/src/main/java/kr/ac/pusan/pickle/access/ResourceAccessGrant.java b/src/main/java/kr/ac/pusan/pickle/access/ResourceAccessGrant.java
index cd3b3f4d..ddfb1267 100644
--- a/src/main/java/kr/ac/pusan/pickle/access/ResourceAccessGrant.java
+++ b/src/main/java/kr/ac/pusan/pickle/access/ResourceAccessGrant.java
@@ -9,6 +9,7 @@
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
+import java.util.UUID;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.annotations.UpdateTimestamp;
@@ -33,6 +34,14 @@ public class ResourceAccessGrant {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
+ /**
+ * The identifier this row wears outside the API boundary. Internal joins,
+ * sorts and foreign keys keep using {@link #id}.
+ */
+ @JdbcTypeCode(SqlTypes.UUID)
+ @Column(name = "public_id", nullable = false, updatable = false, unique = true)
+ private UUID publicId = UUID.randomUUID();
+
@Enumerated(EnumType.STRING)
@JdbcTypeCode(SqlTypes.NAMED_ENUM)
@Column(name = "resource_type", nullable = false, columnDefinition = "resource_type")
@@ -90,6 +99,10 @@ public Long getId() {
return id;
}
+ public UUID getPublicId() {
+ return publicId;
+ }
+
public ResourceType getResourceType() {
return resourceType;
}
diff --git a/src/main/java/kr/ac/pusan/pickle/access/ResourceAccessGrantRepository.java b/src/main/java/kr/ac/pusan/pickle/access/ResourceAccessGrantRepository.java
index 3a1cf1dd..4b7c2f64 100644
--- a/src/main/java/kr/ac/pusan/pickle/access/ResourceAccessGrantRepository.java
+++ b/src/main/java/kr/ac/pusan/pickle/access/ResourceAccessGrantRepository.java
@@ -3,6 +3,7 @@
import java.util.Collection;
import java.util.List;
import java.util.Optional;
+import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
@@ -10,6 +11,9 @@
public interface ResourceAccessGrantRepository extends JpaRepository {
+ /** Resolution of the identifier this row wears outside the API boundary. */
+ Optional findByPublicId(UUID publicId);
+
/**
* Every grant on one resource — the two rows the judgment needs (the
* requester's own and the workspace-wide one) plus the list surfaces' contents.
diff --git a/src/main/java/kr/ac/pusan/pickle/access/ResourceAccessGrantService.java b/src/main/java/kr/ac/pusan/pickle/access/ResourceAccessGrantService.java
index 7724a200..498c3894 100644
--- a/src/main/java/kr/ac/pusan/pickle/access/ResourceAccessGrantService.java
+++ b/src/main/java/kr/ac/pusan/pickle/access/ResourceAccessGrantService.java
@@ -4,6 +4,7 @@
import java.util.List;
import java.util.Map;
import java.util.Objects;
+import java.util.UUID;
import java.util.function.Function;
import java.util.stream.Collectors;
import kr.ac.pusan.pickle.access.dto.AddResourceAccessGrantRequest;
@@ -77,22 +78,25 @@ public ResourceAccessGrantService(List adapters,
@Transactional(readOnly = true)
public ResourceAccessListResponse list(AuthenticatedUser actor, ResourceType type,
- long resourceId) {
+ UUID resourceId) {
Managed managed = requireManager(actor, type, resourceId);
- String workspaceName = workspaceRepository.findById(managed.resource().workspaceId())
- .map(Workspace::getName).orElse("");
- return ResourceAccessListResponse.of(type, managed.resource(), workspaceName,
- views(type, resourceId));
+ Workspace workspace = workspaceRepository.findById(managed.resource().workspaceId())
+ .orElse(null);
+ return ResourceAccessListResponse.of(type, managed.resource(),
+ workspace == null ? null : workspace.getPublicId(),
+ workspace == null ? "" : workspace.getName(),
+ views(type, managed.resource().id()));
}
@Transactional
- public ResourceAccessGrantView add(AuthenticatedUser actor, ResourceType type, long resourceId,
+ public ResourceAccessGrantView add(AuthenticatedUser actor, ResourceType type, UUID resourceId,
AddResourceAccessGrantRequest request, String ip) {
Managed managed = requireManager(actor, type, resourceId);
+ long id = managed.resource().id();
ResourceAccessGrant grant = request.granteeType() == AccessGranteeType.WORKSPACE
- ? ResourceAccessGrant.forOwningWorkspace(type, resourceId,
+ ? ResourceAccessGrant.forOwningWorkspace(type, id,
requireWorkspaceWideRole(request.role()))
- : ResourceAccessGrant.forUser(type, resourceId,
+ : ResourceAccessGrant.forUser(type, id,
requireEligibleMember(managed, request.userId()), request.role());
ResourceAccessGrant saved;
try {
@@ -106,7 +110,7 @@ public ResourceAccessGrantView add(AuthenticatedUser actor, ResourceType type, l
@Transactional
public ResourceAccessGrantView update(AuthenticatedUser actor, ResourceType type,
- long resourceId, long grantId, UpdateResourceAccessGrantRequest request, String ip) {
+ UUID resourceId, UUID grantId, UpdateResourceAccessGrantRequest request, String ip) {
Managed managed = requireManager(actor, type, resourceId);
ResourceAccessGrant grant = requireGrant(managed, grantId);
ResourceRole previous = grant.getRole();
@@ -118,7 +122,7 @@ public ResourceAccessGrantView update(AuthenticatedUser actor, ResourceType type
}
@Transactional
- public void remove(AuthenticatedUser actor, ResourceType type, long resourceId, long grantId,
+ public void remove(AuthenticatedUser actor, ResourceType type, UUID resourceId, UUID grantId,
String ip) {
Managed managed = requireManager(actor, type, resourceId);
ResourceAccessGrant grant = requireGrant(managed, grantId);
@@ -127,11 +131,11 @@ public void remove(AuthenticatedUser actor, ResourceType type, long resourceId,
}
/** Managing the list is a resource owner's right, or a workspace owner's standing one. */
- private Managed requireManager(AuthenticatedUser actor, ResourceType type, long resourceId) {
+ private Managed requireManager(AuthenticatedUser actor, ResourceType type, UUID resourceId) {
ResourceTypeAdapter adapter = adapterFor(type);
- ResourceIdentity resource = adapter.identify(resourceId)
+ ResourceIdentity resource = adapter.identifyByPublicId(resourceId)
.orElseThrow(() -> adapter.accessMessages().notFound());
- ResourceStanding standing = resolver.standing(type, resourceId, resource.workspaceId(),
+ ResourceStanding standing = resolver.standing(type, resource.id(), resource.workspaceId(),
actor.id());
if (!standing.manages()) {
// An outsider is not told the resource exists and a member without
@@ -156,16 +160,20 @@ private ResourceTypeAdapter adapterFor(ResourceType type) {
* rule that keeps the list from disagreeing with the 404 that hides the
* resource from everyone else.
*/
- private long requireEligibleMember(Managed managed, Long userId) {
- if (userId == null) {
+ private long requireEligibleMember(Managed managed, UUID publicUserId) {
+ if (publicUserId == null) {
throw ApiException.validationFailed(List.of(new FieldValidationError("userId",
"대상 사용자를 지정해 주세요.")));
}
+ // An id no account has is refused as ineligible, not as missing: the
+ // grantee's existence is not this endpoint's to disclose.
+ Long userId = userRepository.findByPublicId(publicUserId)
+ .filter(user -> user.getStatus() == UserStatus.ACTIVE)
+ .map(kr.ac.pusan.pickle.user.User::getId)
+ .orElseThrow(() -> managed.messages().granteeIneligible());
boolean member = workspaceMemberRepository
.findByWorkspaceIdAndUserId(managed.resource().workspaceId(), userId).isPresent();
- boolean active = userRepository.findById(userId)
- .filter(user -> user.getStatus() == UserStatus.ACTIVE).isPresent();
- if (!member || !active) {
+ if (!member) {
throw managed.messages().granteeIneligible();
}
return userId;
@@ -182,8 +190,8 @@ private ResourceRole requireWorkspaceWideRole(ResourceRole role) {
}
/** A grant id means nothing outside the resource it was issued under. */
- private ResourceAccessGrant requireGrant(Managed managed, long grantId) {
- return grantRepository.findById(grantId)
+ private ResourceAccessGrant requireGrant(Managed managed, UUID grantId) {
+ return grantRepository.findByPublicId(grantId)
.filter(grant -> grant.getResourceType() == managed.adapter().type()
&& grant.getResourceId() == managed.resource().id())
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND,
@@ -204,12 +212,11 @@ private void audit(AuthenticatedUser actor, Managed managed, GrantChange change,
if (previousRole != null) {
detail.put("previousRole", previousRole.name());
}
- long resourceId = managed.resource().id();
auditService.recordAfterCommit(actor.id(), actor.role().name(), names.actionOf(change),
- names.targetType(), resourceId, Map.copyOf(detail), ip);
+ names.targetType(), managed.resource().publicId(), Map.copyOf(detail), ip);
if (opensTheDoorForActor(actor, managed, change, grant)) {
auditService.recordAfterCommit(actor.id(), actor.role().name(), names.breakGlass(),
- names.targetType(), resourceId, Map.copyOf(detail), ip);
+ names.targetType(), managed.resource().publicId(), Map.copyOf(detail), ip);
}
}
diff --git a/src/main/java/kr/ac/pusan/pickle/access/VmAccessGrantController.java b/src/main/java/kr/ac/pusan/pickle/access/VmAccessGrantController.java
index 46e541a1..684d343a 100644
--- a/src/main/java/kr/ac/pusan/pickle/access/VmAccessGrantController.java
+++ b/src/main/java/kr/ac/pusan/pickle/access/VmAccessGrantController.java
@@ -6,6 +6,7 @@
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
+import java.util.UUID;
import kr.ac.pusan.pickle.access.dto.AddResourceAccessGrantRequest;
import kr.ac.pusan.pickle.access.dto.UpdateResourceAccessGrantRequest;
import kr.ac.pusan.pickle.access.dto.ResourceAccessGrantView;
@@ -47,7 +48,7 @@ public VmAccessGrantController(ResourceAccessGrantService service) {
description = "이 VM의 접근 권한 전체와, 그 목록이 어느 VM의 것인지 알려 주는 최소 정보입니다. VM 소유자와 워크스페이스 소유자만 볼 수 있습니다.")
public ResourceAccessListResponse listVmAccessGrants(
@AuthenticationPrincipal AuthenticatedUser principal,
- @PathVariable long vmId) {
+ @PathVariable UUID vmId) {
return service.list(principal, ResourceType.VM, vmId);
}
@@ -60,7 +61,7 @@ public ResourceAccessListResponse listVmAccessGrants(
+ "참여자·열람자까지만 부여할 수 있습니다.")
public ResourceAccessGrantView addVmAccessGrant(
@AuthenticationPrincipal AuthenticatedUser principal,
- @PathVariable long vmId,
+ @PathVariable UUID vmId,
@Valid @RequestBody AddResourceAccessGrantRequest request,
HttpServletRequest httpRequest) {
return service.add(principal, ResourceType.VM, vmId, request, clientIp(httpRequest));
@@ -71,8 +72,8 @@ public ResourceAccessGrantView addVmAccessGrant(
@Operation(summary = "접근 권한 등급 변경")
public ResourceAccessGrantView updateVmAccessGrant(
@AuthenticationPrincipal AuthenticatedUser principal,
- @PathVariable long vmId,
- @PathVariable long grantId,
+ @PathVariable UUID vmId,
+ @PathVariable UUID grantId,
@Valid @RequestBody UpdateResourceAccessGrantRequest request,
HttpServletRequest httpRequest) {
return service.update(principal, ResourceType.VM, vmId, grantId, request,
@@ -87,8 +88,8 @@ public ResourceAccessGrantView updateVmAccessGrant(
+ "않습니다. 필요하면 비밀번호를 재생성해 주세요.")
public void removeVmAccessGrant(
@AuthenticationPrincipal AuthenticatedUser principal,
- @PathVariable long vmId,
- @PathVariable long grantId,
+ @PathVariable UUID vmId,
+ @PathVariable UUID grantId,
HttpServletRequest httpRequest) {
service.remove(principal, ResourceType.VM, vmId, grantId, clientIp(httpRequest));
}
diff --git a/src/main/java/kr/ac/pusan/pickle/access/VmAccessService.java b/src/main/java/kr/ac/pusan/pickle/access/VmAccessService.java
index 732996fd..580471d4 100644
--- a/src/main/java/kr/ac/pusan/pickle/access/VmAccessService.java
+++ b/src/main/java/kr/ac/pusan/pickle/access/VmAccessService.java
@@ -1,5 +1,6 @@
package kr.ac.pusan.pickle.access;
+import java.util.UUID;
import kr.ac.pusan.pickle.common.error.ApiException;
import kr.ac.pusan.pickle.resource.VmResourceAdapter;
import kr.ac.pusan.pickle.security.AuthenticatedUser;
@@ -40,14 +41,14 @@ public VmAccessService(VmRepository vmRepository, ResourceAccessResolver resolve
/** Standing of {@code actor} on {@code vmId}; unknown VM answers 404. */
@Transactional(readOnly = true)
- public VmAccess of(AuthenticatedUser actor, long vmId) {
+ public VmAccess of(AuthenticatedUser actor, UUID vmId) {
return of(vmId, actor.id());
}
/** Standing of one user on {@code vmId}; unknown VM answers 404. */
@Transactional(readOnly = true)
- public VmAccess of(long vmId, long userId) {
- Vm vm = vmRepository.findById(vmId).orElseThrow(VmAccessService::vmNotFound);
+ public VmAccess of(UUID vmId, long userId) {
+ Vm vm = vmRepository.findByPublicId(vmId).orElseThrow(VmAccessService::vmNotFound);
return of(vm, userId);
}
diff --git a/src/main/java/kr/ac/pusan/pickle/access/dto/AddResourceAccessGrantRequest.java b/src/main/java/kr/ac/pusan/pickle/access/dto/AddResourceAccessGrantRequest.java
index 3ce13b74..4e16e6d0 100644
--- a/src/main/java/kr/ac/pusan/pickle/access/dto/AddResourceAccessGrantRequest.java
+++ b/src/main/java/kr/ac/pusan/pickle/access/dto/AddResourceAccessGrantRequest.java
@@ -2,6 +2,7 @@
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
+import java.util.UUID;
import kr.ac.pusan.pickle.access.AccessGranteeType;
import kr.ac.pusan.pickle.access.ResourceRole;
import org.jspecify.annotations.Nullable;
@@ -11,7 +12,7 @@ public record AddResourceAccessGrantRequest(
@Schema(description = "USER는 지정된 사용자 한 명, WORKSPACE은 소유 워크스페이스 전체")
@NotNull AccessGranteeType granteeType,
@Schema(description = "대상 사용자 id. granteeType이 USER일 때만 보내며, 소유 워크스페이스의 구성원이어야 합니다.")
- @Nullable Long userId,
+ @Nullable UUID userId,
@Schema(description = "부여할 등급. 워크스페이스 전체 항목에는 MEMBER 또는 VIEWER만 지정할 수 있습니다.")
@NotNull ResourceRole role) {
}
diff --git a/src/main/java/kr/ac/pusan/pickle/access/dto/ResourceAccessGrantView.java b/src/main/java/kr/ac/pusan/pickle/access/dto/ResourceAccessGrantView.java
index 41f14bac..cc66f3b5 100644
--- a/src/main/java/kr/ac/pusan/pickle/access/dto/ResourceAccessGrantView.java
+++ b/src/main/java/kr/ac/pusan/pickle/access/dto/ResourceAccessGrantView.java
@@ -2,6 +2,7 @@
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.Instant;
+import java.util.UUID;
import kr.ac.pusan.pickle.access.AccessGranteeType;
import kr.ac.pusan.pickle.access.ResourceAccessGrant;
import kr.ac.pusan.pickle.access.ResourceRole;
@@ -11,7 +12,7 @@
/** One entry in a VM's access list. */
@Schema(description = "VM 접근 권한 한 건. 대상은 지정된 사용자 한 명이거나 소유 워크스페이스 전체입니다.")
public record ResourceAccessGrantView(
- Long id,
+ UUID id,
@Schema(description = "대상 종류 — USER는 지정된 사용자, WORKSPACE은 소유 워크스페이스 전체")
AccessGranteeType granteeType,
@Schema(description = "대상 사용자. 워크스페이스 전체 항목이면 null입니다.")
@@ -27,7 +28,7 @@ public record Grantee(Long userId, String name, String email) {
public static ResourceAccessGrantView of(ResourceAccessGrant grant, @Nullable User user) {
Grantee grantee = user == null ? null
: new Grantee(user.getId(), user.getName(), user.getEmail());
- return new ResourceAccessGrantView(grant.getId(), grant.getGranteeType(), grantee,
+ return new ResourceAccessGrantView(grant.getPublicId(), grant.getGranteeType(), grantee,
grant.getRole(), grant.getCreatedAt());
}
}
diff --git a/src/main/java/kr/ac/pusan/pickle/access/dto/ResourceAccessListResponse.java b/src/main/java/kr/ac/pusan/pickle/access/dto/ResourceAccessListResponse.java
index fa801462..c9508d8d 100644
--- a/src/main/java/kr/ac/pusan/pickle/access/dto/ResourceAccessListResponse.java
+++ b/src/main/java/kr/ac/pusan/pickle/access/dto/ResourceAccessListResponse.java
@@ -2,6 +2,7 @@
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.List;
+import java.util.UUID;
import kr.ac.pusan.pickle.access.ResourceType;
import kr.ac.pusan.pickle.resource.ResourceIdentity;
import org.jspecify.annotations.Nullable;
@@ -29,22 +30,22 @@ public record ResourceAccessListResponse(
/** Name, state and owning workspace — the same fields a restricted row shows. */
@Schema(description = "접근 권한이 없는 사람에게도 보이는 범위의 리소스 정보")
public record ResourceBrief(
- Long id,
+ UUID id,
ResourceType type,
String name,
@Schema(description = "표시명. 지정되지 않았으면 null입니다.")
@Nullable String displayName,
@Schema(description = "리소스 종류의 상태값. 종류마다 어휘가 다릅니다.")
String status,
- Long workspaceId,
+ UUID workspaceId,
String workspaceName) {
}
public static ResourceAccessListResponse of(ResourceType type, ResourceIdentity resource,
- String workspaceName, List grants) {
+ UUID workspaceId, String workspaceName, List grants) {
return new ResourceAccessListResponse(
- new ResourceBrief(resource.id(), type, resource.name(), resource.displayName(),
- resource.status(), resource.workspaceId(), workspaceName),
+ new ResourceBrief(resource.publicId(), type, resource.name(), resource.displayName(),
+ resource.status(), workspaceId, workspaceName),
grants);
}
}
diff --git a/src/main/java/kr/ac/pusan/pickle/admin/AdminController.java b/src/main/java/kr/ac/pusan/pickle/admin/AdminController.java
index d0d2e187..69f73c2e 100644
--- a/src/main/java/kr/ac/pusan/pickle/admin/AdminController.java
+++ b/src/main/java/kr/ac/pusan/pickle/admin/AdminController.java
@@ -5,6 +5,7 @@
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import java.util.List;
+import java.util.UUID;
import kr.ac.pusan.pickle.admin.dto.CreateOrgRequest;
import kr.ac.pusan.pickle.admin.dto.OrgDetailResponse;
import kr.ac.pusan.pickle.admin.dto.UpdateOrgRequest;
@@ -62,7 +63,7 @@ public OrgDetailResponse createOrg(
@PatchMapping("/orgs/{orgId}")
public OrgDetailResponse updateOrg(@AuthenticationPrincipal AuthenticatedUser principal,
- @PathVariable long orgId,
+ @PathVariable UUID orgId,
@Valid @RequestBody UpdateOrgRequest request,
HttpServletRequest httpRequest) {
return adminService.updateOrg(principal, orgId, request, clientIp(httpRequest));
@@ -70,7 +71,7 @@ public OrgDetailResponse updateOrg(@AuthenticationPrincipal AuthenticatedUser pr
@PatchMapping("/users/{userId}")
public UserSummaryResponse updateUser(@AuthenticationPrincipal AuthenticatedUser principal,
- @PathVariable long userId,
+ @PathVariable UUID userId,
@Valid @RequestBody UpdateUserAdminRequest request,
HttpServletRequest httpRequest) {
return adminService.updateUser(principal, userId, request, clientIp(httpRequest));
diff --git a/src/main/java/kr/ac/pusan/pickle/admin/AdminDriftController.java b/src/main/java/kr/ac/pusan/pickle/admin/AdminDriftController.java
index adc7f23f..f1dfecfb 100644
--- a/src/main/java/kr/ac/pusan/pickle/admin/AdminDriftController.java
+++ b/src/main/java/kr/ac/pusan/pickle/admin/AdminDriftController.java
@@ -6,6 +6,7 @@
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
+import java.util.UUID;
import kr.ac.pusan.pickle.admin.dto.DriftFindingResponse;
import kr.ac.pusan.pickle.admin.dto.ResolveDriftFindingRequest;
import kr.ac.pusan.pickle.common.web.PageResponse;
@@ -49,7 +50,7 @@ public PageResponse listDriftFindings(
@PostMapping("/{findingId}/resolve")
public DriftFindingResponse resolveDriftFinding(
@AuthenticationPrincipal AuthenticatedUser principal,
- @PathVariable long findingId,
+ @PathVariable UUID findingId,
@Valid @RequestBody(required = false) ResolveDriftFindingRequest request,
HttpServletRequest httpRequest) {
return adminDriftService.resolve(principal, findingId, request, clientIp(httpRequest));
diff --git a/src/main/java/kr/ac/pusan/pickle/admin/AdminDriftService.java b/src/main/java/kr/ac/pusan/pickle/admin/AdminDriftService.java
index e4e93237..48ff63b9 100644
--- a/src/main/java/kr/ac/pusan/pickle/admin/AdminDriftService.java
+++ b/src/main/java/kr/ac/pusan/pickle/admin/AdminDriftService.java
@@ -4,6 +4,7 @@
import java.util.List;
import java.util.Map;
import java.util.Objects;
+import java.util.UUID;
import java.util.stream.Collectors;
import kr.ac.pusan.pickle.admin.dto.DriftFindingResponse;
import kr.ac.pusan.pickle.admin.dto.ResolveDriftFindingRequest;
@@ -67,21 +68,29 @@ public PageResponse list(DriftFindingStatus status, DriftF
Pageable pageable = PageRequest.of(page, size,
Sort.by(Sort.Order.desc("lastSeenAt"), Sort.Order.desc("id")));
Page result = driftFindingRepository.findAll(spec, pageable);
- Map emails = emailsById(result.getContent());
- Map vmNames = vmNamesById(result.getContent());
+ Map resolvers = resolversById(result.getContent());
+ Map vms = vmsById(result.getContent());
List content = result.getContent().stream()
- .map(finding -> toResponse(finding, finding.getResolvedBy() == null ? null
- : emails.get(finding.getResolvedBy()),
- finding.getVmId() == null ? null : vmNames.get(finding.getVmId())))
+ .map(finding -> {
+ Vm vm = finding.getVmId() == null ? null : vms.get(finding.getVmId());
+ User resolver = finding.getResolvedBy() == null ? null
+ : resolvers.get(finding.getResolvedBy());
+ return toResponse(finding, vm == null ? null : vm.getPublicId(),
+ vm == null ? null : vm.getName(),
+ resolver == null ? null : resolver.getPublicId(),
+ resolver == null ? null : resolver.getEmail());
+ })
.toList();
return PageResponse.of(content, result);
}
/** Contract {@code resolveDriftFinding}: CAS OPEN→RESOLVED with the acting admin. */
@Transactional
- public DriftFindingResponse resolve(AuthenticatedUser actor, long findingId,
+ public DriftFindingResponse resolve(AuthenticatedUser actor, UUID publicFindingId,
ResolveDriftFindingRequest request, String ip) {
String note = request == null ? null : request.note();
+ long findingId = driftFindingRepository.findByPublicId(publicFindingId)
+ .map(DriftFinding::getId).orElse(-1L);
if (driftFindingRepository.resolve(findingId, actor.id(), note, Instant.now()) == 0) {
driftFindingRepository.findById(findingId)
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND,
@@ -92,16 +101,17 @@ public DriftFindingResponse resolve(AuthenticatedUser actor, long findingId,
}
DriftFinding finding = driftFindingRepository.findById(findingId).orElseThrow();
auditService.recordAfterCommit(actor.id(), actor.role().name(), AuditService.DRIFT_RESOLVE,
- "drift_finding", findingId,
+ "drift_finding", finding.getPublicId(),
note == null ? Map.of("kind", finding.getKind().name())
: Map.of("kind", finding.getKind().name(), "note", note),
ip);
String vmName = finding.getVmId() == null ? null
: vmRepository.findById(finding.getVmId()).map(Vm::getName).orElse(null);
- return toResponse(finding, actor.email(), vmName);
+ return toResponse(finding, vmPublicId(finding.getVmId()), vmName,
+ actor.publicId(), actor.email());
}
- private Map emailsById(List findings) {
+ private Map resolversById(List findings) {
List ids = findings.stream()
.map(DriftFinding::getResolvedBy)
.filter(Objects::nonNull)
@@ -109,11 +119,11 @@ private Map emailsById(List findings) {
.toList();
return ids.isEmpty() ? Map.of()
: userRepository.findAllById(ids).stream()
- .collect(Collectors.toMap(User::getId, User::getEmail));
+ .collect(Collectors.toMap(User::getId, java.util.function.Function.identity()));
}
/** VM-name join (v0.9.0 display field); null vmId (UNMANAGED_GUEST) → no entry. */
- private Map vmNamesById(List findings) {
+ private Map vmsById(List findings) {
List ids = findings.stream()
.map(DriftFinding::getVmId)
.filter(Objects::nonNull)
@@ -121,13 +131,18 @@ private Map vmNamesById(List findings) {
.toList();
return ids.isEmpty() ? Map.of()
: vmRepository.findAllById(ids).stream()
- .collect(Collectors.toMap(Vm::getId, Vm::getName));
+ .collect(Collectors.toMap(Vm::getId, java.util.function.Function.identity()));
}
- private DriftFindingResponse toResponse(DriftFinding finding, String resolvedByEmail,
- String vmName) {
+ private DriftFindingResponse toResponse(DriftFinding finding, UUID vmId, String vmName,
+ UUID resolvedById, String resolvedByEmail) {
JsonNode detail = finding.getDetail() == null ? null
: objectMapper.readTree(finding.getDetail());
- return DriftFindingResponse.from(finding, detail, resolvedByEmail, vmName);
+ return DriftFindingResponse.from(finding, detail, vmId, vmName, resolvedById, resolvedByEmail);
+ }
+
+ private UUID vmPublicId(Long vmId) {
+ return vmId == null ? null
+ : vmRepository.findById(vmId).map(Vm::getPublicId).orElse(null);
}
}
diff --git a/src/main/java/kr/ac/pusan/pickle/admin/AdminInventoryService.java b/src/main/java/kr/ac/pusan/pickle/admin/AdminInventoryService.java
index a75c3d7e..c5e071fb 100644
--- a/src/main/java/kr/ac/pusan/pickle/admin/AdminInventoryService.java
+++ b/src/main/java/kr/ac/pusan/pickle/admin/AdminInventoryService.java
@@ -4,6 +4,7 @@
import java.util.List;
import java.util.Map;
import java.util.Objects;
+import java.util.UUID;
import kr.ac.pusan.pickle.admin.dto.AdminOsImageResponse;
import kr.ac.pusan.pickle.admin.dto.CreateVmFlavorRequest;
import kr.ac.pusan.pickle.admin.dto.NodeSummaryResponse;
@@ -63,37 +64,49 @@ public AdminInventoryService(OsImageRepository osImageRepository,
/** Contract {@code listAdminOsImages}: every OS image, retired revisions included. */
@Transactional(readOnly = true)
public List listOsImages() {
- return osImageRepository.findAllInDisplayOrder().stream()
- .map(AdminOsImageResponse::from)
+ List images = osImageRepository.findAllInDisplayOrder();
+ // The image names its node by public id, so the node rows are batched.
+ Map nodeIds = nodePublicIds(images);
+ return images.stream()
+ .map(image -> AdminOsImageResponse.from(image, nodeIds.get(image.getNodeId())))
.toList();
}
+ private Map nodePublicIds(List images) {
+ List ids = images.stream().map(OsImage::getNodeId)
+ .filter(java.util.Objects::nonNull).distinct().toList();
+ return ids.isEmpty() ? Map.of()
+ : nodeRepository.findAllById(ids).stream()
+ .collect(java.util.stream.Collectors.toMap(Node::getId, Node::getPublicId));
+ }
+
@Transactional
- public AdminOsImageResponse updateCatalogStatus(AuthenticatedUser actor, long imageId,
+ public AdminOsImageResponse updateCatalogStatus(AuthenticatedUser actor, UUID imageId,
UpdateOsImageStatusRequest request, String ip) {
- OsImage image = osImageRepository.findById(imageId)
+ OsImage image = osImageRepository.findByPublicId(imageId)
.orElseThrow(() -> notFound("해당 OS 이미지가 존재하지 않습니다."));
if (image.getStatus() != request.status()) {
String fromStatus = image.getStatus().name();
image.setStatus(request.status());
auditService.recordAfterCommit(actor.id(), actor.role().name(),
- AuditService.OS_IMAGE_STATUS_UPDATE, "os_image", imageId,
+ AuditService.OS_IMAGE_STATUS_UPDATE, "os_image", image.getPublicId(),
Map.of("name", image.getName(), "version", image.getVersion(),
"fromStatus", fromStatus, "toStatus", request.status().name()), ip);
}
- return AdminOsImageResponse.from(image);
+ return AdminOsImageResponse.from(image, nodePublicIds(List.of(image)).get(image.getNodeId()));
}
@Transactional
- public NodeSummaryResponse updateNodeStatus(AuthenticatedUser actor, long nodeId,
+ public NodeSummaryResponse updateNodeStatus(AuthenticatedUser actor, UUID publicNodeId,
UpdateNodeStatusRequest request, String ip) {
- Node node = nodeRepository.findById(nodeId)
+ Node node = nodeRepository.findByPublicId(publicNodeId)
.orElseThrow(() -> notFound("해당 노드가 존재하지 않습니다."));
+ long nodeId = node.getId();
if (node.getStatus() != request.status()) {
String fromStatus = node.getStatus().name();
node.setStatus(request.status());
auditService.recordAfterCommit(actor.id(), actor.role().name(),
- AuditService.NODE_STATUS_UPDATE, "node", nodeId,
+ AuditService.NODE_STATUS_UPDATE, "node", node.getPublicId(),
Map.of("name", node.getName(), "fromStatus", fromStatus,
"toStatus", request.status().name()), ip);
}
@@ -127,7 +140,7 @@ public VmFlavorResponse createFlavor(AuthenticatedUser actor, CreateVmFlavorRequ
throw nameTaken();
}
auditService.recordAfterCommit(actor.id(), actor.role().name(), AuditService.FLAVOR_CREATE,
- "vm_flavor", flavor.getId(),
+ "vm_flavor", flavor.getPublicId(),
Map.of("name", flavor.getName(), "vcpu", flavor.getVcpu(),
"memoryMb", flavor.getMemoryMb(), "diskGb", flavor.getDiskGb()), ip);
return VmFlavorResponse.from(flavor);
@@ -138,9 +151,9 @@ public VmFlavorResponse createFlavor(AuthenticatedUser actor, CreateVmFlavorRequ
* idempotent re-application without an audit row, like the status toggles.
*/
@Transactional
- public VmFlavorResponse updateFlavor(AuthenticatedUser actor, long flavorId,
+ public VmFlavorResponse updateFlavor(AuthenticatedUser actor, UUID flavorId,
UpdateVmFlavorRequest request, String ip) {
- VmFlavor flavor = vmFlavorRepository.findById(flavorId)
+ VmFlavor flavor = vmFlavorRepository.findByPublicId(flavorId)
.orElseThrow(() -> notFound("해당 사양 프리셋이 존재하지 않습니다."));
if (request.displayName() == null && request.vcpu() == null && request.memoryMb() == null
&& request.diskGb() == null && request.notes() == null && request.status() == null) {
@@ -179,7 +192,7 @@ public VmFlavorResponse updateFlavor(AuthenticatedUser actor, long flavorId,
if (!changes.isEmpty()) {
changes.put("name", flavor.getName());
auditService.recordAfterCommit(actor.id(), actor.role().name(),
- AuditService.FLAVOR_UPDATE, "vm_flavor", flavorId, changes, ip);
+ AuditService.FLAVOR_UPDATE, "vm_flavor", flavor.getPublicId(), changes, ip);
}
return VmFlavorResponse.from(flavor);
}
diff --git a/src/main/java/kr/ac/pusan/pickle/admin/AdminIpAllocationController.java b/src/main/java/kr/ac/pusan/pickle/admin/AdminIpAllocationController.java
index 6140c8a1..a02c4d6e 100644
--- a/src/main/java/kr/ac/pusan/pickle/admin/AdminIpAllocationController.java
+++ b/src/main/java/kr/ac/pusan/pickle/admin/AdminIpAllocationController.java
@@ -5,6 +5,7 @@
import java.util.List;
import java.util.Map;
import java.util.Objects;
+import java.util.UUID;
import java.util.function.Function;
import java.util.stream.Collectors;
import kr.ac.pusan.pickle.admin.dto.IpAllocationResponse;
@@ -50,13 +51,16 @@ public AdminIpAllocationController(IpAllocationRepository ipAllocationRepository
@GetMapping
public PageResponse listIpAllocations(
- @RequestParam(required = false) Long poolId,
+ @RequestParam(required = false) UUID poolId,
@RequestParam(required = false) AllocationStatus status,
@RequestParam(defaultValue = "0") @Min(0) int page,
@RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) {
Specification spec = (root, query, cb) -> cb.conjunction();
if (poolId != null) {
- spec = spec.and((root, query, cb) -> cb.equal(root.get("poolId"), poolId));
+ // An id no pool has filters to nothing, as a non-matching number did.
+ Long scopedPoolId = ipPoolRepository.findByPublicId(poolId).map(IpPool::getId)
+ .orElse(-1L);
+ spec = spec.and((root, query, cb) -> cb.equal(root.get("poolId"), scopedPoolId));
}
if (status != null) {
spec = spec.and((root, query, cb) -> cb.equal(root.get("status"), status));
@@ -65,18 +69,23 @@ public PageResponse listIpAllocations(
Sort.by(Sort.Order.desc("allocatedAt"), Sort.Order.desc("id")));
Page result = ipAllocationRepository.findAll(spec, pageable);
- Map poolNames = ipPoolRepository.findAllById(result.getContent().stream()
+ Map pools = ipPoolRepository.findAllById(result.getContent().stream()
.map(IpAllocation::getPoolId).distinct().toList()).stream()
- .collect(Collectors.toMap(IpPool::getId, IpPool::getName));
+ .collect(Collectors.toMap(IpPool::getId, Function.identity()));
Map vms = vmRepository.findAllById(result.getContent().stream()
.map(IpAllocation::getVmId).filter(Objects::nonNull).distinct().toList())
.stream()
.collect(Collectors.toMap(Vm::getId, Function.identity()));
List content = result.getContent().stream()
- .map(allocation -> IpAllocationResponse.from(allocation,
- poolNames.get(allocation.getPoolId()),
- allocation.getVmId() == null ? null : vms.get(allocation.getVmId())))
+ .map(allocation -> {
+ IpPool pool = pools.get(allocation.getPoolId());
+ Vm vm = allocation.getVmId() == null ? null : vms.get(allocation.getVmId());
+ return IpAllocationResponse.from(allocation,
+ pool == null ? null : pool.getPublicId(),
+ pool == null ? null : pool.getName(),
+ vm == null ? null : vm.getPublicId(), vm);
+ })
.toList();
return PageResponse.of(content, result);
}
diff --git a/src/main/java/kr/ac/pusan/pickle/admin/AdminNodeController.java b/src/main/java/kr/ac/pusan/pickle/admin/AdminNodeController.java
index 9d07d40d..871906d4 100644
--- a/src/main/java/kr/ac/pusan/pickle/admin/AdminNodeController.java
+++ b/src/main/java/kr/ac/pusan/pickle/admin/AdminNodeController.java
@@ -6,6 +6,7 @@
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import java.util.List;
+import java.util.UUID;
import kr.ac.pusan.pickle.admin.dto.NodeMetricsResponse;
import kr.ac.pusan.pickle.admin.dto.NodeSummaryResponse;
import kr.ac.pusan.pickle.admin.dto.UpdateNodeStatusRequest;
@@ -52,7 +53,7 @@ public List listAdminNodes() {
/** Live host usage series from the hypervisor (contract v0.35.0). */
@GetMapping("/{nodeId}/metrics")
- public NodeMetricsResponse getAdminNodeMetrics(@PathVariable long nodeId,
+ public NodeMetricsResponse getAdminNodeMetrics(@PathVariable UUID nodeId,
@Parameter(description = "조회 구간 — HOUR/DAY/WEEK/MONTH/YEAR "
+ "(해상도는 구간에 따라 거칠어짐)")
@RequestParam(defaultValue = "HOUR") RrdTimeframe timeframe) {
@@ -63,7 +64,7 @@ public NodeMetricsResponse getAdminNodeMetrics(@PathVariable long nodeId,
@PreAuthorize("hasRole('SYS_ADMIN')")
public NodeSummaryResponse updateAdminNode(
@AuthenticationPrincipal AuthenticatedUser principal,
- @PathVariable long nodeId,
+ @PathVariable UUID nodeId,
@Valid @RequestBody UpdateNodeStatusRequest request,
HttpServletRequest httpRequest) {
return adminInventoryService.updateNodeStatus(principal, nodeId, request,
diff --git a/src/main/java/kr/ac/pusan/pickle/admin/AdminNodeMetricsService.java b/src/main/java/kr/ac/pusan/pickle/admin/AdminNodeMetricsService.java
index 130f5414..857523f7 100644
--- a/src/main/java/kr/ac/pusan/pickle/admin/AdminNodeMetricsService.java
+++ b/src/main/java/kr/ac/pusan/pickle/admin/AdminNodeMetricsService.java
@@ -2,6 +2,7 @@
import java.time.Clock;
import java.util.List;
+import java.util.UUID;
import kr.ac.pusan.pickle.admin.dto.NodeMetricPointResponse;
import kr.ac.pusan.pickle.admin.dto.NodeMetricsResponse;
import kr.ac.pusan.pickle.common.error.ApiException;
@@ -44,8 +45,8 @@ public AdminNodeMetricsService(NodeRepository nodeRepository, ProxmoxClient prox
this.clock = clock;
}
- public NodeMetricsResponse metrics(long nodeId, RrdTimeframe timeframe) {
- Node node = nodeRepository.findById(nodeId).orElseThrow(() -> new ApiException(
+ public NodeMetricsResponse metrics(UUID nodeId, RrdTimeframe timeframe) {
+ Node node = nodeRepository.findByPublicId(nodeId).orElseThrow(() -> new ApiException(
HttpStatus.NOT_FOUND, ErrorCodes.RESOURCE_NOT_FOUND, "리소스를 찾을 수 없습니다",
"해당 노드가 존재하지 않습니다."));
List points;
diff --git a/src/main/java/kr/ac/pusan/pickle/admin/AdminNodeQueryService.java b/src/main/java/kr/ac/pusan/pickle/admin/AdminNodeQueryService.java
index 78663a02..09810c6b 100644
--- a/src/main/java/kr/ac/pusan/pickle/admin/AdminNodeQueryService.java
+++ b/src/main/java/kr/ac/pusan/pickle/admin/AdminNodeQueryService.java
@@ -85,7 +85,7 @@ private NodeSummaryResponse toSummary(Node node, NodeAllocation allocation, doub
: round2((double) allocation.vcpu() / node.getCpuThreads());
double memoryRatio = node.getMemoryMb() == 0 ? 0.0
: round2((double) allocation.memoryMb() / node.getMemoryMb());
- return new NodeSummaryResponse(node.getId(), node.getName(), node.getStatus(),
+ return new NodeSummaryResponse(node.getPublicId(), node.getName(), node.getStatus(),
node.getCpuThreads(), node.getMemoryMb(), node.getVmBridge(), node.getStorage(),
node.getDiskCapacityGb(),
allocation.running(), allocation.vcpu(), allocation.memoryMb(),
diff --git a/src/main/java/kr/ac/pusan/pickle/admin/AdminNotificationController.java b/src/main/java/kr/ac/pusan/pickle/admin/AdminNotificationController.java
index d9918aef..ca4a4fab 100644
--- a/src/main/java/kr/ac/pusan/pickle/admin/AdminNotificationController.java
+++ b/src/main/java/kr/ac/pusan/pickle/admin/AdminNotificationController.java
@@ -5,6 +5,7 @@
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
+import java.util.UUID;
import kr.ac.pusan.pickle.admin.dto.AdminNotificationResponse;
import kr.ac.pusan.pickle.auth.dto.MessageResponse;
import kr.ac.pusan.pickle.common.web.PageResponse;
@@ -51,7 +52,7 @@ public PageResponse listAdminNotifications(
@ResponseStatus(HttpStatus.ACCEPTED)
public MessageResponse resendAdminNotification(
@AuthenticationPrincipal AuthenticatedUser principal,
- @PathVariable long notificationId,
+ @PathVariable UUID notificationId,
HttpServletRequest httpRequest) {
return adminNotificationService.resend(principal, notificationId,
clientIp(httpRequest));
diff --git a/src/main/java/kr/ac/pusan/pickle/admin/AdminNotificationService.java b/src/main/java/kr/ac/pusan/pickle/admin/AdminNotificationService.java
index c98ce29f..a0b4aa98 100644
--- a/src/main/java/kr/ac/pusan/pickle/admin/AdminNotificationService.java
+++ b/src/main/java/kr/ac/pusan/pickle/admin/AdminNotificationService.java
@@ -5,6 +5,7 @@
import java.sql.Timestamp;
import java.util.List;
import java.util.Map;
+import java.util.UUID;
import kr.ac.pusan.pickle.admin.dto.AdminNotificationResponse;
import kr.ac.pusan.pickle.audit.AuditService;
import kr.ac.pusan.pickle.auth.dto.MessageResponse;
@@ -54,8 +55,10 @@ public PageResponse list(NotificationStatus status, S
"select count(*) from notifications n join users u on u.id = n.user_id"
+ BASE_WHERE, Long.class, filters);
List content = jdbcTemplate.query("""
- select n.id, n.event, n.title, n.body, n.link_path, n.importance, n.created_at,
- n.read_at, n.user_id, u.email, n.channel::text as channel,
+ select n.public_id, n.event, n.title, n.body, n.link_path, n.importance,
+ n.created_at,
+ n.read_at, u.public_id as user_public_id, u.email,
+ n.channel::text as channel,
n.status::text as status, n.attempts, n.last_error, n.sent_at
from notifications n
join users u on u.id = n.user_id
@@ -70,15 +73,16 @@ public PageResponse list(NotificationStatus status, S
/** CAS FAILED→PENDING due now; anything else answers 409 (404 when absent). */
@Transactional
- public MessageResponse resend(AuthenticatedUser actor, long notificationId, String ip) {
+ public MessageResponse resend(AuthenticatedUser actor, UUID notificationId, String ip) {
int updated = jdbcTemplate.update("""
update notifications
set status = 'PENDING', next_attempt_at = now()
- where id = ? and status = 'FAILED'
+ where public_id = ? and status = 'FAILED'
""", notificationId);
if (updated == 0) {
Long found = jdbcTemplate.queryForObject(
- "select count(*) from notifications where id = ?", Long.class, notificationId);
+ "select count(*) from notifications where public_id = ?", Long.class,
+ notificationId);
if (found == null || found == 0) {
throw new ApiException(HttpStatus.NOT_FOUND, ErrorCodes.RESOURCE_NOT_FOUND,
"리소스를 찾을 수 없습니다", "해당 알림이 존재하지 않습니다.");
@@ -93,7 +97,7 @@ public MessageResponse resend(AuthenticatedUser actor, long notificationId, Stri
private static AdminNotificationResponse mapRow(ResultSet rs, int rowNum) throws SQLException {
return new AdminNotificationResponse(
- rs.getLong("id"),
+ rs.getObject("public_id", java.util.UUID.class),
rs.getString("event"),
rs.getString("title"),
rs.getString("body"),
@@ -101,7 +105,7 @@ private static AdminNotificationResponse mapRow(ResultSet rs, int rowNum) throws
NotificationImportance.valueOf(rs.getString("importance")),
instant(rs.getTimestamp("created_at")),
instant(rs.getTimestamp("read_at")),
- rs.getLong("user_id"),
+ rs.getObject("user_public_id", java.util.UUID.class),
rs.getString("email"),
NotificationChannel.valueOf(rs.getString("channel")),
NotificationStatus.valueOf(rs.getString("status")),
diff --git a/src/main/java/kr/ac/pusan/pickle/admin/AdminOsImageController.java b/src/main/java/kr/ac/pusan/pickle/admin/AdminOsImageController.java
index c588e1f6..eb84a1ec 100644
--- a/src/main/java/kr/ac/pusan/pickle/admin/AdminOsImageController.java
+++ b/src/main/java/kr/ac/pusan/pickle/admin/AdminOsImageController.java
@@ -5,6 +5,7 @@
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import java.util.List;
+import java.util.UUID;
import kr.ac.pusan.pickle.admin.dto.AdminOsImageResponse;
import kr.ac.pusan.pickle.admin.dto.UpdateOsImageStatusRequest;
import kr.ac.pusan.pickle.security.AuthenticatedUser;
@@ -43,7 +44,7 @@ public List listAdminOsImages() {
@PreAuthorize("hasRole('SYS_ADMIN')")
public AdminOsImageResponse updateAdminOsImage(
@AuthenticationPrincipal AuthenticatedUser principal,
- @PathVariable long imageId,
+ @PathVariable UUID imageId,
@Valid @RequestBody UpdateOsImageStatusRequest request,
HttpServletRequest httpRequest) {
return adminInventoryService.updateCatalogStatus(principal, imageId, request,
diff --git a/src/main/java/kr/ac/pusan/pickle/admin/AdminRequestController.java b/src/main/java/kr/ac/pusan/pickle/admin/AdminRequestController.java
index 2f7cff58..0668f406 100644
--- a/src/main/java/kr/ac/pusan/pickle/admin/AdminRequestController.java
+++ b/src/main/java/kr/ac/pusan/pickle/admin/AdminRequestController.java
@@ -6,6 +6,7 @@
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
+import java.util.UUID;
import kr.ac.pusan.pickle.admin.dto.ApprovalContextResponse;
import kr.ac.pusan.pickle.admin.dto.ApproveRequestRequest;
import kr.ac.pusan.pickle.admin.dto.RejectRequestRequest;
@@ -50,7 +51,7 @@ public PageResponse listAdminRequests(
@AuthenticationPrincipal AuthenticatedUser principal,
@RequestParam(required = false) RequestStatus status,
@RequestParam(required = false) ResourceType type,
- @RequestParam(required = false) Long orgId,
+ @RequestParam(required = false) UUID orgId,
@RequestParam(defaultValue = "0") @Min(0) int page,
@RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) {
return approvalService.list(principal, status, type, orgId, page, size);
@@ -58,20 +59,20 @@ public PageResponse listAdminRequests(
@GetMapping("/{requestId}")
public RequestDetailResponse getAdminRequest(@AuthenticationPrincipal AuthenticatedUser principal,
- @PathVariable long requestId) {
+ @PathVariable UUID requestId) {
return approvalService.get(principal, requestId);
}
@GetMapping("/{requestId}/context")
public ApprovalContextResponse getApprovalContext(@AuthenticationPrincipal AuthenticatedUser principal,
- @PathVariable long requestId) {
+ @PathVariable UUID requestId) {
return approvalContextService.context(principal, requestId);
}
@PostMapping("/{requestId}/approve")
@PreAuthorize("hasAnyRole('ORG_ADMIN', 'ORG_MANAGER', 'SYS_ADMIN')")
public RequestDetailResponse approveRequest(@AuthenticationPrincipal AuthenticatedUser principal,
- @PathVariable long requestId,
+ @PathVariable UUID requestId,
@Valid @RequestBody ApproveRequestRequest request,
HttpServletRequest httpRequest) {
return approvalService.approve(principal, requestId, request, clientIp(httpRequest));
@@ -80,7 +81,7 @@ public RequestDetailResponse approveRequest(@AuthenticationPrincipal Authenticat
@PostMapping("/{requestId}/reject")
@PreAuthorize("hasAnyRole('ORG_ADMIN', 'ORG_MANAGER', 'SYS_ADMIN')")
public RequestDetailResponse rejectRequest(@AuthenticationPrincipal AuthenticatedUser principal,
- @PathVariable long requestId,
+ @PathVariable UUID requestId,
@Valid @RequestBody RejectRequestRequest request,
HttpServletRequest httpRequest) {
return approvalService.reject(principal, requestId, request, clientIp(httpRequest));
diff --git a/src/main/java/kr/ac/pusan/pickle/admin/AdminService.java b/src/main/java/kr/ac/pusan/pickle/admin/AdminService.java
index 1458e3e1..586147c4 100644
--- a/src/main/java/kr/ac/pusan/pickle/admin/AdminService.java
+++ b/src/main/java/kr/ac/pusan/pickle/admin/AdminService.java
@@ -2,6 +2,7 @@
import java.util.List;
import java.util.Map;
+import java.util.UUID;
import kr.ac.pusan.pickle.admin.dto.CreateOrgRequest;
import kr.ac.pusan.pickle.admin.dto.OrgDetailResponse;
import kr.ac.pusan.pickle.admin.dto.UpdateOrgRequest;
@@ -18,7 +19,6 @@
import kr.ac.pusan.pickle.user.User;
import kr.ac.pusan.pickle.user.UserRepository;
import kr.ac.pusan.pickle.user.UserRole;
-import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -51,24 +51,18 @@ public java.util.List listOrgs() {
@Transactional
public OrgDetailResponse createOrg(AuthenticatedUser actor, CreateOrgRequest request, String ip) {
- if (orgRepository.existsBySlug(request.slug())) {
- throw orgSlugDuplicate(request.slug());
- }
- Org org;
- try {
- org = orgRepository.save(new Org(request.name().strip(), request.slug(),
- normalize(request.description())));
- } catch (DataIntegrityViolationException raceWithConcurrentCreate) {
- throw orgSlugDuplicate(request.slug());
- }
+ // No duplicate check: the org slug carried the only uniqueness constraint
+ // this table ever had, and V78 dropped it. Two orgs may share a name.
+ Org org = orgRepository.save(new Org(request.name().strip(),
+ normalize(request.description())));
auditService.recordAfterCommit(actor.id(), actor.role().name(), AuditService.ORG_CREATE,
- "org", org.getId(), Map.of("name", org.getName(), "slug", org.getSlug()), ip);
+ "org", org.getPublicId(), Map.of("name", org.getName()), ip);
return OrgDetailResponse.from(org);
}
@Transactional
- public OrgDetailResponse updateOrg(AuthenticatedUser actor, long orgId, UpdateOrgRequest request, String ip) {
- Org org = orgRepository.findById(orgId)
+ public OrgDetailResponse updateOrg(AuthenticatedUser actor, UUID orgId, UpdateOrgRequest request, String ip) {
+ Org org = orgRepository.findByPublicId(orgId)
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, ErrorCodes.RESOURCE_NOT_FOUND,
"리소스를 찾을 수 없습니다", "해당 기관이 존재하지 않습니다."));
if (request.isEmpty()) {
@@ -100,7 +94,7 @@ public OrgDetailResponse updateOrg(AuthenticatedUser actor, long orgId, UpdateOr
org.setHidden(request.getHidden());
}
auditService.recordAfterCommit(actor.id(), actor.role().name(), AuditService.ORG_UPDATE,
- "org", org.getId(), Map.of("name", org.getName(), "status", org.getStatus().name(),
+ "org", org.getPublicId(), Map.of("name", org.getName(), "status", org.getStatus().name(),
"hidden", org.isHidden()), ip);
return OrgDetailResponse.from(org);
}
@@ -110,9 +104,9 @@ public OrgDetailResponse updateOrg(AuthenticatedUser actor, long orgId, UpdateOr
* outstanding access tokens of the target user become invalid immediately.
*/
@Transactional
- public UserSummaryResponse updateUser(AuthenticatedUser actor, long userId,
+ public UserSummaryResponse updateUser(AuthenticatedUser actor, UUID userId,
UpdateUserAdminRequest request, String ip) {
- User user = userRepository.findById(userId)
+ User user = userRepository.findByPublicId(userId)
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, ErrorCodes.RESOURCE_NOT_FOUND,
"사용자를 찾을 수 없습니다", "해당 ID의 사용자가 존재하지 않습니다."));
if (request.isEmpty()) {
@@ -124,12 +118,14 @@ public UserSummaryResponse updateUser(AuthenticatedUser actor, long userId,
UserRole targetRole = request.role() != null ? request.role() : user.getRole();
if (targetRole.isOrgTier()) {
// ORG_ADMIN and ORG_MANAGER both manage one org (per the permission matrix).
- Long orgId = request.orgId() != null ? request.orgId() : user.getOrgId();
- if (orgId == null) {
+ Long orgId = request.orgId() != null
+ ? orgRepository.findByPublicId(request.orgId()).map(Org::getId).orElse(null)
+ : user.getOrgId();
+ if (request.orgId() == null && orgId == null) {
throw ApiException.validationFailed(List.of(new FieldValidationError("orgId",
"ORG_ADMIN·ORG_MANAGER 역할에는 관리 기관(orgId)을 지정해야 합니다.")));
}
- if (!orgRepository.existsById(orgId)) {
+ if (orgId == null) {
throw ApiException.validationFailed(List.of(new FieldValidationError("orgId",
"존재하지 않는 기관(orgId)입니다.")));
}
@@ -148,7 +144,7 @@ public UserSummaryResponse updateUser(AuthenticatedUser actor, long userId,
}
auditService.recordAfterCommit(actor.id(), actor.role().name(), AuditService.USER_ROLE_UPDATE,
- "user", user.getId(),
+ "user", user.getPublicId(),
Map.of("previousRole", previousRole.name(), "role", user.getRole().name(),
"orgId", user.getOrgId() == null ? "null" : String.valueOf(user.getOrgId())), ip);
return UserSummaryResponse.from(user);
@@ -157,9 +153,4 @@ public UserSummaryResponse updateUser(AuthenticatedUser actor, long userId,
private static String normalize(String description) {
return Texts.blankToNull(description);
}
-
- private static ApiException orgSlugDuplicate(String slug) {
- return new ApiException(HttpStatus.CONFLICT, ErrorCodes.ORG_SLUG_DUPLICATE,
- "이미 사용 중인 slug입니다", "'" + slug + "'은(는) 이미 다른 기관이 사용 중입니다.");
- }
}
diff --git a/src/main/java/kr/ac/pusan/pickle/admin/AdminSummaryController.java b/src/main/java/kr/ac/pusan/pickle/admin/AdminSummaryController.java
index 43dcc7e0..4ca4e52b 100644
--- a/src/main/java/kr/ac/pusan/pickle/admin/AdminSummaryController.java
+++ b/src/main/java/kr/ac/pusan/pickle/admin/AdminSummaryController.java
@@ -2,6 +2,7 @@
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
+import java.util.UUID;
import kr.ac.pusan.pickle.admin.dto.CapacityTrendResponse;
import kr.ac.pusan.pickle.admin.dto.OrgDashboardSummaryResponse;
import kr.ac.pusan.pickle.admin.dto.SystemDashboardSummaryResponse;
@@ -36,7 +37,7 @@ public AdminSummaryController(AdminSummaryService adminSummaryService,
@PreAuthorize("hasAnyRole('ORG_ADMIN', 'ORG_MANAGER', 'SYS_ADMIN', 'SYS_MANAGER')")
public OrgDashboardSummaryResponse getAdminSummary(
@AuthenticationPrincipal AuthenticatedUser principal,
- @RequestParam(required = false) Long orgId) {
+ @RequestParam(required = false) UUID orgId) {
return adminSummaryService.orgSummary(principal, orgId);
}
@@ -46,7 +47,7 @@ public OrgDashboardSummaryResponse getAdminSummary(
public CapacityTrendResponse getAdminCapacityTrend(
@AuthenticationPrincipal AuthenticatedUser principal,
@RequestParam(defaultValue = "90") @Min(7) @Max(365) int days,
- @RequestParam(required = false) Long orgId) {
+ @RequestParam(required = false) UUID orgId) {
return capacityTrendService.trend(principal, days, orgId);
}
diff --git a/src/main/java/kr/ac/pusan/pickle/admin/AdminSummaryService.java b/src/main/java/kr/ac/pusan/pickle/admin/AdminSummaryService.java
index 609dd935..02077095 100644
--- a/src/main/java/kr/ac/pusan/pickle/admin/AdminSummaryService.java
+++ b/src/main/java/kr/ac/pusan/pickle/admin/AdminSummaryService.java
@@ -8,6 +8,7 @@
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
+import java.util.UUID;
import kr.ac.pusan.pickle.admin.dto.NodeLiveResponse;
import kr.ac.pusan.pickle.admin.dto.NodeSummaryResponse;
import kr.ac.pusan.pickle.admin.dto.OrgDashboardSummaryResponse;
@@ -27,6 +28,7 @@
import kr.ac.pusan.pickle.inventory.NodeRepository;
import kr.ac.pusan.pickle.ipam.IpPoolRepository;
import kr.ac.pusan.pickle.ipam.IpamService;
+import kr.ac.pusan.pickle.orgs.Org;
import kr.ac.pusan.pickle.orgs.OrgRepository;
import kr.ac.pusan.pickle.provisioning.DriftFindingRepository;
import kr.ac.pusan.pickle.provisioning.DriftFindingStatus;
@@ -90,7 +92,7 @@ public AdminSummaryService(JdbcTemplate jdbcTemplate, OrgRepository orgRepositor
* no org filter anywhere below; the console home calls it that way).
*/
@Transactional(readOnly = true)
- public OrgDashboardSummaryResponse orgSummary(AuthenticatedUser actor, Long orgId) {
+ public OrgDashboardSummaryResponse orgSummary(AuthenticatedUser actor, UUID orgId) {
Long scopedOrgId = resolveOrgId(actor, orgId);
LocalDate today = ClockConfig.todayKst(clock);
Instant decidedSince = clock.instant().minus(Duration.ofDays(14));
@@ -200,7 +202,7 @@ and status in ('ACTIVE', 'RENEWING')
List pools = new ArrayList<>();
ipPoolRepository.findAll(org.springframework.data.domain.Sort.by("id")).forEach(pool -> {
IpamService.PoolUsage usage = ipamService.poolUsage(pool.getId());
- pools.add(new IpPoolUsage(pool.getId(), pool.getName(), pool.getCidr(),
+ pools.add(new IpPoolUsage(pool.getPublicId(), pool.getName(), pool.getCidr(),
usage.allocatedCount(), usage.freeCount()));
});
@@ -272,14 +274,14 @@ private List nodesLive(List nodes) {
status = proxmoxClient.nodeStatus(node.getApiHost(), node.getName());
} catch (ProxmoxApiException | IllegalStateException e) {
log.warn("Node {} live probe failed: {}", node.getName(), e.getMessage());
- live.add(NodeLiveResponse.unreachable(node.getId(), node.getName()));
+ live.add(NodeLiveResponse.unreachable(node.getPublicId(), node.getName()));
continue;
}
// The client hands back whatever the PVE envelope held, so a 200
// with no data is possible and is not a reachable answer.
if (status == null) {
log.warn("Node {} live probe returned an empty status payload", node.getName());
- live.add(NodeLiveResponse.unreachable(node.getId(), node.getName()));
+ live.add(NodeLiveResponse.unreachable(node.getPublicId(), node.getName()));
continue;
}
NodeStorageStatus storage = null;
@@ -289,7 +291,7 @@ private List nodesLive(List nodes) {
log.warn("Node {} guest-storage read failed, tile keeps the status half: {}",
node.getName(), e.getMessage());
}
- live.add(new NodeLiveResponse(node.getId(), node.getName(), true,
+ live.add(new NodeLiveResponse(node.getPublicId(), node.getName(), true,
status.memory() == null ? null : status.memory().total(),
status.memory() == null ? null : status.memory().used(),
status.cpu(),
@@ -358,13 +360,15 @@ private long count(String sql, Object... args) {
* same panel over time and must be scoped by the identical rule; two copies
* of a pinning rule is how one of them drifts open.
*/
- Long resolveOrgId(AuthenticatedUser actor, Long orgId) {
+ Long resolveOrgId(AuthenticatedUser actor, UUID orgId) {
+ Long requested = orgId == null ? null
+ : orgRepository.findByPublicId(orgId).map(Org::getId).orElse(null);
if (actor.role().isOrgTier()) {
if (actor.orgId() == null) {
throw new ApiException(HttpStatus.FORBIDDEN, ErrorCodes.ACCESS_DENIED,
"접근 권한이 없습니다", "관리 기관이 지정되지 않은 계정입니다.");
}
- if (orgId != null && !orgId.equals(actor.orgId())) {
+ if (orgId != null && !actor.orgId().equals(requested)) {
throw orgNotFound();
}
return actor.orgId();
@@ -372,10 +376,10 @@ Long resolveOrgId(AuthenticatedUser actor, Long orgId) {
if (orgId == null) {
return null; // SYS_ADMIN without drill-in → platform-wide
}
- if (orgRepository.findById(orgId).isEmpty()) {
+ if (requested == null) {
throw orgNotFound();
}
- return orgId;
+ return requested;
}
private static ApiException orgNotFound() {
diff --git a/src/main/java/kr/ac/pusan/pickle/admin/AdminTaskController.java b/src/main/java/kr/ac/pusan/pickle/admin/AdminTaskController.java
index 5958811e..1180ed72 100644
--- a/src/main/java/kr/ac/pusan/pickle/admin/AdminTaskController.java
+++ b/src/main/java/kr/ac/pusan/pickle/admin/AdminTaskController.java
@@ -6,6 +6,7 @@
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import java.util.List;
+import java.util.UUID;
import kr.ac.pusan.pickle.admin.dto.AdminTaskResponse;
import kr.ac.pusan.pickle.auth.dto.MessageResponse;
import kr.ac.pusan.pickle.common.web.PageResponse;
@@ -44,7 +45,7 @@ public PageResponse listAdminTasks(
// a single value still binds to a one-element list (compat).
@RequestParam(required = false) List status,
@RequestParam(required = false) ProvisioningTaskKind kind,
- @RequestParam(required = false) Long vmId,
+ @RequestParam(required = false) UUID vmId,
@RequestParam(defaultValue = "0") @Min(0) int page,
@RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) {
return adminTaskService.list(status, kind, vmId, page, size);
@@ -54,7 +55,7 @@ public PageResponse listAdminTasks(
@ResponseStatus(HttpStatus.ACCEPTED)
public MessageResponse retryAdminTask(
@AuthenticationPrincipal AuthenticatedUser principal,
- @PathVariable long taskId,
+ @PathVariable UUID taskId,
HttpServletRequest httpRequest) {
return adminTaskService.retry(principal, taskId, clientIp(httpRequest));
}
diff --git a/src/main/java/kr/ac/pusan/pickle/admin/AdminTaskService.java b/src/main/java/kr/ac/pusan/pickle/admin/AdminTaskService.java
index 89135102..46630063 100644
--- a/src/main/java/kr/ac/pusan/pickle/admin/AdminTaskService.java
+++ b/src/main/java/kr/ac/pusan/pickle/admin/AdminTaskService.java
@@ -4,6 +4,7 @@
import java.util.List;
import java.util.Map;
import java.util.Objects;
+import java.util.UUID;
import java.util.function.Function;
import java.util.stream.Collectors;
import kr.ac.pusan.pickle.admin.dto.AdminTaskResponse;
@@ -77,8 +78,11 @@ public AdminTaskService(ProvisioningTaskRepository taskRepository, VmRepository
* filter (v0.9.0 — OR over the given statuses; empty/null = no status filter).
*/
public PageResponse list(List statuses,
- ProvisioningTaskKind kind, Long vmId, int page, int size) {
+ ProvisioningTaskKind kind, UUID publicVmId, int page, int size) {
Specification spec = (root, query, cb) -> cb.conjunction();
+ // An id no VM has filters to nothing, as a non-matching number did.
+ Long vmId = publicVmId == null ? null
+ : vmRepository.findByPublicId(publicVmId).map(Vm::getId).orElse(-1L);
if (statuses != null && !statuses.isEmpty()) {
spec = spec.and((root, query, cb) -> root.get("status").in(statuses));
}
@@ -95,9 +99,9 @@ public PageResponse list(List statuse
Map vms = vmRepository.findAllById(result.getContent().stream()
.map(ProvisioningTask::getVmId).distinct().toList()).stream()
.collect(Collectors.toMap(Vm::getId, Function.identity()));
- Map orgNames = orgRepository.findAllById(vms.values().stream()
+ Map orgs = orgRepository.findAllById(vms.values().stream()
.map(Vm::getOrgId).filter(Objects::nonNull).distinct().toList()).stream()
- .collect(Collectors.toMap(Org::getId, Org::getName));
+ .collect(Collectors.toMap(Org::getId, Function.identity()));
// History-preserving workspace-name join (v0.9.0): reads all workspaces since a
// task's VM may reference a since-deleted workspace.
Map workspaceNames = workspaceRepository.findAllById(vms.values().stream()
@@ -107,11 +111,13 @@ public PageResponse list(List statuse
List content = result.getContent().stream()
.map(task -> {
Vm vm = vms.get(task.getVmId());
- String orgName = vm == null || vm.getOrgId() == null ? null
- : orgNames.get(vm.getOrgId());
+ Org org = vm == null || vm.getOrgId() == null ? null : orgs.get(vm.getOrgId());
String workspaceName = vm == null || vm.getWorkspaceId() == null ? null
: workspaceNames.get(vm.getWorkspaceId());
- return AdminTaskResponse.from(task, vm, orgName, workspaceName);
+ return AdminTaskResponse.from(task, vm,
+ vm == null ? null : vm.getPublicId(),
+ org == null ? null : org.getPublicId(),
+ org == null ? null : org.getName(), workspaceName);
})
.toList();
return PageResponse.of(content, result);
@@ -119,12 +125,12 @@ public PageResponse list(List statuse
/** NEEDS_ADMIN → RETRYING CAS + after-commit re-enqueue of the kind's job. */
@Transactional
- public MessageResponse retry(AuthenticatedUser actor, long taskId, String ip) {
- ProvisioningTask task = taskRepository.findById(taskId)
+ public MessageResponse retry(AuthenticatedUser actor, UUID publicTaskId, String ip) {
+ ProvisioningTask task = taskRepository.findByPublicId(publicTaskId)
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND,
ErrorCodes.RESOURCE_NOT_FOUND, "리소스를 찾을 수 없습니다",
"작업을 찾을 수 없습니다."));
- if (taskRepository.requeueForAdminRetry(taskId, Instant.now()) == 0) {
+ if (taskRepository.requeueForAdminRetry(task.getId(), Instant.now()) == 0) {
throw notRetryable();
}
long vmId = task.getVmId();
@@ -142,7 +148,7 @@ public void afterCommit() {
}
});
auditService.recordAfterCommit(actor.id(), actor.role().name(), AuditService.TASK_RETRY,
- "provisioning_task", taskId,
+ "provisioning_task", task.getPublicId(),
Map.of("vmId", vmId, "kind", task.getKind().name()), ip);
return new MessageResponse("작업 재시도를 접수했습니다. 잠시 후 작업 상태가 갱신됩니다.");
}
diff --git a/src/main/java/kr/ac/pusan/pickle/admin/AdminUserController.java b/src/main/java/kr/ac/pusan/pickle/admin/AdminUserController.java
index cada4a9f..f58404e6 100644
--- a/src/main/java/kr/ac/pusan/pickle/admin/AdminUserController.java
+++ b/src/main/java/kr/ac/pusan/pickle/admin/AdminUserController.java
@@ -7,6 +7,7 @@
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Size;
+import java.util.UUID;
import kr.ac.pusan.pickle.admin.dto.DisableUserRequest;
import kr.ac.pusan.pickle.admin.dto.UserAdminDetailResponse;
import kr.ac.pusan.pickle.admin.dto.UserAdminViewResponse;
@@ -50,7 +51,7 @@ public PageResponse listUsers(
@RequestParam(required = false) @Size(min = 1, max = 100) String q,
@RequestParam(required = false) UserStatus status,
@RequestParam(required = false) UserRole role,
- @RequestParam(required = false) Long orgId,
+ @RequestParam(required = false) UUID orgId,
@io.swagger.v3.oas.annotations.Parameter(schema = @io.swagger.v3.oas.annotations.media.Schema(
allowableValues = {"name", "-name", "email", "-email", "createdAt", "-createdAt"}))
@RequestParam(required = false) String sort,
@@ -61,14 +62,14 @@ public PageResponse listUsers(
@GetMapping("/{userId}")
public UserAdminDetailResponse getUser(@AuthenticationPrincipal AuthenticatedUser principal,
- @PathVariable long userId) {
+ @PathVariable UUID userId) {
return adminUserQueryService.getUser(principal, userId);
}
@PostMapping("/{userId}/disable")
@PreAuthorize("hasRole('SYS_ADMIN')")
public UserAdminDetailResponse disableUser(@AuthenticationPrincipal AuthenticatedUser principal,
- @PathVariable long userId, @Valid @RequestBody DisableUserRequest request,
+ @PathVariable UUID userId, @Valid @RequestBody DisableUserRequest request,
HttpServletRequest httpRequest) {
return adminUserService.disable(principal, userId, request.reason(), clientIp(httpRequest));
}
@@ -76,14 +77,14 @@ public UserAdminDetailResponse disableUser(@AuthenticationPrincipal Authenticate
@PostMapping("/{userId}/enable")
@PreAuthorize("hasRole('SYS_ADMIN')")
public UserAdminDetailResponse enableUser(@AuthenticationPrincipal AuthenticatedUser principal,
- @PathVariable long userId, HttpServletRequest httpRequest) {
+ @PathVariable UUID userId, HttpServletRequest httpRequest) {
return adminUserService.enable(principal, userId, clientIp(httpRequest));
}
@PostMapping("/{userId}/mfa-reset")
@PreAuthorize("hasRole('SYS_ADMIN')")
public MessageResponse resetUserMfa(@AuthenticationPrincipal AuthenticatedUser principal,
- @PathVariable long userId, HttpServletRequest httpRequest) {
+ @PathVariable UUID userId, HttpServletRequest httpRequest) {
return adminUserService.resetMfa(principal, userId, clientIp(httpRequest));
}
}
diff --git a/src/main/java/kr/ac/pusan/pickle/admin/AdminUserQueryService.java b/src/main/java/kr/ac/pusan/pickle/admin/AdminUserQueryService.java
index 0c719e99..38909be1 100644
--- a/src/main/java/kr/ac/pusan/pickle/admin/AdminUserQueryService.java
+++ b/src/main/java/kr/ac/pusan/pickle/admin/AdminUserQueryService.java
@@ -5,6 +5,7 @@
import java.util.List;
import java.util.Map;
import java.util.Set;
+import java.util.UUID;
import java.util.stream.Collectors;
import kr.ac.pusan.pickle.admin.dto.UserAdminDetailResponse;
import kr.ac.pusan.pickle.admin.dto.UserAdminViewResponse;
@@ -73,7 +74,7 @@ public AdminUserQueryService(JdbcTemplate jdbcTemplate, UserRepository userRepos
@Transactional(readOnly = true)
public PageResponse listUsers(AuthenticatedUser actor, String q,
- UserStatus status, UserRole role, Long orgId, String sort, int page, int size) {
+ UserStatus status, UserRole role, UUID orgId, String sort, int page, int size) {
Long scopedOrgId = scopeOrgId(actor, orgId);
StringBuilder where = new StringBuilder(" where 1 = 1");
List
*/
public void recordAfterCommit(Long actorId, String actorRole, String action, String targetType,
- Long targetId, Map detail, String ip) {
+ UUID targetId, Map detail, String ip) {
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
record(actorId, actorRole, action, targetType, targetId, detail, ip);
return;
diff --git a/src/main/java/kr/ac/pusan/pickle/audit/dto/ActivityEntryResponse.java b/src/main/java/kr/ac/pusan/pickle/audit/dto/ActivityEntryResponse.java
index 79dddb35..79f888f5 100644
--- a/src/main/java/kr/ac/pusan/pickle/audit/dto/ActivityEntryResponse.java
+++ b/src/main/java/kr/ac/pusan/pickle/audit/dto/ActivityEntryResponse.java
@@ -1,12 +1,14 @@
package kr.ac.pusan.pickle.audit.dto;
+import io.swagger.v3.oas.annotations.media.Schema;
import java.time.Instant;
import org.jspecify.annotations.Nullable;
import tools.jackson.databind.JsonNode;
/** Contract {@code ActivityEntry}: one self-view audit row (login history included). */
public record ActivityEntryResponse(
- long id,
+ @Schema(description = "활동 행 식별자. 목록 렌더링용 키이며 어떤 조회 파라미터도 아닙니다.")
+ String id,
String action,
@Nullable String targetType,
@Nullable String targetId,
diff --git a/src/main/java/kr/ac/pusan/pickle/audit/dto/AuditLogViewResponse.java b/src/main/java/kr/ac/pusan/pickle/audit/dto/AuditLogViewResponse.java
index 2e200681..7e45eb80 100644
--- a/src/main/java/kr/ac/pusan/pickle/audit/dto/AuditLogViewResponse.java
+++ b/src/main/java/kr/ac/pusan/pickle/audit/dto/AuditLogViewResponse.java
@@ -1,6 +1,8 @@
package kr.ac.pusan.pickle.audit.dto;
+import io.swagger.v3.oas.annotations.media.Schema;
import java.time.Instant;
+import java.util.UUID;
import org.jspecify.annotations.Nullable;
import tools.jackson.databind.JsonNode;
@@ -11,8 +13,9 @@
* internal actors (e.g. the SSH gateway) stamp roles outside the user enum.
*/
public record AuditLogViewResponse(
- long id,
- @Nullable Long actorId,
+ @Schema(description = "감사 로그 행 식별자. 목록 렌더링용 키이며 어떤 조회 파라미터도 아닙니다.")
+ String id,
+ @Nullable UUID actorId,
@Nullable String actorEmail,
@Nullable String actorName,
@Nullable String actorRole,
diff --git a/src/main/java/kr/ac/pusan/pickle/auth/AuthService.java b/src/main/java/kr/ac/pusan/pickle/auth/AuthService.java
index c06befb0..64b77236 100644
--- a/src/main/java/kr/ac/pusan/pickle/auth/AuthService.java
+++ b/src/main/java/kr/ac/pusan/pickle/auth/AuthService.java
@@ -167,7 +167,7 @@ private void createAccount(SignupRequest request, String email, String ip) {
termsService.recordSignupConsents(user.getId(), request.consents());
sendVerificationMail(user);
auditService.record(user.getId(), user.getRole().name(), AuditService.AUTH_SIGNUP,
- "user", user.getId(), Map.of("email", user.getEmail()), ip);
+ "user", user.getPublicId(), Map.of("email", user.getEmail()), ip);
}
private static MessageResponse signupAccepted() {
@@ -199,7 +199,7 @@ public MessageResponse verifyEmail(String rawToken, String ip) {
}
personalWorkspaceService.ensurePersonalWorkspace(user);
auditService.record(user.getId(), user.getRole().name(), AuditService.AUTH_VERIFY,
- "user", user.getId(), Map.of("email", user.getEmail()), ip);
+ "user", user.getPublicId(), Map.of("email", user.getEmail()), ip);
return new MessageResponse("이메일 인증이 완료되었습니다. 이제 로그인할 수 있습니다.");
}
@@ -231,7 +231,7 @@ public LoginOutcome login(LoginRequest request, String ip, String userAgent) {
rateLimitService.registerLoginFailure(email, ip);
auditService.record(found.map(User::getId).orElse(null),
found.map(u -> u.getRole().name()).orElse(null), AuditService.AUTH_LOGIN_FAILED,
- "user", found.map(User::getId).orElse(null),
+ "user", found.map(User::getPublicId).orElse(null),
Map.of("email", email, "reason", "bad_credentials"), ip);
throw invalidCredentials();
}
@@ -239,14 +239,14 @@ public LoginOutcome login(LoginRequest request, String ip, String userAgent) {
User user = found.get();
if (user.getStatus() == UserStatus.PENDING_VERIFICATION) {
auditService.record(user.getId(), user.getRole().name(), AuditService.AUTH_LOGIN_FAILED,
- "user", user.getId(), Map.of("email", email, "reason", "email_not_verified"), ip);
+ "user", user.getPublicId(), Map.of("email", email, "reason", "email_not_verified"), ip);
throw new ApiException(HttpStatus.FORBIDDEN, ErrorCodes.AUTH_EMAIL_NOT_VERIFIED,
"이메일 인증이 필요합니다", "가입 시 발송된 인증 메일을 확인한 뒤 다시 로그인해 주세요.");
}
if (user.getStatus() != UserStatus.ACTIVE) {
// Uniform 401: do not disclose DISABLED/WITHDRAWN state.
auditService.record(user.getId(), user.getRole().name(), AuditService.AUTH_LOGIN_FAILED,
- "user", user.getId(),
+ "user", user.getPublicId(),
Map.of("email", email, "reason", "status_" + user.getStatus().name().toLowerCase(Locale.ROOT)),
ip);
throw invalidCredentials();
@@ -262,7 +262,7 @@ public LoginOutcome login(LoginRequest request, String ip, String userAgent) {
rateLimitService.clearLoginFailures(email, ip);
var issued = refreshTokenService.issue(user.getId(), authProperties.refreshTokenTtl(), null, userAgent, ip);
auditService.record(user.getId(), user.getRole().name(), AuditService.AUTH_LOGIN,
- "user", user.getId(), Map.of("email", email), ip);
+ "user", user.getPublicId(), Map.of("email", email), ip);
return new AuthResult(
new AuthTokenResponse(jwtService.createAccessToken(user), UserSummaryResponse.from(user)),
issued.rawToken());
@@ -287,7 +287,7 @@ public AuthResult completeMfaLogin(String mfaToken, String code, String recovery
if (!mfaService.verifyEnrolledCode(user.getId(), code, recoveryCode)) {
rateLimitService.registerLoginFailure(user.getEmail(), ip);
auditService.record(user.getId(), user.getRole().name(), AuditService.AUTH_LOGIN_FAILED,
- "user", user.getId(), Map.of("email", user.getEmail(), "reason", "mfa_code"), ip);
+ "user", user.getPublicId(), Map.of("email", user.getEmail(), "reason", "mfa_code"), ip);
throw MfaService.loginCodeInvalid();
}
mfaService.consumeChallenge(challenge);
@@ -295,7 +295,7 @@ public AuthResult completeMfaLogin(String mfaToken, String code, String recovery
var issued = refreshTokenService.issue(user.getId(), authProperties.refreshTokenTtl(), null, userAgent, ip);
auditService.record(user.getId(), user.getRole().name(), AuditService.AUTH_LOGIN,
- "user", user.getId(), Map.of("email", user.getEmail(), "stage", "mfa"), ip);
+ "user", user.getPublicId(), Map.of("email", user.getEmail(), "stage", "mfa"), ip);
return new AuthResult(
new AuthTokenResponse(jwtService.createAccessToken(user), UserSummaryResponse.from(user)),
issued.rawToken());
@@ -316,7 +316,7 @@ public AuthResult refresh(String rawToken, String ip, String userAgent) {
// Theft signal: reuse of a rotated token revokes the whole chain.
refreshTokenService.revokeChainFrom(current.getId());
auditService.record(current.getUserId(), null, AuditService.AUTH_REFRESH_REUSE_DETECTED,
- "refresh_token", current.getId(), Map.of(), ip);
+ "refresh_token", null, Map.of("refreshTokenId", current.getId()), ip);
throw refreshTokenInvalid();
}
if (current.isExpired(Instant.now())) {
@@ -337,7 +337,7 @@ public AuthResult refresh(String rawToken, String ip, String userAgent) {
// Lost a race with another rotation of the same token: reuse.
refreshTokenService.revokeChainFrom(current.getId());
auditService.record(current.getUserId(), null, AuditService.AUTH_REFRESH_REUSE_DETECTED,
- "refresh_token", current.getId(), Map.of(), ip);
+ "refresh_token", null, Map.of("refreshTokenId", current.getId()), ip);
return refreshTokenInvalid();
});
return new AuthResult(
@@ -353,7 +353,7 @@ public void logout(String rawToken, String ip) {
refreshTokenService.findByRawToken(rawToken).ifPresent(token -> {
refreshTokenService.revoke(token.getId());
auditService.record(token.getUserId(), null, AuditService.AUTH_LOGOUT,
- "refresh_token", token.getId(), Map.of(), ip);
+ "refresh_token", null, Map.of("refreshTokenId", token.getId()), ip);
});
}
@@ -378,7 +378,7 @@ public AuthResult changePassword(long userId, String currentPassword, String new
// hijacked session cannot switch endpoints to keep guessing.
rateLimitService.registerLoginFailure(user.getEmail(), ip);
auditService.record(user.getId(), user.getRole().name(), AuditService.ACCOUNT_PASSWORD_CHANGE,
- "user", user.getId(), Map.of("result", "mismatch"), ip);
+ "user", user.getPublicId(), Map.of("result", "mismatch"), ip);
throw passwordMismatch();
}
rateLimitService.clearLoginFailures(user.getEmail(), ip);
@@ -392,7 +392,7 @@ public AuthResult changePassword(long userId, String currentPassword, String new
var issued = refreshTokenService.issue(user.getId(), authProperties.refreshTokenTtl(), null, userAgent, ip);
auditService.recordAfterCommit(user.getId(), user.getRole().name(),
- AuditService.ACCOUNT_PASSWORD_CHANGE, "user", user.getId(), Map.of(), ip);
+ AuditService.ACCOUNT_PASSWORD_CHANGE, "user", user.getPublicId(), Map.of(), ip);
notificationService.publish(user.getId(), NotificationEvent.ACCOUNT_PASSWORD_CHANGED, Map.of(),
"account_password_changed:" + user.getId() + ":" + user.getTokenVersion());
return new AuthResult(
@@ -451,7 +451,7 @@ public MessageResponse confirmPasswordReset(String rawToken, String newPassword,
rateLimitService.clearLoginFailures(user.getEmail(), ip);
auditService.recordAfterCommit(user.getId(), user.getRole().name(),
- AuditService.ACCOUNT_PASSWORD_RESET, "user", user.getId(), Map.of(), ip);
+ AuditService.ACCOUNT_PASSWORD_RESET, "user", user.getPublicId(), Map.of(), ip);
notificationService.publish(user.getId(), NotificationEvent.ACCOUNT_PASSWORD_CHANGED, Map.of(),
"account_password_reset:" + user.getId() + ":" + user.getTokenVersion());
return new MessageResponse("비밀번호가 변경되었습니다. 새 비밀번호로 로그인해 주세요.");
diff --git a/src/main/java/kr/ac/pusan/pickle/auth/ReauthService.java b/src/main/java/kr/ac/pusan/pickle/auth/ReauthService.java
index a9de89aa..9e1396b4 100644
--- a/src/main/java/kr/ac/pusan/pickle/auth/ReauthService.java
+++ b/src/main/java/kr/ac/pusan/pickle/auth/ReauthService.java
@@ -56,7 +56,7 @@ public ReverifyResponse issue(AuthenticatedUser actor, String password, String i
if (!passwordEncoder.matches(password, user.getPasswordHash())) {
rateLimitService.registerLoginFailure(user.getEmail(), ip);
auditService.record(actor.id(), actor.role().name(), AuditService.AUTH_REVERIFY,
- "user", user.getId(), Map.of("result", "mismatch"), ip);
+ "user", user.getPublicId(), Map.of("result", "mismatch"), ip);
throw passwordMismatch();
}
rateLimitService.clearLoginFailures(user.getEmail(), ip);
@@ -65,7 +65,7 @@ public ReverifyResponse issue(AuthenticatedUser actor, String password, String i
repository.save(new AuthReverification(user.getId(), TokenHasher.sha256Hex(rawToken),
user.getTokenVersion(), expiresAt, ip));
auditService.record(actor.id(), actor.role().name(), AuditService.AUTH_REVERIFY,
- "user", user.getId(), Map.of("result", "success"), ip);
+ "user", user.getPublicId(), Map.of("result", "success"), ip);
return new ReverifyResponse(rawToken, expiresAt);
}
diff --git a/src/main/java/kr/ac/pusan/pickle/campusip/AdminCampusIpRequestController.java b/src/main/java/kr/ac/pusan/pickle/campusip/AdminCampusIpRequestController.java
index a421e722..c556e5ea 100644
--- a/src/main/java/kr/ac/pusan/pickle/campusip/AdminCampusIpRequestController.java
+++ b/src/main/java/kr/ac/pusan/pickle/campusip/AdminCampusIpRequestController.java
@@ -6,6 +6,7 @@
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
+import java.util.UUID;
import kr.ac.pusan.pickle.campusip.dto.AdminCampusIpRequestView;
import kr.ac.pusan.pickle.campusip.dto.UpdateCampusIpRequestStatusRequest;
import kr.ac.pusan.pickle.common.web.PageResponse;
@@ -39,7 +40,7 @@ public AdminCampusIpRequestController(CampusIpRequestService campusIpRequestServ
@GetMapping
public PageResponse listAdminCampusIpRequests(
@RequestParam(required = false) CampusIpRequestStatus status,
- @RequestParam(required = false) Long vmId,
+ @RequestParam(required = false) UUID vmId,
@RequestParam(defaultValue = "0") @Min(0) int page,
@RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) {
return campusIpRequestService.adminList(status, vmId, page, size);
@@ -48,7 +49,7 @@ public PageResponse listAdminCampusIpRequests(
@PostMapping("/{requestId}/status")
@PreAuthorize("hasRole('SYS_ADMIN')")
public AdminCampusIpRequestView updateAdminCampusIpRequestStatus(
- @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable long requestId,
+ @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable UUID requestId,
@Valid @RequestBody UpdateCampusIpRequestStatusRequest request,
HttpServletRequest httpRequest) {
return campusIpRequestService.updateStatus(principal, requestId, request,
diff --git a/src/main/java/kr/ac/pusan/pickle/campusip/CampusIpRequest.java b/src/main/java/kr/ac/pusan/pickle/campusip/CampusIpRequest.java
index 13d5851e..e232c607 100644
--- a/src/main/java/kr/ac/pusan/pickle/campusip/CampusIpRequest.java
+++ b/src/main/java/kr/ac/pusan/pickle/campusip/CampusIpRequest.java
@@ -9,6 +9,7 @@
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
+import java.util.UUID;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.annotations.UpdateTimestamp;
@@ -28,6 +29,14 @@ public class CampusIpRequest {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
+ /**
+ * The identifier this row wears outside the API boundary. Internal joins,
+ * sorts and foreign keys keep using {@link #id}.
+ */
+ @JdbcTypeCode(SqlTypes.UUID)
+ @Column(name = "public_id", nullable = false, updatable = false, unique = true)
+ private UUID publicId = UUID.randomUUID();
+
@Column(name = "vm_id", nullable = false)
private Long vmId;
@@ -81,6 +90,10 @@ public Long getId() {
return id;
}
+ public UUID getPublicId() {
+ return publicId;
+ }
+
public Long getVmId() {
return vmId;
}
diff --git a/src/main/java/kr/ac/pusan/pickle/campusip/CampusIpRequestController.java b/src/main/java/kr/ac/pusan/pickle/campusip/CampusIpRequestController.java
index 36b2efb4..15ddb358 100644
--- a/src/main/java/kr/ac/pusan/pickle/campusip/CampusIpRequestController.java
+++ b/src/main/java/kr/ac/pusan/pickle/campusip/CampusIpRequestController.java
@@ -5,6 +5,7 @@
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import java.util.List;
+import java.util.UUID;
import kr.ac.pusan.pickle.campusip.dto.CampusIpRequestView;
import kr.ac.pusan.pickle.campusip.dto.CreateCampusIpRequest;
import kr.ac.pusan.pickle.security.AuthenticatedUser;
@@ -37,14 +38,14 @@ public CampusIpRequestController(CampusIpRequestService campusIpRequestService)
@GetMapping
public List listVmCampusIpRequests(
- @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable long vmId) {
+ @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable UUID vmId) {
return campusIpRequestService.list(principal, vmId);
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public CampusIpRequestView requestVmCampusIp(
- @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable long vmId,
+ @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable UUID vmId,
@Valid @RequestBody CreateCampusIpRequest request, HttpServletRequest httpRequest) {
return campusIpRequestService.create(principal, vmId, request, clientIp(httpRequest));
}
@@ -53,8 +54,8 @@ public CampusIpRequestView requestVmCampusIp(
@DeleteMapping("/{requestId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void cancelVmCampusIpRequest(
- @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable long vmId,
- @PathVariable long requestId, HttpServletRequest httpRequest) {
+ @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable UUID vmId,
+ @PathVariable UUID requestId, HttpServletRequest httpRequest) {
campusIpRequestService.cancel(principal, vmId, requestId, clientIp(httpRequest));
}
}
diff --git a/src/main/java/kr/ac/pusan/pickle/campusip/CampusIpRequestRepository.java b/src/main/java/kr/ac/pusan/pickle/campusip/CampusIpRequestRepository.java
index 10f2a93b..37cce6ff 100644
--- a/src/main/java/kr/ac/pusan/pickle/campusip/CampusIpRequestRepository.java
+++ b/src/main/java/kr/ac/pusan/pickle/campusip/CampusIpRequestRepository.java
@@ -3,12 +3,16 @@
import java.util.Collection;
import java.util.List;
import java.util.Optional;
+import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
public interface CampusIpRequestRepository
extends JpaRepository, JpaSpecificationExecutor {
+ /** Resolution of the identifier this row wears outside the API boundary. */
+ Optional findByPublicId(UUID publicId);
+
List findByVmIdOrderByIdDesc(long vmId);
Optional findByIdAndVmId(long id, long vmId);
diff --git a/src/main/java/kr/ac/pusan/pickle/campusip/CampusIpRequestService.java b/src/main/java/kr/ac/pusan/pickle/campusip/CampusIpRequestService.java
index 15c94c29..a7fa7054 100644
--- a/src/main/java/kr/ac/pusan/pickle/campusip/CampusIpRequestService.java
+++ b/src/main/java/kr/ac/pusan/pickle/campusip/CampusIpRequestService.java
@@ -6,6 +6,7 @@
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
+import java.util.UUID;
import java.util.regex.Pattern;
import kr.ac.pusan.pickle.access.ResourceRole;
import kr.ac.pusan.pickle.access.VmAccessService;
@@ -65,18 +66,21 @@ public class CampusIpRequestService {
private final VmRepository vmRepository;
private final VmAccessService vmAccessService;
private final UserRepository userRepository;
+ private final kr.ac.pusan.pickle.orgs.OrgRepository orgRepository;
private final NotificationService notificationService;
private final AuditService auditService;
private final ObjectMapper objectMapper;
public CampusIpRequestService(CampusIpRequestRepository requestRepository,
VmRepository vmRepository, VmAccessService vmAccessService,
- UserRepository userRepository, NotificationService notificationService,
+ UserRepository userRepository, kr.ac.pusan.pickle.orgs.OrgRepository orgRepository,
+ NotificationService notificationService,
AuditService auditService, ObjectMapper objectMapper) {
this.requestRepository = requestRepository;
this.vmRepository = vmRepository;
this.vmAccessService = vmAccessService;
this.userRepository = userRepository;
+ this.orgRepository = orgRepository;
this.notificationService = notificationService;
this.auditService = auditService;
this.objectMapper = objectMapper;
@@ -86,16 +90,18 @@ public CampusIpRequestService(CampusIpRequestRepository requestRepository,
/** Reads need membership only (VIEWER+); writes need OWNER/EDITOR. */
@Transactional(readOnly = true)
- public List list(AuthenticatedUser actor, long vmId) {
- requireVmMember(actor, vmId);
- return requestRepository.findByVmIdOrderByIdDesc(vmId).stream()
- .map(this::toView).toList();
+ public List list(AuthenticatedUser actor, UUID publicVmId) {
+ Vm vm = requireVmMember(actor, publicVmId);
+ List requests = requestRepository.findByVmIdOrderByIdDesc(vm.getId());
+ Map userIds = userPublicIds(requests);
+ return requests.stream().map(request -> toView(request, vm.getPublicId(), userIds)).toList();
}
@Transactional
- public CampusIpRequestView create(AuthenticatedUser actor, long vmId,
+ public CampusIpRequestView create(AuthenticatedUser actor, UUID publicVmId,
CreateCampusIpRequest request, String ip) {
- Vm vm = requireVmOwnerOrEditor(actor, vmId);
+ Vm vm = requireVmOwnerOrEditor(actor, publicVmId);
+ long vmId = vm.getId();
String purpose = request.purpose().strip();
List ports = normalizePorts(request.ports());
if (requestRepository.existsByVmIdAndStatusIn(vmId, LIVE_STATUSES)) {
@@ -112,13 +118,13 @@ public CampusIpRequestView create(AuthenticatedUser actor, long vmId,
}
notificationService.publish(notificationService.sysAdminIds(),
NotificationEvent.CAMPUS_IP_REQUESTED,
- Map.of("requestId", created.getId(), "vmId", vmId, "vmName", vm.getName(),
+ Map.of("requestId", created.getPublicId(), "vmId", vm.getPublicId(), "vmName", vm.getName(),
"purpose", purpose),
null);
auditService.recordAfterCommit(actor.id(), actor.role().name(),
- AuditService.CAMPUS_IP_REQUEST, "campus_ip_request", created.getId(),
+ AuditService.CAMPUS_IP_REQUEST, "campus_ip_request", created.getPublicId(),
Map.of("vmId", vmId, "ports", ports), ip);
- return toView(created);
+ return toView(created, vm.getPublicId(), Map.of(actor.id(), actor.publicId()));
}
/**
@@ -126,16 +132,18 @@ public CampusIpRequestView create(AuthenticatedUser actor, long vmId,
* only a still-unreviewed (REQUESTED) 신청.
*/
@Transactional
- public void cancel(AuthenticatedUser actor, long vmId, long requestId, String ip) {
- requireVmOwnerOrEditor(actor, vmId);
- CampusIpRequest request = requestRepository.findByIdAndVmId(requestId, vmId)
+ public void cancel(AuthenticatedUser actor, UUID publicVmId, UUID publicRequestId, String ip) {
+ Vm vm = requireVmOwnerOrEditor(actor, publicVmId);
+ long vmId = vm.getId();
+ CampusIpRequest request = requestRepository.findByPublicId(publicRequestId)
+ .filter(row -> row.getVmId() == vmId)
.orElseThrow(CampusIpRequestService::requestNotFound);
if (request.getStatus() != CampusIpRequestStatus.REQUESTED) {
throw invalidTransition("검토가 시작되기 전(REQUESTED)의 신청만 취소할 수 있습니다.");
}
requestRepository.delete(request);
auditService.recordAfterCommit(actor.id(), actor.role().name(),
- AuditService.CAMPUS_IP_CANCEL, "campus_ip_request", requestId,
+ AuditService.CAMPUS_IP_CANCEL, "campus_ip_request", request.getPublicId(),
Map.of("vmId", vmId), ip);
}
@@ -143,34 +151,39 @@ public void cancel(AuthenticatedUser actor, long vmId, long requestId, String ip
@Transactional(readOnly = true)
public PageResponse adminList(CampusIpRequestStatus status,
- Long vmId, int page, int size) {
+ UUID publicVmId, int page, int size) {
Specification spec = (root, query, cb) -> cb.conjunction();
if (status != null) {
spec = spec.and((root, query, cb) -> cb.equal(root.get("status"), status));
}
- if (vmId != null) {
+ if (publicVmId != null) {
+ // An id no VM has filters to nothing, as a non-matching number did.
+ Long vmId = vmRepository.findByPublicId(publicVmId).map(Vm::getId).orElse(-1L);
spec = spec.and((root, query, cb) -> cb.equal(root.get("vmId"), vmId));
}
Page result = requestRepository.findAll(spec,
PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "id")));
+ Map userIds = userPublicIds(result.getContent());
List views = result.getContent().stream().map(request -> {
Vm vm = vmRepository.findById(request.getVmId()).orElse(null);
User requester = userRepository.findById(request.getRequestedBy()).orElse(null);
- return new AdminCampusIpRequestView(request.getId(), request.getVmId(),
- vm != null ? vm.getName() : null, vm != null ? vm.getOrgId() : null,
+ return new AdminCampusIpRequestView(request.getPublicId(),
+ vm != null ? vm.getPublicId() : null,
+ vm != null ? vm.getName() : null, orgPublicId(vm),
request.getPurpose(), parsePorts(request.getPorts()), request.getStatus(),
request.getGrantedAddress(), request.getAdminNote(),
- request.getRequestedBy(),
+ userIds.get(request.getRequestedBy()),
requester != null ? requester.getEmail() : null,
- request.getProcessedBy(), request.getProcessedAt(), request.getCreatedAt());
+ userIds.get(request.getProcessedBy()), request.getProcessedAt(),
+ request.getCreatedAt());
}).toList();
return PageResponse.of(views, result);
}
@Transactional
- public AdminCampusIpRequestView updateStatus(AuthenticatedUser actor, long requestId,
+ public AdminCampusIpRequestView updateStatus(AuthenticatedUser actor, UUID publicRequestId,
UpdateCampusIpRequestStatusRequest body, String ip) {
- CampusIpRequest request = requestRepository.findById(requestId)
+ CampusIpRequest request = requestRepository.findByPublicId(publicRequestId)
.orElseThrow(CampusIpRequestService::requestNotFound);
CampusIpRequestStatus from = request.getStatus();
CampusIpRequestStatus to = body.status();
@@ -202,14 +215,18 @@ public AdminCampusIpRequestView updateStatus(AuthenticatedUser actor, long reque
NotificationEvent.CAMPUS_IP_STATUS_CHANGED,
notificationArgs(request, vm), null);
auditService.recordAfterCommit(actor.id(), actor.role().name(),
- AuditService.CAMPUS_IP_STATUS_UPDATE, "campus_ip_request", requestId,
+ AuditService.CAMPUS_IP_STATUS_UPDATE, "campus_ip_request", request.getPublicId(),
Map.of("vmId", request.getVmId(), "from", from.name(), "to", to.name()), ip);
User requester = userRepository.findById(request.getRequestedBy()).orElse(null);
- return new AdminCampusIpRequestView(request.getId(), request.getVmId(),
- vm != null ? vm.getName() : null, vm != null ? vm.getOrgId() : null,
+ Map userIds = userPublicIds(List.of(request));
+ return new AdminCampusIpRequestView(request.getPublicId(),
+ vm != null ? vm.getPublicId() : null,
+ vm != null ? vm.getName() : null, orgPublicId(vm),
request.getPurpose(), parsePorts(request.getPorts()), request.getStatus(),
- request.getGrantedAddress(), request.getAdminNote(), request.getRequestedBy(),
- requester != null ? requester.getEmail() : null, request.getProcessedBy(),
+ request.getGrantedAddress(), request.getAdminNote(),
+ userIds.get(request.getRequestedBy()),
+ requester != null ? requester.getEmail() : null,
+ userIds.get(request.getProcessedBy()),
request.getProcessedAt(), request.getCreatedAt());
}
@@ -275,11 +292,28 @@ private static List normalizePorts(List ports) {
return List.copyOf(normalized);
}
- private CampusIpRequestView toView(CampusIpRequest request) {
- return new CampusIpRequestView(request.getId(), request.getVmId(), request.getPurpose(),
+ private CampusIpRequestView toView(CampusIpRequest request, UUID vmId, Map userIds) {
+ return new CampusIpRequestView(request.getPublicId(), vmId, request.getPurpose(),
parsePorts(request.getPorts()), request.getStatus(), request.getGrantedAddress(),
- request.getAdminNote(), request.getRequestedBy(), request.getProcessedAt(),
- request.getCreatedAt());
+ request.getAdminNote(), userIds.get(request.getRequestedBy()),
+ request.getProcessedAt(), request.getCreatedAt());
+ }
+
+ /** Batch account join: both {@code requestedBy} and {@code processedBy} are public ids. */
+ private Map userPublicIds(List requests) {
+ List ids = java.util.stream.Stream.concat(
+ requests.stream().map(CampusIpRequest::getRequestedBy),
+ requests.stream().map(CampusIpRequest::getProcessedBy))
+ .filter(java.util.Objects::nonNull).distinct().toList();
+ return ids.isEmpty() ? Map.of()
+ : userRepository.findAllById(ids).stream()
+ .collect(java.util.stream.Collectors.toMap(User::getId, User::getPublicId));
+ }
+
+ private UUID orgPublicId(Vm vm) {
+ return vm == null || vm.getOrgId() == null ? null
+ : orgRepository.findById(vm.getOrgId()).map(kr.ac.pusan.pickle.orgs.Org::getPublicId)
+ .orElse(null);
}
private List parsePorts(String json) {
@@ -292,11 +326,11 @@ private List parsePorts(String json) {
}
/** Membership check (VIEWER+): non-members get the 404 existence mask. */
- private Vm requireVmMember(AuthenticatedUser actor, long vmId) {
+ private Vm requireVmMember(AuthenticatedUser actor, UUID vmId) {
return vmAccessService.of(actor, vmId).requireVisible();
}
- private Vm requireVmOwnerOrEditor(AuthenticatedUser actor, long vmId) {
+ private Vm requireVmOwnerOrEditor(AuthenticatedUser actor, UUID vmId) {
return vmAccessService.of(actor, vmId).requireAtLeast(ResourceRole.EDITOR,
"교내 IP를 신청할 권한이 없습니다",
"이 VM의 소유자 또는 편집자만 교내 IP를 신청할 수 있습니다.");
diff --git a/src/main/java/kr/ac/pusan/pickle/campusip/dto/AdminCampusIpRequestView.java b/src/main/java/kr/ac/pusan/pickle/campusip/dto/AdminCampusIpRequestView.java
index 4aa9fb84..3d8c00df 100644
--- a/src/main/java/kr/ac/pusan/pickle/campusip/dto/AdminCampusIpRequestView.java
+++ b/src/main/java/kr/ac/pusan/pickle/campusip/dto/AdminCampusIpRequestView.java
@@ -3,15 +3,16 @@
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.Instant;
import java.util.List;
+import java.util.UUID;
import kr.ac.pusan.pickle.campusip.CampusIpRequestStatus;
import org.jspecify.annotations.Nullable;
/** Contract schema {@code AdminCampusIpRequestView} — 신청 + VM 컨텍스트. */
public record AdminCampusIpRequestView(
- Long id,
- Long vmId,
+ UUID id,
+ UUID vmId,
@Nullable String vmName,
- @Nullable Long orgId,
+ @Nullable UUID orgId,
@Schema(description = "신청 목적")
String purpose,
@Schema(description = "사용할 포트 번호 목록")
@@ -23,11 +24,11 @@ public record AdminCampusIpRequestView(
@Schema(description = "연결된 교내 IP 주소 (10.0.0.0/8)")
String grantedAddress,
@Nullable String adminNote,
- Long requestedBy,
+ UUID requestedBy,
@Nullable
@Schema(description = "신청자 이메일")
String requesterEmail,
- @Nullable Long processedBy,
+ @Nullable UUID processedBy,
@Nullable Instant processedAt,
Instant createdAt) {
}
diff --git a/src/main/java/kr/ac/pusan/pickle/campusip/dto/CampusIpRequestView.java b/src/main/java/kr/ac/pusan/pickle/campusip/dto/CampusIpRequestView.java
index 30926f02..8f3fec83 100644
--- a/src/main/java/kr/ac/pusan/pickle/campusip/dto/CampusIpRequestView.java
+++ b/src/main/java/kr/ac/pusan/pickle/campusip/dto/CampusIpRequestView.java
@@ -3,13 +3,14 @@
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.Instant;
import java.util.List;
+import java.util.UUID;
import kr.ac.pusan.pickle.campusip.CampusIpRequestStatus;
import org.jspecify.annotations.Nullable;
/** Contract schema {@code CampusIpRequestView} — one 교내 IP 신청. */
public record CampusIpRequestView(
- Long id,
- Long vmId,
+ UUID id,
+ UUID vmId,
@Schema(description = "신청 목적")
String purpose,
@Schema(description = "사용할 포트 번호 목록 (중복 제거·오름차순 정규화)")
@@ -23,7 +24,7 @@ public record CampusIpRequestView(
@Nullable
@Schema(description = "관리자 메모")
String adminNote,
- Long requestedBy,
+ UUID requestedBy,
@Nullable Instant processedAt,
Instant createdAt) {
}
diff --git a/src/main/java/kr/ac/pusan/pickle/common/error/ErrorCodes.java b/src/main/java/kr/ac/pusan/pickle/common/error/ErrorCodes.java
index 18405bc6..45d31f5f 100644
--- a/src/main/java/kr/ac/pusan/pickle/common/error/ErrorCodes.java
+++ b/src/main/java/kr/ac/pusan/pickle/common/error/ErrorCodes.java
@@ -53,7 +53,6 @@ public final class ErrorCodes {
* that no longer exists.
*/
public static final String WORKSPACE_DELETED = "WORKSPACE_DELETED";
- public static final String ORG_SLUG_DUPLICATE = "ORG_SLUG_DUPLICATE";
public static final String REQUEST_ALREADY_DECIDED = "REQUEST_ALREADY_DECIDED";
public static final String REQUEST_REQUESTER_INELIGIBLE = "REQUEST_REQUESTER_INELIGIBLE";
public static final String VM_INVALID_STATE = "VM_INVALID_STATE";
diff --git a/src/main/java/kr/ac/pusan/pickle/common/openapi/OpenApiConfig.java b/src/main/java/kr/ac/pusan/pickle/common/openapi/OpenApiConfig.java
index e1d2ab41..e5aec11a 100644
--- a/src/main/java/kr/ac/pusan/pickle/common/openapi/OpenApiConfig.java
+++ b/src/main/java/kr/ac/pusan/pickle/common/openapi/OpenApiConfig.java
@@ -41,7 +41,7 @@
public class OpenApiConfig {
/** Contract version served in {@code info.version}; bump on any contract change. */
- public static final String CONTRACT_VERSION = "0.37.0";
+ public static final String CONTRACT_VERSION = "0.38.0";
/** Name of the bearer-JWT security scheme in the published spec. */
private static final String BEARER_SCHEME = "bearerAuth";
@@ -70,8 +70,8 @@ public OpenAPI pickleOpenApi() {
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT")
- .description("액세스 토큰 (JWT HS256, 15분 만료). 클레임: sub, role, org_id, "
- + "token_version. 비밀번호 변경·계정 비활성화 시 token_version이 올라가 "
+ .description("액세스 토큰 (JWT HS256, 15분 만료). 클레임: sub(계정 공개 식별자 UUID), "
+ + "role, token_version. 비밀번호 변경·계정 비활성화 시 token_version이 올라가 "
+ "기존 토큰이 즉시 무효화됩니다. 리프레시 토큰은 보안 스킴이 아니라 "
+ "__Host-pickle_refresh httpOnly 쿠키로만 오갑니다.")))
// Applies to every operation unless the operation overrides it
diff --git a/src/main/java/kr/ac/pusan/pickle/inventory/Node.java b/src/main/java/kr/ac/pusan/pickle/inventory/Node.java
index 58238900..cec2897e 100644
--- a/src/main/java/kr/ac/pusan/pickle/inventory/Node.java
+++ b/src/main/java/kr/ac/pusan/pickle/inventory/Node.java
@@ -9,6 +9,7 @@
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
+import java.util.UUID;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.annotations.UpdateTimestamp;
@@ -27,6 +28,14 @@ public class Node {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
+ /**
+ * The identifier this row wears outside the API boundary. Internal joins,
+ * sorts and foreign keys keep using {@link #id}.
+ */
+ @JdbcTypeCode(SqlTypes.UUID)
+ @Column(name = "public_id", nullable = false, updatable = false, unique = true)
+ private UUID publicId = UUID.randomUUID();
+
@Column(nullable = false, unique = true)
private String name;
@@ -78,6 +87,10 @@ public Long getId() {
return id;
}
+ public UUID getPublicId() {
+ return publicId;
+ }
+
public String getName() {
return name;
}
diff --git a/src/main/java/kr/ac/pusan/pickle/inventory/NodeRepository.java b/src/main/java/kr/ac/pusan/pickle/inventory/NodeRepository.java
index 1c412910..3d06996f 100644
--- a/src/main/java/kr/ac/pusan/pickle/inventory/NodeRepository.java
+++ b/src/main/java/kr/ac/pusan/pickle/inventory/NodeRepository.java
@@ -1,9 +1,14 @@
package kr.ac.pusan.pickle.inventory;
import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
public interface NodeRepository extends JpaRepository {
+ /** Resolution of the identifier this row wears outside the API boundary. */
+ Optional findByPublicId(UUID publicId);
+
List findByStatusOrderByIdAsc(NodeStatus status);
}
diff --git a/src/main/java/kr/ac/pusan/pickle/inventory/OsImage.java b/src/main/java/kr/ac/pusan/pickle/inventory/OsImage.java
index 45b79567..28512fb0 100644
--- a/src/main/java/kr/ac/pusan/pickle/inventory/OsImage.java
+++ b/src/main/java/kr/ac/pusan/pickle/inventory/OsImage.java
@@ -9,6 +9,7 @@
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
+import java.util.UUID;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.annotations.UpdateTimestamp;
@@ -24,6 +25,14 @@ public class OsImage {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
+ /**
+ * The identifier this row wears outside the API boundary. Internal joins,
+ * sorts and foreign keys keep using {@link #id}.
+ */
+ @JdbcTypeCode(SqlTypes.UUID)
+ @Column(name = "public_id", nullable = false, updatable = false, unique = true)
+ private UUID publicId = UUID.randomUUID();
+
@Column(nullable = false)
private String name;
@@ -96,6 +105,10 @@ public Long getId() {
return id;
}
+ public UUID getPublicId() {
+ return publicId;
+ }
+
public String getName() {
return name;
}
diff --git a/src/main/java/kr/ac/pusan/pickle/inventory/OsImageRepository.java b/src/main/java/kr/ac/pusan/pickle/inventory/OsImageRepository.java
index c17ece7f..31853a7f 100644
--- a/src/main/java/kr/ac/pusan/pickle/inventory/OsImageRepository.java
+++ b/src/main/java/kr/ac/pusan/pickle/inventory/OsImageRepository.java
@@ -1,12 +1,17 @@
package kr.ac.pusan.pickle.inventory;
import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface OsImageRepository extends JpaRepository {
+ /** Resolution of the identifier this row wears outside the API boundary. */
+ Optional findByPublicId(UUID publicId);
+
/**
* Display order of the OS catalog: distribution alphabetically, then release
* ascending, then id.
diff --git a/src/main/java/kr/ac/pusan/pickle/inventory/VmFlavor.java b/src/main/java/kr/ac/pusan/pickle/inventory/VmFlavor.java
index 2e293ab3..353ccaf5 100644
--- a/src/main/java/kr/ac/pusan/pickle/inventory/VmFlavor.java
+++ b/src/main/java/kr/ac/pusan/pickle/inventory/VmFlavor.java
@@ -9,6 +9,7 @@
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
+import java.util.UUID;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.annotations.UpdateTimestamp;
@@ -28,6 +29,14 @@ public class VmFlavor {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
+ /**
+ * The identifier this row wears outside the API boundary. Internal joins,
+ * sorts and foreign keys keep using {@link #id}.
+ */
+ @JdbcTypeCode(SqlTypes.UUID)
+ @Column(name = "public_id", nullable = false, updatable = false, unique = true)
+ private UUID publicId = UUID.randomUUID();
+
@Column(nullable = false)
private String name;
@@ -76,6 +85,10 @@ public Long getId() {
return id;
}
+ public UUID getPublicId() {
+ return publicId;
+ }
+
public String getName() {
return name;
}
diff --git a/src/main/java/kr/ac/pusan/pickle/inventory/VmFlavorRepository.java b/src/main/java/kr/ac/pusan/pickle/inventory/VmFlavorRepository.java
index 5e6995fa..68a360cc 100644
--- a/src/main/java/kr/ac/pusan/pickle/inventory/VmFlavorRepository.java
+++ b/src/main/java/kr/ac/pusan/pickle/inventory/VmFlavorRepository.java
@@ -1,11 +1,16 @@
package kr.ac.pusan.pickle.inventory;
import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.repository.JpaRepository;
public interface VmFlavorRepository extends JpaRepository {
+ /** Resolution of the identifier this row wears outside the API boundary. */
+ Optional findByPublicId(UUID publicId);
+
/**
* Display order of the spec presets: smallest first, on the three numbers
* that make a preset what it is, with the id as the last tie-break.
diff --git a/src/main/java/kr/ac/pusan/pickle/inventory/dto/OsImageResponse.java b/src/main/java/kr/ac/pusan/pickle/inventory/dto/OsImageResponse.java
index c4bf887e..945d65e8 100644
--- a/src/main/java/kr/ac/pusan/pickle/inventory/dto/OsImageResponse.java
+++ b/src/main/java/kr/ac/pusan/pickle/inventory/dto/OsImageResponse.java
@@ -1,12 +1,13 @@
package kr.ac.pusan.pickle.inventory.dto;
import kr.ac.pusan.pickle.inventory.CatalogStatus;
+import java.util.UUID;
import kr.ac.pusan.pickle.inventory.OsImage;
import org.jspecify.annotations.Nullable;
/** Contract schema {@code OsImage} — OS catalog entry (v0.23.0 axis split). */
public record OsImageResponse(
- Long id,
+ UUID id,
String name,
String displayName,
String osFamily,
@@ -18,7 +19,7 @@ public record OsImageResponse(
@Nullable String notes) {
public static OsImageResponse from(OsImage image) {
- return new OsImageResponse(image.getId(), image.getName(), image.getDisplayName(),
+ return new OsImageResponse(image.getPublicId(), image.getName(), image.getDisplayName(),
image.getOsFamily(), image.getOsVersion(), image.getSshUsername(),
image.getVersion(), image.getMinDiskGb(), image.getStatus(),
image.getNotes());
diff --git a/src/main/java/kr/ac/pusan/pickle/inventory/dto/VmFlavorResponse.java b/src/main/java/kr/ac/pusan/pickle/inventory/dto/VmFlavorResponse.java
index 5c82bd29..50d7b9e9 100644
--- a/src/main/java/kr/ac/pusan/pickle/inventory/dto/VmFlavorResponse.java
+++ b/src/main/java/kr/ac/pusan/pickle/inventory/dto/VmFlavorResponse.java
@@ -1,12 +1,13 @@
package kr.ac.pusan.pickle.inventory.dto;
import kr.ac.pusan.pickle.inventory.CatalogStatus;
+import java.util.UUID;
import kr.ac.pusan.pickle.inventory.VmFlavor;
import org.jspecify.annotations.Nullable;
/** Contract schema {@code VmFlavor} — spec preset (v0.23.0 axis split). */
public record VmFlavorResponse(
- Long id,
+ UUID id,
String name,
String displayName,
int vcpu,
@@ -16,7 +17,7 @@ public record VmFlavorResponse(
@Nullable String notes) {
public static VmFlavorResponse from(VmFlavor flavor) {
- return new VmFlavorResponse(flavor.getId(), flavor.getName(), flavor.getDisplayName(),
+ return new VmFlavorResponse(flavor.getPublicId(), flavor.getName(), flavor.getDisplayName(),
flavor.getVcpu(), flavor.getMemoryMb(), flavor.getDiskGb(), flavor.getStatus(),
flavor.getNotes());
}
diff --git a/src/main/java/kr/ac/pusan/pickle/ipam/IpAllocation.java b/src/main/java/kr/ac/pusan/pickle/ipam/IpAllocation.java
index dcf04d13..3a03556d 100644
--- a/src/main/java/kr/ac/pusan/pickle/ipam/IpAllocation.java
+++ b/src/main/java/kr/ac/pusan/pickle/ipam/IpAllocation.java
@@ -9,6 +9,7 @@
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
+import java.util.UUID;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
@@ -25,6 +26,14 @@ public class IpAllocation {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
+ /**
+ * The identifier this row wears outside the API boundary. Internal joins,
+ * sorts and foreign keys keep using {@link #id}.
+ */
+ @JdbcTypeCode(SqlTypes.UUID)
+ @Column(name = "public_id", nullable = false, updatable = false, unique = true)
+ private UUID publicId = UUID.randomUUID();
+
@Column(name = "pool_id", nullable = false)
private Long poolId;
@@ -54,6 +63,10 @@ public Long getId() {
return id;
}
+ public UUID getPublicId() {
+ return publicId;
+ }
+
public Long getPoolId() {
return poolId;
}
diff --git a/src/main/java/kr/ac/pusan/pickle/ipam/IpAllocationRepository.java b/src/main/java/kr/ac/pusan/pickle/ipam/IpAllocationRepository.java
index eb07eb15..419503f2 100644
--- a/src/main/java/kr/ac/pusan/pickle/ipam/IpAllocationRepository.java
+++ b/src/main/java/kr/ac/pusan/pickle/ipam/IpAllocationRepository.java
@@ -1,12 +1,16 @@
package kr.ac.pusan.pickle.ipam;
import java.util.Optional;
+import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
public interface IpAllocationRepository
extends JpaRepository, JpaSpecificationExecutor {
+ /** Resolution of the identifier this row wears outside the API boundary. */
+ Optional findByPublicId(UUID publicId);
+
/**
* The VM's live allocation. Crash guard for the pipeline's alloc step:
* {@code IpamService.allocate} may have committed before the crash while
diff --git a/src/main/java/kr/ac/pusan/pickle/ipam/IpPool.java b/src/main/java/kr/ac/pusan/pickle/ipam/IpPool.java
index 1c3cc3a3..b5d65f39 100644
--- a/src/main/java/kr/ac/pusan/pickle/ipam/IpPool.java
+++ b/src/main/java/kr/ac/pusan/pickle/ipam/IpPool.java
@@ -7,6 +7,7 @@
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
+import java.util.UUID;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.annotations.UpdateTimestamp;
@@ -26,6 +27,14 @@ public class IpPool {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
+ /**
+ * The identifier this row wears outside the API boundary. Internal joins,
+ * sorts and foreign keys keep using {@link #id}.
+ */
+ @JdbcTypeCode(SqlTypes.UUID)
+ @Column(name = "public_id", nullable = false, updatable = false, unique = true)
+ private UUID publicId = UUID.randomUUID();
+
@Column(nullable = false, unique = true)
private String name;
@@ -62,6 +71,10 @@ public Long getId() {
return id;
}
+ public UUID getPublicId() {
+ return publicId;
+ }
+
public String getName() {
return name;
}
diff --git a/src/main/java/kr/ac/pusan/pickle/ipam/IpPoolRepository.java b/src/main/java/kr/ac/pusan/pickle/ipam/IpPoolRepository.java
index 34733b9a..a4f8fd87 100644
--- a/src/main/java/kr/ac/pusan/pickle/ipam/IpPoolRepository.java
+++ b/src/main/java/kr/ac/pusan/pickle/ipam/IpPoolRepository.java
@@ -1,9 +1,13 @@
package kr.ac.pusan.pickle.ipam;
import java.util.Optional;
+import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
public interface IpPoolRepository extends JpaRepository {
+ /** Resolution of the identifier this row wears outside the API boundary. */
+ Optional findByPublicId(UUID publicId);
+
Optional findByName(String name);
}
diff --git a/src/main/java/kr/ac/pusan/pickle/mfa/MfaService.java b/src/main/java/kr/ac/pusan/pickle/mfa/MfaService.java
index 09662a8f..c1b95466 100644
--- a/src/main/java/kr/ac/pusan/pickle/mfa/MfaService.java
+++ b/src/main/java/kr/ac/pusan/pickle/mfa/MfaService.java
@@ -137,7 +137,7 @@ public MfaRecoveryCodesResponse activate(long userId, String code, String ip) {
List codes = replaceRecoveryCodes(userId);
auditService.record(user.getId(), user.getRole().name(), AuditService.ACCOUNT_MFA_ENROLL,
- "user", user.getId(), Map.of(), ip);
+ "user", user.getPublicId(), Map.of(), ip);
notificationService.publish(user.getId(), NotificationEvent.ACCOUNT_MFA_ENROLLED, Map.of(),
"account_mfa_enrolled:" + user.getId() + ":" + mfa.getEnabledAt());
return new MfaRecoveryCodesResponse(codes);
@@ -166,7 +166,7 @@ public void disable(long userId, String password, String code, String recoveryCo
recoveryCodeRepository.deleteByUserId(userId);
auditService.record(user.getId(), user.getRole().name(), AuditService.ACCOUNT_MFA_DISABLE,
- "user", user.getId(), Map.of(), ip);
+ "user", user.getPublicId(), Map.of(), ip);
notificationService.publish(user.getId(), NotificationEvent.ACCOUNT_MFA_DISABLED, Map.of(),
"account_mfa_disabled:" + user.getId() + ":" + Instant.now().toEpochMilli());
}
@@ -310,7 +310,7 @@ public void adminReset(long actorId, String actorRole, User target, String ip) {
recoveryCodeRepository.deleteByUserId(target.getId());
auditService.record(actorId, actorRole, AuditService.ACCOUNT_MFA_RESET,
- "user", target.getId(), Map.of("targetEmail", target.getEmail()), ip);
+ "user", target.getPublicId(), Map.of("targetEmail", target.getEmail()), ip);
notificationService.publish(target.getId(), NotificationEvent.ACCOUNT_MFA_RESET, Map.of(),
"account_mfa_reset:" + target.getId() + ":" + Instant.now().toEpochMilli());
}
diff --git a/src/main/java/kr/ac/pusan/pickle/notification/Notification.java b/src/main/java/kr/ac/pusan/pickle/notification/Notification.java
index 0405a012..475de717 100644
--- a/src/main/java/kr/ac/pusan/pickle/notification/Notification.java
+++ b/src/main/java/kr/ac/pusan/pickle/notification/Notification.java
@@ -9,6 +9,7 @@
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
+import java.util.UUID;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
@@ -27,6 +28,14 @@ public class Notification {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
+ /**
+ * The identifier this row wears outside the API boundary. Internal joins,
+ * sorts and foreign keys keep using {@link #id}.
+ */
+ @JdbcTypeCode(SqlTypes.UUID)
+ @Column(name = "public_id", nullable = false, updatable = false, unique = true)
+ private UUID publicId = UUID.randomUUID();
+
@Column(name = "user_id", nullable = false)
private Long userId;
@@ -92,6 +101,10 @@ public Long getId() {
return id;
}
+ public UUID getPublicId() {
+ return publicId;
+ }
+
public Long getUserId() {
return userId;
}
diff --git a/src/main/java/kr/ac/pusan/pickle/notification/NotificationController.java b/src/main/java/kr/ac/pusan/pickle/notification/NotificationController.java
index eb17f179..9a4ff951 100644
--- a/src/main/java/kr/ac/pusan/pickle/notification/NotificationController.java
+++ b/src/main/java/kr/ac/pusan/pickle/notification/NotificationController.java
@@ -2,6 +2,7 @@
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
+import java.util.UUID;
import kr.ac.pusan.pickle.common.web.PageResponse;
import kr.ac.pusan.pickle.notification.dto.NotificationView;
import kr.ac.pusan.pickle.notification.dto.ReadAllResponse;
@@ -51,7 +52,7 @@ public UnreadCountResponse getUnreadNotificationCount(
@PostMapping("/{notificationId}/read")
public NotificationView markNotificationRead(
@AuthenticationPrincipal AuthenticatedUser principal,
- @PathVariable long notificationId) {
+ @PathVariable UUID notificationId) {
return notificationService.markRead(principal.id(), notificationId);
}
diff --git a/src/main/java/kr/ac/pusan/pickle/notification/NotificationRepository.java b/src/main/java/kr/ac/pusan/pickle/notification/NotificationRepository.java
index 4c34b80d..d606c5f0 100644
--- a/src/main/java/kr/ac/pusan/pickle/notification/NotificationRepository.java
+++ b/src/main/java/kr/ac/pusan/pickle/notification/NotificationRepository.java
@@ -1,12 +1,16 @@
package kr.ac.pusan.pickle.notification;
import java.util.Optional;
+import java.util.UUID;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
public interface NotificationRepository extends JpaRepository {
+ /** Resolution of the identifier this row wears outside the API boundary. */
+ Optional findByPublicId(UUID publicId);
+
Page findByUserIdOrderByCreatedAtDescIdDesc(Long userId, Pageable pageable);
Page findByUserIdAndReadAtIsNullOrderByCreatedAtDescIdDesc(Long userId,
diff --git a/src/main/java/kr/ac/pusan/pickle/notification/NotificationService.java b/src/main/java/kr/ac/pusan/pickle/notification/NotificationService.java
index a4be1b40..9b3e2b35 100644
--- a/src/main/java/kr/ac/pusan/pickle/notification/NotificationService.java
+++ b/src/main/java/kr/ac/pusan/pickle/notification/NotificationService.java
@@ -5,6 +5,7 @@
import java.util.List;
import java.util.Map;
import java.util.Set;
+import java.util.UUID;
import kr.ac.pusan.pickle.access.AccessGranteeType;
import kr.ac.pusan.pickle.access.ResourceAccessGrant;
import kr.ac.pusan.pickle.access.ResourceAccessGrantRepository;
@@ -205,9 +206,10 @@ public List sysAdminIds() {
* Other users' rows answer 404 (existence masked).
*/
@Transactional
- public NotificationView markRead(long actorUserId, long notificationId) {
+ public NotificationView markRead(long actorUserId, UUID notificationId) {
Notification notification = notificationRepository
- .findByIdAndUserId(notificationId, actorUserId)
+ .findByPublicId(notificationId)
+ .filter(row -> row.getUserId() == actorUserId)
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND,
ErrorCodes.RESOURCE_NOT_FOUND,
"리소스를 찾을 수 없습니다", "해당 알림이 존재하지 않습니다."));
diff --git a/src/main/java/kr/ac/pusan/pickle/notification/dto/NotificationView.java b/src/main/java/kr/ac/pusan/pickle/notification/dto/NotificationView.java
index 7d5b0fd6..9c4df74a 100644
--- a/src/main/java/kr/ac/pusan/pickle/notification/dto/NotificationView.java
+++ b/src/main/java/kr/ac/pusan/pickle/notification/dto/NotificationView.java
@@ -1,13 +1,14 @@
package kr.ac.pusan.pickle.notification.dto;
import java.time.Instant;
+import java.util.UUID;
import kr.ac.pusan.pickle.notification.Notification;
import kr.ac.pusan.pickle.notification.NotificationImportance;
import org.jspecify.annotations.Nullable;
/** Contract {@code NotificationView}: one in-app notification (own rows only). */
public record NotificationView(
- long id,
+ UUID id,
String event,
String title,
String body,
@@ -17,7 +18,7 @@ public record NotificationView(
@Nullable Instant readAt) {
public static NotificationView from(Notification notification) {
- return new NotificationView(notification.getId(), notification.getEvent(),
+ return new NotificationView(notification.getPublicId(), notification.getEvent(),
notification.getTitle(), notification.getBody(), notification.getLinkPath(),
notification.getImportance(), notification.getCreatedAt(),
notification.getReadAt());
diff --git a/src/main/java/kr/ac/pusan/pickle/orgs/Org.java b/src/main/java/kr/ac/pusan/pickle/orgs/Org.java
index 07b0bbf4..1f3c5cf4 100644
--- a/src/main/java/kr/ac/pusan/pickle/orgs/Org.java
+++ b/src/main/java/kr/ac/pusan/pickle/orgs/Org.java
@@ -9,6 +9,7 @@
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
+import java.util.UUID;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.annotations.UpdateTimestamp;
@@ -22,12 +23,17 @@ public class Org {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
+ /**
+ * The identifier this row wears outside the API boundary. Internal joins,
+ * sorts and foreign keys keep using {@link #id}.
+ */
+ @JdbcTypeCode(SqlTypes.UUID)
+ @Column(name = "public_id", nullable = false, updatable = false, unique = true)
+ private UUID publicId = UUID.randomUUID();
+
@Column(nullable = false)
private String name;
- @Column(nullable = false, unique = true)
- private String slug;
-
private String description;
@Enumerated(EnumType.STRING)
@@ -54,9 +60,8 @@ public class Org {
protected Org() {
}
- public Org(String name, String slug, String description) {
+ public Org(String name, String description) {
this.name = name;
- this.slug = slug;
this.description = description;
}
@@ -64,6 +69,10 @@ public Long getId() {
return id;
}
+ public UUID getPublicId() {
+ return publicId;
+ }
+
public String getName() {
return name;
}
@@ -72,10 +81,6 @@ public void setName(String name) {
this.name = name;
}
- public String getSlug() {
- return slug;
- }
-
public String getDescription() {
return description;
}
diff --git a/src/main/java/kr/ac/pusan/pickle/orgs/OrgRepository.java b/src/main/java/kr/ac/pusan/pickle/orgs/OrgRepository.java
index 370dc465..3093b171 100644
--- a/src/main/java/kr/ac/pusan/pickle/orgs/OrgRepository.java
+++ b/src/main/java/kr/ac/pusan/pickle/orgs/OrgRepository.java
@@ -2,15 +2,21 @@
import java.util.List;
import java.util.Optional;
+import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
public interface OrgRepository extends JpaRepository {
- Optional findBySlug(String slug);
+ /** Resolution of the identifier this row wears outside the API boundary. */
+ Optional findByPublicId(UUID publicId);
+
+ /**
+ * Lookup by display name, for the dev seeder's find-or-create. Names carry
+ * no uniqueness constraint, so the oldest match wins.
+ */
+ Optional findFirstByNameOrderByIdAsc(String name);
List findByStatusOrderByIdAsc(OrgStatus status);
List findByStatusAndHiddenFalseOrderByIdAsc(OrgStatus status);
-
- boolean existsBySlug(String slug);
}
diff --git a/src/main/java/kr/ac/pusan/pickle/orgs/dto/OrgSummaryResponse.java b/src/main/java/kr/ac/pusan/pickle/orgs/dto/OrgSummaryResponse.java
index 951345b0..7aced9e2 100644
--- a/src/main/java/kr/ac/pusan/pickle/orgs/dto/OrgSummaryResponse.java
+++ b/src/main/java/kr/ac/pusan/pickle/orgs/dto/OrgSummaryResponse.java
@@ -1,15 +1,16 @@
package kr.ac.pusan.pickle.orgs.dto;
import kr.ac.pusan.pickle.orgs.Org;
+import java.util.UUID;
import kr.ac.pusan.pickle.orgs.OrgStatus;
import org.jspecify.annotations.Nullable;
/** Contract schema {@code OrgSummaryResponse} (v0.15.0 adds {@code hidden}). */
-public record OrgSummaryResponse(Long id, String name, String slug, @Nullable String description,
+public record OrgSummaryResponse(UUID id, String name, @Nullable String description,
OrgStatus status, boolean hidden) {
public static OrgSummaryResponse from(Org org) {
- return new OrgSummaryResponse(org.getId(), org.getName(), org.getSlug(),
+ return new OrgSummaryResponse(org.getPublicId(), org.getName(),
org.getDescription(), org.getStatus(), org.isHidden());
}
}
diff --git a/src/main/java/kr/ac/pusan/pickle/provisioning/DriftFinding.java b/src/main/java/kr/ac/pusan/pickle/provisioning/DriftFinding.java
index 19258ae4..dc98ae9a 100644
--- a/src/main/java/kr/ac/pusan/pickle/provisioning/DriftFinding.java
+++ b/src/main/java/kr/ac/pusan/pickle/provisioning/DriftFinding.java
@@ -9,6 +9,7 @@
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
+import java.util.UUID;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
@@ -27,6 +28,14 @@ public class DriftFinding {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
+ /**
+ * The identifier this row wears outside the API boundary. Internal joins,
+ * sorts and foreign keys keep using {@link #id}.
+ */
+ @JdbcTypeCode(SqlTypes.UUID)
+ @Column(name = "public_id", nullable = false, updatable = false, unique = true)
+ private UUID publicId = UUID.randomUUID();
+
@Enumerated(EnumType.STRING)
@JdbcTypeCode(SqlTypes.NAMED_ENUM)
@Column(nullable = false, columnDefinition = "drift_finding_kind")
@@ -82,6 +91,10 @@ public Long getId() {
return id;
}
+ public UUID getPublicId() {
+ return publicId;
+ }
+
public DriftFindingKind getKind() {
return kind;
}
diff --git a/src/main/java/kr/ac/pusan/pickle/provisioning/DriftFindingRepository.java b/src/main/java/kr/ac/pusan/pickle/provisioning/DriftFindingRepository.java
index a074dc46..ea7a4a1c 100644
--- a/src/main/java/kr/ac/pusan/pickle/provisioning/DriftFindingRepository.java
+++ b/src/main/java/kr/ac/pusan/pickle/provisioning/DriftFindingRepository.java
@@ -2,6 +2,8 @@
import java.time.Instant;
import java.util.Collection;
+import java.util.Optional;
+import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Modifying;
@@ -18,6 +20,9 @@
public interface DriftFindingRepository
extends JpaRepository, JpaSpecificationExecutor {
+ /** Resolution of the identifier this row wears outside the API boundary. */
+ Optional findByPublicId(UUID publicId);
+
/**
* Records one observation of a drift condition: inserts an OPEN finding or
* bumps {@code last_seen_at}/{@code summary}/{@code detail} of the existing
diff --git a/src/main/java/kr/ac/pusan/pickle/provisioning/ProvisioningTask.java b/src/main/java/kr/ac/pusan/pickle/provisioning/ProvisioningTask.java
index be6ba537..71bf8964 100644
--- a/src/main/java/kr/ac/pusan/pickle/provisioning/ProvisioningTask.java
+++ b/src/main/java/kr/ac/pusan/pickle/provisioning/ProvisioningTask.java
@@ -9,6 +9,7 @@
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
+import java.util.UUID;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.annotations.UpdateTimestamp;
@@ -29,6 +30,14 @@ public class ProvisioningTask {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
+ /**
+ * The identifier this row wears outside the API boundary. Internal joins,
+ * sorts and foreign keys keep using {@link #id}.
+ */
+ @JdbcTypeCode(SqlTypes.UUID)
+ @Column(name = "public_id", nullable = false, updatable = false, unique = true)
+ private UUID publicId = UUID.randomUUID();
+
@Column(name = "vm_id", nullable = false)
private Long vmId;
@@ -75,6 +84,10 @@ public Long getId() {
return id;
}
+ public UUID getPublicId() {
+ return publicId;
+ }
+
public Long getVmId() {
return vmId;
}
diff --git a/src/main/java/kr/ac/pusan/pickle/provisioning/ProvisioningTaskRepository.java b/src/main/java/kr/ac/pusan/pickle/provisioning/ProvisioningTaskRepository.java
index 7767e60f..1607a24c 100644
--- a/src/main/java/kr/ac/pusan/pickle/provisioning/ProvisioningTaskRepository.java
+++ b/src/main/java/kr/ac/pusan/pickle/provisioning/ProvisioningTaskRepository.java
@@ -5,6 +5,7 @@
import java.util.List;
import java.util.Optional;
import java.util.Set;
+import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Modifying;
@@ -21,6 +22,9 @@
public interface ProvisioningTaskRepository
extends JpaRepository, JpaSpecificationExecutor {
+ /** Resolution of the identifier this row wears outside the API boundary. */
+ Optional findByPublicId(UUID publicId);
+
List findByVmIdOrderByIdDesc(Long vmId);
/** VM ids that currently have a task in the given statuses (poller/reconciler race guard). */
diff --git a/src/main/java/kr/ac/pusan/pickle/publishing/AdminPublishingController.java b/src/main/java/kr/ac/pusan/pickle/publishing/AdminPublishingController.java
index d71149ef..7d8d4905 100644
--- a/src/main/java/kr/ac/pusan/pickle/publishing/AdminPublishingController.java
+++ b/src/main/java/kr/ac/pusan/pickle/publishing/AdminPublishingController.java
@@ -5,6 +5,7 @@
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
+import java.util.UUID;
import kr.ac.pusan.pickle.auth.dto.MessageResponse;
import kr.ac.pusan.pickle.common.web.PageResponse;
import kr.ac.pusan.pickle.publishing.dto.AdminCertificateView;
@@ -45,7 +46,7 @@ public AdminPublishingController(AdminPublishingService adminPublishingService)
@GetMapping("/routes")
public PageResponse listAdminRoutes(
@AuthenticationPrincipal AuthenticatedUser principal,
- @RequestParam(required = false) Long orgId,
+ @RequestParam(required = false) UUID orgId,
@RequestParam(required = false) RouteStatus status,
@RequestParam(defaultValue = "0") @Min(0) int page,
@RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) {
@@ -55,7 +56,7 @@ public PageResponse listAdminRoutes(
@GetMapping("/domains")
public PageResponse listAdminDomains(
@AuthenticationPrincipal AuthenticatedUser principal,
- @RequestParam(required = false) Long orgId,
+ @RequestParam(required = false) UUID orgId,
@RequestParam(required = false) DomainKind kind,
@RequestParam(required = false) DomainStatus status,
@RequestParam(defaultValue = "0") @Min(0) int page,
@@ -66,7 +67,7 @@ public PageResponse listAdminDomains(
@GetMapping("/certificates")
public PageResponse listAdminCertificates(
@AuthenticationPrincipal AuthenticatedUser principal,
- @RequestParam(required = false) Long orgId,
+ @RequestParam(required = false) UUID orgId,
@RequestParam(required = false) CertificateStatus status,
@RequestParam(required = false) @Min(1) Integer expiringInDays,
@RequestParam(defaultValue = "0") @Min(0) int page,
@@ -87,7 +88,7 @@ public MessageResponse resyncRoutes(
@PostMapping("/domains/{domainId}/force-release")
public MessageResponse forceReleaseDomain(
@AuthenticationPrincipal AuthenticatedUser principal,
- @PathVariable long domainId, HttpServletRequest httpRequest) {
+ @PathVariable UUID domainId, HttpServletRequest httpRequest) {
return adminPublishingService.forceRelease(principal, domainId, clientIp(httpRequest));
}
@@ -96,7 +97,7 @@ public MessageResponse forceReleaseDomain(
@ResponseStatus(HttpStatus.ACCEPTED)
public MessageResponse verifyAdminDomain(
@AuthenticationPrincipal AuthenticatedUser principal,
- @PathVariable long domainId, HttpServletRequest httpRequest) {
+ @PathVariable UUID domainId, HttpServletRequest httpRequest) {
return adminPublishingService.verify(principal, domainId, clientIp(httpRequest));
}
@@ -105,7 +106,7 @@ public MessageResponse verifyAdminDomain(
@ResponseStatus(HttpStatus.ACCEPTED)
public MessageResponse applyAdminRoute(
@AuthenticationPrincipal AuthenticatedUser principal,
- @PathVariable long routeId, HttpServletRequest httpRequest) {
+ @PathVariable UUID routeId, HttpServletRequest httpRequest) {
return adminPublishingService.applyRoute(principal, routeId, clientIp(httpRequest));
}
}
diff --git a/src/main/java/kr/ac/pusan/pickle/publishing/AdminPublishingService.java b/src/main/java/kr/ac/pusan/pickle/publishing/AdminPublishingService.java
index 3bae1e41..343031f5 100644
--- a/src/main/java/kr/ac/pusan/pickle/publishing/AdminPublishingService.java
+++ b/src/main/java/kr/ac/pusan/pickle/publishing/AdminPublishingService.java
@@ -5,6 +5,7 @@
import java.util.List;
import java.util.Map;
import java.util.Set;
+import java.util.UUID;
import java.util.function.Function;
import java.util.stream.Collectors;
import kr.ac.pusan.pickle.audit.AuditService;
@@ -90,7 +91,7 @@ public AdminPublishingService(RouteRepository routeRepository, DomainRepository
}
@Transactional(readOnly = true)
- public PageResponse listRoutes(AuthenticatedUser actor, Long orgId,
+ public PageResponse listRoutes(AuthenticatedUser actor, UUID orgId,
RouteStatus status, int page, int size) {
Long scopedOrgId = scopedOrgId(actor, orgId);
Page routes = routeRepository.findAdmin(scopedOrgId, name(status), page(page, size));
@@ -100,12 +101,13 @@ public PageResponse listRoutes(AuthenticatedUser actor, Long org
List content = routes.getContent().stream().map(route -> {
Domain domain = ctx.domains.get(route.getDomainId());
Vm vm = domain != null ? ctx.vms.get(domain.getVmId()) : null;
- return new AdminRouteView(route.getId(), route.getDomainId(),
+ return new AdminRouteView(route.getPublicId(),
+ domain != null ? domain.getPublicId() : null,
domain != null ? domain.getFqdn() : null,
domain != null ? domain.getKind() : null,
- vm != null ? vm.getId() : null, name(vm),
- vm != null ? vm.getWorkspaceId() : null, ctx.workspaceName(vm),
- vm != null ? vm.getOrgId() : null, ctx.orgName(vm),
+ vm != null ? vm.getPublicId() : null, name(vm),
+ ctx.workspaceId(vm), ctx.workspaceName(vm),
+ ctx.orgId(vm), ctx.orgName(vm),
route.getTargetPort(), route.getProtocol(), route.getStatus(),
route.getAppliedGeneration(), route.getAppliedAt(), route.getLastError(),
route.getUpdatedAt());
@@ -121,7 +123,7 @@ public PageResponse listRoutes(AuthenticatedUser actor, Long org
* identically, and "why is this subdomain taken" has no answer here.
*/
@Transactional(readOnly = true)
- public PageResponse listDomains(AuthenticatedUser actor, Long orgId,
+ public PageResponse listDomains(AuthenticatedUser actor, UUID orgId,
DomainKind kind, DomainStatus status, int page, int size) {
Long scopedOrgId = scopedOrgId(actor, orgId);
Page domains = domainRepository.findAdmin(scopedOrgId, name(kind), name(status),
@@ -133,19 +135,20 @@ public PageResponse listDomains(AuthenticatedUser actor, Long o
.findFirstByDomainIdAndStatusNot(domain.getId(), RouteStatus.REMOVED)
.map(Route::getStatus).orElse(null);
var certStatus = assembler.certificateFor(domain).map(Certificate::getStatus).orElse(null);
- return new AdminDomainView(domain.getId(), domain.getVmId(), domain.getKind(),
+ return new AdminDomainView(domain.getPublicId(),
+ vm != null ? vm.getPublicId() : null, domain.getKind(),
domain.getFqdn(), domain.getRootDomain(), domain.getStatus(),
domain.getVerifiedAt(), domain.getReleasedAt(),
assembler.reservedUntil(domain), domain.getCreatedAt(), name(vm),
- vm != null ? vm.getWorkspaceId() : null, ctx.workspaceName(vm),
- vm != null ? vm.getOrgId() : null, ctx.orgName(vm),
+ ctx.workspaceId(vm), ctx.workspaceName(vm),
+ ctx.orgId(vm), ctx.orgName(vm),
routeStatus, certStatus, domain.getUpdatedAt());
}).toList();
return PageResponse.of(content, domains);
}
@Transactional(readOnly = true)
- public PageResponse listCertificates(AuthenticatedUser actor, Long orgId,
+ public PageResponse listCertificates(AuthenticatedUser actor, UUID orgId,
CertificateStatus status, Integer expiringInDays, int page, int size) {
Long scopedOrgId = scopedOrgId(actor, orgId);
Instant now = Instant.now();
@@ -153,9 +156,15 @@ public PageResponse listCertificates(AuthenticatedUser act
? certificateRepository.findAdminExpiring(scopedOrgId, name(status),
now.plus(expiringInDays, ChronoUnit.DAYS), page(page, size))
: certificateRepository.findAdmin(scopedOrgId, name(status), page(page, size));
+ // The certificate names its domain by public id, and unlike its sibling
+ // listings this one had no domain load at all — hence the batch.
+ Map domainIds = domainRepository.findAllById(certs.getContent().stream()
+ .map(Certificate::getDomainId).filter(java.util.Objects::nonNull)
+ .distinct().toList()).stream()
+ .collect(Collectors.toMap(Domain::getId, Domain::getPublicId));
List content = certs.getContent().stream()
- .map(cert -> new AdminCertificateView(cert.getId(), cert.getKind(), cert.getStatus(),
- cert.getScope(), cert.getDomainId(), cert.getNotAfter(),
+ .map(cert -> new AdminCertificateView(cert.getPublicId(), cert.getKind(), cert.getStatus(),
+ cert.getScope(), domainIds.get(cert.getDomainId()), cert.getNotAfter(),
// a FAILED cert has no meaningful expiry countdown
cert.getStatus() == CertificateStatus.FAILED ? null
: daysUntilExpiry(now, cert.getNotAfter()),
@@ -190,7 +199,7 @@ public void afterCommit() {
* gets none — same as a user's immediate return).
*/
@Transactional
- public MessageResponse forceRelease(AuthenticatedUser actor, long domainId, String ip) {
+ public MessageResponse forceRelease(AuthenticatedUser actor, UUID domainId, String ip) {
Domain domain = requireScopedDomain(actor, domainId);
boolean served = assembler.hasLiveRoute(domain);
publishingService.forceTeardown(domain);
@@ -203,12 +212,12 @@ public MessageResponse forceRelease(AuthenticatedUser actor, long domainId, Stri
notificationService.publish(
notificationService.vmResponsibleIds(vm),
NotificationEvent.DOMAIN_ADMIN_RELEASED,
- Map.of("vmId", vm.getId(), "vmName", vm.getName(),
+ Map.of("vmId", vm.getPublicId(), "vmName", vm.getName(),
"fqdn", domain.getFqdn()),
null);
}
auditService.recordAfterCommit(actor.id(), actor.role().name(),
- AuditService.DOMAIN_FORCE_RELEASE, "domain", domainId,
+ AuditService.DOMAIN_FORCE_RELEASE, "domain", domain.getPublicId(),
Map.of("fqdn", domain.getFqdn()), ip);
return new MessageResponse("도메인을 강제 해제했습니다. 라우트 제거가 곧 적용되며, 이름은 즉시 회수됩니다.");
}
@@ -219,15 +228,16 @@ public MessageResponse forceRelease(AuthenticatedUser actor, long domainId, Stri
* in {@link DomainVerificationJob} still bounds the load.
*/
@Transactional
- public MessageResponse verify(AuthenticatedUser actor, long domainId, String ip) {
- Domain domain = requireScopedDomain(actor, domainId);
+ public MessageResponse verify(AuthenticatedUser actor, UUID publicDomainId, String ip) {
+ Domain domain = requireScopedDomain(actor, publicDomainId);
+ long domainId = domain.getId();
if (domain.getKind() != DomainKind.CUSTOM) {
throw new ApiException(HttpStatus.CONFLICT, ErrorCodes.DOMAIN_NOT_CUSTOM,
"검증할 수 없는 도메인입니다", "플랫폼 서브도메인은 소유권 검증이 필요하지 않습니다.");
}
runAfterCommit(() -> domainVerificationJob.requestVerify(domainId));
auditService.recordAfterCommit(actor.id(), actor.role().name(),
- AuditService.DOMAIN_ADMIN_VERIFY, "domain", domainId,
+ AuditService.DOMAIN_ADMIN_VERIFY, "domain", domain.getPublicId(),
Map.of("fqdn", domain.getFqdn()), ip);
return new MessageResponse("소유권 재검증을 접수했습니다. 잠시 후 상태가 갱신됩니다.");
}
@@ -243,8 +253,8 @@ public MessageResponse verify(AuthenticatedUser actor, long domainId, String ip)
* answers 409.
*/
@Transactional
- public MessageResponse applyRoute(AuthenticatedUser actor, long routeId, String ip) {
- Route route = routeRepository.findById(routeId)
+ public MessageResponse applyRoute(AuthenticatedUser actor, UUID routeId, String ip) {
+ Route route = routeRepository.findByPublicId(routeId)
.orElseThrow(AdminPublishingService::routeNotFound);
Domain domain = domainRepository.findById(route.getDomainId())
.orElseThrow(AdminPublishingService::routeNotFound);
@@ -269,7 +279,7 @@ public void afterCommit() {
}
});
auditService.recordAfterCommit(actor.id(), actor.role().name(), AuditService.ROUTE_APPLY,
- "route", routeId, Map.of("fqdn", domain.getFqdn()), ip);
+ "route", route.getPublicId(), Map.of("fqdn", domain.getFqdn()), ip);
return new MessageResponse("라우트 재적용을 접수했습니다. 잠시 후 적용 상태가 갱신됩니다.");
}
@@ -278,8 +288,8 @@ public void afterCommit() {
* domain, and an org-tier actor naming another org's domain all answer the
* same 404.
*/
- private Domain requireScopedDomain(AuthenticatedUser actor, long domainId) {
- Domain domain = domainRepository.findById(domainId)
+ private Domain requireScopedDomain(AuthenticatedUser actor, UUID domainId) {
+ Domain domain = domainRepository.findByPublicId(domainId)
.orElseThrow(AdminPublishingService::domainNotFound);
if (domain.getStatus() == DomainStatus.REMOVED) {
throw domainNotFound();
@@ -319,7 +329,7 @@ private static ApiException routeNotFound() {
// ── helpers ──────────────────────────────────────────────────────────────
- private Long scopedOrgId(AuthenticatedUser actor, Long orgId) {
+ private Long scopedOrgId(AuthenticatedUser actor, UUID orgId) {
if (actor.role().isOrgTier()) {
if (actor.orgId() == null) {
throw new ApiException(HttpStatus.FORBIDDEN, ErrorCodes.ACCESS_DENIED,
@@ -327,7 +337,9 @@ private Long scopedOrgId(AuthenticatedUser actor, Long orgId) {
}
return actor.orgId();
}
- return orgId;
+ // An id no org has filters to nothing, as a non-matching number did.
+ return orgId == null ? null
+ : orgRepository.findByPublicId(orgId).map(Org::getId).orElse(-1L);
}
private static Pageable page(int page, int size) {
@@ -381,5 +393,15 @@ String orgName(Vm vm) {
Org org = orgs.get(vm.getOrgId());
return org != null ? org.getName() : null;
}
+
+ UUID workspaceId(Vm vm) {
+ Workspace workspace = vm == null ? null : workspaces.get(vm.getWorkspaceId());
+ return workspace != null ? workspace.getPublicId() : null;
+ }
+
+ UUID orgId(Vm vm) {
+ Org org = vm == null ? null : orgs.get(vm.getOrgId());
+ return org != null ? org.getPublicId() : null;
+ }
}
}
diff --git a/src/main/java/kr/ac/pusan/pickle/publishing/Certificate.java b/src/main/java/kr/ac/pusan/pickle/publishing/Certificate.java
index 2a684d7d..bddaec47 100644
--- a/src/main/java/kr/ac/pusan/pickle/publishing/Certificate.java
+++ b/src/main/java/kr/ac/pusan/pickle/publishing/Certificate.java
@@ -9,6 +9,7 @@
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
+import java.util.UUID;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.annotations.UpdateTimestamp;
@@ -28,6 +29,14 @@ public class Certificate {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
+ /**
+ * The identifier this row wears outside the API boundary. Internal joins,
+ * sorts and foreign keys keep using {@link #id}.
+ */
+ @JdbcTypeCode(SqlTypes.UUID)
+ @Column(name = "public_id", nullable = false, updatable = false, unique = true)
+ private UUID publicId = UUID.randomUUID();
+
@Column(name = "domain_id")
private Long domainId;
@@ -75,6 +84,10 @@ public Long getId() {
return id;
}
+ public UUID getPublicId() {
+ return publicId;
+ }
+
public Long getDomainId() {
return domainId;
}
diff --git a/src/main/java/kr/ac/pusan/pickle/publishing/CertificateRepository.java b/src/main/java/kr/ac/pusan/pickle/publishing/CertificateRepository.java
index 48c71a13..b113131e 100644
--- a/src/main/java/kr/ac/pusan/pickle/publishing/CertificateRepository.java
+++ b/src/main/java/kr/ac/pusan/pickle/publishing/CertificateRepository.java
@@ -3,6 +3,7 @@
import java.time.Instant;
import java.util.List;
import java.util.Optional;
+import java.util.UUID;
import org.springframework.data.domain.Limit;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
@@ -12,6 +13,9 @@
public interface CertificateRepository extends JpaRepository {
+ /** Resolution of the identifier this row wears outside the API boundary. */
+ Optional findByPublicId(UUID publicId);
+
/** The current cert for a custom domain (excludes revoked/archived). */
Optional findFirstByDomainIdAndStatusNot(Long domainId, CertificateStatus status);
diff --git a/src/main/java/kr/ac/pusan/pickle/publishing/Domain.java b/src/main/java/kr/ac/pusan/pickle/publishing/Domain.java
index a9f9f039..06867a55 100644
--- a/src/main/java/kr/ac/pusan/pickle/publishing/Domain.java
+++ b/src/main/java/kr/ac/pusan/pickle/publishing/Domain.java
@@ -9,6 +9,7 @@
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
+import java.util.UUID;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.annotations.UpdateTimestamp;
@@ -27,6 +28,14 @@ public class Domain {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
+ /**
+ * The identifier this row wears outside the API boundary. Internal joins,
+ * sorts and foreign keys keep using {@link #id}.
+ */
+ @JdbcTypeCode(SqlTypes.UUID)
+ @Column(name = "public_id", nullable = false, updatable = false, unique = true)
+ private UUID publicId = UUID.randomUUID();
+
@Column(name = "vm_id", nullable = false)
private Long vmId;
@@ -108,6 +117,10 @@ public Long getId() {
return id;
}
+ public UUID getPublicId() {
+ return publicId;
+ }
+
public Long getVmId() {
return vmId;
}
diff --git a/src/main/java/kr/ac/pusan/pickle/publishing/DomainRepository.java b/src/main/java/kr/ac/pusan/pickle/publishing/DomainRepository.java
index 4cbd089a..10475360 100644
--- a/src/main/java/kr/ac/pusan/pickle/publishing/DomainRepository.java
+++ b/src/main/java/kr/ac/pusan/pickle/publishing/DomainRepository.java
@@ -4,6 +4,7 @@
import java.util.Collection;
import java.util.List;
import java.util.Optional;
+import java.util.UUID;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
@@ -13,6 +14,9 @@
public interface DomainRepository extends JpaRepository {
+ /** Resolution of the identifier this row wears outside the API boundary. */
+ Optional findByPublicId(UUID publicId);
+
/** The live row holding an FQDN — the revive-or-409 pre-check. */
Optional findFirstByFqdnAndStatusNot(String fqdn, DomainStatus status);
diff --git a/src/main/java/kr/ac/pusan/pickle/publishing/PublicationAssembler.java b/src/main/java/kr/ac/pusan/pickle/publishing/PublicationAssembler.java
index 0b98647f..4204316f 100644
--- a/src/main/java/kr/ac/pusan/pickle/publishing/PublicationAssembler.java
+++ b/src/main/java/kr/ac/pusan/pickle/publishing/PublicationAssembler.java
@@ -4,6 +4,7 @@
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.Optional;
+import java.util.UUID;
import kr.ac.pusan.pickle.config.PublishingProperties;
import kr.ac.pusan.pickle.publishing.dto.CertificateView;
import kr.ac.pusan.pickle.publishing.dto.DomainDetailView;
@@ -52,17 +53,17 @@ public PublicationAssembler(RouteRepository routeRepository,
this.settingsService = settingsService;
}
- public DomainSummaryView toDomainSummary(Domain domain) {
- return new DomainSummaryView(domain.getId(), domain.getVmId(), domain.getKind(),
+ public DomainSummaryView toDomainSummary(Domain domain, UUID vmId) {
+ return new DomainSummaryView(domain.getPublicId(), vmId, domain.getKind(),
domain.getFqdn(), domain.getRootDomain(), domain.getStatus(),
domain.getVerifiedAt(), domain.getReleasedAt(), reservedUntil(domain),
domain.getCreatedAt());
}
- public DomainDetailView toDomainDetail(Domain domain) {
+ public DomainDetailView toDomainDetail(Domain domain, UUID vmId) {
DomainVerificationView verification = domain.getKind() == DomainKind.CUSTOM
? verification(domain) : null;
- return new DomainDetailView(domain.getId(), domain.getVmId(), domain.getKind(),
+ return new DomainDetailView(domain.getPublicId(), vmId, domain.getKind(),
domain.getFqdn(), domain.getRootDomain(), domain.getStatus(),
domain.getVerifiedAt(), domain.getReleasedAt(), reservedUntil(domain),
domain.getCreatedAt(), verification);
@@ -93,12 +94,12 @@ Instant reservedUntil(Domain domain) {
}
/** The full publish view for a domain — its live route and certificate. */
- public PublicationView toPublication(Domain domain) {
+ public PublicationView toPublication(Domain domain, UUID vmId) {
RouteView route = routeRepository.findFirstByDomainIdAndStatusNot(domain.getId(), RouteStatus.REMOVED)
.map(RouteView::from)
.orElse(null);
CertificateView certificate = certificateFor(domain).map(CertificateView::from).orElse(null);
- return new PublicationView(domain.getFqdn(), toDomainDetail(domain), route, certificate);
+ return new PublicationView(domain.getFqdn(), toDomainDetail(domain, vmId), route, certificate);
}
/**
diff --git a/src/main/java/kr/ac/pusan/pickle/publishing/PublishingController.java b/src/main/java/kr/ac/pusan/pickle/publishing/PublishingController.java
index 7c89042f..5b881ffd 100644
--- a/src/main/java/kr/ac/pusan/pickle/publishing/PublishingController.java
+++ b/src/main/java/kr/ac/pusan/pickle/publishing/PublishingController.java
@@ -5,6 +5,7 @@
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
+import java.util.UUID;
import kr.ac.pusan.pickle.auth.dto.MessageResponse;
import kr.ac.pusan.pickle.common.web.PageResponse;
import kr.ac.pusan.pickle.publishing.dto.CreateVmDomainRequest;
@@ -46,7 +47,7 @@ public PublishingController(PublishingService publishingService) {
@PostMapping("/vms/{vmId}/domains")
@ResponseStatus(HttpStatus.ACCEPTED)
public PublicationView createVmDomain(
- @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable long vmId,
+ @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable UUID vmId,
@RequestBody(required = false) CreateVmDomainRequest request,
HttpServletRequest httpRequest) {
CreateVmDomainRequest body = request != null ? request
@@ -59,7 +60,7 @@ public PublicationView createVmDomain(
@PatchMapping("/domains/{domainId}")
@ResponseStatus(HttpStatus.ACCEPTED)
public PublicationView updateDomain(
- @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable long domainId,
+ @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable UUID domainId,
@RequestBody UpdateDomainRequest request, HttpServletRequest httpRequest) {
return publishingService.updateDomain(principal, domainId, request.port(),
clientIp(httpRequest));
@@ -68,7 +69,7 @@ public PublicationView updateDomain(
@GetMapping("/domains")
public PageResponse listDomains(
@AuthenticationPrincipal AuthenticatedUser principal,
- @RequestParam(required = false) Long vmId,
+ @RequestParam(required = false) UUID vmId,
@RequestParam(required = false) DomainStatus status,
@RequestParam(defaultValue = "0") @Min(0) int page,
@RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) {
@@ -77,14 +78,14 @@ public PageResponse listDomains(
@GetMapping("/domains/{domainId}")
public DomainDetailView getDomain(@AuthenticationPrincipal AuthenticatedUser principal,
- @PathVariable long domainId) {
+ @PathVariable UUID domainId) {
return publishingService.getDomain(principal, domainId);
}
@DeleteMapping("/domains/{domainId}")
@ResponseStatus(HttpStatus.ACCEPTED)
public MessageResponse deleteDomain(
- @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable long domainId,
+ @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable UUID domainId,
HttpServletRequest httpRequest) {
return publishingService.deleteDomain(principal, domainId, clientIp(httpRequest));
}
@@ -92,7 +93,7 @@ public MessageResponse deleteDomain(
@PostMapping("/domains/{domainId}/verify")
@ResponseStatus(HttpStatus.ACCEPTED)
public DomainDetailView verifyDomain(
- @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable long domainId,
+ @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable UUID domainId,
HttpServletRequest httpRequest) {
return publishingService.verifyDomain(principal, domainId, clientIp(httpRequest));
}
diff --git a/src/main/java/kr/ac/pusan/pickle/publishing/PublishingService.java b/src/main/java/kr/ac/pusan/pickle/publishing/PublishingService.java
index 778b2161..0a192a88 100644
--- a/src/main/java/kr/ac/pusan/pickle/publishing/PublishingService.java
+++ b/src/main/java/kr/ac/pusan/pickle/publishing/PublishingService.java
@@ -9,6 +9,7 @@
import java.util.Locale;
import java.util.Map;
import java.util.Set;
+import java.util.UUID;
import java.util.regex.Pattern;
import kr.ac.pusan.pickle.access.ResourceRole;
import kr.ac.pusan.pickle.access.AccessGranteeType;
@@ -127,9 +128,10 @@ public PublishingService(VmRepository vmRepository, WorkspaceMemberRepository wo
// ── create / update / delete a domain ────────────────────────────────────
@Transactional
- public PublicationView createDomain(AuthenticatedUser actor, long vmId, Integer port,
+ public PublicationView createDomain(AuthenticatedUser actor, UUID publicVmId, Integer port,
String subdomain, String rootDomain, String customDomain, String ip) {
- Vm vm = requireVmOwnerOrEditor(actor, vmId);
+ Vm vm = requireVmOwnerOrEditor(actor, publicVmId);
+ long vmId = vm.getId();
requirePublishableState(vm);
if (Texts.blankToNull(subdomain) != null && Texts.blankToNull(customDomain) != null) {
throw ApiException.validationFailed(List.of(new FieldValidationError("subdomain",
@@ -142,16 +144,17 @@ public PublicationView createDomain(AuthenticatedUser actor, long vmId, Integer
: createPlatform(vm, resolvedPort, subdomain, rootDomain);
vmEventRepository.save(new VmEvent(vmId, VmEventType.PUBLISH, actor.id(), domain.getFqdn()));
auditService.recordAfterCommit(actor.id(), actor.role().name(), AuditService.VM_PUBLISH,
- "vm", vmId, Map.of("fqdn", domain.getFqdn(), "port", resolvedPort,
+ "vm", vm.getPublicId(), Map.of("fqdn", domain.getFqdn(), "port", resolvedPort,
"kind", domain.getKind().name()), ip);
- return assembler.toPublication(domain);
+ return assembler.toPublication(domain, vm.getPublicId());
}
@Transactional
- public PublicationView updateDomain(AuthenticatedUser actor, long domainId, Integer port,
+ public PublicationView updateDomain(AuthenticatedUser actor, UUID publicDomainId, Integer port,
String ip) {
- Domain domain = domainRepository.findById(domainId).orElseThrow(PublishingService::domainNotFound);
- Vm vm = requireVmOwnerOrEditor(actor, domain.getVmId());
+ Domain domain = domainRepository.findByPublicId(publicDomainId)
+ .orElseThrow(PublishingService::domainNotFound);
+ Vm vm = requireVmOwnerOrEditorOf(actor, domain.getVmId());
if (domain.getStatus() == DomainStatus.REMOVED) {
throw domainNotFound();
}
@@ -176,8 +179,8 @@ public PublicationView updateDomain(AuthenticatedUser actor, long domainId, Inte
enqueueAfterCommit(() -> routeApplyJob.apply(routeId));
}
auditService.recordAfterCommit(actor.id(), actor.role().name(), AuditService.DOMAIN_UPDATE,
- "domain", domainId, Map.of("fqdn", domain.getFqdn(), "port", resolvedPort), ip);
- return assembler.toPublication(domain);
+ "domain", domain.getPublicId(), Map.of("fqdn", domain.getFqdn(), "port", resolvedPort), ip);
+ return assembler.toPublication(domain, vm.getPublicId());
}
/**
@@ -189,9 +192,10 @@ public PublicationView updateDomain(AuthenticatedUser actor, long domainId, Inte
* row) is removed outright, freeing the name immediately.
*/
@Transactional
- public MessageResponse deleteDomain(AuthenticatedUser actor, long domainId, String ip) {
- Domain domain = domainRepository.findById(domainId).orElseThrow(PublishingService::domainNotFound);
- requireVmOwnerOrEditor(actor, domain.getVmId());
+ public MessageResponse deleteDomain(AuthenticatedUser actor, UUID publicDomainId, String ip) {
+ Domain domain = domainRepository.findByPublicId(publicDomainId)
+ .orElseThrow(PublishingService::domainNotFound);
+ requireVmOwnerOrEditorOf(actor, domain.getVmId());
if (domain.getStatus() == DomainStatus.REMOVED) {
throw domainNotFound();
}
@@ -202,7 +206,7 @@ public MessageResponse deleteDomain(AuthenticatedUser actor, long domainId, Stri
domain.getFqdn()));
}
auditService.recordAfterCommit(actor.id(), actor.role().name(), AuditService.DOMAIN_DELETE,
- "domain", domainId, Map.of("fqdn", domain.getFqdn()), ip);
+ "domain", domain.getPublicId(), Map.of("fqdn", domain.getFqdn()), ip);
return domain.getReleasedAt() != null && domain.getStatus() != DomainStatus.REMOVED
? new MessageResponse("도메인 해제를 접수했습니다. 이름은 유예 기간 동안 이 VM에 예약됩니다.")
: new MessageResponse("도메인 삭제를 접수했습니다.");
@@ -211,26 +215,36 @@ public MessageResponse deleteDomain(AuthenticatedUser actor, long domainId, Stri
// ── domain reads ─────────────────────────────────────────────────────────
@Transactional(readOnly = true)
- public PageResponse listDomains(AuthenticatedUser actor, Long vmId,
+ public PageResponse listDomains(AuthenticatedUser actor, UUID publicVmId,
DomainStatus status, int page, int size) {
Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "id"));
+ // An id no VM has filters to nothing, as a non-matching number did.
+ Long vmId = publicVmId == null ? null
+ : vmRepository.findByPublicId(publicVmId).map(Vm::getId).orElse(-1L);
Page result = domainRepository.findForReachableVms(reachableVmIds(actor), vmId,
status != null ? status.name() : null, pageable);
- return PageResponse.of(result.getContent().stream().map(assembler::toDomainSummary).toList(),
- result);
+ Map vmPublicIds = vmRepository.findAllById(result.getContent().stream()
+ .map(Domain::getVmId).distinct().toList()).stream()
+ .collect(java.util.stream.Collectors.toMap(Vm::getId, Vm::getPublicId));
+ return PageResponse.of(result.getContent().stream()
+ .map(domain -> assembler.toDomainSummary(domain, vmPublicIds.get(domain.getVmId())))
+ .toList(), result);
}
@Transactional(readOnly = true)
- public DomainDetailView getDomain(AuthenticatedUser actor, long domainId) {
- Domain domain = domainRepository.findById(domainId).orElseThrow(PublishingService::domainNotFound);
- requireVmMember(actor, domain.getVmId());
- return assembler.toDomainDetail(domain);
+ public DomainDetailView getDomain(AuthenticatedUser actor, UUID publicDomainId) {
+ Domain domain = domainRepository.findByPublicId(publicDomainId)
+ .orElseThrow(PublishingService::domainNotFound);
+ Vm vm = requireVmMemberOf(actor, domain.getVmId());
+ return assembler.toDomainDetail(domain, vm.getPublicId());
}
@Transactional
- public DomainDetailView verifyDomain(AuthenticatedUser actor, long domainId, String ip) {
- Domain domain = domainRepository.findById(domainId).orElseThrow(PublishingService::domainNotFound);
- requireVmOwnerOrEditor(actor, domain.getVmId());
+ public DomainDetailView verifyDomain(AuthenticatedUser actor, UUID publicDomainId, String ip) {
+ Domain domain = domainRepository.findByPublicId(publicDomainId)
+ .orElseThrow(PublishingService::domainNotFound);
+ Vm vm = requireVmOwnerOrEditorOf(actor, domain.getVmId());
+ long domainId = domain.getId();
// Same 404 mask as update/delete: a REMOVED row is gone to its owner.
if (domain.getStatus() == DomainStatus.REMOVED) {
throw domainNotFound();
@@ -245,8 +259,8 @@ public DomainDetailView verifyDomain(AuthenticatedUser actor, long domainId, Str
RateLimitService.DEFAULT_LIMIT_PER_MINUTE);
runAfterCommit(() -> domainVerificationJob.requestVerify(domainId));
auditService.recordAfterCommit(actor.id(), actor.role().name(), AuditService.DOMAIN_VERIFY,
- "domain", domainId, Map.of("fqdn", domain.getFqdn()), ip);
- return assembler.toDomainDetail(domain);
+ "domain", domain.getPublicId(), Map.of("fqdn", domain.getFqdn()), ip);
+ return assembler.toDomainDetail(domain, vm.getPublicId());
}
// ── shared creation core ─────────────────────────────────────────────────
@@ -580,11 +594,24 @@ private static ApiException fqdnTaken() {
// ── authorization helpers ────────────────────────────────────────────────
- private Vm requireVmMember(AuthenticatedUser actor, long vmId) {
+ private Vm requireVmMember(AuthenticatedUser actor, UUID vmId) {
return vmAccessService.of(actor, vmId).requireVisible();
}
- private Vm requireVmOwnerOrEditor(AuthenticatedUser actor, long vmId) {
+ /** The same two gates reached from a row that already names its VM internally. */
+ private Vm requireVmMemberOf(AuthenticatedUser actor, long vmId) {
+ return vmAccessService.of(vmRepository.findById(vmId)
+ .orElseThrow(VmAccessService::vmNotFound), actor.id()).requireVisible();
+ }
+
+ private Vm requireVmOwnerOrEditorOf(AuthenticatedUser actor, long vmId) {
+ return vmAccessService.of(vmRepository.findById(vmId)
+ .orElseThrow(VmAccessService::vmNotFound), actor.id())
+ .requireAtLeast(ResourceRole.EDITOR,
+ "도메인을 관리할 권한이 없습니다", "이 VM의 편집자 이상만 도메인을 관리할 수 있습니다.");
+ }
+
+ private Vm requireVmOwnerOrEditor(AuthenticatedUser actor, UUID vmId) {
return vmAccessService.of(actor, vmId).requireAtLeast(ResourceRole.EDITOR,
"HTTP 서비스를 공개할 권한이 없습니다",
"이 VM의 소유자 또는 편집자만 도메인·포트를 설정할 수 있습니다.");
diff --git a/src/main/java/kr/ac/pusan/pickle/publishing/Route.java b/src/main/java/kr/ac/pusan/pickle/publishing/Route.java
index ad1a4971..41d1da1e 100644
--- a/src/main/java/kr/ac/pusan/pickle/publishing/Route.java
+++ b/src/main/java/kr/ac/pusan/pickle/publishing/Route.java
@@ -9,6 +9,7 @@
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
+import java.util.UUID;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.annotations.UpdateTimestamp;
@@ -29,6 +30,14 @@ public class Route {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
+ /**
+ * The identifier this row wears outside the API boundary. Internal joins,
+ * sorts and foreign keys keep using {@link #id}.
+ */
+ @JdbcTypeCode(SqlTypes.UUID)
+ @Column(name = "public_id", nullable = false, updatable = false, unique = true)
+ private UUID publicId = UUID.randomUUID();
+
@Column(name = "domain_id", nullable = false)
private Long domainId;
@@ -78,6 +87,10 @@ public Long getId() {
return id;
}
+ public UUID getPublicId() {
+ return publicId;
+ }
+
public Long getDomainId() {
return domainId;
}
diff --git a/src/main/java/kr/ac/pusan/pickle/publishing/RouteRepository.java b/src/main/java/kr/ac/pusan/pickle/publishing/RouteRepository.java
index b921357e..28dec3a4 100644
--- a/src/main/java/kr/ac/pusan/pickle/publishing/RouteRepository.java
+++ b/src/main/java/kr/ac/pusan/pickle/publishing/RouteRepository.java
@@ -4,6 +4,7 @@
import java.time.Instant;
import java.util.List;
import java.util.Optional;
+import java.util.UUID;
import org.springframework.data.domain.Page;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.domain.Pageable;
@@ -15,6 +16,9 @@
public interface RouteRepository extends JpaRepository {
+ /** Resolution of the identifier this row wears outside the API boundary. */
+ Optional findByPublicId(UUID publicId);
+
/**
* The route, locked for one SHORT apply phase — the prepare that reads the
* desired state and the record that writes the outcome each take this lock
diff --git a/src/main/java/kr/ac/pusan/pickle/publishing/dto/AdminCertificateView.java b/src/main/java/kr/ac/pusan/pickle/publishing/dto/AdminCertificateView.java
index 972f18dd..bdbf2262 100644
--- a/src/main/java/kr/ac/pusan/pickle/publishing/dto/AdminCertificateView.java
+++ b/src/main/java/kr/ac/pusan/pickle/publishing/dto/AdminCertificateView.java
@@ -1,17 +1,18 @@
package kr.ac.pusan.pickle.publishing.dto;
import java.time.Instant;
+import java.util.UUID;
import kr.ac.pusan.pickle.publishing.CertificateKind;
import kr.ac.pusan.pickle.publishing.CertificateStatus;
import org.jspecify.annotations.Nullable;
/** Contract schema {@code AdminCertificateView}. */
public record AdminCertificateView(
- Long id,
+ UUID id,
CertificateKind kind,
CertificateStatus status,
String scope,
- @Nullable Long domainId,
+ @Nullable UUID domainId,
@Nullable Instant notAfter,
@Nullable Integer daysUntilExpiry,
@Nullable String lastError) {
diff --git a/src/main/java/kr/ac/pusan/pickle/publishing/dto/AdminDomainView.java b/src/main/java/kr/ac/pusan/pickle/publishing/dto/AdminDomainView.java
index dae26a33..5927b263 100644
--- a/src/main/java/kr/ac/pusan/pickle/publishing/dto/AdminDomainView.java
+++ b/src/main/java/kr/ac/pusan/pickle/publishing/dto/AdminDomainView.java
@@ -1,6 +1,7 @@
package kr.ac.pusan.pickle.publishing.dto;
import java.time.Instant;
+import java.util.UUID;
import kr.ac.pusan.pickle.publishing.CertificateStatus;
import kr.ac.pusan.pickle.publishing.DomainKind;
import kr.ac.pusan.pickle.publishing.DomainStatus;
@@ -18,8 +19,8 @@
* occupied.
*/
public record AdminDomainView(
- Long id,
- Long vmId,
+ UUID id,
+ UUID vmId,
DomainKind kind,
String fqdn,
@Nullable String rootDomain,
@@ -29,9 +30,9 @@ public record AdminDomainView(
@Nullable Instant reservedUntil,
Instant createdAt,
String vmName,
- Long workspaceId,
+ UUID workspaceId,
String workspaceName,
- Long orgId,
+ UUID orgId,
String orgName,
@Nullable RouteStatus routeStatus,
@Nullable CertificateStatus certificateStatus,
diff --git a/src/main/java/kr/ac/pusan/pickle/publishing/dto/AdminRouteView.java b/src/main/java/kr/ac/pusan/pickle/publishing/dto/AdminRouteView.java
index 941e75e8..4d58151a 100644
--- a/src/main/java/kr/ac/pusan/pickle/publishing/dto/AdminRouteView.java
+++ b/src/main/java/kr/ac/pusan/pickle/publishing/dto/AdminRouteView.java
@@ -1,21 +1,22 @@
package kr.ac.pusan.pickle.publishing.dto;
import java.time.Instant;
+import java.util.UUID;
import kr.ac.pusan.pickle.publishing.DomainKind;
import kr.ac.pusan.pickle.publishing.RouteStatus;
import org.jspecify.annotations.Nullable;
/** Contract schema {@code AdminRouteView} — a route with VM/workspace/org context. */
public record AdminRouteView(
- Long id,
- Long domainId,
+ UUID id,
+ UUID domainId,
String fqdn,
DomainKind domainKind,
- Long vmId,
+ UUID vmId,
String vmName,
- Long workspaceId,
+ UUID workspaceId,
String workspaceName,
- Long orgId,
+ UUID orgId,
String orgName,
int targetPort,
String protocol,
diff --git a/src/main/java/kr/ac/pusan/pickle/publishing/dto/DomainDetailView.java b/src/main/java/kr/ac/pusan/pickle/publishing/dto/DomainDetailView.java
index 4d1dde94..d61a5fb6 100644
--- a/src/main/java/kr/ac/pusan/pickle/publishing/dto/DomainDetailView.java
+++ b/src/main/java/kr/ac/pusan/pickle/publishing/dto/DomainDetailView.java
@@ -1,6 +1,7 @@
package kr.ac.pusan.pickle.publishing.dto;
import java.time.Instant;
+import java.util.UUID;
import kr.ac.pusan.pickle.publishing.DomainKind;
import kr.ac.pusan.pickle.publishing.DomainStatus;
import org.jspecify.annotations.Nullable;
@@ -11,8 +12,8 @@
* (null for platform subdomains).
*/
public record DomainDetailView(
- Long id,
- Long vmId,
+ UUID id,
+ UUID vmId,
DomainKind kind,
String fqdn,
@Nullable String rootDomain,
diff --git a/src/main/java/kr/ac/pusan/pickle/publishing/dto/DomainSummaryView.java b/src/main/java/kr/ac/pusan/pickle/publishing/dto/DomainSummaryView.java
index 6eb911a4..6c09ad34 100644
--- a/src/main/java/kr/ac/pusan/pickle/publishing/dto/DomainSummaryView.java
+++ b/src/main/java/kr/ac/pusan/pickle/publishing/dto/DomainSummaryView.java
@@ -1,6 +1,7 @@
package kr.ac.pusan.pickle.publishing.dto;
import java.time.Instant;
+import java.util.UUID;
import kr.ac.pusan.pickle.publishing.DomainKind;
import kr.ac.pusan.pickle.publishing.DomainStatus;
import org.jspecify.annotations.Nullable;
@@ -13,8 +14,8 @@
* grace setting.
*/
public record DomainSummaryView(
- Long id,
- Long vmId,
+ UUID id,
+ UUID vmId,
DomainKind kind,
String fqdn,
@Nullable String rootDomain,
diff --git a/src/main/java/kr/ac/pusan/pickle/relay/AdminPortMappingController.java b/src/main/java/kr/ac/pusan/pickle/relay/AdminPortMappingController.java
index cabc3231..887e67ed 100644
--- a/src/main/java/kr/ac/pusan/pickle/relay/AdminPortMappingController.java
+++ b/src/main/java/kr/ac/pusan/pickle/relay/AdminPortMappingController.java
@@ -8,6 +8,7 @@
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
+import java.util.UUID;
import kr.ac.pusan.pickle.auth.dto.MessageResponse;
import kr.ac.pusan.pickle.common.web.PageResponse;
import kr.ac.pusan.pickle.relay.dto.AdminPortMappingResponse;
@@ -46,8 +47,8 @@ public AdminPortMappingController(AdminPortMappingService adminPortMappingServic
@GetMapping
public PageResponse listAdminPortMappings(
- @RequestParam(required = false) Long relayId,
- @RequestParam(required = false) Long vmId,
+ @RequestParam(required = false) UUID relayId,
+ @RequestParam(required = false) UUID vmId,
@RequestParam(required = false) PortMappingStatus status,
@RequestParam(defaultValue = "0") @Min(0) int page,
@RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) {
@@ -57,7 +58,7 @@ public PageResponse listAdminPortMappings(
/** 200 with the updated row (contract): convergence itself stays async. */
@PostMapping("/{mappingId}/suspend")
public AdminPortMappingResponse suspendAdminPortMapping(
- @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable long mappingId,
+ @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable UUID mappingId,
@Valid @RequestBody SuspendPortMappingRequest request,
HttpServletRequest httpRequest) {
return adminPortMappingService.suspend(principal, mappingId, request.reason().strip(),
@@ -66,7 +67,7 @@ public AdminPortMappingResponse suspendAdminPortMapping(
@PostMapping("/{mappingId}/unsuspend")
public AdminPortMappingResponse unsuspendAdminPortMapping(
- @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable long mappingId,
+ @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable UUID mappingId,
HttpServletRequest httpRequest) {
return adminPortMappingService.unsuspend(principal, mappingId, clientIp(httpRequest));
}
@@ -74,7 +75,7 @@ public AdminPortMappingResponse unsuspendAdminPortMapping(
@DeleteMapping("/{mappingId}")
@ResponseStatus(HttpStatus.ACCEPTED)
public MessageResponse deleteAdminPortMapping(
- @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable long mappingId,
+ @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable UUID mappingId,
HttpServletRequest httpRequest) {
return adminPortMappingService.delete(principal, mappingId, clientIp(httpRequest));
}
@@ -89,7 +90,7 @@ public MessageResponse deleteAdminPortMapping(
@io.swagger.v3.oas.annotations.parameters.RequestBody(content = @Content(
schema = @Schema(implementation = UpdatePortMappingGuardsRequest.class)))
public AdminPortMappingResponse updateAdminPortMappingGuards(
- @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable long mappingId,
+ @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable UUID mappingId,
@RequestBody tools.jackson.databind.JsonNode body, HttpServletRequest httpRequest) {
return adminPortMappingService.updateGuards(principal, mappingId, body,
clientIp(httpRequest));
diff --git a/src/main/java/kr/ac/pusan/pickle/relay/AdminPortMappingService.java b/src/main/java/kr/ac/pusan/pickle/relay/AdminPortMappingService.java
index 83d49f63..91210893 100644
--- a/src/main/java/kr/ac/pusan/pickle/relay/AdminPortMappingService.java
+++ b/src/main/java/kr/ac/pusan/pickle/relay/AdminPortMappingService.java
@@ -5,6 +5,7 @@
import java.util.List;
import java.util.Map;
import java.util.Set;
+import java.util.UUID;
import java.util.function.Function;
import java.util.stream.Collectors;
import kr.ac.pusan.pickle.audit.AuditService;
@@ -62,6 +63,7 @@ public class AdminPortMappingService {
private final PortMappingRepository portMappingRepository;
private final RelayRepository relayRepository;
private final VmRepository vmRepository;
+ private final kr.ac.pusan.pickle.user.UserRepository userRepository;
private final RelayGenerations relayGenerations;
private final NotificationService notificationService;
private final AuditService auditService;
@@ -70,12 +72,14 @@ public class AdminPortMappingService {
public AdminPortMappingService(PortMappingRepository portMappingRepository,
RelayRepository relayRepository, VmRepository vmRepository,
+ kr.ac.pusan.pickle.user.UserRepository userRepository,
RelayGenerations relayGenerations, NotificationService notificationService,
AuditService auditService, VmEventRepository vmEventRepository,
PortForwardingService portForwardingService) {
this.portMappingRepository = portMappingRepository;
this.relayRepository = relayRepository;
this.vmRepository = vmRepository;
+ this.userRepository = userRepository;
this.relayGenerations = relayGenerations;
this.notificationService = notificationService;
this.auditService = auditService;
@@ -86,13 +90,16 @@ public AdminPortMappingService(PortMappingRepository portMappingRepository,
// ── list ─────────────────────────────────────────────────────────────────
@Transactional(readOnly = true)
- public PageResponse list(Long relayId, Long vmId,
+ public PageResponse list(UUID publicRelayId, UUID publicVmId,
PortMappingStatus status, int page, int size) {
Specification spec = (root, query, cb) -> cb.conjunction();
- if (relayId != null) {
+ // An id no row has filters to nothing, as a non-matching number did.
+ if (publicRelayId != null) {
+ Long relayId = relayRepository.findByPublicId(publicRelayId).map(Relay::getId).orElse(-1L);
spec = spec.and((root, query, cb) -> cb.equal(root.get("relayId"), relayId));
}
- if (vmId != null) {
+ if (publicVmId != null) {
+ Long vmId = vmRepository.findByPublicId(publicVmId).map(Vm::getId).orElse(-1L);
spec = spec.and((root, query, cb) -> cb.equal(root.get("vmId"), vmId));
}
if (status != null) {
@@ -111,21 +118,24 @@ public PageResponse list(Long relayId, Long vmId,
relays.values().forEach(relay ->
failedByRelay.put(relay.getId(), portForwardingService.failedIds(relay)));
+ Map userIds = userPublicIds(result.getContent());
List views = result.getContent().stream().map(mapping -> {
Relay relay = relays.get(mapping.getRelayId());
Vm vm = vms.get(mapping.getVmId());
- return new AdminPortMappingResponse(mapping.getId(), mapping.getRelayId(),
- relay != null ? relay.getName() : "", mapping.getVmId(),
+ return new AdminPortMappingResponse(mapping.getPublicId(),
+ relay != null ? relay.getPublicId() : null,
+ relay != null ? relay.getName() : "",
+ vm != null ? vm.getPublicId() : null,
vm != null ? vm.getName() : null, mapping.getProto(),
mapping.getPublicPort(), mapping.getTargetPort(), mapping.getStatus(),
- mapping.getSuspendedReason(), mapping.getSuspendedBy(),
+ mapping.getSuspendedReason(), userIds.get(mapping.getSuspendedBy()),
relay == null ? PortForwardApplyState.PENDING
: PortForwardingService.applyState(mapping,
relay.getAppliedGeneration(),
failedByRelay.getOrDefault(relay.getId(), Set.of())),
mapping.getCtMax(), mapping.getNewConnRate(), mapping.getNewConnBurst(),
mapping.getPerSourceRate(), mapping.getPerSourceBurst(),
- mapping.getCreatedBy(), mapping.getCreatedAt());
+ userIds.get(mapping.getCreatedBy()), mapping.getCreatedAt());
}).toList();
return PageResponse.of(views, result);
}
@@ -133,7 +143,7 @@ public PageResponse list(Long relayId, Long vmId,
// ── suspend / unsuspend ──────────────────────────────────────────────────
@Transactional
- public AdminPortMappingResponse suspend(AuthenticatedUser actor, long mappingId,
+ public AdminPortMappingResponse suspend(AuthenticatedUser actor, UUID mappingId,
String reason, String ip) {
PortMapping mapping = requireMapping(mappingId);
if (mapping.getStatus() != PortMappingStatus.ACTIVE) {
@@ -149,20 +159,20 @@ public AdminPortMappingResponse suspend(AuthenticatedUser actor, long mappingId,
notificationService.publish(
notificationService.vmResponsibleIds(vm),
NotificationEvent.PORT_MAPPING_SUSPENDED,
- Map.of("vmId", vm.getId(), "vmName", vm.getName(),
+ Map.of("vmId", vm.getPublicId(), "vmName", vm.getName(),
"proto", mapping.getProto().name(),
"publicPort", mapping.getPublicPort(), "reason", reason),
null);
}
auditService.recordAfterCommit(actor.id(), actor.role().name(),
- AuditService.PORT_MAPPING_SUSPEND, "port_mapping", mappingId,
+ AuditService.PORT_MAPPING_SUSPEND, "port_mapping", mapping.getPublicId(),
Map.of("auto", false, "relayId", mapping.getRelayId(),
"vmId", mapping.getVmId(), "reason", reason), ip);
return toResponse(mapping);
}
@Transactional
- public AdminPortMappingResponse unsuspend(AuthenticatedUser actor, long mappingId,
+ public AdminPortMappingResponse unsuspend(AuthenticatedUser actor, UUID mappingId,
String ip) {
PortMapping mapping = requireMapping(mappingId);
if (mapping.getStatus() != PortMappingStatus.SUSPENDED) {
@@ -174,7 +184,7 @@ public AdminPortMappingResponse unsuspend(AuthenticatedUser actor, long mappingI
mapping.setSuspendedBy(null);
mapping.setLastChangeGeneration(generation);
auditService.recordAfterCommit(actor.id(), actor.role().name(),
- AuditService.PORT_MAPPING_UNSUSPEND, "port_mapping", mappingId,
+ AuditService.PORT_MAPPING_UNSUSPEND, "port_mapping", mapping.getPublicId(),
Map.of("relayId", mapping.getRelayId(), "vmId", mapping.getVmId()), ip);
return toResponse(mapping);
}
@@ -182,7 +192,7 @@ public AdminPortMappingResponse unsuspend(AuthenticatedUser actor, long mappingI
// ── delete ───────────────────────────────────────────────────────────────
@Transactional
- public MessageResponse delete(AuthenticatedUser actor, long mappingId, String ip) {
+ public MessageResponse delete(AuthenticatedUser actor, UUID mappingId, String ip) {
PortMapping mapping = requireMapping(mappingId);
relayGenerations.bump(mapping.getRelayId());
portMappingRepository.delete(mapping);
@@ -195,13 +205,13 @@ public MessageResponse delete(AuthenticatedUser actor, long mappingId, String ip
notificationService.publish(
notificationService.vmResponsibleIds(vm),
NotificationEvent.PORT_MAPPING_DELETED,
- Map.of("vmId", vm.getId(), "vmName", vm.getName(),
+ Map.of("vmId", vm.getPublicId(), "vmName", vm.getName(),
"proto", mapping.getProto().name(),
"publicPort", mapping.getPublicPort()),
null);
}
auditService.recordAfterCommit(actor.id(), actor.role().name(),
- AuditService.PORT_MAPPING_DELETE, "port_mapping", mappingId,
+ AuditService.PORT_MAPPING_DELETE, "port_mapping", mapping.getPublicId(),
Map.of("relayId", mapping.getRelayId(), "vmId", mapping.getVmId(),
"proto", mapping.getProto().name(),
"publicPort", mapping.getPublicPort()), ip);
@@ -217,7 +227,7 @@ public MessageResponse delete(AuthenticatedUser actor, long mappingId, String ip
* the relay generation.
*/
@Transactional
- public AdminPortMappingResponse updateGuards(AuthenticatedUser actor, long mappingId,
+ public AdminPortMappingResponse updateGuards(AuthenticatedUser actor, UUID mappingId,
JsonNode body, String ip) {
PortMapping mapping = requireMapping(mappingId);
Map changes = new LinkedHashMap<>();
@@ -276,7 +286,7 @@ public AdminPortMappingResponse updateGuards(AuthenticatedUser actor, long mappi
long generation = relayGenerations.bump(mapping.getRelayId());
mapping.setLastChangeGeneration(generation);
auditService.recordAfterCommit(actor.id(), actor.role().name(),
- AuditService.PORT_MAPPING_GUARDS_UPDATE, "port_mapping", mappingId,
+ AuditService.PORT_MAPPING_GUARDS_UPDATE, "port_mapping", mapping.getPublicId(),
Map.of("relayId", mapping.getRelayId(), "vmId", mapping.getVmId(),
"changes", changes), ip);
return toResponse(mapping);
@@ -288,19 +298,33 @@ public AdminPortMappingResponse updateGuards(AuthenticatedUser actor, long mappi
private AdminPortMappingResponse toResponse(PortMapping mapping) {
Relay relay = relayRepository.findById(mapping.getRelayId()).orElseThrow();
Vm vm = vmRepository.findById(mapping.getVmId()).orElse(null);
- return new AdminPortMappingResponse(mapping.getId(), mapping.getRelayId(), relay.getName(),
- mapping.getVmId(), vm != null ? vm.getName() : null, mapping.getProto(),
+ Map userIds = userPublicIds(List.of(mapping));
+ return new AdminPortMappingResponse(mapping.getPublicId(), relay.getPublicId(), relay.getName(),
+ vm != null ? vm.getPublicId() : null, vm != null ? vm.getName() : null,
+ mapping.getProto(),
mapping.getPublicPort(), mapping.getTargetPort(), mapping.getStatus(),
- mapping.getSuspendedReason(), mapping.getSuspendedBy(),
+ mapping.getSuspendedReason(), userIds.get(mapping.getSuspendedBy()),
PortForwardingService.applyState(mapping, relay.getAppliedGeneration(),
portForwardingService.failedIds(relay)),
mapping.getCtMax(), mapping.getNewConnRate(), mapping.getNewConnBurst(),
- mapping.getPerSourceRate(), mapping.getPerSourceBurst(), mapping.getCreatedBy(),
- mapping.getCreatedAt());
+ mapping.getPerSourceRate(), mapping.getPerSourceBurst(),
+ userIds.get(mapping.getCreatedBy()), mapping.getCreatedAt());
}
- private PortMapping requireMapping(long mappingId) {
- return portMappingRepository.findById(mappingId)
+ /** Batch account join for {@code createdBy}/{@code suspendedBy}. */
+ private Map userPublicIds(List mappings) {
+ List ids = java.util.stream.Stream.concat(
+ mappings.stream().map(PortMapping::getCreatedBy),
+ mappings.stream().map(PortMapping::getSuspendedBy))
+ .filter(java.util.Objects::nonNull).distinct().toList();
+ return ids.isEmpty() ? Map.of()
+ : userRepository.findAllById(ids).stream()
+ .collect(Collectors.toMap(kr.ac.pusan.pickle.user.User::getId,
+ kr.ac.pusan.pickle.user.User::getPublicId));
+ }
+
+ private PortMapping requireMapping(UUID mappingId) {
+ return portMappingRepository.findByPublicId(mappingId)
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND,
ErrorCodes.RESOURCE_NOT_FOUND, "리소스를 찾을 수 없습니다",
"해당 포트 매핑이 존재하지 않습니다."));
diff --git a/src/main/java/kr/ac/pusan/pickle/relay/AdminRelayController.java b/src/main/java/kr/ac/pusan/pickle/relay/AdminRelayController.java
index 9037d147..d758af23 100644
--- a/src/main/java/kr/ac/pusan/pickle/relay/AdminRelayController.java
+++ b/src/main/java/kr/ac/pusan/pickle/relay/AdminRelayController.java
@@ -4,6 +4,7 @@
import jakarta.servlet.http.HttpServletRequest;
import java.util.List;
+import java.util.UUID;
import kr.ac.pusan.pickle.relay.dto.AdminRelayView;
import kr.ac.pusan.pickle.relay.dto.RelayTokenResponse;
import kr.ac.pusan.pickle.security.AuthenticatedUser;
@@ -41,7 +42,7 @@ public List listAdminRelays() {
@PreAuthorize("hasRole('SYS_ADMIN')")
@RequireReauth
public RelayTokenResponse issueAdminRelayToken(
- @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable long relayId,
+ @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable UUID relayId,
HttpServletRequest httpRequest) {
return adminRelayService.issueToken(principal, relayId, clientIp(httpRequest));
}
diff --git a/src/main/java/kr/ac/pusan/pickle/relay/AdminRelayService.java b/src/main/java/kr/ac/pusan/pickle/relay/AdminRelayService.java
index f03cfe0e..08a7f148 100644
--- a/src/main/java/kr/ac/pusan/pickle/relay/AdminRelayService.java
+++ b/src/main/java/kr/ac/pusan/pickle/relay/AdminRelayService.java
@@ -4,6 +4,7 @@
import java.time.Instant;
import java.util.List;
import java.util.Map;
+import java.util.UUID;
import kr.ac.pusan.pickle.audit.AuditService;
import kr.ac.pusan.pickle.common.error.ApiException;
import kr.ac.pusan.pickle.common.error.ErrorCodes;
@@ -47,7 +48,7 @@ public List list() {
int usagePercent = (int) (mappingCount * 100 / relay.bandSize());
boolean contactLost = relay.getLastContactAt() != null
&& relay.getLastContactAt().isBefore(lostBefore);
- return new AdminRelayView(relay.getId(), relay.getName(), relay.getPublicHost(),
+ return new AdminRelayView(relay.getPublicId(), relay.getName(), relay.getPublicHost(),
relay.getPortBandStart(), relay.getPortBandEnd(), relay.isEnabled(),
relay.getTokenHash() != null, relay.getMappingGeneration(),
relay.getAppliedGeneration(), relay.getLastContactAt(), contactLost,
@@ -61,16 +62,16 @@ public List list() {
* never logged or audited.
*/
@Transactional
- public RelayTokenResponse issueToken(AuthenticatedUser actor, long relayId, String ip) {
- Relay relay = relayRepository.findById(relayId)
+ public RelayTokenResponse issueToken(AuthenticatedUser actor, UUID relayId, String ip) {
+ Relay relay = relayRepository.findByPublicId(relayId)
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND,
ErrorCodes.RESOURCE_NOT_FOUND, "리소스를 찾을 수 없습니다",
"해당 릴레이가 존재하지 않습니다."));
String token = RelayTokens.newToken();
relay.setTokenHash(RelayTokens.sha256Hex(token));
auditService.recordAfterCommit(actor.id(), actor.role().name(),
- AuditService.RELAY_TOKEN_ISSUE, "relay", relayId,
+ AuditService.RELAY_TOKEN_ISSUE, "relay", relay.getPublicId(),
Map.of("relayName", relay.getName(), "rotated", true), ip);
- return new RelayTokenResponse(relayId, token);
+ return new RelayTokenResponse(relay.getPublicId(), token);
}
}
diff --git a/src/main/java/kr/ac/pusan/pickle/relay/PortForwardingController.java b/src/main/java/kr/ac/pusan/pickle/relay/PortForwardingController.java
index 37d53afa..36cc92f3 100644
--- a/src/main/java/kr/ac/pusan/pickle/relay/PortForwardingController.java
+++ b/src/main/java/kr/ac/pusan/pickle/relay/PortForwardingController.java
@@ -5,6 +5,7 @@
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import java.util.List;
+import java.util.UUID;
import kr.ac.pusan.pickle.auth.dto.MessageResponse;
import kr.ac.pusan.pickle.relay.dto.CreatePortForwardingRequest;
import kr.ac.pusan.pickle.relay.dto.PortForwardingView;
@@ -37,14 +38,14 @@ public PortForwardingController(PortForwardingService portForwardingService) {
@GetMapping
public List listVmPortForwardings(
- @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable long vmId) {
+ @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable UUID vmId) {
return portForwardingService.list(principal, vmId);
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public PortForwardingView createVmPortForwarding(
- @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable long vmId,
+ @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable UUID vmId,
@Valid @RequestBody CreatePortForwardingRequest request,
HttpServletRequest httpRequest) {
return portForwardingService.create(principal, vmId, request, clientIp(httpRequest));
@@ -53,8 +54,8 @@ public PortForwardingView createVmPortForwarding(
@DeleteMapping("/{portForwardingId}")
@ResponseStatus(HttpStatus.ACCEPTED)
public MessageResponse deleteVmPortForwarding(
- @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable long vmId,
- @PathVariable long portForwardingId, HttpServletRequest httpRequest) {
+ @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable UUID vmId,
+ @PathVariable UUID portForwardingId, HttpServletRequest httpRequest) {
return portForwardingService.delete(principal, vmId, portForwardingId,
clientIp(httpRequest));
}
diff --git a/src/main/java/kr/ac/pusan/pickle/relay/PortForwardingService.java b/src/main/java/kr/ac/pusan/pickle/relay/PortForwardingService.java
index 5fae27ca..fbfe9303 100644
--- a/src/main/java/kr/ac/pusan/pickle/relay/PortForwardingService.java
+++ b/src/main/java/kr/ac/pusan/pickle/relay/PortForwardingService.java
@@ -5,6 +5,7 @@
import java.util.List;
import java.util.Map;
import java.util.Set;
+import java.util.UUID;
import kr.ac.pusan.pickle.access.ResourceRole;
import kr.ac.pusan.pickle.access.VmAccessService;
import kr.ac.pusan.pickle.audit.AuditService;
@@ -101,18 +102,19 @@ public PortForwardingService(VmRepository vmRepository,
// ── list ─────────────────────────────────────────────────────────────────
@Transactional(readOnly = true)
- public List list(AuthenticatedUser actor, long vmId) {
- requireVmMember(actor, vmId);
- List mappings = portMappingRepository.findByVmIdOrderByIdAsc(vmId);
+ public List list(AuthenticatedUser actor, UUID publicVmId) {
+ Vm vm = requireVmMember(actor, publicVmId);
+ List mappings = portMappingRepository.findByVmIdOrderByIdAsc(vm.getId());
return mappings.stream().map(this::toView).toList();
}
// ── create ───────────────────────────────────────────────────────────────
@Transactional
- public PortForwardingView create(AuthenticatedUser actor, long vmId,
+ public PortForwardingView create(AuthenticatedUser actor, UUID publicVmId,
CreatePortForwardingRequest request, String ip) {
- Vm vm = requireVmOwnerOrEditor(actor, vmId);
+ Vm vm = requireVmOwnerOrEditor(actor, publicVmId);
+ long vmId = vm.getId();
if (!settingsService.bool(SettingsService.PORT_FORWARDING_ENABLED, false)) {
throw new ApiException(HttpStatus.CONFLICT, ErrorCodes.PORT_FORWARDING_DISABLED,
"포트 포워딩이 비활성화되어 있습니다",
@@ -147,7 +149,7 @@ public PortForwardingView create(AuthenticatedUser actor, long vmId,
vmEventRepository.save(new VmEvent(vmId, VmEventType.PORT_FORWARD_CREATE, actor.id(),
request.proto() + " 공개 포트 할당 → 대상 포트 " + request.targetPort()));
auditService.recordAfterCommit(actor.id(), actor.role().name(),
- AuditService.VM_PORT_FORWARD_CREATE, "vm", vmId,
+ AuditService.VM_PORT_FORWARD_CREATE, "vm", vm.getPublicId(),
Map.of("mappingId", mappingId, "relayId", relay.getId(),
"proto", request.proto().name(), "targetPort", request.targetPort()), ip);
return toView(portMappingRepository.findById(mappingId).orElseThrow());
@@ -156,17 +158,19 @@ public PortForwardingView create(AuthenticatedUser actor, long vmId,
// ── delete ───────────────────────────────────────────────────────────────
@Transactional
- public MessageResponse delete(AuthenticatedUser actor, long vmId, long portForwardingId,
- String ip) {
- requireVmOwnerOrEditor(actor, vmId);
- PortMapping mapping = portMappingRepository.findByIdAndVmId(portForwardingId, vmId)
+ public MessageResponse delete(AuthenticatedUser actor, UUID publicVmId,
+ UUID portForwardingId, String ip) {
+ Vm vm = requireVmOwnerOrEditor(actor, publicVmId);
+ long vmId = vm.getId();
+ PortMapping mapping = portMappingRepository.findByPublicId(portForwardingId)
+ .filter(row -> row.getVmId() == vmId)
.orElseThrow(PortForwardingService::mappingNotFound);
relayGenerations.bump(mapping.getRelayId());
portMappingRepository.delete(mapping);
vmEventRepository.save(new VmEvent(vmId, VmEventType.PORT_FORWARD_DELETE, actor.id(),
mapping.getProto() + " " + mapping.getPublicPort() + " 공개 해제"));
auditService.recordAfterCommit(actor.id(), actor.role().name(),
- AuditService.VM_PORT_FORWARD_DELETE, "vm", vmId,
+ AuditService.VM_PORT_FORWARD_DELETE, "vm", vm.getPublicId(),
Map.of("mappingId", mapping.getId(), "relayId", mapping.getRelayId(),
"proto", mapping.getProto().name(),
"publicPort", mapping.getPublicPort()), ip);
@@ -247,7 +251,7 @@ private void alertOnBandUsage(Relay relay) {
private PortForwardingView toView(PortMapping mapping) {
Relay relay = relayRepository.findById(mapping.getRelayId()).orElseThrow();
- return new PortForwardingView(mapping.getId(), mapping.getProto(),
+ return new PortForwardingView(mapping.getPublicId(), mapping.getProto(),
mapping.getPublicPort(), relay.getPublicHost(), mapping.getTargetPort(),
mapping.getStatus(),
applyState(mapping, relay.getAppliedGeneration(), failedIds(relay)),
@@ -292,11 +296,11 @@ Set failedIds(Relay relay) {
// ── authorization (publishing pattern: 404 mask, 403 for members) ───────
- private Vm requireVmMember(AuthenticatedUser actor, long vmId) {
+ private Vm requireVmMember(AuthenticatedUser actor, UUID vmId) {
return vmAccessService.of(actor, vmId).requireVisible();
}
- private Vm requireVmOwnerOrEditor(AuthenticatedUser actor, long vmId) {
+ private Vm requireVmOwnerOrEditor(AuthenticatedUser actor, UUID vmId) {
return vmAccessService.of(actor, vmId).requireAtLeast(ResourceRole.EDITOR,
"포트 포워딩을 관리할 권한이 없습니다",
"이 VM의 소유자 또는 편집자만 포트포워딩을 설정할 수 있습니다.");
diff --git a/src/main/java/kr/ac/pusan/pickle/relay/PortMapping.java b/src/main/java/kr/ac/pusan/pickle/relay/PortMapping.java
index 0c3ffd84..a1f0f390 100644
--- a/src/main/java/kr/ac/pusan/pickle/relay/PortMapping.java
+++ b/src/main/java/kr/ac/pusan/pickle/relay/PortMapping.java
@@ -9,8 +9,11 @@
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
+import java.util.UUID;
import org.hibernate.annotations.CreationTimestamp;
+import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.annotations.UpdateTimestamp;
+import org.hibernate.type.SqlTypes;
/**
* One desired relay DNAT rule (port_mappings): relay {@code publicPort} →
@@ -34,6 +37,14 @@ public class PortMapping {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
+ /**
+ * The identifier this row wears outside the API boundary. Internal joins,
+ * sorts and foreign keys keep using {@link #id}.
+ */
+ @JdbcTypeCode(SqlTypes.UUID)
+ @Column(name = "public_id", nullable = false, updatable = false, unique = true)
+ private UUID publicId = UUID.randomUUID();
+
@Column(name = "relay_id", nullable = false)
private Long relayId;
@@ -97,6 +108,10 @@ public Long getId() {
return id;
}
+ public UUID getPublicId() {
+ return publicId;
+ }
+
public Long getRelayId() {
return relayId;
}
diff --git a/src/main/java/kr/ac/pusan/pickle/relay/PortMappingRepository.java b/src/main/java/kr/ac/pusan/pickle/relay/PortMappingRepository.java
index 26acd702..616bbe22 100644
--- a/src/main/java/kr/ac/pusan/pickle/relay/PortMappingRepository.java
+++ b/src/main/java/kr/ac/pusan/pickle/relay/PortMappingRepository.java
@@ -2,12 +2,16 @@
import java.util.List;
import java.util.Optional;
+import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
public interface PortMappingRepository
extends JpaRepository, JpaSpecificationExecutor {
+ /** Resolution of the identifier this row wears outside the API boundary. */
+ Optional findByPublicId(UUID publicId);
+
List findByVmIdOrderByIdAsc(long vmId);
Optional findByIdAndVmId(long id, long vmId);
diff --git a/src/main/java/kr/ac/pusan/pickle/relay/Relay.java b/src/main/java/kr/ac/pusan/pickle/relay/Relay.java
index ff3b3f6a..de227898 100644
--- a/src/main/java/kr/ac/pusan/pickle/relay/Relay.java
+++ b/src/main/java/kr/ac/pusan/pickle/relay/Relay.java
@@ -7,6 +7,7 @@
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
+import java.util.UUID;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.annotations.UpdateTimestamp;
@@ -34,6 +35,14 @@ public class Relay {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
+ /**
+ * The identifier this row wears outside the API boundary. Internal joins,
+ * sorts and foreign keys keep using {@link #id}.
+ */
+ @JdbcTypeCode(SqlTypes.UUID)
+ @Column(name = "public_id", nullable = false, updatable = false, unique = true)
+ private UUID publicId = UUID.randomUUID();
+
@Column(nullable = false)
private String name;
@@ -91,6 +100,10 @@ public Long getId() {
return id;
}
+ public UUID getPublicId() {
+ return publicId;
+ }
+
public String getName() {
return name;
}
diff --git a/src/main/java/kr/ac/pusan/pickle/relay/RelayRepository.java b/src/main/java/kr/ac/pusan/pickle/relay/RelayRepository.java
index ae14f9e0..7a232a55 100644
--- a/src/main/java/kr/ac/pusan/pickle/relay/RelayRepository.java
+++ b/src/main/java/kr/ac/pusan/pickle/relay/RelayRepository.java
@@ -2,10 +2,14 @@
import java.util.List;
import java.util.Optional;
+import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
public interface RelayRepository extends JpaRepository {
+ /** Resolution of the identifier this row wears outside the API boundary. */
+ Optional findByPublicId(UUID publicId);
+
/** Allocation target: the lowest-id enabled relay (single relay today). */
Optional findFirstByEnabledTrueOrderByIdAsc();
diff --git a/src/main/java/kr/ac/pusan/pickle/relay/RelaySyncService.java b/src/main/java/kr/ac/pusan/pickle/relay/RelaySyncService.java
index 5b07421e..fd8bafbe 100644
--- a/src/main/java/kr/ac/pusan/pickle/relay/RelaySyncService.java
+++ b/src/main/java/kr/ac/pusan/pickle/relay/RelaySyncService.java
@@ -128,7 +128,7 @@ public RelaySyncResponse sync(long relayId, RelaySyncRequest request) {
// Direct record (not after-commit): a security signal, keep it even
// if something later in this tx were to fail.
auditService.record(null, AuditService.ACTOR_ROLE_RELAY,
- AuditService.RELAY_SYNC_VIOLATION, "relay", relayId,
+ AuditService.RELAY_SYNC_VIOLATION, "relay", relayPublicId(relayId),
Map.of("reported", reported, "storedApplied", state.applied(),
"currentGeneration", state.current(),
"agentVersion", agentVersion == null ? "" : agentVersion), null);
@@ -198,7 +198,7 @@ private void accumulateCounters(long relayId,
// baseline stays put, and the event is audited. Bounds the
// bigint totals against a lying or corrupted agent.
auditService.record(null, AuditService.ACTOR_ROLE_RELAY,
- AuditService.RELAY_SYNC_VIOLATION, "relay", relayId,
+ AuditService.RELAY_SYNC_VIOLATION, "relay", relayPublicId(relayId),
Map.of("kind", "counter_sanity", "mappingId", mappingId,
"maxReported", String.valueOf(raw.max())), null);
log.warn("relay {} reported an insane counter for mapping {} (max {})",
@@ -273,25 +273,39 @@ private void autoSuspend(long relayId, long mappingId, long connsPerMin, long mb
if (suspended != 1) {
return; // already suspended (e.g. duplicated report row)
}
+ // v.public_id, not v.id: the notification's link path is built from
+ // this map by string concatenation, so a numeric id here renders a
+ // console link that resolves to nothing.
Map context = jdbcTemplate.queryForObject("""
- select m.vm_id, v.name as vm_name, m.proto, m.public_port
+ select v.public_id as vm_public_id, v.name as vm_name, m.proto, m.public_port,
+ m.public_id as mapping_public_id
from port_mappings m join vms v on v.id = m.vm_id where m.id = ?
""", (rs, rowNum) -> Map.of(
- "vmId", rs.getLong("vm_id"), "vmName", rs.getString("vm_name"),
- "proto", rs.getString("proto"), "publicPort", rs.getInt("public_port")),
+ "vmId", rs.getObject("vm_public_id", java.util.UUID.class),
+ "vmName", rs.getString("vm_name"),
+ "proto", rs.getString("proto"), "publicPort", rs.getInt("public_port"),
+ "mappingPublicId", rs.getObject("mapping_public_id", java.util.UUID.class)),
mappingId);
+ java.util.UUID mappingPublicId = (java.util.UUID) context.get("mappingPublicId");
Map args = new LinkedHashMap<>(context);
+ args.remove("mappingPublicId");
args.put("reason", reason);
notificationService.publish(notificationService.sysAdminIds(),
NotificationEvent.PORT_MAPPING_SUSPENDED, args, "pm_auto_suspend:" + mappingId);
auditService.recordAfterCommit(null, AuditService.ACTOR_ROLE_RELAY, AuditService.PORT_MAPPING_SUSPEND,
- "port_mapping", mappingId, Map.of("auto", true, "relayId", relayId,
+ "port_mapping", mappingPublicId, Map.of("auto", true, "relayId", relayId,
"connsPerMin", connsPerMin, "mbytesPerMin", mbytesPerMin,
"connsLimit", connsLimit, "mbytesLimit", mbytesLimit), null);
log.warn("port mapping {} auto-suspended (conns/min {} vs {}, MB/min {} vs {})",
mappingId, connsPerMin, connsLimit, mbytesPerMin, mbytesLimit);
}
+ /** The relay's public identifier, for the audit trail's target column. */
+ private java.util.UUID relayPublicId(long relayId) {
+ return jdbcTemplate.queryForObject("select public_id from relays where id = ?",
+ java.util.UUID.class, relayId);
+ }
+
// ── snapshot ─────────────────────────────────────────────────────────────
private RelaySyncResponse readSnapshot(long relayId, long validatedApplied,
diff --git a/src/main/java/kr/ac/pusan/pickle/relay/dto/AdminPortMappingResponse.java b/src/main/java/kr/ac/pusan/pickle/relay/dto/AdminPortMappingResponse.java
index 8798a17a..cefc9c7d 100644
--- a/src/main/java/kr/ac/pusan/pickle/relay/dto/AdminPortMappingResponse.java
+++ b/src/main/java/kr/ac/pusan/pickle/relay/dto/AdminPortMappingResponse.java
@@ -2,6 +2,7 @@
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.Instant;
+import java.util.UUID;
import kr.ac.pusan.pickle.relay.PortForwardApplyState;
import kr.ac.pusan.pickle.relay.PortMappingProto;
import kr.ac.pusan.pickle.relay.PortMappingStatus;
@@ -9,10 +10,10 @@
/** Contract schema {@code AdminPortMappingResponse} — one mapping with context. */
public record AdminPortMappingResponse(
- Long id,
- Long relayId,
+ UUID id,
+ UUID relayId,
String relayName,
- Long vmId,
+ UUID vmId,
@Nullable String vmName,
PortMappingProto proto,
int publicPort,
@@ -23,7 +24,7 @@ public record AdminPortMappingResponse(
String suspendedReason,
@Nullable
@Schema(description = "정지한 관리자 id (자동 정지면 null)")
- Long suspendedBy,
+ UUID suspendedBy,
PortForwardApplyState applyState,
@Nullable
@Schema(description = "동시 연결 상한 오버라이드 (null = 에이전트 기본, 0 = 해제)")
@@ -40,6 +41,6 @@ public record AdminPortMappingResponse(
@Nullable
@Schema(description = "출발지별 버스트 오버라이드")
Integer perSourceBurst,
- Long createdBy,
+ @Nullable UUID createdBy,
Instant createdAt) {
}
diff --git a/src/main/java/kr/ac/pusan/pickle/relay/dto/AdminRelayView.java b/src/main/java/kr/ac/pusan/pickle/relay/dto/AdminRelayView.java
index af2a061a..22a79d00 100644
--- a/src/main/java/kr/ac/pusan/pickle/relay/dto/AdminRelayView.java
+++ b/src/main/java/kr/ac/pusan/pickle/relay/dto/AdminRelayView.java
@@ -2,6 +2,7 @@
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.Instant;
+import java.util.UUID;
import org.jspecify.annotations.Nullable;
/**
@@ -10,7 +11,7 @@
* are claims reported by the relay agent, not measurements.
*/
public record AdminRelayView(
- Long id,
+ UUID id,
String name,
@Nullable
@Schema(description = "사용자 접속용 공개 호스트 (설정 전이면 null)")
diff --git a/src/main/java/kr/ac/pusan/pickle/relay/dto/PortForwardingView.java b/src/main/java/kr/ac/pusan/pickle/relay/dto/PortForwardingView.java
index 2b3959d6..f2b6b6d3 100644
--- a/src/main/java/kr/ac/pusan/pickle/relay/dto/PortForwardingView.java
+++ b/src/main/java/kr/ac/pusan/pickle/relay/dto/PortForwardingView.java
@@ -2,6 +2,7 @@
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.Instant;
+import java.util.UUID;
import kr.ac.pusan.pickle.relay.PortForwardApplyState;
import kr.ac.pusan.pickle.relay.PortMappingProto;
import kr.ac.pusan.pickle.relay.PortMappingStatus;
@@ -9,7 +10,7 @@
/** Contract schema {@code PortForwardingView} — one VM port forwarding. */
public record PortForwardingView(
- Long id,
+ UUID id,
@Schema(description = "프로토콜 (tcp | udp)")
PortMappingProto proto,
@Schema(description = "릴레이에서 열린 공개 포트")
diff --git a/src/main/java/kr/ac/pusan/pickle/relay/dto/RelayTokenResponse.java b/src/main/java/kr/ac/pusan/pickle/relay/dto/RelayTokenResponse.java
index 6e44a29c..db2432bd 100644
--- a/src/main/java/kr/ac/pusan/pickle/relay/dto/RelayTokenResponse.java
+++ b/src/main/java/kr/ac/pusan/pickle/relay/dto/RelayTokenResponse.java
@@ -1,6 +1,7 @@
package kr.ac.pusan.pickle.relay.dto;
import io.swagger.v3.oas.annotations.media.Schema;
+import java.util.UUID;
/**
* Contract op {@code issueAdminRelayToken} response. The plaintext token is
@@ -8,7 +9,7 @@
* again (re-issue replaces it).
*/
public record RelayTokenResponse(
- Long relayId,
+ UUID relayId,
@Schema(description = "새 동기화 토큰(64자 hex). 이 응답에서만 확인 가능하며 저장되지 않습니다")
String token) {
}
diff --git a/src/main/java/kr/ac/pusan/pickle/request/Request.java b/src/main/java/kr/ac/pusan/pickle/request/Request.java
index 04e56577..cffe2971 100644
--- a/src/main/java/kr/ac/pusan/pickle/request/Request.java
+++ b/src/main/java/kr/ac/pusan/pickle/request/Request.java
@@ -10,6 +10,7 @@
import jakarta.persistence.Table;
import java.time.Instant;
import java.time.LocalDate;
+import java.util.UUID;
import kr.ac.pusan.pickle.access.ResourceType;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.JdbcTypeCode;
@@ -31,6 +32,14 @@ public class Request {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
+ /**
+ * The identifier this row wears outside the API boundary. Internal joins,
+ * sorts and foreign keys keep using {@link #id}.
+ */
+ @JdbcTypeCode(SqlTypes.UUID)
+ @Column(name = "public_id", nullable = false, updatable = false, unique = true)
+ private UUID publicId = UUID.randomUUID();
+
@Enumerated(EnumType.STRING)
@JdbcTypeCode(SqlTypes.NAMED_ENUM)
@Column(name = "resource_type", nullable = false, columnDefinition = "resource_type")
@@ -99,6 +108,10 @@ public Long getId() {
return id;
}
+ public UUID getPublicId() {
+ return publicId;
+ }
+
public ResourceType getResourceType() {
return resourceType;
}
diff --git a/src/main/java/kr/ac/pusan/pickle/request/RequestAssembler.java b/src/main/java/kr/ac/pusan/pickle/request/RequestAssembler.java
index e5867bb2..6a0f48c4 100644
--- a/src/main/java/kr/ac/pusan/pickle/request/RequestAssembler.java
+++ b/src/main/java/kr/ac/pusan/pickle/request/RequestAssembler.java
@@ -7,7 +7,14 @@
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;
+import java.util.UUID;
import kr.ac.pusan.pickle.access.ResourceType;
+import kr.ac.pusan.pickle.inventory.Node;
+import kr.ac.pusan.pickle.inventory.NodeRepository;
+import kr.ac.pusan.pickle.inventory.OsImage;
+import kr.ac.pusan.pickle.inventory.OsImageRepository;
+import kr.ac.pusan.pickle.inventory.VmFlavor;
+import kr.ac.pusan.pickle.inventory.VmFlavorRepository;
import kr.ac.pusan.pickle.workspace.Workspace;
import kr.ac.pusan.pickle.workspace.WorkspaceRepository;
import kr.ac.pusan.pickle.orgs.Org;
@@ -34,15 +41,23 @@ public class RequestAssembler {
private final WorkspaceRepository workspaceRepository;
private final OrgRepository orgRepository;
private final UserRepository userRepository;
+ private final OsImageRepository osImageRepository;
+ private final VmFlavorRepository vmFlavorRepository;
+ private final NodeRepository nodeRepository;
public RequestAssembler(RequestReviewRepository reviewRepository,
VmRequestDetailRepository vmDetailRepository, WorkspaceRepository workspaceRepository,
- OrgRepository orgRepository, UserRepository userRepository) {
+ OrgRepository orgRepository, UserRepository userRepository,
+ OsImageRepository osImageRepository, VmFlavorRepository vmFlavorRepository,
+ NodeRepository nodeRepository) {
this.reviewRepository = reviewRepository;
this.vmDetailRepository = vmDetailRepository;
this.workspaceRepository = workspaceRepository;
this.orgRepository = orgRepository;
this.userRepository = userRepository;
+ this.osImageRepository = osImageRepository;
+ this.vmFlavorRepository = vmFlavorRepository;
+ this.nodeRepository = nodeRepository;
}
public RequestDetailResponse toDetail(Request request) {
@@ -71,6 +86,19 @@ public List toDetails(List requests) {
.findByRequestIdIn(idsOfType(requests, ResourceType.VM)).stream()
.collect(Collectors.toMap(VmRequestDetail::getRequestId, Function.identity()));
+ // The per-type spec reports its catalog references by public id, so the
+ // rows behind them are batched here beside the display-name joins.
+ Map imageIds = publicIds(osImageRepository.findAllById(ids(Stream.concat(
+ vmDetails.values().stream().map(VmRequestDetail::getImageId),
+ vmDetails.values().stream().map(VmRequestDetail::getGrantedImageId)))),
+ OsImage::getId, OsImage::getPublicId);
+ Map flavorIds = publicIds(vmFlavorRepository.findAllById(ids(
+ vmDetails.values().stream().map(VmRequestDetail::getFlavorId))),
+ VmFlavor::getId, VmFlavor::getPublicId);
+ Map nodeIds = publicIds(nodeRepository.findAllById(ids(
+ vmDetails.values().stream().map(VmRequestDetail::getNodeId))),
+ Node::getId, Node::getPublicId);
+
List details = new ArrayList<>(requests.size());
for (Request request : requests) {
RequestReview review = reviews.get(request.getId());
@@ -79,18 +107,24 @@ public List toDetails(List requests) {
User requester = users.get(request.getRequesterId());
VmRequestDetail vmDetail = vmDetails.get(request.getId());
details.add(new RequestDetailResponse(
- request.getId(),
+ request.getPublicId(),
request.getResourceType(),
- request.getWorkspaceId(), workspace != null ? workspace.getName() : null,
- request.getOrgId(), org != null ? org.getName() : null,
- request.getRequesterId(), requester != null ? requester.getName() : "탈퇴 회원",
+ workspace != null ? workspace.getPublicId() : null,
+ workspace != null ? workspace.getName() : null,
+ org != null ? org.getPublicId() : null, org != null ? org.getName() : null,
+ requester != null ? requester.getPublicId() : null,
+ requester != null ? requester.getName() : "탈퇴 회원",
request.getPurpose(), request.getCourseOrProject(), request.getExtraNote(),
request.getReqStartDate(), request.getReqEndDate(), request.getDisplayName(),
request.getStatus(),
review != null
? RequestReviewResponse.from(review, users.get(review.getReviewerId()))
: null,
- vmDetail != null ? VmRequestSpecResponse.from(vmDetail) : null,
+ vmDetail != null ? VmRequestSpecResponse.from(vmDetail,
+ imageIds.get(vmDetail.getImageId()),
+ flavorIds.get(vmDetail.getFlavorId()),
+ imageIds.get(vmDetail.getGrantedImageId()),
+ nodeIds.get(vmDetail.getNodeId())) : null,
request.getCreatedAt(), request.getUpdatedAt()));
}
return details;
@@ -103,8 +137,13 @@ private static List idsOfType(List requests, ResourceType type) {
.toList();
}
+ private static Map publicIds(List rows, Function idOf,
+ Function publicIdOf) {
+ return rows.stream().collect(Collectors.toMap(idOf, publicIdOf));
+ }
+
private static Set ids(Stream stream) {
- return stream.collect(Collectors.toSet());
+ return stream.filter(java.util.Objects::nonNull).collect(Collectors.toSet());
}
private static Map byId(List entities, Function idOf) {
diff --git a/src/main/java/kr/ac/pusan/pickle/request/RequestController.java b/src/main/java/kr/ac/pusan/pickle/request/RequestController.java
index 18fb02b6..e83e1470 100644
--- a/src/main/java/kr/ac/pusan/pickle/request/RequestController.java
+++ b/src/main/java/kr/ac/pusan/pickle/request/RequestController.java
@@ -6,6 +6,7 @@
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
+import java.util.UUID;
import kr.ac.pusan.pickle.common.web.PageResponse;
import kr.ac.pusan.pickle.access.ResourceType;
import kr.ac.pusan.pickle.security.AuthenticatedUser;
@@ -47,7 +48,7 @@ public PageResponse listRequests(
@AuthenticationPrincipal AuthenticatedUser principal,
@RequestParam(required = false) RequestStatus status,
@RequestParam(required = false) ResourceType type,
- @RequestParam(required = false) Long workspaceId,
+ @RequestParam(required = false) UUID workspaceId,
@RequestParam(defaultValue = "0") @Min(0) int page,
@RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) {
return requestService.list(principal, status, type, workspaceId, page, size);
@@ -55,13 +56,13 @@ public PageResponse listRequests(
@GetMapping("/{requestId}")
public RequestDetailResponse getRequest(@AuthenticationPrincipal AuthenticatedUser principal,
- @PathVariable long requestId) {
+ @PathVariable UUID requestId) {
return requestService.get(principal, requestId);
}
@PostMapping("/{requestId}/cancel")
public RequestDetailResponse cancelRequest(@AuthenticationPrincipal AuthenticatedUser principal,
- @PathVariable long requestId,
+ @PathVariable UUID requestId,
HttpServletRequest httpRequest) {
return requestService.cancel(principal, requestId, clientIp(httpRequest));
}
diff --git a/src/main/java/kr/ac/pusan/pickle/request/RequestRepository.java b/src/main/java/kr/ac/pusan/pickle/request/RequestRepository.java
index ea9247a3..f08dba8e 100644
--- a/src/main/java/kr/ac/pusan/pickle/request/RequestRepository.java
+++ b/src/main/java/kr/ac/pusan/pickle/request/RequestRepository.java
@@ -3,6 +3,7 @@
import jakarta.persistence.LockModeType;
import java.util.List;
import java.util.Optional;
+import java.util.UUID;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
@@ -13,13 +14,16 @@
public interface RequestRepository extends JpaRepository,
JpaSpecificationExecutor {
+ /** Resolution of the identifier this row wears outside the API boundary. */
+ Optional findByPublicId(UUID publicId);
+
/**
* Locked lookup for decision/cancel mutations: concurrent decisions on the
* same request serialize, so exactly one wins and the rest see 409.
*/
@Lock(LockModeType.PESSIMISTIC_WRITE)
- @Query("select r from Request r where r.id = :id")
- Optional findWithLockById(@Param("id") Long id);
+ @Query("select r from Request r where r.publicId = :publicId")
+ Optional findWithLockByPublicId(@Param("publicId") UUID publicId);
/** All requests of a workspace in a given status (workspace-delete cancels its SUBMITTED ones). */
List findByWorkspaceIdAndStatus(Long workspaceId, RequestStatus status);
diff --git a/src/main/java/kr/ac/pusan/pickle/request/RequestService.java b/src/main/java/kr/ac/pusan/pickle/request/RequestService.java
index e67dcfe4..07245279 100644
--- a/src/main/java/kr/ac/pusan/pickle/request/RequestService.java
+++ b/src/main/java/kr/ac/pusan/pickle/request/RequestService.java
@@ -4,6 +4,7 @@
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
+import java.util.UUID;
import java.util.function.Function;
import java.util.stream.Collectors;
import kr.ac.pusan.pickle.access.ResourceType;
@@ -81,7 +82,7 @@ private RequestTypeHandler handlerFor(ResourceType type) {
public RequestDetailResponse create(AuthenticatedUser actor, CreateRequestRequest form, String ip) {
RequestTypeHandler handler = handlerFor(form.type());
// A soft-deleted workspace cannot receive new requests.
- Workspace workspace = workspaceRepository.findByIdAndDeletedAtIsNull(form.workspaceId())
+ Workspace workspace = workspaceRepository.findByPublicIdAndDeletedAtIsNull(form.workspaceId())
.orElseThrow(() -> notFound("해당 워크스페이스가 존재하지 않습니다."));
// Any member may ask. The rung that used to gate this was really about
// reaching VMs, which is now the access list's business, and asking is
@@ -89,7 +90,7 @@ public RequestDetailResponse create(AuthenticatedUser actor, CreateRequestReques
workspaceMemberRepository.findByWorkspaceIdAndUserId(workspace.getId(), actor.id())
.orElseThrow(RequestService::notWorkspaceMember);
- Org org = orgRepository.findById(form.orgId())
+ Org org = orgRepository.findByPublicId(form.orgId())
.orElseThrow(() -> notFound("해당 기관이 존재하지 않습니다."));
if (org.getStatus() != OrgStatus.ACTIVE) {
throw ApiException.validationFailed(List.of(new FieldValidationError("orgId",
@@ -115,30 +116,35 @@ public RequestDetailResponse create(AuthenticatedUser actor, CreateRequestReques
auditArgs.put("orgId", org.getId());
auditArgs.putAll(handler.submitAuditArgs(saved));
auditService.record(actor.id(), actor.role().name(), AuditService.REQUEST_CREATE,
- "request", saved.getId(), auditArgs, ip);
+ "request", saved.getPublicId(), auditArgs, ip);
// In-tx inserts: the notices exist iff the request row committed.
notificationService.publish(actor.id(), NotificationEvent.REQUEST_SUBMITTED,
- Map.of("requestId", saved.getId(), "workspaceName", workspace.getName(),
+ Map.of("requestId", saved.getPublicId(), "workspaceName", workspace.getName(),
"purpose", saved.getPurpose(), "type", form.type().name()), null);
notificationService.publish(
notificationService.orgAdminIds(org.getId()).stream()
.filter(adminId -> !adminId.equals(actor.id())).toList(),
NotificationEvent.REQUEST_SUBMITTED,
- Map.of("requestId", saved.getId(), "workspaceName", workspace.getName(),
+ Map.of("requestId", saved.getPublicId(), "workspaceName", workspace.getName(),
"purpose", saved.getPurpose(), "type", form.type().name(), "admin", true), null);
return assembler.toDetail(saved);
}
@Transactional(readOnly = true)
public PageResponse list(AuthenticatedUser actor, RequestStatus status,
- ResourceType type, Long workspaceId, int page, int size) {
+ ResourceType type, UUID workspaceId, int page, int size) {
Specification spec;
if (workspaceId != null) {
- if (workspaceMemberRepository.findByWorkspaceIdAndUserId(workspaceId, actor.id()).isEmpty()) {
+ // Unknown id and one I am not a member of answer the same 403, so a
+ // workspace's existence stays private here as it did before.
+ Long scopedWorkspaceId = workspaceRepository.findByPublicId(workspaceId)
+ .map(Workspace::getId).orElse(null);
+ if (scopedWorkspaceId == null || workspaceMemberRepository
+ .findByWorkspaceIdAndUserId(scopedWorkspaceId, actor.id()).isEmpty()) {
throw new ApiException(HttpStatus.FORBIDDEN, ErrorCodes.ACCESS_DENIED,
"접근 권한이 없습니다", "해당 워크스페이스의 신청을 조회할 권한이 없습니다.");
}
- spec = RequestSpecs.workspace(workspaceId);
+ spec = RequestSpecs.workspace(scopedWorkspaceId);
} else {
spec = RequestSpecs.visibleTo(actor.id(), myWorkspaceIds(actor));
}
@@ -153,8 +159,8 @@ public PageResponse list(AuthenticatedUser actor, Request
}
@Transactional(readOnly = true)
- public RequestDetailResponse get(AuthenticatedUser actor, long requestId) {
- Request request = requestRepository.findById(requestId)
+ public RequestDetailResponse get(AuthenticatedUser actor, UUID requestId) {
+ Request request = requestRepository.findByPublicId(requestId)
.orElseThrow(() -> notFound("해당 신청이 존재하지 않습니다."));
boolean participant = request.getRequesterId().equals(actor.id())
|| workspaceMemberRepository.findByWorkspaceIdAndUserId(request.getWorkspaceId(), actor.id()).isPresent();
@@ -166,8 +172,8 @@ public RequestDetailResponse get(AuthenticatedUser actor, long requestId) {
}
@Transactional
- public RequestDetailResponse cancel(AuthenticatedUser actor, long requestId, String ip) {
- Request request = requestRepository.findWithLockById(requestId)
+ public RequestDetailResponse cancel(AuthenticatedUser actor, UUID requestId, String ip) {
+ Request request = requestRepository.findWithLockByPublicId(requestId)
.orElseThrow(() -> notFound("해당 신청이 존재하지 않습니다."));
boolean requester = request.getRequesterId().equals(actor.id());
boolean workspaceOwner = workspaceMemberRepository
@@ -185,7 +191,7 @@ public RequestDetailResponse cancel(AuthenticatedUser actor, long requestId, Str
}
request.setStatus(RequestStatus.CANCELED);
auditService.record(actor.id(), actor.role().name(), AuditService.REQUEST_CANCEL,
- "request", request.getId(), Map.of("workspaceId", request.getWorkspaceId()), ip);
+ "request", request.getPublicId(), Map.of("workspaceId", request.getWorkspaceId()), ip);
return assembler.toDetail(request);
}
diff --git a/src/main/java/kr/ac/pusan/pickle/request/dto/CreateRequestRequest.java b/src/main/java/kr/ac/pusan/pickle/request/dto/CreateRequestRequest.java
index f33fe5ce..2ab1faba 100644
--- a/src/main/java/kr/ac/pusan/pickle/request/dto/CreateRequestRequest.java
+++ b/src/main/java/kr/ac/pusan/pickle/request/dto/CreateRequestRequest.java
@@ -5,6 +5,7 @@
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.time.LocalDate;
+import java.util.UUID;
import kr.ac.pusan.pickle.access.ResourceType;
import kr.ac.pusan.pickle.request.vm.dto.CreateVmRequestSpec;
import org.jspecify.annotations.Nullable;
@@ -24,10 +25,10 @@ public record CreateRequestRequest(
ResourceType type,
@NotNull(message = "신청 워크스페이스(workspaceId)를 지정해 주세요.")
- Long workspaceId,
+ UUID workspaceId,
@NotNull(message = "기관(orgId)을 지정해 주세요.")
- Long orgId,
+ UUID orgId,
@NotBlank(message = "사용 목적을 입력해 주세요.")
@Size(max = 2000, message = "사용 목적은 2000자 이하여야 합니다.")
diff --git a/src/main/java/kr/ac/pusan/pickle/request/dto/RequestDetailResponse.java b/src/main/java/kr/ac/pusan/pickle/request/dto/RequestDetailResponse.java
index 48dd7384..9b0c4beb 100644
--- a/src/main/java/kr/ac/pusan/pickle/request/dto/RequestDetailResponse.java
+++ b/src/main/java/kr/ac/pusan/pickle/request/dto/RequestDetailResponse.java
@@ -1,7 +1,9 @@
package kr.ac.pusan.pickle.request.dto;
+import io.swagger.v3.oas.annotations.media.Schema;
import java.time.Instant;
import java.time.LocalDate;
+import java.util.UUID;
import kr.ac.pusan.pickle.access.ResourceType;
import kr.ac.pusan.pickle.request.RequestStatus;
import kr.ac.pusan.pickle.request.vm.dto.VmRequestSpecResponse;
@@ -13,13 +15,16 @@
* populated: the one named by {@code type}.
*/
public record RequestDetailResponse(
- Long id,
+ UUID id,
ResourceType type,
- Long workspaceId,
+ @Schema(description = "신청 대상 워크스페이스. 행이 사라진 경우에만 null입니다.")
+ @Nullable UUID workspaceId,
String workspaceName,
- Long orgId,
+ @Schema(description = "신청 대상 기관. 행이 사라진 경우에만 null입니다.")
+ @Nullable UUID orgId,
String orgName,
- Long requesterId,
+ @Schema(description = "신청자. 행이 사라진 경우에만 null입니다.")
+ @Nullable UUID requesterId,
String requesterName,
String purpose,
@Nullable String courseOrProject,
diff --git a/src/main/java/kr/ac/pusan/pickle/request/dto/RequestReviewResponse.java b/src/main/java/kr/ac/pusan/pickle/request/dto/RequestReviewResponse.java
index 81764da8..69ee575c 100644
--- a/src/main/java/kr/ac/pusan/pickle/request/dto/RequestReviewResponse.java
+++ b/src/main/java/kr/ac/pusan/pickle/request/dto/RequestReviewResponse.java
@@ -2,6 +2,8 @@
import java.time.Instant;
import java.time.LocalDate;
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.util.UUID;
import kr.ac.pusan.pickle.user.User;
import kr.ac.pusan.pickle.request.ReviewDecision;
import kr.ac.pusan.pickle.request.RequestReview;
@@ -13,7 +15,8 @@
* request's per-type member, since only the period is common to every type.
*/
public record RequestReviewResponse(
- Long reviewerId,
+ @Schema(description = "결재자. 계정 행이 사라진 경우에만 null입니다.")
+ @Nullable UUID reviewerId,
String reviewerName,
ReviewDecision decision,
@Nullable String comment,
@@ -22,7 +25,7 @@ public record RequestReviewResponse(
Instant decidedAt) {
public static RequestReviewResponse from(RequestReview review, User reviewer) {
- return new RequestReviewResponse(review.getReviewerId(),
+ return new RequestReviewResponse(reviewer != null ? reviewer.getPublicId() : null,
reviewer != null ? reviewer.getName() : "탈퇴 회원",
review.getDecision(), review.getComment(),
review.getGrantedStartDate(), review.getGrantedEndDate(), review.getCreatedAt());
diff --git a/src/main/java/kr/ac/pusan/pickle/request/vm/VmRequestSupport.java b/src/main/java/kr/ac/pusan/pickle/request/vm/VmRequestSupport.java
index 587a3524..88b44cd9 100644
--- a/src/main/java/kr/ac/pusan/pickle/request/vm/VmRequestSupport.java
+++ b/src/main/java/kr/ac/pusan/pickle/request/vm/VmRequestSupport.java
@@ -81,9 +81,9 @@ public void validateCreate(CreateRequestRequest form, List
// A reference to something that does not exist is a 404 here, as it is
// for the workspace and the organisation; only a row that exists but may
// no longer be chosen is a validation error.
- OsImage image = imageRepository.findById(spec.imageId())
+ OsImage image = imageRepository.findByPublicId(spec.imageId())
.orElseThrow(() -> notFound("해당 OS 이미지가 존재하지 않습니다."));
- VmFlavor flavor = flavorRepository.findById(spec.flavorId())
+ VmFlavor flavor = flavorRepository.findByPublicId(spec.flavorId())
.orElseThrow(() -> notFound("해당 사양 프리셋이 존재하지 않습니다."));
boolean axesActive = true;
if (image.getStatus() != CatalogStatus.ACTIVE) {
@@ -103,7 +103,10 @@ public void validateCreate(CreateRequestRequest form, List
@Override
public void saveDetail(Request request, CreateRequestRequest form) {
CreateVmRequestSpec spec = form.vm();
- detailRepository.save(new VmRequestDetail(request.getId(), spec.imageId(), spec.flavorId(),
+ // validateCreate already 404'd on an unknown reference, so both resolve.
+ long imageId = imageRepository.findByPublicId(spec.imageId()).orElseThrow().getId();
+ long flavorId = flavorRepository.findByPublicId(spec.flavorId()).orElseThrow().getId();
+ detailRepository.save(new VmRequestDetail(request.getId(), imageId, flavorId,
spec.reqVcpu(), spec.reqMemoryMb(), spec.reqDiskGb(),
Texts.blankToNull(spec.specReason()), Texts.blankToNull(spec.desiredSlug())));
}
@@ -123,7 +126,7 @@ public void validateApprove(Request request, ApproveRequestRequest form,
errors.add(new FieldValidationError("vm", "VM 승인 항목(vm)을 입력해 주세요."));
return;
}
- OsImage image = imageRepository.findById(spec.grantedImageId()).orElse(null);
+ OsImage image = imageRepository.findByPublicId(spec.grantedImageId()).orElse(null);
if (image == null || image.getStatus() != CatalogStatus.ACTIVE) {
errors.add(new FieldValidationError("vm.grantedImageId", "사용할 수 없는 OS 이미지입니다."));
} else if (spec.grantedDiskGb() < image.getMinDiskGb()) {
@@ -131,10 +134,12 @@ public void validateApprove(Request request, ApproveRequestRequest form,
"이 OS 이미지의 최소 디스크 크기는 " + image.getMinDiskGb() + "GiB입니다."));
}
if (spec.nodeId() != null) {
- if (!nodeRepository.existsById(spec.nodeId())) {
+ Long nodeId = nodeRepository.findByPublicId(spec.nodeId())
+ .map(kr.ac.pusan.pickle.inventory.Node::getId).orElse(null);
+ if (nodeId == null) {
errors.add(new FieldValidationError("vm.nodeId", "존재하지 않는 노드입니다."));
} else if (image != null && !imageRepository.existsByNameAndNodeIdAndStatus(
- image.getName(), spec.nodeId(), CatalogStatus.ACTIVE)) {
+ image.getName(), nodeId, CatalogStatus.ACTIVE)) {
// Forced node must host the granted image — the provisioning
// pipeline clones the image on the placed node, so a node without it
// guarantees a mid-pipeline clone failure.
@@ -162,14 +167,17 @@ public void validateApprove(Request request, ApproveRequestRequest form,
@Override
public Materialized materialize(Request request, ApproveRequestRequest form, AuthenticatedUser actor) {
ApproveVmRequestSpec spec = form.vm();
- OsImage image = imageRepository.findById(spec.grantedImageId()).orElseThrow();
+ OsImage image = imageRepository.findByPublicId(spec.grantedImageId()).orElseThrow();
VmRequestDetail detail = detail(request);
+ Long forcedNodeId = spec.nodeId() == null ? null
+ : nodeRepository.findByPublicId(spec.nodeId())
+ .map(kr.ac.pusan.pickle.inventory.Node::getId).orElseThrow();
detail.grant(spec.grantedVcpu(), spec.grantedMemoryMb(), spec.grantedDiskGb(),
- image.getId(), spec.nodeId());
+ image.getId(), forcedNodeId);
// Auto placement: the image's node (single-node cluster; the
// scoring placement step arrives with the provisioning pipeline).
- Long nodeId = spec.nodeId() != null ? spec.nodeId() : image.getNodeId();
+ Long nodeId = forcedNodeId != null ? forcedNodeId : image.getNodeId();
String grantedSlug = Texts.blankToNull(spec.grantedSlug());
String hostname = grantedSlug != null ? grantedSlug
: generateHostname(VmSlugPolicy.sanitizeSeed(request.getDisplayName(),
diff --git a/src/main/java/kr/ac/pusan/pickle/request/vm/dto/CreateVmRequestSpec.java b/src/main/java/kr/ac/pusan/pickle/request/vm/dto/CreateVmRequestSpec.java
index 22632d15..ba233438 100644
--- a/src/main/java/kr/ac/pusan/pickle/request/vm/dto/CreateVmRequestSpec.java
+++ b/src/main/java/kr/ac/pusan/pickle/request/vm/dto/CreateVmRequestSpec.java
@@ -4,6 +4,7 @@
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
+import java.util.UUID;
import org.jspecify.annotations.Nullable;
/**
@@ -13,10 +14,10 @@
*/
public record CreateVmRequestSpec(
@NotNull(message = "OS 이미지(imageId)를 지정해 주세요.")
- Long imageId,
+ UUID imageId,
@NotNull(message = "사양 프리셋(flavorId)을 지정해 주세요.")
- Long flavorId,
+ UUID flavorId,
@NotNull(message = "요청 vCPU 수를 입력해 주세요.")
@Min(value = 1, message = "vCPU는 1 이상이어야 합니다.")
diff --git a/src/main/java/kr/ac/pusan/pickle/request/vm/dto/VmGrantedSpecResponse.java b/src/main/java/kr/ac/pusan/pickle/request/vm/dto/VmGrantedSpecResponse.java
index 3acf0402..34410dce 100644
--- a/src/main/java/kr/ac/pusan/pickle/request/vm/dto/VmGrantedSpecResponse.java
+++ b/src/main/java/kr/ac/pusan/pickle/request/vm/dto/VmGrantedSpecResponse.java
@@ -1,12 +1,13 @@
package kr.ac.pusan.pickle.request.vm.dto;
import org.jspecify.annotations.Nullable;
+import java.util.UUID;
/** Contract schema {@code VmGrantedSpec}: what the reviewer granted for a VM. */
public record VmGrantedSpecResponse(
Integer grantedVcpu,
Integer grantedMemoryMb,
Integer grantedDiskGb,
- Long grantedImageId,
- @Nullable Long nodeId) {
+ UUID grantedImageId,
+ @Nullable UUID nodeId) {
}
diff --git a/src/main/java/kr/ac/pusan/pickle/request/vm/dto/VmRequestSpecResponse.java b/src/main/java/kr/ac/pusan/pickle/request/vm/dto/VmRequestSpecResponse.java
index 79bbf700..22683ac8 100644
--- a/src/main/java/kr/ac/pusan/pickle/request/vm/dto/VmRequestSpecResponse.java
+++ b/src/main/java/kr/ac/pusan/pickle/request/vm/dto/VmRequestSpecResponse.java
@@ -1,6 +1,7 @@
package kr.ac.pusan.pickle.request.vm.dto;
import kr.ac.pusan.pickle.request.vm.VmRequestDetail;
+import java.util.UUID;
import org.jspecify.annotations.Nullable;
/**
@@ -9,8 +10,8 @@
* request is approved.
*/
public record VmRequestSpecResponse(
- Long imageId,
- @Nullable Long flavorId,
+ UUID imageId,
+ @Nullable UUID flavorId,
int reqVcpu,
int reqMemoryMb,
int reqDiskGb,
@@ -20,12 +21,13 @@ public record VmRequestSpecResponse(
@Nullable String rootDomain,
@Nullable VmGrantedSpecResponse granted) {
- public static VmRequestSpecResponse from(VmRequestDetail detail) {
+ public static VmRequestSpecResponse from(VmRequestDetail detail, UUID imageId, UUID flavorId,
+ UUID grantedImageId, UUID nodeId) {
VmGrantedSpecResponse granted = detail.getGrantedVcpu() != null
? new VmGrantedSpecResponse(detail.getGrantedVcpu(), detail.getGrantedMemoryMb(),
- detail.getGrantedDiskGb(), detail.getGrantedImageId(), detail.getNodeId())
+ detail.getGrantedDiskGb(), grantedImageId, nodeId)
: null;
- return new VmRequestSpecResponse(detail.getImageId(), detail.getFlavorId(),
+ return new VmRequestSpecResponse(imageId, flavorId,
detail.getReqVcpu(), detail.getReqMemoryMb(), detail.getReqDiskGb(),
detail.getSpecReason(), detail.getDesiredSlug(), detail.getDesiredSubdomain(),
detail.getRootDomain(), granted);
diff --git a/src/main/java/kr/ac/pusan/pickle/resource/ResourceIdentity.java b/src/main/java/kr/ac/pusan/pickle/resource/ResourceIdentity.java
index 2603bd17..0f46e1c4 100644
--- a/src/main/java/kr/ac/pusan/pickle/resource/ResourceIdentity.java
+++ b/src/main/java/kr/ac/pusan/pickle/resource/ResourceIdentity.java
@@ -1,5 +1,6 @@
package kr.ac.pusan.pickle.resource;
+import java.util.UUID;
import org.jspecify.annotations.Nullable;
/**
@@ -13,12 +14,13 @@
* cannot leak past the limited view even by accident.
*
* @param id the resource's id within its type
+ * @param publicId the identifier it wears outside the API boundary
* @param workspaceId the workspace that owns it, and whose owners hold standing
* rights over it
* @param name the name it is listed under
* @param displayName the name its owners gave it, or null if they gave none
* @param status this type's own state vocabulary, as a plain string
*/
-public record ResourceIdentity(long id, long workspaceId, String name,
+public record ResourceIdentity(long id, UUID publicId, long workspaceId, String name,
@Nullable String displayName, String status) {
}
diff --git a/src/main/java/kr/ac/pusan/pickle/resource/ResourceIndexController.java b/src/main/java/kr/ac/pusan/pickle/resource/ResourceIndexController.java
index 5710ca39..c199aab3 100644
--- a/src/main/java/kr/ac/pusan/pickle/resource/ResourceIndexController.java
+++ b/src/main/java/kr/ac/pusan/pickle/resource/ResourceIndexController.java
@@ -2,6 +2,7 @@
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
+import java.util.UUID;
import kr.ac.pusan.pickle.access.ResourceType;
import kr.ac.pusan.pickle.common.web.PageResponse;
import kr.ac.pusan.pickle.resource.dto.ResourceSummaryResponse;
@@ -27,7 +28,7 @@ public ResourceIndexController(ResourceIndexService resourceIndexService) {
public PageResponse listResources(
@AuthenticationPrincipal AuthenticatedUser principal,
@RequestParam(required = false) ResourceType type,
- @RequestParam(required = false) Long workspaceId,
+ @RequestParam(required = false) UUID workspaceId,
@RequestParam(defaultValue = "0") @Min(0) int page,
@RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) {
return resourceIndexService.list(principal, type, workspaceId, page, size);
diff --git a/src/main/java/kr/ac/pusan/pickle/resource/ResourceIndexService.java b/src/main/java/kr/ac/pusan/pickle/resource/ResourceIndexService.java
index 7a310e7b..264d71e6 100644
--- a/src/main/java/kr/ac/pusan/pickle/resource/ResourceIndexService.java
+++ b/src/main/java/kr/ac/pusan/pickle/resource/ResourceIndexService.java
@@ -2,6 +2,7 @@
import java.util.List;
import java.util.Map;
+import java.util.UUID;
import java.util.function.Function;
import java.util.stream.Collectors;
import kr.ac.pusan.pickle.access.ResourceType;
@@ -38,7 +39,7 @@ public ResourceIndexService(List adapters) {
@Transactional(readOnly = true)
public PageResponse list(AuthenticatedUser actor, ResourceType type,
- Long workspaceId, int page, int size) {
+ UUID workspaceId, int page, int size) {
// Newest first by creation time rather than by id: an id is an opaque
// handle, and ordering by it would break the day one stops being a
// number.
diff --git a/src/main/java/kr/ac/pusan/pickle/resource/ResourceTypeAdapter.java b/src/main/java/kr/ac/pusan/pickle/resource/ResourceTypeAdapter.java
index 22fc50f8..993d61ee 100644
--- a/src/main/java/kr/ac/pusan/pickle/resource/ResourceTypeAdapter.java
+++ b/src/main/java/kr/ac/pusan/pickle/resource/ResourceTypeAdapter.java
@@ -2,6 +2,7 @@
import java.util.List;
import java.util.Optional;
+import java.util.UUID;
import kr.ac.pusan.pickle.access.ResourceAccessAudit;
import kr.ac.pusan.pickle.access.ResourceAccessMessages;
import kr.ac.pusan.pickle.access.ResourceType;
@@ -39,6 +40,14 @@ public interface ResourceTypeAdapter {
*/
Optional identify(long resourceId);
+ /**
+ * The same, reached by the identifier the resource wears outside the API
+ * boundary. This is the form every request-scoped lookup takes: an id that
+ * arrived over HTTP is resolved here once, and everything below works on
+ * the internal id the returned identity carries.
+ */
+ Optional identifyByPublicId(UUID publicId);
+
/**
* What this type says when it refuses — the only part of the access rules
* that is allowed to differ between types.
@@ -74,5 +83,5 @@ public interface ResourceTypeAdapter {
* requester holds no grant on comes back limited rather than omitted, so
* they can see it exists and ask.
*/
- Page page(AuthenticatedUser actor, Long workspaceId, Pageable pageable);
+ Page page(AuthenticatedUser actor, UUID workspaceId, Pageable pageable);
}
diff --git a/src/main/java/kr/ac/pusan/pickle/resource/VmResourceAdapter.java b/src/main/java/kr/ac/pusan/pickle/resource/VmResourceAdapter.java
index 18f91777..eefc34a7 100644
--- a/src/main/java/kr/ac/pusan/pickle/resource/VmResourceAdapter.java
+++ b/src/main/java/kr/ac/pusan/pickle/resource/VmResourceAdapter.java
@@ -2,6 +2,8 @@
import java.util.List;
import java.util.Optional;
+import kr.ac.pusan.pickle.vm.Vm;
+import java.util.UUID;
import kr.ac.pusan.pickle.access.ResourceAccessAudit;
import kr.ac.pusan.pickle.access.ResourceAccessMessages;
import kr.ac.pusan.pickle.access.ResourceType;
@@ -64,10 +66,18 @@ public Optional identify(long resourceId) {
// No filter on status: a destroyed VM keeps its row and its access
// list, which is what lets the people who used it still read its
// history.
- return vmRepository.findById(resourceId)
- .map(vm -> new ResourceIdentity(vm.getId(), vm.getWorkspaceId(), vm.getName(),
- vmSettingsService.string(vm.getId(), VmSettingsService.DISPLAY_NAME),
- vm.getStatus().name()));
+ return vmRepository.findById(resourceId).map(this::identityOf);
+ }
+
+ @Override
+ public Optional identifyByPublicId(UUID publicId) {
+ return vmRepository.findByPublicId(publicId).map(this::identityOf);
+ }
+
+ private ResourceIdentity identityOf(Vm vm) {
+ return new ResourceIdentity(vm.getId(), vm.getPublicId(), vm.getWorkspaceId(), vm.getName(),
+ vmSettingsService.string(vm.getId(), VmSettingsService.DISPLAY_NAME),
+ vm.getStatus().name());
}
@Override
@@ -92,7 +102,7 @@ public long countLiveInWorkspace(long workspaceId) {
}
@Override
- public Page page(AuthenticatedUser actor, Long workspaceId,
+ public Page page(AuthenticatedUser actor, UUID workspaceId,
Pageable pageable) {
// Reuses the VM list rather than re-deriving visibility: the masking
// rules live in one place, so the inventory cannot drift into showing
diff --git a/src/main/java/kr/ac/pusan/pickle/resource/dto/ResourceSummaryResponse.java b/src/main/java/kr/ac/pusan/pickle/resource/dto/ResourceSummaryResponse.java
index da3060db..95c0962b 100644
--- a/src/main/java/kr/ac/pusan/pickle/resource/dto/ResourceSummaryResponse.java
+++ b/src/main/java/kr/ac/pusan/pickle/resource/dto/ResourceSummaryResponse.java
@@ -2,6 +2,7 @@
import java.time.Instant;
import java.util.List;
+import java.util.UUID;
import kr.ac.pusan.pickle.access.ResourceType;
import org.jspecify.annotations.Nullable;
@@ -20,12 +21,12 @@
* meaning neither side has.
*/
public record ResourceSummaryResponse(
- Long id,
+ UUID id,
ResourceType type,
String name,
@Nullable String displayName,
String status,
- Long workspaceId,
+ UUID workspaceId,
String workspaceName,
/** True when the caller may see that it exists but not what it is. */
boolean accessLimited,
diff --git a/src/main/java/kr/ac/pusan/pickle/security/AuthenticatedUser.java b/src/main/java/kr/ac/pusan/pickle/security/AuthenticatedUser.java
index 62544db9..870f4f7e 100644
--- a/src/main/java/kr/ac/pusan/pickle/security/AuthenticatedUser.java
+++ b/src/main/java/kr/ac/pusan/pickle/security/AuthenticatedUser.java
@@ -1,7 +1,15 @@
package kr.ac.pusan.pickle.security;
+import java.util.UUID;
import kr.ac.pusan.pickle.user.UserRole;
-/** Authenticated principal placed in the SecurityContext by the JWT filter. */
-public record AuthenticatedUser(Long id, String email, UserRole role, Long orgId) {
+/**
+ * Authenticated principal placed in the SecurityContext by the JWT filter.
+ *
+ *
{@code id} is the internal key every query joins on; {@code publicId} is
+ * the same account as the API names it, carried here so a response that has to
+ * report who acted does not need a second read of the row the filter already
+ * loaded.
+ */
+public record AuthenticatedUser(Long id, UUID publicId, String email, UserRole role, Long orgId) {
}
diff --git a/src/main/java/kr/ac/pusan/pickle/security/JwtAuthenticationFilter.java b/src/main/java/kr/ac/pusan/pickle/security/JwtAuthenticationFilter.java
index 8aa2790c..bbc1e015 100644
--- a/src/main/java/kr/ac/pusan/pickle/security/JwtAuthenticationFilter.java
+++ b/src/main/java/kr/ac/pusan/pickle/security/JwtAuthenticationFilter.java
@@ -8,6 +8,7 @@
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.List;
+import java.util.UUID;
import kr.ac.pusan.pickle.user.User;
import kr.ac.pusan.pickle.user.UserRepository;
import kr.ac.pusan.pickle.user.UserStatus;
@@ -50,12 +51,14 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse
private void authenticate(HttpServletRequest request, String token) {
try {
Claims claims = jwtService.parse(token);
- long userId = Long.parseLong(claims.getSubject());
+ // A subject that is not a UUID throws IllegalArgumentException, which
+ // the catch below already treats as an invalid token.
+ UUID userId = UUID.fromString(claims.getSubject());
Integer tokenVersion = claims.get(JwtService.CLAIM_TOKEN_VERSION, Integer.class);
if (tokenVersion == null) {
return;
}
- userRepository.findById(userId)
+ userRepository.findByPublicId(userId)
.filter(user -> user.getStatus() == UserStatus.ACTIVE)
.filter(user -> user.getTokenVersion() == tokenVersion)
.ifPresent(user -> setAuthentication(request, user));
@@ -67,7 +70,8 @@ private void authenticate(HttpServletRequest request, String token) {
private void setAuthentication(HttpServletRequest request, User user) {
AuthenticatedUser principal =
- new AuthenticatedUser(user.getId(), user.getEmail(), user.getRole(), user.getOrgId());
+ new AuthenticatedUser(user.getId(), user.getPublicId(), user.getEmail(),
+ user.getRole(), user.getOrgId());
var authentication = new UsernamePasswordAuthenticationToken(principal, null,
List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole().name())));
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
diff --git a/src/main/java/kr/ac/pusan/pickle/security/JwtService.java b/src/main/java/kr/ac/pusan/pickle/security/JwtService.java
index c87a37d9..012c98d6 100644
--- a/src/main/java/kr/ac/pusan/pickle/security/JwtService.java
+++ b/src/main/java/kr/ac/pusan/pickle/security/JwtService.java
@@ -14,13 +14,18 @@
/**
* Access token (JWT HS256, 15 min). Claims per contract securityScheme:
- * {@code sub}, {@code role}, {@code org_id}, {@code token_version}.
+ * {@code sub}, {@code role}, {@code token_version}.
+ *
+ *
{@code sub} is the account's public identifier. The token is readable by
+ * anyone holding it, so the internal key has no business in it — and neither
+ * did the organisation's, which is why {@code org_id} was dropped: it was
+ * written and never read, and it disclosed the org's sequential id to every
+ * client that decoded a token.
*/
@Component
public class JwtService {
public static final String CLAIM_ROLE = "role";
- public static final String CLAIM_ORG_ID = "org_id";
public static final String CLAIM_TOKEN_VERSION = "token_version";
private final SecretKey key;
@@ -43,16 +48,13 @@ public JwtService(JwtProperties properties) {
public String createAccessToken(User user) {
Instant now = Instant.now();
- var builder = Jwts.builder()
- .subject(String.valueOf(user.getId()))
+ return Jwts.builder()
+ .subject(user.getPublicId().toString())
.claim(CLAIM_ROLE, user.getRole().name())
.claim(CLAIM_TOKEN_VERSION, user.getTokenVersion())
.issuedAt(Date.from(now))
- .expiration(Date.from(now.plus(properties.accessTokenTtl())));
- if (user.getOrgId() != null) {
- builder.claim(CLAIM_ORG_ID, user.getOrgId());
- }
- return builder.signWith(key, Jwts.SIG.HS256).compact();
+ .expiration(Date.from(now.plus(properties.accessTokenTtl())))
+ .signWith(key, Jwts.SIG.HS256).compact();
}
/** @throws io.jsonwebtoken.JwtException when invalid/expired */
diff --git a/src/main/java/kr/ac/pusan/pickle/seed/DevDataSeeder.java b/src/main/java/kr/ac/pusan/pickle/seed/DevDataSeeder.java
index 7ef0976c..b4fecdc7 100644
--- a/src/main/java/kr/ac/pusan/pickle/seed/DevDataSeeder.java
+++ b/src/main/java/kr/ac/pusan/pickle/seed/DevDataSeeder.java
@@ -44,7 +44,6 @@ public class DevDataSeeder implements ApplicationRunner {
public static final int SEED_TEMPLATE_VMID = 1001;
public static final String ORG_NAME = "테스트 기관";
- public static final String ORG_SLUG = "test-org";
private final UserRepository userRepository;
private final OrgRepository orgRepository;
@@ -79,10 +78,10 @@ public void run(ApplicationArguments args) {
seedUser(properties.sysadminEmail(), properties.sysadminPassword(), "시스템 관리자",
UserRole.SYS_ADMIN, null);
- Org org = orgRepository.findBySlug(ORG_SLUG)
+ Org org = orgRepository.findFirstByNameOrderByIdAsc(ORG_NAME)
.orElseGet(() -> {
- log.info("Seeding org '{}' ({})", ORG_NAME, ORG_SLUG);
- Org seedOrg = new Org(ORG_NAME, ORG_SLUG, ORG_NAME + " (개발용 시드 기관)");
+ log.info("Seeding org '{}'", ORG_NAME);
+ Org seedOrg = new Org(ORG_NAME, ORG_NAME + " (개발용 시드 기관)");
seedOrg.setHidden(true);
return orgRepository.save(seedOrg);
});
diff --git a/src/main/java/kr/ac/pusan/pickle/sshgw/SshGatewayRouteService.java b/src/main/java/kr/ac/pusan/pickle/sshgw/SshGatewayRouteService.java
index a7628177..6509a722 100644
--- a/src/main/java/kr/ac/pusan/pickle/sshgw/SshGatewayRouteService.java
+++ b/src/main/java/kr/ac/pusan/pickle/sshgw/SshGatewayRouteService.java
@@ -4,6 +4,7 @@
import java.util.List;
import java.util.Map;
import java.util.Optional;
+import java.util.UUID;
import kr.ac.pusan.pickle.access.ResourceRole;
import kr.ac.pusan.pickle.access.VmAccessService;
import kr.ac.pusan.pickle.audit.AuditService;
@@ -211,9 +212,20 @@ private RouteOutcome deny(Context ctx, Long vmId, RouteOutcome outcome) {
/** Denials carry a null actor even after identification (see class javadoc). */
private void auditDenied(Context ctx, Long vmId, String reason) {
auditService.record(null, AuditService.ACTOR_ROLE_SSHGW, AuditService.SSHGW_ROUTE_DENIED,
- "vm", vmId, ctx.detail(reason), ctx.sourceIp());
+ "vm", vmPublicId(vmId), ctx.detail(reason), ctx.sourceIp());
}
+ /**
+ * The VM's public identifier for the audit trail. The gateway contract
+ * itself stays on the internal id (a Go client decodes it as int64), so
+ * the translation happens here, at the audit boundary.
+ */
+ private UUID vmPublicId(Long vmId) {
+ return vmId == null ? null
+ : vmRepository.findById(vmId).map(Vm::getPublicId).orElse(null);
+ }
+
+
/**
* Splits the newline-joined stored host keys into one entry per type. Shared
* with the web-terminal redeem step (the internal web-terminal contract) so both hops
diff --git a/src/main/java/kr/ac/pusan/pickle/sshgw/SshGatewaySessionService.java b/src/main/java/kr/ac/pusan/pickle/sshgw/SshGatewaySessionService.java
index 444c9934..2ac291cb 100644
--- a/src/main/java/kr/ac/pusan/pickle/sshgw/SshGatewaySessionService.java
+++ b/src/main/java/kr/ac/pusan/pickle/sshgw/SshGatewaySessionService.java
@@ -7,6 +7,7 @@
import java.util.List;
import java.util.Map;
import java.util.Set;
+import java.util.UUID;
import kr.ac.pusan.pickle.audit.AuditService;
import kr.ac.pusan.pickle.sshgw.dto.SessionRequest;
import kr.ac.pusan.pickle.sshkey.UserSshKey;
@@ -112,7 +113,7 @@ public void recordSession(SessionRequest request, String gatewayPeer) {
}
auditService.record(actorId, AuditService.ACTOR_ROLE_SSHGW, AuditService.SSHGW_SESSION,
- "vm", vmId, detail, request.sourceIp());
+ "vm", vmPublicId(vmId), detail, request.sourceIp());
} catch (RuntimeException e) {
// Fire-and-forget: never let an audit failure affect the live session.
log.warn("sshgw session audit failed (best-effort) for slug {}: {}",
@@ -185,4 +186,14 @@ private static List nonBlank(List values) {
}
return out;
}
+
+ /**
+ * The VM's public identifier for the audit trail. The gateway contract
+ * itself stays on the internal id (a Go client decodes it as int64), so
+ * the translation happens here, at the audit boundary.
+ */
+ private UUID vmPublicId(Long vmId) {
+ return vmId == null ? null
+ : vmRepository.findById(vmId).map(Vm::getPublicId).orElse(null);
+ }
}
diff --git a/src/main/java/kr/ac/pusan/pickle/sshkey/MeSshKeyController.java b/src/main/java/kr/ac/pusan/pickle/sshkey/MeSshKeyController.java
index 5bb662dc..8dc2cdb2 100644
--- a/src/main/java/kr/ac/pusan/pickle/sshkey/MeSshKeyController.java
+++ b/src/main/java/kr/ac/pusan/pickle/sshkey/MeSshKeyController.java
@@ -5,6 +5,7 @@
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import java.util.List;
+import java.util.UUID;
import kr.ac.pusan.pickle.security.AuthenticatedUser;
import kr.ac.pusan.pickle.security.RequireReauth;
import kr.ac.pusan.pickle.sshkey.dto.SshKeyCreateRequest;
@@ -61,7 +62,7 @@ public SshKeyView generateKey(@AuthenticationPrincipal AuthenticatedUser princip
@ResponseStatus(HttpStatus.NO_CONTENT)
@RequireReauth
public void deleteKey(@AuthenticationPrincipal AuthenticatedUser principal,
- @PathVariable long keyId, HttpServletRequest httpRequest) {
+ @PathVariable UUID keyId, HttpServletRequest httpRequest) {
service.delete(principal, keyId, clientIp(httpRequest));
}
@@ -69,7 +70,7 @@ public void deleteKey(@AuthenticationPrincipal AuthenticatedUser principal,
@GetMapping("/{keyId}/private-key")
@RequireReauth
public ResponseEntity downloadPrivateKey(
- @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable long keyId,
+ @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable UUID keyId,
HttpServletRequest httpRequest) {
SshKeyPrivateKeyResponse response =
service.downloadPrivateKey(principal, keyId, clientIp(httpRequest));
diff --git a/src/main/java/kr/ac/pusan/pickle/sshkey/UserSshKey.java b/src/main/java/kr/ac/pusan/pickle/sshkey/UserSshKey.java
index 92f6dd4c..c45548da 100644
--- a/src/main/java/kr/ac/pusan/pickle/sshkey/UserSshKey.java
+++ b/src/main/java/kr/ac/pusan/pickle/sshkey/UserSshKey.java
@@ -7,8 +7,11 @@
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
+import java.util.UUID;
import kr.ac.pusan.pickle.common.crypto.SshKeyAlgorithm;
import org.hibernate.annotations.CreationTimestamp;
+import org.hibernate.annotations.JdbcTypeCode;
+import org.hibernate.type.SqlTypes;
/**
* A user's registered SSH public key (V28). Either pasted (server holds only the
@@ -24,6 +27,14 @@ public class UserSshKey {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
+ /**
+ * The identifier this row wears outside the API boundary. Internal joins,
+ * sorts and foreign keys keep using {@link #id}.
+ */
+ @JdbcTypeCode(SqlTypes.UUID)
+ @Column(name = "public_id", nullable = false, updatable = false, unique = true)
+ private UUID publicId = UUID.randomUUID();
+
@Column(name = "user_id", nullable = false)
private Long userId;
@@ -68,6 +79,10 @@ public Long getId() {
return id;
}
+ public UUID getPublicId() {
+ return publicId;
+ }
+
public Long getUserId() {
return userId;
}
diff --git a/src/main/java/kr/ac/pusan/pickle/sshkey/UserSshKeyRepository.java b/src/main/java/kr/ac/pusan/pickle/sshkey/UserSshKeyRepository.java
index 8ff2a624..8f2278db 100644
--- a/src/main/java/kr/ac/pusan/pickle/sshkey/UserSshKeyRepository.java
+++ b/src/main/java/kr/ac/pusan/pickle/sshkey/UserSshKeyRepository.java
@@ -3,6 +3,7 @@
import java.time.Instant;
import java.util.List;
import java.util.Optional;
+import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
@@ -12,6 +13,9 @@
public interface UserSshKeyRepository extends JpaRepository {
+ /** Resolution of the identifier this row wears outside the API boundary. */
+ Optional findByPublicId(UUID publicId);
+
List findByUserIdOrderByCreatedAtAscIdAsc(Long userId);
Optional findByIdAndUserId(Long id, Long userId);
diff --git a/src/main/java/kr/ac/pusan/pickle/sshkey/UserSshKeyService.java b/src/main/java/kr/ac/pusan/pickle/sshkey/UserSshKeyService.java
index 20c7efd4..bdde466e 100644
--- a/src/main/java/kr/ac/pusan/pickle/sshkey/UserSshKeyService.java
+++ b/src/main/java/kr/ac/pusan/pickle/sshkey/UserSshKeyService.java
@@ -2,6 +2,7 @@
import java.util.List;
import java.util.Map;
+import java.util.UUID;
import kr.ac.pusan.pickle.audit.AuditService;
import kr.ac.pusan.pickle.common.crypto.CredentialCipher;
import kr.ac.pusan.pickle.common.crypto.GeneratedSshKeyPair;
@@ -85,9 +86,10 @@ public SshKeyView generate(AuthenticatedUser actor, String name, String ip) {
* answer 404 (existence masking).
*/
@Transactional
- public SshKeyPrivateKeyResponse downloadPrivateKey(AuthenticatedUser actor, long keyId,
+ public SshKeyPrivateKeyResponse downloadPrivateKey(AuthenticatedUser actor, UUID keyId,
String ip) {
- UserSshKey key = repository.findByIdAndUserId(keyId, actor.id())
+ UserSshKey key = repository.findByPublicId(keyId)
+ .filter(row -> row.getUserId() == actor.id())
.orElseThrow(UserSshKeyService::keyNotFound);
if (key.getPrivateKeyEnc() == null) {
throw new ApiException(HttpStatus.NOT_FOUND, ErrorCodes.RESOURCE_NOT_FOUND,
@@ -96,18 +98,19 @@ public SshKeyPrivateKeyResponse downloadPrivateKey(AuthenticatedUser actor, long
}
String privateKey = credentialCipher.decrypt(key.getPrivateKeyEnc());
auditService.recordAfterCommit(actor.id(), actor.role().name(),
- AuditService.USER_SSH_KEY_DOWNLOAD, "ssh_key", key.getId(),
+ AuditService.USER_SSH_KEY_DOWNLOAD, "ssh_key", key.getPublicId(),
Map.of("fingerprint", key.getFingerprintSha256()), ip);
return new SshKeyPrivateKeyResponse(privateKey, PRIVATE_KEY_FILENAME);
}
@Transactional
- public void delete(AuthenticatedUser actor, long keyId, String ip) {
- UserSshKey key = repository.findByIdAndUserId(keyId, actor.id())
+ public void delete(AuthenticatedUser actor, UUID keyId, String ip) {
+ UserSshKey key = repository.findByPublicId(keyId)
+ .filter(row -> row.getUserId() == actor.id())
.orElseThrow(UserSshKeyService::keyNotFound);
repository.delete(key);
auditService.recordAfterCommit(actor.id(), actor.role().name(),
- AuditService.USER_SSH_KEY_DELETE, "ssh_key", key.getId(),
+ AuditService.USER_SSH_KEY_DELETE, "ssh_key", key.getPublicId(),
Map.of("fingerprint", key.getFingerprintSha256()), ip);
}
@@ -134,7 +137,7 @@ private UserSshKey persist(AuthenticatedUser actor, String name, ParsedSshKey pa
parsed.algorithm().wireType(), parsed.normalizedLine(), parsed.fingerprint(),
privateKeyEnc));
auditService.recordAfterCommit(actor.id(), actor.role().name(), auditAction,
- "ssh_key", saved.getId(),
+ "ssh_key", saved.getPublicId(),
Map.of("fingerprint", saved.getFingerprintSha256(),
"algorithm", saved.getAlgorithm()), ip);
return saved;
diff --git a/src/main/java/kr/ac/pusan/pickle/sshkey/dto/SshKeyView.java b/src/main/java/kr/ac/pusan/pickle/sshkey/dto/SshKeyView.java
index 5ba1aa2b..39c67aa2 100644
--- a/src/main/java/kr/ac/pusan/pickle/sshkey/dto/SshKeyView.java
+++ b/src/main/java/kr/ac/pusan/pickle/sshkey/dto/SshKeyView.java
@@ -1,13 +1,14 @@
package kr.ac.pusan.pickle.sshkey.dto;
import java.time.Instant;
+import java.util.UUID;
import kr.ac.pusan.pickle.common.crypto.SshKeyAlgorithm;
import kr.ac.pusan.pickle.sshkey.UserSshKey;
import org.jspecify.annotations.Nullable;
/** Contract schema {@code SshKeyView} — a registered SSH public key. */
public record SshKeyView(
- Long id,
+ UUID id,
String name,
SshKeyAlgorithm algorithm,
String publicKey,
@@ -17,7 +18,7 @@ public record SshKeyView(
@Nullable Instant lastUsedAt) {
public static SshKeyView from(UserSshKey key) {
- return new SshKeyView(key.getId(), key.getName(), key.algorithmEnum(), key.getPublicKey(),
+ return new SshKeyView(key.getPublicId(), key.getName(), key.algorithmEnum(), key.getPublicKey(),
key.getFingerprintSha256(), key.isPrivateKeyStored(), key.getCreatedAt(),
key.getLastUsedAt());
}
diff --git a/src/main/java/kr/ac/pusan/pickle/terminal/TerminalController.java b/src/main/java/kr/ac/pusan/pickle/terminal/TerminalController.java
index 5ef1a0b3..988f2563 100644
--- a/src/main/java/kr/ac/pusan/pickle/terminal/TerminalController.java
+++ b/src/main/java/kr/ac/pusan/pickle/terminal/TerminalController.java
@@ -3,6 +3,7 @@
import static kr.ac.pusan.pickle.common.web.ClientIps.clientIp;
import jakarta.servlet.http.HttpServletRequest;
+import java.util.UUID;
import kr.ac.pusan.pickle.security.AuthenticatedUser;
import kr.ac.pusan.pickle.terminal.dto.TerminalTicketResponse;
import org.springframework.http.CacheControl;
@@ -37,7 +38,7 @@ public TerminalController(TerminalService terminalService) {
@ResponseStatus(HttpStatus.CREATED)
public ResponseEntity createTerminalSession(
@AuthenticationPrincipal AuthenticatedUser principal,
- @PathVariable long vmId,
+ @PathVariable UUID vmId,
HttpServletRequest httpRequest) {
TerminalTicketResponse ticket = terminalService.mint(principal, vmId, clientIp(httpRequest));
return ResponseEntity.status(HttpStatus.CREATED)
diff --git a/src/main/java/kr/ac/pusan/pickle/terminal/TerminalService.java b/src/main/java/kr/ac/pusan/pickle/terminal/TerminalService.java
index 68b8d64c..9b94b5d8 100644
--- a/src/main/java/kr/ac/pusan/pickle/terminal/TerminalService.java
+++ b/src/main/java/kr/ac/pusan/pickle/terminal/TerminalService.java
@@ -104,7 +104,7 @@ public TerminalService(SettingsService settingsService, VmRepository vmRepositor
* service-layer here (existence-masking 404 for non-members).
*/
@Transactional(readOnly = true)
- public TerminalTicketResponse mint(AuthenticatedUser actor, long vmId, String clientIp) {
+ public TerminalTicketResponse mint(AuthenticatedUser actor, UUID publicVmId, String clientIp) {
// 1) global kill switch first (a disabled feature reveals nothing).
if (!settingsService.bool(SettingsService.WEB_TERMINAL_ENABLED, false)) {
throw new ApiException(HttpStatus.SERVICE_UNAVAILABLE, ErrorCodes.TERMINAL_DISABLED,
@@ -114,7 +114,7 @@ public TerminalTicketResponse mint(AuthenticatedUser actor, long vmId, String cl
// 404 (existence stays private); a VIEWER already sees the VM via getVm,
// so it gets an honest 403 (same as the power-control paths) rather than
// a misleading 404.
- Vm vm = vmRepository.findById(vmId).orElseThrow(VmAccessService::vmNotFound);
+ Vm vm = vmRepository.findByPublicId(publicVmId).orElseThrow(VmAccessService::vmNotFound);
vmAccessService.of(vm, actor.id()).requireAtLeast(ResourceRole.MEMBER,
"웹 터미널을 열 권한이 없습니다", "이 VM의 참여자 이상만 웹 터미널을 사용할 수 있습니다.");
// 3) RUNNING.
@@ -232,7 +232,7 @@ public void sessionStart(String sessionId, String clientIp) {
detail.put("vmId", s.vmId());
detail.put("clientIp", clientIp);
auditService.record(s.userId(), roleName(s.userRole()), AuditService.TERMINAL_SESSION_START,
- "vm", s.vmId(), detail, clientIp);
+ "vm", vmPublicId(s.vmId()), detail, clientIp);
}
/**
@@ -256,7 +256,7 @@ public void sessionEnd(TerminalSessionEndRequest request) {
detail.put("bytesIn", request.bytesIn());
detail.put("bytesOut", request.bytesOut());
auditService.record(s.userId(), roleName(s.userRole()), AuditService.TERMINAL_SESSION_END,
- "vm", s.vmId(), detail, s.clientIp());
+ "vm", vmPublicId(s.vmId()), detail, s.clientIp());
}
// ── internal: revalidate ──────────────────────────────────────────────────
@@ -338,7 +338,8 @@ public void terminate(AuthenticatedUser actor, String sessionId, String ip) {
detail.put("sessionId", sessionId);
detail.put("vmId", known.get().vmId());
auditService.record(actor.id(), actor.role().name(),
- AuditService.TERMINAL_FORCE_TERMINATE, "vm", known.get().vmId(), detail, ip);
+ AuditService.TERMINAL_FORCE_TERMINATE, "vm", vmPublicId(known.get().vmId()),
+ detail, ip);
}
}
@@ -398,4 +399,14 @@ private static ApiException sessionUserGone() {
return new ApiException(HttpStatus.UNAUTHORIZED, ErrorCodes.AUTH_TOKEN_INVALID,
"인증이 필요합니다", "액세스 토큰이 없거나 만료되었습니다. 토큰을 갱신한 뒤 다시 시도해 주세요.");
}
+
+ /**
+ * The VM's public identifier for the audit trail. The gateway contract
+ * itself stays on the internal id (a Go client decodes it as int64), so
+ * the translation happens here, at the audit boundary.
+ */
+ private UUID vmPublicId(Long vmId) {
+ return vmId == null ? null
+ : vmRepository.findById(vmId).map(Vm::getPublicId).orElse(null);
+ }
}
diff --git a/src/main/java/kr/ac/pusan/pickle/user/AccountService.java b/src/main/java/kr/ac/pusan/pickle/user/AccountService.java
index 4dddef38..e916449b 100644
--- a/src/main/java/kr/ac/pusan/pickle/user/AccountService.java
+++ b/src/main/java/kr/ac/pusan/pickle/user/AccountService.java
@@ -82,7 +82,7 @@ public MessageResponse withdraw(long userId, String password, String totpCode, S
if (!passwordEncoder.matches(password, user.getPasswordHash())) {
rateLimitService.registerLoginFailure(user.getEmail(), ip);
auditService.record(user.getId(), user.getRole().name(), AuditService.ACCOUNT_WITHDRAW,
- "user", user.getId(), Map.of("result", "mismatch"), ip);
+ "user", user.getPublicId(), Map.of("result", "mismatch"), ip);
throw passwordMismatch();
}
// 2FA-enrolled accounts must also present a valid TOTP or recovery code.
@@ -121,7 +121,7 @@ public MessageResponse withdraw(long userId, String password, String totpCode, S
UserStatus.WITHDRAWN, user.getId(), null));
auditService.recordAfterCommit(user.getId(), user.getRole().name(), AuditService.ACCOUNT_WITHDRAW,
- "user", user.getId(), Map.of("email", user.getEmail()), ip);
+ "user", user.getPublicId(), Map.of("email", user.getEmail()), ip);
notificationService.publish(user.getId(), NotificationEvent.ACCOUNT_WITHDRAWN,
Map.of("userId", user.getId(), "userEmail", user.getEmail()),
"account_withdrawn:" + user.getId());
diff --git a/src/main/java/kr/ac/pusan/pickle/user/MeController.java b/src/main/java/kr/ac/pusan/pickle/user/MeController.java
index 047c6b6e..ca64cb57 100644
--- a/src/main/java/kr/ac/pusan/pickle/user/MeController.java
+++ b/src/main/java/kr/ac/pusan/pickle/user/MeController.java
@@ -1,11 +1,14 @@
package kr.ac.pusan.pickle.user;
import kr.ac.pusan.pickle.common.error.ApiException;
+import java.util.UUID;
import kr.ac.pusan.pickle.common.error.ErrorCodes;
import kr.ac.pusan.pickle.consent.TermsService;
import kr.ac.pusan.pickle.workspace.WorkspaceMemberRepository;
import kr.ac.pusan.pickle.mfa.MfaService;
import kr.ac.pusan.pickle.security.AuthenticatedUser;
+import kr.ac.pusan.pickle.orgs.Org;
+import kr.ac.pusan.pickle.orgs.OrgRepository;
import kr.ac.pusan.pickle.user.dto.UserProfileResponse;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
@@ -20,13 +23,16 @@
public class MeController {
private final UserRepository userRepository;
+ private final OrgRepository orgRepository;
private final WorkspaceMemberRepository workspaceMemberRepository;
private final MfaService mfaService;
private final TermsService termsService;
- public MeController(UserRepository userRepository, WorkspaceMemberRepository workspaceMemberRepository,
+ public MeController(UserRepository userRepository, OrgRepository orgRepository,
+ WorkspaceMemberRepository workspaceMemberRepository,
MfaService mfaService, TermsService termsService) {
this.userRepository = userRepository;
+ this.orgRepository = orgRepository;
this.workspaceMemberRepository = workspaceMemberRepository;
this.mfaService = mfaService;
this.termsService = termsService;
@@ -38,7 +44,10 @@ public UserProfileResponse me(@AuthenticationPrincipal AuthenticatedUser princip
User user = userRepository.findById(principal.id())
.orElseThrow(() -> new ApiException(HttpStatus.UNAUTHORIZED, ErrorCodes.AUTH_TOKEN_INVALID,
"인증이 필요합니다", "액세스 토큰이 없거나 만료되었습니다. 토큰을 갱신한 뒤 다시 시도해 주세요."));
- return UserProfileResponse.from(user, workspaceMemberRepository.findWithWorkspaceByUserId(user.getId()),
+ UUID orgId = user.getOrgId() == null ? null
+ : orgRepository.findById(user.getOrgId()).map(Org::getPublicId).orElse(null);
+ return UserProfileResponse.from(user, orgId,
+ workspaceMemberRepository.findWithWorkspaceByUserId(user.getId()),
mfaService.isEnrolled(user.getId()), termsService.pendingConsents(user.getId()));
}
}
diff --git a/src/main/java/kr/ac/pusan/pickle/user/User.java b/src/main/java/kr/ac/pusan/pickle/user/User.java
index f1af3169..8270e373 100644
--- a/src/main/java/kr/ac/pusan/pickle/user/User.java
+++ b/src/main/java/kr/ac/pusan/pickle/user/User.java
@@ -9,6 +9,7 @@
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
+import java.util.UUID;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.annotations.UpdateTimestamp;
@@ -22,6 +23,14 @@ public class User {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
+ /**
+ * The identifier this row wears outside the API boundary. Internal joins,
+ * sorts and foreign keys keep using {@link #id}.
+ */
+ @JdbcTypeCode(SqlTypes.UUID)
+ @Column(name = "public_id", nullable = false, updatable = false, unique = true)
+ private UUID publicId = UUID.randomUUID();
+
@Column(nullable = false, unique = true, columnDefinition = "citext")
private String email;
@@ -80,6 +89,10 @@ public Long getId() {
return id;
}
+ public UUID getPublicId() {
+ return publicId;
+ }
+
public String getEmail() {
return email;
}
diff --git a/src/main/java/kr/ac/pusan/pickle/user/UserRepository.java b/src/main/java/kr/ac/pusan/pickle/user/UserRepository.java
index 55e57cce..c53acc2c 100644
--- a/src/main/java/kr/ac/pusan/pickle/user/UserRepository.java
+++ b/src/main/java/kr/ac/pusan/pickle/user/UserRepository.java
@@ -2,10 +2,14 @@
import java.util.List;
import java.util.Optional;
+import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
public interface UserRepository extends JpaRepository {
+ /** Resolution of the identifier this row wears outside the API boundary. */
+ Optional findByPublicId(UUID publicId);
+
/** Case-insensitive by virtue of the {@code citext} column. */
Optional findByEmail(String email);
diff --git a/src/main/java/kr/ac/pusan/pickle/user/dto/UserProfileResponse.java b/src/main/java/kr/ac/pusan/pickle/user/dto/UserProfileResponse.java
index 95ae40cb..afe9f6e3 100644
--- a/src/main/java/kr/ac/pusan/pickle/user/dto/UserProfileResponse.java
+++ b/src/main/java/kr/ac/pusan/pickle/user/dto/UserProfileResponse.java
@@ -1,6 +1,7 @@
package kr.ac.pusan.pickle.user.dto;
import java.util.List;
+import java.util.UUID;
import kr.ac.pusan.pickle.consent.dto.TermsVersionView;
import kr.ac.pusan.pickle.workspace.WorkspaceKind;
import kr.ac.pusan.pickle.workspace.WorkspaceMember;
@@ -12,11 +13,11 @@
/** Contract schema {@code UserProfile}. */
public record UserProfileResponse(
- Long id,
+ UUID id,
String email,
String name,
UserRole role,
- @Nullable Long orgId,
+ @Nullable UUID orgId,
UserStatus status,
List memberships,
boolean mfaEnabled,
@@ -30,10 +31,11 @@ public static Membership from(WorkspaceMember member) {
}
}
- public static UserProfileResponse from(User user, List memberships,
+ public static UserProfileResponse from(User user, @Nullable UUID orgId,
+ List memberships,
boolean mfaEnabled, List pendingConsents) {
- return new UserProfileResponse(user.getId(), user.getEmail(), user.getName(), user.getRole(),
- user.getOrgId(), user.getStatus(),
+ return new UserProfileResponse(user.getPublicId(), user.getEmail(), user.getName(),
+ user.getRole(), orgId, user.getStatus(),
memberships.stream().map(Membership::from).toList(), mfaEnabled, pendingConsents);
}
}
diff --git a/src/main/java/kr/ac/pusan/pickle/vm/AdminVmAccess.java b/src/main/java/kr/ac/pusan/pickle/vm/AdminVmAccess.java
index 80649a14..921428f2 100644
--- a/src/main/java/kr/ac/pusan/pickle/vm/AdminVmAccess.java
+++ b/src/main/java/kr/ac/pusan/pickle/vm/AdminVmAccess.java
@@ -1,5 +1,6 @@
package kr.ac.pusan.pickle.vm;
+import java.util.UUID;
import kr.ac.pusan.pickle.common.error.ApiException;
import kr.ac.pusan.pickle.common.error.ErrorCodes;
import kr.ac.pusan.pickle.security.AuthenticatedUser;
@@ -21,8 +22,8 @@ public AdminVmAccess(VmRepository vmRepository) {
this.vmRepository = vmRepository;
}
- public Vm requireOrgScopedVm(AuthenticatedUser actor, long vmId) {
- Vm vm = vmRepository.findById(vmId).orElseThrow(AdminVmAccess::vmNotFound);
+ public Vm requireOrgScopedVm(AuthenticatedUser actor, UUID vmId) {
+ Vm vm = vmRepository.findByPublicId(vmId).orElseThrow(AdminVmAccess::vmNotFound);
if (actor.role().isOrgTier() && !vm.getOrgId().equals(actor.orgId())) {
throw vmNotFound();
}
diff --git a/src/main/java/kr/ac/pusan/pickle/vm/Vm.java b/src/main/java/kr/ac/pusan/pickle/vm/Vm.java
index ef5fe7fc..5d66f2c1 100644
--- a/src/main/java/kr/ac/pusan/pickle/vm/Vm.java
+++ b/src/main/java/kr/ac/pusan/pickle/vm/Vm.java
@@ -10,6 +10,7 @@
import jakarta.persistence.Table;
import java.time.Instant;
import java.time.LocalDate;
+import java.util.UUID;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.annotations.UpdateTimestamp;
@@ -29,6 +30,14 @@ public class Vm {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
+ /**
+ * The identifier this row wears outside the API boundary. Internal joins,
+ * sorts and foreign keys keep using {@link #id}.
+ */
+ @JdbcTypeCode(SqlTypes.UUID)
+ @Column(name = "public_id", nullable = false, updatable = false, unique = true)
+ private UUID publicId = UUID.randomUUID();
+
@Column(name = "proxmox_vmid", unique = true)
private Integer proxmoxVmid;
@@ -199,6 +208,10 @@ public Long getId() {
return id;
}
+ public UUID getPublicId() {
+ return publicId;
+ }
+
public Integer getProxmoxVmid() {
return proxmoxVmid;
}
diff --git a/src/main/java/kr/ac/pusan/pickle/vm/VmController.java b/src/main/java/kr/ac/pusan/pickle/vm/VmController.java
index 030642b5..7f7d3a7f 100644
--- a/src/main/java/kr/ac/pusan/pickle/vm/VmController.java
+++ b/src/main/java/kr/ac/pusan/pickle/vm/VmController.java
@@ -8,6 +8,7 @@
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import java.util.List;
+import java.util.UUID;
import kr.ac.pusan.pickle.auth.dto.MessageResponse;
import kr.ac.pusan.pickle.common.web.PageResponse;
import kr.ac.pusan.pickle.proxmox.RrdTimeframe;
@@ -63,7 +64,7 @@ public VmController(VmQueryService vmQueryService, VmLifecycleService vmLifecycl
@GetMapping
public PageResponse listVms(
@AuthenticationPrincipal AuthenticatedUser principal,
- @RequestParam(required = false) Long workspaceId,
+ @RequestParam(required = false) UUID workspaceId,
@RequestParam(defaultValue = "0") @Min(0) int page,
@RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) {
return vmQueryService.list(principal, workspaceId, page, size);
@@ -71,7 +72,7 @@ public PageResponse listVms(
@GetMapping("/{vmId}")
public VmDetailResponse getVm(@AuthenticationPrincipal AuthenticatedUser principal,
- @PathVariable long vmId) {
+ @PathVariable UUID vmId) {
return vmQueryService.get(principal, vmId);
}
@@ -79,7 +80,7 @@ public VmDetailResponse getVm(@AuthenticationPrincipal AuthenticatedUser princip
@ResponseStatus(HttpStatus.ACCEPTED)
@RequireReauth
public VmDeletionResponse deleteVm(
- @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable long vmId,
+ @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable UUID vmId,
HttpServletRequest httpRequest) {
return vmDeletionService.selfDelete(principal, vmId, clientIp(httpRequest));
}
@@ -87,35 +88,35 @@ public VmDeletionResponse deleteVm(
@PostMapping("/{vmId}/start")
@ResponseStatus(HttpStatus.ACCEPTED)
public MessageResponse startVm(
- @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable long vmId) {
+ @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable UUID vmId) {
return vmLifecycleService.start(principal, vmId);
}
@PostMapping("/{vmId}/shutdown")
@ResponseStatus(HttpStatus.ACCEPTED)
public MessageResponse shutdownVm(
- @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable long vmId) {
+ @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable UUID vmId) {
return vmLifecycleService.shutdown(principal, vmId);
}
@PostMapping("/{vmId}/reboot")
@ResponseStatus(HttpStatus.ACCEPTED)
public MessageResponse rebootVm(
- @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable long vmId) {
+ @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable UUID vmId) {
return vmLifecycleService.reboot(principal, vmId);
}
@PostMapping("/{vmId}/force-stop")
@ResponseStatus(HttpStatus.ACCEPTED)
public MessageResponse forceStopVm(
- @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable long vmId) {
+ @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable UUID vmId) {
return vmLifecycleService.forceStop(principal, vmId);
}
@GetMapping("/{vmId}/events")
public PageResponse listVmEvents(
@AuthenticationPrincipal AuthenticatedUser principal,
- @PathVariable long vmId,
+ @PathVariable UUID vmId,
@RequestParam(defaultValue = "0") @Min(0) int page,
@RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) {
return vmQueryService.events(principal, vmId, page, size);
@@ -124,7 +125,7 @@ public PageResponse listVmEvents(
/** Live usage series from the hypervisor (contract v0.35.0); nothing is stored here. */
@GetMapping("/{vmId}/metrics")
public VmMetricsResponse getVmMetrics(@AuthenticationPrincipal AuthenticatedUser principal,
- @PathVariable long vmId,
+ @PathVariable UUID vmId,
@Parameter(description = "조회 구간 — HOUR/DAY/WEEK/MONTH/YEAR "
+ "(해상도는 구간에 따라 거칠어짐)")
@RequestParam(defaultValue = "HOUR") RrdTimeframe timeframe) {
@@ -135,7 +136,7 @@ public VmMetricsResponse getVmMetrics(@AuthenticationPrincipal AuthenticatedUser
@GetMapping("/{vmId}/password")
@RequireReauth
public ResponseEntity revealVmPassword(
- @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable long vmId,
+ @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable UUID vmId,
HttpServletRequest httpRequest) {
VmPasswordResponse response =
vmPasswordService.reveal(principal, vmId, clientIp(httpRequest));
@@ -148,7 +149,7 @@ public ResponseEntity revealVmPassword(
@PostMapping("/{vmId}/password/regenerate")
@RequireReauth
public ResponseEntity regenerateVmPassword(
- @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable long vmId,
+ @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable UUID vmId,
HttpServletRequest httpRequest) {
VmPasswordResponse response =
vmPasswordService.regenerate(principal, vmId, clientIp(httpRequest));
@@ -160,7 +161,7 @@ public ResponseEntity regenerateVmPassword(
/** Per-VM settings (EDITOR+; non-member 404). */
@GetMapping("/{vmId}/settings")
public List getVmSettings(@AuthenticationPrincipal AuthenticatedUser principal,
- @PathVariable long vmId) {
+ @PathVariable UUID vmId) {
return vmSettingsService.get(principal, vmId);
}
@@ -168,7 +169,7 @@ public List getVmSettings(@AuthenticationPrincipal AuthenticatedU
@PatchMapping("/{vmId}/settings")
@RequireReauth
public List updateVmSettings(
- @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable long vmId,
+ @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable UUID vmId,
@Valid @RequestBody VmSettingsUpdateRequest request, HttpServletRequest httpRequest) {
return vmSettingsService.patch(principal, vmId, request.settings(), clientIp(httpRequest));
}
diff --git a/src/main/java/kr/ac/pusan/pickle/vm/VmDeletionService.java b/src/main/java/kr/ac/pusan/pickle/vm/VmDeletionService.java
index 30856c90..1b95e4d8 100644
--- a/src/main/java/kr/ac/pusan/pickle/vm/VmDeletionService.java
+++ b/src/main/java/kr/ac/pusan/pickle/vm/VmDeletionService.java
@@ -8,6 +8,7 @@
import java.util.List;
import java.util.Map;
import java.util.Set;
+import java.util.UUID;
import kr.ac.pusan.pickle.access.VmAccess;
import kr.ac.pusan.pickle.access.VmAccessService;
import kr.ac.pusan.pickle.admin.dto.ForceDeleteVmRequest;
@@ -119,8 +120,9 @@ public VmDeletionService(VmRepository vmRepository, WorkspaceMemberRepository wo
// ── self-delete (DELETE /vms/{vmId}) ───────────────────────────────────
@Transactional
- public VmDeletionResponse selfDelete(AuthenticatedUser actor, long vmId, String ip) {
- Vm vm = requireDeletableByActor(actor, vmId);
+ public VmDeletionResponse selfDelete(AuthenticatedUser actor, UUID publicVmId, String ip) {
+ Vm vm = requireDeletableByActor(actor, publicVmId);
+ long vmId = vm.getId();
requireNoPendingDeletion(vm);
requireNotDeletionProtected(vmId);
if (vm.getStatus() == VmStatus.ERROR) {
@@ -139,14 +141,14 @@ public VmDeletionResponse selfDelete(AuthenticatedUser actor, long vmId, String
vmEventRepository.save(new VmEvent(vmId, VmEventType.SELF_DELETE, actor.id(),
"삭제 접수 — " + KST.format(scheduledFor) + " (KST) 파기 예정"));
auditService.recordAfterCommit(actor.id(), actor.role().name(), AuditService.VM_SELF_DELETE,
- "vm", vmId, Map.of("name", vm.getName(), "orgId", vm.getOrgId(),
+ "vm", vm.getPublicId(), Map.of("name", vm.getName(), "orgId", vm.getOrgId(),
"workspaceId", vm.getWorkspaceId(), "scheduledFor", scheduledFor.toString()), ip);
// Best-effort graceful shutdown; its failure never touches the schedule.
enqueueAfterCommit(() -> deleteVmJob.gracefulShutdown(vmId));
notificationService.publish(recipients(vm, true), NotificationEvent.VM_DELETE_ACCEPTED,
- Map.of("vmId", vmId, "vmName", vm.getName(), "scheduledFor", scheduledFor), null);
- return new VmDeletionResponse(VmDeleteKind.SELF, scheduledFor, now, actor.id(), null, true);
+ Map.of("vmId", vm.getPublicId(), "vmName", vm.getName(), "scheduledFor", scheduledFor), null);
+ return new VmDeletionResponse(VmDeleteKind.SELF, scheduledFor, now, actor.publicId(), null, true);
}
/** ERROR VM: nothing to destroy — release the IP and finish immediately. */
@@ -176,17 +178,18 @@ private VmDeletionResponse deleteErrorVmImmediately(AuthenticatedUser actor, Vm
vmEventRepository.save(new VmEvent(vm.getId(), VmEventType.DELETE, actor.id(),
"VM 파기 완료 — ERROR 상태(파기할 게스트 없음), IP 회수"));
auditService.recordAfterCommit(actor.id(), actor.role().name(), AuditService.VM_SELF_DELETE,
- "vm", vm.getId(), Map.of("name", vm.getName(), "orgId", vm.getOrgId(),
+ "vm", vm.getPublicId(), Map.of("name", vm.getName(), "orgId", vm.getOrgId(),
"workspaceId", vm.getWorkspaceId(), "immediate", true), ip);
- return new VmDeletionResponse(VmDeleteKind.SELF, now, now, actor.id(), null, false);
+ return new VmDeletionResponse(VmDeleteKind.SELF, now, now, actor.publicId(), null, false);
}
// ── admin scheduled delete ─────────────────────────────────────────────
@Transactional
- public VmDeletionResponse scheduleDeletion(AuthenticatedUser actor, long vmId,
+ public VmDeletionResponse scheduleDeletion(AuthenticatedUser actor, UUID publicVmId,
ScheduleVmDeletionRequest request, String ip) {
- Vm vm = requireOrgScopedVm(actor, vmId);
+ Vm vm = requireOrgScopedVm(actor, publicVmId);
+ long vmId = vm.getId();
requireNoPendingDeletion(vm);
// CREATING is deliberately accepted (unlike self-delete): the schedule
// is intent-only, and the sweeper waits for a sweepable power state —
@@ -208,21 +211,22 @@ public VmDeletionResponse scheduleDeletion(AuthenticatedUser actor, long vmId,
vmEventRepository.save(new VmEvent(vmId, VmEventType.SCHEDULE_DELETE, actor.id(),
"관리자 삭제 접수 — " + KST.format(request.scheduledFor()) + " (KST), 사유: " + reason));
auditService.recordAfterCommit(actor.id(), actor.role().name(), AuditService.VM_SCHEDULE_DELETE,
- "vm", vmId, Map.of("name", vm.getName(), "orgId", vm.getOrgId(),
+ "vm", vm.getPublicId(), Map.of("name", vm.getName(), "orgId", vm.getOrgId(),
"workspaceId", vm.getWorkspaceId(),
"scheduledFor", request.scheduledFor().toString(), "reason", reason), ip);
notificationService.publish(recipients(vm, false), NotificationEvent.VM_DELETE_SCHEDULED,
- Map.of("vmId", vmId, "vmName", vm.getName(), "reason", reason,
+ Map.of("vmId", vm.getPublicId(), "vmName", vm.getName(), "reason", reason,
"scheduledFor", request.scheduledFor()), null);
- return new VmDeletionResponse(VmDeleteKind.ADMIN, request.scheduledFor(), now, actor.id(),
+ return new VmDeletionResponse(VmDeleteKind.ADMIN, request.scheduledFor(), now, actor.publicId(),
reason, true);
}
// ── admin cancel (the only cancellation path — users have none) ─────
@Transactional
- public MessageResponse cancelScheduledDeletion(AuthenticatedUser actor, long vmId, String ip) {
- Vm vm = requireOrgScopedVm(actor, vmId);
+ public MessageResponse cancelScheduledDeletion(AuthenticatedUser actor, UUID publicVmId, String ip) {
+ Vm vm = requireOrgScopedVm(actor, publicVmId);
+ long vmId = vm.getId();
Instant now = Instant.now();
if (vm.getDeleteKind() == VmDeleteKind.SELF) {
// SELF: the VM entered DELETING at acceptance — cancel restores
@@ -251,20 +255,21 @@ public MessageResponse cancelScheduledDeletion(AuthenticatedUser actor, long vmI
? "본인 삭제 취소 — VM은 STOPPED 상태로 유지"
: "관리자 삭제 취소"));
auditService.recordAfterCommit(actor.id(), actor.role().name(),
- AuditService.VM_CANCEL_SCHEDULED_DELETE, "vm", vmId,
+ AuditService.VM_CANCEL_SCHEDULED_DELETE, "vm", vm.getPublicId(),
Map.of("name", vm.getName(), "orgId", vm.getOrgId(),
"workspaceId", vm.getWorkspaceId(), "canceledKind", vm.getDeleteKind().name()), ip);
notificationService.publish(recipients(vm, false), NotificationEvent.VM_DELETE_CANCELED,
- Map.of("vmId", vmId, "vmName", vm.getName()), null);
+ Map.of("vmId", vm.getPublicId(), "vmName", vm.getName()), null);
return new MessageResponse("삭제가 취소되었습니다.");
}
// ── force delete (SYS_ADMIN, immediate, not cancelable) ────────────────
@Transactional
- public MessageResponse forceDelete(AuthenticatedUser actor, long vmId,
+ public MessageResponse forceDelete(AuthenticatedUser actor, UUID publicVmId,
ForceDeleteVmRequest request, String ip) {
- Vm vm = vmRepository.findById(vmId).orElseThrow(VmAccessService::vmNotFound);
+ Vm vm = vmRepository.findByPublicId(publicVmId).orElseThrow(VmAccessService::vmNotFound);
+ long vmId = vm.getId();
if (!vm.getName().equals(request.confirmName())) {
throw new ApiException(HttpStatus.CONFLICT, ErrorCodes.VM_CONFIRM_NAME_MISMATCH,
"확인용 이름이 일치하지 않습니다",
@@ -298,11 +303,11 @@ public MessageResponse forceDelete(AuthenticatedUser actor, long vmId,
overrodeProtection ? "강제 삭제 접수 — 삭제 보호 오버라이드, 즉시 강제 종료 후 파기"
: "강제 삭제 접수 — 즉시 강제 종료 후 파기"));
auditService.recordAfterCommit(actor.id(), actor.role().name(), AuditService.VM_FORCE_DELETE,
- "vm", vmId, Map.of("name", vm.getName(), "orgId", vm.getOrgId(),
+ "vm", vm.getPublicId(), Map.of("name", vm.getName(), "orgId", vm.getOrgId(),
"workspaceId", vm.getWorkspaceId(), "overrodeProtection", overrodeProtection), ip);
enqueueAfterCommit(() -> deleteVmJob.deleteVm(vmId));
notificationService.publish(recipients(vm, true), NotificationEvent.VM_DELETE_FORCE,
- Map.of("vmId", vmId, "vmName", vm.getName()), null);
+ Map.of("vmId", vm.getPublicId(), "vmName", vm.getName()), null);
return new MessageResponse("강제 삭제를 접수했습니다. VM이 즉시 강제 종료되고 파기됩니다.");
}
@@ -359,8 +364,8 @@ private void resumeParkedDeleteTask(long vmId) {
* ORG_ADMIN of the VM's org, or SYS_ADMIN. Non-members and cross-org admins
* get 404 (masking); anyone else who can see the VM gets 403.
*/
- private Vm requireDeletableByActor(AuthenticatedUser actor, long vmId) {
- Vm vm = vmRepository.findById(vmId).orElseThrow(VmAccessService::vmNotFound);
+ private Vm requireDeletableByActor(AuthenticatedUser actor, UUID vmId) {
+ Vm vm = vmRepository.findByPublicId(vmId).orElseThrow(VmAccessService::vmNotFound);
if (actor.role() == UserRole.SYS_ADMIN) {
return vm;
}
@@ -381,8 +386,8 @@ private Vm requireDeletableByActor(AuthenticatedUser actor, long vmId) {
}
/** Admin-op scope: ORG_ADMIN sees only their own org's VMs (404 otherwise). */
- private Vm requireOrgScopedVm(AuthenticatedUser actor, long vmId) {
- Vm vm = vmRepository.findById(vmId).orElseThrow(VmAccessService::vmNotFound);
+ private Vm requireOrgScopedVm(AuthenticatedUser actor, UUID vmId) {
+ Vm vm = vmRepository.findByPublicId(vmId).orElseThrow(VmAccessService::vmNotFound);
if (actor.role() == UserRole.ORG_ADMIN && !vm.getOrgId().equals(actor.orgId())) {
throw VmAccessService.vmNotFound();
}
diff --git a/src/main/java/kr/ac/pusan/pickle/vm/VmEvent.java b/src/main/java/kr/ac/pusan/pickle/vm/VmEvent.java
index 3d77349b..3c32fd73 100644
--- a/src/main/java/kr/ac/pusan/pickle/vm/VmEvent.java
+++ b/src/main/java/kr/ac/pusan/pickle/vm/VmEvent.java
@@ -9,6 +9,7 @@
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
+import java.util.UUID;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
@@ -26,6 +27,14 @@ public class VmEvent {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
+ /**
+ * The identifier this row wears outside the API boundary. Internal joins,
+ * sorts and foreign keys keep using {@link #id}.
+ */
+ @JdbcTypeCode(SqlTypes.UUID)
+ @Column(name = "public_id", nullable = false, updatable = false, unique = true)
+ private UUID publicId = UUID.randomUUID();
+
@Column(name = "vm_id", nullable = false)
private Long vmId;
@@ -57,6 +66,10 @@ public Long getId() {
return id;
}
+ public UUID getPublicId() {
+ return publicId;
+ }
+
public Long getVmId() {
return vmId;
}
diff --git a/src/main/java/kr/ac/pusan/pickle/vm/VmEventRepository.java b/src/main/java/kr/ac/pusan/pickle/vm/VmEventRepository.java
index 0290b69e..40632d17 100644
--- a/src/main/java/kr/ac/pusan/pickle/vm/VmEventRepository.java
+++ b/src/main/java/kr/ac/pusan/pickle/vm/VmEventRepository.java
@@ -1,11 +1,16 @@
package kr.ac.pusan.pickle.vm;
import org.springframework.data.domain.Page;
+import java.util.Optional;
+import java.util.UUID;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
/** Append-only: only {@code save} and reads — no updates or deletes. */
public interface VmEventRepository extends JpaRepository {
+ /** Resolution of the identifier this row wears outside the API boundary. */
+ Optional findByPublicId(UUID publicId);
+
Page findByVmIdOrderByIdDesc(Long vmId, Pageable pageable);
}
diff --git a/src/main/java/kr/ac/pusan/pickle/vm/VmLifecycleService.java b/src/main/java/kr/ac/pusan/pickle/vm/VmLifecycleService.java
index 378eb936..1c326341 100644
--- a/src/main/java/kr/ac/pusan/pickle/vm/VmLifecycleService.java
+++ b/src/main/java/kr/ac/pusan/pickle/vm/VmLifecycleService.java
@@ -5,6 +5,7 @@
import java.util.Collection;
import java.util.List;
import java.util.Map;
+import java.util.UUID;
import kr.ac.pusan.pickle.access.ResourceRole;
import kr.ac.pusan.pickle.access.VmAccess;
import kr.ac.pusan.pickle.access.VmAccessService;
@@ -84,8 +85,9 @@ public VmLifecycleService(VmRepository vmRepository, VmAccessService vmAccessSer
}
@Transactional
- public MessageResponse start(AuthenticatedUser actor, long vmId) {
+ public MessageResponse start(AuthenticatedUser actor, UUID vmId) {
Vm vm = requireMemberControllableVm(actor, vmId).vm();
+ long id = vm.getId();
// Expiry guard: a past end date (KST, inclusive end) refuses start
// even from STOPPED — only PATCH /admin/vms/{vmId}/period lifts it.
if (vm.getEndDate() != null && vm.getEndDate().isBefore(ClockConfig.todayKst(clock))) {
@@ -94,55 +96,59 @@ public MessageResponse start(AuthenticatedUser actor, long vmId) {
"사용 기간(종료일 %s)이 만료되어 시작할 수 없습니다. 관리자에게 기간 연장을 요청해 주세요."
.formatted(vm.getEndDate()));
}
- claimPowerAction(vmId, PowerAction.START, List.of(VmStatus.STOPPED),
+ claimPowerAction(id, PowerAction.START, List.of(VmStatus.STOPPED),
"STOPPED 상태의 VM만 시작할 수 있습니다.");
long actorId = actor.id();
- enqueueAfterCommit(() -> vmPowerJobs.start(vmId, actorId));
+ enqueueAfterCommit(() -> vmPowerJobs.start(id, actorId));
return new MessageResponse("VM 시작 요청을 접수했습니다. 잠시 후 상태가 갱신됩니다.");
}
@Transactional
- public MessageResponse shutdown(AuthenticatedUser actor, long vmId) {
+ public MessageResponse shutdown(AuthenticatedUser actor, UUID vmId) {
Controllable controllable = requireMemberControllableVm(actor, vmId);
- requireStopAllowed(vmId, controllable.role());
- claimPowerAction(vmId, PowerAction.SHUTDOWN, List.of(VmStatus.RUNNING),
+ long id = controllable.vm().getId();
+ requireStopAllowed(id, controllable.role());
+ claimPowerAction(id, PowerAction.SHUTDOWN, List.of(VmStatus.RUNNING),
"RUNNING 상태의 VM만 종료할 수 있습니다.");
long actorId = actor.id();
- enqueueAfterCommit(() -> vmPowerJobs.shutdown(vmId, actorId));
+ enqueueAfterCommit(() -> vmPowerJobs.shutdown(id, actorId));
return new MessageResponse("VM 종료 요청을 접수했습니다. 잠시 후 상태가 갱신됩니다.");
}
@Transactional
- public MessageResponse reboot(AuthenticatedUser actor, long vmId) {
+ public MessageResponse reboot(AuthenticatedUser actor, UUID vmId) {
Controllable controllable = requireMemberControllableVm(actor, vmId);
- requireStopAllowed(vmId, controllable.role());
+ long id = controllable.vm().getId();
+ requireStopAllowed(id, controllable.role());
// Intent is visible immediately (and force-stop can target a hung
// reboot); the CAS loses to a concurrent transition OR a live claim → 409.
- if (vmRepository.claimReboot(vmId, VmStatus.RUNNING, VmStatus.REBOOTING, Instant.now()) == 0) {
- throw powerConflict(vmId, "RUNNING 상태의 VM만 재부팅할 수 있습니다.");
+ if (vmRepository.claimReboot(id, VmStatus.RUNNING, VmStatus.REBOOTING, Instant.now()) == 0) {
+ throw powerConflict(id, "RUNNING 상태의 VM만 재부팅할 수 있습니다.");
}
long actorId = actor.id();
- enqueueAfterCommit(() -> vmPowerJobs.reboot(vmId, actorId));
+ enqueueAfterCommit(() -> vmPowerJobs.reboot(id, actorId));
return new MessageResponse("VM 재부팅 요청을 접수했습니다. 잠시 후 상태가 갱신됩니다.");
}
@Transactional
- public MessageResponse forceStop(AuthenticatedUser actor, long vmId) {
+ public MessageResponse forceStop(AuthenticatedUser actor, UUID vmId) {
Controllable controllable = requireMemberControllableVm(actor, vmId);
- requireStopAllowed(vmId, controllable.role());
- claimPowerAction(vmId, PowerAction.FORCE_STOP,
+ long id = controllable.vm().getId();
+ requireStopAllowed(id, controllable.role());
+ claimPowerAction(id, PowerAction.FORCE_STOP,
List.of(VmStatus.RUNNING, VmStatus.REBOOTING),
"RUNNING 또는 REBOOTING 상태의 VM만 강제 종료할 수 있습니다.");
long actorId = actor.id();
- enqueueAfterCommit(() -> vmPowerJobs.forceStop(vmId, actorId));
+ enqueueAfterCommit(() -> vmPowerJobs.forceStop(id, actorId));
return new MessageResponse("VM 강제 종료 요청을 접수했습니다. 잠시 후 상태가 갱신됩니다.");
}
/* ─── admin intervention (org-scoped, stop-protection bypass, audited) ─── */
@Transactional
- public MessageResponse adminStart(AuthenticatedUser actor, long vmId, String ip) {
+ public MessageResponse adminStart(AuthenticatedUser actor, UUID vmId, String ip) {
Vm vm = adminVmAccess.requireOrgScopedVm(actor, vmId);
+ long id = vm.getId();
// Same expiry guard as the member path: extend the period first.
if (vm.getEndDate() != null && vm.getEndDate().isBefore(ClockConfig.todayKst(clock))) {
throw new ApiException(HttpStatus.CONFLICT, ErrorCodes.VM_EXPIRED,
@@ -150,46 +156,49 @@ public MessageResponse adminStart(AuthenticatedUser actor, long vmId, String ip)
"사용 기간(종료일 %s)이 만료되어 시작할 수 없습니다. 먼저 기간을 연장해 주세요."
.formatted(vm.getEndDate()));
}
- claimPowerAction(vmId, PowerAction.START, List.of(VmStatus.STOPPED),
+ claimPowerAction(id, PowerAction.START, List.of(VmStatus.STOPPED),
"STOPPED 상태의 VM만 시작할 수 있습니다.");
recordAdminPowerAudit(actor, vm, AuditService.VM_ADMIN_START, ip);
long actorId = actor.id();
- enqueueAfterCommit(() -> vmPowerJobs.start(vmId, actorId));
+ enqueueAfterCommit(() -> vmPowerJobs.start(id, actorId));
return new MessageResponse("VM 시작 요청을 접수했습니다. 잠시 후 상태가 갱신됩니다.");
}
@Transactional
- public MessageResponse adminShutdown(AuthenticatedUser actor, long vmId, String ip) {
+ public MessageResponse adminShutdown(AuthenticatedUser actor, UUID vmId, String ip) {
Vm vm = adminVmAccess.requireOrgScopedVm(actor, vmId);
- claimPowerAction(vmId, PowerAction.SHUTDOWN, List.of(VmStatus.RUNNING),
+ long id = vm.getId();
+ claimPowerAction(id, PowerAction.SHUTDOWN, List.of(VmStatus.RUNNING),
"RUNNING 상태의 VM만 종료할 수 있습니다.");
recordAdminPowerAudit(actor, vm, AuditService.VM_ADMIN_SHUTDOWN, ip);
long actorId = actor.id();
- enqueueAfterCommit(() -> vmPowerJobs.shutdown(vmId, actorId));
+ enqueueAfterCommit(() -> vmPowerJobs.shutdown(id, actorId));
return new MessageResponse("VM 종료 요청을 접수했습니다. 잠시 후 상태가 갱신됩니다.");
}
@Transactional
- public MessageResponse adminReboot(AuthenticatedUser actor, long vmId, String ip) {
+ public MessageResponse adminReboot(AuthenticatedUser actor, UUID vmId, String ip) {
Vm vm = adminVmAccess.requireOrgScopedVm(actor, vmId);
- if (vmRepository.claimReboot(vmId, VmStatus.RUNNING, VmStatus.REBOOTING, Instant.now()) == 0) {
- throw powerConflict(vmId, "RUNNING 상태의 VM만 재부팅할 수 있습니다.");
+ long id = vm.getId();
+ if (vmRepository.claimReboot(id, VmStatus.RUNNING, VmStatus.REBOOTING, Instant.now()) == 0) {
+ throw powerConflict(id, "RUNNING 상태의 VM만 재부팅할 수 있습니다.");
}
recordAdminPowerAudit(actor, vm, AuditService.VM_ADMIN_REBOOT, ip);
long actorId = actor.id();
- enqueueAfterCommit(() -> vmPowerJobs.reboot(vmId, actorId));
+ enqueueAfterCommit(() -> vmPowerJobs.reboot(id, actorId));
return new MessageResponse("VM 재부팅 요청을 접수했습니다. 잠시 후 상태가 갱신됩니다.");
}
@Transactional
- public MessageResponse adminForceStop(AuthenticatedUser actor, long vmId, String ip) {
+ public MessageResponse adminForceStop(AuthenticatedUser actor, UUID vmId, String ip) {
Vm vm = adminVmAccess.requireOrgScopedVm(actor, vmId);
- claimPowerAction(vmId, PowerAction.FORCE_STOP,
+ long id = vm.getId();
+ claimPowerAction(id, PowerAction.FORCE_STOP,
List.of(VmStatus.RUNNING, VmStatus.REBOOTING),
"RUNNING 또는 REBOOTING 상태의 VM만 강제 종료할 수 있습니다.");
recordAdminPowerAudit(actor, vm, AuditService.VM_ADMIN_FORCE_STOP, ip);
long actorId = actor.id();
- enqueueAfterCommit(() -> vmPowerJobs.forceStop(vmId, actorId));
+ enqueueAfterCommit(() -> vmPowerJobs.forceStop(id, actorId));
return new MessageResponse("VM 강제 종료 요청을 접수했습니다. 잠시 후 상태가 갱신됩니다.");
}
@@ -199,7 +208,7 @@ public MessageResponse adminForceStop(AuthenticatedUser actor, long vmId, String
* member path). Reads are not audited, matching the other admin surfaces.
*/
private void recordAdminPowerAudit(AuthenticatedUser actor, Vm vm, String action, String ip) {
- auditService.recordAfterCommit(actor.id(), actor.role().name(), action, "vm", vm.getId(),
+ auditService.recordAfterCommit(actor.id(), actor.role().name(), action, "vm", vm.getPublicId(),
Map.of("fromStatus", vm.getStatus().name()), ip);
}
@@ -236,7 +245,7 @@ private record Controllable(Vm vm, ResourceRole role) {
* 404 (masking), a member below MEMBER answers 403. The role is returned so
* stop-protected ops can additionally require EDITOR.
*/
- private Controllable requireMemberControllableVm(AuthenticatedUser actor, long vmId) {
+ private Controllable requireMemberControllableVm(AuthenticatedUser actor, UUID vmId) {
VmAccess access = vmAccessService.of(actor, vmId);
Vm vm = access.requireAtLeast(ResourceRole.MEMBER,
"VM을 제어할 권한이 없습니다", "이 VM의 참여자 이상만 VM 전원을 제어할 수 있습니다.");
diff --git a/src/main/java/kr/ac/pusan/pickle/vm/VmMetricsService.java b/src/main/java/kr/ac/pusan/pickle/vm/VmMetricsService.java
index f1c17c1a..b4e770a0 100644
--- a/src/main/java/kr/ac/pusan/pickle/vm/VmMetricsService.java
+++ b/src/main/java/kr/ac/pusan/pickle/vm/VmMetricsService.java
@@ -2,6 +2,7 @@
import java.time.Clock;
import java.util.List;
+import java.util.UUID;
import kr.ac.pusan.pickle.access.VmAccessService;
import kr.ac.pusan.pickle.common.error.ApiException;
import kr.ac.pusan.pickle.common.error.ErrorCodes;
@@ -59,7 +60,7 @@ public VmMetricsService(VmAccessService vmAccessService, NodeRepository nodeRepo
this.clock = clock;
}
- public VmMetricsResponse metrics(AuthenticatedUser actor, long vmId, RrdTimeframe timeframe) {
+ public VmMetricsResponse metrics(AuthenticatedUser actor, UUID vmId, RrdTimeframe timeframe) {
Vm vm = vmAccessService.of(actor, vmId).requireVisible();
if (vm.getProxmoxVmid() == null || vm.getStatus() == VmStatus.DELETED) {
return new VmMetricsResponse(timeframe.name(), clock.instant(), false, NOT_PROVISIONED,
diff --git a/src/main/java/kr/ac/pusan/pickle/vm/VmPasswordService.java b/src/main/java/kr/ac/pusan/pickle/vm/VmPasswordService.java
index 76fdeeff..0711f8ad 100644
--- a/src/main/java/kr/ac/pusan/pickle/vm/VmPasswordService.java
+++ b/src/main/java/kr/ac/pusan/pickle/vm/VmPasswordService.java
@@ -3,6 +3,7 @@
import java.time.Instant;
import java.util.Map;
import java.util.Set;
+import java.util.UUID;
import kr.ac.pusan.pickle.access.ResourceRole;
import kr.ac.pusan.pickle.access.VmAccess;
import kr.ac.pusan.pickle.access.VmAccessService;
@@ -78,11 +79,12 @@ public VmPasswordService(VmRepository vmRepository,
/** Reveals the stored password; min role is per-VM {@code password_reveal_min_role}. */
@Transactional
- public VmPasswordResponse reveal(AuthenticatedUser actor, long vmId, String ip) {
+ public VmPasswordResponse reveal(AuthenticatedUser actor, UUID vmId, String ip) {
MemberVm memberVm = requireMemberVm(actor, vmId);
Vm vm = memberVm.vm();
+ long id = vm.getId();
ResourceRole minRole =
- vmSettingsService.role(vmId, VmSettingsService.PASSWORD_REVEAL_MIN_ROLE);
+ vmSettingsService.role(id, VmSettingsService.PASSWORD_REVEAL_MIN_ROLE);
if (!memberVm.role().atLeast(minRole)) {
throw new ApiException(HttpStatus.FORBIDDEN, ErrorCodes.WORKSPACE_ROLE_INSUFFICIENT,
"비밀번호를 열람할 권한이 없습니다",
@@ -101,9 +103,9 @@ public VmPasswordResponse reveal(AuthenticatedUser actor, long vmId, String ip)
"저장된 비밀번호가 없습니다. 비밀번호 재생성으로 새 비밀번호를 만들 수 있습니다.");
}
String password = credentialCipher.decrypt(vm.getPasswordEnc());
- vmRepository.recordPasswordViewed(vmId, Instant.now());
+ vmRepository.recordPasswordViewed(id, Instant.now());
auditService.record(actor.id(), actor.role().name(), AuditService.VM_PASSWORD_REVEAL,
- "vm", vmId, Map.of(), ip);
+ "vm", vm.getPublicId(), Map.of(), ip);
return new VmPasswordResponse(password, vm.getSshUsername(), sshHost, sshPort);
}
@@ -114,7 +116,7 @@ public VmPasswordResponse reveal(AuthenticatedUser actor, long vmId, String ip)
* the value; generation is always the platform's.
*/
@Transactional
- public VmPasswordResponse regenerate(AuthenticatedUser actor, long vmId, String ip) {
+ public VmPasswordResponse regenerate(AuthenticatedUser actor, UUID vmId, String ip) {
MemberVm memberVm = requireMemberVm(actor, vmId);
Vm vm = memberVm.vm();
if (!memberVm.role().atLeast(ResourceRole.EDITOR)) {
@@ -135,10 +137,10 @@ public VmPasswordResponse regenerate(AuthenticatedUser actor, long vmId, String
if (!applied) {
throw agentUnavailable();
}
- vmRepository.storeCredentials(vmId, credentialCipher.encrypt(password),
+ vmRepository.storeCredentials(vm.getId(), credentialCipher.encrypt(password),
passwordEncoder.encode(password), Instant.now());
auditService.record(actor.id(), actor.role().name(), AuditService.VM_PASSWORD_REGENERATE,
- "vm", vmId, Map.of(), ip);
+ "vm", vm.getPublicId(), Map.of(), ip);
return new VmPasswordResponse(password, vm.getSshUsername(), sshHost, sshPort);
}
@@ -146,7 +148,7 @@ private record MemberVm(Vm vm, ResourceRole role) {
}
/** Non-member answers 404 (masking); returns the VM and the actor's role. */
- private MemberVm requireMemberVm(AuthenticatedUser actor, long vmId) {
+ private MemberVm requireMemberVm(AuthenticatedUser actor, UUID vmId) {
VmAccess access = vmAccessService.of(actor, vmId);
return new MemberVm(access.requireVisible(), access.role());
}
diff --git a/src/main/java/kr/ac/pusan/pickle/vm/VmQueryService.java b/src/main/java/kr/ac/pusan/pickle/vm/VmQueryService.java
index bd4cbd84..99ffff45 100644
--- a/src/main/java/kr/ac/pusan/pickle/vm/VmQueryService.java
+++ b/src/main/java/kr/ac/pusan/pickle/vm/VmQueryService.java
@@ -1,6 +1,7 @@
package kr.ac.pusan.pickle.vm;
import java.util.List;
+import java.util.UUID;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
@@ -20,6 +21,8 @@
import kr.ac.pusan.pickle.ipam.IpAddressResolver;
import kr.ac.pusan.pickle.orgs.Org;
import kr.ac.pusan.pickle.orgs.OrgRepository;
+import kr.ac.pusan.pickle.inventory.OsImage;
+import kr.ac.pusan.pickle.inventory.OsImageRepository;
import kr.ac.pusan.pickle.provisioning.ProvisioningTaskRepository;
import kr.ac.pusan.pickle.provisioning.ProvisioningTaskStatus;
import kr.ac.pusan.pickle.publishing.Domain;
@@ -27,12 +30,15 @@
import kr.ac.pusan.pickle.publishing.DomainStatus;
import kr.ac.pusan.pickle.publishing.PublicationAssembler;
import kr.ac.pusan.pickle.publishing.dto.PublicationView;
+import kr.ac.pusan.pickle.request.Request;
+import kr.ac.pusan.pickle.request.RequestRepository;
import kr.ac.pusan.pickle.security.AuthenticatedUser;
import kr.ac.pusan.pickle.user.User;
import kr.ac.pusan.pickle.user.UserRepository;
import kr.ac.pusan.pickle.vm.dto.ProvisioningTaskResponse;
import kr.ac.pusan.pickle.vm.dto.VmDetailResponse;
import kr.ac.pusan.pickle.vm.dto.VmEventResponse;
+import kr.ac.pusan.pickle.vm.dto.VmReferences;
import kr.ac.pusan.pickle.vm.dto.VmSummaryResponse;
import kr.ac.pusan.pickle.vmsettings.VmSettingsService;
import org.springframework.beans.factory.annotation.Value;
@@ -68,6 +74,8 @@ public class VmQueryService {
private final UserRepository userRepository;
private final WorkspaceRepository workspaceRepository;
private final OrgRepository orgRepository;
+ private final OsImageRepository osImageRepository;
+ private final RequestRepository requestRepository;
private final IpAddressResolver ipAddressResolver;
private final ProvisioningTaskRepository provisioningTaskRepository;
private final VmEventRepository vmEventRepository;
@@ -81,6 +89,7 @@ public VmQueryService(VmRepository vmRepository, WorkspaceMemberRepository works
ResourceAccessGrantRepository grantRepository,
UserRepository userRepository,
WorkspaceRepository workspaceRepository, OrgRepository orgRepository,
+ OsImageRepository osImageRepository, RequestRepository requestRepository,
IpAddressResolver ipAddressResolver,
ProvisioningTaskRepository provisioningTaskRepository,
VmEventRepository vmEventRepository,
@@ -94,6 +103,8 @@ public VmQueryService(VmRepository vmRepository, WorkspaceMemberRepository works
this.userRepository = userRepository;
this.workspaceRepository = workspaceRepository;
this.orgRepository = orgRepository;
+ this.osImageRepository = osImageRepository;
+ this.requestRepository = requestRepository;
this.ipAddressResolver = ipAddressResolver;
this.provisioningTaskRepository = provisioningTaskRepository;
this.vmEventRepository = vmEventRepository;
@@ -104,7 +115,7 @@ public VmQueryService(VmRepository vmRepository, WorkspaceMemberRepository works
}
@Transactional(readOnly = true)
- public PageResponse list(AuthenticatedUser actor, Long workspaceId, int page, int size) {
+ public PageResponse list(AuthenticatedUser actor, UUID workspaceId, int page, int size) {
Page result = listPage(actor, workspaceId,
PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "id")));
return PageResponse.of(result.getContent(), result);
@@ -115,15 +126,20 @@ public PageResponse list(AuthenticatedUser actor, Long worksp
* that one set of visibility rules serves both surfaces.
*/
@Transactional(readOnly = true)
- public Page listPage(AuthenticatedUser actor, Long workspaceId, Pageable pageable) {
+ public Page listPage(AuthenticatedUser actor, UUID workspaceId, Pageable pageable) {
List memberships = workspaceMemberRepository.findWithWorkspaceByUserId(actor.id());
- Map workspaceNames = memberships.stream()
- .collect(Collectors.toMap(m -> m.getWorkspace().getId(), m -> m.getWorkspace().getName()));
- List workspaceIds = List.copyOf(workspaceNames.keySet());
+ Map workspaces = memberships.stream()
+ .collect(Collectors.toMap(m -> m.getWorkspace().getId(), WorkspaceMember::getWorkspace,
+ (first, second) -> first));
+ List workspaceIds = List.copyOf(workspaces.keySet());
Page result;
if (workspaceId != null) {
- result = workspaceIds.contains(workspaceId)
- ? vmRepository.findByWorkspaceId(workspaceId, pageable)
+ // An unknown workspace id and one outside my memberships answer the
+ // same empty page: the contract defines no 403 for the list.
+ Long filterId = workspaceRepository.findByPublicId(workspaceId)
+ .map(Workspace::getId).orElse(null);
+ result = filterId != null && workspaceIds.contains(filterId)
+ ? vmRepository.findByWorkspaceId(filterId, pageable)
: Page.empty(pageable);
} else {
result = workspaceIds.isEmpty()
@@ -132,6 +148,7 @@ public Page listPage(AuthenticatedUser actor, Long workspaceI
}
List vms = result.getContent();
Map orgNames = orgNames(vms);
+ Map requestIds = requestPublicIds(vms);
Map displayNames = vmSettingsService.displayNames(
vms.stream().map(Vm::getId).toList());
Set ownedWorkspaceIds = memberships.stream()
@@ -141,17 +158,20 @@ public Page listPage(AuthenticatedUser actor, Long workspaceI
VmListAccess access = listAccess(actor.id(), vms);
return new PageImpl<>(vms.stream()
.map(vm -> {
- String workspaceName = workspaceNames.getOrDefault(vm.getWorkspaceId(), "");
+ Workspace workspace = workspaces.get(vm.getWorkspaceId());
+ UUID workspacePublicId = workspace == null ? null : workspace.getPublicId();
+ String workspaceName = workspace == null ? "" : workspace.getName();
String displayName = displayNames.get(vm.getId());
// Only a grant opens the row. A workspace owner without one gets
// the same restricted row as anyone else, plus the flag that
// lets the console offer them the access list — the way back
// in for a VM whose own owner is gone.
if (access.reachable().contains(vm.getId())) {
- return VmSummaryResponse.from(vm, workspaceName, orgNames.get(vm.getOrgId()),
- displayName);
+ return VmSummaryResponse.from(vm, workspacePublicId, workspaceName,
+ orgNames.get(vm.getOrgId()), displayName,
+ requestIds.get(vm.getRequestId()));
}
- return VmSummaryResponse.restricted(vm, workspaceName, displayName,
+ return VmSummaryResponse.restricted(vm, workspacePublicId, workspaceName, displayName,
access.ownerNames().getOrDefault(vm.getId(), List.of()),
ownedWorkspaceIds.contains(vm.getWorkspaceId()));
})
@@ -189,6 +209,22 @@ private VmListAccess listAccess(long userId, List vms) {
return new VmListAccess(reachable, ownerNames);
}
+ /**
+ * Batch request-reference join for the summary views. The summary reports
+ * which request produced the VM and had no join for it while the id was
+ * the VM's own column; a public id lives on the request row, so it needs
+ * one. Shared with the admin list so both pages pay for it once.
+ */
+ public Map requestPublicIds(List vms) {
+ List requestIds = vms.stream().map(Vm::getRequestId)
+ .filter(java.util.Objects::nonNull).distinct().toList();
+ if (requestIds.isEmpty()) {
+ return Map.of();
+ }
+ return requestRepository.findAllById(requestIds).stream()
+ .collect(Collectors.toMap(Request::getId, Request::getPublicId));
+ }
+
/** Batch org-name join for the summary views (avoids N+1). */
private Map orgNames(List vms) {
List orgIds = vms.stream().map(Vm::getOrgId).filter(java.util.Objects::nonNull)
@@ -201,7 +237,7 @@ private Map orgNames(List vms) {
}
@Transactional(readOnly = true)
- public VmDetailResponse get(AuthenticatedUser actor, long vmId) {
+ public VmDetailResponse get(AuthenticatedUser actor, UUID vmId) {
VmAccess access = vmAccessService.of(actor, vmId);
return detailOf(access.requireVisible(), access.role(), access.manages());
}
@@ -226,10 +262,20 @@ public VmDetailResponse detailOf(Vm vm, ResourceRole myResourceRole,
long vmId = vm.getId();
// History-preserving joins: a DELETED vm's workspace/org may have been
// deleted afterwards, so this deliberately reads all workspaces/orgs.
- String workspaceName = workspaceRepository.findById(vm.getWorkspaceId())
- .map(Workspace::getName).orElse("");
- String orgName = vm.getOrgId() == null ? null
- : orgRepository.findById(vm.getOrgId()).map(Org::getName).orElse(null);
+ Workspace workspace = workspaceRepository.findById(vm.getWorkspaceId()).orElse(null);
+ String workspaceName = workspace == null ? "" : workspace.getName();
+ Org org = vm.getOrgId() == null ? null
+ : orgRepository.findById(vm.getOrgId()).orElse(null);
+ String orgName = org == null ? null : org.getName();
+ VmReferences refs = new VmReferences(
+ workspace == null ? null : workspace.getPublicId(),
+ org == null ? null : org.getPublicId(),
+ vm.getImageId() == null ? null
+ : osImageRepository.findById(vm.getImageId()).map(OsImage::getPublicId).orElse(null),
+ vm.getRequestId() == null ? null
+ : requestRepository.findById(vm.getRequestId()).map(Request::getPublicId).orElse(null),
+ vm.getDeleteRequestedBy() == null ? null
+ : userRepository.findById(vm.getDeleteRequestedBy()).map(User::getPublicId).orElse(null));
String displayName = vmSettingsService.string(vmId, VmSettingsService.DISPLAY_NAME);
String ipAddress = liveIpAddress(vm);
ProvisioningTaskResponse provisioning = provisioningTaskRepository
@@ -245,20 +291,19 @@ public VmDetailResponse detailOf(Vm vm, ResourceRole myResourceRole,
.filter(domain -> domain.getStatus() != DomainStatus.REMOVED)
.filter(publicationAssembler::hasLiveRoute)
.sorted(java.util.Comparator.comparing(Domain::getId))
- .map(publicationAssembler::toPublication)
+ .map(domain -> publicationAssembler.toPublication(domain, vm.getPublicId()))
.toList();
boolean passwordRevealAllowed = myResourceRole != null && myResourceRole.atLeast(
vmSettingsService.role(vmId, VmSettingsService.PASSWORD_REVEAL_MIN_ROLE));
- return VmDetailResponse.from(vm, workspaceName, orgName, displayName, ipAddress, sshHost,
+ return VmDetailResponse.from(vm, refs, workspaceName, orgName, displayName, ipAddress, sshHost,
myResourceRole, passwordRevealAllowed, accessManageAllowed, provisioning,
publications);
}
/** Newest-first lifecycle history (contract op {@code listVmEvents}). */
@Transactional(readOnly = true)
- public PageResponse events(AuthenticatedUser actor, long vmId, int page, int size) {
- requireVisibleVm(actor, vmId);
- return eventsOf(vmId, page, size);
+ public PageResponse events(AuthenticatedUser actor, UUID vmId, int page, int size) {
+ return eventsOf(requireVisibleVm(actor, vmId).getId(), page, size);
}
/**
@@ -269,8 +314,12 @@ public PageResponse events(AuthenticatedUser actor, long vmId,
public PageResponse eventsOf(long vmId, int page, int size) {
Page result = vmEventRepository.findByVmIdOrderByIdDesc(vmId,
PageRequest.of(page, size));
- return PageResponse.of(result.getContent().stream().map(VmEventResponse::from).toList(),
- result);
+ Map actorIds = userRepository.findAllById(result.getContent().stream()
+ .map(VmEvent::getActorId).filter(java.util.Objects::nonNull).distinct().toList())
+ .stream().collect(Collectors.toMap(User::getId, User::getPublicId));
+ return PageResponse.of(result.getContent().stream()
+ .map(event -> VmEventResponse.from(event, actorIds.get(event.getActorId())))
+ .toList(), result);
}
/**
@@ -278,7 +327,7 @@ public PageResponse eventsOf(long vmId, int page, int size) {
* non-member cannot probe which VM ids exist (masking, contract v0.3.2 —
* consistent with the power/delete paths).
*/
- private Vm requireVisibleVm(AuthenticatedUser actor, long vmId) {
+ private Vm requireVisibleVm(AuthenticatedUser actor, UUID vmId) {
return vmAccessService.of(actor, vmId).requireVisible();
}
diff --git a/src/main/java/kr/ac/pusan/pickle/vm/VmRepository.java b/src/main/java/kr/ac/pusan/pickle/vm/VmRepository.java
index 037622ba..9c6a2fa1 100644
--- a/src/main/java/kr/ac/pusan/pickle/vm/VmRepository.java
+++ b/src/main/java/kr/ac/pusan/pickle/vm/VmRepository.java
@@ -3,6 +3,8 @@
import java.time.Instant;
import java.util.Collection;
import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
@@ -14,6 +16,9 @@
public interface VmRepository extends JpaRepository, JpaSpecificationExecutor {
+ /** Resolution of the identifier this row wears outside the API boundary. */
+ Optional findByPublicId(UUID publicId);
+
boolean existsByHostname(String hostname);
/**
diff --git a/src/main/java/kr/ac/pusan/pickle/vm/dto/VmDeletionResponse.java b/src/main/java/kr/ac/pusan/pickle/vm/dto/VmDeletionResponse.java
index 0ff8b5e5..804930af 100644
--- a/src/main/java/kr/ac/pusan/pickle/vm/dto/VmDeletionResponse.java
+++ b/src/main/java/kr/ac/pusan/pickle/vm/dto/VmDeletionResponse.java
@@ -1,6 +1,7 @@
package kr.ac.pusan.pickle.vm.dto;
import java.time.Instant;
+import java.util.UUID;
import kr.ac.pusan.pickle.vm.Vm;
import kr.ac.pusan.pickle.vm.VmDeleteKind;
import kr.ac.pusan.pickle.vm.VmStatus;
@@ -16,12 +17,12 @@ public record VmDeletionResponse(
VmDeleteKind kind,
Instant scheduledFor,
Instant requestedAt,
- Long requestedById,
+ @Nullable UUID requestedById,
@Nullable String reason,
boolean cancelable) {
/** Maps the {@code delete_*} columns; null when no deletion is pending. */
- public static VmDeletionResponse from(Vm vm) {
+ public static VmDeletionResponse from(Vm vm, UUID requestedById) {
if (vm.getDeleteKind() == null) {
return null;
}
@@ -30,7 +31,7 @@ public static VmDeletionResponse from(Vm vm) {
&& vm.getDeleteScheduledFor() != null
&& vm.getDeleteScheduledFor().isAfter(Instant.now());
return new VmDeletionResponse(vm.getDeleteKind(), vm.getDeleteScheduledFor(),
- vm.getDeleteRequestedAt(), vm.getDeleteRequestedBy(), vm.getDeleteReason(),
+ vm.getDeleteRequestedAt(), requestedById, vm.getDeleteReason(),
cancelable);
}
}
diff --git a/src/main/java/kr/ac/pusan/pickle/vm/dto/VmDetailResponse.java b/src/main/java/kr/ac/pusan/pickle/vm/dto/VmDetailResponse.java
index 6b3f84ad..1707d116 100644
--- a/src/main/java/kr/ac/pusan/pickle/vm/dto/VmDetailResponse.java
+++ b/src/main/java/kr/ac/pusan/pickle/vm/dto/VmDetailResponse.java
@@ -4,6 +4,7 @@
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
+import java.util.UUID;
import kr.ac.pusan.pickle.access.ResourceRole;
import kr.ac.pusan.pickle.publishing.dto.PublicationView;
import kr.ac.pusan.pickle.vm.Vm;
@@ -19,23 +20,27 @@
* VM is unpublished.
*/
public record VmDetailResponse(
- Long id,
+ UUID id,
String name,
String hostname,
VmStatus status,
int vcpu,
int memoryMb,
int diskGb,
- Long workspaceId,
+ @Schema(description = "소유 워크스페이스. 행이 사라진 경우에만 null입니다.")
+ @Nullable UUID workspaceId,
String workspaceName,
@Nullable String orgName,
@Nullable String displayName,
- Long requestId,
+ @Schema(description = "이 VM을 만든 신청. 행이 사라진 경우에만 null입니다.")
+ @Nullable UUID requestId,
@Nullable String statusDetail,
boolean sshGatewayBlocked,
Instant createdAt,
- Long orgId,
- Long imageId,
+ @Schema(description = "소속 기관. 행이 사라진 경우에만 null입니다.")
+ @Nullable UUID orgId,
+ @Schema(description = "생성에 쓰인 OS 이미지. 행이 사라진 경우에만 null입니다.")
+ @Nullable UUID imageId,
@Nullable String ipAddress,
String sshUsername,
String sshHost,
@@ -67,7 +72,8 @@ public record VmDetailResponse(
* Admin surfaces pass {@code myResourceRole} null and get them all false —
* their own authorization is org-scoped and lives elsewhere.
*/
- public static VmDetailResponse from(Vm vm, String workspaceName, String orgName, String displayName,
+ public static VmDetailResponse from(Vm vm, VmReferences refs, String workspaceName,
+ String orgName, String displayName,
String ipAddress, String sshHost, ResourceRole myResourceRole,
boolean passwordRevealAllowed, boolean accessManageAllowed,
ProvisioningTaskResponse provisioning, List publications) {
@@ -75,13 +81,13 @@ public static VmDetailResponse from(Vm vm, String workspaceName, String orgName,
&& myResourceRole.atLeast(ResourceRole.MEMBER);
boolean atLeastEditor = myResourceRole != null
&& myResourceRole.atLeast(ResourceRole.EDITOR);
- return new VmDetailResponse(vm.getId(), vm.getName(), vm.getHostname(), vm.getStatus(),
- vm.getVcpu(), vm.getMemoryMb(), vm.getDiskGb(), vm.getWorkspaceId(), workspaceName, orgName,
- displayName, vm.getRequestId(), vm.getStatusDetail(), vm.isSshGatewayBlocked(),
- vm.getCreatedAt(), vm.getOrgId(),
- vm.getImageId(), ipAddress, vm.getSshUsername(), sshHost, myResourceRole,
+ return new VmDetailResponse(vm.getPublicId(), vm.getName(), vm.getHostname(), vm.getStatus(),
+ vm.getVcpu(), vm.getMemoryMb(), vm.getDiskGb(), refs.workspaceId(), workspaceName, orgName,
+ displayName, refs.requestId(), vm.getStatusDetail(), vm.isSshGatewayBlocked(),
+ vm.getCreatedAt(), refs.orgId(),
+ refs.imageId(), ipAddress, vm.getSshUsername(), sshHost, myResourceRole,
vm.getStartDate(), vm.getEndDate(), vm.getExpiryStoppedAt(), provisioning,
- VmDeletionResponse.from(vm), publications,
+ VmDeletionResponse.from(vm, refs.deleteRequestedById()), publications,
vm.getPasswordEnc() != null, passwordRevealAllowed,
atLeastMember, atLeastMember, atLeastEditor, accessManageAllowed,
accessManageAllowed, vm.getUpdatedAt());
diff --git a/src/main/java/kr/ac/pusan/pickle/vm/dto/VmEventResponse.java b/src/main/java/kr/ac/pusan/pickle/vm/dto/VmEventResponse.java
index 462eeb38..9ec7c1df 100644
--- a/src/main/java/kr/ac/pusan/pickle/vm/dto/VmEventResponse.java
+++ b/src/main/java/kr/ac/pusan/pickle/vm/dto/VmEventResponse.java
@@ -1,20 +1,21 @@
package kr.ac.pusan.pickle.vm.dto;
import java.time.Instant;
+import java.util.UUID;
import kr.ac.pusan.pickle.vm.VmEvent;
import kr.ac.pusan.pickle.vm.VmEventType;
import org.jspecify.annotations.Nullable;
/** Contract schema {@code VmEvent} ({@code actorId} null = system/automatic). */
public record VmEventResponse(
- Long id,
+ UUID id,
VmEventType type,
- @Nullable Long actorId,
+ @Nullable UUID actorId,
@Nullable String detail,
Instant createdAt) {
- public static VmEventResponse from(VmEvent event) {
- return new VmEventResponse(event.getId(), event.getType(), event.getActorId(),
+ public static VmEventResponse from(VmEvent event, UUID actorId) {
+ return new VmEventResponse(event.getPublicId(), event.getType(), actorId,
event.getDetail(), event.getCreatedAt());
}
}
diff --git a/src/main/java/kr/ac/pusan/pickle/vm/dto/VmReferences.java b/src/main/java/kr/ac/pusan/pickle/vm/dto/VmReferences.java
new file mode 100644
index 00000000..402bd31d
--- /dev/null
+++ b/src/main/java/kr/ac/pusan/pickle/vm/dto/VmReferences.java
@@ -0,0 +1,25 @@
+package kr.ac.pusan.pickle.vm.dto;
+
+import java.util.UUID;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * The public identifiers of the rows a VM points at, resolved by the caller.
+ *
+ *
A VM carries its references as internal foreign keys, and the public face
+ * of each is a column on the referenced row — so the join has to happen where
+ * the query is, not in the view. This carries the result across rather than
+ * five more positional {@code UUID} parameters, which are trivially swapped.
+ *
+ *
Every field is nullable because these are history-preserving joins: a
+ * destroyed VM keeps its row after the workspace, org or account it named is
+ * gone, and a reference that no longer resolves is reported as absent rather
+ * than as a broken id.
+ */
+public record VmReferences(
+ @Nullable UUID workspaceId,
+ @Nullable UUID orgId,
+ @Nullable UUID imageId,
+ @Nullable UUID requestId,
+ @Nullable UUID deleteRequestedById) {
+}
diff --git a/src/main/java/kr/ac/pusan/pickle/vm/dto/VmSummaryResponse.java b/src/main/java/kr/ac/pusan/pickle/vm/dto/VmSummaryResponse.java
index 21821db6..9b33dea2 100644
--- a/src/main/java/kr/ac/pusan/pickle/vm/dto/VmSummaryResponse.java
+++ b/src/main/java/kr/ac/pusan/pickle/vm/dto/VmSummaryResponse.java
@@ -4,6 +4,7 @@
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
+import java.util.UUID;
import kr.ac.pusan.pickle.vm.Vm;
import kr.ac.pusan.pickle.vm.VmStatus;
import org.jspecify.annotations.Nullable;
@@ -22,7 +23,7 @@
* display name there instead — see {@link #restricted}.
*/
public record VmSummaryResponse(
- Long id,
+ UUID id,
@Schema(description = "SSH 슬러그. 접근 권한이 없으면 대신 표시 이름이 들어갑니다.")
String name,
@Schema(description = "SSH 슬러그. 접근 권한이 없으면 생략됩니다.")
@@ -34,11 +35,12 @@ public record VmSummaryResponse(
@Nullable Integer memoryMb,
@Schema(description = "디스크(GiB). 접근 권한이 없으면 생략됩니다.")
@Nullable Integer diskGb,
- Long workspaceId,
+ @Schema(description = "소유 워크스페이스. 행이 사라진 경우에만 null입니다.")
+ @Nullable UUID workspaceId,
String workspaceName,
@Nullable String orgName,
@Nullable String displayName,
- @Nullable Long requestId,
+ @Nullable UUID requestId,
@Nullable String statusDetail,
@Nullable Boolean sshGatewayBlocked,
@Nullable LocalDate endDate,
@@ -51,11 +53,11 @@ public record VmSummaryResponse(
@Schema(description = "접근 권한이 없어도 접근 권한 목록을 관리할 수 있는지. 워크스페이스 소유자가 참입니다.")
boolean accessManageAllowed) {
- public static VmSummaryResponse from(Vm vm, String workspaceName, String orgName,
- String displayName) {
- return new VmSummaryResponse(vm.getId(), vm.getName(), vm.getHostname(), vm.getStatus(),
- vm.getVcpu(), vm.getMemoryMb(), vm.getDiskGb(), vm.getWorkspaceId(), workspaceName, orgName,
- displayName, vm.getRequestId(), vm.getStatusDetail(), vm.isSshGatewayBlocked(),
+ public static VmSummaryResponse from(Vm vm, UUID workspaceId, String workspaceName,
+ String orgName, String displayName, UUID requestId) {
+ return new VmSummaryResponse(vm.getPublicId(), vm.getName(), vm.getHostname(), vm.getStatus(),
+ vm.getVcpu(), vm.getMemoryMb(), vm.getDiskGb(), workspaceId, workspaceName, orgName,
+ displayName, requestId, vm.getStatusDetail(), vm.isSshGatewayBlocked(),
vm.getEndDate(), vm.getExpiryStoppedAt(), vm.getCreatedAt(), false, List.of(),
false);
}
@@ -69,10 +71,10 @@ public static VmSummaryResponse from(Vm vm, String workspaceName, String orgName
* null so the row still renders as one label — a list that shows both would
* print the name twice.
*/
- public static VmSummaryResponse restricted(Vm vm, String workspaceName, String displayName,
- List ownerNames, boolean accessManageAllowed) {
- return new VmSummaryResponse(vm.getId(), restrictedName(vm, displayName), null, vm.getStatus(),
- null, null, null, vm.getWorkspaceId(), workspaceName, null,
+ public static VmSummaryResponse restricted(Vm vm, UUID workspaceId, String workspaceName,
+ String displayName, List ownerNames, boolean accessManageAllowed) {
+ return new VmSummaryResponse(vm.getPublicId(), restrictedName(vm, displayName), null, vm.getStatus(),
+ null, null, null, workspaceId, workspaceName, null,
null, null, null, null,
null, null, vm.getCreatedAt(), true, ownerNames, accessManageAllowed);
}
@@ -84,6 +86,6 @@ public static VmSummaryResponse restricted(Vm vm, String workspaceName, String d
* {@code displayName || name}, so a null name would leave the row nameless.
*/
private static String restrictedName(Vm vm, String displayName) {
- return displayName != null && !displayName.isBlank() ? displayName : "VM #" + vm.getId();
+ return displayName != null && !displayName.isBlank() ? displayName : "VM #" + vm.getPublicId();
}
}
diff --git a/src/main/java/kr/ac/pusan/pickle/vmsettings/VmSettingsService.java b/src/main/java/kr/ac/pusan/pickle/vmsettings/VmSettingsService.java
index 30d33a07..4052b2a4 100644
--- a/src/main/java/kr/ac/pusan/pickle/vmsettings/VmSettingsService.java
+++ b/src/main/java/kr/ac/pusan/pickle/vmsettings/VmSettingsService.java
@@ -7,6 +7,7 @@
import java.util.List;
import java.util.Map;
import java.util.Set;
+import java.util.UUID;
import java.util.function.Function;
import java.util.stream.Collectors;
import kr.ac.pusan.pickle.access.ResourceRole;
@@ -206,8 +207,8 @@ public Map displayNames(java.util.Collection vmIds) {
/** EDITOR+ only; non-member answers 404 (existence masking). */
@Transactional(readOnly = true)
- public List get(AuthenticatedUser actor, long vmId) {
- Vm vm = vmRepository.findById(vmId).orElseThrow(VmAccessService::vmNotFound);
+ public List get(AuthenticatedUser actor, UUID vmId) {
+ Vm vm = vmRepository.findByPublicId(vmId).orElseThrow(VmAccessService::vmNotFound);
ResourceRole actorRole = memberRole(vm, actor);
if (!actorRole.atLeast(ResourceRole.EDITOR)) {
throw new ApiException(HttpStatus.FORBIDDEN, ErrorCodes.WORKSPACE_ROLE_INSUFFICIENT,
@@ -222,9 +223,10 @@ public List get(AuthenticatedUser actor, long vmId) {
* VM → 409. Every change is audited (old→new). Returns the full list.
*/
@Transactional
- public List patch(AuthenticatedUser actor, long vmId,
+ public List patch(AuthenticatedUser actor, UUID publicVmId,
Map settings, String ip) {
- Vm vm = vmRepository.findById(vmId).orElseThrow(VmAccessService::vmNotFound);
+ Vm vm = vmRepository.findByPublicId(publicVmId).orElseThrow(VmAccessService::vmNotFound);
+ long vmId = vm.getId();
ResourceRole actorRole = memberRole(vm, actor);
if (settings == null || settings.isEmpty()) {
throw ApiException.validationFailed(List.of(
@@ -278,7 +280,7 @@ public List patch(AuthenticatedUser actor, long vmId,
settingRepository.save(row);
}
auditService.recordAfterCommit(actor.id(), actor.role().name(), def.auditAction(),
- "vm", vmId, Map.of("key", def.key(), "old", oldValue, "new", newValue), ip);
+ "vm", vm.getPublicId(), Map.of("key", def.key(), "old", oldValue, "new", newValue), ip);
}
return buildViews(vm, actorRole);
}
diff --git a/src/main/java/kr/ac/pusan/pickle/workspace/Workspace.java b/src/main/java/kr/ac/pusan/pickle/workspace/Workspace.java
index 0acc550c..a0a76606 100644
--- a/src/main/java/kr/ac/pusan/pickle/workspace/Workspace.java
+++ b/src/main/java/kr/ac/pusan/pickle/workspace/Workspace.java
@@ -9,6 +9,7 @@
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
+import java.util.UUID;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.annotations.UpdateTimestamp;
@@ -22,6 +23,14 @@ public class Workspace {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
+ /**
+ * The identifier this row wears outside the API boundary. Internal joins,
+ * sorts and foreign keys keep using {@link #id}.
+ */
+ @JdbcTypeCode(SqlTypes.UUID)
+ @Column(name = "public_id", nullable = false, updatable = false, unique = true)
+ private UUID publicId = UUID.randomUUID();
+
@Enumerated(EnumType.STRING)
@JdbcTypeCode(SqlTypes.NAMED_ENUM)
@Column(nullable = false, columnDefinition = "workspace_kind")
@@ -61,6 +70,10 @@ public Long getId() {
return id;
}
+ public UUID getPublicId() {
+ return publicId;
+ }
+
public WorkspaceKind getKind() {
return kind;
}
diff --git a/src/main/java/kr/ac/pusan/pickle/workspace/WorkspaceController.java b/src/main/java/kr/ac/pusan/pickle/workspace/WorkspaceController.java
index 8719da7f..db73f40d 100644
--- a/src/main/java/kr/ac/pusan/pickle/workspace/WorkspaceController.java
+++ b/src/main/java/kr/ac/pusan/pickle/workspace/WorkspaceController.java
@@ -5,6 +5,7 @@
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import java.util.List;
+import java.util.UUID;
import kr.ac.pusan.pickle.workspace.dto.AddWorkspaceMemberRequest;
import kr.ac.pusan.pickle.workspace.dto.CreateWorkspaceRequest;
import kr.ac.pusan.pickle.workspace.dto.WorkspaceDetailResponse;
@@ -53,13 +54,13 @@ public WorkspaceDetailResponse createWorkspace(
@GetMapping("/{workspaceId}")
public WorkspaceDetailResponse getWorkspace(@AuthenticationPrincipal AuthenticatedUser principal,
- @PathVariable long workspaceId) {
+ @PathVariable UUID workspaceId) {
return workspaceService.get(principal, workspaceId);
}
@PatchMapping("/{workspaceId}")
public WorkspaceDetailResponse updateWorkspace(@AuthenticationPrincipal AuthenticatedUser principal,
- @PathVariable long workspaceId,
+ @PathVariable UUID workspaceId,
@Valid @RequestBody UpdateWorkspaceRequest request) {
return workspaceService.update(principal, workspaceId, request);
}
@@ -67,7 +68,7 @@ public WorkspaceDetailResponse updateWorkspace(@AuthenticationPrincipal Authenti
@DeleteMapping("/{workspaceId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteWorkspace(@AuthenticationPrincipal AuthenticatedUser principal,
- @PathVariable long workspaceId,
+ @PathVariable UUID workspaceId,
HttpServletRequest httpRequest) {
workspaceService.delete(principal, workspaceId, clientIp(httpRequest));
}
@@ -77,7 +78,7 @@ public void deleteWorkspace(@AuthenticationPrincipal AuthenticatedUser principal
@RequireReauth
public WorkspaceMemberResponse addWorkspaceMember(
@AuthenticationPrincipal AuthenticatedUser principal,
- @PathVariable long workspaceId,
+ @PathVariable UUID workspaceId,
@Valid @RequestBody AddWorkspaceMemberRequest request,
HttpServletRequest httpRequest) {
return workspaceService.addMember(principal, workspaceId, request, clientIp(httpRequest));
@@ -86,8 +87,8 @@ public WorkspaceMemberResponse addWorkspaceMember(
@PatchMapping("/{workspaceId}/members/{userId}")
@RequireReauth
public WorkspaceMemberResponse updateWorkspaceMember(@AuthenticationPrincipal AuthenticatedUser principal,
- @PathVariable long workspaceId,
- @PathVariable long userId,
+ @PathVariable UUID workspaceId,
+ @PathVariable UUID userId,
@Valid @RequestBody UpdateWorkspaceMemberRequest request,
HttpServletRequest httpRequest) {
return workspaceService.updateMemberRole(principal, workspaceId, userId, request, clientIp(httpRequest));
@@ -97,8 +98,8 @@ public WorkspaceMemberResponse updateWorkspaceMember(@AuthenticationPrincipal Au
@ResponseStatus(HttpStatus.NO_CONTENT)
@RequireReauth
public void removeWorkspaceMember(@AuthenticationPrincipal AuthenticatedUser principal,
- @PathVariable long workspaceId,
- @PathVariable long userId,
+ @PathVariable UUID workspaceId,
+ @PathVariable UUID userId,
HttpServletRequest httpRequest) {
workspaceService.removeMember(principal, workspaceId, userId, clientIp(httpRequest));
}
diff --git a/src/main/java/kr/ac/pusan/pickle/workspace/WorkspaceRepository.java b/src/main/java/kr/ac/pusan/pickle/workspace/WorkspaceRepository.java
index 7ef22634..8864b648 100644
--- a/src/main/java/kr/ac/pusan/pickle/workspace/WorkspaceRepository.java
+++ b/src/main/java/kr/ac/pusan/pickle/workspace/WorkspaceRepository.java
@@ -1,14 +1,20 @@
package kr.ac.pusan.pickle.workspace;
import java.util.Optional;
+import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
public interface WorkspaceRepository extends JpaRepository {
+ /** Resolution of the identifier this row wears outside the API boundary. */
+ Optional