Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions contract/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4772,6 +4772,7 @@ components:
- "integer"
- "null"
name:
description: "SSH 슬러그. 접근 권한이 없으면 대신 표시 이름이 들어갑니다."
type: "string"
orgName:
type:
Expand Down
17 changes: 16 additions & 1 deletion src/main/java/kr/ac/pusan/pickle/access/ResourceType.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,20 @@
* in the authorization design.
*/
public enum ResourceType {
VM
VM("VM");

private final String label;

ResourceType(String label) {
this.label = label;
}

/**
* What user-facing text calls this kind of thing. Notifications about the
* request flow are shared by every type, so the word has to come from the
* type rather than from the sentence it appears in.
*/
public String label() {
return label;
}
}
19 changes: 17 additions & 2 deletions src/main/java/kr/ac/pusan/pickle/admin/ApprovalService.java
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import kr.ac.pusan.pickle.common.text.Texts;
import kr.ac.pusan.pickle.common.web.PageResponse;
import kr.ac.pusan.pickle.workspace.WorkspaceMemberRepository;
import kr.ac.pusan.pickle.workspace.WorkspaceRepository;
import kr.ac.pusan.pickle.notification.NotificationEvent;
import kr.ac.pusan.pickle.notification.NotificationService;
import kr.ac.pusan.pickle.security.AuthenticatedUser;
Expand Down Expand Up @@ -59,6 +60,7 @@ public class ApprovalService {
private final RequestAssembler assembler;
private final Map<ResourceType, RequestTypeHandler> handlers;
private final WorkspaceMemberRepository workspaceMemberRepository;
private final WorkspaceRepository workspaceRepository;
private final ResourceAccessGrantRepository grantRepository;
private final UserRepository userRepository;
private final AuditService auditService;
Expand All @@ -67,6 +69,7 @@ public class ApprovalService {
public ApprovalService(RequestRepository requestRepository, RequestReviewRepository reviewRepository,
RequestAssembler assembler, List<RequestTypeHandler> handlers,
WorkspaceMemberRepository workspaceMemberRepository,
WorkspaceRepository workspaceRepository,
ResourceAccessGrantRepository grantRepository, UserRepository userRepository,
AuditService auditService, NotificationService notificationService) {
this.requestRepository = requestRepository;
Expand All @@ -75,6 +78,7 @@ public ApprovalService(RequestRepository requestRepository, RequestReviewReposit
this.handlers = handlers.stream()
.collect(Collectors.toMap(RequestTypeHandler::type, Function.identity()));
this.workspaceMemberRepository = workspaceMemberRepository;
this.workspaceRepository = workspaceRepository;
this.grantRepository = grantRepository;
this.userRepository = userRepository;
this.auditService = auditService;
Expand Down Expand Up @@ -116,6 +120,16 @@ public RequestDetailResponse approve(AuthenticatedUser actor, long requestId,
ApproveRequestRequest form, String ip) {
Request request = findScopedWithLock(actor, requestId);
requireSubmitted(request);
// Approval creates a resource inside the workspace, so the workspace has
// to still be there. Deleting a workspace cancels its in-flight requests,
// but a request submitted concurrently with that delete commits after the
// sweep has already run and stays SUBMITTED — this is what stops it from
// being approved into a workspace nobody can reach.
if (workspaceRepository.findByIdAndDeletedAtIsNull(request.getWorkspaceId()).isEmpty()) {
throw new ApiException(HttpStatus.CONFLICT, ErrorCodes.WORKSPACE_DELETED,
"워크스페이스가 삭제되었습니다",
"삭제된 워크스페이스에는 리소스를 만들 수 없습니다. 이 신청은 반려해 주세요.");
}
RequestTypeHandler handler = handlerFor(request);

List<FieldValidationError> errors = new ArrayList<>();
Expand Down Expand Up @@ -167,7 +181,7 @@ public void afterCommit() {
Map<String, Object> notifyArgs = new LinkedHashMap<>();
notifyArgs.put("requestId", request.getId());
notifyArgs.put("type", request.getResourceType().name());
notifyArgs.put("hostname", created.resourceName());
notifyArgs.put("resourceName", created.resourceName());
String reviewComment = Texts.blankToNull(form.comment());
if (reviewComment != null) {
notifyArgs.put("comment", reviewComment);
Expand All @@ -187,7 +201,8 @@ public RequestDetailResponse reject(AuthenticatedUser actor, long requestId,
auditService.recordAfterCommit(actor.id(), actor.role().name(), AuditService.REQUEST_REJECT,
"request", request.getId(), Map.of("workspaceId", request.getWorkspaceId()), ip);
notificationService.publish(request.getRequesterId(), NotificationEvent.REQUEST_REJECTED,
Map.of("requestId", request.getId(), "comment", form.comment().strip()), null);
Map.of("requestId", request.getId(), "comment", form.comment().strip(),
"type", request.getResourceType().name()), null);
return assembler.toDetail(request);
}

Expand Down
13 changes: 13 additions & 0 deletions src/main/java/kr/ac/pusan/pickle/common/error/ErrorCodes.java
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,22 @@ public final class ErrorCodes {
public static final String WORKSPACE_MEMBER_ALREADY_EXISTS = "WORKSPACE_MEMBER_ALREADY_EXISTS";
public static final String WORKSPACE_SOLE_OWNER_REMOVAL = "WORKSPACE_SOLE_OWNER_REMOVAL";
public static final String WORKSPACE_ROLE_INSUFFICIENT = "WORKSPACE_ROLE_INSUFFICIENT";
/**
* The caller is not a member of the workspace at all, on a path where every
* member is allowed. Distinct from {@link #WORKSPACE_ROLE_INSUFFICIENT}
* because the remedy is different: being added to the workspace, not being
* moved up a rung.
*/
public static final String WORKSPACE_MEMBERSHIP_REQUIRED = "WORKSPACE_MEMBERSHIP_REQUIRED";
// Workspace deletion (contract v0.9.0).
public static final String WORKSPACE_HAS_ACTIVE_VMS = "WORKSPACE_HAS_ACTIVE_VMS";
public static final String WORKSPACE_PERSONAL_UNDELETABLE = "WORKSPACE_PERSONAL_UNDELETABLE";
/**
* The workspace the operation would act in has been soft-deleted. Raised on
* approval, where the resource would otherwise be created inside a workspace
* 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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import java.time.format.DateTimeFormatter;
import java.util.LinkedHashMap;
import java.util.Map;
import kr.ac.pusan.pickle.access.ResourceType;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

Expand Down Expand Up @@ -48,19 +49,20 @@ public NotificationComposer(
public Composed compose(NotificationEvent event, Map<String, Object> args) {
return switch (event) {
case REQUEST_SUBMITTED -> requestSubmitted(event, args);
case REQUEST_APPROVED -> new Composed(event.id(), "VM 신청 승인",
case REQUEST_APPROVED -> new Composed(event.id(), resourceLabel(args) + " 신청 승인",
"""
VM 신청이 승인되었습니다. VM '%s' 생성이 시작됩니다.
생성이 완료되면 다시 알려드립니다.%s""".formatted(str(args, "hostname"),
%s 신청이 승인되었습니다. %s '%s' 생성이 시작됩니다.
생성이 완료되면 다시 알려드립니다.%s""".formatted(resourceLabel(args),
resourceLabel(args), str(args, "resourceName"),
args.get("comment") != null
? "\n\n- 검토 의견: " + str(args, "comment") : ""),
"/console/requests/" + args.get("requestId"), event.defaultImportance(),
payload(args, "requestId", "hostname"));
case REQUEST_REJECTED -> new Composed(event.id(), "VM 신청 반려",
payload(args, "requestId", "resourceName"));
case REQUEST_REJECTED -> new Composed(event.id(), resourceLabel(args) + " 신청 반려",
"""
VM 신청이 반려되었습니다.
%s 신청이 반려되었습니다.

- 반려 사유: %s""".formatted(str(args, "comment")),
- 반려 사유: %s""".formatted(resourceLabel(args), str(args, "comment")),
"/console/requests/" + args.get("requestId"), event.defaultImportance(),
payload(args, "requestId"));
case VM_CREATE_DONE -> new Composed(event.id(),
Expand Down Expand Up @@ -363,16 +365,17 @@ public Composed compose(NotificationEvent event, Map<String, Object> args) {

private Composed requestSubmitted(NotificationEvent event, Map<String, Object> args) {
boolean admin = Boolean.TRUE.equals(args.get("admin"));
String title = admin ? "새 VM 신청 접수" : "VM 신청 접수";
String label = resourceLabel(args);
String title = admin ? "새 " + label + " 신청 접수" : label + " 신청 접수";
String body = admin
? """
워크스페이스 '%s'에서 새 VM 신청이 접수되었습니다. 검토해 주세요.
워크스페이스 '%s'에서 새 %s 신청이 접수되었습니다. 검토해 주세요.

- 신청 목적: %s""".formatted(str(args, "workspaceName"), str(args, "purpose"))
- 신청 목적: %s""".formatted(str(args, "workspaceName"), label, str(args, "purpose"))
: """
워크스페이스 '%s'의 VM 신청이 접수되었습니다. 관리자 검토 후 결과를 알려드립니다.
워크스페이스 '%s'의 %s 신청이 접수되었습니다. 관리자 검토 후 결과를 알려드립니다.

- 신청 목적: %s""".formatted(str(args, "workspaceName"), str(args, "purpose"));
- 신청 목적: %s""".formatted(str(args, "workspaceName"), label, str(args, "purpose"));
String link = (admin ? "/admin/requests/" : "/console/requests/") + args.get("requestId");
return new Composed(event.id(), title, body, link, event.defaultImportance(),
payload(args, "requestId", "workspaceName"));
Expand All @@ -393,6 +396,26 @@ private Composed expiryNotice(Map<String, Object> args) {
payload(args, "vmId", "vmName", "endDate"));
}

/**
* The word the request-flow notices use for the thing being asked for. The
* request events are shared by every resource type, so the type travels in
* the payload and the sentence reads it; a payload without one (or naming a
* type this build does not know) falls back to the generic word rather than
* asserting it is a VM.
*/
private static String resourceLabel(Map<String, Object> args) {
Object type = args.get("type");
if (type == null) {
return "리소스";
}
for (ResourceType candidate : ResourceType.values()) {
if (candidate.name().equals(String.valueOf(type))) {
return candidate.label();
}
}
return "리소스";
}

private static String str(Map<String, Object> args, String key) {
Object value = args.get(key);
return value != null ? String.valueOf(value) : "";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,12 +121,14 @@ public List<Long> workspaceOwnerIds(long workspaceId) {
* own list; right after the changeover both answers name the same people.
*/
public List<Long> vmResponsibleIds(Vm vm) {
return vmRecipients(vm, List.of(ResourceRole.OWNER, ResourceRole.EDITOR));
return resourceRecipients(ResourceType.VM, vm.getId(), vm.getWorkspaceId(),
List.of(ResourceRole.OWNER, ResourceRole.EDITOR));
}

/** Narrower audience: only those the list makes an owner of the VM. */
public List<Long> vmOwnerIds(Vm vm) {
return vmRecipients(vm, List.of(ResourceRole.OWNER));
return resourceRecipients(ResourceType.VM, vm.getId(), vm.getWorkspaceId(),
List.of(ResourceRole.OWNER));
}

/**
Expand All @@ -135,17 +137,26 @@ public List<Long> vmOwnerIds(Vm vm) {
* deletion.
*/
public List<Long> vmAudienceIds(Vm vm) {
return vmRecipients(vm, List.of(ResourceRole.values()));
return resourceRecipients(ResourceType.VM, vm.getId(), vm.getWorkspaceId(),
List.of(ResourceRole.values()));
}

private List<Long> vmRecipients(Vm vm, Collection<ResourceRole> roles) {
/**
* Who hears about one resource, whatever kind it is: the people its access
* list names at the given rungs, plus the owners of the workspace that owns
* it. The rule is the same for every type — a grant is a grant — so a second
* kind of resource answers this question by calling here with its own type
* rather than by growing a parallel audience rule beside this one.
*/
public List<Long> resourceRecipients(ResourceType type, long resourceId, long workspaceId,
Collection<ResourceRole> roles) {
Set<Long> ids = grantRepository
.findByResourceTypeAndResourceIdAndGranteeTypeAndRoleIn(ResourceType.VM,
vm.getId(), AccessGranteeType.USER, roles)
.findByResourceTypeAndResourceIdAndGranteeTypeAndRoleIn(type,
resourceId, AccessGranteeType.USER, roles)
.stream()
.map(ResourceAccessGrant::getUserId)
.collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new));
ids.addAll(workspaceMemberRepository.findByWorkspaceIdOrderByIdAsc(vm.getWorkspaceId()).stream()
ids.addAll(workspaceMemberRepository.findByWorkspaceIdOrderByIdAsc(workspaceId).stream()
.filter(m -> m.getRole() == WorkspaceMemberRole.OWNER)
.map(WorkspaceMember::getUserId)
.toList());
Expand Down
14 changes: 10 additions & 4 deletions src/main/java/kr/ac/pusan/pickle/request/RequestService.java
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ public RequestDetailResponse create(AuthenticatedUser actor, CreateRequestReques
// reaching VMs, which is now the access list's business, and asking is
// not the step that costs anything — approval is.
workspaceMemberRepository.findByWorkspaceIdAndUserId(workspace.getId(), actor.id())
.orElseThrow(RequestService::requestRoleInsufficient);
.orElseThrow(RequestService::notWorkspaceMember);

Org org = orgRepository.findById(form.orgId())
.orElseThrow(() -> notFound("해당 기관이 존재하지 않습니다."));
Expand Down Expand Up @@ -214,8 +214,14 @@ private static ApiException notFound(String detail) {
"리소스를 찾을 수 없습니다", detail);
}

private static ApiException requestRoleInsufficient() {
return new ApiException(HttpStatus.FORBIDDEN, ErrorCodes.WORKSPACE_ROLE_INSUFFICIENT,
"VM을 신청할 권한이 없습니다", "워크스페이스 소유자(OWNER) 또는 편집자(EDITOR)만 VM을 신청할 수 있습니다.");
/**
* The only standing this path still asks for is membership, and it is not
* about VMs: any type of request travels through here, and what the refusal
* has to tell the caller is that they are outside the workspace.
*/
private static ApiException notWorkspaceMember() {
return new ApiException(HttpStatus.FORBIDDEN, ErrorCodes.WORKSPACE_MEMBERSHIP_REQUIRED,
"워크스페이스 구성원이 아닙니다",
"이 워크스페이스의 구성원만 신청할 수 있습니다. 워크스페이스 소유자에게 구성원 추가를 요청해 주세요.");
}
}
11 changes: 11 additions & 0 deletions src/main/java/kr/ac/pusan/pickle/resource/ResourceTypeAdapter.java
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,17 @@ public interface ResourceTypeAdapter {
*/
List<Long> idsOwnedByWorkspace(long workspaceId);

/**
* How many of this type the workspace still holds that are not yet fully
* destroyed — what stands between the workspace and its own deletion.
*
* <p>A resource on its way out counts: it still holds the platform's
* resources until its destruction completes, and the workspace it belongs to
* has to outlive it. Only the rows whose history is all that is left of them
* are excluded.
*/
long countLiveInWorkspace(long workspaceId);

/**
* This type's contribution to the inventory the requester may see, with the
* same visibility rules the type's own list endpoint applies: a row the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import kr.ac.pusan.pickle.security.AuthenticatedUser;
import kr.ac.pusan.pickle.vm.VmQueryService;
import kr.ac.pusan.pickle.vm.VmRepository;
import kr.ac.pusan.pickle.vm.VmStatus;
import kr.ac.pusan.pickle.vm.dto.VmSummaryResponse;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
Expand Down Expand Up @@ -34,6 +35,12 @@ public List<Long> idsOwnedByWorkspace(long workspaceId) {
return vmRepository.findIdsByWorkspaceIdIn(List.of(workspaceId));
}

@Override
public long countLiveInWorkspace(long workspaceId) {
// DELETING counts: the VM is still there until its destruction finishes.
return vmRepository.countActiveByWorkspaceId(workspaceId, VmStatus.DELETED);
}

@Override
public Page<ResourceSummaryResponse> page(AuthenticatedUser actor, Long workspaceId,
Pageable pageable) {
Expand Down
27 changes: 24 additions & 3 deletions src/main/java/kr/ac/pusan/pickle/vm/dto/VmSummaryResponse.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,12 @@
* {@code ownerNames} says who to ask, and everything else the row would reveal
* about the machine is omitted rather than blanked in the console. The redaction
* happens here because a field the API sends has already left the building.
* {@code name} is the SSH slug on an open row, so a restricted row carries the
* display name there instead — see {@link #restricted}.
*/
public record VmSummaryResponse(
Long id,
@Schema(description = "SSH 슬러그. 접근 권한이 없으면 대신 표시 이름이 들어갑니다.")
String name,
@Schema(description = "SSH 슬러그. 접근 권한이 없으면 생략됩니다.")
@Nullable String hostname,
Expand Down Expand Up @@ -57,12 +60,30 @@ public static VmSummaryResponse from(Vm vm, String workspaceName, String orgName
false);
}

/** Name, state and who to ask — nothing about the machine itself. */
/**
* Name, state and who to ask — nothing about the machine itself.
*
* <p>{@code name} is the SSH slug: the same string one types to reach the
* machine, so it is exactly what somebody without a grant must not be handed.
* The display name takes its place, and {@code displayName} itself is left
* 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<String> ownerNames, boolean accessManageAllowed) {
return new VmSummaryResponse(vm.getId(), vm.getName(), null, vm.getStatus(),
return new VmSummaryResponse(vm.getId(), restrictedName(vm, displayName), null, vm.getStatus(),
null, null, null, vm.getWorkspaceId(), workspaceName, null,
displayName, null, null, null,
null, null, null, null,
null, null, vm.getCreatedAt(), true, ownerNames, accessManageAllowed);
}

/**
* What a restricted row is called. The display name when the VM has one;
* otherwise its id, which the row already carries and which no slug can be
* confused with. Never null: the console picks the label as
* {@code displayName || name}, so a null name would leave the row nameless.
*/
private static String restrictedName(Vm vm, String displayName) {
return displayName != null && !displayName.isBlank() ? displayName : "VM #" + vm.getId();
}
}
Loading