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 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 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 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 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 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 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 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