Warnings for temporary admin user and service account (#31387)

* UI banner, labels and log messages are shown when temporary admin account is used
* added UI tests that check the elements' presence

Signed-off-by: Peter Zaoral <pzaoral@redhat.com>
Co-authored-by: Václav Muzikář <vaclav@muzikari.cz>
This commit is contained in:
Peter Zaoral 2024-08-21 09:30:24 +02:00 committed by GitHub
parent 4675a4eda9
commit 1b5fe5437a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 175 additions and 16 deletions

View file

@ -258,6 +258,8 @@ jobs:
env: env:
KC_BOOTSTRAP_ADMIN_USERNAME: admin KC_BOOTSTRAP_ADMIN_USERNAME: admin
KC_BOOTSTRAP_ADMIN_PASSWORD: admin KC_BOOTSTRAP_ADMIN_PASSWORD: admin
KC_BOOTSTRAP_ADMIN_CLIENT_ID: temporary-admin-service
KC_BOOTSTRAP_ADMIN_CLIENT_SECRET: temporary-admin-service
- name: Start LDAP server - name: Start LDAP server
run: pnpm --fail-if-no-match --filter ${{ env.WORKSPACE }} cy:ldap-server & run: pnpm --fail-if-no-match --filter ${{ env.WORKSPACE }} cy:ldap-server &

View file

@ -115,6 +115,24 @@ describe("Clients test", () => {
); );
}); });
it("Should check temporary admin service label (non)existence", () => {
commonPage.sidebar().goToRealm("master");
commonPage.sidebar().goToClients();
commonPage
.tableToolbarUtils()
.searchItem("temporary-admin-service", false);
commonPage.tableUtils().checkRowItemExists("temporary-admin-service");
commonPage
.tableUtils()
.checkTemporaryAdminLabelExists("temporary-admin-label");
commonPage.tableToolbarUtils().searchItem("admin-cli", false);
commonPage.tableUtils().checkRowItemExists("admin-cli");
commonPage
.tableUtils()
.checkTemporaryAdminLabelExists("temporary-admin-label", false);
});
it("Should list client scopes", () => { it("Should list client scopes", () => {
commonPage commonPage
.tableUtils() .tableUtils()

View file

@ -78,6 +78,22 @@ describe("User creation", () => {
masthead.checkNotificationMessage("The user has been created"); masthead.checkNotificationMessage("The user has been created");
}); });
it("Should check temporary admin user existence", () => {
const commonPage = new CommonPage();
// check banner visibility first
cy.get(".pf-v5-c-banner").should(
"contain.text",
"You are logged in as a temporary admin user.",
);
commonPage.tableToolbarUtils().searchItem("admin", false);
commonPage.tableUtils().checkRowItemExists("admin");
commonPage
.tableUtils()
.checkTemporaryAdminLabelExists("temporary-admin-label");
});
it("Create user with groups test", () => { it("Create user with groups test", () => {
itemIdWithGroups += uuid(); itemIdWithGroups += uuid();
// Add user from search bar // Add user from search bar

View file

@ -148,6 +148,16 @@ export default class TablePage extends CommonElements {
return this; return this;
} }
checkTemporaryAdminLabelExists(labelId: string, exist = true) {
cy.get(
(this.#tableInModal ? ".pf-v5-c-modal-box.pf-m-md " : "") +
this.#tableRowItem,
)
.find(`#${labelId}`)
.should((!exist ? "not." : "") + "exist");
return this;
}
checkRowItemValueByItemName(itemName: string, column: number, value: string) { checkRowItemValueByItemName(itemName: string, column: number, value: string) {
cy.get( cy.get(
(this.#tableInModal ? ".pf-v5-c-modal-box.pf-m-md " : "") + (this.#tableInModal ? ".pf-v5-c-modal-box.pf-m-md " : "") +

View file

@ -3237,3 +3237,6 @@ userInvitedOrganization_one=Invite to user sent
userInvitedOrganizationError=Could not invite user to the organizations\: {{error}} userInvitedOrganizationError=Could not invite user to the organizations\: {{error}}
userInvitedOrganization_other={{count}} invites to users sent userInvitedOrganization_other={{count}} invites to users sent
sentInvitation=Sent invitation sentInvitation=Sent invitation
loggedInAsTempAdminUser=You are logged in as a temporary admin user. To harden security, create a permanent admin account and delete the temporary one.
temporaryAdmin=Temporary admin user account. Ensure it is replaced with a permanent admin user account as soon as possible.
temporaryService=Temporary admin service account. Ensure it is replaced with a permanent admin service account as soon as possible.

View file

@ -25,6 +25,7 @@ import { WhoAmIContextProvider } from "./context/whoami/WhoAmI";
import type { Environment } from "./environment"; import type { Environment } from "./environment";
import { SubGroups } from "./groups/SubGroupsContext"; import { SubGroups } from "./groups/SubGroupsContext";
import { AuthWall } from "./root/AuthWall"; import { AuthWall } from "./root/AuthWall";
import { Banners } from "./Banners";
const AppContexts = ({ children }: PropsWithChildren) => ( const AppContexts = ({ children }: PropsWithChildren) => (
<ErrorBoundaryProvider> <ErrorBoundaryProvider>
@ -65,6 +66,7 @@ export const App = () => {
breadcrumb={<PageBreadCrumbs />} breadcrumb={<PageBreadCrumbs />}
mainContainerId={mainPageContentId} mainContainerId={mainPageContentId}
> >
<Banners />
<ErrorBoundaryFallback fallback={ErrorRenderer}> <ErrorBoundaryFallback fallback={ErrorRenderer}>
<Suspense fallback={<KeycloakSpinner />}> <Suspense fallback={<KeycloakSpinner />}>
<AuthWall> <AuthWall>

View file

@ -0,0 +1,26 @@
import { Banner, Flex, FlexItem } from "@patternfly/react-core";
import { ExclamationTriangleIcon } from "@patternfly/react-icons";
import { useWhoAmI } from "./context/whoami/WhoAmI";
import { useTranslation } from "react-i18next";
const WarnBanner = (msg: string) => {
const { t } = useTranslation();
return (
<Banner screenReaderText={t(msg)} variant="gold" isSticky>
<Flex spaceItems={{ default: "spaceItemsSm" }}>
<FlexItem>
<ExclamationTriangleIcon />
</FlexItem>
<FlexItem>{t(msg)}</FlexItem>
</Flex>
</Banner>
);
};
export const Banners = () => {
const { whoAmI } = useWhoAmI();
if (whoAmI.isTemporary()) return WarnBanner("loggedInAsTempAdminUser");
// more banners in the future?
};

View file

@ -10,7 +10,9 @@ import {
Tab, Tab,
TabTitleText, TabTitleText,
ToolbarItem, ToolbarItem,
Tooltip,
} from "@patternfly/react-core"; } from "@patternfly/react-core";
import { WarningTriangleIcon } from "@patternfly/react-icons";
import { IRowData, TableText, cellWidth } from "@patternfly/react-table"; import { IRowData, TableText, cellWidth } from "@patternfly/react-table";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -58,6 +60,14 @@ const ClientDetailLink = (client: ClientRepresentation) => {
</Badge> </Badge>
)} )}
</Link> </Link>
{client.attributes?.["is_temporary_admin"] === "true" && (
<Tooltip content={t("temporaryService")}>
<WarningTriangleIcon
className="pf-v5-u-ml-sm"
id="temporary-admin-label"
/>
</Tooltip>
)}
</TableText> </TableText>
); );
}; };

View file

@ -48,11 +48,23 @@ export type UserAttribute = {
}; };
const UserDetailLink = (user: BruteUser) => { const UserDetailLink = (user: BruteUser) => {
const { t } = useTranslation();
const { realm } = useRealm(); const { realm } = useRealm();
return ( return (
<>
<Link to={toUser({ realm, id: user.id!, tab: "settings" })}> <Link to={toUser({ realm, id: user.id!, tab: "settings" })}>
{user.username} <StatusRow user={user} /> {user.username}
<StatusRow user={user} />
</Link> </Link>
{user.attributes?.["is_temporary_admin"][0] === "true" && (
<Tooltip content={t("temporaryAdmin")}>
<WarningTriangleIcon
className="pf-v5-u-ml-sm"
id="temporary-admin-label"
/>
</Tooltip>
)}
</>
); );
}; };

View file

@ -75,6 +75,10 @@ export class WhoAmI {
return this.#me.realm_access; return this.#me.realm_access;
} }
public isTemporary(): boolean {
return this.#me?.temporary ?? false;
}
} }
type WhoAmIProps = { type WhoAmIProps = {

View file

@ -16,6 +16,8 @@ const LOCAL_DIST_NAME = "keycloak-999.0.0-SNAPSHOT.tar.gz";
const SCRIPT_EXTENSION = process.platform === "win32" ? ".bat" : ".sh"; const SCRIPT_EXTENSION = process.platform === "win32" ? ".bat" : ".sh";
const ADMIN_USERNAME = "admin"; const ADMIN_USERNAME = "admin";
const ADMIN_PASSWORD = "admin"; const ADMIN_PASSWORD = "admin";
const CLIENT_ID = "temporary-admin-service";
const CLIENT_SECRET = "temporary-admin-service";
const options = { const options = {
local: { local: {
@ -39,6 +41,8 @@ async function startServer() {
const env = { const env = {
KC_BOOTSTRAP_ADMIN_USERNAME: ADMIN_USERNAME, KC_BOOTSTRAP_ADMIN_USERNAME: ADMIN_USERNAME,
KC_BOOTSTRAP_ADMIN_PASSWORD: ADMIN_PASSWORD, KC_BOOTSTRAP_ADMIN_PASSWORD: ADMIN_PASSWORD,
KC_BOOTSTRAP_ADMIN_CLIENT_ID: CLIENT_ID,
KC_BOOTSTRAP_ADMIN_CLIENT_SECRET: CLIENT_SECRET,
...process.env, ...process.env,
}; };

View file

@ -32,4 +32,5 @@ export default interface WhoAmIRepresentation {
locale: string; locale: string;
createRealm: boolean; createRealm: boolean;
realm_access: { [key: string]: AccessType[] }; realm_access: { [key: string]: AccessType[] };
temporary: boolean;
} }

View file

@ -184,4 +184,7 @@ public final class Constants {
//attribute name used to mark a client as realm client //attribute name used to mark a client as realm client
public static final String REALM_CLIENT = "realm_client"; public static final String REALM_CLIENT = "realm_client";
//attribute name used to mark a temporary admin user/service account as temporary
public static final String IS_TEMP_ADMIN_ATTR_NAME = "is_temporary_admin";
} }

View file

@ -68,6 +68,7 @@ import java.util.function.Function;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.stream.Stream; import java.util.stream.Stream;
import static org.keycloak.models.light.LightweightUserAdapter.isLightweightUser; import static org.keycloak.models.light.LightweightUserAdapter.isLightweightUser;
import static org.keycloak.models.Constants.IS_TEMP_ADMIN_ATTR_NAME;
/** /**
* @author <a href="mailto:bill@burkecentral.com">Bill Burke</a> * @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
@ -263,10 +264,21 @@ public class ModelToRepresentation {
rep.setEnabled(user.isEnabled()); rep.setEnabled(user.isEnabled());
rep.setEmailVerified(user.isEmailVerified()); rep.setEmailVerified(user.isEmailVerified());
rep.setFederationLink(user.getFederationLink()); rep.setFederationLink(user.getFederationLink());
addAttributeToBriefRep(user, rep, IS_TEMP_ADMIN_ATTR_NAME);
return rep; return rep;
} }
private static void addAttributeToBriefRep(UserModel user, UserRepresentation userRep, String attributeName) {
String userAttributeValue = user.getFirstAttribute(attributeName);
if (userAttributeValue != null) {
if (userRep.getAttributes() == null) {
userRep.setAttributes(new HashMap<>());
}
userRep.getAttributes().put(attributeName, Collections.singletonList(userAttributeValue));
}
}
public static EventRepresentation toRepresentation(Event event) { public static EventRepresentation toRepresentation(Event event) {
EventRepresentation rep = new EventRepresentation(); EventRepresentation rep = new EventRepresentation();
rep.setTime(event.getTime()); rep.setTime(event.getTime());

View file

@ -18,6 +18,7 @@
package org.keycloak.events.log; package org.keycloak.events.log;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
import org.keycloak.Config;
import org.keycloak.common.util.StackUtil; import org.keycloak.common.util.StackUtil;
import org.keycloak.events.Event; import org.keycloak.events.Event;
import org.keycloak.events.EventListenerProvider; import org.keycloak.events.EventListenerProvider;
@ -25,6 +26,7 @@ import org.keycloak.events.EventListenerTransaction;
import org.keycloak.events.admin.AdminEvent; import org.keycloak.events.admin.AdminEvent;
import org.keycloak.models.KeycloakContext; import org.keycloak.models.KeycloakContext;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.sessions.AuthenticationSessionModel; import org.keycloak.sessions.AuthenticationSessionModel;
import org.keycloak.utils.StringUtil; import org.keycloak.utils.StringUtil;
@ -32,6 +34,9 @@ import jakarta.ws.rs.core.Cookie;
import jakarta.ws.rs.core.HttpHeaders; import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.UriInfo; import jakarta.ws.rs.core.UriInfo;
import java.util.Map; import java.util.Map;
import java.util.function.Supplier;
import static org.keycloak.models.Constants.IS_TEMP_ADMIN_ATTR_NAME;
/** /**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a> * @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
@ -135,6 +140,24 @@ public class JBossLoggingEventListenerProvider implements EventListenerProvider
logger.log(logger.isTraceEnabled() ? Logger.Level.TRACE : level, sb.toString()); logger.log(logger.isTraceEnabled() ? Logger.Level.TRACE : level, sb.toString());
} }
if (event.getRealmName().equals(Config.getAdminRealm())) {
Supplier<RealmModel> getRealm = () -> session.realms().getRealm(event.getRealmId());
switch (event.getType()) {
case LOGIN:
var user = session.users().getUserById(getRealm.get(), event.getUserId());
if (Boolean.parseBoolean(user.getFirstAttribute(IS_TEMP_ADMIN_ATTR_NAME))) {
logger.warn(user.getUsername() + " is a temporary admin user account. To harden security, create a permanent account and delete the temporary one.");
}
break;
case CLIENT_LOGIN:
var client = session.clients().getClientByClientId(getRealm.get(), event.getClientId());
if (Boolean.parseBoolean(client.getAttribute(IS_TEMP_ADMIN_ATTR_NAME))) {
logger.warn(client.getClientId() + " is a temporary admin service account. To harden security, create a permanent account and delete the temporary one.");
}
break;
}
}
} }
private void logAdminEvent(AdminEvent adminEvent, boolean includeRepresentation) { private void logAdminEvent(AdminEvent adminEvent, boolean includeRepresentation) {

View file

@ -37,6 +37,8 @@ import org.keycloak.services.ServicesLogger;
import org.keycloak.userprofile.UserProfileProvider; import org.keycloak.userprofile.UserProfileProvider;
import org.keycloak.utils.StringUtil; import org.keycloak.utils.StringUtil;
import static org.keycloak.models.Constants.IS_TEMP_ADMIN_ATTR_NAME;
/** /**
* @author <a href="mailto:bill@burkecentral.com">Bill Burke</a> * @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
* @version $Revision: 1 $ * @version $Revision: 1 $
@ -136,8 +138,7 @@ public class ApplianceBootstrap {
try { try {
UserModel adminUser = session.users().addUser(realm, username); UserModel adminUser = session.users().addUser(realm, username);
adminUser.setEnabled(true); adminUser.setEnabled(true);
// TODO: is this appropriate, does it need to be managed? adminUser.setSingleAttribute(IS_TEMP_ADMIN_ATTR_NAME, Boolean.TRUE.toString());
// adminUser.setSingleAttribute("temporary_admin", Boolean.TRUE.toString());
// also set the expiration - could be relative to a creation timestamp, or computed // also set the expiration - could be relative to a creation timestamp, or computed
UserCredentialModel usrCredModel = UserCredentialModel.password(password); UserCredentialModel usrCredModel = UserCredentialModel.password(password);
@ -182,7 +183,7 @@ public class ApplianceBootstrap {
RoleModel adminRole = realm.getRole(AdminRoles.ADMIN); RoleModel adminRole = realm.getRole(AdminRoles.ADMIN);
serviceAccount.grantRole(adminRole); serviceAccount.grantRole(adminRole);
// TODO: set temporary adminClientModel.setAttribute(IS_TEMP_ADMIN_ATTR_NAME, Boolean.TRUE.toString());
// also set the expiration - could be relative to a creation timestamp, or computed // also set the expiration - could be relative to a creation timestamp, or computed
ServicesLogger.LOGGER.createdTemporaryAdminService(clientId); ServicesLogger.LOGGER.createdTemporaryAdminService(clientId);

View file

@ -68,6 +68,8 @@ import java.util.Properties;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import static org.keycloak.models.Constants.IS_TEMP_ADMIN_ATTR_NAME;
/** /**
* @author <a href="mailto:bill@burkecentral.com">Bill Burke</a> * @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
* @version $Revision: 1 $ * @version $Revision: 1 $
@ -98,6 +100,7 @@ public class AdminConsole {
protected String realm; protected String realm;
protected String displayName; protected String displayName;
protected Locale locale; protected Locale locale;
protected boolean isTemporary;
@JsonProperty("createRealm") @JsonProperty("createRealm")
protected boolean createRealm; protected boolean createRealm;
@ -107,13 +110,14 @@ public class AdminConsole {
public WhoAmI() { public WhoAmI() {
} }
public WhoAmI(String userId, String realm, String displayName, boolean createRealm, Map<String, Set<String>> realmAccess, Locale locale) { public WhoAmI(String userId, String realm, String displayName, boolean createRealm, Map<String, Set<String>> realmAccess, Locale locale, boolean isTemporary) {
this.userId = userId; this.userId = userId;
this.realm = realm; this.realm = realm;
this.displayName = displayName; this.displayName = displayName;
this.createRealm = createRealm; this.createRealm = createRealm;
this.realmAccess = realmAccess; this.realmAccess = realmAccess;
this.locale = locale; this.locale = locale;
this.isTemporary = isTemporary;
} }
public String getUserId() { public String getUserId() {
@ -168,6 +172,14 @@ public class AdminConsole {
public String getLocaleLanguageTag() { public String getLocaleLanguageTag() {
return locale != null ? locale.toLanguageTag() : null; return locale != null ? locale.toLanguageTag() : null;
} }
public boolean isTemporary() {
return isTemporary;
}
public void setTemporary(boolean temporary) {
isTemporary = temporary;
}
} }
/** /**
@ -269,7 +281,7 @@ public class AdminConsole {
.allowedOrigins(authResult.getToken()) .allowedOrigins(authResult.getToken())
.allowedMethods("GET") .allowedMethods("GET")
.auth() .auth()
.add(Response.ok(new WhoAmI(user.getId(), realm.getName(), displayName, createRealm, realmAccess, locale))); .add(Response.ok(new WhoAmI(user.getId(), realm.getName(), displayName, createRealm, realmAccess, locale, Boolean.parseBoolean(user.getFirstAttribute(IS_TEMP_ADMIN_ATTR_NAME)))));
} }
private void addRealmAccess(RealmModel realm, UserModel user, Map<String, Set<String>> realmAdminAccess) { private void addRealmAccess(RealmModel realm, UserModel user, Map<String, Set<String>> realmAdminAccess) {