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:
parent
6dc28bc7b5
commit
b019cf6129
14 changed files with 115 additions and 278 deletions
|
@ -329,4 +329,9 @@ public interface UserResource {
|
|||
@Path("impersonation")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
Map<String, Object> impersonate();
|
||||
|
||||
@GET
|
||||
@Path("unmanagedAttributes")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
Map<String, List<String>> getUnmanagedAttributes();
|
||||
}
|
||||
|
|
|
@ -203,7 +203,7 @@ describe("User creation", () => {
|
|||
|
||||
cy.wait("@save-user").should(({ request, response }) => {
|
||||
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"],
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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`);
|
|
@ -32,7 +32,6 @@ import {
|
|||
RoutableTabs,
|
||||
useRoutableTab,
|
||||
} from "../components/routable-tabs/RoutableTabs";
|
||||
import { getUnmanagedAttributes } from "../components/users/resource";
|
||||
import { ViewHeader } from "../components/view-header/ViewHeader";
|
||||
import { useAccess } from "../context/access/Access";
|
||||
import { useRealm } from "../context/realm-context/RealmContext";
|
||||
|
@ -117,7 +116,7 @@ export default function EditUser() {
|
|||
userProfileMetadata: true,
|
||||
}) as UIUserRepresentation | undefined,
|
||||
adminClient.attackDetection.findOne({ id: id! }),
|
||||
getUnmanagedAttributes(adminClient, id!),
|
||||
adminClient.users.getUnmanagedAttributes({ id: id! }),
|
||||
adminClient.users.getProfile({ realm: realmName }),
|
||||
]),
|
||||
([realm, userData, attackDetection, unmanagedAttributes, upConfig]) => {
|
||||
|
|
|
@ -494,6 +494,15 @@ export class Users extends Resource<{ realm?: string }> {
|
|||
urlParamKeys: ["id", "clientId"],
|
||||
});
|
||||
|
||||
public getUnmanagedAttributes = this.makeRequest<
|
||||
{ id: string },
|
||||
Record<string, string[]>
|
||||
>({
|
||||
method: "GET",
|
||||
path: "/{id}/unmanagedAttributes",
|
||||
urlParamKeys: ["id"],
|
||||
});
|
||||
|
||||
constructor(client: KeycloakAdminClient) {
|
||||
super(client, {
|
||||
path: "/admin/realms/{realm}/users",
|
||||
|
|
|
@ -54,9 +54,4 @@ public final class AdminExtResource {
|
|||
public UIRealmResource realm() {
|
||||
return new UIRealmResource(session, auth, adminEvent);
|
||||
}
|
||||
|
||||
@Path("/users")
|
||||
public UsersResource users() {
|
||||
return new UsersResource(session, auth);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -98,18 +98,6 @@ public class DefaultAttributes extends HashMap<String, List<String>> implements
|
|||
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)) {
|
||||
return true;
|
||||
}
|
||||
|
@ -311,10 +299,6 @@ public class DefaultAttributes extends HashMap<String, List<String>> implements
|
|||
return Collections.unmodifiableMap(this);
|
||||
}
|
||||
|
||||
protected boolean isServiceAccountUser() {
|
||||
return user != null && user.getServiceAccountClientLink() != null;
|
||||
}
|
||||
|
||||
private AttributeContext createAttributeContext(Entry<String, List<String>> attribute, AttributeMetadata metadata) {
|
||||
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());
|
||||
}
|
||||
|
||||
private boolean isAllowUnmanagedAttribute() {
|
||||
protected boolean isAllowUnmanagedAttribute() {
|
||||
UnmanagedAttributePolicy unmanagedAttributePolicy = upConfig.getUnmanagedAttributePolicy();
|
||||
|
||||
if (unmanagedAttributePolicy == null) {
|
||||
|
@ -501,11 +485,8 @@ public class DefaultAttributes extends HashMap<String, List<String>> implements
|
|||
return UnmanagedAttributePolicy.ENABLED.equals(unmanagedAttributePolicy);
|
||||
}
|
||||
|
||||
private void setUserName(Map<String, List<String>> newAttributes, List<String> lowerCaseEmailList) {
|
||||
if (isServiceAccountUser()) {
|
||||
return;
|
||||
}
|
||||
newAttributes.put(UserModel.USERNAME, lowerCaseEmailList);
|
||||
protected void setUserName(Map<String, List<String>> newAttributes, List<String> values) {
|
||||
newAttributes.put(UserModel.USERNAME, values);
|
||||
}
|
||||
|
||||
protected boolean isIncludeAttributeIfNotProvided(AttributeMetadata metadata) {
|
||||
|
@ -530,10 +511,6 @@ public class DefaultAttributes extends HashMap<String, List<String>> implements
|
|||
return true;
|
||||
}
|
||||
|
||||
if (isServiceAccountUser()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return isReadOnlyInternalAttribute(name);
|
||||
}
|
||||
|
||||
|
@ -575,7 +552,7 @@ public class DefaultAttributes extends HashMap<String, List<String>> implements
|
|||
return unmanagedAttributes;
|
||||
}
|
||||
|
||||
private AttributeMetadata createUnmanagedAttributeMetadata(String name) {
|
||||
protected AttributeMetadata createUnmanagedAttributeMetadata(String name) {
|
||||
return new AttributeMetadata(name, Integer.MAX_VALUE) {
|
||||
final UnmanagedAttributePolicy unmanagedAttributePolicy = upConfig.getUnmanagedAttributePolicy();
|
||||
|
||||
|
|
|
@ -108,6 +108,7 @@ import jakarta.ws.rs.core.MediaType;
|
|||
import jakarta.ws.rs.core.Response;
|
||||
import jakarta.ws.rs.core.Response.Status;
|
||||
import jakarta.ws.rs.core.UriBuilder;
|
||||
import org.keycloak.utils.StringUtil;
|
||||
|
||||
import java.net.URI;
|
||||
import java.text.MessageFormat;
|
||||
|
@ -118,6 +119,7 @@ import java.util.HashSet;
|
|||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Map.Entry;
|
||||
import java.util.Objects;
|
||||
import java.util.Properties;
|
||||
import java.util.Set;
|
||||
|
@ -125,6 +127,8 @@ import java.util.concurrent.TimeUnit;
|
|||
import java.util.stream.Collectors;
|
||||
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_USERNAME;
|
||||
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}.
|
||||
*
|
||||
|
|
|
@ -39,7 +39,6 @@ import org.keycloak.models.ClientModel;
|
|||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.models.UserModel;
|
||||
import org.keycloak.models.UserProvider;
|
||||
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
|
||||
import org.keycloak.sessions.AuthenticationSessionModel;
|
||||
import org.keycloak.userprofile.config.DeclarativeUserProfileModel;
|
||||
|
@ -107,7 +106,7 @@ public class DeclarativeUserProfileProvider implements UserProfileProvider {
|
|||
UserModel user, UserProfileMetadata metadata) {
|
||||
|
||||
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);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -132,6 +132,7 @@ public class ServiceAccountUserProfileTest extends AbstractKeycloakTest {
|
|||
UserRepresentation representation = serviceAccount.toRepresentation();
|
||||
String username = representation.getUsername();
|
||||
|
||||
assertNotNull(username);
|
||||
assertNull(representation.getEmail());
|
||||
|
||||
serviceAccount.update(representation);
|
||||
|
@ -158,6 +159,10 @@ public class ServiceAccountUserProfileTest extends AbstractKeycloakTest {
|
|||
representation = serviceAccount.toRepresentation();
|
||||
assertFalse(representation.getAttributes().isEmpty());
|
||||
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
|
||||
|
|
Loading…
Reference in a new issue