From f4cd41dd4d725f209e03fbe045990a01b860f5e8 Mon Sep 17 00:00:00 2001 From: Claas Date: Sat, 27 Jun 2026 17:01:58 +0000 Subject: [PATCH] Add role permissions to organizations --- .../java/com/yealch/yealch/Organization.java | 44 ++++++++----- .../yealch/yealch/OrganizationMembership.java | 63 +++++++++++++++++++ .../com/yealch/yealch/OrganizationRole.java | 7 +++ .../yealch/OrganizationsController.java | 55 ++++++++-------- .../src/main/java/com/yealch/yealch/User.java | 29 +++++---- .../com/yealch/yealch/UsersController.java | 22 +++---- 6 files changed, 150 insertions(+), 70 deletions(-) create mode 100644 server/src/main/java/com/yealch/yealch/OrganizationMembership.java create mode 100644 server/src/main/java/com/yealch/yealch/OrganizationRole.java diff --git a/server/src/main/java/com/yealch/yealch/Organization.java b/server/src/main/java/com/yealch/yealch/Organization.java index cb2993c..de0d29f 100644 --- a/server/src/main/java/com/yealch/yealch/Organization.java +++ b/server/src/main/java/com/yealch/yealch/Organization.java @@ -6,17 +6,19 @@ import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; -import jakarta.persistence.JoinTable; -import jakarta.persistence.ManyToMany; -import jakarta.persistence.OneToMany; import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToMany; import jakarta.persistence.Table; + +import java.util.Arrays; import java.util.HashSet; import java.util.Set; +import java.util.stream.Collectors; @Entity @Table(name = "organizations") public class Organization { + @Id @GeneratedValue(strategy = GenerationType.AUTO) private Long id; @@ -24,13 +26,9 @@ public class Organization { @Column(nullable = false) private String name; - @ManyToMany - @JoinTable(name = "organization_members", joinColumns = @JoinColumn(name = "organization_id"), inverseJoinColumns = @JoinColumn(name = "user_id")) - private Set members = new HashSet<>(); + @OneToMany(mappedBy = "organization", cascade = CascadeType.ALL, orphanRemoval = true) + private Set memberships = new HashSet<>(); - /** - * An organization has one or many projects - */ @OneToMany(mappedBy = "organization", cascade = CascadeType.ALL, orphanRemoval = true) private Set projects = new HashSet<>(); @@ -46,18 +44,33 @@ public class Organization { this.name = name; } + public Set getMemberships() { + return memberships; + } + public Set getMembers() { - return members; + return memberships.stream().map(OrganizationMembership::getUser).collect(Collectors.toSet()); } - public void addMember(User user) { - members.add(user); - user.getOrganizations().add(this); + public void addMember(User user, OrganizationRole role) { + OrganizationMembership membership = new OrganizationMembership(this, user, role); + memberships.add(membership); + user.getMemberships().add(membership); } public void removeMember(User user) { - members.remove(user); - user.getOrganizations().remove(this); + memberships.removeIf(membership -> user.getId().equals(membership.getUser().getId())); + user.getMemberships().removeIf(membership -> this.getId().equals(membership.getOrganization().getId())); + } + + public boolean hasMember(Long userId) { + return memberships.stream().anyMatch(membership -> userId.equals(membership.getUser().getId())); + } + + public boolean hasMemberWithRole(Long userId, OrganizationRole... roles) { + Set allowedRoles = new HashSet<>(Arrays.asList(roles)); + return memberships.stream().anyMatch(membership -> + userId.equals(membership.getUser().getId()) && allowedRoles.contains(membership.getRole())); } public Set getProjects() { @@ -68,5 +81,4 @@ public class Organization { projects.add(project); project.setOrganization(this); } - } diff --git a/server/src/main/java/com/yealch/yealch/OrganizationMembership.java b/server/src/main/java/com/yealch/yealch/OrganizationMembership.java new file mode 100644 index 0000000..964ba47 --- /dev/null +++ b/server/src/main/java/com/yealch/yealch/OrganizationMembership.java @@ -0,0 +1,63 @@ +package com.yealch.yealch; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; + +@Entity +@Table(name = "organization_members", uniqueConstraints = @UniqueConstraint(columnNames = { "organization_id", "user_id" })) +public class OrganizationMembership { + + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + private Long id; + + @ManyToOne + @JoinColumn(name = "organization_id", nullable = false) + private Organization organization; + + @ManyToOne + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private OrganizationRole role; + + public OrganizationMembership() { + } + + public OrganizationMembership(Organization organization, User user, OrganizationRole role) { + this.organization = organization; + this.user = user; + this.role = role; + } + + public Long getId() { + return id; + } + + public Organization getOrganization() { + return organization; + } + + public User getUser() { + return user; + } + + public OrganizationRole getRole() { + return role; + } + + public void setRole(OrganizationRole role) { + this.role = role; + } +} diff --git a/server/src/main/java/com/yealch/yealch/OrganizationRole.java b/server/src/main/java/com/yealch/yealch/OrganizationRole.java new file mode 100644 index 0000000..566dd50 --- /dev/null +++ b/server/src/main/java/com/yealch/yealch/OrganizationRole.java @@ -0,0 +1,7 @@ +package com.yealch.yealch; + +public enum OrganizationRole { + MEMBER, + ADMINISTRATOR, + OWNER +} diff --git a/server/src/main/java/com/yealch/yealch/OrganizationsController.java b/server/src/main/java/com/yealch/yealch/OrganizationsController.java index d9b0e0d..630c28f 100644 --- a/server/src/main/java/com/yealch/yealch/OrganizationsController.java +++ b/server/src/main/java/com/yealch/yealch/OrganizationsController.java @@ -57,7 +57,7 @@ public class OrganizationsController { return organizationRepository.findById(organizationId) .>map(organization -> { - if (!isMember(organization, authenticatedUserId)) { + if (!organization.hasMember(authenticatedUserId)) { return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); } return ResponseEntity.ok(toResponse(organization)); @@ -67,7 +67,8 @@ public class OrganizationsController { } /** - * Creates a registration of a user as a member of an organization + * Creates a registration of a user as a member of an organization. + * Requires the authenticated user to be an owner of the organization. */ @PostMapping("/{organizationId}/members/registrations") public ResponseEntity addMemberToOrganization(@PathVariable Long organizationId, @PathVariable Long userId, @@ -77,20 +78,19 @@ public class OrganizationsController { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } - Optional organizationOptional = organizationRepository.findById(organizationId); - Optional userOptional = userRepository.findById(userId); + Optional foundOrganization = organizationRepository.findById(organizationId); + Optional foundUser = userRepository.findById(userId); - if (organizationOptional.isEmpty() || userOptional.isEmpty()) { + if (foundOrganization.isEmpty() || foundUser.isEmpty()) { return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("error", "organization or user not found")); } - Organization organization = organizationOptional.get(); - if (!isMember(organization, authenticatedUserId)) { + Organization organization = foundOrganization.get(); + if (!organization.hasMemberWithRole(authenticatedUserId, OrganizationRole.OWNER)) { return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); } - User user = userOptional.get(); - organization.addMember(user); + organization.addMember(foundUser.get(), OrganizationRole.MEMBER); organizationRepository.save(organization); return ResponseEntity.ok(toResponse(organization)); @@ -104,20 +104,19 @@ public class OrganizationsController { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } - Optional organizationOptional = organizationRepository.findById(organizationId); - Optional userOptional = userRepository.findById(userId); + Optional foundOrganization = organizationRepository.findById(organizationId); + Optional foundUser = userRepository.findById(userId); - if (organizationOptional.isEmpty() || userOptional.isEmpty()) { + if (foundOrganization.isEmpty() || foundUser.isEmpty()) { return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("error", "organization or user not found")); } - Organization organization = organizationOptional.get(); - if (!isMember(organization, authenticatedUserId)) { + Organization organization = foundOrganization.get(); + if (!organization.hasMemberWithRole(authenticatedUserId, OrganizationRole.OWNER)) { return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); } - User user = userOptional.get(); - organization.removeMember(user); + organization.removeMember(foundUser.get()); organizationRepository.save(organization); return ResponseEntity.ok(toResponse(organization)); @@ -130,13 +129,13 @@ public class OrganizationsController { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } - Optional organizationOptional = organizationRepository.findById(organizationId); - if (organizationOptional.isEmpty()) { + Optional foundOrganization = organizationRepository.findById(organizationId); + if (foundOrganization.isEmpty()) { return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("error", "organization not found")); } - Organization organization = organizationOptional.get(); - if (!isMember(organization, authenticatedUserId)) { + Organization organization = foundOrganization.get(); + if (!organization.hasMemberWithRole(authenticatedUserId, OrganizationRole.OWNER)) { return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); } @@ -144,13 +143,6 @@ public class OrganizationsController { return ResponseEntity.noContent().build(); } - private Map toUserResponse(User user) { - return Map.of( - "id", user.getId(), - "name", user.getName(), - "username", user.getUsername()); - } - @GetMapping("/{organizationId}/users") public ResponseEntity getOrganizationUsers(@PathVariable Long organizationId, Authentication authentication) { Long authenticatedUserId = UsersController.getUserId(authentication).orElse(null); @@ -160,7 +152,7 @@ public class OrganizationsController { return organizationRepository.findById(organizationId) .>map(organization -> { - if (!isMember(organization, authenticatedUserId)) { + if (!organization.hasMember(authenticatedUserId)) { return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); } return ResponseEntity.ok(organization.getMembers().stream().map(this::toUserResponse).toList()); @@ -169,8 +161,11 @@ public class OrganizationsController { .body(Map.of("error", "organization not found"))); } - private boolean isMember(Organization organization, Long userId) { - return organization.getMembers().stream().anyMatch(member -> userId.equals(member.getId())); + private Map toUserResponse(User user) { + return Map.of( + "id", user.getId(), + "name", user.getName(), + "username", user.getUsername()); } private Map toResponse(Organization organization) { diff --git a/server/src/main/java/com/yealch/yealch/User.java b/server/src/main/java/com/yealch/yealch/User.java index 61da21a..9335198 100644 --- a/server/src/main/java/com/yealch/yealch/User.java +++ b/server/src/main/java/com/yealch/yealch/User.java @@ -1,12 +1,21 @@ package com.yealch.yealch; -import jakarta.persistence.*; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; + import java.util.HashSet; import java.util.Set; +import java.util.stream.Collectors; @Entity @Table(name = "users") public class User { + @Id @GeneratedValue(strategy = GenerationType.AUTO) private Long id; @@ -20,25 +29,19 @@ public class User { @Column(nullable = false) private String password; - @ManyToMany(mappedBy = "members") - private Set organizations = new HashSet<>(); + @OneToMany(mappedBy = "user") + private Set memberships = new HashSet<>(); public Long getId() { return id; } - public Set getOrganizations() { - return organizations; - } - - public void addOrganization(Organization organization) { - organizations.add(organization); - organization.getMembers().add(this); + public Set getMemberships() { + return memberships; } - public void removeOrganization(Organization organization) { - organizations.remove(organization); - organization.getMembers().remove(this); + public Set getOrganizations() { + return memberships.stream().map(OrganizationMembership::getOrganization).collect(Collectors.toSet()); } public String getName() { diff --git a/server/src/main/java/com/yealch/yealch/UsersController.java b/server/src/main/java/com/yealch/yealch/UsersController.java index b1316a9..c515e64 100644 --- a/server/src/main/java/com/yealch/yealch/UsersController.java +++ b/server/src/main/java/com/yealch/yealch/UsersController.java @@ -90,7 +90,7 @@ public class UsersController { if (user.getOrganizations().isEmpty()) { Organization defaultOrganization = new Organization(); defaultOrganization.setName(user.getName() + " Organization"); - defaultOrganization.addMember(user); + defaultOrganization.addMember(user, OrganizationRole.OWNER); organizationRepository.save(defaultOrganization); } @@ -120,7 +120,7 @@ public class UsersController { if (user.getOrganizations().isEmpty()) { Organization defaultOrganization = new Organization(); defaultOrganization.setName(user.getName() + " Organization"); - defaultOrganization.addMember(user); + defaultOrganization.addMember(user, OrganizationRole.OWNER); organizationRepository.save(defaultOrganization); } @@ -164,7 +164,7 @@ public class UsersController { return userRepository.findById(userId) .>map(user -> { Organization organization = organizationRepository.findById(organizationId).orElse(null); - if (organization == null || !organization.getMembers().contains(user)) { + if (organization == null || !organization.hasMember(user.getId())) { return ResponseEntity.status(HttpStatus.NOT_FOUND) .body(Map.of("error", "organization not found or user is not a member")); } @@ -216,7 +216,7 @@ public class UsersController { .>map(user -> projectRepository.findById(projectId) .>map(project -> { Organization organization = project.getOrganization(); - if (organization == null || !organization.getMembers().contains(user)) { + if (organization == null || !organization.hasMember(user.getId())) { return ResponseEntity.status(HttpStatus.NOT_FOUND) .body(Map.of("error", "project not found or user is not a member")); } @@ -263,7 +263,7 @@ public class UsersController { if (organization == null) { return false; } - return organization.getMembers().contains(user); + return organization.hasMember(user.getId()); }) .map(time -> Map.of( "id", time.getId(), @@ -297,7 +297,7 @@ public class UsersController { .>map(user -> { Organization organization = new Organization(); organization.setName(organizationName); - organization.addMember(user); + organization.addMember(user, OrganizationRole.OWNER); organizationRepository.save(organization); return ResponseEntity.status(HttpStatus.CREATED).build(); @@ -321,9 +321,9 @@ public class UsersController { return userRepository.findById(userId) .>map(user -> organizationRepository.findById(organizationId) .>map(organization -> { - if (!organization.getMembers().contains(user)) { + if (!organization.hasMemberWithRole(user.getId(), OrganizationRole.ADMINISTRATOR, OrganizationRole.OWNER)) { return ResponseEntity.status(HttpStatus.FORBIDDEN) - .body(Map.of("error", "user is not a member of the organization")); + .body(Map.of("error", "user does not have permission to edit the organization")); } organization.setName(newName); @@ -356,7 +356,7 @@ public class UsersController { return userRepository.findById(userId) .>map(user -> projectRepository.findById(projectId) .>map(project -> { - if (!project.getOrganization().getMembers().contains(user)) { + if (!project.getOrganization().hasMember(user.getId())) { return ResponseEntity.status(HttpStatus.FORBIDDEN) .body(Map.of("error", "user is not a member of the project")); } @@ -429,7 +429,7 @@ public class UsersController { Time time = foundTime.get(); Project project = time.getProject(); if (project == null || project.getOrganization() == null - || !project.getOrganization().getMembers().contains(user)) { + || !project.getOrganization().hasMember(user.getId())) { return ResponseEntity.status(HttpStatus.FORBIDDEN) .body(Map.of("error", "user is not allowed to modify time: " + timeId)); } @@ -482,7 +482,7 @@ public class UsersController { Time time = foundTime.get(); Project project = time.getProject(); if (project == null || project.getOrganization() == null - || !project.getOrganization().getMembers().contains(user)) { + || !project.getOrganization().hasMember(user.getId())) { return ResponseEntity.status(HttpStatus.FORBIDDEN) .body(Map.of("error", "user is not allowed to delete time: " + timeId)); } -- 2.51.2