feat/25 #33

Merged
hurui200320 merged 2 commits from feat/25 into develop 2025-07-01 03:30:13 +00:00
25 changed files with 1812 additions and 1 deletions
+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<fileset-config file-format-version="1.2.0" simple-config="false" sync-formatter="false">
<local-check-config name="maven-checkstyle-plugin validate" location="file:/D:/vmware/micro-code/user-management/checkstyle.xml" type="remote" description="maven-checkstyle-plugin configuration validate">
<property name="checkstyle.header.file" value="D:\vmware\micro-code\.metadata\.plugins\org.eclipse.core.resources\.projects\user-management\com.basistech.m2e.code.quality.checkstyleConfigurator\checkstyle-header-validate.txt"/>
<property name="checkstyle.cache.file" value="${project_loc}/target/checkstyle-cachefile"/>
</local-check-config>
<fileset name="java-sources-validate" enabled="true" check-config-name="maven-checkstyle-plugin validate" local="true">
<file-match-pattern match-pattern="^src/main/java/.*\.java" include-pattern="true"/>
<file-match-pattern match-pattern="^src/main/resources/.*\.properties" include-pattern="true"/>
<file-match-pattern match-pattern="^src/main/resources/.*\.properties" include-pattern="true"/>
<file-match-pattern match-pattern="^src/test/resources.*\.properties" include-pattern="true"/>
</fileset>
</fileset-config>
@@ -0,0 +1,175 @@
package com.cleverthis.authservice.controller;
import com.cleverthis.authservice.dto.group.AddGroupMemberRequest;
import com.cleverthis.authservice.dto.group.CreateGroupRequest;
import com.cleverthis.authservice.dto.group.GroupResponse;
import com.cleverthis.authservice.dto.group.UpdateGroupRequest;
import com.cleverthis.authservice.dto.organization.OrganizationMemberResponse;
import com.cleverthis.authservice.service.IdentityManagerService;
import jakarta.validation.Valid;
import java.util.List;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Mono;
/**
* REST Controller for managing fine-grained groups (departments, teams) within
* the context of a parent organization.
*/
@RestController
@RequestMapping("/groups")
@Slf4j
public class IntraOrganizationGroupController {
private final IdentityManagerService identityManagerService;
/**
* Constructs the controller with the required service dependency.
*
* @param identityManagerService The service handling identity management logic.
*/
@Autowired
public IntraOrganizationGroupController(final IdentityManagerService identityManagerService) {
this.identityManagerService = identityManagerService;
}
/**
* Creates a new sub-group within a parent organization.
*
* @param orgId The ID of the parent organization.
* @param request The request body containing details for the new group.
* @return A Mono emitting the created group's representation.
*/
@PostMapping("/{orgId}/children")
public Mono<ResponseEntity<GroupResponse>> createSubGroup(@PathVariable final String orgId,
@Valid @RequestBody final CreateGroupRequest request) {
log.info("Received request to create sub-group with name '{}' in organization '{}'",
request.getName(), orgId);
return this.identityManagerService.createSubGroup(orgId, request)
.map(response -> ResponseEntity.status(HttpStatus.CREATED).body(response));
}
/**
* Lists all sub-groups within a parent organization.
*
* @param orgId The ID of the parent organization.
* @return A Mono emitting a list of group representations.
*/
@GetMapping("/{orgId}/children")
public Mono<ResponseEntity<List<GroupResponse>>> listSubGroups(
@PathVariable final String orgId) {
log.info("Received request to list sub-groups for organization '{}'", orgId);
return this.identityManagerService.listSubGroups(orgId).map(ResponseEntity::ok);
}
/**
* Retrieves a single sub-group by its ID.
*
* @param orgId The ID of the parent organization (for context).
* @param groupId The ID of the sub-group to retrieve.
* @return A Mono emitting the group's representation.
*/
@GetMapping("/{orgId}/children/{groupId}")
public Mono<ResponseEntity<GroupResponse>> getSubGroupById(@PathVariable final String orgId,
@PathVariable final String groupId) {
log.info("Received request to get sub-group '{}' from organization '{}'", groupId, orgId);
return this.identityManagerService.getGroupById(groupId).map(ResponseEntity::ok);
}
/**
* Updates an existing sub-group.
*
* @param orgId The ID of the parent organization (for context).
* @param groupId The ID of the sub-group to update.
* @param request The request body with fields to update.
* @return A Mono completing with a 204 No Content response.
*/
@PutMapping("/{orgId}/children/{groupId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public Mono<Void> updateSubGroup(@PathVariable final String orgId,
@PathVariable final String groupId,
@Valid @RequestBody final UpdateGroupRequest request) {
log.info("Received request to update sub-group '{}' in organization '{}'", groupId, orgId);
return this.identityManagerService.updateGroup(groupId, request);
}
/**
* Deletes a sub-group.
*
* @param orgId The ID of the parent organization (for context).
* @param groupId The ID of the sub-group to delete.
* @return A Mono completing with a 204 No Content response.
*/
@DeleteMapping("/{orgId}/children/{groupId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public Mono<Void> deleteSubGroup(@PathVariable final String orgId,
@PathVariable final String groupId) {
log.info("Received request to delete sub-group '{}' from organization '{}'",
groupId, orgId);
return this.identityManagerService.deleteGroup(groupId);
}
// --- Sub-Group Membership Management ---
/**
* Adds an existing organization member to a sub-group.
*
* @param orgId The ID of the parent organization.
* @param groupId The ID of the sub-group.
* @param request The request body containing the user's ID.
* @return A Mono completing with a 204 No Content response.
*/
@PostMapping("/{orgId}/children/{groupId}/members")
@ResponseStatus(HttpStatus.NO_CONTENT)
public Mono<Void> addMemberToSubGroup(@PathVariable final String orgId,
@PathVariable final String groupId,
@Valid @RequestBody final AddGroupMemberRequest request) {
log.info("Received request to add user '{}' to sub-group '{}' in organization '{}'",
request.getUserId(), groupId, orgId);
return this.identityManagerService.addMemberToGroup(groupId, request.getUserId());
}
/**
* Lists all members of a specific sub-group.
*
* @param orgId The ID of the parent organization.
* @param groupId The ID of the sub-group.
* @return A Mono emitting a list of organization member representations.
*/
@GetMapping("/{orgId}/children/{groupId}/members")
public Mono<ResponseEntity<List<OrganizationMemberResponse>>>
listSubGroupMembers(@PathVariable final String orgId,
@PathVariable final String groupId) {
log.info("Received request to list members of sub-group '{}' in organization '{}'",
groupId, orgId);
return this.identityManagerService.getGroupMembers(groupId).map(ResponseEntity::ok);
}
/**
* Removes a user from a sub-group.
*
* @param orgId The ID of the parent organization.
* @param groupId The ID of the sub-group.
* @param userId The ID of the user to remove.
* @return A Mono completing with a 204 No Content response.
*/
@DeleteMapping("/{orgId}/children/{groupId}/members/{userId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public Mono<Void> removeMemberFromSubGroup(@PathVariable final String orgId,
@PathVariable final String groupId, @PathVariable final String userId) {
log.info("Received request to remove user '{}' from sub-group '{}' in organization '{}'",
userId, groupId, orgId);
return this.identityManagerService.removeMemberFromGroup(groupId, userId);
}
}
@@ -0,0 +1,140 @@
package com.cleverthis.authservice.controller;
import com.cleverthis.authservice.dto.organization.AddMemberRequest;
import com.cleverthis.authservice.dto.organization.CreateOrganizationRequest;
import com.cleverthis.authservice.dto.organization.InviteUserRequest;
import com.cleverthis.authservice.dto.organization.OrganizationMemberResponse;
import com.cleverthis.authservice.dto.organization.OrganizationResponse;
import com.cleverthis.authservice.dto.organization.UpdateOrganizationRequest;
import com.cleverthis.authservice.service.IdentityManagerService;
import jakarta.validation.Valid;
import java.util.List;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Mono;
/**
* REST Controller for handling Organization related requests.
*/
@RestController
@RequestMapping("/organizations")
@Slf4j
public class OrganizationController {
private final IdentityManagerService identityManagerService;
@Autowired
public OrganizationController(IdentityManagerService identityManagerService) {
this.identityManagerService = identityManagerService;
}
/**
* Creates a new Organization. Requires administrative privileges, which should be enforced by
* the /auth endpoint.
*/
@PostMapping
public Mono<ResponseEntity<OrganizationResponse>> createOrganization(
@Valid @RequestBody CreateOrganizationRequest request) {
log.info("Received request to create organization with name: {}", request.getName());
return this.identityManagerService.createOrganization(request)
.map(response -> ResponseEntity.status(HttpStatus.CREATED).body(response));
}
/**
* Retrieves a list of all Organizations.
*/
@GetMapping
public Mono<ResponseEntity<List<OrganizationResponse>>> getOrganizations() {
log.info("Received request to list all organizations");
return this.identityManagerService.getOrganizations().map(ResponseEntity::ok);
}
/**
* Retrieves a single Organization by its ID.
*/
@GetMapping("/{orgId}")
public Mono<ResponseEntity<OrganizationResponse>> getOrganizationById(
@PathVariable String orgId) {
log.info("Received request to get organization by ID: {}", orgId);
return this.identityManagerService.getOrganizationById(orgId).map(ResponseEntity::ok);
}
/**
* Updates an existing Organization.
*/
@PutMapping("/{orgId}")
public Mono<ResponseEntity<Void>> updateOrganization(@PathVariable String orgId,
@Valid @RequestBody UpdateOrganizationRequest request) {
log.info("Received request to update organization ID: {}", orgId);
return this.identityManagerService.updateOrganization(orgId, request)
.thenReturn(ResponseEntity.noContent().<Void>build());
}
/**
* Deletes an Organization.
*/
@DeleteMapping("/{orgId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public Mono<Void> deleteOrganization(@PathVariable String orgId) {
log.info("Received request to delete organization ID: {}", orgId);
return this.identityManagerService.deleteOrganization(orgId);
}
// --- Membership Management ---
/**
* Invites a user to join an Organization via email.
*/
@PostMapping("/{orgId}/invitations")
public Mono<ResponseEntity<Void>> inviteUserToOrganization(@PathVariable String orgId,
@Valid @RequestBody InviteUserRequest request) {
log.info("Received request to invite user with email {} to organization ID: {}",
request.getEmail(), orgId);
return this.identityManagerService.inviteUserToOrganization(orgId, request)
.thenReturn(ResponseEntity.ok().<Void>build());
}
/**
* Retrieves all members of an Organization.
*/
@GetMapping("/{orgId}/members")
public Mono<ResponseEntity<List<OrganizationMemberResponse>>> getOrganizationMembers(
@PathVariable String orgId) {
log.info("Received request to get members of organization ID: {}", orgId);
return this.identityManagerService.getOrganizationMembers(orgId).map(ResponseEntity::ok);
}
/**
* Adds an existing user to an Organization.
*/
@PostMapping("/{orgId}/members")
@ResponseStatus(HttpStatus.NO_CONTENT)
public Mono<Void> addMemberToOrganization(@PathVariable String orgId,
@Valid @RequestBody AddMemberRequest request) {
log.info("Received request to add user ID {} to organization ID: {}", request.getUserId(),
orgId);
return this.identityManagerService.addMemberToOrganization(orgId, request.getUserId());
}
/**
* Removes a user from an Organization.
*/
@DeleteMapping("/{orgId}/members/{userId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public Mono<Void> removeMemberFromOrganization(@PathVariable String orgId,
@PathVariable String userId) {
log.info("Received request to remove user ID {} from organization ID: {}", userId, orgId);
return this.identityManagerService.removeMemberFromOrganization(orgId, userId);
}
}
@@ -0,0 +1,18 @@
package com.cleverthis.authservice.dto.group;
import jakarta.validation.constraints.NotBlank;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* DTO for adding an existing user to a group.
* This can be reused from the organization membership logic.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class AddGroupMemberRequest {
@NotBlank(message = "User ID cannot be blank")
private String userId; // The Keycloak user ID (sub)
}
@@ -0,0 +1,25 @@
package com.cleverthis.authservice.dto.group; // New package for group-related DTOs
import jakarta.validation.constraints.NotBlank;
import java.util.List;
import java.util.Map;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* DTO for the request body when creating a new sub-group (e.g., department, team)
* within an organization.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class CreateGroupRequest {
@NotBlank(message = "Group name cannot be blank")
private String name;
private String description;
private Map<String, List<String>> attributes;
}
@@ -0,0 +1,21 @@
package com.cleverthis.authservice.dto.group;
import java.util.List;
import java.util.Map;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* DTO for representing a group in API responses.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class GroupResponse {
private String id;
private String name;
private String path; // The full path of the group (e.g., /tenant-org/departments/engineering)
private String description;
private Map<String, List<String>> attributes;
}
@@ -0,0 +1,20 @@
package com.cleverthis.authservice.dto.group;
import java.util.List;
import java.util.Map;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* DTO for the request body when updating a sub-group.
* The name of a group is typically not updatable via this DTO; it's part of the path.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class UpdateGroupRequest {
private String description;
private Map<String, List<String>> attributes;
}
@@ -0,0 +1,28 @@
package com.cleverthis.authservice.dto.keycloakadmin;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.util.List;
import java.util.Map;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* Data Transfer Object for representing a Keycloak Group when interacting
* with the Keycloak Admin API.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonInclude(JsonInclude.Include.NON_NULL)
public class KeycloakGroupRepresentation {
private String id;
private String name;
private String path;
private String description; // Note: Description is often stored in attributes
private Map<String, List<String>> attributes;
private List<KeycloakGroupRepresentation> subGroups;
}
@@ -0,0 +1,22 @@
package com.cleverthis.authservice.dto.keycloakadmin;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* Data Transfer Object for creating an invitation request to join an organization.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class KeycloakInvitationRequest {
private String email;
private String firstName;
private String lastName;
// You can also specify roles to be redirected to after accepting
// private List<String> redirectRoles;
}
@@ -0,0 +1,30 @@
package com.cleverthis.authservice.dto.keycloakadmin;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.util.List;
import java.util.Map;
import java.util.Set;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* Data Transfer Object for representing a Keycloak Organization when interacting with the Keycloak
* Admin API.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
// Include only non-null fields during serialization, useful for update operations
@JsonInclude(JsonInclude.Include.NON_NULL)
public class KeycloakOrganizationRepresentation {
private String id;
private String name;
private Set<OrganizationDomainRepresentation> domains;
private String description;
private Boolean enabled;
private Map<String, List<String>> attributes;
}
@@ -0,0 +1,25 @@
package com.cleverthis.authservice.dto.keycloakadmin;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* Data Transfer Object for representing a Keycloak User, typically when listing members of an
* organization. It includes only the most relevant fields.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class KeycloakUserRepresentation {
private String id;
private String username;
private String firstName;
private String lastName;
private String email;
private boolean enabled;
private Long createdTimestamp;
}
@@ -0,0 +1,18 @@
package com.cleverthis.authservice.dto.keycloakadmin;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* Representation of an organization's internet domain within Keycloak.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class OrganizationDomainRepresentation {
private String name;
private Boolean verified;
}
@@ -0,0 +1,17 @@
package com.cleverthis.authservice.dto.organization;
import jakarta.validation.constraints.NotBlank;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* DTO for adding an existing user to an Organization.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor public class AddMemberRequest {
@NotBlank(message = "User ID cannot be blank")
private String userId; // The Keycloak user ID (sub)
}
@@ -0,0 +1,28 @@
package com.cleverthis.authservice.dto.organization;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import java.util.List;
import java.util.Map;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* DTO for the request body when creating a new Organization.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class CreateOrganizationRequest {
@NotBlank(message = "Organization name cannot be blank")
private String name; // This will become the internal name in Keycloak
@NotEmpty(message = "At least one organization domain must be provided")
private List<String> domains;
private String description;
private Map<String, List<String>> attributes; // For custom metadata like displayName
}
@@ -0,0 +1,26 @@
package com.cleverthis.authservice.dto.organization;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* DTO for inviting a new or existing user to an Organization via email.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor public class InviteUserRequest {
@NotBlank(message = "Email cannot be blank")
@Email(message = "A valid email address is required")
private String email;
// Optional fields if inviting a brand new user
private String firstName;
private String lastName;
// You could also add a list of roles/groups to assign upon joining
// private List<String> rolesToAssign;
}
@@ -0,0 +1,21 @@
package com.cleverthis.authservice.dto.organization;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* DTO for representing a member of an Organization. This mirrors Keycloak's UserRepresentation for
* relevant fields.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor public class OrganizationMemberResponse {
private String id;
private String username;
private String firstName;
private String lastName;
private String email;
private boolean enabled;
}
@@ -0,0 +1,25 @@
package com.cleverthis.authservice.dto.organization;
import com.cleverthis.authservice.dto.keycloakadmin.OrganizationDomainRepresentation;
import java.util.List;
import java.util.Map;
import java.util.Set;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* DTO for representing an Organization in API responses.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class OrganizationResponse {
private String id;
private String name;
private Set<OrganizationDomainRepresentation> domains;
private String description;
private Boolean enabled;
private Map<String, List<String>> attributes;
}
@@ -0,0 +1,20 @@
package com.cleverthis.authservice.dto.organization;
import java.util.List;
import java.util.Map;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* DTO for the request body when updating an Organization.
* Name is typically not updatable, but description and attributes are.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class UpdateOrganizationRequest {
private List<String> domains;
private String description;
private Map<String, List<String>> attributes;
}
@@ -99,6 +99,20 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
return problemDetail;
}
/**
* Handles ResourceNotFoundException and translates it to a 404 Not Found response.
*
* @param ex The ResourceNotFoundException that was thrown.
* @return A ProblemDetail object for a 404 response.
*/
@ExceptionHandler(ResourceNotFoundException.class)
public ProblemDetail handleResourceNotFoundException(ResourceNotFoundException ex) {
ProblemDetail problemDetail =
ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
problemDetail.setTitle("Resource Not Found");
return problemDetail;
}
/**
* Map {@link Exception} to HTTP 500, Catch-all for other Generic exceptions.
*/
@@ -0,0 +1,24 @@
package com.cleverthis.authservice.exception;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
/**
* Custom exception to be thrown when a requested resource is not found.
* The @ResponseStatus annotation tells Spring to automatically return a 404 Not Found
* status when this exception is thrown from a controller and not handled by an
* exception handler. This can simplify the GlobalExceptionHandler.
*/
@ResponseStatus(value = HttpStatus.NOT_FOUND)
public class ResourceNotFoundException extends RuntimeException {
/**
* Constructs a new ResourceNotFoundException with the specified detail message.
*
* @param message the detail message.
*/
public ResourceNotFoundException(String message) {
super(message);
}
}
@@ -1,9 +1,17 @@
package com.cleverthis.authservice.service;
import com.cleverthis.authservice.dto.RegistrationRequest;
import com.cleverthis.authservice.dto.TokenResponse;
import com.cleverthis.authservice.dto.VerificationResponse;
import com.cleverthis.authservice.dto.group.CreateGroupRequest;
import com.cleverthis.authservice.dto.group.GroupResponse;
import com.cleverthis.authservice.dto.group.UpdateGroupRequest;
import com.cleverthis.authservice.dto.organization.CreateOrganizationRequest;
import com.cleverthis.authservice.dto.organization.InviteUserRequest;
import com.cleverthis.authservice.dto.organization.OrganizationMemberResponse;
import com.cleverthis.authservice.dto.organization.OrganizationResponse;
import com.cleverthis.authservice.dto.organization.UpdateOrganizationRequest;
import java.util.List;
import reactor.core.publisher.Mono;
/**
@@ -56,4 +64,148 @@ public interface IdentityManagerService {
Mono<Boolean> checkResourcePermission(
String reqAccessToken, String clientId, String resourceUri, String scope
);
/**
* Creates a new Organization in Keycloak.
*
* @param request DTO with details for the new organization.
* @return A Mono emitting the newly created Organization's representation.
*/
Mono<OrganizationResponse> createOrganization(CreateOrganizationRequest request);
/**
* Retrieves all Organizations from Keycloak.
*
* @return A Mono emitting a list of Organization representations.
*/
Mono<List<OrganizationResponse>> getOrganizations();
/**
* Retrieves a single Organization by its ID from Keycloak.
*
* @param orgId The ID of the organization to retrieve.
* @return A Mono emitting the Organization's representation.
*/
Mono<OrganizationResponse> getOrganizationById(String orgId);
/**
* Updates an existing Organization in Keycloak.
*
* @param orgId The ID of the organization to update.
* @param request DTO with the fields to update.
* @return A Mono completing when the update is successful.
*/
Mono<Void> updateOrganization(String orgId, UpdateOrganizationRequest request);
/**
* Deletes an Organization from Keycloak.
*
* @param orgId The ID of the organization to delete.
* @return A Mono completing when the deletion is successful.
*/
Mono<Void> deleteOrganization(String orgId);
/**
* Invites a user to join an Organization. Keycloak handles the email sending.
*
* @param orgId The ID of the organization to invite the user to.
* @param request DTO containing the user's email and other details.
* @return A Mono completing when the invitation is successfully sent.
*/
Mono<Void> inviteUserToOrganization(String orgId, InviteUserRequest request);
/**
* Retrieves all members of an Organization.
*
* @param orgId The ID of the organization.
* @return A Mono emitting a list of members.
*/
Mono<List<OrganizationMemberResponse>> getOrganizationMembers(String orgId);
/**
* Adds an existing user to an Organization.
*
* @param orgId The ID of the organization.
* @param userId The ID of the user to add.
* @return A Mono completing when the user is successfully added.
*/
Mono<Void> addMemberToOrganization(String orgId, String userId);
/**
* Removes a user from an Organization.
*
* @param orgId The ID of the organization.
* @param userId The ID of the user to remove.
* @return A Mono completing when the user is successfully removed.
*/
Mono<Void> removeMemberFromOrganization(String orgId, String userId);
/**
* Creates a new sub-group as a child of a specified parent group.
*
* @param parentGroupId The ID of the parent group.
* @param request DTO containing the details for the new sub-group.
* @return A Mono emitting the representation of the created group.
*/
Mono<GroupResponse> createSubGroup(String parentGroupId, CreateGroupRequest request);
/**
* Retrieves a single group by its unique ID.
*
* @param groupId The ID of the group to retrieve.
* @return A Mono emitting the group's representation.
*/
Mono<GroupResponse> getGroupById(String groupId);
/**
* Lists all direct sub-groups (children) of a given parent group.
*
* @param parentGroupId The ID of the parent group.
* @return A Mono emitting a list of group representations.
*/
Mono<List<GroupResponse>> listSubGroups(String parentGroupId);
/**
* Updates an existing group's details.
*
* @param groupId The ID of the group to update.
* @param request DTO containing the fields to update.
* @return A Mono that completes when the update is successful.
*/
Mono<Void> updateGroup(String groupId, UpdateGroupRequest request);
/**
* Deletes a group by its ID.
*
* @param groupId The ID of the group to delete.
* @return A Mono that completes when the deletion is successful.
*/
Mono<Void> deleteGroup(String groupId);
/**
* Adds an existing user to a group.
*
* @param groupId The ID of the group.
* @param userId The ID of the user to add.
* @return A Mono that completes when the user is successfully added.
*/
Mono<Void> addMemberToGroup(String groupId, String userId);
/**
* Retrieves all members of a specific group.
*
* @param groupId The ID of the group.
* @return A Mono emitting a list of member representations.
*/
Mono<List<OrganizationMemberResponse>> getGroupMembers(String groupId);
/**
* Removes a user from a specific group.
*
* @param groupId The ID of the group.
* @param userId The ID of the user to remove.
* @return A Mono that completes when the user is successfully removed.
*/
Mono<Void> removeMemberFromGroup(String groupId, String userId);
}
@@ -4,6 +4,10 @@ import com.cleverthis.authservice.config.KeycloakClientConfigProperties;
import com.cleverthis.authservice.dto.KeycloakAdminTokenResponse;
import com.cleverthis.authservice.dto.KeycloakClientRepresentation;
import com.cleverthis.authservice.dto.KeycloakRoleRepresentation;
import com.cleverthis.authservice.dto.keycloakadmin.KeycloakGroupRepresentation;
import com.cleverthis.authservice.dto.keycloakadmin.KeycloakInvitationRequest;
import com.cleverthis.authservice.dto.keycloakadmin.KeycloakOrganizationRepresentation;
import com.cleverthis.authservice.dto.keycloakadmin.KeycloakUserRepresentation;
import com.cleverthis.authservice.exception.ServiceUnavailableException;
import java.net.URI;
import java.time.Duration;
@@ -75,6 +79,73 @@ public class KeycloakAdminClient {
.encode().toUriString();
}
private String getOrganizationByIdEndpoint(String orgId) {
return UriComponentsBuilder.fromUriString(this.keycloakAdminHost)
.path("/admin/realms/{realm}/organizations/{orgId}")
.buildAndExpand(this.realm, orgId)
.encode().toUriString();
}
private String getOrganizationsEndpoint() {
return UriComponentsBuilder.fromUriString(this.keycloakAdminHost)
.path("/admin/realms/{realm}/organizations")
.buildAndExpand(this.realm)
.encode().toUriString();
}
private String getOrganizationMembersEndpoint(String orgId) {
return UriComponentsBuilder.fromUriString(this.keycloakAdminHost)
.path("/admin/realms/{realm}/organizations/{orgId}/members")
.buildAndExpand(this.realm, orgId)
.encode().toUriString();
}
private String getOrganizationMemberByIdEndpoint(String orgId, String userId) {
return UriComponentsBuilder.fromUriString(this.keycloakAdminHost)
.path("/admin/realms/{realm}/organizations/{orgId}/members/{userId}")
.buildAndExpand(this.realm, orgId, userId)
.encode().toUriString();
}
private String getOrganizationInvitationEndpoint(String orgId) {
return UriComponentsBuilder.fromUriString(this.keycloakAdminHost)
.path("/admin/realms/{realm}/organizations/{orgId}/members/invite-user")
.buildAndExpand(this.realm, orgId)
.encode().toUriString();
}
private String getGroupsEndpoint() {
return UriComponentsBuilder.fromUriString(this.keycloakAdminHost)
.path("/admin/realms/{realm}/groups")
.buildAndExpand(this.realm).toUriString();
}
private String getGroupByIdEndpoint(String groupId) {
return UriComponentsBuilder.fromUriString(this.keycloakAdminHost)
.path("/admin/realms/{realm}/groups/{groupId}")
.buildAndExpand(this.realm, groupId).encode().toUriString();
}
private String getGroupChildrenEndpoint(String parentGroupId) {
return UriComponentsBuilder.fromUriString(this.keycloakAdminHost)
.path("/admin/realms/{realm}/groups/{parentGroupId}/children")
.buildAndExpand(this.realm, parentGroupId)
.encode().toUriString();
}
private String getGroupMembersEndpoint(String groupId) {
return UriComponentsBuilder.fromUriString(this.keycloakAdminHost)
.path("/admin/realms/{realm}/groups/{groupId}/members")
.buildAndExpand(this.realm, groupId).encode()
.toUriString();
}
private String getGroupMemberByIdEndpoint(String groupId, String userId) {
return UriComponentsBuilder.fromUriString(this.keycloakAdminHost)
.path("/admin/realms/{realm}/users/{userId}/groups/{groupId}")
.buildAndExpand(this.realm, userId, groupId).encode().toUriString();
}
/**
* Retrieves a valid admin access token for this service, refreshing if necessary. Uses client
* credentials grant.
@@ -187,4 +258,274 @@ public class KeycloakAdminClient {
"Found {} effective client roles for user '{}' on client internal ID '{}'",
roles.size(), userId, clientInternalId)));
}
/**
* Creates a new organization in Keycloak.
*
* @param orgToCreate A representation of the organization to be created.
* @return A Mono emitting the representation of the newly created organization, including its
* ID.
*/
public Mono<KeycloakOrganizationRepresentation> createOrganization(
KeycloakOrganizationRepresentation orgToCreate) {
return getAdminAccessToken()
.flatMap(token -> this.webClient.post().uri(getOrganizationsEndpoint())
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
.contentType(MediaType.APPLICATION_JSON).bodyValue(orgToCreate).retrieve()
// Add error handling here for 409 Conflict, etc.
.bodyToMono(KeycloakOrganizationRepresentation.class));
}
/**
* Retrieves all organizations within the realm.
*
* @return A Mono emitting a List of all organization representations.
*/
public Mono<List<KeycloakOrganizationRepresentation>> getOrganizations() {
return getAdminAccessToken()
.flatMap(token -> this.webClient.get().uri(getOrganizationsEndpoint())
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token).retrieve()
.bodyToFlux(KeycloakOrganizationRepresentation.class).collectList());
}
/**
* Retrieves a single organization by its unique ID.
*
* @param orgId The ID of the organization to retrieve.
* @return A Mono emitting the representation of the found organization.
*/
public Mono<KeycloakOrganizationRepresentation> getOrganizationById(String orgId) {
return getAdminAccessToken()
.flatMap(token -> this.webClient.get().uri(getOrganizationByIdEndpoint(orgId))
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token).retrieve()
.bodyToMono(KeycloakOrganizationRepresentation.class));
}
/**
* Updates an existing organization's details in Keycloak.
*
* @param orgId The ID of the organization to update.
* @param orgToUpdate A representation containing the updated fields for the organization.
* @return A Mono that completes when the update is successful.
*/
public Mono<Void> updateOrganization(String orgId,
KeycloakOrganizationRepresentation orgToUpdate) {
return getAdminAccessToken()
.flatMap(token -> this.webClient.put().uri(getOrganizationByIdEndpoint(orgId))
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
.contentType(MediaType.APPLICATION_JSON).bodyValue(orgToUpdate).retrieve()
.bodyToMono(Void.class));
}
/**
* Deletes an organization from Keycloak.
*
* @param orgId The ID of the organization to delete.
* @return A Mono that completes when the deletion is successful.
*/
public Mono<Void> deleteOrganization(String orgId) {
return getAdminAccessToken()
.flatMap(token -> this.webClient.delete().uri(getOrganizationByIdEndpoint(orgId))
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token).retrieve()
.bodyToMono(Void.class));
}
/**
* Sends an invitation for a user to join an organization. Keycloak handles the email delivery.
*
* @param orgId The ID of the organization to invite the user to.
* @param invitation An object containing the invitee's details (email, name).
* @return A Mono that completes when the invitation has been successfully sent.
*/
public Mono<Void> inviteUserToOrganization(String orgId, KeycloakInvitationRequest invitation) {
// Create form data from the invitation DTO
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
formData.add("email", invitation.getEmail());
if (invitation.getFirstName() != null) {
formData.add("firstName", invitation.getFirstName());
}
if (invitation.getLastName() != null) {
formData.add("lastName", invitation.getLastName());
}
return getAdminAccessToken().flatMap(token -> this.webClient.post()
.uri(getOrganizationInvitationEndpoint(orgId)) // Points to /invitations
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
// UPDATED: Use FORM_URLENCODED content type
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
// UPDATED: Use fromFormData to send the body
.body(BodyInserters.fromFormData(formData)).retrieve().bodyToMono(Void.class));
}
/**
* Retrieves a list of all members of a specific organization.
*
* @param orgId The ID of the organization.
* @return A Mono emitting a List of user representations for the members.
*/
public Mono<List<KeycloakUserRepresentation>> getOrganizationMembers(String orgId) {
return getAdminAccessToken()
.flatMap(token -> this.webClient.get().uri(getOrganizationMembersEndpoint(orgId))
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token).retrieve()
.bodyToFlux(KeycloakUserRepresentation.class).collectList());
}
/**
* Adds an existing Keycloak user to an organization.
*
* @param orgId The ID of the organization.
* @param userId The ID of the user to add as a member.
* @return A Mono that completes when the user is successfully added.
*/
public Mono<Void> addMemberToOrganization(String orgId, String userId) {
// Keycloak's API to add an existing user to an Organization is a POST
// to the /members collection endpoint, with the user's ID in the body.
return getAdminAccessToken().flatMap(token -> this.webClient.post()
// Use the endpoint for the members collection, NOT the specific member
.uri(getOrganizationMembersEndpoint(orgId))
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
.contentType(MediaType.APPLICATION_JSON)
// The body should be a representation containing the user's ID
.bodyValue(userId).retrieve().bodyToMono(Void.class));
}
/**
* Removes a member from an organization.
*
* @param orgId The ID of the organization.
* @param userId The ID of the user to remove.
* @return A Mono that completes when the user is successfully removed.
*/
public Mono<Void> removeMemberFromOrganization(String orgId, String userId) {
return getAdminAccessToken().flatMap(token -> this.webClient.delete()
.uri(getOrganizationMemberByIdEndpoint(orgId, userId))
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token).retrieve()
.bodyToMono(Void.class));
}
/**
* Creates a new sub-group as a child of a parent group.
*
* @param parentGroupId The ID of the parent group.
* @param group A representation of the sub-group to create.
* @return A Mono that completes when the group is created.
* Keycloak returns location header, not body.
*/
public Mono<Void> createSubGroup(String parentGroupId, KeycloakGroupRepresentation group) {
return getAdminAccessToken().flatMap(token -> this.webClient.post()
.uri(getGroupChildrenEndpoint(parentGroupId))
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(group)
.retrieve()
.bodyToMono(Void.class));
}
/**
* Retrieves a group by its unique ID.
*
* @param groupId The ID of the group to retrieve.
* @return A Mono emitting the group representation.
*/
public Mono<KeycloakGroupRepresentation> getGroupById(String groupId) {
return getAdminAccessToken().flatMap(token -> this.webClient.get()
.uri(getGroupByIdEndpoint(groupId))
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
.retrieve()
.bodyToMono(KeycloakGroupRepresentation.class));
}
/**
* Lists the direct children (sub-groups) of a parent group.
*
* @param parentGroupId The ID of the parent group.
* @return A Mono emitting a list of sub-group representations.
*/
public Mono<List<KeycloakGroupRepresentation>> listSubGroups(String parentGroupId) {
return getAdminAccessToken().flatMap(token -> this.webClient.get()
.uri(getGroupChildrenEndpoint(parentGroupId))
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
.retrieve()
.bodyToFlux(KeycloakGroupRepresentation.class)
.collectList());
}
/**
* Updates an existing group.
*
* @param groupId The ID of the group to update.
* @param group A representation containing the updated fields.
* @return A Mono that completes on successful update.
*/
public Mono<Void> updateGroup(String groupId, KeycloakGroupRepresentation group) {
return getAdminAccessToken().flatMap(token -> this.webClient.put()
.uri(getGroupByIdEndpoint(groupId))
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(group)
.retrieve()
.bodyToMono(Void.class));
}
/**
* Deletes a group by its ID.
*
* @param groupId The ID of the group to delete.
* @return A Mono that completes on successful deletion.
*/
public Mono<Void> deleteGroup(String groupId) {
return getAdminAccessToken().flatMap(token -> this.webClient.delete()
.uri(getGroupByIdEndpoint(groupId))
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
.retrieve()
.bodyToMono(Void.class));
}
/**
* Adds a user to a group.
*
* @param groupId The ID of the group.
* @param userId The ID of the user.
* @return A Mono that completes when the user is added to the group.
*/
public Mono<Void> addMemberToGroup(String groupId, String userId) {
// Keycloak API for adding a user to a group is a PUT on the user's groups link
return getAdminAccessToken().flatMap(token -> this.webClient.put()
.uri(getGroupMemberByIdEndpoint(groupId, userId))
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
.retrieve()
.bodyToMono(Void.class));
}
/**
* Retrieves all members of a specific group.
*
* @param groupId The ID of the group.
* @return A Mono emitting a list of user representations.
*/
public Mono<List<KeycloakUserRepresentation>> getGroupMembers(String groupId) {
return getAdminAccessToken().flatMap(token -> this.webClient.get()
.uri(getGroupMembersEndpoint(groupId))
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
.retrieve()
.bodyToFlux(KeycloakUserRepresentation.class)
.collectList());
}
/**
* Removes a user from a group.
*
* @param groupId The ID of the group.
* @param userId The ID of the user.
* @return A Mono that completes when the user is removed from the group.
*/
public Mono<Void> removeMemberFromGroup(String groupId, String userId) {
return getAdminAccessToken().flatMap(token -> this.webClient.delete()
.uri(getGroupMemberByIdEndpoint(groupId, userId))
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
.retrieve()
.bodyToMono(Void.class));
}
}
@@ -4,6 +4,19 @@ import com.cleverthis.authservice.config.KeycloakClientConfigProperties;
import com.cleverthis.authservice.dto.RegistrationRequest;
import com.cleverthis.authservice.dto.TokenResponse;
import com.cleverthis.authservice.dto.VerificationResponse;
import com.cleverthis.authservice.dto.group.CreateGroupRequest;
import com.cleverthis.authservice.dto.group.GroupResponse;
import com.cleverthis.authservice.dto.group.UpdateGroupRequest;
import com.cleverthis.authservice.dto.keycloakadmin.KeycloakGroupRepresentation;
import com.cleverthis.authservice.dto.keycloakadmin.KeycloakInvitationRequest;
import com.cleverthis.authservice.dto.keycloakadmin.KeycloakOrganizationRepresentation;
import com.cleverthis.authservice.dto.keycloakadmin.KeycloakUserRepresentation;
import com.cleverthis.authservice.dto.keycloakadmin.OrganizationDomainRepresentation;
import com.cleverthis.authservice.dto.organization.CreateOrganizationRequest;
import com.cleverthis.authservice.dto.organization.InviteUserRequest;
import com.cleverthis.authservice.dto.organization.OrganizationMemberResponse;
import com.cleverthis.authservice.dto.organization.OrganizationResponse;
import com.cleverthis.authservice.dto.organization.UpdateOrganizationRequest;
import com.cleverthis.authservice.exception.AuthenticationException;
import com.cleverthis.authservice.exception.LogoutException;
import com.cleverthis.authservice.exception.PermissionCheckException;
@@ -14,7 +27,10 @@ import com.cleverthis.authservice.exception.UserAlreadyExistsException;
import com.cleverthis.authservice.service.IdentityManagerService;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpHeaders;
@@ -311,4 +327,178 @@ public class KeycloakIdentityManagerService implements IdentityManagerService {
error.getMessage()))
.onErrorResume(PermissionCheckException.class, e -> Mono.just(false));
}
@Override
public Mono<OrganizationResponse> createOrganization(CreateOrganizationRequest request) {
KeycloakOrganizationRepresentation newOrg = new KeycloakOrganizationRepresentation();
newOrg.setName(request.getName());
newOrg.setDescription(request.getDescription());
newOrg.setAttributes(request.getAttributes());
newOrg.setEnabled(true); // Default to enabled
if (request.getDomains() != null) {
Set<OrganizationDomainRepresentation> domainRepresentations = request.getDomains()
.stream()
.map(domainName -> new OrganizationDomainRepresentation(domainName, false))
.collect(Collectors.toSet());
newOrg.setDomains(domainRepresentations);
}
return this.keycloakAdminClient.createOrganization(newOrg)
.map(this::toOrganizationResponse);
}
@Override
public Mono<List<OrganizationResponse>> getOrganizations() {
return this.keycloakAdminClient.getOrganizations().map(orgList -> orgList.stream()
.map(this::toOrganizationResponse).collect(Collectors.toList()));
}
@Override
public Mono<OrganizationResponse> getOrganizationById(String orgId) {
return this.keycloakAdminClient.getOrganizationById(orgId)
.map(this::toOrganizationResponse);
}
@Override
public Mono<Void> updateOrganization(String orgId, UpdateOrganizationRequest request) {
return this.keycloakAdminClient.getOrganizationById(orgId).flatMap(existingOrg -> {
// Update fields from the request
existingOrg.setDescription(request.getDescription());
existingOrg.setAttributes(request.getAttributes());
// Update domains based on the new list of names
if (request.getDomains() != null) {
Set<OrganizationDomainRepresentation> domainRepresentations = request.getDomains()
.stream()
.map(domainName -> new OrganizationDomainRepresentation(domainName, false))
.collect(Collectors.toSet());
existingOrg.setDomains(domainRepresentations);
}
return this.keycloakAdminClient.updateOrganization(orgId, existingOrg);
});
}
@Override
public Mono<Void> deleteOrganization(String orgId) {
return this.keycloakAdminClient.deleteOrganization(orgId);
}
@Override
public Mono<Void> inviteUserToOrganization(String orgId, InviteUserRequest request) {
KeycloakInvitationRequest invitation = new KeycloakInvitationRequest();
invitation.setEmail(request.getEmail());
invitation.setFirstName(request.getFirstName());
invitation.setLastName(request.getLastName());
// Keycloak handles creating the user if they don't exist.
return this.keycloakAdminClient.inviteUserToOrganization(orgId, invitation);
}
@Override
public Mono<List<OrganizationMemberResponse>> getOrganizationMembers(String orgId) {
return this.keycloakAdminClient.getOrganizationMembers(orgId).map(userList -> userList
.stream().map(this::toOrganizationMemberResponse).collect(Collectors.toList()));
}
@Override
public Mono<Void> addMemberToOrganization(String orgId, String userId) {
return this.keycloakAdminClient.addMemberToOrganization(orgId, userId);
}
@Override
public Mono<Void> removeMemberFromOrganization(String orgId, String userId) {
return this.keycloakAdminClient.removeMemberFromOrganization(orgId, userId);
}
@Override
public Mono<GroupResponse> createSubGroup(final String parentGroupId,
final CreateGroupRequest request) {
KeycloakGroupRepresentation newGroup = new KeycloakGroupRepresentation();
newGroup.setName(request.getName());
newGroup.setAttributes(request.getAttributes());
return this.keycloakAdminClient.createSubGroup(parentGroupId, newGroup)
.thenReturn(new GroupResponse(null, request.getName(),
null, null, request.getAttributes()));
}
@Override
public Mono<GroupResponse> getGroupById(final String groupId) {
return this.keycloakAdminClient.getGroupById(groupId).map(this::toGroupResponse);
}
@Override
public Mono<List<GroupResponse>> listSubGroups(final String parentGroupId) {
return this.keycloakAdminClient.listSubGroups(parentGroupId)
.map(groupList -> groupList.stream()
.map(this::toGroupResponse)
.collect(Collectors.toList()));
}
@Override
public Mono<Void> updateGroup(final String groupId, final UpdateGroupRequest request) {
KeycloakGroupRepresentation groupUpdate = new KeycloakGroupRepresentation();
groupUpdate.setAttributes(request.getAttributes());
return this.keycloakAdminClient.updateGroup(groupId, groupUpdate);
}
@Override
public Mono<Void> deleteGroup(final String groupId) {
return this.keycloakAdminClient.deleteGroup(groupId);
}
@Override
public Mono<Void> addMemberToGroup(final String groupId, final String userId) {
return this.keycloakAdminClient.addMemberToGroup(groupId, userId);
}
@Override
public Mono<List<OrganizationMemberResponse>> getGroupMembers(final String groupId) {
return this.keycloakAdminClient.getGroupMembers(groupId)
.map(userList -> userList.stream()
.map(this::toOrganizationMemberResponse)
.collect(Collectors.toList()));
}
@Override
public Mono<Void> removeMemberFromGroup(final String groupId, final String userId) {
return this.keycloakAdminClient.removeMemberFromGroup(groupId, userId);
}
// --- Add DTO Mapper for Group ---
private GroupResponse toGroupResponse(final KeycloakGroupRepresentation keycloakGroup) {
if (keycloakGroup == null) {
return null;
}
return new GroupResponse(
keycloakGroup.getId(),
keycloakGroup.getName(),
keycloakGroup.getPath(),
keycloakGroup.getDescription(),
keycloakGroup.getAttributes()
);
}
// --- DTO Mapper Helper Methods ---
private OrganizationResponse toOrganizationResponse(
KeycloakOrganizationRepresentation keycloakOrg) {
if (keycloakOrg == null) {
return null;
}
return new OrganizationResponse(keycloakOrg.getId(), keycloakOrg.getName(),
keycloakOrg.getDomains(), keycloakOrg.getDescription(), keycloakOrg.getEnabled(),
keycloakOrg.getAttributes());
}
private OrganizationMemberResponse toOrganizationMemberResponse(
KeycloakUserRepresentation keycloakUser) {
if (keycloakUser == null) {
return null;
}
return new OrganizationMemberResponse(keycloakUser.getId(), keycloakUser.getUsername(),
keycloakUser.getFirstName(), keycloakUser.getLastName(), keycloakUser.getEmail(),
keycloakUser.isEnabled());
}
}
@@ -0,0 +1,209 @@
package com.cleverthis.authservice;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
import com.cleverthis.authservice.controller.IntraOrganizationGroupController;
import com.cleverthis.authservice.dto.group.AddGroupMemberRequest;
import com.cleverthis.authservice.dto.group.CreateGroupRequest;
import com.cleverthis.authservice.dto.group.GroupResponse;
import com.cleverthis.authservice.dto.group.UpdateGroupRequest;
import com.cleverthis.authservice.dto.organization.OrganizationMemberResponse;
import com.cleverthis.authservice.exception.GlobalExceptionHandler;
import com.cleverthis.authservice.exception.ResourceNotFoundException;
import com.cleverthis.authservice.service.IdentityManagerService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.reactive.server.WebTestClient;
import reactor.core.publisher.Mono;
import java.util.Collections;
import java.util.List;
import java.util.Map;
/**
* Unit tests for the IntraOrganizationGroupController. This class tests the API
* layer for sub-group management, mocking the service layer.
*/
@WebFluxTest(IntraOrganizationGroupController.class)
@Import(GlobalExceptionHandler.class)
public class IntraOrganizationGroupControllerTest {
@Autowired
private WebTestClient webTestClient;
@MockitoBean
private IdentityManagerService identityManagerService;
private GroupResponse mockGroupResponse;
/**
* Sets up mock data before each test.
*/
@BeforeEach
void setUp() {
mockGroupResponse = new GroupResponse("group-id-eng", "engineering",
"/tenant-id-123/engineering",
"Engineering Department", Map.of("cost-center", List.of("ENG-101")));
}
/**
* Tests successful creation of a sub-group.
*/
@Test
void createSubGroup_Success_ReturnsCreated() {
String parentGroupId = "org-id-123";
CreateGroupRequest request = new CreateGroupRequest("engineering",
"Engineering Department", null);
when(identityManagerService.createSubGroup(eq(parentGroupId), any(CreateGroupRequest.class)))
.thenReturn(Mono.just(mockGroupResponse));
webTestClient.post().uri("/groups/{parentGroupId}/children", parentGroupId)
.contentType(MediaType.APPLICATION_JSON).bodyValue(request)
.exchange().expectStatus().isCreated()
.expectBody(GroupResponse.class).isEqualTo(mockGroupResponse);
}
/**
* Tests creation of a sub-group with invalid input.
*/
@Test
void createSubGroup_InvalidInput_ReturnsBadRequest() {
String parentGroupId = "org-id-123";
// Blank name is invalid
CreateGroupRequest request = new CreateGroupRequest("", null, null);
webTestClient.post().uri("/groups/{parentGroupId}/children", parentGroupId)
.contentType(MediaType.APPLICATION_JSON).bodyValue(request).exchange()
.expectStatus().isBadRequest();
}
/**
* Tests successful listing of sub-groups.
*/
@Test
void listSubGroups_Success_ReturnsListOfGroups() {
String parentGroupId = "org-id-123";
when(identityManagerService.listSubGroups(parentGroupId))
.thenReturn(Mono.just(Collections.singletonList(mockGroupResponse)));
webTestClient.get().uri("/groups/{parentGroupId}/children", parentGroupId).exchange()
.expectStatus().isOk()
.expectBodyList(GroupResponse.class).hasSize(1);
}
/**
* Tests successful retrieval of a group by its ID.
*/
@Test
void getGroupById_Exists_ReturnsGroup() {
String parentGroupId = "org-id-123";
String groupId = "group-id-eng";
when(identityManagerService.getGroupById(groupId)).thenReturn(Mono.just(mockGroupResponse));
webTestClient.get().uri("/groups/{parentGroupId}/children/{groupId}", parentGroupId, groupId)
.exchange().expectStatus().isOk()
.expectBody(GroupResponse.class).isEqualTo(mockGroupResponse);
}
/**
* Tests retrieval of a non-existent group.
*/
@Test
void getGroupById_NotFound_ReturnsNotFound() {
String parentGroupId = "org-id-123";
String groupId = "non-existent-group";
when(identityManagerService.getGroupById(groupId))
.thenReturn(Mono.error(new ResourceNotFoundException("Group not found")));
webTestClient.get().uri("/groups/{parentGroupId}/children/{groupId}",
parentGroupId, groupId).exchange()
.expectStatus().isNotFound();
}
/**
* Tests successful update of a group.
*/
@Test
void updateGroup_Success_ReturnsNoContent() {
String parentGroupId = "org-id-123";
String groupId = "group-id-eng";
UpdateGroupRequest request = new UpdateGroupRequest("An updated description", null);
when(identityManagerService.updateGroup(eq(groupId), any(UpdateGroupRequest.class)))
.thenReturn(Mono.empty());
webTestClient.put().uri("/groups/{parentGroupId}/children/{groupId}",
parentGroupId, groupId)
.contentType(MediaType.APPLICATION_JSON).bodyValue(request)
.exchange().expectStatus().isNoContent();
}
/**
* Tests successful deletion of a group.
*/
@Test
void deleteGroup_Success_ReturnsNoContent() {
String parentGroupId = "org-id-123";
String groupId = "group-id-eng";
when(identityManagerService.deleteGroup(groupId)).thenReturn(Mono.empty());
webTestClient.delete().uri("/groups/{parentGroupId}/children/{groupId}",
parentGroupId, groupId).exchange()
.expectStatus().isNoContent();
}
/**
* Tests successfully adding a member to a group.
*/
@Test
void addMemberToGroup_Success_ReturnsNoContent() {
String parentGroupId = "org-id-123";
String groupId = "group-id-eng";
AddGroupMemberRequest request = new AddGroupMemberRequest("user-id-to-add");
when(identityManagerService.addMemberToGroup(groupId, request.getUserId())).thenReturn(Mono.empty());
webTestClient.post().uri("/groups/{parentGroupId}/children/{groupId}/members",
parentGroupId, groupId).contentType(MediaType.APPLICATION_JSON)
.bodyValue(request).exchange().expectStatus().isNoContent();
}
/**
* Tests successful listing of group members.
*/
@Test
void listGroupMembers_Success_ReturnsListOfMembers() {
String parentGroupId = "org-id-123";
String groupId = "group-id-eng";
List<OrganizationMemberResponse> members = List.of(
new OrganizationMemberResponse("user-id-123", "abed.spring",
"Abed", "Spring", "abed@test.com", true));
when(identityManagerService.getGroupMembers(groupId)).thenReturn(Mono.just(members));
webTestClient.get().uri("/groups/{parentGroupId}/children/{groupId}/members",
parentGroupId, groupId).exchange()
.expectStatus().isOk().expectBodyList(OrganizationMemberResponse.class).isEqualTo(members);
}
/**
* Tests successfully removing a member from a group.
*/
@Test
void removeMemberFromGroup_Success_ReturnsNoContent() {
String parentGroupId = "org-id-123";
String groupId = "group-id-eng";
String userId = "user-to-remove";
when(identityManagerService.removeMemberFromGroup(groupId, userId)).thenReturn(Mono.empty());
webTestClient.delete()
.uri("/groups/{parentGroupId}/children/{groupId}/members/{userId}",
parentGroupId, groupId, userId)
.exchange().expectStatus().isNoContent();
}
}
@@ -0,0 +1,208 @@
package com.cleverthis.authservice;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
import com.cleverthis.authservice.controller.OrganizationController;
import com.cleverthis.authservice.dto.keycloakadmin.OrganizationDomainRepresentation;
import com.cleverthis.authservice.dto.organization.AddMemberRequest;
import com.cleverthis.authservice.dto.organization.CreateOrganizationRequest;
import com.cleverthis.authservice.dto.organization.InviteUserRequest;
import com.cleverthis.authservice.dto.organization.OrganizationMemberResponse;
import com.cleverthis.authservice.dto.organization.OrganizationResponse;
import com.cleverthis.authservice.dto.organization.UpdateOrganizationRequest;
import com.cleverthis.authservice.exception.GlobalExceptionHandler;
import com.cleverthis.authservice.exception.ResourceNotFoundException;
import com.cleverthis.authservice.service.IdentityManagerService;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.reactive.server.WebTestClient;
import reactor.core.publisher.Mono;
/**
* Unit tests for the OrganizationController. This class tests the API layer for organization
* management, mocking the service layer.
*/
@WebFluxTest(OrganizationController.class)
@Import(GlobalExceptionHandler.class)
public class OrganizationControllerTest {
@Autowired
private WebTestClient webTestClient;
@MockitoBean
private IdentityManagerService identityManagerService;
private OrganizationResponse mockOrgResponse;
private List<OrganizationResponse> mockOrgResponseList;
/**
* Sets up mock data before each test.
*/
@BeforeEach
void setUp() {
// Create the correct domain representation object
OrganizationDomainRepresentation domain =
new OrganizationDomainRepresentation("test-org.com", true);
this.mockOrgResponse = new OrganizationResponse("org-id-123", "test-org", Set.of(domain),
"A test organization", true, Map.of("displayName", List.of("Test Org")));
this.mockOrgResponseList = Collections.singletonList(this.mockOrgResponse);
}
/**
* Tests successful creation of an organization.
*/
@Test
void createOrganization_Success_ReturnsCreatedWithBody() {
CreateOrganizationRequest request = new CreateOrganizationRequest("test-org",
List.of("test-org.com"), "A test org", null);
when(this.identityManagerService.createOrganization(any(CreateOrganizationRequest.class)))
.thenReturn(Mono.just(this.mockOrgResponse));
this.webTestClient.post().uri("/organizations").contentType(MediaType.APPLICATION_JSON)
.bodyValue(request).exchange().expectStatus().isCreated()
.expectBody(OrganizationResponse.class).isEqualTo(this.mockOrgResponse);
}
/**
* Tests organization creation with invalid input, expecting a Bad Request.
*/
@Test
void createOrganization_InvalidInput_ReturnsBadRequest() {
CreateOrganizationRequest request = new CreateOrganizationRequest("", null, null, null);
this.webTestClient.post().uri("/organizations").contentType(MediaType.APPLICATION_JSON)
.bodyValue(request).exchange().expectStatus().isBadRequest();
}
/**
* Tests successful retrieval of all organizations.
*/
@Test
void getOrganizations_Success_ReturnsListOfOrgs() {
when(this.identityManagerService.getOrganizations())
.thenReturn(Mono.just(this.mockOrgResponseList));
this.webTestClient.get().uri("/organizations").exchange().expectStatus().isOk()
.expectBodyList(OrganizationResponse.class).isEqualTo(this.mockOrgResponseList);
}
/**
* Tests successful retrieval of a single organization by its ID.
*/
@Test
void getOrganizationById_Exists_ReturnsOrg() {
when(this.identityManagerService.getOrganizationById("org-id-123"))
.thenReturn(Mono.just(this.mockOrgResponse));
this.webTestClient.get().uri("/organizations/{orgId}", "org-id-123").exchange()
.expectStatus().isOk().expectBody(OrganizationResponse.class)
.isEqualTo(this.mockOrgResponse);
}
/**
* Tests retrieval of a non-existent organization, expecting Not Found.
*/
@Test
void getOrganizationById_NotFound_ReturnsNotFound() {
when(this.identityManagerService.getOrganizationById("non-existent-id"))
.thenReturn(Mono.error(new ResourceNotFoundException("Organization not found")));
this.webTestClient.get().uri("/organizations/{orgId}", "non-existent-id").exchange()
.expectStatus().isNotFound();
}
/**
* Tests successful update of an organization.
*/
@Test
void updateOrganization_Success_ReturnsNoContent() {
UpdateOrganizationRequest request =
new UpdateOrganizationRequest(List.of("updated.com"), "Updated desc", null);
when(this.identityManagerService.updateOrganization(eq("org-id-123"),
any(UpdateOrganizationRequest.class))).thenReturn(Mono.empty());
this.webTestClient.put().uri("/organizations/{orgId}", "org-id-123")
.contentType(MediaType.APPLICATION_JSON).bodyValue(request).exchange()
.expectStatus().isNoContent();
}
/**
* Tests successful deletion of an organization.
*/
@Test
void deleteOrganization_Success_ReturnsNoContent() {
when(this.identityManagerService.deleteOrganization("org-id-123")).thenReturn(Mono.empty());
this.webTestClient.delete().uri("/organizations/{orgId}", "org-id-123").exchange()
.expectStatus().isNoContent();
}
/**
* Tests successful invitation of a user to an organization.
*/
@Test
void inviteUserToOrganization_Success_ReturnsOk() {
InviteUserRequest request = new InviteUserRequest("invite@example.com", "Invited", "User");
when(this.identityManagerService.inviteUserToOrganization(eq("org-id-123"),
any(InviteUserRequest.class))).thenReturn(Mono.empty());
this.webTestClient.post().uri("/organizations/{orgId}/invitations", "org-id-123")
.contentType(MediaType.APPLICATION_JSON).bodyValue(request).exchange()
.expectStatus().isOk();
}
/**
* Tests successful retrieval of organization members.
*/
@Test
void getOrganizationMembers_Success_ReturnsListOfMembers() {
List<OrganizationMemberResponse> members =
List.of(new OrganizationMemberResponse("user-id-1", "test", "Test", "User",
"test@test.com", true));
when(this.identityManagerService.getOrganizationMembers("org-id-123"))
.thenReturn(Mono.just(members));
this.webTestClient.get().uri("/organizations/{orgId}/members", "org-id-123").exchange()
.expectStatus().isOk().expectBodyList(OrganizationMemberResponse.class)
.isEqualTo(members);
}
/**
* Tests successfully adding a member to an organization.
*/
@Test
void addMemberToOrganization_Success_ReturnsNoContent() {
AddMemberRequest request = new AddMemberRequest("user-to-add-id");
when(this.identityManagerService.addMemberToOrganization("org-id-123", "user-to-add-id"))
.thenReturn(Mono.empty());
this.webTestClient.post().uri("/organizations/{orgId}/members", "org-id-123")
.contentType(MediaType.APPLICATION_JSON).bodyValue(request).exchange()
.expectStatus().isNoContent();
}
/**
* Tests successfully removing a member from an organization.
*/
@Test
void removeMemberFromOrganization_Success_ReturnsNoContent() {
when(this.identityManagerService.removeMemberFromOrganization("org-id-123",
"user-to-remove-id")).thenReturn(Mono.empty());
this.webTestClient.delete()
.uri("/organizations/{orgId}/members/{userId}", "org-id-123", "user-to-remove-id")
.exchange().expectStatus().isNoContent();
}
}