Initial security profile SPI to integrate default client policies

Closes #27189

Signed-off-by: rmartinc <rmartinc@redhat.com>
This commit is contained in:
rmartinc 2024-04-02 13:23:33 +02:00 committed by Marek Posolda
parent c76cbc94d8
commit 41b706bb6a
33 changed files with 1274 additions and 277 deletions

View file

@ -30,6 +30,7 @@ import org.keycloak.util.JsonSerialization;
*/ */
public class ClientPoliciesRepresentation { public class ClientPoliciesRepresentation {
protected List<ClientPolicyRepresentation> policies = new ArrayList<>(); protected List<ClientPolicyRepresentation> policies = new ArrayList<>();
private List<ClientPolicyRepresentation> globalPolicies;
public List<ClientPolicyRepresentation> getPolicies() { public List<ClientPolicyRepresentation> getPolicies() {
return policies; return policies;
@ -39,6 +40,14 @@ public class ClientPoliciesRepresentation {
this.policies = policies; this.policies = policies;
} }
public List<ClientPolicyRepresentation> getGlobalPolicies() {
return globalPolicies;
}
public void setGlobalPolicies(List<ClientPolicyRepresentation> globalPolicies) {
this.globalPolicies = globalPolicies;
}
@Override @Override
public int hashCode() { public int hashCode() {
return JsonSerialization.mapper.convertValue(this, JsonNode.class).hashCode(); return JsonSerialization.mapper.convertValue(this, JsonNode.class).hashCode();

View file

@ -0,0 +1,80 @@
/*
* Copyright 2024 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.representations.idm;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
/**
* Default configuration for security profile. For the moment just a name and pointers
* to default global client profiles and policies.
*
* @author rmartinc
*/
public class SecurityProfileConfiguration {
private String name;
@JsonProperty("client-profiles")
private String clientProfiles;
@JsonProperty("client-policies")
private String clientPolicies;
@JsonIgnore
private List<ClientProfileRepresentation> defaultClientProfiles;
@JsonIgnore
private List<ClientPolicyRepresentation> defaultClientPolicies;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getClientProfiles() {
return clientProfiles;
}
public void setClientProfiles(String clientProfiles) {
this.clientProfiles = clientProfiles;
}
public String getClientPolicies() {
return clientPolicies;
}
public void setClientPolicies(String clientPolicies) {
this.clientPolicies = clientPolicies;
}
public List<ClientProfileRepresentation> getDefaultClientProfiles() {
return defaultClientProfiles;
}
public void setDefaultClientProfiles(List<ClientProfileRepresentation> defaultClientProfiles) {
this.defaultClientProfiles = defaultClientProfiles;
}
public List<ClientPolicyRepresentation> getDefaultClientPolicies() {
return defaultClientPolicies;
}
public void setDefaultClientPolicies(List<ClientPolicyRepresentation> defaultClientPolicies) {
this.defaultClientPolicies = defaultClientPolicies;
}
}

View file

@ -4,6 +4,7 @@ import javax.ws.rs.Consumes;
import javax.ws.rs.GET; import javax.ws.rs.GET;
import javax.ws.rs.PUT; import javax.ws.rs.PUT;
import javax.ws.rs.Produces; import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.MediaType; import javax.ws.rs.core.MediaType;
import org.keycloak.representations.idm.ClientPoliciesRepresentation; import org.keycloak.representations.idm.ClientPoliciesRepresentation;
@ -17,6 +18,10 @@ public interface ClientPoliciesPoliciesResource {
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
ClientPoliciesRepresentation getPolicies(); ClientPoliciesRepresentation getPolicies();
@GET
@Produces(MediaType.APPLICATION_JSON)
ClientPoliciesRepresentation getPolicies(@QueryParam("include-global-policies") Boolean includeGlobalPolicies);
@PUT @PUT
@Consumes(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON)
void updatePolicies(final ClientPoliciesRepresentation clientPolicies); void updatePolicies(final ClientPoliciesRepresentation clientPolicies);

View file

@ -1586,6 +1586,7 @@ times.minutes=Minutes
disableUserInfo=Disable user info disableUserInfo=Disable user info
authorizationEncryptedResponseEnc=Authorization response encryption content encryption algorithm authorizationEncryptedResponseEnc=Authorization response encryption content encryption algorithm
editCondition=Edit condition editCondition=Edit condition
viewCondition=View condition
ssoSessionMaxRememberMe=Max time before a session is expired when a user has set the remember me option. Tokens and browser sessions are invalidated when a session is expired. If not set it uses the standard SSO Session Max value. ssoSessionMaxRememberMe=Max time before a session is expired when a user has set the remember me option. Tokens and browser sessions are invalidated when a session is expired. If not set it uses the standard SSO Session Max value.
forcePostBinding=Force POST binding forcePostBinding=Force POST binding
usersExplain=Users are the users in the current realm. usersExplain=Users are the users in the current realm.

View file

@ -14,6 +14,7 @@ import {
Flex, Flex,
FlexItem, FlexItem,
FormGroup, FormGroup,
Label,
PageSection, PageSection,
Text, Text,
TextVariants, TextVariants,
@ -67,7 +68,12 @@ export default function NewClientPolicy() {
const { t } = useTranslation(); const { t } = useTranslation();
const { realm } = useRealm(); const { realm } = useRealm();
const { addAlert, addError } = useAlerts(); const { addAlert, addError } = useAlerts();
const [isGlobalPolicy, setIsGlobalPolicy] = useState(false);
const [policies, setPolicies] = useState<ClientPolicyRepresentation[]>(); const [policies, setPolicies] = useState<ClientPolicyRepresentation[]>();
const [globalPolicies, setGlobalPolicies] =
useState<ClientPolicyRepresentation[]>();
const [allPolicies, setAllPolicies] =
useState<ClientPolicyRepresentation[]>();
const [clientProfiles, setClientProfiles] = useState< const [clientProfiles, setClientProfiles] = useState<
ClientProfileRepresentation[] ClientProfileRepresentation[]
>([]); >([]);
@ -101,7 +107,9 @@ export default function NewClientPolicy() {
useFetch( useFetch(
async () => { async () => {
const [policies, profiles] = await Promise.all([ const [policies, profiles] = await Promise.all([
adminClient.clientPolicies.listPolicies(), adminClient.clientPolicies.listPolicies({
includeGlobalPolicies: true,
}),
adminClient.clientPolicies.listProfiles({ adminClient.clientPolicies.listProfiles({
includeGlobalProfiles: true, includeGlobalProfiles: true,
}), }),
@ -110,16 +118,29 @@ export default function NewClientPolicy() {
return { policies, profiles }; return { policies, profiles };
}, },
({ policies, profiles }) => { ({ policies, profiles }) => {
const currentPolicy = policies.policies?.find( let currentPolicy = policies.policies?.find(
(item) => item.name === policyName, (item) => item.name === policyName,
); );
if (currentPolicy === undefined) {
currentPolicy = policies.globalPolicies?.find(
(item) => item.name === policyName,
);
setIsGlobalPolicy(currentPolicy !== undefined);
}
const allClientProfiles = [ const allClientProfiles = [
...(profiles.globalProfiles ?? []), ...(profiles.globalProfiles ?? []),
...(profiles.profiles ?? []), ...(profiles.profiles ?? []),
]; ];
const allClientPolicies = [
...(policies.globalPolicies ?? []),
...(policies.policies ?? []),
];
setPolicies(policies.policies ?? []); setPolicies(policies.policies ?? []);
setGlobalPolicies(policies.globalPolicies ?? []);
setAllPolicies(allClientPolicies);
if (currentPolicy) { if (currentPolicy) {
setupForm(currentPolicy); setupForm(currentPolicy);
setClientProfiles(allClientProfiles); setClientProfiles(allClientProfiles);
@ -134,7 +155,7 @@ export default function NewClientPolicy() {
form.reset(policy); form.reset(policy);
}; };
const policy = (policies || []).filter( const policy = (allPolicies || []).filter(
(policy) => policy.name === policyName, (policy) => policy.name === policyName,
); );
const policyConditions = policy[0]?.conditions || []; const policyConditions = policy[0]?.conditions || [];
@ -151,8 +172,6 @@ export default function NewClientPolicy() {
const createdForm = form.getValues(); const createdForm = form.getValues();
const createdPolicy = { const createdPolicy = {
...createdForm, ...createdForm,
profiles: [],
conditions: [],
}; };
const getAllPolicies = () => { const getAllPolicies = () => {
@ -279,6 +298,7 @@ export default function NewClientPolicy() {
policies: policies, policies: policies,
}); });
addAlert(t("deleteClientPolicyProfileSuccess"), AlertVariant.success); addAlert(t("deleteClientPolicyProfileSuccess"), AlertVariant.success);
form.setValue("profiles", currentPolicy?.profiles || []);
navigate(toEditClientPolicy({ realm, policyName: formValues.name! })); navigate(toEditClientPolicy({ realm, policyName: formValues.name! }));
} catch (error) { } catch (error) {
addError(t("deleteClientPolicyProfileError"), error); addError(t("deleteClientPolicyProfileError"), error);
@ -346,6 +366,10 @@ export default function NewClientPolicy() {
policies: newPolicies, policies: newPolicies,
}); });
setPolicies(newPolicies); setPolicies(newPolicies);
const allClientPolicies = [...(globalPolicies || []), ...newPolicies];
setAllPolicies(allClientPolicies);
setCurrentPolicy(createdPolicy);
form.setValue("profiles", createdPolicy.profiles);
navigate(toEditClientPolicy({ realm, policyName: formValues.name! })); navigate(toEditClientPolicy({ realm, policyName: formValues.name! }));
addAlert(t("addClientProfileSuccess"), AlertVariant.success); addAlert(t("addClientProfileSuccess"), AlertVariant.success);
} catch (error) { } catch (error) {
@ -393,9 +417,20 @@ export default function NewClientPolicy() {
? policyName ? policyName
: "createPolicy" : "createPolicy"
} }
badges={[
{
id: "global-client-policy-badge",
text: isGlobalPolicy ? (
<Label color="blue">{t("global")}</Label>
) : (
""
),
},
]}
divider divider
dropdownItems={ dropdownItems={
showAddConditionsAndProfilesForm || policyName (showAddConditionsAndProfilesForm || policyName) &&
!isGlobalPolicy
? [ ? [
<DropdownItem <DropdownItem
key="delete" key="delete"
@ -410,6 +445,7 @@ export default function NewClientPolicy() {
] ]
: undefined : undefined
} }
isReadOnly={isGlobalPolicy}
isEnabled={field.value} isEnabled={field.value}
onToggle={(value) => { onToggle={(value) => {
if (!value) { if (!value) {
@ -455,7 +491,7 @@ export default function NewClientPolicy() {
variant="primary" variant="primary"
type="submit" type="submit"
data-testid="saveCreatePolicy" data-testid="saveCreatePolicy"
isDisabled={!form.formState.isValid} isDisabled={!form.formState.isValid || isGlobalPolicy}
> >
{t("save")} {t("save")}
</Button> </Button>
@ -463,7 +499,8 @@ export default function NewClientPolicy() {
id="cancelCreatePolicy" id="cancelCreatePolicy"
variant="link" variant="link"
onClick={() => onClick={() =>
showAddConditionsAndProfilesForm || policyName (showAddConditionsAndProfilesForm || policyName) &&
!isGlobalPolicy
? reset() ? reset()
: navigate( : navigate(
toClientPolicies({ toClientPolicies({
@ -474,7 +511,9 @@ export default function NewClientPolicy() {
} }
data-testid="cancelCreatePolicy" data-testid="cancelCreatePolicy"
> >
{showAddConditionsAndProfilesForm ? t("reload") : t("cancel")} {showAddConditionsAndProfilesForm && !isGlobalPolicy
? t("reload")
: t("cancel")}
</Button> </Button>
</ActionGroup> </ActionGroup>
{(showAddConditionsAndProfilesForm || {(showAddConditionsAndProfilesForm ||
@ -490,26 +529,28 @@ export default function NewClientPolicy() {
/> />
</Text> </Text>
</FlexItem> </FlexItem>
<FlexItem align={{ default: "alignRight" }}> {!isGlobalPolicy && (
<Button <FlexItem align={{ default: "alignRight" }}>
id="addCondition" <Button
component={(props) => ( id="addCondition"
<Link component={(props) => (
{...props} <Link
to={toNewClientPolicyCondition({ {...props}
realm, to={toNewClientPolicyCondition({
policyName: policyName!, realm,
})} policyName: policyName!,
></Link> })}
)} ></Link>
variant="link" )}
className="kc-addCondition" variant="link"
data-testid="addCondition" className="kc-addCondition"
icon={<PlusCircleIcon />} data-testid="addCondition"
> icon={<PlusCircleIcon />}
{t("addCondition")} >
</Button> {t("addCondition")}
</FlexItem> </Button>
</FlexItem>
)}
</Flex> </Flex>
{policyConditions.length > 0 ? ( {policyConditions.length > 0 ? (
<DataList aria-label={t("conditions")} isCompact> <DataList aria-label={t("conditions")} isCompact>
@ -552,24 +593,26 @@ export default function NewClientPolicy() {
helpText={type.helpText} helpText={type.helpText}
fieldLabelId={condition.condition} fieldLabelId={condition.condition}
/> />
<Button {!isGlobalPolicy && (
variant="link" <Button
aria-label="remove-condition" variant="link"
isInline aria-label="remove-condition"
icon={ isInline
<TrashIcon icon={
className="kc-conditionType-trash-icon" <TrashIcon
data-testid={`delete-${condition.condition}-condition`} className="kc-conditionType-trash-icon"
onClick={() => { data-testid={`delete-${condition.condition}-condition`}
toggleDeleteConditionDialog(); onClick={() => {
setConditionToDelete({ toggleDeleteConditionDialog();
idx: idx, setConditionToDelete({
name: type.id!, idx: idx,
}); name: type.id!,
}} });
/> }}
} />
></Button> }
></Button>
)}
</> </>
), ),
)} )}
@ -609,18 +652,20 @@ export default function NewClientPolicy() {
/> />
</Text> </Text>
</FlexItem> </FlexItem>
<FlexItem align={{ default: "alignRight" }}> {!isGlobalPolicy && (
<Button <FlexItem align={{ default: "alignRight" }}>
id="addClientProfile" <Button
variant="link" id="addClientProfile"
className="kc-addClientProfile" variant="link"
data-testid="addClientProfile" className="kc-addClientProfile"
icon={<PlusCircleIcon />} data-testid="addClientProfile"
onClick={toggleModal} icon={<PlusCircleIcon />}
> onClick={toggleModal}
{t("addClientProfile")} >
</Button> {t("addClientProfile")}
</FlexItem> </Button>
</FlexItem>
)}
</Flex> </Flex>
{policyProfiles.length > 0 ? ( {policyProfiles.length > 0 ? (
<DataList aria-label={t("profiles")} isCompact> <DataList aria-label={t("profiles")} isCompact>
@ -663,24 +708,26 @@ export default function NewClientPolicy() {
} }
fieldLabelId={profile} fieldLabelId={profile}
/> />
<Button {!isGlobalPolicy && (
variant="link" <Button
aria-label="remove-client-profile" variant="link"
isInline aria-label="remove-client-profile"
icon={ isInline
<TrashIcon icon={
className="kc-conditionType-trash-icon" <TrashIcon
data-testid="deleteClientProfileDropdown" className="kc-conditionType-trash-icon"
onClick={() => { data-testid="deleteClientProfileDropdown"
toggleDeleteProfileDialog(); onClick={() => {
setProfileToDelete({ toggleDeleteProfileDialog();
idx: idx, setProfileToDelete({
name: type!, idx: idx,
}); name: type!,
}} });
/> }}
} />
></Button> }
></Button>
)}
</> </>
))} ))}
</DataListCell>, </DataListCell>,

View file

@ -18,12 +18,13 @@ import { camelCase } from "lodash-es";
import { useState } from "react"; import { useState } from "react";
import { Controller, FormProvider, useForm } from "react-hook-form"; import { Controller, FormProvider, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "react-router-dom"; import { Link, useNavigate, useParams } from "react-router-dom";
import { FormPanel, HelpItem } from "ui-shared"; import { HelpItem } from "ui-shared";
import { adminClient } from "../admin-client"; import { adminClient } from "../admin-client";
import { useAlerts } from "../components/alert/Alerts"; import { useAlerts } from "../components/alert/Alerts";
import { DynamicComponents } from "../components/dynamic/DynamicComponents"; import { DynamicComponents } from "../components/dynamic/DynamicComponents";
import { FormAccess } from "../components/form/FormAccess"; import { FormAccess } from "../components/form/FormAccess";
import { ViewHeader } from "../components/view-header/ViewHeader";
import { useRealm } from "../context/realm-context/RealmContext"; import { useRealm } from "../context/realm-context/RealmContext";
import { useServerInfo } from "../context/server-info/ServerInfoProvider"; import { useServerInfo } from "../context/server-info/ServerInfoProvider";
import { useFetch } from "../utils/useFetch"; import { useFetch } from "../utils/useFetch";
@ -44,6 +45,7 @@ export default function NewClientPolicyCondition() {
const { realm } = useRealm(); const { realm } = useRealm();
const [openConditionType, setOpenConditionType] = useState(false); const [openConditionType, setOpenConditionType] = useState(false);
const [isGlobalPolicy, setIsGlobalPolicy] = useState(false);
const [policies, setPolicies] = useState<ClientPolicyRepresentation[]>([]); const [policies, setPolicies] = useState<ClientPolicyRepresentation[]>([]);
const [condition, setCondition] = useState< const [condition, setCondition] = useState<
@ -72,15 +74,24 @@ export default function NewClientPolicyCondition() {
}; };
useFetch( useFetch(
() => adminClient.clientPolicies.listPolicies(), () =>
adminClient.clientPolicies.listPolicies({
includeGlobalPolicies: true,
}),
(policies) => { (policies) => {
setPolicies(policies.policies ?? []); setPolicies(policies.policies ?? []);
if (conditionName) { if (conditionName) {
const currentPolicy = policies.policies?.find( let currentPolicy = policies.policies?.find(
(item) => item.name === policyName, (item) => item.name === policyName,
); );
if (currentPolicy === undefined) {
currentPolicy = policies.globalPolicies?.find(
(item) => item.name === policyName,
);
setIsGlobalPolicy(currentPolicy !== undefined);
}
const typeAndConfigData = currentPolicy?.conditions?.find( const typeAndConfigData = currentPolicy?.conditions?.find(
(item) => item.condition === conditionName, (item) => item.condition === conditionName,
@ -170,14 +181,22 @@ export default function NewClientPolicyCondition() {
}; };
return ( return (
<PageSection variant="light"> <>
<FormPanel <ViewHeader
className="kc-login-screen" titleKey={
title={conditionName ? t("editCondition") : t("addCondition")} conditionName
> ? isGlobalPolicy
? t("viewCondition")
: t("editCondition")
: t("addCondition")
}
divider
/>
<PageSection variant="light">
<FormAccess <FormAccess
isHorizontal isHorizontal
role="manage-realm" role="manage-realm"
isReadOnly={isGlobalPolicy}
className="pf-v5-u-mt-lg" className="pf-v5-u-mt-lg"
onSubmit={form.handleSubmit(save)} onSubmit={form.handleSubmit(save)}
> >
@ -245,27 +264,48 @@ export default function NewClientPolicyCondition() {
<FormProvider {...form}> <FormProvider {...form}>
<DynamicComponents properties={conditionProperties} /> <DynamicComponents properties={conditionProperties} />
</FormProvider> </FormProvider>
<ActionGroup> {!isGlobalPolicy && (
<Button <ActionGroup>
variant="primary" <Button
type="submit" variant="primary"
data-testid="addCondition-saveBtn" type="submit"
isDisabled={conditionType === "" && !conditionName} data-testid="addCondition-saveBtn"
> isDisabled={
{conditionName ? t("save") : t("add")} conditionType === "" && !conditionName && isGlobalPolicy
</Button> }
<Button >
variant="link" {conditionName ? t("save") : t("add")}
data-testid="addCondition-cancelBtn" </Button>
onClick={() => <Button
navigate(toEditClientPolicy({ realm, policyName: policyName! })) variant="link"
} data-testid="addCondition-cancelBtn"
> onClick={() =>
{t("cancel")} navigate(
</Button> toEditClientPolicy({ realm, policyName: policyName! }),
</ActionGroup> )
}
>
{t("cancel")}
</Button>
</ActionGroup>
)}
</FormAccess> </FormAccess>
</FormPanel> {isGlobalPolicy && (
</PageSection> <div className="kc-backToProfile">
<Button
component={(props) => (
<Link
{...props}
to={toEditClientPolicy({ realm, policyName: policyName! })}
/>
)}
variant="primary"
>
{t("back")}
</Button>
</div>
)}
</PageSection>
</>
); );
} }

View file

@ -7,6 +7,7 @@ import {
Divider, Divider,
Flex, Flex,
FlexItem, FlexItem,
Label,
PageSection, PageSection,
Radio, Radio,
Switch, Switch,
@ -36,29 +37,48 @@ import { toEditClientPolicy } from "./routes/EditClientPolicy";
import "./realm-settings-section.css"; import "./realm-settings-section.css";
type ClientPolicy = ClientPolicyRepresentation & {
global?: boolean;
};
export const PoliciesTab = () => { export const PoliciesTab = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { addAlert, addError } = useAlerts(); const { addAlert, addError } = useAlerts();
const { realm } = useRealm(); const { realm } = useRealm();
const navigate = useNavigate(); const navigate = useNavigate();
const [show, setShow] = useState(false); const [show, setShow] = useState(false);
const [policies, setPolicies] = useState<ClientPolicyRepresentation[]>(); const [policies, setPolicies] = useState<ClientPolicy[]>();
const [selectedPolicy, setSelectedPolicy] = const [selectedPolicy, setSelectedPolicy] = useState<ClientPolicy>();
useState<ClientPolicyRepresentation>();
const [key, setKey] = useState(0); const [key, setKey] = useState(0);
const [code, setCode] = useState<string>(); const [code, setCode] = useState<string>();
const [tablePolicies, setTablePolicies] = const [tablePolicies, setTablePolicies] = useState<ClientPolicy[]>();
useState<ClientPolicyRepresentation[]>();
const refresh = () => setKey(key + 1); const refresh = () => setKey(key + 1);
const form = useForm<Record<string, boolean>>({ mode: "onChange" }); const form = useForm<Record<string, boolean>>({ mode: "onChange" });
useFetch( useFetch(
() => adminClient.clientPolicies.listPolicies(), () =>
(policies) => { adminClient.clientPolicies.listPolicies({
setPolicies(policies.policies), includeGlobalPolicies: true,
setTablePolicies(policies.policies || []), }),
setCode(prettyPrintJSON(policies.policies)); (allPolicies) => {
const globalPolicies = allPolicies.globalPolicies?.map(
(globalPolicies) => ({
...globalPolicies,
global: true,
}),
);
const policies = allPolicies.policies?.map((policies) => ({
...policies,
global: false,
}));
const allClientPolicies = globalPolicies?.concat(policies ?? []);
setPolicies(allClientPolicies),
setTablePolicies(allClientPolicies || []),
setCode(prettyPrintJSON(allClientPolicies));
}, },
[key], [key],
); );
@ -68,16 +88,19 @@ export const PoliciesTab = () => {
const saveStatus = async () => { const saveStatus = async () => {
const switchValues = form.getValues(); const switchValues = form.getValues();
const updatedPolicies = policies?.map<ClientPolicyRepresentation>( const updatedPolicies = policies
(policy) => { ?.filter((policy) => {
return !policy.global;
})
.map<ClientPolicyRepresentation>((policy) => {
const enabled = switchValues[policy.name!]; const enabled = switchValues[policy.name!];
const enabledPolicy = {
return {
...policy, ...policy,
enabled, enabled,
}; };
}, delete enabledPolicy.global;
); return enabledPolicy;
});
try { try {
await adminClient.clientPolicies.updatePolicy({ await adminClient.clientPolicies.updatePolicy({
@ -90,15 +113,13 @@ export const PoliciesTab = () => {
} }
}; };
const ClientPolicyDetailLink = ({ name }: ClientPolicyRepresentation) => ( const ClientPolicyDetailLink = (row: ClientPolicy) => (
<Link to={toEditClientPolicy({ realm, policyName: name! })}>{name}</Link> <Link to={toEditClientPolicy({ realm, policyName: row.name! })}>
{row.name} {row.global && <Label color="blue">{t("global")}</Label>}
</Link>
); );
const SwitchRenderer = ({ const SwitchRenderer = ({ clientPolicy }: { clientPolicy: ClientPolicy }) => {
clientPolicy,
}: {
clientPolicy: ClientPolicyRepresentation;
}) => {
const [toggleDisableDialog, DisableConfirm] = useConfirmDialog({ const [toggleDisableDialog, DisableConfirm] = useConfirmDialog({
titleKey: "disablePolicyConfirmTitle", titleKey: "disablePolicyConfirmTitle",
messageKey: "disablePolicyConfirm", messageKey: "disablePolicyConfirm",
@ -122,6 +143,7 @@ export const PoliciesTab = () => {
label={t("enabled")} label={t("enabled")}
labelOff={t("disabled")} labelOff={t("disabled")}
isChecked={field.value} isChecked={field.value}
isDisabled={clientPolicy.global}
onChange={(_event, value) => { onChange={(_event, value) => {
if (!value) { if (!value) {
toggleDisableDialog(); toggleDisableDialog();
@ -144,7 +166,7 @@ export const PoliciesTab = () => {
} }
try { try {
const obj: ClientPolicyRepresentation[] = JSON.parse(code); const obj: ClientPolicy[] = JSON.parse(code);
try { try {
await adminClient.clientPolicies.updatePolicy({ await adminClient.clientPolicies.updatePolicy({
@ -169,9 +191,15 @@ export const PoliciesTab = () => {
continueButtonLabel: t("delete"), continueButtonLabel: t("delete"),
continueButtonVariant: ButtonVariant.danger, continueButtonVariant: ButtonVariant.danger,
onConfirm: async () => { onConfirm: async () => {
const updatedPolicies = policies?.filter( const updatedPolicies = policies
(policy) => policy.name !== selectedPolicy?.name, ?.filter((policy) => {
); return !policy.global && policy.name !== selectedPolicy?.name;
})
.map<ClientPolicyRepresentation>((policy) => {
const newPolicy = { ...policy };
delete newPolicy.global;
return newPolicy;
});
try { try {
await adminClient.clientPolicies.updatePolicy({ await adminClient.clientPolicies.updatePolicy({
@ -250,6 +278,7 @@ export const PoliciesTab = () => {
</Button> </Button>
</ToolbarItem> </ToolbarItem>
} }
isRowDisabled={(value) => !!value.global}
actions={[ actions={[
{ {
title: t("delete"), title: t("delete"),
@ -257,7 +286,7 @@ export const PoliciesTab = () => {
toggleDeleteDialog(); toggleDeleteDialog();
setSelectedPolicy(item); setSelectedPolicy(item);
}, },
} as Action<ClientPolicyRepresentation>, } as Action<ClientPolicy>,
]} ]}
columns={[ columns={[
{ {

View file

@ -4,5 +4,6 @@ import type ClientPolicyRepresentation from "./clientPolicyRepresentation.js";
* https://www.keycloak.org/docs-api/15.0/rest-api/#_clientpoliciesrepresentation * https://www.keycloak.org/docs-api/15.0/rest-api/#_clientpoliciesrepresentation
*/ */
export default interface ClientPoliciesRepresentation { export default interface ClientPoliciesRepresentation {
globalPolicies?: ClientPolicyRepresentation[];
policies?: ClientPolicyRepresentation[]; policies?: ClientPolicyRepresentation[];
} }

View file

@ -38,9 +38,16 @@ export class ClientPolicies extends Resource<{ realm?: string }> {
/* Client Policies */ /* Client Policies */
public listPolicies = this.makeRequest<{}, ClientPoliciesRepresentation>({ public listPolicies = this.makeRequest<
{ includeGlobalPolicies?: boolean },
ClientPoliciesRepresentation
>({
method: "GET", method: "GET",
path: "/policies", path: "/policies",
queryParamKeys: ["include-global-policies"],
keyTransform: {
includeGlobalPolicies: "include-global-policies",
},
}); });
public updatePolicy = this.makeRequest<ClientPoliciesRepresentation, void>({ public updatePolicy = this.makeRequest<ClientPoliciesRepresentation, void>({

View file

@ -0,0 +1,51 @@
/*
* Copyright 2024 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.securityprofile;
import java.util.List;
import org.keycloak.provider.Provider;
import org.keycloak.representations.idm.ClientPolicyRepresentation;
import org.keycloak.representations.idm.ClientProfileRepresentation;
/**
* The security profile provider is a default security configuration that enforces a
* minimum level of security in the keycloak environment. For the moment the class
* is just used for client policies but it can be extended for password policies
* or any other security configuration in the future.
*
* @author rmartinc
*/
public interface SecurityProfileProvider extends Provider {
/**
* Name of the security profile.
* @return The name
*/
String getName();
/**
* List of default client profiles that the security profile contains.
* @return The list of client profiles defined
*/
List<ClientProfileRepresentation> getDefaultClientProfiles();
/**
* List of default client policies defined in the security profile.
* @return The list of client policies defined
*/
List<ClientPolicyRepresentation> getDefaultClientPolicies();
}

View file

@ -0,0 +1,27 @@
/*
* Copyright 2024 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.securityprofile;
import org.keycloak.provider.ProviderFactory;
/**
*
* @author rmartinc
*/
public interface SecurityProfileProviderFactory extends ProviderFactory<SecurityProfileProvider>{
}

View file

@ -0,0 +1,48 @@
/*
* Copyright 2024 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.securityprofile;
import org.keycloak.provider.Provider;
import org.keycloak.provider.ProviderFactory;
import org.keycloak.provider.Spi;
/**
*
* @author rmartinc
*/
public class SecurityProfileSpi implements Spi {
@Override
public boolean isInternal() {
return true;
}
@Override
public String getName() {
return "security-profile";
}
@Override
public Class<? extends Provider> getProviderClass() {
return SecurityProfileProvider.class;
}
@Override
public Class<? extends ProviderFactory> getProviderFactoryClass() {
return SecurityProfileProviderFactory.class;
}
}

View file

@ -99,3 +99,4 @@ org.keycloak.device.DeviceRepresentationSpi
org.keycloak.health.LoadBalancerCheckSpi org.keycloak.health.LoadBalancerCheckSpi
org.keycloak.cookie.CookieSpi org.keycloak.cookie.CookieSpi
org.keycloak.organization.OrganizationSpi org.keycloak.organization.OrganizationSpi
org.keycloak.securityprofile.SecurityProfileSpi

View file

@ -93,9 +93,10 @@ public interface ClientPolicyManager extends Provider {
* when getting client policies via Admin REST API, returns the existing client policies set on the realm. * when getting client policies via Admin REST API, returns the existing client policies set on the realm.
* *
* @param realm - the realm whose client policies is to be returned * @param realm - the realm whose client policies is to be returned
* @param includeGlobalPolicies - the json representation will include the default policies
* @return the json representation of the client policies set on the realm * @return the json representation of the client policies set on the realm
*/ */
ClientPoliciesRepresentation getClientPolicies(RealmModel realm) throws ClientPolicyException; ClientPoliciesRepresentation getClientPolicies(RealmModel realm, boolean includeGlobalPolicies) throws ClientPolicyException;
/** /**
* when exporting realm, or retrieve the realm for admin REST API, prepares the exported representation of the client profiles and policies. * when exporting realm, or retrieve the realm for admin REST API, prepares the exported representation of the client profiles and policies.

View file

@ -18,8 +18,13 @@
package org.keycloak.services.clientpolicy; package org.keycloak.services.clientpolicy;
import com.fasterxml.jackson.databind.JsonNode;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.LinkedList; import java.util.LinkedList;
@ -27,10 +32,7 @@ import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import com.fasterxml.jackson.databind.JsonNode;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
import org.keycloak.common.Profile; import org.keycloak.common.Profile;
import org.keycloak.component.ComponentModel; import org.keycloak.component.ComponentModel;
import org.keycloak.component.JsonConfigComponentModel; import org.keycloak.component.JsonConfigComponentModel;
@ -38,14 +40,15 @@ import org.keycloak.models.Constants;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel; import org.keycloak.models.RealmModel;
import org.keycloak.representations.idm.ClientPoliciesRepresentation; import org.keycloak.representations.idm.ClientPoliciesRepresentation;
import org.keycloak.representations.idm.ClientPolicyConditionConfigurationRepresentation;
import org.keycloak.representations.idm.ClientPolicyConditionRepresentation; import org.keycloak.representations.idm.ClientPolicyConditionRepresentation;
import org.keycloak.representations.idm.ClientPolicyExecutorConfigurationRepresentation;
import org.keycloak.representations.idm.ClientPolicyExecutorRepresentation; import org.keycloak.representations.idm.ClientPolicyExecutorRepresentation;
import org.keycloak.representations.idm.ClientPolicyRepresentation; import org.keycloak.representations.idm.ClientPolicyRepresentation;
import org.keycloak.representations.idm.ClientProfileRepresentation; import org.keycloak.representations.idm.ClientProfileRepresentation;
import org.keycloak.representations.idm.ClientProfilesRepresentation; import org.keycloak.representations.idm.ClientProfilesRepresentation;
import org.keycloak.representations.idm.ClientPolicyConditionConfigurationRepresentation; import org.keycloak.securityprofile.SecurityProfileProvider;
import org.keycloak.services.clientpolicy.condition.ClientPolicyConditionProvider; import org.keycloak.services.clientpolicy.condition.ClientPolicyConditionProvider;
import org.keycloak.representations.idm.ClientPolicyExecutorConfigurationRepresentation;
import org.keycloak.services.clientpolicy.executor.ClientPolicyExecutorProvider; import org.keycloak.services.clientpolicy.executor.ClientPolicyExecutorProvider;
import org.keycloak.util.JsonSerialization; import org.keycloak.util.JsonSerialization;
@ -58,6 +61,43 @@ public class ClientPoliciesUtil {
private static final Logger logger = Logger.getLogger(ClientPoliciesUtil.class); private static final Logger logger = Logger.getLogger(ClientPoliciesUtil.class);
public static InputStream getJsonFileFromClasspathOrConfFolder(String name) throws IOException {
final String fileName = name + ".json";
// first try to read the json configuration file from classpath
InputStream is = ClientPoliciesUtil.class.getResourceAsStream("/" + fileName);
if (is == null) {
Path path = Paths.get(System.getProperty("jboss.server.config.dir")).resolve(fileName);
if (!Files.isReadable(path)) {
throw new IOException(String.format("File \"%s\" does not exists under the config folder", path));
}
is = Files.newInputStream(path);
}
return is;
}
public static List<ClientProfileRepresentation> readGlobalClientProfilesRepresentation(KeycloakSession session, String name) throws ClientPolicyException {
if (name == null) {
return Collections.emptyList();
}
try (InputStream is = getJsonFileFromClasspathOrConfFolder(name)) {
return getValidatedGlobalClientProfilesRepresentation(session, is);
} catch (IOException e) {
throw new ClientPolicyException("Error reading profiles from " + name, e.getMessage(), e);
}
}
public static List<ClientPolicyRepresentation> readGlobalClientPoliciesRepresentation(KeycloakSession session, String name,
List<ClientProfileRepresentation> profiles) throws ClientPolicyException {
if (name == null) {
return Collections.emptyList();
}
try (InputStream is = getJsonFileFromClasspathOrConfFolder(name)) {
return getValidatedGlobalClientPoliciesRepresentation(session, is, profiles);
} catch (IOException e) {
throw new ClientPolicyException("Error reading profiles from " + name, e.getMessage(), e);
}
}
/** /**
* gets existing client profiles in a realm as representation. * gets existing client profiles in a realm as representation.
* not return null. * not return null.
@ -183,6 +223,24 @@ public class ClientPoliciesUtil {
return updatingProfileList; return updatingProfileList;
} }
/**
* get validated and modified global (built-in) client policies set on keycloak app as representation.
* it is loaded from json file enclosed in keycloak's binary.
* not return null.
*/
static List<ClientPolicyRepresentation> getValidatedGlobalClientPoliciesRepresentation(KeycloakSession session, InputStream is, List<ClientProfileRepresentation> profiles) throws ClientPolicyException {
ClientPoliciesRepresentation proposedPoliciesRep = null;
try {
proposedPoliciesRep = JsonSerialization.readValue(is, ClientPoliciesRepresentation.class);
} catch (Exception e) {
throw new ClientPolicyException("failed to deserialize global proposed client profiles json string.", e.getMessage());
}
if (proposedPoliciesRep == null) {
return Collections.emptyList();
}
return validatePolicies(session, proposedPoliciesRep.getPolicies(), profiles, Collections.emptyList());
}
/** /**
* convert client profiles as representation to json. * convert client profiles as representation to json.
* can return null. * can return null.
@ -240,7 +298,7 @@ public class ClientPoliciesUtil {
Set<String> globalProfileNames = globalClientProfiles.stream().map(ClientProfileRepresentation::getName).collect(Collectors.toSet()); Set<String> globalProfileNames = globalClientProfiles.stream().map(ClientProfileRepresentation::getName).collect(Collectors.toSet());
for (ClientProfileRepresentation clientProfile : proposedProfileRepList) { for (ClientProfileRepresentation clientProfile : proposedProfileRepList) {
if (globalProfileNames.contains(clientProfile.getName())) { if (globalProfileNames.contains(clientProfile.getName())) {
throw new ClientPolicyException("Proposed profile name duplicated as the name of some global profile"); throw new ClientPolicyException("Proposed profile name '" + clientProfile.getName() + "' is duplicated as a global profile");
} }
} }
@ -298,32 +356,42 @@ public class ClientPoliciesUtil {
return convertClientPoliciesJsonToRepresentation(policiesJson); return convertClientPoliciesJsonToRepresentation(policiesJson);
} }
static List<ClientProfileRepresentation> getGlobalClientProfiles(KeycloakSession session) {
SecurityProfileProvider securityProfile = session.getProvider(SecurityProfileProvider.class);
return securityProfile.getDefaultClientProfiles();
}
static List<ClientPolicyRepresentation> getGlobalClientPolicies(KeycloakSession session) {
SecurityProfileProvider securityProfile = session.getProvider(SecurityProfileProvider.class);
return securityProfile.getDefaultClientPolicies();
}
/** /**
* Gets existing enabled client policies in a realm. * Gets existing enabled client policies in a realm.
* not return null. * not return null.
*/ */
static List<ClientPolicy> getEnabledClientPolicies(KeycloakSession session, RealmModel realm) { static List<ClientPolicy> getEnabledClientPolicies(KeycloakSession session, RealmModel realm) {
// get the global policies defined in the security profile
List<ClientPolicyRepresentation> policiesRep = new ArrayList<>(getGlobalClientPolicies(session));
// get existing profiles as json // get existing profiles as json
String policiesJson = getClientPoliciesJsonString(realm); String policiesJson = getClientPoliciesJsonString(realm);
if (policiesJson == null) { if (policiesJson != null) {
return Collections.emptyList(); // deserialize existing policies (json -> representation)
try {
policiesRep.addAll(convertClientPoliciesJsonToRepresentation(policiesJson).getPolicies());
} catch (ClientPolicyException e) {
logger.warnv("Failed to serialize client policies json string. err={0}, errDetail={1}", e.getError(), e.getErrorDetail());
return Collections.emptyList();
}
} }
if (policiesRep.isEmpty()) {
// deserialize existing policies (json -> representation)
ClientPoliciesRepresentation policiesRep = null;
try {
policiesRep = convertClientPoliciesJsonToRepresentation(policiesJson);
} catch (ClientPolicyException e) {
logger.warnv("Failed to serialize client policies json string. err={0}, errDetail={1}", e.getError(), e.getErrorDetail());
return Collections.emptyList();
}
if (policiesRep == null || policiesRep.getPolicies() == null) {
return Collections.emptyList(); return Collections.emptyList();
} }
// constructing existing policies (representation -> model) // constructing existing policies (representation -> model)
List<ClientPolicy> policyList = new ArrayList<>(); List<ClientPolicy> policyList = new ArrayList<>();
for (ClientPolicyRepresentation policyRep: policiesRep.getPolicies()) { for (ClientPolicyRepresentation policyRep: policiesRep) {
// ignore policy without name // ignore policy without name
if (policyRep.getName() == null) { if (policyRep.getName() == null) {
logger.warnf("Ignored client policy without name in the realm %s", realm.getName()); logger.warnf("Ignored client policy without name in the realm %s", realm.getName());
@ -395,6 +463,80 @@ public class ClientPoliciesUtil {
} }
} }
/**
* Validates the policies passed with the profiles.
* @param session The session
* @param proposedPoliciesRepList The policies to validate
* @param profiles The already validated profiles used by the policies
* @param globalPolicies The global policies defined already validated
* @return The validated policies
* @throws ClientPolicyException Some error in the policies
*/
static private List<ClientPolicyRepresentation> validatePolicies(KeycloakSession session, List<ClientPolicyRepresentation> proposedPoliciesRepList,
List<ClientProfileRepresentation> profiles, List<ClientPolicyRepresentation> globalPolicies) throws ClientPolicyException {
// empty policies is valid
if (proposedPoliciesRepList == null || proposedPoliciesRepList.isEmpty()) {
return Collections.emptyList();
}
// check for duplicated names
if (proposedPoliciesRepList.size() != proposedPoliciesRepList.stream().map(i->i.getName()).distinct().count()) {
throw new ClientPolicyException("proposed client policy name duplicated");
}
// Conflict with any global policy is not allowed
Set<String> globalPolicyNames = globalPolicies.stream().map(ClientPolicyRepresentation::getName).collect(Collectors.toSet());
for (ClientPolicyRepresentation clientPolicy : proposedPoliciesRepList) {
if (globalPolicyNames.contains(clientPolicy.getName())) {
throw new ClientPolicyException("Proposed policy name '" + clientPolicy.getName() + "' is duplicated as a global policy");
}
}
// construct validated and modified profiles from builtin profiles in JSON file enclosed in keycloak binary.
List<ClientPolicyRepresentation> updatingPolicyList = new LinkedList<>();
Set<String> profileNames = profiles.stream().map(ClientProfileRepresentation::getName).collect(Collectors.toSet());
for (ClientPolicyRepresentation proposedPolicyRep : proposedPoliciesRepList) {
if (proposedPolicyRep.getName() == null) {
throw new ClientPolicyException("client policy without name not allowed");
}
ClientPolicyRepresentation policyRep = new ClientPolicyRepresentation();
policyRep.setName(proposedPolicyRep.getName());
policyRep.setDescription(proposedPolicyRep.getDescription());
policyRep.setEnabled(proposedPolicyRep.isEnabled() != null ? proposedPolicyRep.isEnabled() : Boolean.FALSE);
policyRep.setConditions(new ArrayList<>());
if (proposedPolicyRep.getConditions() != null) {
for (ClientPolicyConditionRepresentation condition : proposedPolicyRep.getConditions()) {
if (Profile.isFeatureEnabled(Profile.Feature.CLIENT_POLICIES) && !isValidCondition(session, condition.getConditionProviderId())) {
throw new ClientPolicyException("Policy " + proposedPolicyRep.getName() + " contains invalid condition " + condition.getConditionProviderId());
}
policyRep.getConditions().add(condition);
}
}
policyRep.setProfiles(new ArrayList<>());
if (proposedPolicyRep.getProfiles() != null) {
if (proposedPolicyRep.getProfiles().size() != proposedPolicyRep.getProfiles().stream().distinct().count()) {
throw new ClientPolicyException("Policy " + proposedPolicyRep.getName() + " contains duplicated profiles");
}
for (String profile : proposedPolicyRep.getProfiles()) {
if (!profileNames.contains(profile)) {
throw new ClientPolicyException("Policy " + proposedPolicyRep.getName() + " contains invalid profile " + profile);
}
policyRep.getProfiles().add(profile);
}
}
updatingPolicyList.add(policyRep);
}
return updatingPolicyList;
}
/** /**
* get validated and modified client policies as representation. * get validated and modified client policies as representation.
* it can be constructed by merging proposed client policies with existing client policies. * it can be constructed by merging proposed client policies with existing client policies.
@ -405,73 +547,17 @@ public class ClientPoliciesUtil {
* @param proposedPoliciesRep * @param proposedPoliciesRep
*/ */
static ClientPoliciesRepresentation getValidatedClientPoliciesForUpdate(KeycloakSession session, RealmModel realm, static ClientPoliciesRepresentation getValidatedClientPoliciesForUpdate(KeycloakSession session, RealmModel realm,
ClientPoliciesRepresentation proposedPoliciesRep, List<ClientProfileRepresentation> existingGlobalProfiles) throws ClientPolicyException { ClientPoliciesRepresentation proposedPoliciesRep, List<ClientProfileRepresentation> existingGlobalProfiles,
List<ClientPolicyRepresentation> existingGlobalPolicies) throws ClientPolicyException {
if (realm == null) { if (realm == null) {
throw new ClientPolicyException("realm not specified."); throw new ClientPolicyException("realm not specified.");
} }
// no policy contained (it is valid)
List<ClientPolicyRepresentation> proposedPolicyRepList = proposedPoliciesRep.getPolicies();
if (proposedPolicyRepList == null || proposedPolicyRepList.isEmpty()) {
proposedPolicyRepList = new ArrayList<>();
proposedPoliciesRep.setPolicies(new ArrayList<>());
}
// Policy without name not allowed
if (proposedPolicyRepList.stream().anyMatch(clientPolicy -> clientPolicy.getName() == null || clientPolicy.getName().isEmpty())) {
throw new ClientPolicyException("proposed client policy name missing.");
}
// duplicated policy name is not allowed.
if (proposedPolicyRepList.size() != proposedPolicyRepList.stream().map(i->i.getName()).distinct().count()) {
throw new ClientPolicyException("proposed client policy name duplicated.");
}
// construct updating policies from existing policies and proposed policies
ClientPoliciesRepresentation updatingPoliciesRep = new ClientPoliciesRepresentation(); ClientPoliciesRepresentation updatingPoliciesRep = new ClientPoliciesRepresentation();
updatingPoliciesRep.setPolicies(new ArrayList<>());
List<ClientPolicyRepresentation> updatingPoliciesList = updatingPoliciesRep.getPolicies();
for (ClientPolicyRepresentation proposedPolicyRep : proposedPoliciesRep.getPolicies()) { List<ClientProfileRepresentation> allProfiles = new ArrayList<>(getClientProfilesRepresentation(session, realm).getProfiles());
// newly proposed builtin policy not allowed because builtin policy cannot added/deleted/modified. allProfiles.addAll(existingGlobalProfiles);
Boolean enabled = (proposedPolicyRep.isEnabled() != null) ? proposedPolicyRep.isEnabled() : Boolean.FALSE; updatingPoliciesRep.setPolicies(validatePolicies(session, proposedPoliciesRep.getPolicies(), allProfiles, existingGlobalPolicies));
// basically, proposed policy totally overrides existing policy except for enabled field..
ClientPolicyRepresentation policyRep = new ClientPolicyRepresentation();
policyRep.setName(proposedPolicyRep.getName());
policyRep.setDescription(proposedPolicyRep.getDescription());
policyRep.setEnabled(enabled);
policyRep.setConditions(new ArrayList<>());
if (proposedPolicyRep.getConditions() != null) {
for (ClientPolicyConditionRepresentation conditionRep : proposedPolicyRep.getConditions()) {
if (!isValidCondition(session, conditionRep.getConditionProviderId())) {
throw new ClientPolicyException("the proposed client policy contains the condition with its invalid configuration.");
}
policyRep.getConditions().add(conditionRep);
}
}
Set<String> existingProfileNames = existingGlobalProfiles.stream().map(ClientProfileRepresentation::getName).collect(Collectors.toSet());
ClientProfilesRepresentation reps = getClientProfilesRepresentation(session, realm);
policyRep.setProfiles(new ArrayList<>());
if (reps.getProfiles() != null) {
existingProfileNames.addAll(reps.getProfiles().stream()
.map(ClientProfileRepresentation::getName)
.collect(Collectors.toSet()));
}
if (proposedPolicyRep.getProfiles() != null) {
for (String profileName : proposedPolicyRep.getProfiles()) {
if (!existingProfileNames.contains(profileName)) {
logger.warnf("Client policy %s referred not existing profile %s");
throw new ClientPolicyException("referring not existing client profile not allowed.");
}
}
proposedPolicyRep.getProfiles().stream().distinct().forEach(profileName->policyRep.getProfiles().add(profileName));
}
updatingPoliciesList.add(policyRep);
}
return updatingPoliciesRep; return updatingPoliciesRep;
} }

View file

@ -18,16 +18,13 @@
package org.keycloak.services.clientpolicy; package org.keycloak.services.clientpolicy;
import java.io.IOException; import java.io.IOException;
import java.util.LinkedList;
import java.util.List; import java.util.List;
import java.util.function.Supplier;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
import org.keycloak.common.Profile; import org.keycloak.common.Profile;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel; import org.keycloak.models.RealmModel;
import org.keycloak.representations.idm.ClientPoliciesRepresentation; import org.keycloak.representations.idm.ClientPoliciesRepresentation;
import org.keycloak.representations.idm.ClientProfileRepresentation;
import org.keycloak.representations.idm.ClientProfilesRepresentation; import org.keycloak.representations.idm.ClientProfilesRepresentation;
import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.services.clientpolicy.condition.ClientPolicyConditionProvider; import org.keycloak.services.clientpolicy.condition.ClientPolicyConditionProvider;
@ -42,11 +39,9 @@ public class DefaultClientPolicyManager implements ClientPolicyManager {
private static final Logger logger = Logger.getLogger(DefaultClientPolicyManager.class); private static final Logger logger = Logger.getLogger(DefaultClientPolicyManager.class);
private final KeycloakSession session; private final KeycloakSession session;
private final Supplier<List<ClientProfileRepresentation>> globalClientProfilesSupplier;
public DefaultClientPolicyManager(KeycloakSession session, Supplier<List<ClientProfileRepresentation>> globalClientProfilesSupplier) { public DefaultClientPolicyManager(KeycloakSession session) {
this.session = session; this.session = session;
this.globalClientProfilesSupplier = globalClientProfilesSupplier;
} }
@Override @Override
@ -143,7 +138,8 @@ public class DefaultClientPolicyManager implements ClientPolicyManager {
ClientProfilesRepresentation clientProfiles = ClientPoliciesUtil.getClientProfilesRepresentation(session, realm); ClientProfilesRepresentation clientProfiles = ClientPoliciesUtil.getClientProfilesRepresentation(session, realm);
for (String profileName : policy.getProfiles()) { for (String profileName : policy.getProfiles()) {
ClientProfile profile = ClientPoliciesUtil.getClientProfileModel(session, realm, clientProfiles, globalClientProfilesSupplier.get(), profileName); ClientProfile profile = ClientPoliciesUtil.getClientProfileModel(session, realm, clientProfiles,
ClientPoliciesUtil.getGlobalClientProfiles(session), profileName);
if (profile == null) { if (profile == null) {
logger.tracev("PROFILE NOT FOUND :: policy name = {0}, profile name = {1}", policy.getName(), profileName); logger.tracev("PROFILE NOT FOUND :: policy name = {0}, profile name = {1}", policy.getName(), profileName);
continue; continue;
@ -212,7 +208,8 @@ public class DefaultClientPolicyManager implements ClientPolicyManager {
if (clientProfiles == null) { if (clientProfiles == null) {
throw new ClientPolicyException("Passing null clientProfiles not allowed"); throw new ClientPolicyException("Passing null clientProfiles not allowed");
} }
ClientProfilesRepresentation validatedProfilesRep = ClientPoliciesUtil.getValidatedClientProfilesForUpdate(session, realm, clientProfiles, globalClientProfilesSupplier.get()); ClientProfilesRepresentation validatedProfilesRep = ClientPoliciesUtil.getValidatedClientProfilesForUpdate(session, realm, clientProfiles,
ClientPoliciesUtil.getGlobalClientProfiles(session));
String validatedJsonString = ClientPoliciesUtil.convertClientProfilesRepresentationToJson(validatedProfilesRep); String validatedJsonString = ClientPoliciesUtil.convertClientProfilesRepresentationToJson(validatedProfilesRep);
ClientPoliciesUtil.setClientProfilesJsonString(realm, validatedJsonString); ClientPoliciesUtil.setClientProfilesJsonString(realm, validatedJsonString);
logger.tracev("UPDATE PROFILES :: realm = {0}, validated and modified PUT = {1}", realm.getName(), validatedJsonString); logger.tracev("UPDATE PROFILES :: realm = {0}, validated and modified PUT = {1}", realm.getName(), validatedJsonString);
@ -227,7 +224,7 @@ public class DefaultClientPolicyManager implements ClientPolicyManager {
try { try {
ClientProfilesRepresentation clientProfiles = ClientPoliciesUtil.getClientProfilesRepresentation(session, realm); ClientProfilesRepresentation clientProfiles = ClientPoliciesUtil.getClientProfilesRepresentation(session, realm);
if (includeGlobalProfiles) { if (includeGlobalProfiles) {
clientProfiles.setGlobalProfiles(new LinkedList<>(globalClientProfilesSupplier.get())); clientProfiles.setGlobalProfiles(ClientPoliciesUtil.getGlobalClientProfiles(session));
} }
if (logger.isTraceEnabled()) { if (logger.isTraceEnabled()) {
@ -250,7 +247,8 @@ public class DefaultClientPolicyManager implements ClientPolicyManager {
if (clientPolicies == null) { if (clientPolicies == null) {
throw new ClientPolicyException("Passing null clientPolicies not allowed"); throw new ClientPolicyException("Passing null clientPolicies not allowed");
} }
ClientPoliciesRepresentation clientPoliciesRep = ClientPoliciesUtil.getValidatedClientPoliciesForUpdate(session, realm, clientPolicies, globalClientProfilesSupplier.get()); ClientPoliciesRepresentation clientPoliciesRep = ClientPoliciesUtil.getValidatedClientPoliciesForUpdate(session, realm, clientPolicies,
ClientPoliciesUtil.getGlobalClientProfiles(session), ClientPoliciesUtil.getGlobalClientPolicies(session));
validatedJsonString = ClientPoliciesUtil.convertClientPoliciesRepresentationToJson(clientPoliciesRep); validatedJsonString = ClientPoliciesUtil.convertClientPoliciesRepresentationToJson(clientPoliciesRep);
} catch (ClientPolicyException e) { } catch (ClientPolicyException e) {
logger.warnv("VALIDATE SERIALIZE POLICIES FAILED :: error = {0}, error detail = {1}", e.getError(), e.getErrorDetail()); logger.warnv("VALIDATE SERIALIZE POLICIES FAILED :: error = {0}, error detail = {1}", e.getError(), e.getErrorDetail());
@ -261,9 +259,12 @@ public class DefaultClientPolicyManager implements ClientPolicyManager {
} }
@Override @Override
public ClientPoliciesRepresentation getClientPolicies(RealmModel realm) throws ClientPolicyException { public ClientPoliciesRepresentation getClientPolicies(RealmModel realm, boolean includeGlobalPolicies) throws ClientPolicyException {
try { try {
ClientPoliciesRepresentation clientPolicies = ClientPoliciesUtil.getClientPoliciesRepresentation(session, realm); ClientPoliciesRepresentation clientPolicies = ClientPoliciesUtil.getClientPoliciesRepresentation(session, realm);
if (includeGlobalPolicies) {
clientPolicies.setGlobalPolicies(ClientPoliciesUtil.getGlobalClientPolicies(session));
}
if (logger.isTraceEnabled()) { if (logger.isTraceEnabled()) {
logger.tracev("GET POLICIES :: realm = {0}, GET = {1}", realm.getName(), JsonSerialization.writeValueAsString(clientPolicies)); logger.tracev("GET POLICIES :: realm = {0}, GET = {1}", realm.getName(), JsonSerialization.writeValueAsString(clientPolicies));
} }
@ -283,7 +284,7 @@ public class DefaultClientPolicyManager implements ClientPolicyManager {
ClientProfilesRepresentation filteredOutProfiles = getClientProfiles(realm, false); ClientProfilesRepresentation filteredOutProfiles = getClientProfiles(realm, false);
rep.setParsedClientProfiles(filteredOutProfiles); rep.setParsedClientProfiles(filteredOutProfiles);
ClientPoliciesRepresentation filteredOutPolicies = getClientPolicies(realm); ClientPoliciesRepresentation filteredOutPolicies = getClientPolicies(realm, false);
rep.setParsedClientPolicies(filteredOutPolicies); rep.setParsedClientPolicies(filteredOutPolicies);
} catch (ClientPolicyException cpe) { } catch (ClientPolicyException cpe) {
throw new IllegalStateException("Exception during export client profiles or client policies", cpe); throw new IllegalStateException("Exception during export client profiles or client policies", cpe);

View file

@ -18,13 +18,10 @@
package org.keycloak.services.clientpolicy; package org.keycloak.services.clientpolicy;
import java.util.List;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
import org.keycloak.Config; import org.keycloak.Config;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory; import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.representations.idm.ClientProfileRepresentation;
/** /**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a> * @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
@ -33,14 +30,9 @@ public class DefaultClientPolicyManagerFactory implements ClientPolicyManagerFac
private static final Logger logger = Logger.getLogger(DefaultClientPolicyManagerFactory.class); private static final Logger logger = Logger.getLogger(DefaultClientPolicyManagerFactory.class);
// Global (builtin) profiles are loaded on booting keycloak at once.
// therefore, their representations are kept and remain unchanged.
// these are shared among all realms.
private volatile List<ClientProfileRepresentation> globalClientProfiles;
@Override @Override
public ClientPolicyManager create(KeycloakSession session) { public ClientPolicyManager create(KeycloakSession session) {
return new DefaultClientPolicyManager(session, () -> getGlobalClientProfiles(session)); return new DefaultClientPolicyManager(session);
} }
@Override @Override
@ -62,26 +54,4 @@ public class DefaultClientPolicyManagerFactory implements ClientPolicyManagerFac
public String getId() { public String getId() {
return "default"; return "default";
} }
/**
* When this method is called, assumption is that CLIENT_POLICIES feature is enabled
*/
protected List<ClientProfileRepresentation> getGlobalClientProfiles(KeycloakSession session) {
if (globalClientProfiles == null) {
synchronized (this) {
if (globalClientProfiles == null) {
logger.trace("LOAD GLOBAL CLIENT PROFILES ON KEYCLOAK");
// load builtin profiles from keycloak-services
try {
this.globalClientProfiles = ClientPoliciesUtil.getValidatedGlobalClientProfilesRepresentation(session, getClass().getResourceAsStream("/keycloak-default-client-profiles.json"));
} catch (ClientPolicyException cpe) {
logger.warnv("LOAD GLOBAL PROFILES ON KEYCLOAK FAILED :: error = {0}, error detail = {1}", cpe.getError(), cpe.getErrorDetail());
throw new IllegalStateException(cpe);
}
}
}
}
return globalClientProfiles;
}
} }

View file

@ -18,11 +18,11 @@
package org.keycloak.services.resources.admin; package org.keycloak.services.resources.admin;
import org.eclipse.microprofile.openapi.annotations.Operation; import org.eclipse.microprofile.openapi.annotations.Operation;
import jakarta.ws.rs.BadRequestException;
import jakarta.ws.rs.Consumes; import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET; import jakarta.ws.rs.GET;
import jakarta.ws.rs.PUT; import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Produces; import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response;
@ -66,11 +66,11 @@ public class ClientPoliciesResource {
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
@Tag(name = KeycloakOpenAPI.Admin.Tags.REALMS_ADMIN) @Tag(name = KeycloakOpenAPI.Admin.Tags.REALMS_ADMIN)
@Operation() @Operation()
public ClientPoliciesRepresentation getPolicies() { public ClientPoliciesRepresentation getPolicies(@QueryParam("include-global-policies") boolean includeGlobalPolicies) {
auth.realm().requireViewRealm(); auth.realm().requireViewRealm();
try { try {
return session.clientPolicy().getClientPolicies(realm); return session.clientPolicy().getClientPolicies(realm, includeGlobalPolicies);
} catch (ClientPolicyException e) { } catch (ClientPolicyException e) {
throw ErrorResponse.error(e.getError(), Response.Status.BAD_REQUEST); throw ErrorResponse.error(e.getError(), Response.Status.BAD_REQUEST);
} }

View file

@ -0,0 +1,56 @@
/*
* Copyright 2024 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.services.securityprofile;
import java.util.List;
import org.keycloak.representations.idm.ClientPolicyRepresentation;
import org.keycloak.representations.idm.ClientProfileRepresentation;
import org.keycloak.representations.idm.SecurityProfileConfiguration;
import org.keycloak.securityprofile.SecurityProfileProvider;
/**
*
* @author rmartinc
*/
public class DefaultSecurityProfileProvider implements SecurityProfileProvider {
private SecurityProfileConfiguration config;
public DefaultSecurityProfileProvider(SecurityProfileConfiguration config) {
this.config = config;
}
@Override
public String getName() {
return config.getName();
}
@Override
public List<ClientProfileRepresentation> getDefaultClientProfiles() {
return config.getDefaultClientProfiles();
}
@Override
public List<ClientPolicyRepresentation> getDefaultClientPolicies() {
return config.getDefaultClientPolicies();
}
@Override
public void close() {
config = null;
}
}

View file

@ -0,0 +1,116 @@
/*
* Copyright 2024 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.services.securityprofile;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import org.jboss.logging.Logger;
import org.keycloak.Config;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.provider.ProviderConfigurationBuilder;
import org.keycloak.representations.idm.SecurityProfileConfiguration;
import org.keycloak.securityprofile.SecurityProfileProvider;
import org.keycloak.securityprofile.SecurityProfileProviderFactory;
import org.keycloak.services.clientpolicy.ClientPoliciesUtil;
import org.keycloak.services.clientpolicy.ClientPolicyException;
import org.keycloak.util.JsonSerialization;
/**
* The default implementation for the security profile. It reads the configuration
* from the file configured.
* @author rmartinc
*/
public class DefaultSecurityProfileProviderFactory implements SecurityProfileProviderFactory {
private static final Logger logger = Logger.getLogger(DefaultSecurityProfileProviderFactory.class);
private String name;
private volatile SecurityProfileConfiguration configuration;
@Override
public SecurityProfileProvider create(KeycloakSession session) {
return new DefaultSecurityProfileProvider(readConfiguration(session));
}
@Override
public List<ProviderConfigProperty> getConfigMetadata() {
return ProviderConfigurationBuilder.create()
.property()
.name("name")
.type("string")
.helpText("Name for the security configuration file to use. File `name`.json is searched in classapth and `conf` installation folder.")
.add()
.build();
}
@Override
public void init(Config.Scope config) {
this.name = config.get("name", "none-security-profile");
}
@Override
public void postInit(KeycloakSessionFactory factory) {
// no-op
}
@Override
public void close() {
// no-op
}
@Override
public String getId() {
return "default";
}
protected SecurityProfileConfiguration readConfiguration(KeycloakSession session) {
if (configuration == null) {
synchronized (this) {
SecurityProfileConfiguration conf;
final String file = name + ".json";
try {
// first try to read the json configuration file from classpath
InputStream tmp = getClass().getResourceAsStream("/" + file);
if (tmp == null) {
Path path = Paths.get(System.getProperty("jboss.server.config.dir")).resolve(file);
if (!Files.isReadable(path)) {
throw new IOException(String.format("File %s does not exists in the conf folder", file));
}
tmp = Files.newInputStream(path);
}
try (InputStream is = tmp) {
conf = JsonSerialization.readValue(is, SecurityProfileConfiguration.class);
}
// read the list of client profiles and policies validated
conf.setDefaultClientProfiles(ClientPoliciesUtil.readGlobalClientProfilesRepresentation(session, conf.getClientProfiles()));
conf.setDefaultClientPolicies(ClientPoliciesUtil.readGlobalClientPoliciesRepresentation(session, conf.getClientPolicies(),
conf.getDefaultClientProfiles()));
} catch (ClientPolicyException|IOException e) {
throw new IllegalStateException("Error loading the security profile from file " + file, e);
}
this.configuration = conf;
}
}
return this.configuration;
}
}

View file

@ -0,0 +1 @@
org.keycloak.services.securityprofile.DefaultSecurityProfileProviderFactory

View file

@ -0,0 +1,68 @@
{
"policies": [
{
"name": "Openid-connect OAuth 2.1 confidential client",
"description": "Openid-connect confidential client policy to ensure OAuth 2.1 specification.",
"enabled": true,
"conditions": [
{
"condition": "client-type",
"configuration": {
"protocol": "openid-connect"
}
},
{
"condition": "client-access-type",
"configuration": {
"type": [
"confidential"
]
}
}
],
"profiles": [
"oauth-2-1-for-confidential-client"
]
},
{
"name": "Openid-connect OAuth 2.1 public client",
"description": "Openid-connect confidential client policy to ensure OAuth 2.1 specification.",
"enabled": true,
"conditions": [
{
"condition": "client-type",
"configuration": {
"protocol": "openid-connect"
}
},
{
"condition": "client-access-type",
"configuration": {
"type": [
"public"
]
}
}
],
"profiles": [
"oauth-2-1-for-public-client"
]
},
{
"name": "Saml secure client (signatures, post, https)",
"description": "Saml policy to ensure signatures, POST binding and https URLs.",
"enabled": true,
"conditions": [
{
"condition": "client-type",
"configuration": {
"protocol": "saml"
}
}
],
"profiles": [
"saml-security-profile"
]
}
]
}

View file

@ -0,0 +1,5 @@
{
"name":"lax",
"client-profiles":"keycloak-default-client-profiles",
"client-policies":null
}

View file

@ -0,0 +1,5 @@
{
"name":"none",
"client-profiles":"keycloak-default-client-profiles",
"client-policies":null
}

View file

@ -0,0 +1,5 @@
{
"name":"strict",
"client-profiles":"keycloak-default-client-profiles",
"client-policies":"keycloak-strict-client-policies"
}

View file

@ -0,0 +1,81 @@
/*
* Copyright 2024 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.services.securityprofile;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import org.junit.Assert;
import org.junit.BeforeClass;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.Parameterized;
import org.junit.runners.Parameterized.Parameters;
import org.keycloak.common.Profile;
import org.keycloak.common.crypto.CryptoIntegration;
import org.keycloak.common.crypto.CryptoProvider;
import org.keycloak.models.KeycloakSession;
import org.keycloak.securityprofile.SecurityProfileProvider;
import org.keycloak.securityprofile.SecurityProfileProviderFactory;
import org.keycloak.services.resteasy.ResteasyKeycloakSession;
import org.keycloak.services.resteasy.ResteasyKeycloakSessionFactory;
import org.keycloak.utils.ScopeUtil;
/**
*
* @author rmartinc
*/
@RunWith(Parameterized.class)
public class DefaultSecurityProfileProverFactoryTest {
private static KeycloakSession session;
private final String name;
@Parameters
public static Collection<Object[]> data() {
// return of json profile files packed with keycloak
return Arrays.asList(new Object[][]{
{"none-security-profile"},
{"lax-security-profile"},
{"strict-security-profile"},
});
}
public DefaultSecurityProfileProverFactoryTest(String name) {
this.name = name;
}
@BeforeClass
public static void beforeClass() {
Profile.defaults();
CryptoIntegration.init(CryptoProvider.class.getClassLoader());
ResteasyKeycloakSessionFactory sessionFactory = new ResteasyKeycloakSessionFactory();
sessionFactory.init();
session = new ResteasyKeycloakSession(sessionFactory);
}
@Test
public void testConfigurationFile() {
SecurityProfileProviderFactory fact = new DefaultSecurityProfileProviderFactory();
fact.init(ScopeUtil.createScope(Collections.singletonMap("name", name)));
SecurityProfileProvider prov = fact.create(session);
Assert.assertNotNull(prov.getName());
Assert.assertNotNull(prov.getDefaultClientProfiles());
Assert.assertNotNull(prov.getDefaultClientPolicies());
}
}

View file

@ -35,4 +35,10 @@ public @interface SetDefaultProvider {
boolean beforeEnableFeature() default true; boolean beforeEnableFeature() default true;
String defaultProvider() default ""; String defaultProvider() default "";
/**
* Configuration for the provider in the form option1, value1, option2, value2
* @return The config options and values
*/
String[] config() default {};
} }

View file

@ -9,9 +9,12 @@ import org.keycloak.testsuite.arquillian.containers.AbstractQuarkusDeployableCon
import org.keycloak.utils.StringUtil; import org.keycloak.utils.StringUtil;
import java.util.Collections; import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
public class SpiProvidersSwitchingUtils { public class SpiProvidersSwitchingUtils {
@ -28,24 +31,58 @@ public class SpiProvidersSwitchingUtils {
} }
@Override @Override
public void setDefaultProvider(Container container, String spiName, String providerId) { public void setDefaultProvider(Container container, String spiName, String providerId, String... config) {
System.setProperty(getProviderPropertyName(spiName), providerId); System.setProperty(getProviderPropertyName(spiName), providerId);
if (config != null) {
String optionName = null;
for (String c : config) {
if (optionName == null) {
optionName = c;
} else {
System.setProperty(getProviderPropertyNameConfig(spiName, providerId, optionName), c);
optionName = null;
}
}
}
} }
@Override @Override
public void removeProviderConfig(Container container, String spiName) { public void removeProviderConfig(Container container, String spiName) {
System.clearProperty(getProviderPropertyName(spiName)); List<String> toRemove = System.getProperties().stringPropertyNames().stream()
.filter(p -> p.startsWith(getProviderPropertyNamePrefix(spiName)))
.collect(Collectors.toList());
toRemove.forEach(p -> System.clearProperty(p));
}
private String getProviderPropertyNamePrefix(String spiName) {
return "keycloak." + spiName + ".";
} }
private String getProviderPropertyName(String spiName) { private String getProviderPropertyName(String spiName) {
return "keycloak." + spiName + ".provider"; return getProviderPropertyNamePrefix(spiName) + "provider";
}
private String getProviderPropertyNameConfig(String spiName, String providerId, String configName) {
return getProviderPropertyNamePrefix(spiName) + providerId + "." + configName;
} }
}, },
QUARKUS { QUARKUS {
@Override @Override
public void setDefaultProvider(Container container, String spiName, String providerId) { public void setDefaultProvider(Container container, String spiName, String providerId, String... config) {
getQuarkusContainer(container).setAdditionalBuildArgs(Collections List<String> args = new LinkedList<>();
.singletonList(KEYCLOAKX_ARG_SPI_PREFIX + toDashCase(spiName) + "-provider=" + providerId)); args.add(KEYCLOAKX_ARG_SPI_PREFIX + toDashCase(spiName) + "-provider=" + providerId);
if (config != null) {
String optionName = null;
for (String c : config) {
if (optionName == null) {
optionName = c;
} else {
args.add(KEYCLOAKX_ARG_SPI_PREFIX + toDashCase(spiName) + "-" + providerId + "-" + optionName + "=" + c);
optionName = null;
}
}
}
getQuarkusContainer(container).setAdditionalBuildArgs(args);
} }
@Override @Override
@ -92,10 +129,10 @@ public class SpiProvidersSwitchingUtils {
return Optional.empty(); return Optional.empty();
} }
public abstract void setDefaultProvider(Container container, String spiName, String providerId); public abstract void setDefaultProvider(Container container, String spiName, String providerId, String... config);
public void updateDefaultProvider(Container container, String spiName, String providerId) { public void updateDefaultProvider(Container container, String spiName, String providerId, String... config) {
setDefaultProvider(container, spiName, providerId); setDefaultProvider(container, spiName, providerId, config);
} }
public void unsetDefaultProvider(Container container, String spiName) { public void unsetDefaultProvider(Container container, String spiName) {
@ -125,9 +162,9 @@ public class SpiProvidersSwitchingUtils {
if (annotation.onlyUpdateDefault()) { if (annotation.onlyUpdateDefault()) {
spiSwitcher.getCurrentDefaultProvider(container, spi, annotation).ifPresent(v -> originalSettingsBackup.put(spi, v)); spiSwitcher.getCurrentDefaultProvider(container, spi, annotation).ifPresent(v -> originalSettingsBackup.put(spi, v));
spiSwitcher.updateDefaultProvider(container, spi, annotation.providerId()); spiSwitcher.updateDefaultProvider(container, spi, annotation.providerId(), annotation.config());
} else { } else {
spiSwitcher.setDefaultProvider(container, spi, annotation.providerId()); spiSwitcher.setDefaultProvider(container, spi, annotation.providerId(), annotation.config());
} }
} }

View file

@ -304,7 +304,6 @@ public abstract class AbstractClientPoliciesTest extends AbstractKeycloakTest {
createClientScopesConditionConfig(ClientScopesConditionFactory.OPTIONAL, Arrays.asList(SAMPLE_CLIENT_ROLE))) createClientScopesConditionConfig(ClientScopesConditionFactory.OPTIONAL, Arrays.asList(SAMPLE_CLIENT_ROLE)))
.addProfile("ordinal-test-profile") .addProfile("ordinal-test-profile")
.addProfile("lack-of-builtin-field-test-profile") .addProfile("lack-of-builtin-field-test-profile")
.addProfile("ordinal-test-profile")
.toRepresentation(); .toRepresentation();

View file

@ -0,0 +1,200 @@
/*
* Copyright 2024 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.testsuite.securityprofile;
import jakarta.ws.rs.BadRequestException;
import jakarta.ws.rs.core.Response;
import java.util.Collections;
import java.util.stream.Collectors;
import org.hamcrest.MatcherAssert;
import org.hamcrest.Matchers;
import org.junit.Assert;
import org.junit.Test;
import org.keycloak.OAuthErrorException;
import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.saml.SamlConfigAttributes;
import org.keycloak.representations.idm.ClientPoliciesRepresentation;
import org.keycloak.representations.idm.ClientPolicyRepresentation;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.ErrorRepresentation;
import org.keycloak.representations.idm.OAuth2ErrorRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.testsuite.AbstractTestRealmKeycloakTest;
import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.arquillian.annotation.SetDefaultProvider;
import org.keycloak.testsuite.auth.page.login.OIDCLogin;
import org.keycloak.testsuite.util.ClientBuilder;
@SetDefaultProvider(spi = "security-profile", providerId = "default", config = {"name", "strict-security-profile"})
public class StrictSecurityProfileTest extends AbstractTestRealmKeycloakTest {
@Override
public void configureTestRealm(RealmRepresentation testRealm) {
// no-op
}
@Test
public void testGlobalClientPolicies() {
RealmResource realm = testRealm();
// test there are policies defined in the endpoint
ClientPoliciesRepresentation policies = realm.clientPoliciesPoliciesResource().getPolicies(true);
Assert.assertNotNull(policies.getGlobalPolicies());
MatcherAssert.assertThat(policies.getGlobalPolicies().stream().map(ClientPolicyRepresentation::getName).collect(Collectors.toList()),
Matchers.containsInAnyOrder("Openid-connect OAuth 2.1 confidential client",
"Openid-connect OAuth 2.1 public client",
"Saml secure client (signatures, post, https)"));
// try creating a global policy fails
ClientPoliciesRepresentation policiesRep = new ClientPoliciesRepresentation();
policiesRep.setPolicies(Collections.singletonList(policies.getGlobalPolicies().iterator().next()));
BadRequestException e = Assert.assertThrows(BadRequestException.class,
() -> realm.clientPoliciesPoliciesResource().updatePolicies(policiesRep));
ErrorRepresentation error = e.getResponse().readEntity(ErrorRepresentation.class);
MatcherAssert.assertThat(error.getErrorMessage(), Matchers.containsString("duplicated as a global policy"));
}
@Test
public void testCreatePublicOpenIdConnectClientSecure() {
RealmResource realm = testRealm();
ClientRepresentation clientRep = ClientBuilder.create()
.name("test-client-policy-app")
.clientId("test-client-policy-app")
.publicClient()
.protocol(OIDCLogin.OIDC)
.baseUrl("https://www.keycloak.org")
.redirectUris("https://www.keycloak.org")
.build();
clientRep.setImplicitFlowEnabled(true);
OIDCAdvancedConfigWrapper wrapper = OIDCAdvancedConfigWrapper.fromClientRepresentation(clientRep);
wrapper.setPostLogoutRedirectUris(Collections.singletonList("https://www.keycloak.org"));
wrapper.setPkceCodeChallengeMethod(OIDCLoginProtocol.PKCE_METHOD_PLAIN);
// set the redirect uri to unsecure http
clientRep.setRedirectUris(Collections.singletonList("http://www.keycloak.org"));
Response resp = realm.clients().create(clientRep);
Assert.assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), resp.getStatus());
OAuth2ErrorRepresentation error = resp.readEntity(OAuth2ErrorRepresentation.class);
Assert.assertEquals(OAuthErrorException.INVALID_REQUEST, error.getError());
Assert.assertEquals("Invalid Redirect Uri: invalid uri", error.getErrorDescription());
clientRep.setRedirectUris(Collections.singletonList("https://www.keycloak.org"));
// create OK
resp = realm.clients().create(clientRep);
Assert.assertEquals(Response.Status.CREATED.getStatusCode(), resp.getStatus());
String id = ApiUtil.getCreatedId(resp);
getCleanup().addClientUuid(id);
// check everything is auto-configure for security as the policy has auto-configure
clientRep = realm.clients().get(id).toRepresentation();
wrapper = OIDCAdvancedConfigWrapper.fromClientRepresentation(clientRep);
Assert.assertEquals(OIDCLoginProtocol.PKCE_METHOD_S256, wrapper.getPkceCodeChallengeMethod());
Assert.assertFalse(clientRep.isImplicitFlowEnabled());
Assert.assertFalse(clientRep.isDirectAccessGrantsEnabled());
}
@Test
public void testCreateConfidentialOpenIdConnectClientSecure() {
RealmResource realm = testRealm();
ClientRepresentation clientRep = ClientBuilder.create()
.name("test-client-policy-app")
.clientId("test-client-policy-app")
.protocol(OIDCLogin.OIDC)
.baseUrl("https://www.keycloak.org")
.redirectUris("https://www.keycloak.org")
.directAccessGrants()
.build();
clientRep.setImplicitFlowEnabled(true);
OIDCAdvancedConfigWrapper wrapper = OIDCAdvancedConfigWrapper.fromClientRepresentation(clientRep);
wrapper.setPostLogoutRedirectUris(Collections.singletonList("https://www.keycloak.org"));
wrapper.setPkceCodeChallengeMethod(OIDCLoginProtocol.PKCE_METHOD_PLAIN);
wrapper.setUseMtlsHoKToken(false);
// set the redirect uri to unsecure http
clientRep.setRedirectUris(Collections.singletonList("http://www.keycloak.org"));
Response resp = realm.clients().create(clientRep);
Assert.assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), resp.getStatus());
OAuth2ErrorRepresentation error = resp.readEntity(OAuth2ErrorRepresentation.class);
Assert.assertEquals(OAuthErrorException.INVALID_REQUEST, error.getError());
Assert.assertEquals("Invalid Redirect Uri: invalid uri", error.getErrorDescription());
clientRep.setRedirectUris(Collections.singletonList("https://www.keycloak.org"));
// create OK
resp = realm.clients().create(clientRep);
Assert.assertEquals(Response.Status.CREATED.getStatusCode(), resp.getStatus());
String id = ApiUtil.getCreatedId(resp);
getCleanup().addClientUuid(id);
// check everything is auto-configure for security as the policy has auto-configure
clientRep = realm.clients().get(id).toRepresentation();
wrapper = OIDCAdvancedConfigWrapper.fromClientRepresentation(clientRep);
Assert.assertEquals(OIDCLoginProtocol.PKCE_METHOD_S256, wrapper.getPkceCodeChallengeMethod());
Assert.assertTrue(wrapper.isUseMtlsHokToken());
Assert.assertFalse(clientRep.isImplicitFlowEnabled());
Assert.assertFalse(clientRep.isDirectAccessGrantsEnabled());
}
@Test
public void testCreateSamlClientSecure() {
RealmResource realm = testRealm();
// setup a perfect client for SAML secure
ClientRepresentation clientRep = ClientBuilder.create()
.name("test-client-policy-app")
.clientId("test-client-policy-app")
.protocol(OIDCLogin.SAML)
.baseUrl("https://www.keycloak.org")
.redirectUris("https://www.keycloak.org")
.adminUrl("https://www.keycloak.org")
.attribute(SamlConfigAttributes.SAML_FORCE_POST_BINDING, "true")
.attribute(SamlConfigAttributes.SAML_CLIENT_SIGNATURE_ATTRIBUTE, "true")
.attribute(SamlConfigAttributes.SAML_SERVER_SIGNATURE, "true")
.build();
// change base url to unsecure http
clientRep.setBaseUrl("http://www.keycloak.org");
Response resp = realm.clients().create(clientRep);
Assert.assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), resp.getStatus());
OAuth2ErrorRepresentation error = resp.readEntity(OAuth2ErrorRepresentation.class);
Assert.assertEquals(OAuthErrorException.INVALID_CLIENT_METADATA, error.getError());
MatcherAssert.assertThat(error.getErrorDescription(), Matchers.startsWith("Non secure scheme for"));
clientRep.setBaseUrl("https://www.keycloak.org");
// change force post to false
clientRep.getAttributes().put(SamlConfigAttributes.SAML_FORCE_POST_BINDING, "false");
resp = realm.clients().create(clientRep);
Assert.assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), resp.getStatus());
error = resp.readEntity(OAuth2ErrorRepresentation.class);
Assert.assertEquals(OAuthErrorException.INVALID_CLIENT_METADATA, error.getError());
Assert.assertEquals("Force POST binding is not enabled", error.getErrorDescription());
clientRep.getAttributes().put(SamlConfigAttributes.SAML_FORCE_POST_BINDING, "true");
// remove client signature
clientRep.getAttributes().put(SamlConfigAttributes.SAML_CLIENT_SIGNATURE_ATTRIBUTE, "false");
resp = realm.clients().create(clientRep);
Assert.assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), resp.getStatus());
error = resp.readEntity(OAuth2ErrorRepresentation.class);
Assert.assertEquals(OAuthErrorException.INVALID_CLIENT_METADATA, error.getError());
Assert.assertEquals("Signatures not ensured for the client. Ensure Client signature required and Sign documents or Sign assertions are ON",
error.getErrorDescription());
clientRep.getAttributes().put(SamlConfigAttributes.SAML_CLIENT_SIGNATURE_ATTRIBUTE, "true");
// create OK
resp = realm.clients().create(clientRep);
Assert.assertEquals(Response.Status.CREATED.getStatusCode(), resp.getStatus());
getCleanup().addClientUuid(ApiUtil.getCreatedId(resp));
}
}

View file

@ -265,5 +265,12 @@
"saml-artifact-resolver": { "saml-artifact-resolver": {
"provider": "${keycloak.saml-artifact-resolver.provider:default}" "provider": "${keycloak.saml-artifact-resolver.provider:default}"
},
"security-profile": {
"provider": "${keycloak.security-profile.provider:default}",
"default": {
"name":"${keycloak.security-profile.default.name:none-security-profile}"
}
} }
} }

View file

@ -35,6 +35,7 @@ providers,4
runonserver,6 runonserver,6
saml,6 saml,6
script,6 script,6
securityprofile,4
session,6 session,6
sessionlimits,6 sessionlimits,6
ssl,6 ssl,6
@ -46,4 +47,4 @@ util,4
validation,6 validation,6
vault,4 vault,4
welcomepage,6 welcomepage,6
x509,4 x509,4

View file

@ -184,6 +184,12 @@
"sslCertChainPrefix": "x-ssl-client-cert-chain", "sslCertChainPrefix": "x-ssl-client-cert-chain",
"certificateChainLength": 1 "certificateChainLength": 1
} }
} },
"security-profile": {
"provider": "${keycloak.security-profile.provider:default}",
"default": {
"name":"${keycloak.security-profile.default.name:none-security-profile}"
}
}
} }