From dea54e674a78acb46927c25860069543158b78f9 Mon Sep 17 00:00:00 2001 From: Jenny <32821331+jenny-s51@users.noreply.github.com> Date: Sun, 23 Jan 2022 15:21:19 -0500 Subject: [PATCH] Clients: Authorization -> Evaluate tab (#1861) --- src/clients/ClientDetails.tsx | 35 ++ .../authorization/AuthorizationEvaluate.tsx | 425 ++++++++++++++++++ src/clients/help.ts | 15 + src/clients/messages.ts | 12 + .../attribute-form/attribute-form.css | 4 + .../attribute-input/AttributeInput.tsx | 163 ++++--- 6 files changed, 584 insertions(+), 70 deletions(-) create mode 100644 src/clients/authorization/AuthorizationEvaluate.tsx diff --git a/src/clients/ClientDetails.tsx b/src/clients/ClientDetails.tsx index fc629e2030..f5ec622f22 100644 --- a/src/clients/ClientDetails.tsx +++ b/src/clients/ClientDetails.tsx @@ -60,6 +60,9 @@ import { AuthorizationResources } from "./authorization/Resources"; import { AuthorizationScopes } from "./authorization/Scopes"; import { AuthorizationPolicies } from "./authorization/Policies"; import { AuthorizationPermissions } from "./authorization/Permissions"; +import { AuthorizationEvaluate } from "./authorization/AuthorizationEvaluate"; +import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation"; +import type RoleRepresentation from "@keycloak/keycloak-admin-client/lib/defs/roleRepresentation"; type ClientDetailHeaderProps = { onChange: (value: boolean) => void; @@ -195,6 +198,24 @@ export default function ClientDetails() { }); const [client, setClient] = useState(); + const [clients, setClients] = useState([]); + const [clientRoles, setClientRoles] = useState([]); + const [users, setUsers] = useState([]); + + useFetch( + () => + Promise.all([ + adminClient.clients.find(), + adminClient.roles.find(), + adminClient.users.find(), + ]), + ([clients, roles, users]) => { + setClients(clients); + setClientRoles(roles); + setUsers(users); + }, + [] + ); const loader = async () => { const roles = await adminClient.clients.listRoles({ id: clientId }); @@ -508,6 +529,20 @@ export default function ClientDetails() { > + {t("evaluate")}} + > + setupForm(client)} + /> + )} diff --git a/src/clients/authorization/AuthorizationEvaluate.tsx b/src/clients/authorization/AuthorizationEvaluate.tsx new file mode 100644 index 0000000000..269e4617e7 --- /dev/null +++ b/src/clients/authorization/AuthorizationEvaluate.tsx @@ -0,0 +1,425 @@ +import type ClientRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientRepresentation"; +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + FormGroup, + Select, + SelectVariant, + SelectOption, + PageSection, + ActionGroup, + Button, + Switch, + ExpandableSection, + TextInput, +} from "@patternfly/react-core"; +import { Controller, useFormContext } from "react-hook-form"; + +import { FormAccess } from "../../components/form-access/FormAccess"; +import { HelpItem } from "../../components/help-enabler/HelpItem"; +import { FormPanel } from "../../components/scroll-form/FormPanel"; +import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation"; +import type RoleRepresentation from "@keycloak/keycloak-admin-client/lib/defs/roleRepresentation"; +import { useAdminClient, useFetch } from "../../context/auth/AdminClient"; +import type ResourceEvaluation from "@keycloak/keycloak-admin-client/lib/defs/resourceEvaluation"; +import { useRealm } from "../../context/realm-context/RealmContext"; +import { AttributeInput } from "../../components/attribute-input/AttributeInput"; +import { defaultContextAttributes } from "../utils"; +import type ResourceRepresentation from "@keycloak/keycloak-admin-client/lib/defs/resourceRepresentation"; +import { useParams } from "react-router-dom"; +import type ScopeRepresentation from "@keycloak/keycloak-admin-client/lib/defs/scopeRepresentation"; + +export type AttributeType = { + key: string; + name: string; + custom?: boolean; + values?: { + [key: string]: string; + }[]; +}; + +type ClientSettingsProps = { + clients: ClientRepresentation[]; + clientName?: string; + save: () => void; + reset: () => void; + users: UserRepresentation[]; + clientRoles: RoleRepresentation[]; +}; + +export const AuthorizationEvaluate = ({ + clients, + clientRoles, + clientName, + users, + reset, +}: ClientSettingsProps) => { + const form = useFormContext(); + const { control } = form; + const { t } = useTranslation("clients"); + const adminClient = useAdminClient(); + const realm = useRealm(); + const { clientId } = useParams<{ clientId: string }>(); + + const [clientsDropdownOpen, setClientsDropdownOpen] = useState(false); + const [scopesDropdownOpen, setScopesDropdownOpen] = useState(false); + + const [userDropdownOpen, setUserDropdownOpen] = useState(false); + const [roleDropdownOpen, setRoleDropdownOpen] = useState(false); + const [isExpanded, setIsExpanded] = useState(false); + const [applyToResourceType, setApplyToResourceType] = useState(false); + const [resources, setResources] = useState([]); + const [scopes, setScopes] = useState([]); + const [selectedClient, setSelectedClient] = useState(); + const [selectedUser, setSelectedUser] = useState(); + + useFetch( + async () => + Promise.all([ + adminClient.clients.listResources({ + id: clientId, + }), + adminClient.clients.listAllScopes({ + id: clientId, + }), + ]), + ([resources, scopes]) => { + setResources(resources); + setScopes(scopes); + }, + [] + ); + + const evaluate = (formValues: ResourceEvaluation) => { + const resEval: ResourceEvaluation = { + roleIds: formValues.roleIds ?? [], + userId: selectedUser?.id!, + entitlements: false, + context: formValues.context, + resources: formValues.resources, + clientId: selectedClient?.id!, + }; + return adminClient.clients.evaluateResource( + { id: clientId!, realm: realm.realm }, + resEval + ); + }; + + return ( + + + + + } + fieldId="client" + > + ( + + )} + /> + + + } + fieldId="loginTheme" + > + ( + + )} + /> + + + } + fieldId="realmRole" + > + ( + + )} + /> + + + + + + + } + > + ( + { + onChange(value.toString()); + setApplyToResourceType(value); + }} + /> + )} + /> + + + {!applyToResourceType && ( + + } + helperTextInvalid={t("common:required")} + fieldId={name!} + > + item.name!)} + resources={resources} + isKeySelectable + name="resources" + /> + + )} + {applyToResourceType && ( + <> + + } + fieldId="client" + > + + + + } + fieldId="authScopes" + > + ( + + )} + /> + + + )} + setIsExpanded(!isExpanded)} + isExpanded={isExpanded} + > + + } + helperTextInvalid={t("common:required")} + fieldId={name!} + > + item.name + )} + isKeySelectable + name="context" + /> + + + + + + + + + + + ); +}; diff --git a/src/clients/help.ts b/src/clients/help.ts index 03f4922a78..634047dd3c 100644 --- a/src/clients/help.ts +++ b/src/clients/help.ts @@ -44,8 +44,23 @@ export default { "Default URL to use when the auth server needs to redirect or link back to the client.", adminURL: "URL to the admin interface of the client. Set this if the client supports the adapter REST API. This REST API allows the auth server to push revocation policies and other administrative tasks. Usually this is set to the base URL of the client.", + client: + "Select the client making this authorization request. If not provided, authorization requests would be done based on the client you are in.", clientId: "Specifies ID referenced in URI and tokens. For example 'my-client'. For SAML this is also the expected issuer value from authn requests", + selectUser: + "Select a user whose identity is going to be used to query permissions from the server.", + roles: "Select the roles you want to associate with the selected user.", + contextualAttributes: + "Any attribute provided by a running environment or execution context.", + resourceType: + "Specifies that this permission must be applied to all resource instances of a given type.", + applyToResourceType: + "Specifies if this permission should be applied to all resources with a given type. In this case, this permission will be evaluated for all instances of a given resource type.", + resources: + "Specifies that this permission must be applied to a specific resource instance.", + scopesSelect: + "Specifies that this permission must be applied to one or more scopes.", clientName: "Specifies display name of the client. For example 'My Client'. Supports keys for localized values as well. For example: ${my_client}", description: diff --git a/src/clients/messages.ts b/src/clients/messages.ts index a9a42d9d51..36ffa0c8de 100644 --- a/src/clients/messages.ts +++ b/src/clients/messages.ts @@ -36,7 +36,18 @@ export default { clientScopeError: "Could not update the scope mapping {{error}}", searchByName: "Search by name", setup: "Setup", + selectAUser: "Select a user", + client: "Client", evaluate: "Evaluate", + lastEvaluation: "Last Evaluation", + resourcesAndAuthScopes: "Resources and Authentication Scopes", + authScopes: "Authorization scopes", + anyResource: "Any resource", + anyScope: "Any scope", + selectScope: "Select a scope", + applyToResourceType: "Apply to Resource Type", + contextualInfo: "Contextual Information", + contextualAttributes: "Contextual Attributes", selectOrTypeAKey: "Select or type a key", custom: "Custom Attribute...", kc: { @@ -128,6 +139,7 @@ export default { "The permissions below will be removed when they are no longer used by other resources:", resourceDeletedSuccess: "The resource successfully deleted", resourceDeletedError: "Could not remove the resource {{error}}", + identityInformation: "Identity Information", permissions: "Permissions", searchForPermission: "Search for permission", deleteScope: "Permanently delete authorization scope?", diff --git a/src/components/attribute-form/attribute-form.css b/src/components/attribute-form/attribute-form.css index 1223a21c56..5baa85b741 100644 --- a/src/components/attribute-form/attribute-form.css +++ b/src/components/attribute-form/attribute-form.css @@ -27,5 +27,9 @@ } .pf-c-select.kc-attribute-value-selectable { + width: 500px; +} + +.pf-c-form-control.value-input { width: 350px; } \ No newline at end of file diff --git a/src/components/attribute-input/AttributeInput.tsx b/src/components/attribute-input/AttributeInput.tsx index 41fa2dac20..ea7e1b0945 100644 --- a/src/components/attribute-input/AttributeInput.tsx +++ b/src/components/attribute-input/AttributeInput.tsx @@ -1,9 +1,8 @@ import React, { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -// import { Controller, useFieldArray, useFormContext } from "react-hook-form"; +import { Controller, useFieldArray, useFormContext } from "react-hook-form"; import { Button, - FormGroup, Select, SelectOption, SelectVariant, @@ -18,10 +17,10 @@ import { Tr, } from "@patternfly/react-table"; import { MinusCircleIcon, PlusCircleIcon } from "@patternfly/react-icons"; -import { Controller, useFieldArray, useFormContext } from "react-hook-form"; import "../attribute-form/attribute-form.css"; import { defaultContextAttributes } from "../../clients/utils"; +import { camelCase } from "lodash"; import type ResourceRepresentation from "@keycloak/keycloak-admin-client/lib/defs/resourceRepresentation"; export type AttributeType = { @@ -44,10 +43,11 @@ export const AttributeInput = ({ name, isKeySelectable, selectableValues, + resources, }: AttributeInputProps) => { const { t } = useTranslation("common"); - const { control, register, watch } = useFormContext(); - const { fields, append, remove, insert } = useFieldArray({ + const { control, register, watch, getValues } = useFormContext(); + const { fields, append, remove } = useFieldArray({ control: control, name, }); @@ -58,63 +58,90 @@ export const AttributeInput = ({ } }, []); - const [isOpenArray, setIsOpenArray] = useState([false]); + const [isKeyOpenArray, setIsKeyOpenArray] = useState([false]); const watchLastKey = watch(`${name}[${fields.length - 1}].key`, ""); const watchLastValue = watch(`${name}[${fields.length - 1}].value`, ""); - const [valueOpen, setValueOpen] = useState(false); - const toggleSelect = (rowIndex: number, open: boolean) => { - const arr = [...isOpenArray]; + const [isValueOpenArray, setIsValueOpenArray] = useState([false]); + const toggleKeySelect = (rowIndex: number, open: boolean) => { + const arr = [...isKeyOpenArray]; arr[rowIndex] = open; - setIsOpenArray(arr); + setIsKeyOpenArray(arr); + }; + + const toggleValueSelect = (rowIndex: number, open: boolean) => { + const arr = [...isValueOpenArray]; + arr[rowIndex] = open; + setIsValueOpenArray(arr); }; const renderValueInput = (rowIndex: number, attribute: any) => { - const attributeValues = defaultContextAttributes.find( - (attr) => attr.key === attribute.key - )?.values; + let attributeValues: { key: string; name: string }[] | undefined = []; + + const scopeValues = resources?.find( + (resource) => resource.name === getValues().resources[rowIndex]?.key + )?.scopes; + + if (selectableValues) { + attributeValues = defaultContextAttributes.find( + (attr) => attr.name === getValues().context[rowIndex]?.key + )?.values; + } + + const getMessageBundleKey = (attributeName: string) => + camelCase(attributeName).replace(/\W/g, ""); return ( - {attributeValues?.length ? ( + {scopeValues?.length || attributeValues?.length ? ( ( )} /> ) : ( {isKeySelectable ? ( - - ( - toggleKeySelect(rowIndex, open)} + isOpen={isKeyOpenArray[rowIndex]} + variant={SelectVariant.typeahead} + typeAheadAriaLabel={t("clients:selectOrTypeAKey")} + placeholderText={t("clients:selectOrTypeAKey")} + selections={value} + onSelect={(_, v) => { + onChange(v); - toggleSelect(rowIndex, false); - }} - selections={value} - aria-label="some label" - isOpen={isOpenArray[rowIndex]} - > - {selectableValues?.map((attribute) => ( - - {t(`clients:${attribute}`)} - - ))} - - )} - /> - + toggleKeySelect(rowIndex, false); + }} + > + {selectableValues?.map((attribute) => ( + + {attribute} + + ))} + + )} + /> ) : ( { append({ key: "", value: "" }); if (isKeySelectable) { - setIsOpenArray([...isOpenArray, false]); + setIsKeyOpenArray([...isKeyOpenArray, false]); } }} icon={}