Feat(#3031) Support policy authoring through UI (outside of role page) (#6863)

*  Feat(#3031) Support policy authoring through UI (outside of role page)

* Change remove text to remove icon

* - [x] Add confirmation for removing items from the roles and policies details page

* Change rules styling according to the mocks

* Make Access category protected!

* Changed condition from normal text to code

* Create rule form component

* Fix spacing

* Add support for "Add and Edit" rule for policy

* Add helper text for Add "roles" and "policy" page
This commit is contained in:
Sachin Chaurasiya 2022-08-23 20:11:10 +05:30 committed by GitHub
parent 9c8ee19497
commit d942c55e34
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 835 additions and 248 deletions

View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 16C3.58887 16 0 12.4111 0 8C0 3.58887 3.58887 0 8 0C12.4111 0 16 3.58887 16 8C16 12.4111 12.4111 16 8 16ZM8 1C4.14013 1 1 4.14013 1 8C1 11.8599 4.14013 15 8 15C11.8599 15 15 11.8599 15 8C15 4.14013 11.8599 1 8 1ZM12 8C12 7.72363 11.7764 7.5 11.5 7.5H4.5C4.22363 7.5 4 7.72363 4 8C4 8.27637 4.22363 8.5 4.5 8.5H11.5C11.7764 8.5 12 8.27637 12 8Z" fill="#37352F"/>
</svg>

After

Width:  |  Height:  |  Size: 477 B

View File

@ -24,3 +24,15 @@ export const CONFIGURE_WEBHOOK_TEXT =
export const CONFIGURE_SLACK_TEXT =
'Automatically send out event notifications to registered Slack webhooks through OpenMetadata. Enter the webhook name, and an Endpoint URL to receive the HTTP call back on. Use Event Filters to only receive notifications for the required entities. Filter events based on when an entity is created, updated, or deleted. Add a description to note the use case of the webhook. You can use advanced configuration to set up a shared secret key to verify the Slack webhook events using HMAC signature.';
export const ADD_ROLE_TEXT = `Roles are assigned to Users. In OpenMetadata, Roles are a collection of
Policies. Each Role must have at least one policy attached to it. A Role
supports multiple policies with a one to many relationship. Ensure that
the necessary policies are created before creating a new role. Build
rich access control roles with well-defined policies based on
conditional rules.`;
export const ADD_POLICY_TEXT = `
Policies are assigned to Teams. In OpenMetadata, a Policy is a collection of Rules, which define access based on certain conditions. We support rich SpEL (Spring Expression Language) based conditions. All the operations supported by an Entity are published. Use these fine grained operations to define the conditional Rules for each Policy.
Create well-defined policies based on conditional rules to build rich access control roles.
`;

View File

@ -85,6 +85,7 @@ export const PLACEHOLDER_TASK_ID = ':taskId';
export const PLACEHOLDER_SETTING_CATEGORY = ':settingCategory';
export const PLACEHOLDER_USER_BOT = ':bot';
export const PLACEHOLDER_WEBHOOK_TYPE = ':webhookType';
export const PLACEHOLDER_RULE_NAME = ':ruleName';
export const pagingObject = { after: '', before: '', total: 0 };
@ -229,6 +230,8 @@ export const ROUTES = {
ACTIVITY_PUSH_FEED: '/api/v1/push/feed',
ADD_ROLE: '/settings/access/roles/add-role',
ADD_POLICY: '/settings/access/policies/add-policy',
ADD_POLICY_RULE: `/settings/access/policies/${PLACEHOLDER_ROUTE_FQN}/add-rule`,
EDIT_POLICY_RULE: `/settings/access/policies/${PLACEHOLDER_ROUTE_FQN}/edit-rule/${PLACEHOLDER_RULE_NAME}`,
};
export const SOCKET_EVENTS = {

View File

@ -49,7 +49,7 @@ export const GLOBAL_SETTINGS_MENU = [
},
{
category: 'Access',
isProtected: false,
isProtected: true,
items: [
{
label: 'Roles',

View File

@ -19,35 +19,30 @@ import {
Form,
Input,
Row,
Select,
Space,
TreeSelect,
Typography,
} from 'antd';
import { AxiosError } from 'axios';
import { capitalize, startCase, uniq } from 'lodash';
import React, { useEffect, useMemo, useState } from 'react';
import React, { useState } from 'react';
import { useHistory } from 'react-router-dom';
import { addPolicy, getPolicyResources } from '../../../axiosAPIs/rolesAPIV1';
import { addPolicy } from '../../../axiosAPIs/rolesAPIV1';
import RichTextEditor from '../../../components/common/rich-text-editor/RichTextEditor';
import TitleBreadcrumb from '../../../components/common/title-breadcrumb/title-breadcrumb.component';
import { GlobalSettingOptions } from '../../../constants/globalSettings.constants';
import { ADD_POLICY_TEXT } from '../../../constants/HelperTextUtil';
import {
CreatePolicy,
Effect,
Operation,
PolicyType,
Rule,
} from '../../../generated/api/policies/createPolicy';
import { ResourceDescriptor } from '../../../generated/entity/policies/accessControl/resourceDescriptor';
import {
getPath,
getPolicyWithFqnPath,
getSettingPath,
} from '../../../utils/RouterUtils';
import { showErrorToast } from '../../../utils/ToastUtils';
const { Option } = Select;
import RuleForm from '../RuleForm/RuleForm';
const policiesPath = getPath(GlobalSettingOptions.POLICIES);
@ -68,9 +63,7 @@ const breadcrumb = [
const AddPolicyPage = () => {
const history = useHistory();
const [policyResources, setPolicyResources] = useState<ResourceDescriptor[]>(
[]
);
const [name, setName] = useState<string>('');
const [description, setDescription] = useState<string>('');
const [ruleData, setRuleData] = useState<Rule>({
@ -81,69 +74,6 @@ const AddPolicyPage = () => {
effect: Effect.Allow,
});
/**
* Derive the resources from policy resources
*/
const resourcesOptions = useMemo(() => {
const resources = policyResources.filter(
(resource) => resource.name !== 'all'
);
const option = [
{
title: 'All',
value: 'all',
key: 'all',
children: resources.map((resource) => ({
title: startCase(resource.name),
value: resource.name,
key: resource.name,
})),
},
];
return option;
}, [policyResources]);
/**
* Derive the operations from selected resources
*/
const operationOptions = useMemo(() => {
const selectedResources = policyResources.filter((resource) =>
ruleData.resources?.includes(resource.name || '')
);
const operations = selectedResources
.reduce(
(prev: Operation[], curr: ResourceDescriptor) =>
uniq([...prev, ...(curr.operations || [])]),
[]
)
.filter((operation) => operation !== Operation.All);
const option = [
{
title: 'All',
value: Operation.All,
key: 'All',
children: operations.map((operation) => ({
title: operation,
value: operation,
key: operation,
})),
},
];
return option;
}, [ruleData.resources, policyResources]);
const fetchPolicyResources = async () => {
try {
const data = await getPolicyResources();
setPolicyResources(data.data || []);
} catch (error) {
showErrorToast(error as AxiosError);
}
};
const handleCancel = () => {
history.push(policiesPath);
};
@ -168,13 +98,9 @@ const AddPolicyPage = () => {
}
};
useEffect(() => {
fetchPolicyResources();
}, []);
return (
<Row className="tw-bg-body-main tw-h-auto" gutter={[16, 16]}>
<Col offset={5} span={14}>
<Col offset={4} span={12}>
<TitleBreadcrumb titleLinks={breadcrumb} />
<Card>
<Typography.Paragraph className="tw-text-base">
@ -217,98 +143,7 @@ const AddPolicyPage = () => {
</Form.Item>
<Divider>Add Rule</Divider>
<Form.Item
label="Rule Name:"
name="ruleName"
rules={[
{
required: true,
max: 128,
min: 1,
},
]}>
<Input
placeholder="Rule Name"
type="text"
value={ruleData.name}
onChange={(e) =>
setRuleData((prev) => ({ ...prev, name: e.target.value }))
}
/>
</Form.Item>
<Form.Item label="Description:" name="ruleDescription">
<RichTextEditor
height="200px"
initialValue={ruleData.description || ''}
placeHolder="Write your description"
style={{ margin: 0 }}
onTextChange={(value) =>
setRuleData((prev) => ({ ...prev, description: value }))
}
/>
</Form.Item>
<Form.Item
label="Resources:"
name="resources"
rules={[
{
required: true,
},
]}>
<TreeSelect
treeCheckable
className="tw-w-full"
placeholder="Select Resources"
showCheckedStrategy={TreeSelect.SHOW_PARENT}
treeData={resourcesOptions}
onChange={(values) => {
setRuleData((prev) => ({
...prev,
resources: values,
}));
}}
/>
</Form.Item>
<Form.Item
label="Operations:"
name="operations"
rules={[
{
required: true,
},
]}>
<TreeSelect
treeCheckable
className="tw-w-full"
placeholder="Select Operations"
showCheckedStrategy={TreeSelect.SHOW_PARENT}
treeData={operationOptions}
onChange={(values) => {
setRuleData((prev) => ({
...prev,
operations: values,
}));
}}
/>
</Form.Item>
<Form.Item
label="Effect:"
name="ruleEffect"
rules={[
{
required: true,
},
]}>
<Select
placeholder="Select Rule Effect"
value={ruleData.effect}
onChange={(value) =>
setRuleData((prev) => ({ ...prev, effect: value }))
}>
<Option key={Effect.Allow}>{capitalize(Effect.Allow)}</Option>
<Option key={Effect.Deny}>{capitalize(Effect.Deny)}</Option>
</Select>
</Form.Item>
<RuleForm ruleData={ruleData} setRuleData={setRuleData} />
<Space align="center" className="tw-w-full tw-justify-end">
<Button type="link" onClick={handleCancel}>
@ -321,6 +156,12 @@ const AddPolicyPage = () => {
</Form>
</Card>
</Col>
<Col className="tw-mt-4" span={4}>
<Typography.Paragraph className="tw-text-base tw-font-medium">
Add Policy
</Typography.Paragraph>
<Typography.Text>{ADD_POLICY_TEXT}</Typography.Text>
</Col>
</Row>
);
};

View File

@ -0,0 +1,143 @@
/*
* Copyright 2022 Collate
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Button, Card, Col, Form, Row, Space, Typography } from 'antd';
import { AxiosError } from 'axios';
import { compare } from 'fast-json-patch';
import React, { useEffect, useMemo, useState } from 'react';
import { useHistory, useParams } from 'react-router-dom';
import { getPolicyByName, patchPolicy } from '../../../axiosAPIs/rolesAPIV1';
import TitleBreadcrumb from '../../../components/common/title-breadcrumb/title-breadcrumb.component';
import Loader from '../../../components/Loader/Loader';
import { GlobalSettingOptions } from '../../../constants/globalSettings.constants';
import { Effect, Rule } from '../../../generated/api/policies/createPolicy';
import { Policy } from '../../../generated/entity/policies/policy';
import { getEntityName } from '../../../utils/CommonUtils';
import {
getPath,
getPolicyWithFqnPath,
getSettingPath,
} from '../../../utils/RouterUtils';
import { showErrorToast } from '../../../utils/ToastUtils';
import RuleForm from '../RuleForm/RuleForm';
const policiesPath = getPath(GlobalSettingOptions.POLICIES);
const AddRulePage = () => {
const history = useHistory();
const { fqn } = useParams<{ fqn: string }>();
const [isLoading, setLoading] = useState<boolean>(false);
const [policy, setPolicy] = useState<Policy>({} as Policy);
const [ruleData, setRuleData] = useState<Rule>({
name: '',
description: '',
resources: [],
operations: [],
effect: Effect.Allow,
});
const breadcrumb = useMemo(
() => [
{
name: 'Settings',
url: getSettingPath(),
},
{
name: 'Policies',
url: policiesPath,
},
{
name: getEntityName(policy),
url: getPolicyWithFqnPath(fqn),
},
{
name: 'Add New Rule',
url: '',
},
],
[fqn, policy]
);
const fetchPolicy = async () => {
setLoading(true);
try {
const data = await getPolicyByName(fqn, 'owner,location,teams,roles');
setPolicy(data ?? ({} as Policy));
} catch (error) {
showErrorToast(error as AxiosError);
} finally {
setLoading(false);
}
};
const handleBack = () => {
history.push(getPolicyWithFqnPath(fqn));
};
const handleSubmit = async () => {
const patch = compare(policy, {
...policy,
rules: [...policy.rules, ruleData],
});
try {
const data = await patchPolicy(patch, policy.id);
if (data) {
handleBack();
}
} catch (error) {
showErrorToast(error as AxiosError);
}
};
useEffect(() => {
fetchPolicy();
}, [fqn]);
if (isLoading) {
return <Loader />;
}
return (
<Row className="tw-bg-body-main tw-h-auto" gutter={[16, 16]}>
<Col offset={5} span={14}>
<TitleBreadcrumb titleLinks={breadcrumb} />
<Card>
<Typography.Paragraph className="tw-text-base">
Add New Rule
</Typography.Paragraph>
<Form
data-testid="rule-form"
id="rule-form"
initialValues={{
ruleEffect: ruleData.effect,
}}
layout="vertical"
onFinish={handleSubmit}>
<RuleForm ruleData={ruleData} setRuleData={setRuleData} />
<Space align="center" className="tw-w-full tw-justify-end">
<Button type="link" onClick={handleBack}>
Cancel
</Button>
<Button form="rule-form" htmlType="submit" type="primary">
Submit
</Button>
</Space>
</Form>
</Card>
</Col>
</Row>
);
};
export default AddRulePage;

View File

@ -0,0 +1,162 @@
/*
* Copyright 2022 Collate
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Button, Card, Col, Form, Row, Space, Typography } from 'antd';
import { AxiosError } from 'axios';
import { compare } from 'fast-json-patch';
import React, { useEffect, useMemo, useState } from 'react';
import { useHistory, useParams } from 'react-router-dom';
import { getPolicyByName, patchPolicy } from '../../../axiosAPIs/rolesAPIV1';
import TitleBreadcrumb from '../../../components/common/title-breadcrumb/title-breadcrumb.component';
import Loader from '../../../components/Loader/Loader';
import { GlobalSettingOptions } from '../../../constants/globalSettings.constants';
import { Effect, Rule } from '../../../generated/api/policies/createPolicy';
import { Policy } from '../../../generated/entity/policies/policy';
import { getEntityName } from '../../../utils/CommonUtils';
import {
getPath,
getPolicyWithFqnPath,
getSettingPath,
} from '../../../utils/RouterUtils';
import { showErrorToast } from '../../../utils/ToastUtils';
import RuleForm from '../RuleForm/RuleForm';
const policiesPath = getPath(GlobalSettingOptions.POLICIES);
const InitialData = {
name: '',
description: '',
resources: [],
operations: [],
effect: Effect.Allow,
};
const EditRulePage = () => {
const history = useHistory();
const { fqn, ruleName } = useParams<{ fqn: string; ruleName: string }>();
const [isLoading, setLoading] = useState<boolean>(false);
const [policy, setPolicy] = useState<Policy>({} as Policy);
const [ruleData, setRuleData] = useState<Rule>(InitialData);
const breadcrumb = useMemo(
() => [
{
name: 'Settings',
url: getSettingPath(),
},
{
name: 'Policies',
url: policiesPath,
},
{
name: getEntityName(policy),
url: getPolicyWithFqnPath(fqn),
},
{
name: ruleName,
url: '',
},
],
[fqn, policy, ruleName]
);
const fetchPolicy = async () => {
setLoading(true);
try {
const data = await getPolicyByName(fqn, 'owner,location,teams,roles');
if (data) {
setPolicy(data);
const selectedRule = data.rules.find((rule) => rule.name === ruleName);
setRuleData(selectedRule ?? InitialData);
} else {
setPolicy({} as Policy);
}
} catch (error) {
showErrorToast(error as AxiosError);
} finally {
setLoading(false);
}
};
const handleBack = () => {
history.push(getPolicyWithFqnPath(fqn));
};
const handleSubmit = async () => {
const existingRules = policy.rules;
const updatedRules = existingRules.map((rule) => {
if (rule.name === ruleName) {
return ruleData;
} else {
return rule;
}
});
const patch = compare(policy, {
...policy,
rules: updatedRules,
});
try {
const data = await patchPolicy(patch, policy.id);
if (data) {
handleBack();
}
} catch (error) {
showErrorToast(error as AxiosError);
}
};
useEffect(() => {
fetchPolicy();
}, [fqn]);
if (isLoading) {
return <Loader />;
}
return (
<Row className="tw-bg-body-main tw-h-auto" gutter={[16, 16]}>
<Col offset={5} span={14}>
<TitleBreadcrumb titleLinks={breadcrumb} />
<Card>
<Typography.Paragraph className="tw-text-base">
Edit Rule {`"${ruleName}"`}
</Typography.Paragraph>
<Form
data-testid="rule-form"
id="rule-form"
initialValues={{
ruleEffect: ruleData.effect,
ruleName: ruleData.name,
resources: ruleData.resources,
operations: ruleData.operations,
}}
layout="vertical"
onFinish={handleSubmit}>
<RuleForm ruleData={ruleData} setRuleData={setRuleData} />
<Space align="center" className="tw-w-full tw-justify-end">
<Button type="link" onClick={handleBack}>
Cancel
</Button>
<Button form="rule-form" htmlType="submit" type="primary">
Submit
</Button>
</Space>
</Form>
</Card>
</Col>
</Row>
);
};
export default EditRulePage;

View File

@ -11,6 +11,9 @@
* limitations under the License.
*/
@primary: #7147e8;
@white: #ffffff;
.policies-detail {
.list-table {
.ant-table-row .ant-table-cell:first-child,
@ -18,4 +21,28 @@
padding-left: 16px;
}
}
.ant-collapse {
background-color: @white;
.ant-collapse-item {
.ant-collapse-header {
padding: 6px 16px;
.ant-collapse-arrow {
color: @primary;
font-size: 14px;
margin-right: 6px;
}
}
}
.ant-collapse-content {
border-top: none;
.ant-collapse-content-box {
padding-top: 0px;
}
}
}
.rules-tab {
> .ant-space-item:first-child {
align-self: flex-end;
}
}
}

View File

@ -13,12 +13,10 @@
import {
Button,
Card,
Col,
Collapse,
Empty,
Row,
Modal,
Space,
Switch,
Table,
Tabs,
Typography,
@ -26,7 +24,7 @@ import {
import { ColumnsType } from 'antd/lib/table';
import { AxiosError } from 'axios';
import { compare } from 'fast-json-patch';
import { isEmpty, uniqueId } from 'lodash';
import { isEmpty, isUndefined, uniqueId } from 'lodash';
import React, { useEffect, useMemo, useState } from 'react';
import { Link, useHistory, useParams } from 'react-router-dom';
import { getPolicyByName, patchPolicy } from '../../../axiosAPIs/rolesAPIV1';
@ -39,19 +37,26 @@ import {
GlobalSettingsMenuCategory,
} from '../../../constants/globalSettings.constants';
import { EntityType } from '../../../enums/entity.enum';
import { Effect, Policy } from '../../../generated/entity/policies/policy';
import { Policy } from '../../../generated/entity/policies/policy';
import { EntityReference } from '../../../generated/type/entityReference';
import { getEntityName } from '../../../utils/CommonUtils';
import {
getAddPolicyRulePath,
getEditPolicyRulePath,
getRoleWithFqnPath,
getSettingPath,
getTeamsWithFqnPath,
} from '../../../utils/RouterUtils';
import SVGIcons, { Icons } from '../../../utils/SvgUtils';
import { showErrorToast } from '../../../utils/ToastUtils';
import './PoliciesDetail.less';
const { Panel } = Collapse;
const { TabPane } = Tabs;
type Attribute = 'roles' | 'teams';
const List = ({
list,
type,
@ -107,7 +112,7 @@ const List = ({
render: (_, record) => {
return (
<Button type="text" onClick={() => onDelete(record)}>
Remove
<SVGIcons alt="remove" icon={Icons.ICON_REMOVE} title="Remove" />
</Button>
);
},
@ -133,6 +138,8 @@ const PoliciesDetailPage = () => {
const [policy, setPolicy] = useState<Policy>({} as Policy);
const [isLoading, setLoading] = useState<boolean>(false);
const [editDescription, setEditDescription] = useState<boolean>(false);
const [selectedEntity, setEntity] =
useState<{ attribute: Attribute; record: EntityReference }>();
const policiesPath = getSettingPath(
GlobalSettingsMenuCategory.ACCESS,
@ -177,10 +184,7 @@ const PoliciesDetailPage = () => {
}
};
const handleDelete = async (
data: EntityReference,
attribute: 'roles' | 'teams'
) => {
const handleDelete = async (data: EntityReference, attribute: Attribute) => {
const attributeData =
(policy[attribute as keyof Policy] as EntityReference[]) ?? [];
const updatedAttributeData = attributeData.filter(
@ -238,76 +242,137 @@ const PoliciesDetailPage = () => {
{isEmpty(policy.rules) ? (
<Empty description="No rules found" />
) : (
<Row gutter={[16, 16]}>
{policy.rules.map((rule) => (
<Col key={uniqueId()} span={24}>
<Card>
<Space
align="baseline"
className="tw-w-full tw-justify-between"
size={4}>
<Typography.Paragraph className="tw-font-medium tw-text-base">
{rule.name}
</Typography.Paragraph>
<div>
<Switch
checked={rule.effect === Effect.Allow}
size="small"
/>
<span className="tw-ml-1">Active</span>
</div>
</Space>
<Space className="tw-w-full rules-tab" direction="vertical">
<Button
type="primary"
onClick={() => history.push(getAddPolicyRulePath(fqn))}>
Add Rule
</Button>
<Space className="tw-w-full" direction="vertical">
{policy.rules.map((rule) => (
<Collapse key={uniqueId()}>
<Panel
header={
<Space
className="tw-w-full"
direction="vertical"
size={4}>
<Space
align="baseline"
className="tw-w-full tw-justify-between"
size={4}>
<Typography.Text className="tw-font-medium tw-text-base">
{rule.name}
</Typography.Text>
<Button
data-testid="edit-rule"
type="text"
onClick={(e) => {
e.stopPropagation();
history.push(
getEditPolicyRulePath(
fqn,
rule.name || ''
)
);
}}>
<SVGIcons alt="edit" icon={Icons.EDIT} />
</Button>
</Space>
<div
className="tw--ml-5"
data-testid="description">
<Typography.Text className="tw-text-grey-muted">
Description:
</Typography.Text>
<RichTextEditorPreviewer
markdown={rule.description || ''}
/>
</div>
</Space>
}
key={rule.name || 'rule'}>
<Space direction="vertical">
<Space
data-testid="resources"
direction="vertical"
size={4}>
<Typography.Text className="tw-text-grey-muted tw-mb-0">
Resources:
</Typography.Text>
<Typography.Text>
{rule.resources?.join(', ')}
</Typography.Text>
</Space>
<div className="tw-mb-3" data-testid="description">
<Typography.Text className="tw-text-grey-muted">
Description:
</Typography.Text>
<RichTextEditorPreviewer
markdown={rule.description || ''}
/>
</div>
<Space direction="vertical">
<Space data-testid="resources" direction="vertical">
<Typography.Text className="tw-text-grey-muted tw-mb-0">
Resources:
</Typography.Text>
<Typography.Text>
{rule.resources?.join(', ')}
</Typography.Text>
<Space
data-testid="operations"
direction="vertical"
size={4}>
<Typography.Text className="tw-text-grey-muted">
Operations:
</Typography.Text>
<Typography.Text>
{rule.operations?.join(', ')}
</Typography.Text>
</Space>
{rule.condition && (
<Space
data-testid="condition"
direction="vertical"
size={4}>
<Typography.Text className="tw-text-grey-muted">
Condition:
</Typography.Text>
<code>{rule.condition}</code>
</Space>
)}
</Space>
<Space data-testid="operations" direction="vertical">
<Typography.Text className="tw-text-grey-muted">
Operations:
</Typography.Text>
<Typography.Text>
{rule.operations?.join(', ')}
</Typography.Text>
</Space>
</Space>
</Card>
</Col>
))}
</Row>
</Panel>
</Collapse>
))}
</Space>
</Space>
)}
</TabPane>
<TabPane key="roles" tab="Roles">
<List
list={policy.roles ?? []}
type="role"
onDelete={(record) => handleDelete(record, 'roles')}
onDelete={(record) => setEntity({ record, attribute: 'roles' })}
/>
</TabPane>
<TabPane key="teams" tab="Teams">
<List
list={policy.teams ?? []}
type="team"
onDelete={(record) => handleDelete(record, 'teams')}
onDelete={(record) => setEntity({ record, attribute: 'teams' })}
/>
</TabPane>
</Tabs>
</div>
)}
{selectedEntity && (
<Modal
centered
okText="Confirm"
title={`Remove ${getEntityName(
selectedEntity.record
)} from ${getEntityName(policy)}`}
visible={!isUndefined(selectedEntity.record)}
onCancel={() => setEntity(undefined)}
onOk={() => {
handleDelete(selectedEntity.record, selectedEntity.attribute);
setEntity(undefined);
}}>
<Typography.Text>
Are you sure you want to remove the{' '}
{`${getEntityName(selectedEntity.record)} from ${getEntityName(
policy
)}?`}
</Typography.Text>
</Modal>
)}
</div>
);
};

View File

@ -16,8 +16,13 @@ import { AxiosError } from 'axios';
import React, { useEffect, useState } from 'react';
import { useHistory } from 'react-router-dom';
import { getPolicies } from '../../../axiosAPIs/rolesAPIV1';
import NextPrevious from '../../../components/common/next-previous/NextPrevious';
import Loader from '../../../components/Loader/Loader';
import { ROUTES } from '../../../constants/constants';
import {
INITIAL_PAGING_VALUE,
PAGE_SIZE,
ROUTES,
} from '../../../constants/constants';
import { Policy } from '../../../generated/entity/policies/policy';
import { Paging } from '../../../generated/type/paging';
import { showErrorToast } from '../../../utils/ToastUtils';
@ -28,6 +33,8 @@ const PoliciesListPage = () => {
const history = useHistory();
const [policies, setPolicies] = useState<Policy[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [paging, setPaging] = useState<Paging>();
const [currentPage, setCurrentPage] = useState<number>(INITIAL_PAGING_VALUE);
const fetchPolicies = async (paging?: Paging) => {
setIsLoading(true);
@ -39,6 +46,7 @@ const PoliciesListPage = () => {
);
setPolicies(data.data || []);
setPaging(data.paging);
} catch (error) {
showErrorToast(error as AxiosError);
} finally {
@ -50,6 +58,11 @@ const PoliciesListPage = () => {
history.push(ROUTES.ADD_POLICY);
};
const handlePaging = (_: string | number, activePage?: number) => {
setCurrentPage(activePage ?? INITIAL_PAGING_VALUE);
fetchPolicies(paging);
};
useEffect(() => {
fetchPolicies();
}, []);
@ -68,6 +81,17 @@ const PoliciesListPage = () => {
<Col span={24}>
<PoliciesList fetchPolicies={fetchPolicies} policies={policies} />
</Col>
<Col span={24}>
{paging && paging.total > PAGE_SIZE && (
<NextPrevious
currentPage={currentPage}
pageSize={PAGE_SIZE}
paging={paging}
pagingHandler={handlePaging}
totalCount={paging.total}
/>
)}
</Col>
</Row>
);
};

View File

@ -0,0 +1,205 @@
/*
* Copyright 2022 Collate
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Form, Input, Select, TreeSelect } from 'antd';
import { AxiosError } from 'axios';
import { capitalize, startCase, uniq } from 'lodash';
import React, { FC, useEffect, useMemo, useState } from 'react';
import { getPolicyResources } from '../../../axiosAPIs/rolesAPIV1';
import RichTextEditor from '../../../components/common/rich-text-editor/RichTextEditor';
import {
Effect,
Operation,
Rule,
} from '../../../generated/api/policies/createPolicy';
import { ResourceDescriptor } from '../../../generated/entity/policies/accessControl/resourceDescriptor';
import { showErrorToast } from '../../../utils/ToastUtils';
const { Option } = Select;
interface RuleFormProps {
ruleData: Rule;
setRuleData: (value: React.SetStateAction<Rule>) => void;
}
const RuleForm: FC<RuleFormProps> = ({ ruleData, setRuleData }) => {
const [policyResources, setPolicyResources] = useState<ResourceDescriptor[]>(
[]
);
/**
* Derive the resources from policy resources
*/
const resourcesOptions = useMemo(() => {
const resources = policyResources.filter(
(resource) => resource.name !== 'all'
);
const option = [
{
title: 'All',
value: 'all',
key: 'all',
children: resources.map((resource) => ({
title: startCase(resource.name),
value: resource.name,
key: resource.name,
})),
},
];
return option;
}, [policyResources]);
/**
* Derive the operations from selected resources
*/
const operationOptions = useMemo(() => {
const selectedResources = policyResources.filter((resource) =>
ruleData.resources?.includes(resource.name || '')
);
const operations = selectedResources
.reduce(
(prev: Operation[], curr: ResourceDescriptor) =>
uniq([...prev, ...(curr.operations || [])]),
[]
)
.filter((operation) => operation !== Operation.All);
const option = [
{
title: 'All',
value: Operation.All,
key: 'All',
children: operations.map((operation) => ({
title: operation,
value: operation,
key: operation,
})),
},
];
return option;
}, [ruleData.resources, policyResources]);
const fetchPolicyResources = async () => {
try {
const data = await getPolicyResources();
setPolicyResources(data.data || []);
} catch (error) {
showErrorToast(error as AxiosError);
}
};
useEffect(() => {
fetchPolicyResources();
}, []);
return (
<>
<Form.Item
label="Rule Name:"
name="ruleName"
rules={[
{
required: true,
max: 128,
min: 1,
},
]}>
<Input
placeholder="Rule Name"
type="text"
value={ruleData.name}
onChange={(e) =>
setRuleData((prev) => ({ ...prev, name: e.target.value }))
}
/>
</Form.Item>
<Form.Item label="Description:" name="ruleDescription">
<RichTextEditor
height="200px"
initialValue={ruleData.description || ''}
placeHolder="Write your description"
style={{ margin: 0 }}
onTextChange={(value) =>
setRuleData((prev) => ({ ...prev, description: value }))
}
/>
</Form.Item>
<Form.Item
label="Resources:"
name="resources"
rules={[
{
required: true,
},
]}>
<TreeSelect
treeCheckable
className="tw-w-full"
placeholder="Select Resources"
showCheckedStrategy={TreeSelect.SHOW_PARENT}
treeData={resourcesOptions}
onChange={(values) => {
setRuleData((prev) => ({
...prev,
resources: values,
}));
}}
/>
</Form.Item>
<Form.Item
label="Operations:"
name="operations"
rules={[
{
required: true,
},
]}>
<TreeSelect
treeCheckable
className="tw-w-full"
placeholder="Select Operations"
showCheckedStrategy={TreeSelect.SHOW_PARENT}
treeData={operationOptions}
onChange={(values) => {
setRuleData((prev) => ({
...prev,
operations: values,
}));
}}
/>
</Form.Item>
<Form.Item
label="Effect:"
name="ruleEffect"
rules={[
{
required: true,
},
]}>
<Select
placeholder="Select Rule Effect"
value={ruleData.effect}
onChange={(value) =>
setRuleData((prev) => ({ ...prev, effect: value }))
}>
<Option key={Effect.Allow}>{capitalize(Effect.Allow)}</Option>
<Option key={Effect.Deny}>{capitalize(Effect.Deny)}</Option>
</Select>
</Form.Item>
</>
);
};
export default RuleForm;

View File

@ -29,6 +29,7 @@ import { addRole, getPolicies } from '../../../axiosAPIs/rolesAPIV1';
import RichTextEditor from '../../../components/common/rich-text-editor/RichTextEditor';
import TitleBreadcrumb from '../../../components/common/title-breadcrumb/title-breadcrumb.component';
import { GlobalSettingOptions } from '../../../constants/globalSettings.constants';
import { ADD_ROLE_TEXT } from '../../../constants/HelperTextUtil';
import { Policy } from '../../../generated/entity/policies/policy';
import {
getPath,
@ -101,7 +102,7 @@ const AddRolePage = () => {
return (
<Row className="tw-bg-body-main tw-h-full" gutter={[16, 16]}>
<Col offset={5} span={14}>
<Col offset={4} span={12}>
<TitleBreadcrumb titleLinks={breadcrumb} />
<Card>
<Typography.Paragraph className="tw-text-base">
@ -170,6 +171,12 @@ const AddRolePage = () => {
</Form>
</Card>
</Col>
<Col className="tw-mt-4" span={4}>
<Typography.Paragraph className="tw-text-base tw-font-medium">
Add Role
</Typography.Paragraph>
<Typography.Text>{ADD_ROLE_TEXT}</Typography.Text>
</Col>
</Row>
);
};

View File

@ -11,11 +11,11 @@
* limitations under the License.
*/
import { Button, Empty, Table, Tabs } from 'antd';
import { Button, Empty, Modal, Table, Tabs, Typography } from 'antd';
import { ColumnsType } from 'antd/lib/table';
import { AxiosError } from 'axios';
import { compare } from 'fast-json-patch';
import { isEmpty } from 'lodash';
import { isEmpty, isUndefined } from 'lodash';
import { EntityReference } from 'Models';
import React, { useEffect, useMemo, useState } from 'react';
import { Link, useHistory, useParams } from 'react-router-dom';
@ -37,11 +37,14 @@ import {
getSettingPath,
getTeamsWithFqnPath,
} from '../../../utils/RouterUtils';
import SVGIcons, { Icons } from '../../../utils/SvgUtils';
import { showErrorToast } from '../../../utils/ToastUtils';
import './RolesDetail.less';
const { TabPane } = Tabs;
type Attribute = 'policies' | 'teams' | 'users';
const List = ({
list,
type,
@ -101,7 +104,7 @@ const List = ({
render: (_, record) => {
return (
<Button type="text" onClick={() => onDelete(record)}>
Remove
<SVGIcons alt="remove" icon={Icons.ICON_REMOVE} title="Remove" />
</Button>
);
},
@ -127,6 +130,8 @@ const RolesDetailPage = () => {
const [role, setRole] = useState<Role>({} as Role);
const [isLoading, setLoading] = useState<boolean>(false);
const [editDescription, setEditDescription] = useState<boolean>(false);
const [selectedEntity, setEntity] =
useState<{ attribute: Attribute; record: EntityReference }>();
const rolesPath = getSettingPath(
GlobalSettingsMenuCategory.ACCESS,
@ -171,10 +176,7 @@ const RolesDetailPage = () => {
}
};
const handleDelete = async (
data: EntityReference,
attribute: 'policies' | 'teams' | 'users'
) => {
const handleDelete = async (data: EntityReference, attribute: Attribute) => {
const attributeData =
(role[attribute as keyof Role] as EntityReference[]) ?? [];
const updatedAttributeData = attributeData.filter(
@ -232,26 +234,49 @@ const RolesDetailPage = () => {
<List
list={role.policies ?? []}
type="policy"
onDelete={(record) => handleDelete(record, 'policies')}
onDelete={(record) =>
setEntity({ record, attribute: 'policies' })
}
/>
</TabPane>
<TabPane key="teams" tab="Teams">
<List
list={role.teams ?? []}
type="team"
onDelete={(record) => handleDelete(record, 'teams')}
onDelete={(record) => setEntity({ record, attribute: 'teams' })}
/>
</TabPane>
<TabPane key="users" tab="Users">
<List
list={role.users ?? []}
type="user"
onDelete={(record) => handleDelete(record, 'users')}
onDelete={(record) => setEntity({ record, attribute: 'users' })}
/>
</TabPane>
</Tabs>
</div>
)}
{selectedEntity && (
<Modal
centered
okText="Confirm"
title={`Remove ${getEntityName(
selectedEntity.record
)} from ${getEntityName(role)}`}
visible={!isUndefined(selectedEntity.record)}
onCancel={() => setEntity(undefined)}
onOk={() => {
handleDelete(selectedEntity.record, selectedEntity.attribute);
setEntity(undefined);
}}>
<Typography.Text>
Are you sure you want to remove the{' '}
{`${getEntityName(selectedEntity.record)} from ${getEntityName(
role
)}?`}
</Typography.Text>
</Modal>
)}
</div>
);
};

View File

@ -16,8 +16,13 @@ import { AxiosError } from 'axios';
import React, { useEffect, useState } from 'react';
import { useHistory } from 'react-router-dom';
import { getRoles } from '../../../axiosAPIs/rolesAPIV1';
import NextPrevious from '../../../components/common/next-previous/NextPrevious';
import Loader from '../../../components/Loader/Loader';
import { ROUTES } from '../../../constants/constants';
import {
INITIAL_PAGING_VALUE,
PAGE_SIZE,
ROUTES,
} from '../../../constants/constants';
import { Role } from '../../../generated/entity/teams/role';
import { Paging } from '../../../generated/type/paging';
import { showErrorToast } from '../../../utils/ToastUtils';
@ -29,6 +34,8 @@ const RolesListPage = () => {
const [roles, setRoles] = useState<Role[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [paging, setPaging] = useState<Paging>();
const [currentPage, setCurrentPage] = useState<number>(INITIAL_PAGING_VALUE);
const fetchRoles = async (paging?: Paging) => {
setIsLoading(true);
@ -40,6 +47,7 @@ const RolesListPage = () => {
);
setRoles(data.data || []);
setPaging(data.paging);
} catch (error) {
showErrorToast(error as AxiosError);
} finally {
@ -51,6 +59,11 @@ const RolesListPage = () => {
history.push(ROUTES.ADD_ROLE);
};
const handlePaging = (_: string | number, activePage?: number) => {
setCurrentPage(activePage ?? INITIAL_PAGING_VALUE);
fetchRoles(paging);
};
useEffect(() => {
fetchRoles();
}, []);
@ -69,6 +82,17 @@ const RolesListPage = () => {
<Col span={24}>
<RolesList fetchRoles={fetchRoles} roles={roles} />
</Col>
<Col span={24}>
{paging && paging.total > PAGE_SIZE && (
<NextPrevious
currentPage={currentPage}
pageSize={PAGE_SIZE}
paging={paging}
pagingHandler={handlePaging}
totalCount={paging.total}
/>
)}
</Col>
</Row>
);
};

View File

@ -183,6 +183,17 @@ const AddPolicyPage = withSuspenseFallback(
React.lazy(() => import('../pages/PoliciesPage/AddPolicyPage/AddPolicyPage'))
);
const AddRulePage = withSuspenseFallback(
React.lazy(
() => import('../pages/PoliciesPage/PoliciesDetailPage/AddRulePage')
)
);
const EditRulePage = withSuspenseFallback(
React.lazy(
() => import('../pages/PoliciesPage/PoliciesDetailPage/EditRulePage')
)
);
const AuthenticatedAppRouter: FunctionComponent = () => {
return (
<Switch>
@ -368,6 +379,16 @@ const AuthenticatedAppRouter: FunctionComponent = () => {
component={AddPolicyPage}
path={ROUTES.ADD_POLICY}
/>
<AdminProtectedRoute
exact
component={AddRulePage}
path={ROUTES.ADD_POLICY_RULE}
/>
<AdminProtectedRoute
exact
component={EditRulePage}
path={ROUTES.EDIT_POLICY_RULE}
/>
<Route exact component={GlobalSettingPage} path={ROUTES.SETTINGS} />
<Route

View File

@ -24,6 +24,7 @@ import {
PLACEHOLDER_ROUTE_SERVICE_CAT,
PLACEHOLDER_ROUTE_SERVICE_FQN,
PLACEHOLDER_ROUTE_TAB,
PLACEHOLDER_RULE_NAME,
PLACEHOLDER_SETTING_CATEGORY,
ROUTES,
} from '../constants/constants';
@ -301,3 +302,21 @@ export const getProfilerDashboardWithFqnPath = (entityTypeFQN: string) => {
return path;
};
export const getAddPolicyRulePath = (fqn: string) => {
let path = ROUTES.ADD_POLICY_RULE;
path = path.replace(PLACEHOLDER_ROUTE_FQN, fqn);
return path;
};
export const getEditPolicyRulePath = (fqn: string, ruleName: string) => {
let path = ROUTES.EDIT_POLICY_RULE;
path = path
.replace(PLACEHOLDER_ROUTE_FQN, fqn)
.replace(PLACEHOLDER_RULE_NAME, ruleName);
return path;
};

View File

@ -136,6 +136,7 @@ import IconProfilerColor from '../assets/svg/profiler-color.svg';
import IconProfiler from '../assets/svg/profiler.svg';
import IconHelpCircle from '../assets/svg/question-circle.svg';
import IconReaction from '../assets/svg/Reaction.svg';
import IconRemove from '../assets/svg/Remove.svg';
import IconReplyFeed from '../assets/svg/Reply.svg';
import IconRequest from '../assets/svg/request-icon.svg';
import IconSampleDataColor from '../assets/svg/sample-data-colored.svg';
@ -329,6 +330,7 @@ export const Icons = {
FOREGIN_KEY: 'foreign-key',
ROLE_GREY: 'role-grey',
POLICIES: 'policies',
ICON_REMOVE: 'icon-remove',
};
const SVGIcons: FunctionComponent<Props> = ({
@ -963,6 +965,10 @@ const SVGIcons: FunctionComponent<Props> = ({
case Icons.POLICIES:
IconComponent = IconPolicies;
break;
case Icons.ICON_REMOVE:
IconComponent = IconRemove;
break;
default: