From f8670bf8a7cd758294799f058598f3727b57fed8 Mon Sep 17 00:00:00 2001 From: yessjun Date: Mon, 10 Aug 2026 21:41:47 +0900 Subject: [PATCH 1/2] refactor: add a resource-generic access standing and vocabulary The access list is keyed by resource type already, but everything that reads it is written against the VM entity. These are the pieces the shared write side needs: what a grant is worth (one resolver, one standing), and the per-type words and audit names that are the only part a second resource type may legitimately answer differently. --- .../ac/pusan/pickle/access/GrantChange.java | 8 ++ .../pickle/access/ResourceAccessAudit.java | 29 +++++++ .../pickle/access/ResourceAccessMessages.java | 75 +++++++++++++++++++ .../pickle/access/ResourceAccessResolver.java | 72 ++++++++++++++++++ .../pusan/pickle/access/ResourceStanding.java | 64 ++++++++++++++++ .../pickle/resource/ResourceIdentity.java | 24 ++++++ 6 files changed, 272 insertions(+) create mode 100644 src/main/java/kr/ac/pusan/pickle/access/GrantChange.java create mode 100644 src/main/java/kr/ac/pusan/pickle/access/ResourceAccessAudit.java create mode 100644 src/main/java/kr/ac/pusan/pickle/access/ResourceAccessMessages.java create mode 100644 src/main/java/kr/ac/pusan/pickle/access/ResourceAccessResolver.java create mode 100644 src/main/java/kr/ac/pusan/pickle/access/ResourceStanding.java create mode 100644 src/main/java/kr/ac/pusan/pickle/resource/ResourceIdentity.java 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 0000000..d2d786b --- /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 0000000..34f6192 --- /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/ResourceAccessMessages.java b/src/main/java/kr/ac/pusan/pickle/access/ResourceAccessMessages.java new file mode 100644 index 0000000..6734402 --- /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 0000000..199f5c8 --- /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 0000000..939859b --- /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/resource/ResourceIdentity.java b/src/main/java/kr/ac/pusan/pickle/resource/ResourceIdentity.java new file mode 100644 index 0000000..2603bd1 --- /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) { +} From 891afec94f595f79d9a1a01d30cd4315f47768a0 Mon Sep 17 00:00:00 2001 From: yessjun Date: Mon, 10 Aug 2026 21:41:59 +0900 Subject: [PATCH 2/2] refactor: move the access grant rules off the VM type Grant, revoke, list, the cap on workspace-wide grants, the grantee eligibility rule and the break-glass boundary now live in one service that names no resource type. What a type still answers for itself is loading the thing, which workspace owns it, what its limited view shows and the sentences it refuses in, all through its existing adapter. The VM controller keeps its paths and its bodies unchanged. --- .../access/ResourceAccessGrantService.java | 270 ++++++++++++++++++ .../kr/ac/pusan/pickle/access/VmAccess.java | 39 +-- .../access/VmAccessGrantController.java | 21 +- .../pickle/access/VmAccessGrantService.java | 231 --------------- .../pusan/pickle/access/VmAccessService.java | 63 +--- .../dto/ResourceAccessListResponse.java | 14 +- .../pickle/resource/ResourceTypeAdapter.java | 24 ++ .../pickle/resource/VmResourceAdapter.java | 52 +++- 8 files changed, 401 insertions(+), 313 deletions(-) create mode 100644 src/main/java/kr/ac/pusan/pickle/access/ResourceAccessGrantService.java delete mode 100644 src/main/java/kr/ac/pusan/pickle/access/VmAccessGrantService.java 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 0000000..7724a20 --- /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/VmAccess.java b/src/main/java/kr/ac/pusan/pickle/access/VmAccess.java index 303599f..ad5efb2 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 948c81c..46e541a 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 04ff735..0000000 --- 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 9e9b781..732996f 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 df8dada..fa80146 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/ResourceTypeAdapter.java b/src/main/java/kr/ac/pusan/pickle/resource/ResourceTypeAdapter.java index 5dbd9d9..22fc50f 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 cd017c3..18f9177 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));