diff --git a/README.md b/README.md index 052c7b44..0411ea50 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ Start a database which will be used by `tackle-controls`: docker run -d -p 5432:5432 \ -e POSTGRES_USER=username \ -e POSTGRES_PASSWORD=password \ --e POSTGRES_DB=controls_db \ +-e POSTGRES_DB=db \ postgres:13.1 ``` @@ -76,8 +76,8 @@ Move your terminal to where you cloned `tackle-controls` and then: -Dquarkus.http.port=8080 \ -Dquarkus.datasource.username=username \ -Dquarkus.datasource.password=password \ --Dquarkus.datasource.jdbc.url=jdbc:postgresql://localhost:5432/controls_db \ --Dquarkus.oidc.client-id=controls-api \ +-Dquarkus.datasource.jdbc.url=jdbc:postgresql://localhost:5432/db \ +-Dquarkus.oidc.client-id=tackle-api \ -Dquarkus.oidc.credentials.secret=secret \ -Dquarkus.oidc.auth-server-url=http://localhost:8180/auth/realms/konveyor ``` diff --git a/docker-compose.yml b/docker-compose.yml index be0727d4..df2b450d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,11 +23,11 @@ services: ports: - 5433:5432 environment: - POSTGRES_DB: controls_db + POSTGRES_DB: db POSTGRES_USER: user POSTGRES_PASSWORD: password healthcheck: - test: ["CMD-SHELL", "pg_isready -U user -d controls_db"] + test: ["CMD-SHELL", "pg_isready -U user -d db"] interval: 10s timeout: 5s retries: 5 @@ -40,9 +40,9 @@ services: QUARKUS_HTTP_PORT: 8080 QUARKUS_DATASOURCE_USERNAME: user QUARKUS_DATASOURCE_PASSWORD: password - QUARKUS_DATASOURCE_JDBC_URL: jdbc:postgresql://controls-db:5432/controls_db + QUARKUS_DATASOURCE_JDBC_URL: jdbc:postgresql://controls-db:5432/db QUARKUS_OIDC_AUTH_SERVER_URL: http://keycloak:8080/auth/realms/konveyor - QUARKUS_OIDC_CLIENT_ID: controls-api + QUARKUS_OIDC_CLIENT_ID: tackle-api QUARKUS_OIDC_CREDENTIALS_SECRET: secret healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8080/controls/q/health"] @@ -60,11 +60,11 @@ services: ports: - 5434:5432 environment: - POSTGRES_DB: application_inventory_db + POSTGRES_DB: db POSTGRES_USER: user POSTGRES_PASSWORD: password healthcheck: - test: ["CMD-SHELL", "pg_isready -U user -d application_inventory_db"] + test: ["CMD-SHELL", "pg_isready -U user -d db"] interval: 10s timeout: 5s retries: 5 @@ -77,9 +77,9 @@ services: QUARKUS_HTTP_PORT: 8080 QUARKUS_DATASOURCE_USERNAME: user QUARKUS_DATASOURCE_PASSWORD: password - QUARKUS_DATASOURCE_JDBC_URL: jdbc:postgresql://application-inventory-db:5432/application_inventory_db + QUARKUS_DATASOURCE_JDBC_URL: jdbc:postgresql://application-inventory-db:5432/db QUARKUS_OIDC_AUTH_SERVER_URL: http://keycloak:8080/auth/realms/konveyor - QUARKUS_OIDC_CLIENT_ID: application-inventory-api + QUARKUS_OIDC_CLIENT_ID: tackle-api QUARKUS_OIDC_CREDENTIALS_SECRET: secret IO_TACKLE_APPLICATIONINVENTORY_SERVICES_CONTROLS_SERVICE: controls:8080 healthcheck: @@ -104,11 +104,11 @@ services: ports: - 5435:5432 environment: - POSTGRES_DB: pathfinder_db + POSTGRES_DB: db POSTGRES_USER: user POSTGRES_PASSWORD: password healthcheck: - test: ["CMD-SHELL", "pg_isready -U user -d pathfinder_db"] + test: ["CMD-SHELL", "pg_isready -U user -d db"] interval: 10s timeout: 5s retries: 5 @@ -121,9 +121,9 @@ services: QUARKUS_HTTP_PORT: 8080 QUARKUS_DATASOURCE_USERNAME: user QUARKUS_DATASOURCE_PASSWORD: password - QUARKUS_DATASOURCE_JDBC_URL: jdbc:postgresql://pathfinder-db:5432/pathfinder_db + QUARKUS_DATASOURCE_JDBC_URL: jdbc:postgresql://pathfinder-db:5432/db QUARKUS_OIDC_AUTH_SERVER_URL: http://keycloak:8080/auth/realms/konveyor - QUARKUS_OIDC_CLIENT_ID: pathfinder-api + QUARKUS_OIDC_CLIENT_ID: tackle-api QUARKUS_OIDC_CREDENTIALS_SECRET: secret healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8080/pathfinder/q/health"] diff --git a/konveyor-realm.json b/konveyor-realm.json index 171d99f6..117a6d7e 100644 --- a/konveyor-realm.json +++ b/konveyor-realm.json @@ -45,15 +45,60 @@ { "id": "d723b5ff-6c33-4152-b0ac-3ad9c1b79e6c", "name": "admin", - "composite": false, + "description": "Admins of Tackle", + "composite": true, + "composites": { + "client": { + "tackle-api": [ + "controls:write", + "inventory:application:write", + "inventory:application-import:write", + "inventory:application-dependency:write", + "inventory:application-review:write", + "pathfinder:assessment:write" + ] + } + }, "clientRole": false, "containerId": "konveyor", "attributes": {} }, { - "id": "88607edb-72b0-46fb-8d76-1a75a51a50f0", - "name": "user", - "composite": false, + "id": "f777a295-e6bc-45d5-8d84-e476a9021242", + "name": "architect", + "description": "Architects of Tackle", + "composite": true, + "composites": { + "client": { + "tackle-api": [ + "controls:write", + "inventory:application:write", + "inventory:application-import:write", + "inventory:application-dependency:write", + "inventory:application-review:write", + "pathfinder:assessment:write" + ] + } + }, + "clientRole": false, + "containerId": "konveyor", + "attributes": {} + }, + { + "id": "014309e1-d2cf-496f-9cc3-fb156c86e360", + "name": "migrator", + "description": "Migrators of Tackle", + "composite": true, + "composites": { + "client": { + "tackle-api": [ + "controls:read", + "inventory:application:read", + "inventory:application-import:read", + "pathfinder:assessment:write" + ] + } + }, "clientRole": false, "containerId": "konveyor", "attributes": {} @@ -291,7 +336,7 @@ } ], "security-admin-console": [], - "controls-api": [ + "tackle-api": [ { "id": "d056105b-2d60-4415-addb-9639ac3bfd74", "name": "uma_protection", @@ -299,6 +344,145 @@ "clientRole": true, "containerId": "5cda179e-8443-4467-a488-292976d25bf1", "attributes": {} + }, + { + "id": "691daa33-980e-419a-a63f-c86d07a03dae", + "name": "controls:read", + "composite": false, + "clientRole": true, + "containerId": "a19ae9a3-c9c1-4f4a-b39a-1bd5851eee90", + "attributes": {} + }, + { + "id": "574e82d1-9fc3-4166-bccd-f227afe02982", + "name": "controls:write", + "composite": true, + "composites": { + "client": { + "tackle-api": [ + "controls:read" + ] + } + }, + "clientRole": true, + "containerId": "a19ae9a3-c9c1-4f4a-b39a-1bd5851eee90", + "attributes": {} + }, + { + "id": "5cf0b4ca-7e26-4f86-a3d3-6dab69243d33", + "name": "inventory:application:read", + "composite": true, + "composites": { + "client": { + "tackle-api": [ + "controls:read", + "pathfinder:assessment:read" + ] + } + }, + "clientRole": true, + "containerId": "a19ae9a3-c9c1-4f4a-b39a-1bd5851eee90", + "attributes": {} + }, + { + "id": "f89d033f-1d90-4061-bfff-bf380aaa844a", + "name": "inventory:application:write", + "composite": true, + "composites": { + "client": { + "tackle-api": [ + "inventory:application:read" + ] + } + }, + "clientRole": true, + "containerId": "a19ae9a3-c9c1-4f4a-b39a-1bd5851eee90", + "attributes": {} + }, + { + "id": "f0eb63c2-033f-447a-85bd-55c83f1e3619", + "name": "inventory:application-import:read", + "composite": true, + "composites": { + "client": { + "tackle-api": [ + "inventory:application:read" + ] + } + }, + "clientRole": true, + "containerId": "a19ae9a3-c9c1-4f4a-b39a-1bd5851eee90", + "attributes": {} + }, + { + "id": "67082935-a948-4395-a0c8-a851773ca1ba", + "name": "inventory:application-import:write", + "composite": true, + "composites": { + "client": { + "tackle-api": [ + "inventory:application-import:read" + ] + } + }, + "clientRole": true, + "containerId": "a19ae9a3-c9c1-4f4a-b39a-1bd5851eee90", + "attributes": {} + }, + { + "id": "d8a18018-8ce5-497f-b7e3-de7b1112ac39", + "name": "inventory:application-dependency:write", + "composite": true, + "composites": { + "client": { + "tackle-api": [ + "inventory:application:read" + ] + } + }, + "clientRole": true, + "containerId": "a19ae9a3-c9c1-4f4a-b39a-1bd5851eee90", + "attributes": {} + }, + { + "id": "3ed86dfd-d354-4d70-9467-a0b8270bb37c", + "name": "inventory:application-review:write", + "composite": true, + "composites": { + "client": { + "tackle-api": [ + "inventory:application:read", + "pathfinder:assessment:write" + ] + } + }, + "clientRole": true, + "containerId": "a19ae9a3-c9c1-4f4a-b39a-1bd5851eee90", + "attributes": {} + }, + { + "id": "caf1d234-b5e4-4cbe-8915-24a9a7cc7ab1", + "name": "pathfinder:assessment:read", + "composite": false, + "clientRole": true, + "containerId": "a19ae9a3-c9c1-4f4a-b39a-1bd5851eee90", + "attributes": {} + }, + { + "id": "03f48fd4-de69-4baa-8177-cdab92d76209", + "name": "pathfinder:assessment:write", + "composite": true, + "composites": { + "client": { + "tackle-api": [ + "inventory:application:read", + "pathfinder:assessment:read" + ] + } + }, + "clientRole": true, + "containerId": "a19ae9a3-c9c1-4f4a-b39a-1bd5851eee90", + "attributes": {} } ], "admin-cli": [], @@ -389,7 +573,8 @@ "groups": [], "defaultRoles": [ "uma_authorization", - "offline_access" + "offline_access", + "migrator" ], "requiredCredentials": [ "password" @@ -454,8 +639,7 @@ ], "requiredActions": [], "realmRoles": [ - "admin", - "user" + "admin" ], "notBefore": 0, "groups": [] @@ -485,7 +669,7 @@ ], "requiredActions": [], "realmRoles": [ - "user" + "admin", "migrator" ], "notBefore": 0, "groups": [] @@ -493,11 +677,11 @@ { "id": "f2f78ee5-b6f9-4e7b-a837-8301a30a6d73", "createdTimestamp": 1602936578977, - "username": "service-account-controls-api", + "username": "service-account-tackle-api", "enabled": true, "totp": false, "emailVerified": false, - "serviceAccountClientId": "controls-api", + "serviceAccountClientId": "tackle-api", "disableableCredentialTypes": [], "requiredActions": [], "realmRoles": [ @@ -505,7 +689,7 @@ "offline_access" ], "clientRoles": { - "controls-api": [ + "tackle-api": [ "uma_protection" ], "account": [ @@ -821,7 +1005,7 @@ }, { "id": "5cda179e-8443-4467-a488-292976d25bf1", - "clientId": "controls-api", + "clientId": "tackle-api", "surrogateAuthRequired": false, "enabled": true, "alwaysDisplayInConsole": false, @@ -881,130 +1065,6 @@ "manage": true } }, - { - "id": "065c4bcf-379e-4a83-99d7-5491176185e2", - "clientId": "application-inventory-api", - "surrogateAuthRequired": false, - "enabled": true, - "alwaysDisplayInConsole": false, - "clientAuthenticatorType": "client-secret", - "secret": "secret", - "redirectUris": [ - "/*" - ], - "webOrigins": [], - "notBefore": 0, - "bearerOnly": false, - "consentRequired": false, - "standardFlowEnabled": true, - "implicitFlowEnabled": false, - "directAccessGrantsEnabled": true, - "serviceAccountsEnabled": false, - "publicClient": false, - "frontchannelLogout": false, - "protocol": "openid-connect", - "attributes": { - "saml.assertion.signature": "false", - "saml.force.post.binding": "false", - "saml.multivalued.roles": "false", - "saml.encrypt": "false", - "backchannel.logout.revoke.offline.tokens": "false", - "saml.server.signature": "false", - "saml.server.signature.keyinfo.ext": "false", - "exclude.session.state.from.auth.response": "false", - "backchannel.logout.session.required": "true", - "client_credentials.use_refresh_token": "false", - "saml_force_name_id_format": "false", - "saml.client.signature": "false", - "tls.client.certificate.bound.access.tokens": "false", - "saml.authnstatement": "false", - "display.on.consent.screen": "false", - "saml.onetimeuse.condition": "false" - }, - "authenticationFlowBindingOverrides": {}, - "fullScopeAllowed": true, - "nodeReRegistrationTimeout": -1, - "defaultClientScopes": [ - "web-origins", - "role_list", - "roles", - "profile", - "email" - ], - "optionalClientScopes": [ - "address", - "phone", - "offline_access", - "microprofile-jwt" - ], - "access": { - "view": true, - "configure": true, - "manage": true - } - }, - { - "id": "7f4a9ed7-3554-4aef-955a-a5737fb942f3", - "clientId": "pathfinder-api", - "surrogateAuthRequired": false, - "enabled": true, - "alwaysDisplayInConsole": false, - "clientAuthenticatorType": "client-secret", - "secret": "secret", - "redirectUris": [ - "/*" - ], - "webOrigins": [], - "notBefore": 0, - "bearerOnly": false, - "consentRequired": false, - "standardFlowEnabled": true, - "implicitFlowEnabled": false, - "directAccessGrantsEnabled": true, - "serviceAccountsEnabled": false, - "publicClient": false, - "frontchannelLogout": false, - "protocol": "openid-connect", - "attributes": { - "saml.assertion.signature": "false", - "saml.force.post.binding": "false", - "saml.multivalued.roles": "false", - "saml.encrypt": "false", - "backchannel.logout.revoke.offline.tokens": "false", - "saml.server.signature": "false", - "saml.server.signature.keyinfo.ext": "false", - "exclude.session.state.from.auth.response": "false", - "backchannel.logout.session.required": "true", - "client_credentials.use_refresh_token": "false", - "saml_force_name_id_format": "false", - "saml.client.signature": "false", - "tls.client.certificate.bound.access.tokens": "false", - "saml.authnstatement": "false", - "display.on.consent.screen": "false", - "saml.onetimeuse.condition": "false" - }, - "authenticationFlowBindingOverrides": {}, - "fullScopeAllowed": true, - "nodeReRegistrationTimeout": -1, - "defaultClientScopes": [ - "web-origins", - "role_list", - "roles", - "profile", - "email" - ], - "optionalClientScopes": [ - "address", - "phone", - "offline_access", - "microprofile-jwt" - ], - "access": { - "view": true, - "configure": true, - "manage": true - } - }, { "id": "695da74e-39ca-4e46-a2b6-6a673f92d4e2", "clientId": "tackle-ui", diff --git a/src/Constants.ts b/src/Constants.ts index 15f6d256..c45369ac 100644 --- a/src/Constants.ts +++ b/src/Constants.ts @@ -194,3 +194,19 @@ export enum ApplicationFilterKey { BUSINESS_SERVICE = "business_service", TAG = "tag", } + +// Keycloak RBAC + +export const KC_API_CLIENT = "tackle-api"; + +export type KcPermission = + | "controls:read" + | "controls:write" + | "inventory:application:read" + | "inventory:application:write" + | "inventory:application-import:read" + | "inventory:application-import:write" + | "inventory:application-dependency:write" + | "inventory:application-review:write" + | "pathfinder:assessment:read" + | "pathfinder:assessment:write"; diff --git a/src/ProtectedRoute.tsx b/src/ProtectedRoute.tsx new file mode 100644 index 00000000..6a4c4f7c --- /dev/null +++ b/src/ProtectedRoute.tsx @@ -0,0 +1,47 @@ +import React from "react"; +import { Route, RouteProps } from "react-router-dom"; + +import { + Bullseye, + EmptyState, + EmptyStateBody, + EmptyStateIcon, + EmptyStateVariant, + Title, +} from "@patternfly/react-core"; +import { WarningTriangleIcon } from "@patternfly/react-icons"; + +import { useKcPermission } from "shared/hooks"; + +import { KcPermission } from "Constants"; + +export interface IProtectedRouteProps extends RouteProps { + permissionsAllowed: KcPermission[]; +} + +export const ProtectedRoute: React.FC = ({ + permissionsAllowed, + ...rest +}) => { + const { isAllowed } = useKcPermission({ + permissionsAllowed, + }); + + const notAuthorizedState = ( + + + + + 403 Forbidden + + You are not allowed to access this page + + + ); + + if (!isAllowed) { + return notAuthorizedState}>; + } + + return ; +}; diff --git a/src/Routes.tsx b/src/Routes.tsx index 23911f7a..0678f5a9 100644 --- a/src/Routes.tsx +++ b/src/Routes.tsx @@ -1,7 +1,8 @@ -import React, { lazy, Suspense } from "react"; -import { Route, Switch, Redirect } from "react-router-dom"; +import { lazy, Suspense } from "react"; +import { Switch, Redirect } from "react-router-dom"; import { AppPlaceholder } from "shared/components"; +import { ProtectedRoute, IProtectedRouteProps } from "./ProtectedRoute"; import { Paths } from "./Paths"; const ApplicationInventory = lazy( @@ -11,21 +12,37 @@ const Reports = lazy(() => import("./pages/reports")); const Controls = lazy(() => import("./pages/controls")); export const AppRoutes = () => { - const routes = [ + const routes: IProtectedRouteProps[] = [ { component: ApplicationInventory, path: Paths.applicationInventory, exact: false, + permissionsAllowed: ["inventory:application:read"], + }, + { + component: Reports, + path: Paths.reports, + exact: false, + permissionsAllowed: ["inventory:application:read"], + }, + { + component: Controls, + path: Paths.controls, + exact: false, + permissionsAllowed: ["controls:read"], }, - { component: Reports, path: Paths.reports, exact: false }, - { component: Controls, path: Paths.controls, exact: false }, ]; return ( }> {routes.map(({ path, component, ...rest }, index) => ( - + ))} diff --git a/src/layout/SidebarApp/SidebarApp.tsx b/src/layout/SidebarApp/SidebarApp.tsx index 02d63419..2d0ec62c 100644 --- a/src/layout/SidebarApp/SidebarApp.tsx +++ b/src/layout/SidebarApp/SidebarApp.tsx @@ -5,33 +5,50 @@ import { Nav, NavItem, PageSidebar, NavList } from "@patternfly/react-core"; import { Paths } from "Paths"; import { LayoutTheme } from "../LayoutUtils"; +import { VisibilityByPermission } from "shared/components"; export const SidebarApp: React.FC = () => { + // i18 const { t } = useTranslation(); + + // Location const { search } = useLocation(); const renderPageNav = () => { return ( ); diff --git a/src/pages/application-inventory/application-assessment/application-assessment.tsx b/src/pages/application-inventory/application-assessment/application-assessment.tsx index 8f8d2d2c..74dc6773 100644 --- a/src/pages/application-inventory/application-assessment/application-assessment.tsx +++ b/src/pages/application-inventory/application-assessment/application-assessment.tsx @@ -55,15 +55,25 @@ import { getAxiosErrorMessage } from "utils/utils"; import { ApplicationAssessmentPage } from "./components/application-assessment-page"; import { WizardStepNavDescription } from "./components/wizard-step-nav-description"; +import { useKcPermission } from "shared/hooks"; export const ApplicationAssessment: React.FC = () => { + //i18 const { t } = useTranslation(); + // Redux const dispatch = useDispatch(); + // RBAC + const { isAllowed: isAllowedToWriteReview } = useKcPermission({ + permissionsAllowed: ["inventory:application-review:write"], + }); + + // URL const history = useHistory(); const { assessmentId } = useParams(); + // States const [currentStep, setCurrentStep] = useState(0); const [saveError, setSaveError] = useState(); @@ -332,6 +342,7 @@ export const ApplicationAssessment: React.FC = () => { isLastStep={currentStep === sortedCategories.length} isDisabled={formik.isSubmitting || formik.isValidating} isFormInvalid={!formik.isValid} + showSaveAndReviewBtn={isAllowedToWriteReview} onSave={(review) => { const saveActionValue = review ? SAVE_ACTION_VALUE.SAVE_AND_REVIEW diff --git a/src/pages/application-inventory/application-assessment/components/application-assessment-page/application-assessment-page-header.tsx b/src/pages/application-inventory/application-assessment/components/application-assessment-page/application-assessment-page-header.tsx index 7cfadc66..0638a2f5 100644 --- a/src/pages/application-inventory/application-assessment/components/application-assessment-page/application-assessment-page-header.tsx +++ b/src/pages/application-inventory/application-assessment/components/application-assessment-page/application-assessment-page-header.tsx @@ -7,7 +7,7 @@ import { Button, ButtonVariant, Modal, Text } from "@patternfly/react-core"; import { useDispatch } from "react-redux"; import { confirmDialogActions } from "store/confirmDialog"; -import { PageHeader } from "shared/components"; +import { PageHeader, VisibilityByPermission } from "shared/components"; import { useEntityModal } from "shared/hooks"; import { ApplicationDependenciesFormContainer } from "shared/containers"; @@ -76,9 +76,13 @@ export const ApplicationAssessmentPageHeader: React.FC {application && ( - + + + )} } diff --git a/src/pages/application-inventory/application-assessment/components/custom-wizard-footer/custom-wizard-footer.tsx b/src/pages/application-inventory/application-assessment/components/custom-wizard-footer/custom-wizard-footer.tsx index 19d32498..48a995f7 100644 --- a/src/pages/application-inventory/application-assessment/components/custom-wizard-footer/custom-wizard-footer.tsx +++ b/src/pages/application-inventory/application-assessment/components/custom-wizard-footer/custom-wizard-footer.tsx @@ -12,6 +12,7 @@ export interface CustomWizardFooterProps { isLastStep: boolean; isDisabled: boolean; isFormInvalid: boolean; + showSaveAndReviewBtn: boolean; onSave: (review: boolean) => void; onSaveAsDraft: () => void; } @@ -21,6 +22,7 @@ export const CustomWizardFooter: React.FC = ({ isLastStep, isDisabled, isFormInvalid, + showSaveAndReviewBtn, onSave, onSaveAsDraft, }) => { @@ -47,14 +49,16 @@ export const CustomWizardFooter: React.FC = ({ > {t("actions.save")} - + {showSaveAndReviewBtn && ( + + )} ) : ( ), - }, + }), ], }); @@ -418,7 +434,10 @@ export const ApplicationList: React.FC = () => { const actions: (IAction | ISeparator)[] = []; const applicationAssessment = getApplicationAssessment(row.id!); - if (applicationAssessment?.status === "COMPLETE") { + if ( + isAllowedToWriteAssessment && + applicationAssessment?.status === "COMPLETE" + ) { actions.push({ title: t("actions.copyAssessment"), onClick: ( @@ -431,7 +450,7 @@ export const ApplicationList: React.FC = () => { }, }); } - if (row.review) { + if (isAllowedToWriteAssessmentAndReview && row.review) { actions.push({ title: t("actions.copyAssessmentAndReview"), onClick: ( @@ -444,7 +463,11 @@ export const ApplicationList: React.FC = () => { }, }); } - if (applicationAssessment) { + + if ( + isAllowedToWriteAssessmentAndReview && + getApplicationAssessment(row.id!) + ) { actions.push({ title: t("actions.discardAssessment"), onClick: ( @@ -458,8 +481,8 @@ export const ApplicationList: React.FC = () => { }); } - actions.push( - { + if (isAllowedToWriteAppDependencies) { + actions.push({ title: t("actions.manageDependencies"), onClick: ( event: React.MouseEvent, @@ -469,8 +492,11 @@ export const ApplicationList: React.FC = () => { const row: Application = getRow(rowData); openDependenciesModal(row); }, - }, - { + }); + } + + if (isAllowedToWriteApp) { + actions.push({ title: t("actions.delete"), onClick: ( event: React.MouseEvent, @@ -480,8 +506,8 @@ export const ApplicationList: React.FC = () => { const row: Application = getRow(rowData); deleteRow(row); }, - } - ); + }); + } return actions; }; @@ -709,68 +735,86 @@ export const ApplicationList: React.FC = () => { toolbarActions={ <> - - - - - - - - - - - setIsApplicationImportModalOpen(true)} - > - {t("actions.import")} - , - { - history.push( - Paths.applicationInventory_manageImports - ); - }} - > - {t("actions.manageImports")} - , - ]} - /> - + + + + + + + + + + + + + + + + + + + setIsApplicationImportModalOpen(true) + } + > + {t("actions.import")} + , + { + history.push( + Paths.applicationInventory_manageImports + ); + }} + > + {t("actions.manageImports")} + , + ]} + /> + + } diff --git a/src/pages/application-inventory/manage-imports/manage-imports.tsx b/src/pages/application-inventory/manage-imports/manage-imports.tsx index 592cf116..e0765c1f 100644 --- a/src/pages/application-inventory/manage-imports/manage-imports.tsx +++ b/src/pages/application-inventory/manage-imports/manage-imports.tsx @@ -47,6 +47,7 @@ import { PageHeader, ToolbarSearchFilter, KebabDropdown, + VisibilityByPermission, } from "shared/components"; import { formatPath, Paths } from "Paths"; @@ -424,16 +425,20 @@ export const ManageImports: React.FC = () => { toolbarActions={ <> - - - + + + + + { + // i18 const { t } = useTranslation(); + + // Redux const dispatch = useDispatch(); + // RBAC + const { isAllowed: isAllowedToWrite } = useKcPermission({ + permissionsAllowed: ["controls:write"], + }); + + // Filters const filters = [ { key: FilterKey.NAME, @@ -106,15 +117,18 @@ export const BusinessServices: React.FC = () => { new Map([]) ); + // Create and update modal states const [isNewModalOpen, setIsNewModalOpen] = useState(false); const [rowToUpdate, setRowToUpdate] = useState(); + // Delete state const { requestDelete: requestDeleteBusinessService, } = useDelete({ onDelete: (t: BusinessService) => deleteBusinessService(t.id!), }); + // Table data const { businessServices, isFetching, @@ -155,16 +169,17 @@ export const BusinessServices: React.FC = () => { ); }, [filtersValue, paginationQuery, sortByQuery, fetchBusinessServices]); + // Table's rows and columns const columns: ICell[] = [ { title: t("terms.name"), transforms: [sortable, cellWidth(25)] }, { title: t("terms.description"), transforms: [cellWidth(40)] }, { title: t("terms.owner"), transforms: [sortable] }, - { + ...wrapInArrayWhen(isAllowedToWrite, { title: "", props: { className: "pf-u-text-align-right", }, - }, + }), ]; const rows: IRow[] = []; @@ -187,14 +202,14 @@ export const BusinessServices: React.FC = () => { ), }, - { + ...wrapInArrayWhen(isAllowedToWrite, { title: ( editRow(item)} onDelete={() => deleteRow(item)} /> ), - }, + }), ], }); }); @@ -371,18 +386,20 @@ export const BusinessServices: React.FC = () => { } toolbarActions={ - - - - - + + + + + + + } noDataState={ { + // i18 const { t } = useTranslation(); + + // Redux const dispatch = useDispatch(); + // RBAC + const { isAllowed: isAllowedToWrite } = useKcPermission({ + permissionsAllowed: ["controls:write"], + }); + + // Filters const filters = [ { key: FilterKey.NAME, @@ -89,13 +100,16 @@ export const JobFunctions: React.FC = () => { new Map([]) ); + // Create and update modal states const [isNewModalOpen, setIsNewModalOpen] = useState(false); const [rowToUpdate, setRowToUpdate] = useState(); + // Delete state const { requestDelete: requestDeleteJobFunction } = useDelete({ onDelete: (t: JobFunction) => deleteJobFunction(t.id!), }); + // Table data const { jobFunctions, isFetching, @@ -132,20 +146,19 @@ export const JobFunctions: React.FC = () => { ); }, [filtersValue, paginationQuery, sortByQuery, fetchJobFunctions]); - // - + // Table's rows and columns const columns: ICell[] = [ { title: t("terms.name"), transforms: [sortable, cellWidth(70)], cellFormatters: [], }, - { + ...wrapInArrayWhen(isAllowedToWrite, { title: "", props: { className: "pf-u-text-align-right", }, - }, + }), ]; const rows: IRow[] = []; @@ -156,14 +169,14 @@ export const JobFunctions: React.FC = () => { { title: {item.role}, }, - { + ...wrapInArrayWhen(isAllowedToWrite, { title: ( setRowToUpdate(item)} onDelete={() => deleteRow(item)} /> ), - }, + }), ], }); }); @@ -308,18 +321,20 @@ export const JobFunctions: React.FC = () => { } toolbarActions={ - - - - - + + + + + + + } noDataState={ { }; export const StakeholderGroups: React.FC = () => { + // i18 const { t } = useTranslation(); + + // Redux const dispatch = useDispatch(); + // RBAC + const { isAllowed: isAllowedToWrite } = useKcPermission({ + permissionsAllowed: ["controls:write"], + }); + + // Filters const filters = [ { key: FilterKey.NAME, @@ -114,15 +125,18 @@ export const StakeholderGroups: React.FC = () => { new Map([]) ); + // Create and update modal states const [isNewModalOpen, setIsNewModalOpen] = useState(false); const [rowToUpdate, setRowToUpdate] = useState(); + // Delete state const { requestDelete: requestDeleteStakeholderGroup, } = useDelete({ onDelete: (t: StakeholderGroup) => deleteStakeholderGroup(t.id!), }); + // Table data const { stakeholderGroups, isFetching, @@ -171,6 +185,7 @@ export const StakeholderGroups: React.FC = () => { ); }, [filtersValue, paginationQuery, sortByQuery, fetchStakeholderGroups]); + // Table's rows and columns const columns: ICell[] = [ { title: t("terms.name"), @@ -182,12 +197,12 @@ export const StakeholderGroups: React.FC = () => { title: t("terms.memberCount"), transforms: [sortable], }, - { + ...wrapInArrayWhen(isAllowedToWrite, { title: "", props: { className: "pf-u-text-align-right", }, - }, + }), ]; const rows: IRow[] = []; @@ -208,14 +223,14 @@ export const StakeholderGroups: React.FC = () => { { title: item.stakeholders ? item.stakeholders.length : 0, }, - { + ...wrapInArrayWhen(isAllowedToWrite, { title: ( editRow(item)} onDelete={() => deleteRow(item)} /> ), - }, + }), ], }); @@ -398,18 +413,20 @@ export const StakeholderGroups: React.FC = () => { } toolbarActions={ - - - - - + + + + + + + } noDataState={ { }; export const Stakeholders: React.FC = () => { + // i18 const { t } = useTranslation(); + + // Redux const dispatch = useDispatch(); + // RBAC + const { isAllowed: isAllowedToWrite } = useKcPermission({ + permissionsAllowed: ["controls:write"], + }); + + // Filters const filters = [ { key: FilterKey.EMAIL, @@ -125,13 +136,16 @@ export const Stakeholders: React.FC = () => { new Map([]) ); + // Create and update modal states const [isNewModalOpen, setIsNewModalOpen] = useState(false); const [rowToUpdate, setRowToUpdate] = useState(); + // Delete state const { requestDelete: requestDeleteStakeholder } = useDelete({ onDelete: (t: Stakeholder) => deleteStakeholder(t.id!), }); + // Table data const { stakeholders, isFetching, @@ -182,6 +196,7 @@ export const Stakeholders: React.FC = () => { ); }, [filtersValue, paginationQuery, sortByQuery, fetchStakeholders]); + // Table's rows and columns const columns: ICell[] = [ { title: t("terms.email"), @@ -194,12 +209,12 @@ export const Stakeholders: React.FC = () => { title: t("terms.groupCount"), transforms: [sortable], }, - { + ...wrapInArrayWhen(isAllowedToWrite, { title: "", props: { className: "pf-u-text-align-right", }, - }, + }), ]; const rows: IRow[] = []; @@ -227,14 +242,14 @@ export const Stakeholders: React.FC = () => { { title: item.stakeholderGroups ? item.stakeholderGroups.length : 0, }, - { + ...wrapInArrayWhen(isAllowedToWrite, { title: ( editRow(item)} onDelete={() => deleteRow(item)} /> ), - }, + }), ], }); @@ -415,18 +430,20 @@ export const Stakeholders: React.FC = () => { } toolbarActions={ - - - - - + + + + + + + } noDataState={ = ({ onEdit, onDelete, }) => { + // i18 const { t } = useTranslation(); + // RBAC + const { isAllowed: isAllowedToWrite } = useKcPermission({ + permissionsAllowed: ["controls:write"], + }); + + // Table's rows and columns const columns: ICell[] = [ { title: t("terms.tagName"), @@ -102,7 +110,7 @@ export const TagTable: React.FC = ({ aria-label="tag-table" cells={columns} rows={rows} - actions={actions} + actions={isAllowedToWrite ? actions : []} className={styles.actionColumnPadding} > diff --git a/src/pages/controls/tags/tags.tsx b/src/pages/controls/tags/tags.tsx index ea635ca7..f8f3811e 100644 --- a/src/pages/controls/tags/tags.tsx +++ b/src/pages/controls/tags/tags.tsx @@ -32,10 +32,16 @@ import { NoDataEmptyState, SearchFilter, Color, + VisibilityByPermission, } from "shared/components"; -import { useTableControls, useFetchTagTypes, useDelete } from "shared/hooks"; +import { + useTableControls, + useFetchTagTypes, + useDelete, + useKcPermission, +} from "shared/hooks"; -import { getAxiosErrorMessage } from "utils/utils"; +import { getAxiosErrorMessage, wrapInArrayWhen } from "utils/utils"; import { deleteTag, deleteTagType, @@ -93,9 +99,18 @@ const getRow = (rowData: IRowData): TagType => { }; export const Tags: React.FC = () => { + // i18 const { t } = useTranslation(); + + // Redux const dispatch = useDispatch(); + // RBAC + const { isAllowed: isAllowedToWrite } = useKcPermission({ + permissionsAllowed: ["controls:write"], + }); + + // Filters const filters = [ { key: FilterKey.TAG_TYPE, @@ -110,12 +125,14 @@ export const Tags: React.FC = () => { new Map([]) ); + // Create and update modal states const [isNewTagTypeModalOpen, setIsNewTagTypeModalOpen] = useState(false); const [rowToUpdate, setRowToUpdate] = useState(); const [isNewTagModalOpen, setIsNewTagModalOpen] = useState(false); const [tagToUpdate, setTagToUpdate] = useState(); + // Delete state const { requestDelete: requestDeleteTagType } = useDelete({ onDelete: (t: TagType) => deleteTagType(t.id!), }); @@ -123,6 +140,7 @@ export const Tags: React.FC = () => { onDelete: (t: Tag) => deleteTag(t.id!), }); + // Table data const { tagTypes, isFetching, fetchError, fetchTagTypes } = useFetchTagTypes( true ); @@ -196,8 +214,7 @@ export const Tags: React.FC = () => { ); }; - // - + // Table's rows and columns const columns: ICell[] = [ { title: t("terms.tagType"), @@ -213,12 +230,12 @@ export const Tags: React.FC = () => { title: t("terms.tagCount"), transforms: [sortable], }, - { + ...wrapInArrayWhen(isAllowedToWrite, { title: "", props: { className: "pf-u-text-align-right", }, - }, + }), ]; const rows: IRow[] = []; @@ -240,14 +257,14 @@ export const Tags: React.FC = () => { { title: item.tags ? item.tags.length : 0, }, - { + ...wrapInArrayWhen(isAllowedToWrite, { title: ( setRowToUpdate(item)} onDelete={() => deleteRow(item)} /> ), - }, + }), ], }); @@ -427,7 +444,6 @@ export const Tags: React.FC = () => { onCollapse={collapseRow} cells={columns} rows={rows} - // actions={actions} isLoading={isFetching} loadingVariant="skeleton" fetchError={fetchError} @@ -451,28 +467,30 @@ export const Tags: React.FC = () => { } toolbarActions={ - - - - - - - - + + + + + + + + + + } noDataState={ = ({ + permissionsAllowed, + children, +}) => { + const { isAllowed } = useKcPermission({ permissionsAllowed }); + return <>{isAllowed && children}; +}; diff --git a/src/shared/hooks/index.ts b/src/shared/hooks/index.ts index 3638ad4c..bfe2c038 100644 --- a/src/shared/hooks/index.ts +++ b/src/shared/hooks/index.ts @@ -11,6 +11,7 @@ export { useFetchJobFunctions } from "./useFetchJobFunctions"; export { useFetchStakeholderGroups } from "./useFetchStakeholderGroups"; export { useFetchStakeholders } from "./useFetchStakeholders"; export { useFetchTagTypes } from "./useFetchTagTypes"; +export { useKcPermission } from "./useKcPermission"; export { useMultipleFetch } from "./useMultipleFetch"; export { useQueryString } from "./useRouteAsState"; export { useSelectionFromPageState } from "./useSelectionFromPageState"; diff --git a/src/shared/hooks/useKcPermission/index.ts b/src/shared/hooks/useKcPermission/index.ts new file mode 100644 index 00000000..ced1aafc --- /dev/null +++ b/src/shared/hooks/useKcPermission/index.ts @@ -0,0 +1 @@ +export { useKcPermission } from "./useKcPermission"; diff --git a/src/shared/hooks/useKcPermission/useKcPermission.ts b/src/shared/hooks/useKcPermission/useKcPermission.ts new file mode 100644 index 00000000..be562355 --- /dev/null +++ b/src/shared/hooks/useKcPermission/useKcPermission.ts @@ -0,0 +1,27 @@ +import { useKeycloak } from "@react-keycloak/web"; +import { KcPermission, KC_API_CLIENT } from "Constants"; + +export interface IArgs { + permissionsAllowed: KcPermission[]; +} + +export interface IState { + isAllowed: boolean; +} + +export const useKcPermission = ({ permissionsAllowed }: IArgs): IState => { + const { keycloak } = useKeycloak(); + + const isAllowed = permissionsAllowed.some((permission) => { + return ( + keycloak.hasRealmRole(permission) || + keycloak.hasResourceRole(permission, KC_API_CLIENT) + ); + }); + + return { + isAllowed, + }; +}; + +export default useKcPermission; diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 98b172e4..15f74768 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -61,3 +61,9 @@ export const formatDate = (value: Date, includeTime = true) => { return value.toLocaleDateString("en", options); }; + +// Util functions + +export const wrapInArrayWhen = (when: boolean, obj: any) => { + return when ? [obj] : []; +};