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:
parent
4675a4eda9
commit
1b5fe5437a
17 changed files with 175 additions and 16 deletions
2
.github/workflows/js-ci.yml
vendored
2
.github/workflows/js-ci.yml
vendored
|
@ -258,6 +258,8 @@ jobs:
|
|||
env:
|
||||
KC_BOOTSTRAP_ADMIN_USERNAME: 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
|
||||
run: pnpm --fail-if-no-match --filter ${{ env.WORKSPACE }} cy:ldap-server &
|
||||
|
|
|
@ -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", () => {
|
||||
commonPage
|
||||
.tableUtils()
|
||||
|
|
|
@ -78,6 +78,22 @@ describe("User creation", () => {
|
|||
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", () => {
|
||||
itemIdWithGroups += uuid();
|
||||
// Add user from search bar
|
||||
|
|
|
@ -148,6 +148,16 @@ export default class TablePage extends CommonElements {
|
|||
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) {
|
||||
cy.get(
|
||||
(this.#tableInModal ? ".pf-v5-c-modal-box.pf-m-md " : "") +
|
||||
|
|
|
@ -3237,3 +3237,6 @@ userInvitedOrganization_one=Invite to user sent
|
|||
userInvitedOrganizationError=Could not invite user to the organizations\: {{error}}
|
||||
userInvitedOrganization_other={{count}} invites to users sent
|
||||
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.
|
|
@ -25,6 +25,7 @@ import { WhoAmIContextProvider } from "./context/whoami/WhoAmI";
|
|||
import type { Environment } from "./environment";
|
||||
import { SubGroups } from "./groups/SubGroupsContext";
|
||||
import { AuthWall } from "./root/AuthWall";
|
||||
import { Banners } from "./Banners";
|
||||
|
||||
const AppContexts = ({ children }: PropsWithChildren) => (
|
||||
<ErrorBoundaryProvider>
|
||||
|
@ -65,6 +66,7 @@ export const App = () => {
|
|||
breadcrumb={<PageBreadCrumbs />}
|
||||
mainContainerId={mainPageContentId}
|
||||
>
|
||||
<Banners />
|
||||
<ErrorBoundaryFallback fallback={ErrorRenderer}>
|
||||
<Suspense fallback={<KeycloakSpinner />}>
|
||||
<AuthWall>
|
||||
|
|
26
js/apps/admin-ui/src/Banners.tsx
Normal file
26
js/apps/admin-ui/src/Banners.tsx
Normal 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?
|
||||
};
|
|
@ -10,7 +10,9 @@ import {
|
|||
Tab,
|
||||
TabTitleText,
|
||||
ToolbarItem,
|
||||
Tooltip,
|
||||
} from "@patternfly/react-core";
|
||||
import { WarningTriangleIcon } from "@patternfly/react-icons";
|
||||
import { IRowData, TableText, cellWidth } from "@patternfly/react-table";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
@ -58,6 +60,14 @@ const ClientDetailLink = (client: ClientRepresentation) => {
|
|||
</Badge>
|
||||
)}
|
||||
</Link>
|
||||
{client.attributes?.["is_temporary_admin"] === "true" && (
|
||||
<Tooltip content={t("temporaryService")}>
|
||||
<WarningTriangleIcon
|
||||
className="pf-v5-u-ml-sm"
|
||||
id="temporary-admin-label"
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</TableText>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -48,11 +48,23 @@ export type UserAttribute = {
|
|||
};
|
||||
|
||||
const UserDetailLink = (user: BruteUser) => {
|
||||
const { t } = useTranslation();
|
||||
const { realm } = useRealm();
|
||||
return (
|
||||
<Link to={toUser({ realm, id: user.id!, tab: "settings" })}>
|
||||
{user.username} <StatusRow user={user} />
|
||||
</Link>
|
||||
<>
|
||||
<Link to={toUser({ realm, id: user.id!, tab: "settings" })}>
|
||||
{user.username}
|
||||
<StatusRow user={user} />
|
||||
</Link>
|
||||
{user.attributes?.["is_temporary_admin"][0] === "true" && (
|
||||
<Tooltip content={t("temporaryAdmin")}>
|
||||
<WarningTriangleIcon
|
||||
className="pf-v5-u-ml-sm"
|
||||
id="temporary-admin-label"
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -75,6 +75,10 @@ export class WhoAmI {
|
|||
|
||||
return this.#me.realm_access;
|
||||
}
|
||||
|
||||
public isTemporary(): boolean {
|
||||
return this.#me?.temporary ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
type WhoAmIProps = {
|
||||
|
|
|
@ -16,6 +16,8 @@ const LOCAL_DIST_NAME = "keycloak-999.0.0-SNAPSHOT.tar.gz";
|
|||
const SCRIPT_EXTENSION = process.platform === "win32" ? ".bat" : ".sh";
|
||||
const ADMIN_USERNAME = "admin";
|
||||
const ADMIN_PASSWORD = "admin";
|
||||
const CLIENT_ID = "temporary-admin-service";
|
||||
const CLIENT_SECRET = "temporary-admin-service";
|
||||
|
||||
const options = {
|
||||
local: {
|
||||
|
@ -39,6 +41,8 @@ async function startServer() {
|
|||
const env = {
|
||||
KC_BOOTSTRAP_ADMIN_USERNAME: ADMIN_USERNAME,
|
||||
KC_BOOTSTRAP_ADMIN_PASSWORD: ADMIN_PASSWORD,
|
||||
KC_BOOTSTRAP_ADMIN_CLIENT_ID: CLIENT_ID,
|
||||
KC_BOOTSTRAP_ADMIN_CLIENT_SECRET: CLIENT_SECRET,
|
||||
...process.env,
|
||||
};
|
||||
|
||||
|
|
|
@ -32,4 +32,5 @@ export default interface WhoAmIRepresentation {
|
|||
locale: string;
|
||||
createRealm: boolean;
|
||||
realm_access: { [key: string]: AccessType[] };
|
||||
temporary: boolean;
|
||||
}
|
||||
|
|
|
@ -184,4 +184,7 @@ public final class Constants {
|
|||
|
||||
//attribute name used to mark a client as 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";
|
||||
}
|
||||
|
|
|
@ -68,6 +68,7 @@ import java.util.function.Function;
|
|||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
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>
|
||||
|
@ -263,10 +264,21 @@ public class ModelToRepresentation {
|
|||
rep.setEnabled(user.isEnabled());
|
||||
rep.setEmailVerified(user.isEmailVerified());
|
||||
rep.setFederationLink(user.getFederationLink());
|
||||
addAttributeToBriefRep(user, rep, IS_TEMP_ADMIN_ATTR_NAME);
|
||||
|
||||
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) {
|
||||
EventRepresentation rep = new EventRepresentation();
|
||||
rep.setTime(event.getTime());
|
||||
|
|
|
@ -18,6 +18,7 @@
|
|||
package org.keycloak.events.log;
|
||||
|
||||
import org.jboss.logging.Logger;
|
||||
import org.keycloak.Config;
|
||||
import org.keycloak.common.util.StackUtil;
|
||||
import org.keycloak.events.Event;
|
||||
import org.keycloak.events.EventListenerProvider;
|
||||
|
@ -25,6 +26,7 @@ import org.keycloak.events.EventListenerTransaction;
|
|||
import org.keycloak.events.admin.AdminEvent;
|
||||
import org.keycloak.models.KeycloakContext;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.sessions.AuthenticationSessionModel;
|
||||
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.UriInfo;
|
||||
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>
|
||||
|
@ -135,6 +140,24 @@ public class JBossLoggingEventListenerProvider implements EventListenerProvider
|
|||
|
||||
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) {
|
||||
|
|
|
@ -37,6 +37,8 @@ import org.keycloak.services.ServicesLogger;
|
|||
import org.keycloak.userprofile.UserProfileProvider;
|
||||
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>
|
||||
* @version $Revision: 1 $
|
||||
|
@ -136,8 +138,7 @@ public class ApplianceBootstrap {
|
|||
try {
|
||||
UserModel adminUser = session.users().addUser(realm, username);
|
||||
adminUser.setEnabled(true);
|
||||
// TODO: is this appropriate, does it need to be managed?
|
||||
// adminUser.setSingleAttribute("temporary_admin", Boolean.TRUE.toString());
|
||||
adminUser.setSingleAttribute(IS_TEMP_ADMIN_ATTR_NAME, Boolean.TRUE.toString());
|
||||
// also set the expiration - could be relative to a creation timestamp, or computed
|
||||
|
||||
UserCredentialModel usrCredModel = UserCredentialModel.password(password);
|
||||
|
@ -182,7 +183,7 @@ public class ApplianceBootstrap {
|
|||
RoleModel adminRole = realm.getRole(AdminRoles.ADMIN);
|
||||
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
|
||||
|
||||
ServicesLogger.LOGGER.createdTemporaryAdminService(clientId);
|
||||
|
|
|
@ -68,6 +68,8 @@ import java.util.Properties;
|
|||
import java.util.Set;
|
||||
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>
|
||||
* @version $Revision: 1 $
|
||||
|
@ -98,6 +100,7 @@ public class AdminConsole {
|
|||
protected String realm;
|
||||
protected String displayName;
|
||||
protected Locale locale;
|
||||
protected boolean isTemporary;
|
||||
|
||||
@JsonProperty("createRealm")
|
||||
protected boolean createRealm;
|
||||
|
@ -107,13 +110,14 @@ public class AdminConsole {
|
|||
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.realm = realm;
|
||||
this.displayName = displayName;
|
||||
this.createRealm = createRealm;
|
||||
this.realmAccess = realmAccess;
|
||||
this.locale = locale;
|
||||
this.isTemporary = isTemporary;
|
||||
}
|
||||
|
||||
public String getUserId() {
|
||||
|
@ -168,6 +172,14 @@ public class AdminConsole {
|
|||
public String getLocaleLanguageTag() {
|
||||
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())
|
||||
.allowedMethods("GET")
|
||||
.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) {
|
||||
|
|
Loading…
Reference in a new issue