Support unmanaged attributes for service accounts and make sure they are only managed through the admin api

Closes #29362

Signed-off-by: Pedro Igor <pigor.craveiro@gmail.com>
This commit is contained in:
Pedro Igor 2024-05-15 12:44:37 -03:00
parent 6dc28bc7b5
commit b019cf6129
14 changed files with 115 additions and 278 deletions

View file

@ -329,4 +329,9 @@ public interface UserResource {
@Path("impersonation") @Path("impersonation")
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
Map<String, Object> impersonate(); Map<String, Object> impersonate();
@GET
@Path("unmanagedAttributes")
@Produces(MediaType.APPLICATION_JSON)
Map<String, List<String>> getUnmanagedAttributes();
} }

View file

@ -203,7 +203,7 @@ describe("User creation", () => {
cy.wait("@save-user").should(({ request, response }) => { cy.wait("@save-user").should(({ request, response }) => {
expect(response?.statusCode).to.equal(204); expect(response?.statusCode).to.equal(204);
expect(request.body.attributes, "response body").deep.equal({ expect(request.body.attributes, "response body").deep.contains({
"key-multiple": ["other value"], "key-multiple": ["other value"],
}); });
}); });

View file

@ -1,8 +0,0 @@
import KeycloakAdminClient from "@keycloak/keycloak-admin-client";
import { fetchAdminUI } from "../../context/auth/admin-ui-endpoint";
export const getUnmanagedAttributes = (
adminClient: KeycloakAdminClient,
id: string,
): Promise<Record<string, string[]> | undefined> =>
fetchAdminUI(adminClient, `ui-ext/users/${id}/unmanagedAttributes`);

View file

@ -32,7 +32,6 @@ import {
RoutableTabs, RoutableTabs,
useRoutableTab, useRoutableTab,
} from "../components/routable-tabs/RoutableTabs"; } from "../components/routable-tabs/RoutableTabs";
import { getUnmanagedAttributes } from "../components/users/resource";
import { ViewHeader } from "../components/view-header/ViewHeader"; import { ViewHeader } from "../components/view-header/ViewHeader";
import { useAccess } from "../context/access/Access"; import { useAccess } from "../context/access/Access";
import { useRealm } from "../context/realm-context/RealmContext"; import { useRealm } from "../context/realm-context/RealmContext";
@ -117,7 +116,7 @@ export default function EditUser() {
userProfileMetadata: true, userProfileMetadata: true,
}) as UIUserRepresentation | undefined, }) as UIUserRepresentation | undefined,
adminClient.attackDetection.findOne({ id: id! }), adminClient.attackDetection.findOne({ id: id! }),
getUnmanagedAttributes(adminClient, id!), adminClient.users.getUnmanagedAttributes({ id: id! }),
adminClient.users.getProfile({ realm: realmName }), adminClient.users.getProfile({ realm: realmName }),
]), ]),
([realm, userData, attackDetection, unmanagedAttributes, upConfig]) => { ([realm, userData, attackDetection, unmanagedAttributes, upConfig]) => {

View file

@ -494,6 +494,15 @@ export class Users extends Resource<{ realm?: string }> {
urlParamKeys: ["id", "clientId"], urlParamKeys: ["id", "clientId"],
}); });
public getUnmanagedAttributes = this.makeRequest<
{ id: string },
Record<string, string[]>
>({
method: "GET",
path: "/{id}/unmanagedAttributes",
urlParamKeys: ["id"],
});
constructor(client: KeycloakAdminClient) { constructor(client: KeycloakAdminClient) {
super(client, { super(client, {
path: "/admin/realms/{realm}/users", path: "/admin/realms/{realm}/users",

View file

@ -54,9 +54,4 @@ public final class AdminExtResource {
public UIRealmResource realm() { public UIRealmResource realm() {
return new UIRealmResource(session, auth, adminEvent); return new UIRealmResource(session, auth, adminEvent);
} }
@Path("/users")
public UsersResource users() {
return new UsersResource(session, auth);
}
} }

View file

@ -1,68 +0,0 @@
package org.keycloak.admin.ui.rest;
import static java.util.Collections.emptyList;
import static java.util.Optional.ofNullable;
import static org.keycloak.userprofile.UserProfileContext.USER_API;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.stream.Collectors;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import org.jboss.resteasy.reactive.NoCache;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.representations.userprofile.config.UPConfig;
import org.keycloak.userprofile.UserProfile;
import org.keycloak.userprofile.UserProfileProvider;
import org.keycloak.utils.StringUtil;
/**
* @author <a href="mailto:psilva@redhat.com">Pedro Igor</a>
*/
public class UserResource {
private final KeycloakSession session;
private final UserModel user;
public UserResource(KeycloakSession session, UserModel user) {
this.session = session;
this.user = user;
}
@GET
@Path("unmanagedAttributes")
@NoCache
@Produces(MediaType.APPLICATION_JSON)
public Map<String, List<String>> getUnmanagedAttributes() {
UserProfileProvider provider = session.getProvider(UserProfileProvider.class);
UserProfile profile = provider.create(USER_API, user);
Map<String, List<String>> managedAttributes = profile.getAttributes().getReadable();
Map<String, List<String>> attributes = new HashMap<>(user.getAttributes());
UPConfig upConfig = provider.getConfiguration();
if (upConfig.getUnmanagedAttributePolicy() == null) {
return Collections.emptyMap();
}
Map<String, List<String>> unmanagedAttributes = profile.getAttributes().getUnmanagedAttributes();
managedAttributes.entrySet().removeAll(unmanagedAttributes.entrySet());
attributes.entrySet().removeAll(managedAttributes.entrySet());
attributes.remove(UserModel.USERNAME);
attributes.remove(UserModel.EMAIL);
return attributes.entrySet().stream()
.filter(entry -> ofNullable(entry.getValue()).orElse(emptyList()).stream().anyMatch(StringUtil::isNotBlank))
.collect(Collectors.toMap(Entry::getKey, Entry::getValue));
}
}

View file

@ -1,45 +0,0 @@
package org.keycloak.admin.ui.rest;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.models.light.LightweightUserAdapter;
import org.keycloak.services.resources.admin.permissions.AdminPermissionEvaluator;
import jakarta.ws.rs.ForbiddenException;
public class UsersResource {
private final KeycloakSession session;
private final AdminPermissionEvaluator auth;
public UsersResource(KeycloakSession session, AdminPermissionEvaluator auth) {
this.session = session;
this.auth = auth;
}
@Path("{id}")
public UserResource getUser(@PathParam("id") String id) {
RealmModel realm = session.getContext().getRealm();
UserModel user = null;
if (LightweightUserAdapter.isLightweightUser(id)) {
UserSessionModel userSession = session.sessions().getUserSession(realm, LightweightUserAdapter.getLightweightUserId(id));
if (userSession != null) {
user = userSession.getUser();
}
} else {
user = session.users().getUserById(realm, id);
}
if (user == null) {
// we do this to make sure somebody can't phish ids
if (auth.users().canQuery()) throw new NotFoundException("User not found");
else throw new ForbiddenException();
}
return new UserResource(session, user);
}
}

View file

@ -98,18 +98,6 @@ public class DefaultAttributes extends HashMap<String, List<String>> implements
return !isAllowEditUnmanagedAttribute(); return !isAllowEditUnmanagedAttribute();
} }
if (UserModel.USERNAME.equals(name)) {
if (isServiceAccountUser()) {
return true;
}
}
if (UserModel.EMAIL.equals(name)) {
if (isServiceAccountUser()) {
return false;
}
}
if (isReadOnlyFromMetadata(name) || isReadOnlyInternalAttribute(name)) { if (isReadOnlyFromMetadata(name) || isReadOnlyInternalAttribute(name)) {
return true; return true;
} }
@ -311,10 +299,6 @@ public class DefaultAttributes extends HashMap<String, List<String>> implements
return Collections.unmodifiableMap(this); return Collections.unmodifiableMap(this);
} }
protected boolean isServiceAccountUser() {
return user != null && user.getServiceAccountClientLink() != null;
}
private AttributeContext createAttributeContext(Entry<String, List<String>> attribute, AttributeMetadata metadata) { private AttributeContext createAttributeContext(Entry<String, List<String>> attribute, AttributeMetadata metadata) {
return new AttributeContext(context, session, attribute, user, metadata, this); return new AttributeContext(context, session, attribute, user, metadata, this);
} }
@ -482,7 +466,7 @@ public class DefaultAttributes extends HashMap<String, List<String>> implements
return valuesStream.collect(Collectors.toList()); return valuesStream.collect(Collectors.toList());
} }
private boolean isAllowUnmanagedAttribute() { protected boolean isAllowUnmanagedAttribute() {
UnmanagedAttributePolicy unmanagedAttributePolicy = upConfig.getUnmanagedAttributePolicy(); UnmanagedAttributePolicy unmanagedAttributePolicy = upConfig.getUnmanagedAttributePolicy();
if (unmanagedAttributePolicy == null) { if (unmanagedAttributePolicy == null) {
@ -501,11 +485,8 @@ public class DefaultAttributes extends HashMap<String, List<String>> implements
return UnmanagedAttributePolicy.ENABLED.equals(unmanagedAttributePolicy); return UnmanagedAttributePolicy.ENABLED.equals(unmanagedAttributePolicy);
} }
private void setUserName(Map<String, List<String>> newAttributes, List<String> lowerCaseEmailList) { protected void setUserName(Map<String, List<String>> newAttributes, List<String> values) {
if (isServiceAccountUser()) { newAttributes.put(UserModel.USERNAME, values);
return;
}
newAttributes.put(UserModel.USERNAME, lowerCaseEmailList);
} }
protected boolean isIncludeAttributeIfNotProvided(AttributeMetadata metadata) { protected boolean isIncludeAttributeIfNotProvided(AttributeMetadata metadata) {
@ -530,10 +511,6 @@ public class DefaultAttributes extends HashMap<String, List<String>> implements
return true; return true;
} }
if (isServiceAccountUser()) {
return true;
}
return isReadOnlyInternalAttribute(name); return isReadOnlyInternalAttribute(name);
} }
@ -575,7 +552,7 @@ public class DefaultAttributes extends HashMap<String, List<String>> implements
return unmanagedAttributes; return unmanagedAttributes;
} }
private AttributeMetadata createUnmanagedAttributeMetadata(String name) { protected AttributeMetadata createUnmanagedAttributeMetadata(String name) {
return new AttributeMetadata(name, Integer.MAX_VALUE) { return new AttributeMetadata(name, Integer.MAX_VALUE) {
final UnmanagedAttributePolicy unmanagedAttributePolicy = upConfig.getUnmanagedAttributePolicy(); final UnmanagedAttributePolicy unmanagedAttributePolicy = upConfig.getUnmanagedAttributePolicy();

View file

@ -108,6 +108,7 @@ import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.Status; import jakarta.ws.rs.core.Response.Status;
import jakarta.ws.rs.core.UriBuilder; import jakarta.ws.rs.core.UriBuilder;
import org.keycloak.utils.StringUtil;
import java.net.URI; import java.net.URI;
import java.text.MessageFormat; import java.text.MessageFormat;
@ -118,6 +119,7 @@ import java.util.HashSet;
import java.util.LinkedList; import java.util.LinkedList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Map.Entry;
import java.util.Objects; import java.util.Objects;
import java.util.Properties; import java.util.Properties;
import java.util.Set; import java.util.Set;
@ -125,6 +127,8 @@ import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.stream.Stream; import java.util.stream.Stream;
import static java.util.Collections.emptyList;
import static java.util.Optional.ofNullable;
import static org.keycloak.models.ImpersonationSessionNote.IMPERSONATOR_ID; import static org.keycloak.models.ImpersonationSessionNote.IMPERSONATOR_ID;
import static org.keycloak.models.ImpersonationSessionNote.IMPERSONATOR_USERNAME; import static org.keycloak.models.ImpersonationSessionNote.IMPERSONATOR_USERNAME;
import static org.keycloak.userprofile.UserProfileContext.USER_API; import static org.keycloak.userprofile.UserProfileContext.USER_API;
@ -1051,6 +1055,30 @@ public class UserResource {
} }
} }
@GET
@Path("unmanagedAttributes")
@NoCache
@Produces(MediaType.APPLICATION_JSON)
@Tag(name = KeycloakOpenAPI.Admin.Tags.USERS)
@Operation()
public Map<String, List<String>> getUnmanagedAttributes() {
UserProfileProvider provider = session.getProvider(UserProfileProvider.class);
UserProfile profile = provider.create(USER_API, user);
Map<String, List<String>> managedAttributes = profile.getAttributes().getReadable();
Map<String, List<String>> unmanagedAttributes = profile.getAttributes().getUnmanagedAttributes();
managedAttributes.entrySet().removeAll(unmanagedAttributes.entrySet());
Map<String, List<String>> attributes = new HashMap<>(user.getAttributes());
attributes.entrySet().removeAll(managedAttributes.entrySet());
attributes.remove(UserModel.USERNAME);
attributes.remove(UserModel.EMAIL);
return attributes.entrySet().stream()
.filter(entry -> ofNullable(entry.getValue()).orElse(emptyList()).stream().anyMatch(StringUtil::isNotBlank))
.collect(Collectors.toMap(Entry::getKey, Entry::getValue));
}
/** /**
* Converts the specified {@link UserSessionModel} into a {@link UserSessionRepresentation}. * Converts the specified {@link UserSessionModel} into a {@link UserSessionRepresentation}.
* *

View file

@ -39,7 +39,6 @@ import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel; import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel; import org.keycloak.models.UserModel;
import org.keycloak.models.UserProvider;
import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.sessions.AuthenticationSessionModel; import org.keycloak.sessions.AuthenticationSessionModel;
import org.keycloak.userprofile.config.DeclarativeUserProfileModel; import org.keycloak.userprofile.config.DeclarativeUserProfileModel;
@ -107,7 +106,7 @@ public class DeclarativeUserProfileProvider implements UserProfileProvider {
UserModel user, UserProfileMetadata metadata) { UserModel user, UserProfileMetadata metadata) {
if (isServiceAccountUser(user)) { if (isServiceAccountUser(user)) {
return new LegacyAttributes(context, attributes, user, metadata, session); return new ServiceAccountAttributes(context, attributes, user, metadata, session);
} }
return new DefaultAttributes(context, attributes, user, metadata, session); return new DefaultAttributes(context, attributes, user, metadata, session);
} }

View file

@ -1,120 +0,0 @@
package org.keycloak.userprofile;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.keycloak.models.Constants;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
/**
* Enables legacy support when managing attributes without the declarative provider.
*
* @author <a href="mailto:psilva@redhat.com">Pedro Igor</a>
*/
public class LegacyAttributes extends DefaultAttributes {
public LegacyAttributes(UserProfileContext context, Map<String, ?> attributes, UserModel user,
UserProfileMetadata profileMetadata, KeycloakSession session) {
super(context, attributes, user, profileMetadata, session);
}
@Override
protected boolean isSupportedAttribute(String name) {
if (UserProfileContext.USER_API.equals(context) || UserProfileContext.ACCOUNT.equals(context)) {
return true;
}
if (super.isSupportedAttribute(name)) {
return true;
}
if (name.startsWith(Constants.USER_ATTRIBUTES_PREFIX)) {
return true;
}
return false;
}
@Override
public boolean isReadOnly(String name) {
RealmModel realm = session.getContext().getRealm();
if (isReadOnlyInternalAttribute(name)) {
return true;
}
if (user == null) {
return false;
}
if (UserModel.USERNAME.equals(name)) {
if (isServiceAccountUser()) {
return true;
}
if (UserProfileContext.IDP_REVIEW.equals(context)) {
return false;
}
if (realm.isRegistrationEmailAsUsername()) {
return true;
}
return !realm.isEditUsernameAllowed();
}
if (UserModel.EMAIL.equals(name)) {
if (isServiceAccountUser()) {
return false;
}
if (UserProfileContext.IDP_REVIEW.equals(context)
|| UserProfileContext.USER_API.equals(context)) {
return false;
}
if (realm.isRegistrationEmailAsUsername() && !realm.isEditUsernameAllowed()) {
return true;
}
}
return false;
}
@Override
public Map<String, List<String>> getReadable() {
if(user == null || user.getAttributes() == null) {
return super.getReadable();
}
return new HashMap<>(user.getAttributes());
}
@Override
public Map<String, List<String>> getWritable() {
Map<String, List<String>> attributes = new HashMap<>(this);
RealmModel realm = session.getContext().getRealm();
for (String name : nameSet()) {
if (isReadOnly(name)) {
if (UserModel.USERNAME.equals(name)
&& realm.isRegistrationEmailAsUsername()) {
continue;
}
attributes.remove(name);
}
}
return attributes;
}
@Override
protected boolean isIncludeAttributeIfNotProvided(AttributeMetadata metadata) {
if (UserModel.LOCALE.equals(metadata.getName())) {
// locale is an internal attribute and should be updated as a regular attribute
return false;
}
// user api expects that attributes are not updated if not provided when in legacy mode
return UserProfileContext.USER_API.equals(context);
}
}

View file

@ -0,0 +1,61 @@
package org.keycloak.userprofile;
import java.util.List;
import java.util.Map;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.UserModel;
/**
* <p>A specific {@link Attributes} implementation to handle service accounts.
*
* <p>Service accounts are not regular users, and it should be possible to manage unmanaged attributes but only when
* operating through the {@link UserProfileContext#USER_API}. Otherwise, administrators will be forced to enable unmanaged
* attributes by setting a {@link org.keycloak.representations.userprofile.config.UPConfig.UnmanagedAttributePolicy} or
* end up defining managed attributes that are specific for service accounts in the user profile configuration, which is
* mainly targeted for regular users.
*
*
* @author <a href="mailto:psilva@redhat.com">Pedro Igor</a>
*/
public class ServiceAccountAttributes extends DefaultAttributes {
public ServiceAccountAttributes(UserProfileContext context, Map<String, ?> attributes, UserModel user,
UserProfileMetadata profileMetadata, KeycloakSession session) {
super(context, attributes, user, profileMetadata, session);
}
@Override
public boolean isReadOnly(String name) {
if (UserModel.USERNAME.equals(name)) {
return true;
}
return !UserProfileContext.USER_API.equals(context);
}
@Override
protected AttributeMetadata createUnmanagedAttributeMetadata(String name) {
return new AttributeMetadata(name, Integer.MAX_VALUE) {
@Override
public boolean canView(AttributeContext context) {
return UserProfileContext.USER_API.equals(context.getContext());
}
@Override
public boolean canEdit(AttributeContext context) {
return UserProfileContext.USER_API.equals(context.getContext());
}
};
}
@Override
protected boolean isAllowUnmanagedAttribute() {
return UserProfileContext.USER_API.equals(context);
}
@Override
protected void setUserName(Map<String, List<String>> newAttributes, List<String> values) {
// can not update username for service accounts
}
}

View file

@ -132,6 +132,7 @@ public class ServiceAccountUserProfileTest extends AbstractKeycloakTest {
UserRepresentation representation = serviceAccount.toRepresentation(); UserRepresentation representation = serviceAccount.toRepresentation();
String username = representation.getUsername(); String username = representation.getUsername();
assertNotNull(username);
assertNull(representation.getEmail()); assertNull(representation.getEmail());
serviceAccount.update(representation); serviceAccount.update(representation);
@ -158,6 +159,10 @@ public class ServiceAccountUserProfileTest extends AbstractKeycloakTest {
representation = serviceAccount.toRepresentation(); representation = serviceAccount.toRepresentation();
assertFalse(representation.getAttributes().isEmpty()); assertFalse(representation.getAttributes().isEmpty());
assertEquals("attr-1-value", representation.getAttributes().get("attr-1").get(0)); assertEquals("attr-1-value", representation.getAttributes().get("attr-1").get(0));
Map<String, List<String>> unmanagedAttributes = test.users().get(userId).getUnmanagedAttributes();
assertEquals(1, unmanagedAttributes.size());
} }
@Test @Test