From 53ed514803a37fa016a64ce6841fa3f20b0b1d64 Mon Sep 17 00:00:00 2001 From: Givi Khojanashvili Date: Thu, 1 Jun 2023 10:06:08 +0400 Subject: [PATCH] ACL to firewall rules (#163) ACL based on the firewall rules --- src/components/AccessControlNew.tsx | 438 +++++++++++++++++++--------- src/store/index.ts | 4 +- src/store/policy/actions.ts | 34 +++ src/store/policy/index.ts | 7 + src/store/policy/reducer.ts | 89 ++++++ src/store/policy/sagas.ts | 167 +++++++++++ src/store/policy/service.ts | 32 ++ src/store/policy/types.ts | 29 ++ src/store/root-reducer.ts | 24 +- src/store/rule/service.ts | 55 ++-- src/store/rule/types.ts | 6 +- src/views/AccessControl.tsx | 436 +++++++++++++++------------ 12 files changed, 950 insertions(+), 371 deletions(-) create mode 100644 src/store/policy/actions.ts create mode 100644 src/store/policy/index.ts create mode 100644 src/store/policy/reducer.ts create mode 100644 src/store/policy/sagas.ts create mode 100644 src/store/policy/service.ts create mode 100644 src/store/policy/types.ts diff --git a/src/components/AccessControlNew.tsx b/src/components/AccessControlNew.tsx index 5a67407d..675cfa3f 100644 --- a/src/components/AccessControlNew.tsx +++ b/src/components/AccessControlNew.tsx @@ -1,7 +1,7 @@ -import React, {useEffect, useRef, useState} from 'react'; -import {useDispatch, useSelector} from "react-redux"; -import {RootState} from "typesafe-actions"; -import {actions as ruleActions} from '../store/rule'; +import React, { useEffect, useRef, useState } from 'react'; +import { useDispatch, useSelector } from "react-redux"; +import { RootState } from "typesafe-actions"; +import { actions as policyActions } from '../store/policy'; import { Button, Col, @@ -13,98 +13,123 @@ import { RadioChangeEvent, Row, Select, + SelectProps, Space, + Switch, Tag, Typography } from "antd"; -import {CloseOutlined, FlagFilled, QuestionCircleFilled} from "@ant-design/icons"; -import type {CustomTagProps} from 'rc-select/lib/BaseSelect' -import {Rule, RuleToSave} from "../store/rule/types"; -import {uniq} from "lodash" -import {Header} from "antd/es/layout/layout"; -import {RuleObject} from "antd/lib/form"; -import {useGetTokenSilently} from "../utils/token"; - -const {Paragraph} = Typography; -const {Option} = Select; - -interface FormRule extends Rule { +import { CloseOutlined, FlagFilled, QuestionCircleFilled } from "@ant-design/icons"; +import type { CustomTagProps } from 'rc-select/lib/BaseSelect' +import { Policy, PolicyToSave } from "../store/policy/types"; +import { uniq } from "lodash"; +import { Header } from "antd/es/layout/layout"; +import { RuleObject } from "antd/lib/form"; +import { useGetTokenSilently } from "../utils/token"; + +const { Paragraph } = Typography; +const { Option } = Select; + +interface FormPolicy { + id?: string + name: string + description: string + enabled: boolean + query: string + bidirectional: boolean + protocol: string + ports: string[] + action: string tagSourceGroups: string[] tagDestinationGroups: string[] } const AccessControlNew = () => { - const {getTokenSilently} = useGetTokenSilently() + const { getTokenSilently } = useGetTokenSilently() const dispatch = useDispatch() - const setupNewRuleVisible = useSelector((state: RootState) => state.rule.setupNewRuleVisible) + const setupNewPolicyVisible = useSelector((state: RootState) => state.policy.setupNewPolicyVisible) const groups = useSelector((state: RootState) => state.group.data) - const rule = useSelector((state: RootState) => state.rule.rule) - const savedRule = useSelector((state: RootState) => state.rule.savedRule) + const actions: SelectProps['options'] = [ + { label: 'Accept', value: 'accept' }, + { label: 'Drop', value: 'drop' }, + ] + const protocols: SelectProps['options'] = [ + { label: 'All', value: 'all' }, + { label: 'TCP', value: 'tcp' }, + { label: 'UDP', value: 'udp' }, + { label: 'ICMP', value: 'icmp' }, + ] + const policy = useSelector((state: RootState) => state.policy.policy) + const savedPolicy = useSelector((state: RootState) => state.policy.savedPolicy) const [editName, setEditName] = useState(false) const [editDescription, setEditDescription] = useState(false) const [tagGroups, setTagGroups] = useState([] as string[]) - const [formRule, setFormRule] = useState({} as FormRule) + const [formPolicy, setFormPolicy] = useState({} as FormPolicy) const [form] = Form.useForm() const inputNameRef = useRef(null) const inputDescriptionRef = useRef(null) - const optionsDisabledEnabled = [{label: 'Enabled', value: false}, {label: 'Disabled', value: true}] + const optionsStatusEnabled = [{ label: 'Enabled', value: true }, { label: 'Disabled', value: false }] + useEffect(() => { if (editName) inputNameRef.current!.focus({ cursor: 'end' }) }, [editName]) + useEffect(() => { if (editDescription) inputDescriptionRef.current!.focus({ cursor: 'end' }) }, [editDescription]) + useEffect(() => { setTagGroups(groups?.map(g => g.name) || []) }, [groups]) useEffect(() => { - if (editName) inputNameRef.current!.focus({ - cursor: 'end', - }); - }, [editName]); - - useEffect(() => { - if (editDescription) inputDescriptionRef.current!.focus({ - cursor: 'end', - }); - }, [editDescription]); - - useEffect(() => { - if (!rule) return - const fRule = { - ...rule, - tagSourceGroups: rule.sources ? rule.sources?.map(t => t.name) : [], - tagDestinationGroups: rule.destinations ? rule.destinations?.map(t => t.name) : [] - } as FormRule - setFormRule(fRule) - form.setFieldsValue(fRule) - }, [rule]) - - useEffect(() => { - setTagGroups(groups?.map(g => g.name) || []) - }, [groups]) - - const createRuleToSave = (): RuleToSave => { - const sources = groups?.filter(g => formRule.tagSourceGroups.includes(g.name)).map(g => g.id || '') || [] - const destinations = groups?.filter(g => formRule.tagDestinationGroups.includes(g.name)).map(g => g.id || '') || [] - const sourcesNoId = formRule.tagSourceGroups.filter(s => !tagGroups.includes(s)) - const destinationsNoId = formRule.tagDestinationGroups.filter(s => !tagGroups.includes(s)) + if (!policy) return + const fPolicy = { + id: policy.id, + name: policy.name, + description: policy.description, + enabled: policy.enabled, + query: '', + bidirectional: policy.rules[0].bidirectional, + protocol: policy.rules[0].protocol, + ports: policy.rules[0].ports, + action: policy.rules[0].action, + tagSourceGroups: policy.rules[0].sources ? policy.rules[0].sources?.map(t => t.name) : [], + tagDestinationGroups: policy.rules[0].destinations ? policy.rules[0].destinations?.map(t => t.name) : [], + } as FormPolicy + setFormPolicy(fPolicy) + form.setFieldsValue(fPolicy) + }, [policy, form]) + + const createPolicyToSave = (): PolicyToSave => { + const sources = groups?.filter(g => formPolicy.tagSourceGroups.includes(g.name)).map(g => g.id || '') || [] + const destinations = groups?.filter(g => formPolicy.tagDestinationGroups.includes(g.name)).map(g => g.id || '') || [] + const sourcesNoId = formPolicy.tagSourceGroups.filter(s => !tagGroups.includes(s)) + const destinationsNoId = formPolicy.tagDestinationGroups.filter(s => !tagGroups.includes(s)) const groupsToSave = uniq([...sourcesNoId, ...destinationsNoId]) return { - id: formRule.id, - name: formRule.name, - description: formRule.description, - sources, - destinations, + id: formPolicy.id, + name: formPolicy.name, + description: formPolicy.description, + enabled: formPolicy.enabled, sourcesNoId, destinationsNoId, groupsToSave, - flow: formRule.flow, - disabled: formRule.disabled - } as RuleToSave + rules: [{ + id: formPolicy.id, + name: formPolicy.name, + description: formPolicy.description, + enabled: formPolicy.enabled, + sources, + destinations, + bidirectional: formPolicy.bidirectional, + protocol: formPolicy.protocol, + ports: formPolicy.ports, + action: 'accept', + }], + } as PolicyToSave } const handleFormSubmit = () => { form.validateFields() - .then((values) => { - const ruleToSave = createRuleToSave() - dispatch(ruleActions.saveRule.request({ + .then((_) => { + const policyToSave = createPolicyToSave() + dispatch(policyActions.savePolicy.request({ getAccessTokenSilently: getTokenSilently, - payload: ruleToSave + payload: policyToSave })) }) .catch((errorInfo) => { @@ -113,50 +138,82 @@ const AccessControlNew = () => { }; const setVisibleNewRule = (status: boolean) => { - dispatch(ruleActions.setSetupNewRuleVisible(status)); + dispatch(policyActions.setSetupNewPolicyVisible(status)); } const onCancel = () => { - if (savedRule.loading) return + if (savedPolicy.loading) return setEditName(false) - dispatch(ruleActions.setRule({ + dispatch(policyActions.setPolicy({ name: '', description: '', - sources: [], - destinations: [], - flow: 'bidirect', - disabled: false - } as Rule)) + enabled: true, + query: '', + rules: [{ + name: '', + description: '', + enabled: true, + sources: [], + destinations: [], + bidirectional: true, + protocol: 'all', + ports: [], + action: 'accept', + }], + } as Policy)) setVisibleNewRule(false) } const onChange = (data: any) => { - setFormRule({...formRule, ...data}) + setFormPolicy({ ...formPolicy, ...data }) } const handleChangeSource = (value: string[]) => { - setFormRule({ - ...formRule, + setFormPolicy({ + ...formPolicy, tagSourceGroups: value }) - }; + } const handleChangeDestination = (value: string[]) => { - setFormRule({ - ...formRule, + setFormPolicy({ + ...formPolicy, tagDestinationGroups: value }) - }; + } + + const handleChangeProtocol = (value: string) => { + setFormPolicy({ + ...formPolicy, + bidirectional: (value === 'all' || value === 'icmp') ? true : formPolicy.bidirectional, + ports: (value === 'all' || value === 'icmp') ? [] : formPolicy.ports, + protocol: value + }) + } + + const handleChangePorts = (value: string[]) => { + setFormPolicy({ + ...formPolicy, + ports: value + }) + } + + const handleChangeDisabled = ({ target: { value } }: RadioChangeEvent) => { + setFormPolicy({ + ...formPolicy, + enabled: value + }) + } - const handleChangeDisabled = ({target: {value}}: RadioChangeEvent) => { - setFormRule({ - ...formRule, - disabled: value + const handleChangeBidirect = (checked: boolean) => { + setFormPolicy({ + ...formPolicy, + bidirectional: checked, }) }; const tagRender = (props: CustomTagProps) => { - const {label, value, closable, onClose} = props; + const { value, closable, onClose } = props; const onPreventMouseDown = (event: React.MouseEvent) => { event.preventDefault(); event.stopPropagation(); @@ -168,7 +225,7 @@ const AccessControlNew = () => { onMouseDown={onPreventMouseDown} closable={closable} onClose={onClose} - style={{marginRight: 3}} + style={{ marginRight: 3 }} > {value} @@ -183,28 +240,47 @@ const AccessControlNew = () => { <> {label} - {peersCount} + {peersCount} ) } - const dropDownRender = (menu: React.ReactElement) => ( + const dropDownRenderGroups = (menu: React.ReactElement) => ( <> {menu} - - + + - Add new group by pressing "Enter" + Add new group by pressing "Enter" + fill="#9CA3AF" /> + + + + + ) + + const dropDownRenderPorts = (menu: React.ReactElement) => ( + <> + {menu} + + + + Add new ports or range by pressing "Enter" + + + + @@ -231,7 +307,7 @@ const AccessControlNew = () => { return Promise.reject(new Error("Please enter at least one group")) } - value.forEach(function (v: string) { + value.forEach(function(v: string) { if (!v.trim().length) { hasSpaceNamed.push(v) } @@ -244,47 +320,69 @@ const AccessControlNew = () => { return Promise.resolve() } + const selectPortRangeValidator = (_: RuleObject, value: string[]) => { + if (value) { + var failed = false + value.forEach(function(v: string) { + let p = Number(v) + if (Number.isNaN(p) || p < 1 || p > 65535) { + failed = true + return + } + }) + if (failed) { + return Promise.reject(new Error("Port value must be in 1..65535 range")) + } + } + return Promise.resolve() + } + + const selectPortProtocolValidator = (_: RuleObject, value: string[]) => { + if (!formPolicy.bidirectional && value.length === 0) { + return Promise.reject(new Error("Directional traffic require ports")) + } + return Promise.resolve() + } + return ( <> - {rule && + {policy && - - + + + } >
-
+
- - {!editName && !editDescription && formRule.id && + + {!editName && !editDescription && formPolicy.id && } - {!editName && formRule.id ? ( + {!editName && formPolicy.id ? (
toggleEditName(true)}>{formRule.id ? formRule.name : 'New Rule'}
+ onClick={() => toggleEditName(true)}>{formPolicy.id ? formPolicy.name : 'New Rule'} ) : ( { }]} > toggleEditName(false)} - onBlur={() => toggleEditName(false)} autoComplete="off"/> + onPressEnter={() => toggleEditName(false)} + onBlur={() => toggleEditName(false)} autoComplete="off" /> )} {!editDescription ? (
toggleEditDescription(true)}> - {formRule.description && formRule.description.trim() !== "" ? formRule.description : 'Add description...'} + onClick={() => toggleEditDescription(true)}> + {formPolicy.description && formPolicy.description.trim() !== "" ? formPolicy.description : 'Add description...'}
) : ( toggleEditDescription(false)} - onBlur={() => toggleEditDescription(false)} - autoComplete="off"/> + onPressEnter={() => toggleEditDescription(false)} + onBlur={() => toggleEditDescription(false)} + autoComplete="off" /> )} @@ -332,15 +430,16 @@ const AccessControlNew = () => { @@ -348,14 +447,14 @@ const AccessControlNew = () => { + + + + + + + + + { + formPolicy && + formPolicy.ports?.map(m => + + ) + } + + + - + - At the moment access rules are bi-directional by default, this means both - source and destination can talk to each-other in both directions. However - destination peers will not be able to communicate with each other, nor will - the source peers. + The default behavior is to drop all traffic that doesn't match an Access control rule. If you want to enable all peers of the same group to talk to each other - you can add that group both as a receiver and as a destination. + + Protocol type All or ICMP must be bi-directional. + Directional traffic for TCP and UDP protocol requires at least one port to be defined. + @@ -419,4 +581,4 @@ const AccessControlNew = () => { ) } -export default AccessControlNew \ No newline at end of file +export default AccessControlNew diff --git a/src/store/index.ts b/src/store/index.ts index 24fc5b80..ce1bcbeb 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -5,6 +5,7 @@ import { composeWithDevTools } from 'redux-devtools-extension'; import { sagas as peerSagas } from './peer'; import { sagas as setupKeySagas } from './setup-key'; import { sagas as userSagas } from './user'; +import { sagas as policySagas } from './policy'; import { sagas as ruleSagas } from './rule'; import { sagas as groupSagas } from './group'; import { sagas as routeSagas } from './route'; @@ -28,6 +29,7 @@ sagaMiddleware.run(peerSagas); sagaMiddleware.run(setupKeySagas); sagaMiddleware.run(userSagas); sagaMiddleware.run(ruleSagas); +sagaMiddleware.run(policySagas); sagaMiddleware.run(groupSagas); sagaMiddleware.run(routeSagas); sagaMiddleware.run(nameserverGroupSagas); @@ -36,4 +38,4 @@ sagaMiddleware.run(dnsSettingsSagas); sagaMiddleware.run(accountSagas); sagaMiddleware.run(personalAccessTokenSagas); -export { apiClient, rootReducer, store }; \ No newline at end of file +export { apiClient, rootReducer, store }; diff --git a/src/store/policy/actions.ts b/src/store/policy/actions.ts new file mode 100644 index 00000000..da41d73e --- /dev/null +++ b/src/store/policy/actions.ts @@ -0,0 +1,34 @@ +import { ActionType, createAction, createAsyncAction } from 'typesafe-actions'; +import { Policy, PolicyToSave } from './types'; +import { ApiError, CreateResponse, DeleteResponse, RequestPayload } from '../../services/api-client/types'; + +const actions = { + getPolicies: createAsyncAction( + 'GET_POLICIES_REQUEST', + 'GET_POLICIES_SUCCESS', + 'GET_POLICIES_FAILURE', + ), Policy[], ApiError>(), + + savePolicy: createAsyncAction( + 'SAVE_POLICY_REQUEST', + 'SAVE_POLICY_SUCCESS', + 'SAVE_POLICY_FAILURE', + ), CreateResponse, CreateResponse>(), + setSavedPolicy: createAction('SET_CREATE_POLICY')>(), + resetSavedPolicy: createAction('RESET_CREATE_POLICY')(), + + deletePolicy: createAsyncAction( + 'DELETE_POLICY_REQUEST', + 'DELETE_POLICY_SUCCESS', + 'DELETE_POLICY_FAILURE' + ), DeleteResponse, DeleteResponse>(), + setDeletedPolicy: createAction('SET_DELETED_POLICY')>(), + resetDeletedPolicy: createAction('RESET_DELETED_POLICY')(), + removePolicy: createAction('REMOVE_POLICY')(), + + setPolicy: createAction('SET_POLICY')(), + setSetupNewPolicyVisible: createAction('SET_SETUP_NEW_POLICY_VISIBLE')() +}; + +export type ActionTypes = ActionType; +export default actions; diff --git a/src/store/policy/index.ts b/src/store/policy/index.ts new file mode 100644 index 00000000..1f10d218 --- /dev/null +++ b/src/store/policy/index.ts @@ -0,0 +1,7 @@ +import actions, { ActionTypes as _actionTypes } from './actions'; +import reducer from './reducer'; +import sagas from './sagas'; + +export type ActionTypes = _actionTypes; + +export { actions, reducer, sagas }; diff --git a/src/store/policy/reducer.ts b/src/store/policy/reducer.ts new file mode 100644 index 00000000..a469a866 --- /dev/null +++ b/src/store/policy/reducer.ts @@ -0,0 +1,89 @@ +import { createReducer } from 'typesafe-actions'; +import { combineReducers } from 'redux'; +import { Policy } from './types'; +import actions, { ActionTypes } from './actions'; +import { ApiError, DeleteResponse, CreateResponse } from "../../services/api-client/types"; + +type StateType = Readonly<{ + data: Policy[] | null; + policy: Policy | null; + loading: boolean; + failed: ApiError | null; + saving: boolean; + deletePolicy: DeleteResponse; + savedPolicy: CreateResponse; + setupNewPolicyVisible: boolean +}>; + +const initialState: StateType = { + data: [], + policy: null, + loading: false, + failed: null, + saving: false, + deletePolicy: >{ + loading: false, + success: false, + failure: false, + error: null, + data: null + }, + savedPolicy: >{ + loading: false, + success: false, + failure: false, + error: null, + data: null + }, + setupNewPolicyVisible: false +}; + +const data = createReducer(initialState.data as Policy[]) + .handleAction(actions.getPolicies.success, (_, action) => action.payload) + .handleAction(actions.getPolicies.failure, () => []); + +const policy = createReducer(initialState.policy as Policy) + .handleAction(actions.setPolicy, (store, action) => action.payload); + +const loading = createReducer(initialState.loading) + .handleAction(actions.getPolicies.request, () => true) + .handleAction(actions.getPolicies.success, () => false) + .handleAction(actions.getPolicies.failure, () => false); + +const failed = createReducer(initialState.failed) + .handleAction(actions.getPolicies.request, () => null) + .handleAction(actions.getPolicies.success, () => null) + .handleAction(actions.getPolicies.failure, (store, action) => action.payload); + +const saving = createReducer(initialState.saving) + .handleAction(actions.getPolicies.request, () => true) + .handleAction(actions.getPolicies.success, () => false) + .handleAction(actions.getPolicies.failure, () => false); + +const deletedPolicy = createReducer, ActionTypes>(initialState.deletePolicy) + .handleAction(actions.deletePolicy.request, () => initialState.deletePolicy) + .handleAction(actions.deletePolicy.success, (store, action) => action.payload) + .handleAction(actions.deletePolicy.failure, (store, action) => action.payload) + .handleAction(actions.setDeletedPolicy, (store, action) => action.payload) + .handleAction(actions.resetDeletedPolicy, () => initialState.deletePolicy) + +const savedPolicy = createReducer, ActionTypes>(initialState.savedPolicy) + .handleAction(actions.savePolicy.request, () => initialState.savedPolicy) + .handleAction(actions.savePolicy.success, (store, action) => action.payload) + .handleAction(actions.savePolicy.failure, (store, action) => action.payload) + .handleAction(actions.setSavedPolicy, (store, action) => action.payload) + .handleAction(actions.resetSavedPolicy, () => initialState.savedPolicy) + +const setupNewPolicyVisible = createReducer(initialState.setupNewPolicyVisible) + .handleAction(actions.setSetupNewPolicyVisible, (store, action) => action.payload) + +export default combineReducers({ + data, + policy, + loading, + failed, + saving, + deletedPolicy, + savedPolicy, + setupNewPolicyVisible +}); diff --git a/src/store/policy/sagas.ts b/src/store/policy/sagas.ts new file mode 100644 index 00000000..034704e0 --- /dev/null +++ b/src/store/policy/sagas.ts @@ -0,0 +1,167 @@ +import { all, call, put, select, takeLatest } from 'redux-saga/effects'; +import { ApiError, ApiResponse, CreateResponse, DeleteResponse } from '../../services/api-client/types'; +import { Policy, PolicyRule } from './types' +import service from './service'; +import serviceGroup from '../group/service'; +import actions from './actions'; +import { actions as groupActions } from '../group'; +import { Group } from "../group/types"; + +export function* getPolicies(action: ReturnType): Generator { + try { + + yield put(actions.setDeletedPolicy({ + loading: false, + success: false, + failure: false, + error: null, + data: null + } as DeleteResponse)) + + const effect = yield call(service.getPolicies, action.payload); + const response = effect as ApiResponse; + + yield put(actions.getPolicies.success(response.body)); + } catch (err) { + yield put(actions.getPolicies.failure(err as ApiError)); + } +} + +export function* setCreatedPolicy(action: ReturnType): Generator { + yield put(actions.setSavedPolicy(action.payload)) +} + +function getNewGroupIds(dataString: string[], responses: Group[]): string[] { + return responses.filter(r => dataString.includes(r.name)).map(r => r.id || '') +} + +export function* savePolicy(action: ReturnType): Generator { + try { + yield put(actions.setSavedPolicy({ + loading: true, + success: false, + failure: false, + error: null, + data: null + } as CreateResponse)) + + const policyToSave = action.payload.payload + + const responsesGroup = yield all(policyToSave.groupsToSave.map(g => call(serviceGroup.createGroup, { + getAccessTokenSilently: action.payload.getAccessTokenSilently, + payload: { name: g } + }) + )) + + const resGroups = (responsesGroup as ApiResponse[]).filter(r => r.statusCode === 200).map(r => (r.body as Group)) + + const currentGroups = [...(yield select(state => state.group.data)) as Policy[]] + const newGroups = [...currentGroups, ...resGroups] + yield put(groupActions.getGroups.success(newGroups)); + + const newSources = getNewGroupIds(policyToSave.sourcesNoId, resGroups) + const newDestinations = getNewGroupIds(policyToSave.destinationsNoId, resGroups) + + const payloadToSave = { + getAccessTokenSilently: action.payload.getAccessTokenSilently, + payload: { + name: policyToSave.name, + description: policyToSave.description, + enabled: policyToSave.enabled, + query: policyToSave.query + } as Policy + } + if (policyToSave.rules.length > 0) { + payloadToSave.payload.rules = [] + } + policyToSave.rules.forEach((r) => { + payloadToSave.payload.rules.push({ + name: r.name, + description: r.description, + enabled: r.enabled, + sources: [...r.sources as string[], ...newSources], + destinations: [...r.destinations as string[], ...newDestinations], + bidirectional: r.bidirectional, + protocol: r.protocol, + ports: r.ports, + action: r.action + } as PolicyRule) + }) + + let effect + if (!policyToSave.id) { + effect = yield call(service.createPolicy, payloadToSave); + } else { + payloadToSave.payload.id = policyToSave.id + effect = yield call(service.editPolicy, payloadToSave); + } + + const response = effect as ApiResponse; + + yield put(actions.savePolicy.success({ + loading: false, + success: true, + failure: false, + error: null, + data: response.body + } as CreateResponse)); + + yield put(groupActions.getGroups.request({ getAccessTokenSilently: action.payload.getAccessTokenSilently, payload: null })); + yield put(actions.getPolicies.request({ getAccessTokenSilently: action.payload.getAccessTokenSilently, payload: null })); + } catch (err) { + yield put(actions.savePolicy.failure({ + loading: false, + success: false, + failure: true, + error: err as ApiError, + data: null + } as CreateResponse)); + } +} + +export function* setDeletePolicy(action: ReturnType): Generator { + yield put(actions.setDeletedPolicy(action.payload)) +} + +export function* deletePolicy(action: ReturnType): Generator { + try { + yield call(actions.setDeletedPolicy, { + loading: true, + success: false, + failure: false, + error: null, + data: null + } as DeleteResponse) + + const effect = yield call(service.deletedPolicy, action.payload); + const response = effect as ApiResponse; + + yield put(actions.deletePolicy.success({ + loading: false, + success: true, + failure: false, + error: null, + data: response.body + } as DeleteResponse)); + + const policies = (yield select(state => state.policy.data)) as Policy[] + yield put(actions.getPolicies.success(policies.filter((p: Policy) => p.id !== action.payload.payload))) + } catch (err) { + yield put(actions.deletePolicy.failure({ + loading: false, + success: false, + failure: false, + error: err as ApiError, + data: null + } as DeleteResponse)); + } +} + +export default function* sagas(): Generator { + yield all([ + takeLatest(actions.getPolicies.request, getPolicies), + takeLatest(actions.savePolicy.request, savePolicy), + takeLatest(actions.deletePolicy.request, deletePolicy) + ]); +} + diff --git a/src/store/policy/service.ts b/src/store/policy/service.ts new file mode 100644 index 00000000..1edd123f --- /dev/null +++ b/src/store/policy/service.ts @@ -0,0 +1,32 @@ +import { ApiResponse, RequestPayload } from '../../services/api-client/types'; +import { apiClient } from '../../services/api-client'; +import { Policy } from './types'; + +export default { + async getPolicies(payload: RequestPayload): Promise> { + return apiClient.get( + `/api/policies`, + payload + ); + }, + async deletedPolicy(payload: RequestPayload): Promise> { + return apiClient.delete( + `/api/policies/` + payload.payload, + payload + ); + }, + async createPolicy(payload: RequestPayload): Promise> { + return apiClient.post( + `/api/policies`, + payload + ); + }, + async editPolicy(payload: RequestPayload): Promise> { + const id = payload.payload.id + delete payload.payload.id + return apiClient.put( + `/api/policies/${id}`, + payload + ); + }, +}; diff --git a/src/store/policy/types.ts b/src/store/policy/types.ts new file mode 100644 index 00000000..db2545d6 --- /dev/null +++ b/src/store/policy/types.ts @@ -0,0 +1,29 @@ +import { Group } from "../group/types"; + +export interface PolicyRule { + id?: string + name: string + description: string + enabled: boolean + sources: Group[] | string[] | null + destinations: Group[] | string[] | null + bidirectional: boolean + action: string + protocol: string + ports: string[] +} + +export interface Policy { + id?: string + name: string + description: string + enabled: boolean + query: string + rules: PolicyRule[] +}; + +export interface PolicyToSave extends Policy { + sourcesNoId: string[], + destinationsNoId: string[], + groupsToSave: string[] +}; diff --git a/src/store/root-reducer.ts b/src/store/root-reducer.ts index 01d8f508..39fb683f 100644 --- a/src/store/root-reducer.ts +++ b/src/store/root-reducer.ts @@ -5,6 +5,7 @@ import { reducer as setupKey } from './setup-key'; import { reducer as user } from './user'; import { reducer as group } from './group'; import { reducer as rule } from './rule'; +import { reducer as policy } from './policy'; import { reducer as route } from './route'; import { reducer as nameserverGroup } from './nameservers'; import { reducer as event } from './event'; @@ -13,15 +14,16 @@ import { reducer as account } from './account'; import { reducer as personalAccessToken } from './personal-access-token'; export default combineReducers({ - peer, - setupKey, - user, - group, - rule, - route, - nameserverGroup, - event, - dnsSettings, - account, - personalAccessToken + peer, + setupKey, + user, + group, + rule, + policy, + route, + nameserverGroup, + event, + dnsSettings, + account, + personalAccessToken }); diff --git a/src/store/rule/service.ts b/src/store/rule/service.ts index 31eed9c8..4682243a 100644 --- a/src/store/rule/service.ts +++ b/src/store/rule/service.ts @@ -1,33 +1,32 @@ -import {ApiResponse, RequestPayload} from '../../services/api-client/types'; +import { ApiResponse, RequestPayload } from '../../services/api-client/types'; import { apiClient } from '../../services/api-client'; import { Rule } from './types'; -import {SetupKey} from "../setup-key/types"; export default { - async getRules(payload:RequestPayload): Promise> { - return apiClient.get( - `/api/rules`, - payload - ); - }, - async deletedRule(payload:RequestPayload): Promise> { - return apiClient.delete( - `/api/rules/` + payload.payload, - payload - ); - }, - async createRule(payload:RequestPayload): Promise> { - return apiClient.post( - `/api/rules`, - payload - ); - }, - async editRule(payload:RequestPayload): Promise> { - const id = payload.payload.id - delete payload.payload.id - return apiClient.put( - `/api/rules/${id}`, - payload - ); - }, + async getRules(payload: RequestPayload): Promise> { + return apiClient.get( + `/api/rules`, + payload + ); + }, + async deletedRule(payload: RequestPayload): Promise> { + return apiClient.delete( + `/api/rules/` + payload.payload, + payload + ); + }, + async createRule(payload: RequestPayload): Promise> { + return apiClient.post( + `/api/rules`, + payload + ); + }, + async editRule(payload: RequestPayload): Promise> { + const id = payload.payload.id + delete payload.payload.id + return apiClient.put( + `/api/rules/${id}`, + payload + ); + }, }; diff --git a/src/store/rule/types.ts b/src/store/rule/types.ts index 3f0b90cb..b9745929 100644 --- a/src/store/rule/types.ts +++ b/src/store/rule/types.ts @@ -1,4 +1,4 @@ -import {Group} from "../group/types"; +import { Group } from "../group/types"; export interface Rule { id?: string @@ -7,6 +7,8 @@ export interface Rule { sources: Group[] | string[] | null destinations: Group[] | string[] | null flow: string + protocol: string + ports: string[] disabled: boolean } @@ -14,4 +16,4 @@ export interface RuleToSave extends Rule { sourcesNoId: string[], destinationsNoId: string[], groupsToSave: string[] -} \ No newline at end of file +} diff --git a/src/views/AccessControl.tsx b/src/views/AccessControl.tsx index 5da709ab..70d678c1 100644 --- a/src/views/AccessControl.tsx +++ b/src/views/AccessControl.tsx @@ -1,4 +1,4 @@ -import React, {useEffect, useState} from 'react'; +import React, { useEffect, useState } from 'react'; import { Alert, Button, @@ -23,68 +23,76 @@ import { import {Container} from "../components/Container"; import {useDispatch, useSelector} from "react-redux"; import {RootState} from "typesafe-actions"; -import {Rule} from "../store/rule/types"; -import {actions as ruleActions} from "../store/rule"; +import {Policy} from "../store/policy/types"; +import {actions as policyActions} from "../store/policy"; import {actions as groupActions} from "../store/group"; import {filter, sortBy} from "lodash"; -import {CloseOutlined, EllipsisOutlined, ExclamationCircleOutlined} from "@ant-design/icons"; +import {EllipsisOutlined, ExclamationCircleOutlined } from "@ant-design/icons"; import bidirect from '../assets/direct_bi.svg'; -import inbound from '../assets/direct_in.svg'; import outbound from '../assets/direct_out.svg'; import AccessControlNew from "../components/AccessControlNew"; -import {Group} from "../store/group/types"; +import { Group } from "../store/group/types"; import AccessControlModalGroups from "../components/AccessControlModalGroups"; import tableSpin from "../components/Spin"; import {useGetTokenSilently} from "../utils/token"; import {usePageSizeHelpers} from "../utils/pageSize"; -import {PeerDataTable} from "../store/peer/types"; -const {Title, Paragraph, Text} = Typography; -const {Column} = Table; -const {confirm} = Modal; +const { Title, Paragraph, Text } = Typography; +const { Column } = Table; +const { confirm } = Modal; -interface RuleDataTable extends Rule { +interface PolicyDataTable { + id?: string key: string; - sourceCount: number; - sourceLabel: ''; - destinationCount: number; - destinationLabel: ''; + name: string + description: string + enabled: boolean + query: string + sources: string[] + destinations: string[] + bidirectional: boolean + protocol: string + ports: string[] + sourceCount: number + sourceLabel: '' + destinationCount: number + destinationLabel: '' } interface GroupsToShow { - title: string, - groups: Group[] | string[] | null, + title: string + groups: Group[] | string[] | null modalVisible: boolean } export const AccessControl = () => { - const {onChangePageSize,pageSizeOptions,pageSize} = usePageSizeHelpers() - const {getTokenSilently} = useGetTokenSilently() + const { onChangePageSize, pageSizeOptions, pageSize } = usePageSizeHelpers() + const { getTokenSilently } = useGetTokenSilently() const dispatch = useDispatch() - const rules = useSelector((state: RootState) => state.rule.data); - const failed = useSelector((state: RootState) => state.rule.failed); - const loading = useSelector((state: RootState) => state.rule.loading); - const deletedRule = useSelector((state: RootState) => state.rule.deletedRule); - const savedRule = useSelector((state: RootState) => state.rule.savedRule); + const policies = useSelector((state: RootState) => state.policy.data); + const failed = useSelector((state: RootState) => state.policy.failed); + const loading = useSelector((state: RootState) => state.policy.loading); + const deletedPolicy = useSelector((state: RootState) => state.policy.deletedPolicy); + const savedPolicy = useSelector((state: RootState) => state.policy.savedPolicy); const [showTutorial, setShowTutorial] = useState(true) const [textToSearch, setTextToSearch] = useState(''); const [optionAllEnable, setOptionAllEnable] = useState('enabled'); const [currentPage, setCurrentPage] = useState(1); - const [dataTable, setDataTable] = useState([] as RuleDataTable[]); - const [ruleToAction, setRuleToAction] = useState(null as RuleDataTable | null); + const [dataTable, setDataTable] = useState([] as PolicyDataTable[]); + const [policyToAction, setPolicyToAction] = useState(null as PolicyDataTable | null); const [groupsToShow, setGroupsToShow] = useState({} as GroupsToShow) - const setupNewRuleVisible = useSelector((state: RootState) => state.rule.setupNewRuleVisible); + const setupNewPolicyVisible = useSelector((state: RootState) => state.policy.setupNewPolicyVisible); const [groupPopupVisible, setGroupPopupVisible] = useState("") - const optionsAllEnabled = [{label: 'Enabled', value: 'enabled'}, {label: 'All', value: 'all'}] + const optionsAllEnabled = [{ label: 'Enabled', value: 'enabled' }, { label: 'All', value: 'all' }] const itemsMenuAction = [ { key: "view", - label: () + label: () }, // { // key: "delete", @@ -101,88 +109,97 @@ export const AccessControl = () => { return (!data) ? "No group" : (data.length > 1) ? `${data.length} Groups` : (data.length === 1) ? data[0].name : "No group" } - const isShowTutorial = (rules: Rule[]): boolean => { - return (!rules.length || (rules.length === 1 && rules[0].name === "Default")) + const isShowTutorial = (policy: Policy[]): boolean => { + return (!policy.length || (policy.length === 1 && policy[0].name === "Default")) } - const transformDataTable = (d: Rule[]): RuleDataTable[] => { - return d.map(p => { - const sourceLabel = getSourceDestinationLabel(p.sources as Group[]) - const destinationLabel = getSourceDestinationLabel(p.destinations as Group[]) + const transformDataTable = (d: Policy[]): PolicyDataTable[] => { + return d.map(policy => { + const sourceLabel = getSourceDestinationLabel(policy.rules[0].sources as Group[]) + const destinationLabel = getSourceDestinationLabel(policy.rules[0].destinations as Group[]) return { - key: p.id, ...p, - sourceCount: p.sources?.length, + id: policy.id, + key: policy.id, + name: policy.name, + description: policy.description, + enabled: policy.enabled, + sources: policy.rules[0].sources, + destinations: policy.rules[0].destinations, + bidirectional: policy.rules[0].bidirectional, + sourceCount: policy.rules[0].sources?.length, sourceLabel, - destinationCount: p.destinations?.length, - destinationLabel - } as RuleDataTable + destinationCount: policy.rules[0].destinations?.length, + destinationLabel, + protocol: policy.rules[0].protocol, + ports: policy.rules[0].ports, + } as PolicyDataTable }) } useEffect(() => { - dispatch(ruleActions.getRules.request({getAccessTokenSilently: getTokenSilently, payload: null})); - dispatch(groupActions.getGroups.request({getAccessTokenSilently: getTokenSilently, payload: null})); + dispatch(policyActions.getPolicies.request({ getAccessTokenSilently: getTokenSilently, payload: null })); + dispatch(groupActions.getGroups.request({ getAccessTokenSilently: getTokenSilently, payload: null })); }, []) useEffect(() => { if (failed) { setShowTutorial(false) } else { - setShowTutorial(isShowTutorial(rules)) + setShowTutorial(isShowTutorial(policies)) setDataTable(sortBy(transformDataTable(filterDataTable()), "name")) } - }, [rules]) + }, [policies]) useEffect(() => { setDataTable(transformDataTable(filterDataTable())) }, [textToSearch, optionAllEnable]) - const styleNotification = {marginTop: 85} + const styleNotification = { marginTop: 85 } const saveKey = 'saving'; useEffect(() => { - if (savedRule.loading) { - message.loading({content: 'Saving...', key: saveKey, duration: 0, style: styleNotification}) - } else if (savedRule.success) { + if (savedPolicy.loading) { + message.loading({ content: 'Saving...', key: saveKey, duration: 0, style: styleNotification }) + } else if (savedPolicy.success) { message.success({ content: 'Rule has been successfully saved.', key: saveKey, duration: 2, style: styleNotification }); - dispatch(ruleActions.setSetupNewRuleVisible(false)) - dispatch(ruleActions.setSavedRule({...savedRule, success: false})) - dispatch(ruleActions.resetSavedRule(null)) - } else if (savedRule.error) { + dispatch(policyActions.setSetupNewPolicyVisible(false)) + dispatch(policyActions.setSavedPolicy({ ...savedPolicy, success: false })) + dispatch(policyActions.resetSavedPolicy(null)) + } else if (savedPolicy.error) { message.error({ content: 'Failed to update rule. You might not have enough permissions.', key: saveKey, duration: 2, style: styleNotification }); - dispatch(ruleActions.setSavedRule({...savedRule, error: null})) - dispatch(ruleActions.resetSavedRule(null)) + dispatch(policyActions.setSavedPolicy({ ...savedPolicy, error: null })) + dispatch(policyActions.resetSavedPolicy(null)) } - }, [savedRule]) + }, [savedPolicy]) const deleteKey = 'deleting'; useEffect(() => { - const style = {marginTop: 85} - if (deletedRule.loading) { - message.loading({content: 'Deleting...', key: deleteKey, style}) - } else if (deletedRule.success) { - message.success({content: 'Rule has been successfully disabled.', key: deleteKey, duration: 2, style}) - dispatch(ruleActions.resetDeletedRule(null)) - } else if (deletedRule.error) { + const style = { marginTop: 85 } + if (deletedPolicy.loading) { + message.loading({ content: 'Deleting...', key: deleteKey, style }) + } else if (deletedPolicy.success) { + message.success({ content: 'Rule has been successfully disabled.', key: deleteKey, duration: 2, style }) + dispatch(policyActions.resetDeletedPolicy(null)) + } else if (deletedPolicy.error) { message.error({ content: 'Failed to remove rule. You might not have enough permissions.', key: deleteKey, duration: 2, style }) - dispatch(ruleActions.resetDeletedRule(null)) + dispatch(policyActions.resetDeletedPolicy(null)) } - }, [deletedRule]) + }, [deletedPolicy]) const onChangeTextToSearch = (e: React.ChangeEvent) => { setTextToSearch(e.target.value) @@ -193,14 +210,14 @@ export const AccessControl = () => { setDataTable(transformDataTable(data)) } - const onChangeAllEnabled = ({target: {value}}: RadioChangeEvent) => { + const onChangeAllEnabled = ({ target: { value } }: RadioChangeEvent) => { setOptionAllEnable(value) } const showConfirmDelete = () => { - let name = ruleToAction ? ruleToAction.name : ''; + let name = policyToAction ? policyToAction.name : ''; confirm({ - icon: , + icon: , title: "Delete rule \"" + name + "\"", width: 600, content: @@ -208,25 +225,25 @@ export const AccessControl = () => { , okType: 'danger', onOk() { - dispatch(ruleActions.deleteRule.request({ + dispatch(policyActions.deletePolicy.request({ getAccessTokenSilently: getTokenSilently, - payload: ruleToAction?.id || '' + payload: policyToAction?.id || '' })); }, onCancel() { - setRuleToAction(null); + setPolicyToAction(null); }, }); } const showConfirmDeactivate = () => { confirm({ - icon: , + icon: , width: 600, content: - {ruleToAction && + {policyToAction && <> - Deactivate rule "{ruleToAction ? ruleToAction.name : ''}" + Deactivate rule "{policyToAction ? policyToAction.name : ''}" Are you sure you want to deactivate peer from your account? } @@ -236,58 +253,78 @@ export const AccessControl = () => { //dispatch(ruleActions.deleteRule.request({getAccessTokenSilently, payload: ruleToAction?.id || ''})); }, onCancel() { - setRuleToAction(null); + setPolicyToAction(null); }, }); } - const filterDataTable = (): Rule[] => { + const filterDataTable = (): Policy[] => { const t = textToSearch.toLowerCase().trim() - let f: Rule[] = filter(rules, (f: Rule) => + let f: Policy[] = filter(policies, (f: Policy) => (f.name.toLowerCase().includes(t) || f.description.toLowerCase().includes(t) || t === "") - ) as Rule[] + ) as Policy[] if (optionAllEnable !== "all") { - f = filter(f, (f: Rule) => !f.disabled) + f = filter(f, (f: Policy) => f.enabled) } return f } - const onClickAddNewRule = () => { - dispatch(ruleActions.setSetupNewRuleVisible(true)); - dispatch(ruleActions.setRule({ + const onClickAddNewPolicy = () => { + dispatch(policyActions.setSetupNewPolicyVisible(true)); + dispatch(policyActions.setPolicy({ name: '', description: '', - sources: [], - destinations: [], - flow: 'bidirect', - disabled: false - } as Rule)) + enabled: true, + rules: [{ + name: '', + description: '', + enabled: true, + bidirectional: true, + action: 'accept', + protocol: 'all', + }] + } as Policy)) } - const onClickViewRule = () => { - dispatch(ruleActions.setSetupNewRuleVisible(true)); - dispatch(ruleActions.setRule({ - id: ruleToAction?.id || null, - name: ruleToAction?.name, - description: ruleToAction?.description, - sources: ruleToAction?.sources, - destinations: ruleToAction?.destinations, - flow: ruleToAction?.flow, - disabled: ruleToAction?.disabled - } as Rule)) + const onClickViewPolicy = () => { + dispatch(policyActions.setSetupNewPolicyVisible(true)); + dispatch(policyActions.setPolicy({ + id: policyToAction?.id || null, + name: policyToAction?.name, + description: policyToAction?.description, + enabled: policyToAction?.enabled, + rules: [{ + name: policyToAction?.name, + description: policyToAction?.description, + enabled: policyToAction?.enabled, + sources: policyToAction?.sources, + destinations: policyToAction?.destinations, + bidirectional: policyToAction?.bidirectional, + protocol: policyToAction?.protocol, + ports: policyToAction?.ports, + }] + } as Policy)) } - const setRuleAndView = (rule: RuleDataTable) => { - dispatch(ruleActions.setSetupNewRuleVisible(true)); - dispatch(ruleActions.setRule({ - id: rule.id || null, - name: rule.name, - description: rule.description, - sources: rule.sources, - destinations: rule.destinations, - flow: rule.flow, - disabled: rule.disabled - } as Rule)) + const setPolicyAndView = (p: PolicyDataTable) => { + dispatch(policyActions.setSetupNewPolicyVisible(true)); + dispatch(policyActions.setPolicy({ + id: p.id || null, + name: p.name, + description: p.description, + enabled: p.enabled, + rules: [{ + id: p.id || null, + name: p.name, + description: p.description, + enabled: p.enabled, + sources: p.sources, + destinations: p.destinations, + bidirectional: p.bidirectional, + protocol: p.protocol, + ports: p.ports, + }] + } as Policy)) } const toggleModalGroups = (title: string, groups: Group[] | string[] | null, modalVisible: boolean) => { @@ -299,13 +336,13 @@ export const AccessControl = () => { } useEffect(() => { - if (setupNewRuleVisible) { + if (setupNewPolicyVisible) { setGroupPopupVisible("") } - }, [setupNewRuleVisible]) + }, [setupNewPolicyVisible]) const onPopoverVisibleChange = (b: boolean, key: string) => { - if (setupNewRuleVisible) { + if (setupNewPolicyVisible) { setGroupPopupVisible("") } else { if (b) { @@ -316,20 +353,20 @@ export const AccessControl = () => { } } - const renderPopoverGroups = (label: string, groups: Group[] | string[] | null, rule: RuleDataTable) => { + const renderPopoverGroups = (label: string, groups: Group[] | string[] | null, rule: PolicyDataTable) => { const content = groups?.map((g, i) => { const _g = g as Group const peersCount = ` - ${_g.peers_count || 0} ${(!_g.peers_count || parseInt(_g.peers_count) !== 1) ? 'peers' : 'peer'} ` return ( -
- - {_g.name} - - {peersCount} -
+
+ + {_g.name} + + {peersCount} +
) }) const mainContent = ({content}) @@ -339,11 +376,20 @@ export const AccessControl = () => { open={groupPopupVisible === rule.key} content={mainContent} title={null}> - + ) } + const renderPorts = (ports: string[]) => { + const content = ports?.map((p, i) => { + return ( + {p} + ) + }) + return (
{content}
) + } + return ( <> @@ -351,11 +397,11 @@ export const AccessControl = () => { Access Control Access rules help you manage access permissions in your organisation. - + + placeholder="Search..." onChange={onChangeTextToSearch} /> @@ -367,28 +413,28 @@ export const AccessControl = () => { buttonStyle="solid" />