View groups from account console (#7933)

Closes #8748
This commit is contained in:
cgeorgilakis 2022-09-07 12:25:31 +03:00 committed by GitHub
parent 1f197aa96b
commit 07b0df8f62
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 292 additions and 7 deletions

View file

@ -0,0 +1,41 @@
package org.keycloak.migration.migrators;
import org.keycloak.migration.ModelVersion;
import org.keycloak.models.AccountRoles;
import org.keycloak.models.ClientModel;
import org.keycloak.models.Constants;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.RoleModel;
import org.keycloak.representations.idm.RealmRepresentation;
public class MigrateTo20_0_0 implements Migration {
public static final ModelVersion VERSION = new ModelVersion("20.0.0");
@Override
public void migrate(KeycloakSession session) {
session.realms().getRealmsStream().forEach(this::addViewGroupsRole);
}
@Override
public void migrateImport(KeycloakSession session, RealmModel realm, RealmRepresentation rep, boolean skipUserDependent) {
addViewGroupsRole(realm);
}
private void addViewGroupsRole(RealmModel realm) {
ClientModel accountClient = realm.getClientByClientId(Constants.ACCOUNT_MANAGEMENT_CLIENT_ID);
if (accountClient != null && accountClient.getRole(AccountRoles.VIEW_GROUPS) == null) {
RoleModel viewGroupsRole = accountClient.addRole(AccountRoles.VIEW_GROUPS);
viewGroupsRole.setDescription("${role_" + AccountRoles.VIEW_GROUPS + "}");
ClientModel accountConsoleClient = realm.getClientByClientId(Constants.ACCOUNT_CONSOLE_CLIENT_ID);
accountConsoleClient.addScopeMapping(viewGroupsRole);
}
}
@Override
public ModelVersion getVersion() {
return VERSION;
}
}

View file

@ -24,6 +24,7 @@ import org.keycloak.migration.ModelVersion;
import org.keycloak.migration.migrators.MigrateTo12_0_0;
import org.keycloak.migration.migrators.MigrateTo14_0_0;
import org.keycloak.migration.migrators.MigrateTo18_0_0;
import org.keycloak.migration.migrators.MigrateTo20_0_0;
import org.keycloak.migration.migrators.MigrateTo1_2_0;
import org.keycloak.migration.migrators.MigrateTo1_3_0;
import org.keycloak.migration.migrators.MigrateTo1_4_0;
@ -104,7 +105,8 @@ public class LegacyMigrationManager implements MigrationManager {
new MigrateTo9_0_4(),
new MigrateTo12_0_0(),
new MigrateTo14_0_0(),
new MigrateTo18_0_0()
new MigrateTo18_0_0(),
new MigrateTo20_0_0()
};
private final KeycloakSession session;

View file

@ -29,6 +29,7 @@ public interface AccountRoles {
String VIEW_CONSENT = "view-consent";
String MANAGE_CONSENT = "manage-consent";
String DELETE_ACCOUNT = "delete-account";
String VIEW_GROUPS = "view-groups";
String[] DEFAULT = {VIEW_PROFILE, MANAGE_ACCOUNT};

View file

@ -438,6 +438,8 @@ public class RealmManager {
RoleModel manageConsentRole = accountClient.addRole(AccountRoles.MANAGE_CONSENT);
manageConsentRole.setDescription("${role_" + AccountRoles.MANAGE_CONSENT + "}");
manageConsentRole.addCompositeRole(viewConsentRole);
RoleModel viewGroups = accountClient.addRole(AccountRoles.VIEW_GROUPS);
viewGroups.setDescription("${role_" + AccountRoles.VIEW_GROUPS + "}");
KeycloakModelUtils.setupDeleteAccount(accountClient);
@ -458,6 +460,7 @@ public class RealmManager {
accountConsoleClient.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL);
accountConsoleClient.addScopeMapping(accountClient.getRole(AccountRoles.MANAGE_ACCOUNT));
accountConsoleClient.addScopeMapping(accountClient.getRole(AccountRoles.VIEW_GROUPS));
ProtocolMapperModel audienceMapper = new ProtocolMapperModel();
audienceMapper.setName(OIDCLoginProtocolFactory.AUDIENCE_RESOLVE);

View file

@ -138,15 +138,21 @@ public class AccountConsole {
boolean isTotpConfigured = false;
boolean deleteAccountAllowed = false;
boolean isViewGroupsEnabled= false;
if (user != null) {
isTotpConfigured = user.credentialManager().isConfiguredFor(realm.getOTPPolicy().getType());
RoleModel deleteAccountRole = realm.getClientByClientId(Constants.ACCOUNT_MANAGEMENT_CLIENT_ID).getRole(AccountRoles.DELETE_ACCOUNT);
deleteAccountAllowed = deleteAccountRole != null && user.hasRole(deleteAccountRole) && realm.getRequiredActionProviderByAlias(DeleteAccount.PROVIDER_ID).isEnabled();
RoleModel viewGrouRole = realm.getClientByClientId(Constants.ACCOUNT_MANAGEMENT_CLIENT_ID).getRole(AccountRoles.VIEW_GROUPS);
isViewGroupsEnabled = viewGrouRole != null && user.hasRole(viewGrouRole);
}
map.put("isTotpConfigured", isTotpConfigured);
map.put("deleteAccountAllowed", deleteAccountAllowed);
map.put("isViewGroupsEnabled", isViewGroupsEnabled);
map.put("updateEmailFeatureEnabled", Profile.isFeatureEnabled(Profile.Feature.UPDATE_EMAIL));
RequiredActionProviderModel updateEmailActionProvider = realm.getRequiredActionProviderByAlias(UserModel.RequiredAction.UPDATE_EMAIL.name());
map.put("updateEmailActionEnabled", updateEmailActionProvider != null && updateEmailActionProvider.isEnabled());

View file

@ -35,6 +35,7 @@ import java.util.stream.Stream;
import javax.ws.rs.Consumes;
import javax.ws.rs.DELETE;
import javax.ws.rs.DefaultValue;
import javax.ws.rs.GET;
import javax.ws.rs.NotFoundException;
import javax.ws.rs.POST;
@ -66,6 +67,7 @@ import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserConsentModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.utils.ModelToRepresentation;
import org.keycloak.provider.ConfiguredProvider;
import org.keycloak.representations.account.ClientRepresentation;
import org.keycloak.representations.account.ConsentRepresentation;
@ -74,6 +76,7 @@ import org.keycloak.representations.account.UserProfileAttributeMetadata;
import org.keycloak.representations.account.UserProfileMetadata;
import org.keycloak.representations.account.UserRepresentation;
import org.keycloak.representations.idm.ErrorRepresentation;
import org.keycloak.representations.idm.GroupRepresentation;
import org.keycloak.services.ErrorResponse;
import org.keycloak.services.managers.Auth;
import org.keycloak.services.managers.UserConsentManager;
@ -484,6 +487,15 @@ public class AccountRestService {
return new LinkedAccountsResource(session, request, client, auth, event, user);
}
@Path("/groups")
@GET
@NoCache
@Produces(MediaType.APPLICATION_JSON)
public Stream<GroupRepresentation> groupMemberships(@QueryParam("briefRepresentation") @DefaultValue("true") boolean briefRepresentation) {
auth.require(AccountRoles.VIEW_GROUPS);
return ModelToRepresentation.toGroupHierarchy(user, !briefRepresentation);
}
@Path("/applications")
@GET
@Produces(MediaType.APPLICATION_JSON)

View file

@ -627,7 +627,7 @@ public class ClientTest extends AbstractAdminTest {
Assert.assertNames(scopesResource.clientLevel(accountMgmtId).listAll(), AccountRoles.VIEW_PROFILE);
Assert.assertNames(scopesResource.clientLevel(accountMgmtId).listEffective(), AccountRoles.VIEW_PROFILE);
Assert.assertNames(scopesResource.clientLevel(accountMgmtId).listAvailable(), AccountRoles.MANAGE_ACCOUNT, AccountRoles.MANAGE_ACCOUNT_LINKS, AccountRoles.VIEW_APPLICATIONS, AccountRoles.VIEW_CONSENT, AccountRoles.MANAGE_CONSENT, AccountRoles.DELETE_ACCOUNT);
Assert.assertNames(scopesResource.clientLevel(accountMgmtId).listAvailable(), AccountRoles.MANAGE_ACCOUNT, AccountRoles.MANAGE_ACCOUNT_LINKS, AccountRoles.VIEW_APPLICATIONS, AccountRoles.VIEW_CONSENT, AccountRoles.MANAGE_CONSENT, AccountRoles.DELETE_ACCOUNT, AccountRoles.VIEW_GROUPS);
Assert.assertNames(scopesResource.getAll().getRealmMappings(), "realm-composite");
Assert.assertNames(scopesResource.getAll().getClientMappings().get(Constants.ACCOUNT_MANAGEMENT_CLIENT_ID).getMappings(), AccountRoles.VIEW_PROFILE);
@ -643,7 +643,7 @@ public class ClientTest extends AbstractAdminTest {
Assert.assertNames(scopesResource.realmLevel().listAvailable(), "offline_access", Constants.AUTHZ_UMA_AUTHORIZATION, "realm-composite", "realm-child", Constants.DEFAULT_ROLES_ROLE_PREFIX + "-" + REALM_NAME);
Assert.assertNames(scopesResource.clientLevel(accountMgmtId).listAll());
Assert.assertNames(scopesResource.clientLevel(accountMgmtId).listAvailable(), AccountRoles.VIEW_PROFILE, AccountRoles.MANAGE_ACCOUNT, AccountRoles.MANAGE_ACCOUNT_LINKS, AccountRoles.VIEW_APPLICATIONS, AccountRoles.VIEW_CONSENT, AccountRoles.MANAGE_CONSENT, AccountRoles.DELETE_ACCOUNT);
Assert.assertNames(scopesResource.clientLevel(accountMgmtId).listAvailable(), AccountRoles.VIEW_PROFILE, AccountRoles.MANAGE_ACCOUNT, AccountRoles.MANAGE_ACCOUNT_LINKS, AccountRoles.VIEW_APPLICATIONS, AccountRoles.VIEW_CONSENT, AccountRoles.MANAGE_CONSENT, AccountRoles.DELETE_ACCOUNT, AccountRoles.VIEW_GROUPS);
Assert.assertNames(scopesResource.clientLevel(accountMgmtId).listEffective());
}

View file

@ -98,6 +98,7 @@ import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import static org.keycloak.models.AccountRoles.MANAGE_ACCOUNT;
import static org.keycloak.models.AccountRoles.MANAGE_ACCOUNT_LINKS;
import static org.keycloak.models.AccountRoles.VIEW_GROUPS;
import static org.keycloak.models.Constants.ACCOUNT_MANAGEMENT_CLIENT_ID;
import static org.keycloak.testsuite.Assert.assertNames;
import static org.keycloak.testsuite.auth.page.AuthRealm.MASTER;
@ -319,6 +320,12 @@ public abstract class AbstractMigrationTest extends AbstractKeycloakTest {
testPostLogoutRedirectUrisSet(migrationRealm);
}
protected void testMigrationTo20_0_0() {
testViewGroups(masterRealm);
testViewGroups(migrationRealm);
}
protected void testDeleteAccount(RealmResource realm) {
ClientRepresentation accountClient = realm.clients().findByClientId(ACCOUNT_MANAGEMENT_CLIENT_ID).get(0);
ClientResource accountResource = realm.clients().get(accountClient.getId());
@ -387,9 +394,8 @@ public abstract class AbstractMigrationTest extends AbstractKeycloakTest {
MappingsRepresentation scopes = clientResource.getScopeMappings().getAll();
assertNull(scopes.getRealmMappings());
assertEquals(1, scopes.getClientMappings().size());
assertEquals(1, scopes.getClientMappings().get(ACCOUNT_MANAGEMENT_CLIENT_ID).getMappings().size());
assertEquals(MANAGE_ACCOUNT, scopes.getClientMappings().get(ACCOUNT_MANAGEMENT_CLIENT_ID).getMappings().get(0).getName());
assertEquals(2, scopes.getClientMappings().get(ACCOUNT_MANAGEMENT_CLIENT_ID).getMappings().size());
Assert.assertNames(scopes.getClientMappings().get(ACCOUNT_MANAGEMENT_CLIENT_ID).getMappings(), MANAGE_ACCOUNT, VIEW_GROUPS);
List<ProtocolMapperRepresentation> mappers = clientResource.getProtocolMappers().getMappers();
assertEquals(1, mappers.size());
assertEquals("oidc-audience-resolve-mapper", mappers.get(0).getProtocolMapper());
@ -491,6 +497,14 @@ public abstract class AbstractMigrationTest extends AbstractKeycloakTest {
}
}
protected void testViewGroups(RealmResource realm) {
ClientRepresentation accountClient = realm.clients().findByClientId(ACCOUNT_MANAGEMENT_CLIENT_ID).get(0);
ClientResource accountResource = realm.clients().get(accountClient.getId());
RoleRepresentation viewAppRole = accountResource.roles().get(VIEW_GROUPS).toRepresentation();
assertNotNull(viewAppRole);
}
protected void testRoleManageAccountLinks(RealmResource... realms) {
log.info("testing role manage account links");
for (RealmResource realm : realms) {
@ -954,6 +968,10 @@ public abstract class AbstractMigrationTest extends AbstractKeycloakTest {
testMigrationTo19_0_0();
}
protected void testMigrationTo20_x() {
testMigrationTo20_0_0();
}
protected void testMigrationTo7_x(boolean supportedAuthzServices) {
if (supportedAuthzServices) {
testDecisionStrategySetOnResourceServer();

View file

@ -66,5 +66,6 @@ public class JsonFileImport1301MigrationClientPoliciesTest extends AbstractJsonF
Assert.assertTrue(clientProfiles.getProfiles().isEmpty());
ClientPoliciesRepresentation clientPolicies = adminClient.realms().realm("test").clientPoliciesPoliciesResource().getPolicies();
Assert.assertTrue(clientPolicies.getPolicies().isEmpty());
testViewGroups(masterRealm);
}
}

View file

@ -70,6 +70,7 @@ public class JsonFileImport198MigrationTest extends AbstractJsonFileImportMigrat
testMigrationTo9_x();
testMigrationTo12_x(false);
testMigrationTo18_x();
testMigrationTo20_x();
}
@Override

View file

@ -72,6 +72,7 @@ public class JsonFileImport255MigrationTest extends AbstractJsonFileImportMigrat
testMigrationTo9_x();
testMigrationTo12_x(false);
testMigrationTo18_x();
testMigrationTo20_x();
}
}

View file

@ -67,6 +67,7 @@ public class JsonFileImport343MigrationTest extends AbstractJsonFileImportMigrat
testMigrationTo9_x();
testMigrationTo12_x(true);
testMigrationTo18_x();
testMigrationTo20_x();
}
}

View file

@ -60,6 +60,7 @@ public class JsonFileImport483MigrationTest extends AbstractJsonFileImportMigrat
testMigrationTo9_x();
testMigrationTo12_x(true);
testMigrationTo18_x();
testMigrationTo20_x();
}
}

View file

@ -53,6 +53,7 @@ public class JsonFileImport903MigrationTest extends AbstractJsonFileImportMigrat
checkRealmsImported();
testMigrationTo12_x(true);
testMigrationTo18_x();
testMigrationTo20_x();
}
}

View file

@ -68,6 +68,8 @@ public class MigrationTest extends AbstractMigrationTest {
// Always test offline-token login during migration test
testOfflineTokenLogin();
testExtremelyLongClientAttribute(migrationRealm);
testMigrationTo20_x();
}
@Test
@ -78,6 +80,7 @@ public class MigrationTest extends AbstractMigrationTest {
testMigrationTo12_x(true);
testMigrationTo18_x();
testMigrationTo19_x();
testMigrationTo20_x();
// Always test offline-token login during migration test
testOfflineTokenLogin();
@ -97,6 +100,7 @@ public class MigrationTest extends AbstractMigrationTest {
testMigrationTo12_x(true);
testMigrationTo18_x();
testMigrationTo19_x();
testMigrationTo20_x();
// Always test offline-token login during migration test
testOfflineTokenLogin();
@ -117,6 +121,7 @@ public class MigrationTest extends AbstractMigrationTest {
testMigrationTo12_x(true);
testMigrationTo18_x();
testMigrationTo19_x();
testMigrationTo20_x();
// Always test offline-token login during migration test
testOfflineTokenLogin();
@ -145,6 +150,7 @@ public class MigrationTest extends AbstractMigrationTest {
testMigrationTo12_x(false);
testMigrationTo18_x();
testMigrationTo19_x();
testMigrationTo20_x();
// Always test offline-token login during migration test
testOfflineTokenLogin();
@ -166,6 +172,7 @@ public class MigrationTest extends AbstractMigrationTest {
testMigrationTo12_x(false);
testMigrationTo18_x();
testMigrationTo19_x();
testMigrationTo20_x();
// Always test offline-token login during migration test
testOfflineTokenLogin();

View file

@ -80,6 +80,7 @@ role_create-realm=Create realm
role_view-realm=View realm
role_view-users=View users
role_view-applications=View applications
role_view-groups=View groups
role_view-clients=View clients
role_view-events=View events
role_view-identity-providers=View identity providers

View file

@ -48,7 +48,8 @@
isTotpConfigured : ${isTotpConfigured?c},
deleteAccountAllowed : ${deleteAccountAllowed?c},
updateEmailFeatureEnabled: ${updateEmailFeatureEnabled?c},
updateEmailActionEnabled: ${updateEmailActionEnabled?c}
updateEmailActionEnabled: ${updateEmailActionEnabled?c},
isViewGroupsEnabled : ${isViewGroupsEnabled?c}
}
var availableLocales = [];

View file

@ -166,3 +166,11 @@ error-username-invalid-character=''{0}'' contains invalid character.
error-person-name-invalid-character='{0}' contains invalid character.
updateEmail=Update email
#groups
groupLabel=Groups
groupDescriptionLabel=View groups that you are associated with
path=Path
directMembership=Direct membership
noGroups=No groups
noGroupsText=You are not joined in any group

View file

@ -47,6 +47,16 @@
"modulePath": "/content/applications-page/ApplicationsPage.js",
"componentName": "ApplicationsPage"
},
{
"id": "groups",
"path": "groups",
"icon": "pf-icon-server-group",
"label": "groupLabel",
"descriptionLabel": "groupDescriptionLabel",
"modulePath": "/content/group-page/GroupsPage.js",
"componentName": "GroupsPage",
"hidden": "!features.isViewGroupsEnabled"
},
{
"id": "resources",
"icon": "pf-icon-repository",

View file

@ -0,0 +1,168 @@
import * as React from 'react';
import {
Checkbox,
DataList,
DataListItem,
DataListItemRow,
DataListCell,
DataListItemCells,
} from '@patternfly/react-core';
import { ContentPage } from '../ContentPage';
import { HttpResponse } from '../../account-service/account.service';
import { AccountServiceContext } from '../../account-service/AccountServiceContext';
import { Msg } from '../../widgets/Msg';
export interface GroupsPageProps {
}
export interface GroupsPageState {
groups: Group[];
directGroups: Group[];
isDirectMembership: boolean;
}
interface Group {
id?: string;
name: string;
path: string;
}
export class GroupsPage extends React.Component<GroupsPageProps, GroupsPageState> {
static contextType = AccountServiceContext;
context: React.ContextType<typeof AccountServiceContext>;
public constructor(props: GroupsPageProps, context: React.ContextType<typeof AccountServiceContext>) {
super(props);
this.context = context;
this.state = {
groups: [],
directGroups: [],
isDirectMembership: false
};
this.fetchGroups();
}
private fetchGroups(): void {
this.context!.doGet<Group[]>("/groups")
.then((response: HttpResponse<Group[]>) => {
const directGroups = response.data || [];
const groups = [...directGroups];
const groupsPaths = directGroups.map(s => s.path);
directGroups.forEach((el) => this.getParents(el, groups, groupsPaths))
this.setState({
groups: groups,
directGroups: directGroups
});
});
}
private getParents(el: Group, groups: Group[], groupsPaths: string[]): void {
const parentPath = el.path.slice(0, el.path.lastIndexOf('/'));
if (parentPath && (groupsPaths.indexOf(parentPath) === -1)) {
el = {
name: parentPath.slice(parentPath.lastIndexOf('/')+1),
path: parentPath
};
groups.push(el);
groupsPaths.push(parentPath);
this.getParents(el, groups, groupsPaths);
}
}
private changeDirectMembership = (checked: boolean,event: React.FormEvent<HTMLInputElement> )=> {
this.setState({
isDirectMembership: checked
});
}
private emptyGroup(): React.ReactNode {
return (
<DataListItem key='emptyItem' aria-labelledby="empty-item">
<DataListItemRow key='emptyRow'>
<DataListItemCells dataListCells={[
<DataListCell key='empty'><strong><Msg msgKey='noGroupsText' /></strong></DataListCell>
]} />
</DataListItemRow>
</DataListItem>
)
}
private renderGroupList(group: Group, appIndex: number): React.ReactNode {
return (
<DataListItem id={`${appIndex}-group`} key={'group-' + appIndex} aria-labelledby="groups-list" >
<DataListItemRow>
<DataListItemCells
dataListCells={[
<DataListCell id={`${appIndex}-group-name`} width={2} key={'name-' + appIndex}>
{group.name}
</DataListCell>,
<DataListCell id={`${appIndex}-group-path`} width={2} key={'path-' + appIndex}>
{group.path}
</DataListCell>,
<DataListCell id={`${appIndex}-group-directMembership`} width={2} key={'directMembership-' + appIndex}>
<Checkbox id={`${appIndex}-checkbox-directMembership`} isChecked={group.id != null} isDisabled={true} />
</DataListCell>
]}
/>
</DataListItemRow>
</DataListItem>
)
}
public render(): React.ReactNode {
return (
<ContentPage title={Msg.localize('groupLabel')}>
<DataList id="groups-list" aria-label={Msg.localize('groupLabel')} isCompact>
<DataListItem id="groups-list-header" aria-labelledby="Columns names">
<DataListItemRow >
<DataListItemCells
dataListCells={[
<DataListCell key='directMembership-header' >
<Checkbox
label={Msg.localize('directMembership')}
id="directMembership-checkbox"
isChecked={this.state.isDirectMembership}
onChange={this.changeDirectMembership}
/>
</DataListCell>
]}
/>
</DataListItemRow>
</DataListItem>
<DataListItem id="groups-list-header" aria-labelledby="Columns names">
<DataListItemRow >
<DataListItemCells
dataListCells={[
<DataListCell key='group-name-header' width={2}>
<strong><Msg msgKey='Name' /></strong>
</DataListCell>,
<DataListCell key='group-path-header' width={2}>
<strong><Msg msgKey='path' /></strong>
</DataListCell>,
<DataListCell key='group-direct-membership-header' width={2}>
<strong><Msg msgKey='directMembership' /></strong>
</DataListCell>,
]}
/>
</DataListItemRow>
</DataListItem>
{this.state.groups.length === 0
? this.emptyGroup()
: (this.state.isDirectMembership ? this.state.directGroups.map((group: Group, appIndex: number) =>
this.renderGroupList(group, appIndex)
) : this.state.groups.map((group: Group, appIndex: number) =>
this.renderGroupList(group, appIndex)))}
</DataList>
</ContentPage>
);
}
};

View file

@ -26,6 +26,7 @@
deleteAccountAllowed: boolean;
updateEmailFeatureEnabled: boolean;
updateEmailActionEnabled: boolean;
isViewGroupsEnabled: boolean;
}