*/
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/auth/dto/UserSummaryResponse.java b/src/main/java/kr/ac/pusan/pickle/auth/dto/UserSummaryResponse.java
index 158b5eae..065ee4b7 100644
--- a/src/main/java/kr/ac/pusan/pickle/auth/dto/UserSummaryResponse.java
+++ b/src/main/java/kr/ac/pusan/pickle/auth/dto/UserSummaryResponse.java
@@ -1,12 +1,14 @@
package kr.ac.pusan.pickle.auth.dto;
+import java.util.UUID;
import kr.ac.pusan.pickle.user.User;
import kr.ac.pusan.pickle.user.UserRole;
/** Contract schema {@code UserSummary}. */
-public record UserSummaryResponse(Long id, String email, String name, UserRole role) {
+public record UserSummaryResponse(UUID id, String email, String name, UserRole role) {
public static UserSummaryResponse from(User user) {
- return new UserSummaryResponse(user.getId(), user.getEmail(), user.getName(), user.getRole());
+ return new UserSummaryResponse(user.getPublicId(), user.getEmail(), user.getName(),
+ user.getRole());
}
}
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..a7b68d9a 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());
}
@@ -227,8 +244,11 @@ static boolean isLegalTransition(CampusIpRequestStatus from, CampusIpRequestStat
private Map notificationArgs(CampusIpRequest request, Vm vm) {
Map args = new java.util.LinkedHashMap<>();
- args.put("requestId", request.getId());
- args.put("vmId", request.getVmId());
+ // Public ids: these travel into the notification's link path, which the
+ // console resolves. The sibling publish at the create path already does
+ // this -- the two disagreed until 2026-08-11.
+ args.put("requestId", request.getPublicId());
+ args.put("vmId", vm != null ? vm.getPublicId() : null);
args.put("vmName", vm != null ? vm.getName() : "");
args.put("statusLabel", statusLabel(request.getStatus()));
if (request.getGrantedAddress() != null
@@ -275,11 +295,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 +329,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/DeleteVmJob.java b/src/main/java/kr/ac/pusan/pickle/provisioning/DeleteVmJob.java
index b36aea6b..37629253 100644
--- a/src/main/java/kr/ac/pusan/pickle/provisioning/DeleteVmJob.java
+++ b/src/main/java/kr/ac/pusan/pickle/provisioning/DeleteVmJob.java
@@ -429,7 +429,7 @@ private void notifyOrgAdmins(Vm vm) {
.map(User::getId)
.toList();
notificationService.publish(admins, NotificationEvent.VM_DELETE_COMPLETED,
- Map.of("vmId", vm.getId(), "vmName", vm.getName()),
+ Map.of("vmId", vm.getPublicId(), "vmName", vm.getName()),
"vm_delete_completed:" + vm.getId());
}
}
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/ExpiryStopJob.java b/src/main/java/kr/ac/pusan/pickle/provisioning/ExpiryStopJob.java
index e2f097c8..82990a6a 100644
--- a/src/main/java/kr/ac/pusan/pickle/provisioning/ExpiryStopJob.java
+++ b/src/main/java/kr/ac/pusan/pickle/provisioning/ExpiryStopJob.java
@@ -156,7 +156,7 @@ private void finalizeStop(Vm vm) {
recipients.addAll(notificationService.vmResponsibleIds(vm));
recipients.addAll(notificationService.orgAdminIds(vm.getOrgId()));
notificationService.publish(recipients, NotificationEvent.VM_EXPIRY_STOPPED,
- Map.of("vmId", vmId, "vmName", vm.getName(), "endDate",
+ Map.of("vmId", vm.getPublicId(), "vmName", vm.getName(), "endDate",
String.valueOf(vm.getEndDate())),
"vm-expiry-stopped:%d:%s".formatted(vmId, vm.getEndDate()));
log.info("vm {} auto-stopped: usage period ended {}", vmId, vm.getEndDate());
diff --git a/src/main/java/kr/ac/pusan/pickle/provisioning/ProvisionVmJob.java b/src/main/java/kr/ac/pusan/pickle/provisioning/ProvisionVmJob.java
index cb1664da..f6e82044 100644
--- a/src/main/java/kr/ac/pusan/pickle/provisioning/ProvisionVmJob.java
+++ b/src/main/java/kr/ac/pusan/pickle/provisioning/ProvisionVmJob.java
@@ -727,7 +727,7 @@ private void publishCreated(Vm vm, String ip) {
return;
}
Map args = new LinkedHashMap<>();
- args.put("vmId", vm.getId());
+ args.put("vmId", vm.getPublicId());
args.put("hostname", vm.getHostname());
if (ip != null) {
args.put("ip", ip);
@@ -753,7 +753,7 @@ private void publishCreateFailed(long vmId, String reason) {
return;
}
Map args = new LinkedHashMap<>();
- args.put("vmId", vmId);
+ args.put("vmId", vm.getPublicId());
args.put("hostname", vm.getHostname());
args.put("reason", reason);
notificationService.publish(notificationService.vmOwnerIds(vm),
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/provisioning/VmExpiryJob.java b/src/main/java/kr/ac/pusan/pickle/provisioning/VmExpiryJob.java
index 864eec9f..92a9d75e 100644
--- a/src/main/java/kr/ac/pusan/pickle/provisioning/VmExpiryJob.java
+++ b/src/main/java/kr/ac/pusan/pickle/provisioning/VmExpiryJob.java
@@ -125,7 +125,7 @@ private void notifyStage(Vm vm, int stage) {
}
List recipients = notificationService.vmResponsibleIds(vm);
notificationService.publish(recipients, NotificationEvent.VM_EXPIRY_NOTICE,
- Map.of("vmId", vm.getId(), "vmName", vm.getName(),
+ Map.of("vmId", vm.getPublicId(), "vmName", vm.getName(),
"endDate", String.valueOf(vm.getEndDate()), "days", stage),
"vm-expiry:%d:%s:D%d".formatted(vm.getId(), vm.getEndDate(), stage));
log.info("vm {} expiry notice D-{} sent to {} recipient(s) (end date {})",
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/DomainReservationSweeper.java b/src/main/java/kr/ac/pusan/pickle/publishing/DomainReservationSweeper.java
index bfcf5938..b32d4bdf 100644
--- a/src/main/java/kr/ac/pusan/pickle/publishing/DomainReservationSweeper.java
+++ b/src/main/java/kr/ac/pusan/pickle/publishing/DomainReservationSweeper.java
@@ -144,7 +144,7 @@ private void notify(Domain domain, NotificationEvent event, Instant reservedUnti
return;
}
notificationService.publish(notificationService.vmResponsibleIds(vm),
- event, Map.of("fqdn", domain.getFqdn(), "vmId", vm.getId(),
+ event, Map.of("fqdn", domain.getFqdn(), "vmId", vm.getPublicId(),
"reservedUntil", reservedUntil), dedupKey);
}
}
diff --git a/src/main/java/kr/ac/pusan/pickle/publishing/DomainVerifier.java b/src/main/java/kr/ac/pusan/pickle/publishing/DomainVerifier.java
index 12caadf1..c8fecd85 100644
--- a/src/main/java/kr/ac/pusan/pickle/publishing/DomainVerifier.java
+++ b/src/main/java/kr/ac/pusan/pickle/publishing/DomainVerifier.java
@@ -169,7 +169,7 @@ private void notifyVerificationFailed(Domain domain, String error) {
}
notificationService.publish(notificationService.vmResponsibleIds(vm),
NotificationEvent.DOMAIN_CONNECT_FAILED,
- Map.of("fqdn", domain.getFqdn(), "vmId", vm.getId(), "reason", error),
+ Map.of("fqdn", domain.getFqdn(), "vmId", vm.getPublicId(), "reason", error),
"domain_verify_failed:" + domain.getId());
}
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/RouteApplyJob.java b/src/main/java/kr/ac/pusan/pickle/publishing/RouteApplyJob.java
index ae82d447..5e3894cd 100644
--- a/src/main/java/kr/ac/pusan/pickle/publishing/RouteApplyJob.java
+++ b/src/main/java/kr/ac/pusan/pickle/publishing/RouteApplyJob.java
@@ -368,8 +368,8 @@ private void notifyDomainOutcome(Domain domain, NotificationEvent event, String
return;
}
Map args = reason == null
- ? Map.of("fqdn", domain.getFqdn(), "vmId", vm.getId())
- : Map.of("fqdn", domain.getFqdn(), "vmId", vm.getId(), "reason", reason);
+ ? Map.of("fqdn", domain.getFqdn(), "vmId", vm.getPublicId())
+ : Map.of("fqdn", domain.getFqdn(), "vmId", vm.getPublicId(), "reason", reason);
notificationService.publish(notificationService.vmResponsibleIds(vm),
event, args, dedupKey);
}
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..7e7f1835 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 -> actor.id().equals(row.getUserId()))
.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 -> actor.id().equals(row.getUserId()))
.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..68cce0db 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 ──────────────────────────────────────────────────
@@ -306,18 +306,23 @@ public List list(AuthenticatedUser actor) {
Map workspaceNames = workspaceRepository.findAllById(
vms.values().stream().map(Vm::getWorkspaceId).distinct().toList())
.stream().collect(Collectors.toMap(Workspace::getId, Workspace::getName));
- Map orgNames = orgRepository.findAllById(
+ Map orgs = orgRepository.findAllById(
sessions.stream().map(MirrorSession::orgId).distinct().toList())
- .stream().collect(Collectors.toMap(Org::getId, Org::getName));
+ .stream().collect(Collectors.toMap(Org::getId, o -> o));
List views = new ArrayList<>(sessions.size());
for (MirrorSession s : sessions) {
+ // The mirror holds the internal ids the gateway contract speaks; the
+ // admin view reports the same rows by their public identifiers.
Vm vm = vms.get(s.vmId());
User user = users.get(s.userId());
+ Org org = orgs.get(s.orgId());
views.add(new TerminalSessionView(
- s.sessionId(), s.vmId(), vm != null ? vm.getName() : "", s.orgId(),
- orgNames.getOrDefault(s.orgId(), ""),
+ s.sessionId(), vm != null ? vm.getPublicId() : null,
+ vm != null ? vm.getName() : "", org != null ? org.getPublicId() : null,
+ org != null ? org.getName() : "",
vm != null ? workspaceNames.getOrDefault(vm.getWorkspaceId(), "") : "",
- s.userId(), user != null ? user.getEmail() : "",
+ user != null ? user.getPublicId() : null,
+ user != null ? user.getEmail() : "",
user != null ? user.getName() : "", s.clientIp(), s.startedAt()));
}
return views;
@@ -338,7 +343,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 +404,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/terminal/dto/TerminalSessionView.java b/src/main/java/kr/ac/pusan/pickle/terminal/dto/TerminalSessionView.java
index 266fb177..38732197 100644
--- a/src/main/java/kr/ac/pusan/pickle/terminal/dto/TerminalSessionView.java
+++ b/src/main/java/kr/ac/pusan/pickle/terminal/dto/TerminalSessionView.java
@@ -1,6 +1,7 @@
package kr.ac.pusan.pickle.terminal.dto;
import java.time.Instant;
+import java.util.UUID;
/**
* Live web-terminal session, admin view (contract {@code TerminalSessionView},
@@ -8,7 +9,7 @@
* carries no terminal content. Ordered by {@code startedAt} descending in the
* list response.
*/
-public record TerminalSessionView(String sessionId, long vmId, String vmName, long orgId,
- String orgName, String workspaceName, long userId, String userEmail, String userName,
+public record TerminalSessionView(String sessionId, UUID vmId, String vmName, UUID orgId,
+ String orgName, String workspaceName, UUID userId, String userEmail, String userName,
String clientIp, Instant startedAt) {
}
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..89bf5a78 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,28 +13,29 @@
/** 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,
List pendingConsents) {
- public record Membership(Long workspaceId, String workspaceName, WorkspaceKind workspaceKind, WorkspaceMemberRole role) {
+ public record Membership(UUID workspaceId, String workspaceName, WorkspaceKind workspaceKind, WorkspaceMemberRole role) {
public static Membership from(WorkspaceMember member) {
- return new Membership(member.getWorkspace().getId(), member.getWorkspace().getName(),
+ return new Membership(member.getWorkspace().getPublicId(), member.getWorkspace().getName(),
member.getWorkspace().getKind(), member.getRole());
}
}
- 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