diff --git a/src/main/java/kr/ac/pusan/pickle/access/GrantChange.java b/src/main/java/kr/ac/pusan/pickle/access/GrantChange.java new file mode 100644 index 00000000..d2d786b5 --- /dev/null +++ b/src/main/java/kr/ac/pusan/pickle/access/GrantChange.java @@ -0,0 +1,8 @@ +package kr.ac.pusan.pickle.access; + +/** The three edits an access list accepts, as the audit trail distinguishes them. */ +public enum GrantChange { + ADD, + UPDATE, + REMOVE +} diff --git a/src/main/java/kr/ac/pusan/pickle/access/ResourceAccessAudit.java b/src/main/java/kr/ac/pusan/pickle/access/ResourceAccessAudit.java new file mode 100644 index 00000000..34f61921 --- /dev/null +++ b/src/main/java/kr/ac/pusan/pickle/access/ResourceAccessAudit.java @@ -0,0 +1,29 @@ +package kr.ac.pusan.pickle.access; + +/** + * The names one resource type's access-list edits go into the audit trail + * under. + * + *

The trail is read by target type and action, and those strings outlive the + * code that writes them, so they are stated per type rather than derived from + * an enum name that a later rename could quietly change. + * + * @param targetType the audited target's type, e.g. {@code vm} + * @param grantAdd an entry added to the list + * @param grantUpdate an entry's rung changed + * @param grantRemove an entry taken off the list + * @param breakGlass the extra record left when a workspace owner's edit is + * what puts them inside the resource + */ +public record ResourceAccessAudit(String targetType, String grantAdd, String grantUpdate, + String grantRemove, String breakGlass) { + + /** The action name for one kind of edit. */ + public String actionOf(GrantChange change) { + return switch (change) { + case ADD -> grantAdd; + case UPDATE -> grantUpdate; + case REMOVE -> grantRemove; + }; + } +} diff --git a/src/main/java/kr/ac/pusan/pickle/access/ResourceAccessGrantService.java b/src/main/java/kr/ac/pusan/pickle/access/ResourceAccessGrantService.java new file mode 100644 index 00000000..7724a200 --- /dev/null +++ b/src/main/java/kr/ac/pusan/pickle/access/ResourceAccessGrantService.java @@ -0,0 +1,270 @@ +package kr.ac.pusan.pickle.access; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; +import java.util.stream.Collectors; +import kr.ac.pusan.pickle.access.dto.AddResourceAccessGrantRequest; +import kr.ac.pusan.pickle.access.dto.ResourceAccessGrantView; +import kr.ac.pusan.pickle.access.dto.ResourceAccessListResponse; +import kr.ac.pusan.pickle.access.dto.UpdateResourceAccessGrantRequest; +import kr.ac.pusan.pickle.audit.AuditService; +import kr.ac.pusan.pickle.common.error.ApiException; +import kr.ac.pusan.pickle.common.error.ErrorCodes; +import kr.ac.pusan.pickle.common.error.FieldValidationError; +import kr.ac.pusan.pickle.resource.ResourceIdentity; +import kr.ac.pusan.pickle.resource.ResourceTypeAdapter; +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.user.UserStatus; +import kr.ac.pusan.pickle.workspace.Workspace; +import kr.ac.pusan.pickle.workspace.WorkspaceMemberRepository; +import kr.ac.pusan.pickle.workspace.WorkspaceRepository; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Reading and editing one resource's access list, for every resource type. + * + *

Who may edit it: the resource's own owners, and the owners of the + * workspace that owns it. The second is the recovery path — a resource can end + * up with no owner of its own, for instance when the person who requested it + * leaves the workspace. + * + *

A workspace owner holds no way inside a resource until they appear on its + * list. They may put themselves there, and that is the intended escape hatch, + * but any edit of theirs that creates content access for themselves is + * additionally recorded as a break-glass event. Adding a workspace-wide grant + * is included: without that, the same result could be had without the marker. + * + *

Nothing here knows what a VM is. What a resource type contributes is its + * {@link ResourceTypeAdapter}: how to load the thing, which workspace owns it, + * what its limited view shows, and the sentences it refuses in. Everything else + * — the rungs, the cap on workspace-wide grants, the eligibility of a grantee, + * the break-glass boundary — is one implementation, because two copies of "who + * may reach a resource" would be two policies within a release or two. + */ +@Service +public class ResourceAccessGrantService { + + private final Map adapters; + private final ResourceAccessResolver resolver; + private final ResourceAccessGrantRepository grantRepository; + private final WorkspaceMemberRepository workspaceMemberRepository; + private final WorkspaceRepository workspaceRepository; + private final UserRepository userRepository; + private final AuditService auditService; + + public ResourceAccessGrantService(List adapters, + ResourceAccessResolver resolver, ResourceAccessGrantRepository grantRepository, + WorkspaceMemberRepository workspaceMemberRepository, + WorkspaceRepository workspaceRepository, UserRepository userRepository, + AuditService auditService) { + this.adapters = adapters.stream() + .collect(Collectors.toMap(ResourceTypeAdapter::type, Function.identity())); + this.resolver = resolver; + this.grantRepository = grantRepository; + this.workspaceMemberRepository = workspaceMemberRepository; + this.workspaceRepository = workspaceRepository; + this.userRepository = userRepository; + this.auditService = auditService; + } + + @Transactional(readOnly = true) + public ResourceAccessListResponse list(AuthenticatedUser actor, ResourceType type, + long resourceId) { + Managed managed = requireManager(actor, type, resourceId); + String workspaceName = workspaceRepository.findById(managed.resource().workspaceId()) + .map(Workspace::getName).orElse(""); + return ResourceAccessListResponse.of(type, managed.resource(), workspaceName, + views(type, resourceId)); + } + + @Transactional + public ResourceAccessGrantView add(AuthenticatedUser actor, ResourceType type, long resourceId, + AddResourceAccessGrantRequest request, String ip) { + Managed managed = requireManager(actor, type, resourceId); + ResourceAccessGrant grant = request.granteeType() == AccessGranteeType.WORKSPACE + ? ResourceAccessGrant.forOwningWorkspace(type, resourceId, + requireWorkspaceWideRole(request.role())) + : ResourceAccessGrant.forUser(type, resourceId, + requireEligibleMember(managed, request.userId()), request.role()); + ResourceAccessGrant saved; + try { + saved = grantRepository.saveAndFlush(grant); + } catch (DataIntegrityViolationException alreadyListed) { + throw managed.messages().alreadyListed(); + } + audit(actor, managed, GrantChange.ADD, saved, null, ip); + return view(saved); + } + + @Transactional + public ResourceAccessGrantView update(AuthenticatedUser actor, ResourceType type, + long resourceId, long grantId, UpdateResourceAccessGrantRequest request, String ip) { + Managed managed = requireManager(actor, type, resourceId); + ResourceAccessGrant grant = requireGrant(managed, grantId); + ResourceRole previous = grant.getRole(); + grant.setRole(grant.getGranteeType() == AccessGranteeType.WORKSPACE + ? requireWorkspaceWideRole(request.role()) + : request.role()); + audit(actor, managed, GrantChange.UPDATE, grant, previous, ip); + return view(grant); + } + + @Transactional + public void remove(AuthenticatedUser actor, ResourceType type, long resourceId, long grantId, + String ip) { + Managed managed = requireManager(actor, type, resourceId); + ResourceAccessGrant grant = requireGrant(managed, grantId); + grantRepository.delete(grant); + audit(actor, managed, GrantChange.REMOVE, grant, null, ip); + } + + /** Managing the list is a resource owner's right, or a workspace owner's standing one. */ + private Managed requireManager(AuthenticatedUser actor, ResourceType type, long resourceId) { + ResourceTypeAdapter adapter = adapterFor(type); + ResourceIdentity resource = adapter.identify(resourceId) + .orElseThrow(() -> adapter.accessMessages().notFound()); + ResourceStanding standing = resolver.standing(type, resourceId, resource.workspaceId(), + actor.id()); + if (!standing.manages()) { + // An outsider is not told the resource exists and a member without + // a grant is refused in the open; only somebody who can already see + // it is told that managing the list is a rung above theirs. + standing.requireVisible(adapter.accessMessages()); + throw adapter.accessMessages().notGrantManager(); + } + return new Managed(adapter, resource, standing); + } + + private ResourceTypeAdapter adapterFor(ResourceType type) { + ResourceTypeAdapter adapter = adapters.get(type); + if (adapter == null) { + throw new IllegalStateException("No adapter for resource type " + type); + } + return adapter; + } + + /** + * A named grant may only be given to a member of the owning workspace — the + * rule that keeps the list from disagreeing with the 404 that hides the + * resource from everyone else. + */ + private long requireEligibleMember(Managed managed, Long userId) { + if (userId == null) { + throw ApiException.validationFailed(List.of(new FieldValidationError("userId", + "대상 사용자를 지정해 주세요."))); + } + boolean member = workspaceMemberRepository + .findByWorkspaceIdAndUserId(managed.resource().workspaceId(), userId).isPresent(); + boolean active = userRepository.findById(userId) + .filter(user -> user.getStatus() == UserStatus.ACTIVE).isPresent(); + if (!member || !active) { + throw managed.messages().granteeIneligible(); + } + return userId; + } + + /** The whole workspace is never handed the rungs that manage access or destroy. */ + private ResourceRole requireWorkspaceWideRole(ResourceRole role) { + if (role == ResourceRole.OWNER || role == ResourceRole.EDITOR) { + throw ApiException.validationFailed(List.of(new FieldValidationError("role", + "워크스페이스 전체에는 참여자 또는 열람자까지만 부여할 수 있습니다. 그보다 높은 등급은 " + + "구성원을 지정해 부여해 주세요."))); + } + return role; + } + + /** A grant id means nothing outside the resource it was issued under. */ + private ResourceAccessGrant requireGrant(Managed managed, long grantId) { + return grantRepository.findById(grantId) + .filter(grant -> grant.getResourceType() == managed.adapter().type() + && grant.getResourceId() == managed.resource().id()) + .orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, + ErrorCodes.RESOURCE_NOT_FOUND, "리소스를 찾을 수 없습니다", + "해당 접근 권한이 존재하지 않습니다.")); + } + + private void audit(AuthenticatedUser actor, Managed managed, GrantChange change, + ResourceAccessGrant grant, ResourceRole previousRole, String ip) { + ResourceAccessAudit names = managed.adapter().accessAudit(); + Map detail = new LinkedHashMap<>(); + detail.put("grantId", grant.getId()); + detail.put("granteeType", grant.getGranteeType().name()); + if (grant.getUserId() != null) { + detail.put("granteeUserId", grant.getUserId()); + } + detail.put("role", grant.getRole().name()); + if (previousRole != null) { + detail.put("previousRole", previousRole.name()); + } + long resourceId = managed.resource().id(); + auditService.recordAfterCommit(actor.id(), actor.role().name(), names.actionOf(change), + names.targetType(), resourceId, Map.copyOf(detail), ip); + if (opensTheDoorForActor(actor, managed, change, grant)) { + auditService.recordAfterCommit(actor.id(), actor.role().name(), names.breakGlass(), + names.targetType(), resourceId, Map.copyOf(detail), ip); + } + } + + /** + * True when this edit is what lets the actor inside a resource they could + * not reach before it — the case the break-glass record exists for. + * + *

Both sides of the comparison are needed. Looking only at the state + * after the write marks a resource owner who was already inside, and marks + * someone lowering their own rung, which would put a false + * emergency-access record in the trail; a signal that fires on ordinary + * edits is worth nothing to whoever reads the audit later. + */ + private boolean opensTheDoorForActor(AuthenticatedUser actor, Managed managed, + GrantChange change, ResourceAccessGrant grant) { + if (change == GrantChange.REMOVE) { + return false; + } + boolean namesTheActor = grant.getGranteeType() == AccessGranteeType.WORKSPACE + || Long.valueOf(actor.id()).equals(grant.getUserId()); + if (!namesTheActor || managed.standing().atLeast(ResourceRole.MEMBER)) { + return false; + } + return resolver.standing(managed.adapter().type(), managed.resource().id(), + managed.resource().workspaceId(), actor.id()).atLeast(ResourceRole.MEMBER); + } + + private List views(ResourceType type, long resourceId) { + List grants = grantRepository + .findByResourceTypeAndResourceIdOrderByIdAsc(type, resourceId); + Map users = userRepository.findAllById(grants.stream() + .map(ResourceAccessGrant::getUserId).filter(Objects::nonNull) + .toList()).stream() + .collect(Collectors.toMap(User::getId, user -> user)); + return grants.stream() + .map(grant -> ResourceAccessGrantView.of(grant, + grant.getUserId() == null ? null : users.get(grant.getUserId()))) + .toList(); + } + + private ResourceAccessGrantView view(ResourceAccessGrant grant) { + return ResourceAccessGrantView.of(grant, + grant.getUserId() == null ? null : userRepository.findById(grant.getUserId()) + .orElse(null)); + } + + /** + * One resource, loaded, plus the standing the actor held on it before the + * edit — which is half of what the break-glass comparison needs. + */ + private record Managed(ResourceTypeAdapter adapter, ResourceIdentity resource, + ResourceStanding standing) { + + ResourceAccessMessages messages() { + return adapter.accessMessages(); + } + } +} diff --git a/src/main/java/kr/ac/pusan/pickle/access/ResourceAccessMessages.java b/src/main/java/kr/ac/pusan/pickle/access/ResourceAccessMessages.java new file mode 100644 index 00000000..6734402a --- /dev/null +++ b/src/main/java/kr/ac/pusan/pickle/access/ResourceAccessMessages.java @@ -0,0 +1,75 @@ +package kr.ac.pusan.pickle.access; + +import java.util.List; +import kr.ac.pusan.pickle.common.error.ApiException; +import kr.ac.pusan.pickle.common.error.ErrorCodes; +import kr.ac.pusan.pickle.common.error.FieldValidationError; +import org.springframework.http.HttpStatus; + +/** + * The words one resource type uses when it refuses. + * + *

Which refusal a situation calls for, and what status and code it carries, + * is a rule and lives in the shared code; only the sentence a person reads + * differs per type, because a sentence about a VM and one about an API key are + * not the same sentence and a noun cannot be substituted into Korean and stay + * grammatical. Each type builds one of these once, and it is the only home of + * those sentences — the resource's own services read them from here too. + * + * @param notFoundDetail the masking 404: an existing but unreachable + * resource has to read as a missing one + * @param whenNoGrant 403 for a member of the owning workspace who + * holds no grant and can already see it listed + * @param whenNotGrantManager 403 for someone who may see the resource but + * not decide who else reaches it + * @param granteeIneligibleDetail field error when a grant names someone the + * owning workspace does not have + * @param grantExistsCode error code for a second entry naming a target + * the list already carries + * @param whenGrantExists what that conflict says + */ +public record ResourceAccessMessages( + String notFoundDetail, + Refusal whenNoGrant, + Refusal whenNotGrantManager, + String granteeIneligibleDetail, + String grantExistsCode, + Refusal whenGrantExists) { + + /** A title and the sentence under it, the shape every error body here takes. */ + public record Refusal(String title, String detail) { + } + + /** Existence masked: the same answer as an id that names nothing. */ + public ApiException notFound() { + return new ApiException(HttpStatus.NOT_FOUND, ErrorCodes.RESOURCE_NOT_FOUND, + "리소스를 찾을 수 없습니다", notFoundDetail); + } + + /** Visible but closed — an honest 403 rather than a lie about existence. */ + public ApiException noGrant() { + return forbidden(whenNoGrant); + } + + /** Visible, and not theirs to hand out. */ + public ApiException notGrantManager() { + return forbidden(whenNotGrantManager); + } + + /** A named grant may only reach a member of the owning workspace. */ + public ApiException granteeIneligible() { + return ApiException.validationFailed(List.of( + new FieldValidationError("userId", granteeIneligibleDetail))); + } + + /** One entry per target: a second one is a conflict, not a second opinion. */ + public ApiException alreadyListed() { + return new ApiException(HttpStatus.CONFLICT, grantExistsCode, whenGrantExists.title(), + whenGrantExists.detail()); + } + + private static ApiException forbidden(Refusal refusal) { + return new ApiException(HttpStatus.FORBIDDEN, ErrorCodes.WORKSPACE_ROLE_INSUFFICIENT, + refusal.title(), refusal.detail()); + } +} diff --git a/src/main/java/kr/ac/pusan/pickle/access/ResourceAccessResolver.java b/src/main/java/kr/ac/pusan/pickle/access/ResourceAccessResolver.java new file mode 100644 index 00000000..199f5c85 --- /dev/null +++ b/src/main/java/kr/ac/pusan/pickle/access/ResourceAccessResolver.java @@ -0,0 +1,72 @@ +package kr.ac.pusan.pickle.access; + +import java.util.List; +import kr.ac.pusan.pickle.workspace.WorkspaceMember; +import kr.ac.pusan.pickle.workspace.WorkspaceMemberRepository; +import kr.ac.pusan.pickle.workspace.WorkspaceMemberRole; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * The one place that turns the access list into a standing, for every resource + * type. + * + *

A type's own service says which resource is meant and which workspace owns + * it; what that buys the requester is decided here, so that a second resource + * type cannot arrive with a second opinion on what a grant means. + * + *

Admin surfaces are deliberately not routed through here: their scope is + * the organisation, not the access list, and mixing the two is how a bypass + * gets written by accident. + */ +@Service +public class ResourceAccessResolver { + + private final WorkspaceMemberRepository workspaceMemberRepository; + private final ResourceAccessGrantRepository grantRepository; + + public ResourceAccessResolver(WorkspaceMemberRepository workspaceMemberRepository, + ResourceAccessGrantRepository grantRepository) { + this.workspaceMemberRepository = workspaceMemberRepository; + this.grantRepository = grantRepository; + } + + /** Standing of one user on one resource of the workspace that owns it. */ + @Transactional(readOnly = true) + public ResourceStanding standing(ResourceType type, long resourceId, long owningWorkspaceId, + long userId) { + WorkspaceMemberRole membership = workspaceMemberRepository + .findByWorkspaceIdAndUserId(owningWorkspaceId, userId) + .map(WorkspaceMember::getRole) + .orElse(null); + return new ResourceStanding(grantedRole(type, resourceId, userId, membership != null), + membership != null, membership == WorkspaceMemberRole.OWNER); + } + + /** + * The strongest rung the access list gives this person: their own grant and + * the workspace-wide one, whichever is higher. + * + *

A grant counts only while its holder is still in the owning workspace. + * Losing membership already deletes their grants, so this changes nothing + * in practice — it is here so that a missed cleanup cannot leave someone + * reaching a resource of a workspace they left. + */ + private ResourceRole grantedRole(ResourceType type, long resourceId, long userId, + boolean owningWorkspaceMember) { + if (!owningWorkspaceMember) { + return null; + } + ResourceRole best = null; + List grants = grantRepository + .findByResourceTypeAndResourceIdOrderByIdAsc(type, resourceId); + for (ResourceAccessGrant grant : grants) { + boolean applies = grant.getGranteeType() == AccessGranteeType.WORKSPACE + || Long.valueOf(userId).equals(grant.getUserId()); + if (applies && (best == null || grant.getRole().atLeast(best))) { + best = grant.getRole(); + } + } + return best; + } +} diff --git a/src/main/java/kr/ac/pusan/pickle/access/ResourceStanding.java b/src/main/java/kr/ac/pusan/pickle/access/ResourceStanding.java new file mode 100644 index 00000000..939859bf --- /dev/null +++ b/src/main/java/kr/ac/pusan/pickle/access/ResourceStanding.java @@ -0,0 +1,64 @@ +package kr.ac.pusan.pickle.access; + +/** + * What one requester may do to one resource, with the resource itself left out. + * + *

This is the whole of the two-axis model in one place: the rung comes from + * the resource's access list and from nowhere else, and the one thing workspace + * standing still carries — an owner's permanent read, deletion and grant + * management — is a flag rather than a rung, so that no check for something + * inside the resource can be satisfied by it. + * + *

It names no resource type on purpose. Every type's answers are computed by + * {@link ResourceAccessResolver} and read through the predicates here, so the + * meaning of a rung cannot drift between one type and the next. + * + * @param grantedRole the rung from the access list, or null with no grant + * @param owningWorkspaceMember whether the requester belongs to the owning workspace + * @param standingRights whether they are an owner of that workspace, which + * carries deletion and grant management on every + * resource the workspace owns, and nothing inside them + */ +public record ResourceStanding(ResourceRole grantedRole, boolean owningWorkspaceMember, + boolean standingRights) { + + /** + * The rung this requester acts at, which comes from the access list and + * nowhere else. Null when the list does not name them — including for an + * owner of the workspace, whose standing rights are deliberately not a rung + * (operator, 2026-08-09). + */ + public ResourceRole role() { + return grantedRole; + } + + public boolean atLeast(ResourceRole min) { + return grantedRole != null && grantedRole.atLeast(min); + } + + /** + * True when the resource may only be shown as name, state and who owns it. A + * workspace owner with no grant lands here too, with the access list and + * deletion still open to them via {@link #manages()}. + */ + public boolean limited() { + return grantedRole == null && owningWorkspaceMember; + } + + /** May manage the access list and delete: a resource owner, or a workspace owner. */ + public boolean manages() { + return standingRights || grantedRole == ResourceRole.OWNER; + } + + /** + * The masking split, in the type's own words: 404 for someone the + * resource's existence is hidden from, and an honest 403 for a member of + * the owning workspace who can already see it listed but holds no grant. + */ + public void requireVisible(ResourceAccessMessages messages) { + if (grantedRole != null) { + return; + } + throw owningWorkspaceMember ? messages.noGrant() : messages.notFound(); + } +} diff --git a/src/main/java/kr/ac/pusan/pickle/access/VmAccess.java b/src/main/java/kr/ac/pusan/pickle/access/VmAccess.java index 303599fb..ad5efb2a 100644 --- a/src/main/java/kr/ac/pusan/pickle/access/VmAccess.java +++ b/src/main/java/kr/ac/pusan/pickle/access/VmAccess.java @@ -2,16 +2,20 @@ import kr.ac.pusan.pickle.common.error.ApiException; import kr.ac.pusan.pickle.common.error.ErrorCodes; +import kr.ac.pusan.pickle.resource.VmResourceAdapter; import kr.ac.pusan.pickle.vm.Vm; import org.springframework.http.HttpStatus; /** - * One requester's standing on one VM. + * One requester's standing on one VM: a {@link ResourceStanding} with the VM it + * was computed for, for the VM services that need both. * - *

Three outcomes live here, and they are the whole visibility policy: - * someone outside the owning workspace with no grant is answered 404 so VM ids - * cannot be probed; a member of the owning workspace without a grant may see that - * the VM exists but nothing inside it; a grantee acts at their rung. + *

Three outcomes decide every one of those services: someone outside the + * owning workspace with no grant is answered 404 so VM ids cannot be probed; a + * member of the owning workspace without a grant may see that the VM exists but + * nothing inside it; a grantee acts at their rung. What each outcome means is + * decided by {@code ResourceStanding} for every resource type at once; this + * record only adds the VM and the words the VM refuses in. * * @param vm the VM in question * @param grantedRole the rung from the access list, or null with no grant @@ -23,6 +27,11 @@ public record VmAccess(Vm vm, ResourceRole grantedRole, boolean owningWorkspaceMember, boolean standingRights) { + /** This standing without the VM — the part every resource type shares. */ + public ResourceStanding standing() { + return new ResourceStanding(grantedRole, owningWorkspaceMember, standingRights); + } + /** * The rung this requester acts at, which comes from the access list and * nowhere else. Null when the list does not name them — including for an @@ -32,12 +41,11 @@ public record VmAccess(Vm vm, ResourceRole grantedRole, boolean owningWorkspaceM * published ports are inside, and inside needs a grant. */ public ResourceRole role() { - return grantedRole; + return standing().role(); } public boolean atLeast(ResourceRole min) { - ResourceRole role = role(); - return role != null && role.atLeast(min); + return standing().atLeast(min); } /** @@ -46,12 +54,12 @@ public boolean atLeast(ResourceRole min) { * deletion still open to them via {@link #manages()}. */ public boolean limited() { - return role() == null && owningWorkspaceMember; + return standing().limited(); } /** May manage the access list and delete: a resource owner, or a workspace owner. */ public boolean manages() { - return standingRights || grantedRole == ResourceRole.OWNER; + return standing().manages(); } /** @@ -59,15 +67,8 @@ public boolean manages() { * a workspace member who can already see it listed but holds no grant. */ public Vm requireVisible() { - if (role() != null) { - return vm; - } - if (owningWorkspaceMember) { - throw new ApiException(HttpStatus.FORBIDDEN, ErrorCodes.WORKSPACE_ROLE_INSUFFICIENT, - "이 VM에 접근할 권한이 없습니다", - "이 VM의 접근 목록에 등록되어 있지 않습니다. 자원 소유자에게 접근 권한을 요청해 주세요."); - } - throw VmAccessService.vmNotFound(); + standing().requireVisible(VmResourceAdapter.MESSAGES); + return vm; } /** Visible first, then 403 in the caller's words below {@code min}. */ diff --git a/src/main/java/kr/ac/pusan/pickle/access/VmAccessGrantController.java b/src/main/java/kr/ac/pusan/pickle/access/VmAccessGrantController.java index 948c81cc..46e541a1 100644 --- a/src/main/java/kr/ac/pusan/pickle/access/VmAccessGrantController.java +++ b/src/main/java/kr/ac/pusan/pickle/access/VmAccessGrantController.java @@ -24,15 +24,21 @@ import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; -/** Contract tag {@code vm-access}: who may reach one VM, and at what rung. */ +/** + * Contract tag {@code vm-access}: who may reach one VM, and at what rung. + * + *

The rules are {@link ResourceAccessGrantService}'s and know nothing about + * VMs; this class is the VM's path into them, and is the whole of what a second + * resource type needs to open its own access list. + */ @Tag(name = "vm-access", description = "VM 접근 권한 — 이 VM에 누가 접근할 수 있는지를 정합니다.") @RestController @RequestMapping("/api/v1/vms/{vmId}/access") public class VmAccessGrantController { - private final VmAccessGrantService service; + private final ResourceAccessGrantService service; - public VmAccessGrantController(VmAccessGrantService service) { + public VmAccessGrantController(ResourceAccessGrantService service) { this.service = service; } @@ -42,7 +48,7 @@ public VmAccessGrantController(VmAccessGrantService service) { public ResourceAccessListResponse listVmAccessGrants( @AuthenticationPrincipal AuthenticatedUser principal, @PathVariable long vmId) { - return service.list(principal, vmId); + return service.list(principal, ResourceType.VM, vmId); } @PostMapping @@ -57,7 +63,7 @@ public ResourceAccessGrantView addVmAccessGrant( @PathVariable long vmId, @Valid @RequestBody AddResourceAccessGrantRequest request, HttpServletRequest httpRequest) { - return service.add(principal, vmId, request, clientIp(httpRequest)); + return service.add(principal, ResourceType.VM, vmId, request, clientIp(httpRequest)); } @PatchMapping("/{grantId}") @@ -69,7 +75,8 @@ public ResourceAccessGrantView updateVmAccessGrant( @PathVariable long grantId, @Valid @RequestBody UpdateResourceAccessGrantRequest request, HttpServletRequest httpRequest) { - return service.update(principal, vmId, grantId, request, clientIp(httpRequest)); + return service.update(principal, ResourceType.VM, vmId, grantId, request, + clientIp(httpRequest)); } @DeleteMapping("/{grantId}") @@ -83,6 +90,6 @@ public void removeVmAccessGrant( @PathVariable long vmId, @PathVariable long grantId, HttpServletRequest httpRequest) { - service.remove(principal, vmId, grantId, clientIp(httpRequest)); + service.remove(principal, ResourceType.VM, vmId, grantId, clientIp(httpRequest)); } } diff --git a/src/main/java/kr/ac/pusan/pickle/access/VmAccessGrantService.java b/src/main/java/kr/ac/pusan/pickle/access/VmAccessGrantService.java deleted file mode 100644 index 04ff7355..00000000 --- a/src/main/java/kr/ac/pusan/pickle/access/VmAccessGrantService.java +++ /dev/null @@ -1,231 +0,0 @@ -package kr.ac.pusan.pickle.access; - -import java.util.List; -import java.util.Map; -import kr.ac.pusan.pickle.access.dto.AddResourceAccessGrantRequest; -import kr.ac.pusan.pickle.access.dto.UpdateResourceAccessGrantRequest; -import kr.ac.pusan.pickle.access.dto.ResourceAccessGrantView; -import kr.ac.pusan.pickle.access.dto.ResourceAccessListResponse; -import kr.ac.pusan.pickle.audit.AuditService; -import kr.ac.pusan.pickle.common.error.ApiException; -import kr.ac.pusan.pickle.common.error.ErrorCodes; -import kr.ac.pusan.pickle.common.error.FieldValidationError; -import kr.ac.pusan.pickle.workspace.Workspace; -import kr.ac.pusan.pickle.workspace.WorkspaceMemberRepository; -import kr.ac.pusan.pickle.workspace.WorkspaceRepository; -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.user.UserStatus; -import kr.ac.pusan.pickle.vm.Vm; -import kr.ac.pusan.pickle.vmsettings.VmSettingsService; -import org.springframework.dao.DataIntegrityViolationException; -import org.springframework.http.HttpStatus; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -/** - * Reading and editing one VM's access list (contract tag {@code vm-access}). - * - *

Who may edit it: the VM's own owners, and the owners of the workspace that owns - * it. The second is the recovery path — a VM can end up with no owner of its - * own, for instance when the person who requested it leaves the workspace. - * - *

A workspace owner holds no way inside a VM until they appear on its list. They - * may put themselves there, and that is the intended escape hatch, but any edit - * of theirs that creates content access for themselves is additionally recorded - * as a break-glass event. Adding a workspace-wide grant is included: without that, - * the same result could be had without the marker. - */ -@Service -public class VmAccessGrantService { - - private final VmAccessService vmAccessService; - private final ResourceAccessGrantRepository grantRepository; - private final WorkspaceMemberRepository workspaceMemberRepository; - private final WorkspaceRepository workspaceRepository; - private final VmSettingsService vmSettingsService; - private final UserRepository userRepository; - private final AuditService auditService; - - public VmAccessGrantService(VmAccessService vmAccessService, - ResourceAccessGrantRepository grantRepository, - WorkspaceMemberRepository workspaceMemberRepository, WorkspaceRepository workspaceRepository, - VmSettingsService vmSettingsService, UserRepository userRepository, - AuditService auditService) { - this.vmAccessService = vmAccessService; - this.grantRepository = grantRepository; - this.workspaceMemberRepository = workspaceMemberRepository; - this.workspaceRepository = workspaceRepository; - this.vmSettingsService = vmSettingsService; - this.userRepository = userRepository; - this.auditService = auditService; - } - - @Transactional(readOnly = true) - public ResourceAccessListResponse list(AuthenticatedUser actor, long vmId) { - Vm vm = requireManager(actor, vmId).vm(); - String workspaceName = workspaceRepository.findById(vm.getWorkspaceId()) - .map(Workspace::getName).orElse(""); - return ResourceAccessListResponse.of(vm, workspaceName, - vmSettingsService.string(vmId, VmSettingsService.DISPLAY_NAME), views(vmId)); - } - - @Transactional - public ResourceAccessGrantView add(AuthenticatedUser actor, long vmId, - AddResourceAccessGrantRequest request, String ip) { - VmAccess before = requireManager(actor, vmId); - Vm vm = before.vm(); - ResourceAccessGrant grant = request.granteeType() == AccessGranteeType.WORKSPACE - ? ResourceAccessGrant.forOwningWorkspace(ResourceType.VM, vmId, - requireWorkspaceWideRole(request.role())) - : ResourceAccessGrant.forUser(ResourceType.VM, vmId, - requireEligibleMember(vm, request.userId()), request.role()); - ResourceAccessGrant saved; - try { - saved = grantRepository.saveAndFlush(grant); - } catch (DataIntegrityViolationException alreadyListed) { - throw new ApiException(HttpStatus.CONFLICT, ErrorCodes.VM_ACCESS_GRANT_EXISTS, - "이미 접근 권한이 있습니다", - "이 대상은 이미 이 VM의 접근 목록에 있습니다. 등급을 바꾸려면 기존 항목을 수정해 주세요."); - } - audit(actor, before, AuditService.VM_ACCESS_GRANT_ADD, saved, null, ip); - return view(saved); - } - - @Transactional - public ResourceAccessGrantView update(AuthenticatedUser actor, long vmId, long grantId, - UpdateResourceAccessGrantRequest request, String ip) { - VmAccess before = requireManager(actor, vmId); - ResourceAccessGrant grant = requireGrant(vmId, grantId); - ResourceRole previous = grant.getRole(); - grant.setRole(grant.getGranteeType() == AccessGranteeType.WORKSPACE - ? requireWorkspaceWideRole(request.role()) - : request.role()); - audit(actor, before, AuditService.VM_ACCESS_GRANT_UPDATE, grant, previous, ip); - return view(grant); - } - - @Transactional - public void remove(AuthenticatedUser actor, long vmId, long grantId, String ip) { - VmAccess before = requireManager(actor, vmId); - ResourceAccessGrant grant = requireGrant(vmId, grantId); - grantRepository.delete(grant); - audit(actor, before, AuditService.VM_ACCESS_GRANT_REMOVE, grant, null, ip); - } - - /** Managing the list is a resource owner's right, or a workspace owner's standing one. */ - private VmAccess requireManager(AuthenticatedUser actor, long vmId) { - VmAccess access = vmAccessService.of(actor, vmId); - if (!access.manages()) { - access.requireVisible(); - throw new ApiException(HttpStatus.FORBIDDEN, ErrorCodes.WORKSPACE_ROLE_INSUFFICIENT, - "접근 권한을 관리할 권한이 없습니다", - "이 VM의 소유자 또는 워크스페이스 소유자만 접근 권한을 관리할 수 있습니다."); - } - return access; - } - - /** - * A named grant may only be given to a member of the owning workspace — the rule - * that keeps the list from disagreeing with the 404 that hides this VM from - * everyone else. - */ - private long requireEligibleMember(Vm vm, Long userId) { - if (userId == null) { - throw ApiException.validationFailed(List.of(new FieldValidationError("userId", - "대상 사용자를 지정해 주세요."))); - } - boolean member = workspaceMemberRepository.findByWorkspaceIdAndUserId(vm.getWorkspaceId(), userId) - .isPresent(); - boolean active = userRepository.findById(userId) - .filter(user -> user.getStatus() == UserStatus.ACTIVE).isPresent(); - if (!member || !active) { - throw ApiException.validationFailed(List.of(new FieldValidationError("userId", - "이 VM을 소유한 워크스페이스의 구성원만 접근 권한을 받을 수 있습니다. 먼저 워크스페이스에 추가해 주세요."))); - } - return userId; - } - - /** The whole workspace is never handed the rungs that manage access or destroy. */ - private ResourceRole requireWorkspaceWideRole(ResourceRole role) { - if (role == ResourceRole.OWNER || role == ResourceRole.EDITOR) { - throw ApiException.validationFailed(List.of(new FieldValidationError("role", - "워크스페이스 전체에는 참여자 또는 열람자까지만 부여할 수 있습니다. 그보다 높은 등급은 " - + "구성원을 지정해 부여해 주세요."))); - } - return role; - } - - private ResourceAccessGrant requireGrant(long vmId, long grantId) { - return grantRepository.findById(grantId) - .filter(grant -> grant.getResourceType() == ResourceType.VM - && grant.getResourceId() == vmId) - .orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, - ErrorCodes.RESOURCE_NOT_FOUND, "리소스를 찾을 수 없습니다", - "해당 접근 권한이 존재하지 않습니다.")); - } - - private void audit(AuthenticatedUser actor, VmAccess before, String action, - ResourceAccessGrant grant, ResourceRole previousRole, String ip) { - Vm vm = before.vm(); - Map detail = new java.util.LinkedHashMap<>(); - detail.put("grantId", grant.getId()); - detail.put("granteeType", grant.getGranteeType().name()); - if (grant.getUserId() != null) { - detail.put("granteeUserId", grant.getUserId()); - } - detail.put("role", grant.getRole().name()); - if (previousRole != null) { - detail.put("previousRole", previousRole.name()); - } - auditService.recordAfterCommit(actor.id(), actor.role().name(), action, "vm", vm.getId(), - Map.copyOf(detail), ip); - if (opensTheDoorForActor(actor, before, action, grant)) { - auditService.recordAfterCommit(actor.id(), actor.role().name(), - AuditService.VM_ACCESS_BREAK_GLASS, "vm", vm.getId(), Map.copyOf(detail), ip); - } - } - - /** - * True when this edit is what lets the actor inside a VM they could not - * reach before it — the case the break-glass record exists for. - * - *

Both sides of the comparison are needed. Looking only at the state - * after the write marks a resource owner who was already inside, and marks - * someone lowering their own rung, which would put a false - * emergency-access record in the trail; a signal that fires on ordinary - * edits is worth nothing to whoever reads the audit later. - */ - private boolean opensTheDoorForActor(AuthenticatedUser actor, VmAccess before, String action, - ResourceAccessGrant grant) { - if (AuditService.VM_ACCESS_GRANT_REMOVE.equals(action)) { - return false; - } - boolean namesTheActor = grant.getGranteeType() == AccessGranteeType.WORKSPACE - || Long.valueOf(actor.id()).equals(grant.getUserId()); - if (!namesTheActor || before.atLeast(ResourceRole.MEMBER)) { - return false; - } - return vmAccessService.of(before.vm(), actor.id()).atLeast(ResourceRole.MEMBER); - } - - private List views(long vmId) { - List grants = grantRepository - .findByResourceTypeAndResourceIdOrderByIdAsc(ResourceType.VM, vmId); - Map users = userRepository.findAllById(grants.stream() - .map(ResourceAccessGrant::getUserId).filter(java.util.Objects::nonNull) - .toList()).stream() - .collect(java.util.stream.Collectors.toMap(User::getId, user -> user)); - return grants.stream() - .map(grant -> ResourceAccessGrantView.of(grant, - grant.getUserId() == null ? null : users.get(grant.getUserId()))) - .toList(); - } - - private ResourceAccessGrantView view(ResourceAccessGrant grant) { - return ResourceAccessGrantView.of(grant, - grant.getUserId() == null ? null : userRepository.findById(grant.getUserId()) - .orElse(null)); - } -} diff --git a/src/main/java/kr/ac/pusan/pickle/access/VmAccessService.java b/src/main/java/kr/ac/pusan/pickle/access/VmAccessService.java index 9e9b7814..732996fd 100644 --- a/src/main/java/kr/ac/pusan/pickle/access/VmAccessService.java +++ b/src/main/java/kr/ac/pusan/pickle/access/VmAccessService.java @@ -1,15 +1,10 @@ package kr.ac.pusan.pickle.access; -import java.util.List; import kr.ac.pusan.pickle.common.error.ApiException; -import kr.ac.pusan.pickle.common.error.ErrorCodes; -import kr.ac.pusan.pickle.workspace.WorkspaceMember; -import kr.ac.pusan.pickle.workspace.WorkspaceMemberRepository; -import kr.ac.pusan.pickle.workspace.WorkspaceMemberRole; +import kr.ac.pusan.pickle.resource.VmResourceAdapter; import kr.ac.pusan.pickle.security.AuthenticatedUser; import kr.ac.pusan.pickle.vm.Vm; import kr.ac.pusan.pickle.vm.VmRepository; -import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -22,9 +17,11 @@ * management — held here as a flag rather than folded into a rung, so that no * check for something inside the VM can be satisfied by it. * - *

This is the VM adapter of a resource-generic model: containers and API - * keys are meant to arrive as siblings of this class over the same table, which - * is why grant rows are keyed by resource type rather than by vm_id. + *

This is the VM's face of a resource-generic model: what a grant is worth is + * worked out by {@link ResourceAccessResolver} for every type at once, and what + * is left here is loading the VM and carrying it alongside the answer. Containers + * and API keys arrive as siblings over the same table and the same resolver, + * which is why grant rows are keyed by resource type rather than by vm_id. * *

Admin surfaces are deliberately not routed through here: their scope is * the organisation, not the access list, and mixing the two is how a bypass @@ -34,14 +31,11 @@ public class VmAccessService { private final VmRepository vmRepository; - private final WorkspaceMemberRepository workspaceMemberRepository; - private final ResourceAccessGrantRepository grantRepository; + private final ResourceAccessResolver resolver; - public VmAccessService(VmRepository vmRepository, WorkspaceMemberRepository workspaceMemberRepository, - ResourceAccessGrantRepository grantRepository) { + public VmAccessService(VmRepository vmRepository, ResourceAccessResolver resolver) { this.vmRepository = vmRepository; - this.workspaceMemberRepository = workspaceMemberRepository; - this.grantRepository = grantRepository; + this.resolver = resolver; } /** Standing of {@code actor} on {@code vmId}; unknown VM answers 404. */ @@ -60,12 +54,10 @@ public VmAccess of(long vmId, long userId) { /** Standing on an already-loaded VM, for callers that resolved it first. */ @Transactional(readOnly = true) public VmAccess of(Vm vm, long userId) { - WorkspaceMemberRole membership = workspaceMemberRepository - .findByWorkspaceIdAndUserId(vm.getWorkspaceId(), userId) - .map(WorkspaceMember::getRole) - .orElse(null); - return new VmAccess(vm, grantedRole(vm.getId(), userId, membership != null), - membership != null, membership == WorkspaceMemberRole.OWNER); + ResourceStanding standing = resolver.standing(ResourceType.VM, vm.getId(), + vm.getWorkspaceId(), userId); + return new VmAccess(vm, standing.grantedRole(), standing.owningWorkspaceMember(), + standing.standingRights()); } /** @@ -78,35 +70,8 @@ public VmAccess find(long vmId, long userId) { return vmRepository.findById(vmId).map(vm -> of(vm, userId)).orElse(null); } - /** - * The strongest rung the access list gives this person: their own grant and - * the workspace-wide one, whichever is higher. - * - *

A grant counts only while its holder is still in the owning workspace. - * Losing membership already deletes their grants, so this changes nothing - * in practice — it is here so that a missed cleanup cannot leave someone - * reaching a VM of a workspace they left. - */ - private ResourceRole grantedRole(long vmId, long userId, boolean owningWorkspaceMember) { - if (!owningWorkspaceMember) { - return null; - } - ResourceRole best = null; - List grants = grantRepository - .findByResourceTypeAndResourceIdOrderByIdAsc(ResourceType.VM, vmId); - for (ResourceAccessGrant grant : grants) { - boolean applies = grant.getGranteeType() == AccessGranteeType.WORKSPACE - || Long.valueOf(userId).equals(grant.getUserId()); - if (applies && (best == null || grant.getRole().atLeast(best))) { - best = grant.getRole(); - } - } - return best; - } - /** The masking 404: an existing but unreachable VM reads as a missing one. */ public static ApiException vmNotFound() { - return new ApiException(HttpStatus.NOT_FOUND, ErrorCodes.RESOURCE_NOT_FOUND, - "리소스를 찾을 수 없습니다", "해당 VM이 존재하지 않습니다."); + return VmResourceAdapter.MESSAGES.notFound(); } } diff --git a/src/main/java/kr/ac/pusan/pickle/access/dto/ResourceAccessListResponse.java b/src/main/java/kr/ac/pusan/pickle/access/dto/ResourceAccessListResponse.java index df8dada1..fa801462 100644 --- a/src/main/java/kr/ac/pusan/pickle/access/dto/ResourceAccessListResponse.java +++ b/src/main/java/kr/ac/pusan/pickle/access/dto/ResourceAccessListResponse.java @@ -3,6 +3,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import java.util.List; import kr.ac.pusan.pickle.access.ResourceType; +import kr.ac.pusan.pickle.resource.ResourceIdentity; import org.jspecify.annotations.Nullable; /** @@ -16,8 +17,9 @@ * about. What it carries is exactly what such a person may already see in the * list — name, state, owning workspace — and nothing from inside. * - *

Written in resource terms rather than VM terms so a second kind of - * resource opens its own access list with a controller and nothing else. + *

Written in resource terms rather than VM terms: the identity comes from the + * resource type's own adapter, so a second kind of resource opens its own + * access list with a controller over the shared service and nothing else. */ @Schema(description = "리소스 접근 권한 목록과, 그 목록이 어느 리소스의 것인지 알려 주는 최소 정보") public record ResourceAccessListResponse( @@ -38,11 +40,11 @@ public record ResourceBrief( String workspaceName) { } - public static ResourceAccessListResponse of(kr.ac.pusan.pickle.vm.Vm vm, String workspaceName, - @Nullable String displayName, List grants) { + public static ResourceAccessListResponse of(ResourceType type, ResourceIdentity resource, + String workspaceName, List grants) { return new ResourceAccessListResponse( - new ResourceBrief(vm.getId(), ResourceType.VM, vm.getName(), displayName, - vm.getStatus().name(), vm.getWorkspaceId(), workspaceName), + new ResourceBrief(resource.id(), type, resource.name(), resource.displayName(), + resource.status(), resource.workspaceId(), workspaceName), grants); } } diff --git a/src/main/java/kr/ac/pusan/pickle/resource/ResourceIdentity.java b/src/main/java/kr/ac/pusan/pickle/resource/ResourceIdentity.java new file mode 100644 index 00000000..2603bd17 --- /dev/null +++ b/src/main/java/kr/ac/pusan/pickle/resource/ResourceIdentity.java @@ -0,0 +1,24 @@ +package kr.ac.pusan.pickle.resource; + +import org.jspecify.annotations.Nullable; + +/** + * As much of one resource as somebody without a grant may be told: that it + * exists, what it is called, what state it is in, and whose workspace it + * belongs to. + * + *

This is what a resource type hands the shared access machinery in place of + * its own entity. Everything inside the resource — an address, a guest account, + * a published port — is deliberately absent, so that code holding one of these + * cannot leak past the limited view even by accident. + * + * @param id the resource's id within its type + * @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, + @Nullable String displayName, String status) { +} 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 5dbd9d90..22fc50f8 100644 --- a/src/main/java/kr/ac/pusan/pickle/resource/ResourceTypeAdapter.java +++ b/src/main/java/kr/ac/pusan/pickle/resource/ResourceTypeAdapter.java @@ -1,6 +1,9 @@ package kr.ac.pusan.pickle.resource; import java.util.List; +import java.util.Optional; +import kr.ac.pusan.pickle.access.ResourceAccessAudit; +import kr.ac.pusan.pickle.access.ResourceAccessMessages; import kr.ac.pusan.pickle.access.ResourceType; import kr.ac.pusan.pickle.resource.dto.ResourceSummaryResponse; import kr.ac.pusan.pickle.security.AuthenticatedUser; @@ -24,6 +27,27 @@ public interface ResourceTypeAdapter { /** The type this adapter answers for. */ ResourceType type(); + /** + * The resource behind an id, reduced to what somebody without a grant may + * be told; empty when this type has nothing under that id. + * + *

Empty is what the masking 404 is built on, so a type whose rows outlive + * the thing they describe must still answer here: a destroyed VM keeps its + * row and its access list, and the people on that list may still read its + * history. "Gone" means the row is gone, not that the resource stopped + * running. + */ + Optional identify(long resourceId); + + /** + * What this type says when it refuses — the only part of the access rules + * that is allowed to differ between types. + */ + ResourceAccessMessages accessMessages(); + + /** The names this type's access-list edits take in the audit trail. */ + ResourceAccessAudit accessAudit(); + /** * Every resource of this type the workspace owns, destroyed ones included. * 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 cd017c3c..18f91777 100644 --- a/src/main/java/kr/ac/pusan/pickle/resource/VmResourceAdapter.java +++ b/src/main/java/kr/ac/pusan/pickle/resource/VmResourceAdapter.java @@ -1,13 +1,19 @@ package kr.ac.pusan.pickle.resource; import java.util.List; +import java.util.Optional; +import kr.ac.pusan.pickle.access.ResourceAccessAudit; +import kr.ac.pusan.pickle.access.ResourceAccessMessages; import kr.ac.pusan.pickle.access.ResourceType; +import kr.ac.pusan.pickle.audit.AuditService; +import kr.ac.pusan.pickle.common.error.ErrorCodes; import kr.ac.pusan.pickle.resource.dto.ResourceSummaryResponse; 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 kr.ac.pusan.pickle.vmsettings.VmSettingsService; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; @@ -17,12 +23,35 @@ @Component public class VmResourceAdapter implements ResourceTypeAdapter { + /** + * Every sentence the access machinery says about a VM. Public because the + * VM's own services refuse in the same words, and one wording that two + * places copy is one wording that drifts. + */ + public static final ResourceAccessMessages MESSAGES = new ResourceAccessMessages( + "해당 VM이 존재하지 않습니다.", + new ResourceAccessMessages.Refusal("이 VM에 접근할 권한이 없습니다", + "이 VM의 접근 목록에 등록되어 있지 않습니다. 자원 소유자에게 접근 권한을 요청해 주세요."), + new ResourceAccessMessages.Refusal("접근 권한을 관리할 권한이 없습니다", + "이 VM의 소유자 또는 워크스페이스 소유자만 접근 권한을 관리할 수 있습니다."), + "이 VM을 소유한 워크스페이스의 구성원만 접근 권한을 받을 수 있습니다. 먼저 워크스페이스에 추가해 주세요.", + ErrorCodes.VM_ACCESS_GRANT_EXISTS, + new ResourceAccessMessages.Refusal("이미 접근 권한이 있습니다", + "이 대상은 이미 이 VM의 접근 목록에 있습니다. 등급을 바꾸려면 기존 항목을 수정해 주세요.")); + + private static final ResourceAccessAudit AUDIT = new ResourceAccessAudit("vm", + AuditService.VM_ACCESS_GRANT_ADD, AuditService.VM_ACCESS_GRANT_UPDATE, + AuditService.VM_ACCESS_GRANT_REMOVE, AuditService.VM_ACCESS_BREAK_GLASS); + private final VmRepository vmRepository; private final VmQueryService vmQueryService; + private final VmSettingsService vmSettingsService; - public VmResourceAdapter(VmRepository vmRepository, VmQueryService vmQueryService) { + public VmResourceAdapter(VmRepository vmRepository, VmQueryService vmQueryService, + VmSettingsService vmSettingsService) { this.vmRepository = vmRepository; this.vmQueryService = vmQueryService; + this.vmSettingsService = vmSettingsService; } @Override @@ -30,6 +59,27 @@ public ResourceType type() { return ResourceType.VM; } + @Override + 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())); + } + + @Override + public ResourceAccessMessages accessMessages() { + return MESSAGES; + } + + @Override + public ResourceAccessAudit accessAudit() { + return AUDIT; + } + @Override public List idsOwnedByWorkspace(long workspaceId) { return vmRepository.findIdsByWorkspaceIdIn(List.of(workspaceId));