8000 Introduce a capability allowing to change current membershipType for … by martin-kanis · Pull Request #52370 · keycloak/keycloak · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,18 @@ realm user to an organization, the account belongs to the realm first and foremo
removing an organization or a member will not remove the account from the realm; the realm is
the single-source of truth for the identity.

== Changing membership type

You can change the membership type of an existing organization member using the Admin REST API.

`PUT /admin/realms/{realm}/organizations/{orgId}/members/{memberId}/membership-type`

The request body should contain the membership type as a JSON string: `"MANAGED"` or `"UNMANAGED"`.

WARNING: Changing the membership type affects the lifecycle of the member's account. When changing from *Unmanaged* to *Managed*,
the member's account becomes bound to the organization -- removing the member from the organization or deleting the organization will also remove the account from the realm.
Conversely, changing from *Managed* to *Unmanaged* means the account will persist in the realm even if the member is removed from the organization, but the user may lack local credentials if originally federated via an identity provider.

== Adding an existing realm user as a member

An existing realm user can join an organization by selecting that user from a list and adding the user to the organization.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,11 @@

import java.util.List;

import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
Expand All @@ -30,6 +32,7 @@

import org.keycloak.representations.idm.GroupRepresentation;
import org.keycloak.representations.idm.MemberRepresentation;
import org.keycloak.representations.idm.MembershipType;
import org.keycloak.representations.idm.OrganizationRepresentation;

public interface OrganizationMemberResource {
Expand All @@ -41,6 +44,18 @@ public interface OrganizationMemberResource {
@DELETE
Response delete();

/**
* Updates the membership type of this member.
*
* @param membershipType the new membership type
* @since Keycloak server 26.8.0
* @return response
*/
@Path("membership-type")
@PUT
@Consumes(MediaType.APPLICATION_JSON)
Response updateMembershipType(MembershipType membershipType);

/**
* Returns the organizations associated with the user
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
import org.keycloak.models.cache.infinispan.UserCacheSession;
import org.keycloak.organization.InvitationManager;
import org.keycloak.organization.OrganizationProvider;
import org.keycloak.representations.idm.MembershipType;

import static org.keycloak.models.cache.infinispan.idp.InfinispanIdentityProviderStorageProvider.cacheKeyOrgId;

Expand Down Expand Up @@ -199,6 +200,12 @@ public boolean removeMember(OrganizationModel organization, UserModel member) {
return getDelegate().removeMember(organization, member);
}

@Override
public boolean updateMembershipType(OrganizationModel organization, UserModel member, MembershipType membershipType) {
registerMemberInvalidation(organization, member);
return getDelegate().updateMembershipType(organization, member, membershipType);
}

@Override
public Stream<UserModel> getMembersStream(OrganizationModel organization, String search, Boolean exact, Integer first, Integer max) {
Map<String, String> filters = Optional.ofNullable(search)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -195,10 +195,47 @@ public boolean addMember(OrganizationModel organization, UserModel user) {
return addMember(organization, user, new MembershipMetadata(MembershipType.UNMANAGED));
}

@Override
public boolean updateMembershipType(OrganizationModel organization, UserModel member, MembershipType membershipType) {
throwExceptionIfObjectIsNull(organization, "organization");
throwExceptionIfObjectIsNull(member, "member");
throwExceptionIfObjectIsNull(membershipType, "membershipType");

if (MembershipType.MANAGED.equals(membershipType)) {
throwIfManagedByAnotherOrg(organization, member);
Comment on lines +204 to +205
}

UserEntity userEntity = em.find(UserEntity.class, member.getId());
if (userEntity == null) {
return false;
}
Comment on lines +208 to +211

GroupModel organizationGroup = getOrganizationGroup(organization);
try {
UserGroupMembershipEntity membership = em.createNamedQuery("userMemberOf", UserGroupMembershipEntity.class)
.setParameter("user", userEntity)
.setParameter("groupId", organizationGroup.getId())
.getSingleResult();

if (membershipType.equals(membership.getMembershipType())) {
return true;
}

membership.setMembershipType(membershipType);
return true;
} catch (NoResultException e) {
return false;
}
}

private boolean addMember(OrganizationModel organization, UserModel user, MembershipMetadata metadata) {
throwExceptionIfObjectIsNull(organization, "Organization");
throwExceptionIfObjectIsNull(user, "User");

if (MembershipType.MANAGED.equals(metadata.getMembershipType())) {
throwIfManagedByAnotherOrg(organization, user);
}

OrganizationEntity entity = getEntity(organization.getId());
OrganizationModel current = Organizations.resolveOrganization(session);

Expand Down Expand Up @@ -229,6 +266,15 @@ private boolean addMember(OrganizationModel organization, UserModel user, Member
return true;
}

private void throwIfManagedByAnotherOrg(OrganizationModel organization, UserModel user) {
boolean managedElsewhere = getByMember(user)
.filter(org -> !org.equals(organization))
.anyMatch(org -> isManagedMember(org, user));
if (managedElsewhere) {
throw new ModelException("User is already a managed member of another organization");
}
}

@Override
public OrganizationModel getById(String id) {
OrganizationEntity entity = getEntity(id, false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,20 @@ default long count(Map<String, String> attributes) {
*/
boolean addMember(OrganizationModel organization, UserModel user);

/**
* Updates the membership type of an existing member of the given {@link OrganizationModel}.
*
* <p>Returns {@code true} if the member exists and the operation was handled successfully,
* including the idempotent case where the membership type is already the requested value.
* Returns {@code false} only if the user is not a member of the organization.
*
* @param organization the organization
* @param member the member
* @param membershipType the new membership type
* @return {@code true} if the member exists and the operation succeeded. Otherwise, returns {@code false}
*/
boolean updateMembershipType(OrganizationModel organization, UserModel member, MembershipType membershipType);

/**
* Returns the members of a given {@link OrganizationModel} filtered according to the specified parameters.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
import jakarta.ws.rs.GET;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
Expand Down Expand Up @@ -236,6 +237,52 @@ public MemberRepresentation get(@PathParam("member-id") String memberId) {
return toRepresentation(member, false);
}

@Path("{member-id}/membership-type")
@PUT
@Consumes(MediaType.APPLICATION_JSON)
@Tag(name = KeycloakOpenAPI.Admin.Tags.ORGANIZATIONS)
@Operation(summary = "Updates the membership type of the member with the specified id")
@APIResponses(value = {
@APIResponse(responseCode = "204", description = "No Content"),
@APIResponse(responseCode = "400", description = "Bad Request"),
@APIResponse(responseCode = "403", description = "Forbidden"),
@APIResponse(responseCode = "404", description = "Not Found")
})
public Response updateMembershipType(@PathParam("member-id") String memberId, MembershipType membershipType) {
auth.orgs().requireManage(organization);
if (StringUtil.isBlank(memberId)) {
throw ErrorResponse.error("id cannot be null", Status.BAD_REQUEST);
}
Comment on lines +253 to +255
if (membershipType == null) {
throw ErrorResponse.error("membershipType cannot be null", Status.BAD_REQUEST);
}

UserModel member = getMember(memberId);
auth.users().requireManage(member);

MembershipType currentType = provider.isManagedMember(organization, member) ? MembershipType.MANAGED : MembershipType.UNMANAGED;
if (membershipType.equals(currentType)) {
return Response.noContent().build();
}

try {
if (provider.updateMembershipType(organization, member, membershipType)) {
adminEvent.operation(OperationType.UPDATE)
.representation(toRepresentation(member, false))
.resourcePath(session.getContext().getUri())
.detail(UserModel.USERNAME, member.getUsername())
.detail(UserModel.EMAIL, member.getEmail())
.detail(MembershipType.NAME, membershipType.name())
.success();
return Response.noContent().build();
}
} catch (ModelException me) {
throw ErrorResponse.error(me.getMessage(), Status.BAD_REQUEST);
}

throw ErrorResponse.error("Not a member of the organization", Status.NOT_FOUND);
}

@Path("{member-id}")
@DELETE
@Tag(name = KeycloakOpenAPI.Admin.Tags.ORGANIZATIONS)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,16 @@
import org.keycloak.admin.client.resource.OrganizationResource;
import org.keycloak.admin.client.resource.UserProfileResource;
import org.keycloak.admin.client.resource.UserResource;
import org.keycloak.events.admin.OperationType;
import org.keycloak.events.admin.ResourceType;
import org.keycloak.models.Constants;
import org.keycloak.models.OrganizationModel;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.organization.OrganizationProvider;
import org.keycloak.representations.idm.AbstractUserRepresentation;
import org.keycloak.representations.idm.AdminEventRepresentation;
import org.keycloak.representations.idm.IdentityProviderRepresentation;
import org.keycloak.representations.idm.MemberRepresentation;
import org.keycloak.representations.idm.MembershipType;
Expand All @@ -56,9 +59,12 @@
import org.keycloak.representations.userprofile.config.UPConfig.UnmanagedAttributePolicy;
import org.keycloak.testframework.admin.AdminClientFactory;
import org.keycloak.testframework.annotations.InjectAdminClientFactory;
import org.keycloak.testframework.annotations.InjectAdminEvents;
import org.keycloak.testframework.annotations.InjectRealm;
import org.keycloak.testframework.annotations.InjectUser;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.testframework.events.AdminEventAssertion;
import org.keycloak.testframework.events.AdminEvents;
import org.keycloak.testframework.injection.LifeCycle;
import org.keycloak.testframework.oauth.OAuthClient;
import org.keycloak.testframework.oauth.annotations.InjectOAuthClient;
Expand Down Expand Up @@ -128,6 +134,9 @@ public class OrganizationMemberTest extends AbstractOrganizationTest {
@InjectRunOnServer
RunOnServerClient runOnServer;

@InjectAdminEvents
AdminEvents adminEvents;

@Test
public void testUserProfileAttributePermissions() {
OrganizationRepresentation org = createOrganization();
Expand Down Expand Up @@ -926,6 +935,111 @@ public void testSearchMembersBriefVsFullRepresentation() {
assertTrue(singleMember.getAttributes().containsKey("testAttr"));
}

@Test
public void testUpdateMembershipType() {
OrganizationResource organization = realm.admin().organizations().get(createOrganization().getId());

// Change UNMANAGED -> MANAGED
MemberRepresentation member = addMember(organization);
assertEquals(MembershipType.UNMANAGED, member.getMembershipType());

adminEvents.clear();
try (Response response = organization.members().member(member.getId()).updateMembershipType(MembershipType.MANAGED)) {
assertEquals(Status.NO_CONTENT.getStatusCode(), response.getStatus());
}

MemberRepresentation updated = organization.members().member(member.getId()).toRepresentation();
assertEquals(MembershipType.MANAGED, updated.getMembershipType());

// Verify admin event
AdminEventRepresentation event = adminEvents.poll();
AdminEventAssertion.assertSuccess(event)
.operationType(OperationType.UPDATE)
.resourceType(ResourceType.ORGANIZATION_MEMBERSHIP);
assertEquals(MembershipType.MANAGED.name(), event.getDetails().get(MembershipType.NAME));
Comment on lines +946 to +959

// Change MANAGED -> UNMANAGED
adminEvents.clear();
try (Response response = organization.members().member(member.getId()).updateMembershipType(MembershipType.UNMANAGED)) {
assertEquals(Status.NO_CONTENT.getStatusCode(), response.getStatus());
}

updated = organization.members().member(member.getId()).toRepresentation();
assertEquals(MembershipType.UNMANAGED, updated.getMembershipType());

// Verify admin event for MANAGED -> UNMANAGED
event = adminEvents.poll();
AdminEventAssertion.assertSuccess(event)
.operationType(OperationType.UPDATE)
.resourceType(ResourceType.ORGANIZATION_MEMBERSHIP);
assertEquals(MembershipType.UNMANAGED.name(), event.getDetails().get(MembershipType.NAME));

// No-op when setting same type — should succeed but not emit an event
adminEvents.clear();
try (Response response = organization.members().member(member.getId()).updateMembershipType(MembershipType.UNMANAGED)) {
assertEquals(Status.NO_CONTENT.getStatusCode(), response.getStatus());
}

updated = organization.members().member(member.getId()).toRepresentation();
assertEquals(MembershipType.UNMANAGED, updated.getMembershipType());
assertNull(adminEvents.poll(), "No admin event should be emitted for no-op membership type change");

// 404 for non-member
UserRepresentation nonMember = UserBuilder.create()
.username("nonmember")
.email("nonmember@other.org")
.enabled(true)
.build();
try (Response response = realm.admin().users().create(nonMember)) {
nonMember.setId(ApiUtil.getCreatedId(response));
}
realm.cleanup().add(r -> r.users().get(nonMember.getId()).remove());

try (Response response = organization.members().member(nonMember.getId()).updateMembershipType(MembershipType.MANAGED)) {
assertEquals(Status.NOT_FOUND.getStatusCode(), response.getStatus());
}

// Lifecycle: after UNMANAGED -> MANAGED, removing member deletes user from realm
MemberRepresentation lifecycleMember = addMember(organization, "lifecycle@neworg.org");
assertEquals(MembershipType.UNMANAGED, lifecycleMember.getMembershipType());

try (Response response = organization.members().member(lifecycleMember.getId()).updateMembershipType(MembershipType.MANAGED)) {
assertEquals(Status.NO_CONTENT.getStatusCode(), response.getStatus());
}

organization.members().member(lifecycleMember.getId()).delete().close();

try {
realm.admin().users().get(lifecycleMember.getId()).toRepresentation();
fail("User should have been deleted when removing managed member");
} catch (NotFoundException expected) {
}
}

@Test
public void testUpdateMembershipTypeRejectsDoubleManagedAcrossOrgs() {
OrganizationResource org1 = realm.admin().organizations().get(createOrganization("org1").getId());
OrganizationResource org2 = realm.admin().organizations().get(createOrganization("org2").getId());

// Create a user and add as unmanaged member to both orgs
MemberRepresentation member = addMember(org1, "shared@org1.org");
org2.members().addMember(member.getId()).close();

// Make managed in org1
try (Response response = org1.members().member(member.getId()).updateMembershipType(MembershipType.MANAGED)) {
assertEquals(Status.NO_CONTENT.getStatusCode(), response.getStatus());
}

// Attempt to make managed in org2 should fail
try (Response response = org2.members().member(member.getId()).updateMembershipType(MembershipType.MANAGED)) {
assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus());
}

// Verify org2 membership is still unmanaged
MemberRepresentation org2Member = org2.members().member(member.getId()).toRepresentation();
assertEquals(MembershipType.UNMANAGED, org2Member.getMembershipType());
}

private void loginViaNonOrgIdP(String idpAlias) {
oauth.openLoginForm();

Expand Down
Loading
0