feat(UI): AccessManagement UI to access the role metadata for a dataset (#8541)

Co-authored-by: Ramendra Srivastava <ramsrivastava@paypal.com>
This commit is contained in:
Ramendra761 2023-09-08 23:10:49 +05:30 committed by GitHub
parent 68ae3bfc26
commit 75252a3d9f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 721 additions and 4 deletions

View File

@ -15,4 +15,5 @@ public class FeatureFlags {
private boolean showBrowseV2 = false;
private PreProcessHooks preProcessHooks;
private boolean showAcrylInfo = false;
private boolean showAccessManagement = false;
}

View File

@ -171,6 +171,7 @@ public class AppConfigResolver implements DataFetcher<CompletableFuture<AppConfi
.setReadOnlyModeEnabled(_featureFlags.isReadOnlyModeEnabled())
.setShowBrowseV2(_featureFlags.isShowBrowseV2())
.setShowAcrylInfo(_featureFlags.isShowAcrylInfo())
.setShowAccessManagement(_featureFlags.isShowAccessManagement())
.build();
appConfig.setFeatureFlags(featureFlagsConfig);

View File

@ -68,6 +68,7 @@ public class SearchUtils {
EntityType.GLOSSARY_TERM,
EntityType.GLOSSARY_NODE,
EntityType.TAG,
EntityType.ROLE,
EntityType.CORP_USER,
EntityType.CORP_GROUP,
EntityType.CONTAINER,
@ -94,6 +95,7 @@ public class SearchUtils {
EntityType.TAG,
EntityType.CORP_USER,
EntityType.CORP_GROUP,
EntityType.ROLE,
EntityType.NOTEBOOK,
EntityType.DATA_PRODUCT);
@ -386,4 +388,4 @@ public class SearchUtils {
(inputTypes == null || inputTypes.isEmpty()) ? SEARCHABLE_ENTITY_TYPES : inputTypes;
return entityTypes.stream().map(EntityTypeMapper::getName).collect(Collectors.toList());
}
}
}

View File

@ -441,6 +441,10 @@ type FeatureFlagsConfig {
Whether we should show CTAs in the UI related to moving to Managed DataHub by Acryl.
"""
showAcrylInfo: Boolean!
"""
Whether we should show AccessManagement tab in the datahub UI.
"""
showAccessManagement: Boolean!
}
"""

View File

@ -68,6 +68,10 @@ type Query {
Fetch a Tag by primary key (urn)
"""
tag(urn: String!): Tag
"""
Fetch a Role by primary key (urn)
"""
role(urn: String!): Role
"""
Fetch a Glossary Term by primary key (urn)
@ -1451,12 +1455,12 @@ type Role implements Entity {
"""
Role properties to include Request Access Url
"""
properties: RoleProperties!
properties: RoleProperties
"""
A standard Entity Type
"""
actors: Actor!
actors: Actor
}
@ -11164,4 +11168,4 @@ input UpdateOwnershipTypeInput {
The description of the Custom Ownership Type
"""
description: String
}
}

View File

@ -35,6 +35,7 @@ import GlossaryNodeEntity from './app/entity/glossaryNode/GlossaryNodeEntity';
import { DataPlatformEntity } from './app/entity/dataPlatform/DataPlatformEntity';
import { DataProductEntity } from './app/entity/dataProduct/DataProductEntity';
import { DataPlatformInstanceEntity } from './app/entity/dataPlatformInstance/DataPlatformInstanceEntity';
import { RoleEntity } from './app/entity/Access/RoleEntity';
/*
Construct Apollo Client
@ -116,6 +117,7 @@ const App: React.VFC = () => {
register.register(new DomainEntity());
register.register(new ContainerEntity());
register.register(new GlossaryNodeEntity());
register.register(new RoleEntity());
register.register(new DataPlatformEntity());
register.register(new DataProductEntity());
register.register(new DataPlatformInstanceEntity());

View File

@ -0,0 +1,88 @@
import { TagOutlined, TagFilled } from '@ant-design/icons';
import * as React from 'react';
import styled from 'styled-components';
import { Role, EntityType, SearchResult } from '../../../types.generated';
import DefaultPreviewCard from '../../preview/DefaultPreviewCard';
import { Entity, EntityCapabilityType, IconStyleType, PreviewType } from '../Entity';
import { getDataForEntityType } from '../shared/containers/profile/utils';
import { urlEncodeUrn } from '../shared/utils';
import RoleEntityProfile from './RoleEntityProfile';
const PreviewTagIcon = styled(TagOutlined)`
font-size: 20px;
`;
// /**
// * Definition of the DataHub Access Role entity.
// */
export class RoleEntity implements Entity<Role> {
type: EntityType = EntityType.Role;
icon = (fontSize: number, styleType: IconStyleType, color?: string) => {
if (styleType === IconStyleType.TAB_VIEW) {
return <TagFilled style={{ fontSize, color }} />;
}
if (styleType === IconStyleType.HIGHLIGHT) {
return <TagFilled style={{ fontSize, color: color || '#B37FEB' }} />;
}
return (
<TagOutlined
style={{
fontSize,
color: color || '#BFBFBF',
}}
/>
);
};
isSearchEnabled = () => true;
isBrowseEnabled = () => false;
isLineageEnabled = () => false;
getAutoCompleteFieldName = () => 'name';
getPathName: () => string = () => 'role';
getCollectionName: () => string = () => 'Roles';
getEntityName: () => string = () => 'Role';
renderProfile: (urn: string) => JSX.Element = (_) => <RoleEntityProfile />;
renderPreview = (_: PreviewType, data: Role) => (
<DefaultPreviewCard
description={data?.properties?.description || ''}
name={this.displayName(data)}
urn={data.urn}
url={`/${this.getPathName()}/${urlEncodeUrn(data.urn)}`}
logoComponent={<PreviewTagIcon />}
type="Role"
typeIcon={this.icon(14, IconStyleType.ACCENT)}
/>
);
renderSearch = (result: SearchResult) => {
return this.renderPreview(PreviewType.SEARCH, result.entity as Role);
};
displayName = (data: Role) => {
return data.properties?.name || data.urn;
};
getOverridePropertiesFromEntity = (data: Role) => {
return {
name: data.properties?.name,
};
};
getGenericEntityProperties = (role: Role) => {
return getDataForEntityType({ data: role, entityType: this.type, getOverrideProperties: (data) => data });
};
supportedCapabilities = () => {
return new Set([EntityCapabilityType.OWNERS]);
};
}

View File

@ -0,0 +1,75 @@
import React from 'react';
import { useParams } from 'react-router';
import { Divider, Typography } from 'antd';
import { grey } from '@ant-design/colors';
import styled from 'styled-components';
import { Message } from '../../shared/Message';
import { decodeUrn } from '../shared/utils';
import { useGetExternalRoleQuery } from '../../../graphql/accessrole.generated';
const PageContainer = styled.div`
padding: 32px 100px;
`;
const LoadingMessage = styled(Message)`
margin-top: 10%;
`;
type RolePageParams = {
urn: string;
};
const TitleLabel = styled(Typography.Text)`
&&& {
color: ${grey[2]};
font-size: 12px;
display: block;
line-height: 20px;
font-weight: 700;
}
`;
const DescriptionLabel = styled(Typography.Text)`
&&& {
text-align: left;
font-weight: bold;
font-size: 14px;
line-height: 28px;
color: rgb(38, 38, 38);
}
`;
const TitleText = styled(Typography.Text)`
&&& {
color: ${grey[10]};
font-weight: 700;
font-size: 20px;
line-height: 28px;
display: inline-block;
margin: 0px 7px;
}
`;
const { Paragraph } = Typography;
export default function RoleEntityProfile() {
const { urn: encodedUrn } = useParams<RolePageParams>();
const urn = decodeUrn(encodedUrn);
const { data, loading } = useGetExternalRoleQuery({ variables: { urn } });
return (
<PageContainer>
{loading && <LoadingMessage type="loading" content="Loading..." />}
<TitleLabel>Role</TitleLabel>
<TitleText>{data?.role?.properties?.name}</TitleText>
<Divider />
{/* Role Description */}
<DescriptionLabel>About</DescriptionLabel>
<Paragraph style={{ fontSize: '12px', lineHeight: '15px', padding: '5px 0px' }}>
{data?.role?.properties?.description}
</Paragraph>
</PageContainer>
);
}

View File

@ -2,6 +2,7 @@ import * as React from 'react';
import { DatabaseFilled, DatabaseOutlined } from '@ant-design/icons';
import { Dataset, DatasetProperties, EntityType, OwnershipType, SearchResult } from '../../../types.generated';
import { Entity, EntityCapabilityType, IconStyleType, PreviewType } from '../Entity';
import { useAppConfig } from '../../useAppConfig';
import { Preview } from './preview/Preview';
import { EntityProfile } from '../shared/containers/profile/EntityProfile';
import { GetDatasetQuery, useGetDatasetQuery, useUpdateDatasetMutation } from '../../../graphql/dataset.generated';
@ -30,6 +31,7 @@ import { EmbedTab } from '../shared/tabs/Embed/EmbedTab';
import EmbeddedProfile from '../shared/embed/EmbeddedProfile';
import DataProductSection from '../shared/containers/profile/sidebar/DataProduct/DataProductSection';
import { getDataProduct } from '../shared/utils';
import AccessManagement from '../shared/tabs/Dataset/AccessManagement/AccessManagement';
import { matchedFieldPathsRenderer } from '../../search/matches/matchedFieldPathsRenderer';
const SUBTYPES = {
@ -69,6 +71,8 @@ export class DatasetEntity implements Entity<Dataset> {
isSearchEnabled = () => true;
appconfig = useAppConfig;
isBrowseEnabled = () => true;
isLineageEnabled = () => true;
@ -176,6 +180,14 @@ export class DatasetEntity implements Entity<Dataset> {
},
},
},
{
name: 'Access Management',
component: AccessManagement,
display: {
visible: (_, _1) => this.appconfig().config.featureFlags.showAccessManagement,
enabled: (_, _2) => true,
},
},
]}
sidebarSections={[
{

View File

@ -0,0 +1,115 @@
import React from 'react';
import styled from 'styled-components';
import { Button, Table } from 'antd';
import { useBaseEntity } from '../../../EntityContext';
import { GetDatasetQuery, useGetExternalRolesQuery } from '../../../../../../graphql/dataset.generated';
import { useGetMeQuery } from '../../../../../../graphql/me.generated';
import { handleAccessRoles } from './utils';
import AccessManagerDescription from './AccessManagerDescription';
const StyledTable = styled(Table)`
overflow: inherit;
height: inherit;
&&& .ant-table-cell {
background-color: #fff;
}
&&& .ant-table-thead .ant-table-cell {
font-weight: 600;
font-size: 12px;
color: '#898989';
}
&&
.ant-table-thead
> tr
> th:not(:last-child):not(.ant-table-selection-column):not(.ant-table-row-expand-icon-cell):not([colspan])::before {
border: 1px solid #f0f0f0;
}
` as typeof Table;
const StyledSection = styled.section`
background-color: #fff;
color: black;
width: 83px;
text-align: center;
border-radius: 3px;
border: none;
font-weight: bold;
`;
const AccessButton = styled(Button)`
background-color: #1890ff;
color: white;
width: 80px;
height: 30px;
border-radius: 3.5px;
border: none;
font-weight: bold;
&:hover {
background-color: #18baff;
color: white;
width: 80px;
height: 30px;
border-radius: 3.5px;
border: none;
font-weight: bold;
}
`;
export default function AccessManagement() {
const { data: loggedInUser } = useGetMeQuery({ fetchPolicy: 'cache-first' });
const baseEntity = useBaseEntity<GetDatasetQuery>();
const { data: externalRoles } = useGetExternalRolesQuery({
variables: { urn: baseEntity?.dataset?.urn as string },
skip: !baseEntity?.dataset?.urn,
});
const columns = [
{
title: 'Role Name',
dataIndex: 'name',
key: 'name',
},
{
title: 'Description',
dataIndex: 'description',
key: 'description',
render: (roleDescription) => {
return <AccessManagerDescription description={roleDescription} />;
},
},
{
title: 'Access Type',
dataIndex: 'accessType',
key: 'accessType',
},
{
title: 'Access',
dataIndex: 'hasAccess',
key: 'hasAccess',
render: (hasAccess, record) => {
if (hasAccess) {
return <StyledSection>Provisioned</StyledSection>;
}
if (record?.url) {
return (
<AccessButton
onClick={(e) => {
e.preventDefault();
window.open(record.url);
}}
>
Request
</AccessButton>
);
}
return <StyledSection />;
},
hidden: true,
},
];
return (
<StyledTable dataSource={handleAccessRoles(externalRoles, loggedInUser)} columns={columns} pagination={false} />
);
}

View File

@ -0,0 +1,38 @@
import React, { useState } from 'react';
import styled from 'styled-components';
import { Typography } from 'antd';
export type Props = {
description: any;
};
const DescriptionContainer = styled.div`
position: relative;
display: flex;
flex-direction: column;
width: 500px;
height: 100%;
min-height: 22px;
`;
export default function AccessManagerDescription({ description }: Props) {
const shouldTruncateDescription = description.length > 150;
const [expanded, setIsExpanded] = useState(!shouldTruncateDescription);
const finalDescription = expanded ? description : description.slice(0, 150);
const toggleExpanded = () => {
setIsExpanded(!expanded);
};
return (
<DescriptionContainer>
{finalDescription}
<Typography.Link
onClick={() => {
toggleExpanded();
}}
>
{(shouldTruncateDescription && (expanded ? ' Read Less' : '...Read More')) || undefined}
</Typography.Link>
</DescriptionContainer>
);
}

View File

@ -0,0 +1,267 @@
import { handleAccessRoles } from '../utils';
import { GetExternalRolesQuery } from '../../../../../../../graphql/dataset.generated';
import { GetMeQuery } from '../../../../../../../graphql/me.generated';
describe('handleAccessRoles', () => {
it('should properly map the externalroles and loggedin user', () => {
const externalRolesQuery: GetExternalRolesQuery = {
dataset: {
access: {
roles: [
{
role: {
id: 'accessRole',
properties: {
name: 'accessRole',
description:
'This role access is required by the developers to test and deploy the code also adding few more details to check the description length for the given data and hence check the condition of read more and read less ',
type: 'READ',
requestUrl: 'https://www.google.com/',
},
urn: 'urn:li:role:accessRole',
actors: {
users: null,
},
},
},
],
},
__typename: 'Dataset',
},
};
const GetMeQueryUser: GetMeQuery = {
me: {
corpUser: {
urn: 'urn:li:corpuser:datahub',
username: 'datahub',
info: {
active: true,
displayName: 'DataHub',
title: 'DataHub Root User',
firstName: null,
lastName: null,
fullName: null,
email: null,
__typename: 'CorpUserInfo',
},
editableProperties: {
displayName: null,
title: null,
pictureLink:
'https://raw.githubusercontent.com/datahub-project/datahub/master/datahub-web-react/src/images/default_avatar.png',
teams: [],
skills: [],
__typename: 'CorpUserEditableProperties',
},
settings: {
appearance: {
showSimplifiedHomepage: false,
__typename: 'CorpUserAppearanceSettings',
},
views: null,
__typename: 'CorpUserSettings',
},
__typename: 'CorpUser',
},
platformPrivileges: {
viewAnalytics: true,
managePolicies: true,
manageIdentities: true,
generatePersonalAccessTokens: true,
manageIngestion: true,
manageSecrets: true,
manageDomains: true,
manageTests: true,
manageGlossaries: true,
manageUserCredentials: true,
manageTags: true,
createDomains: true,
createTags: true,
manageGlobalViews: true,
manageOwnershipTypes: true,
__typename: 'PlatformPrivileges',
},
__typename: 'AuthenticatedUser',
},
};
const externalRole = handleAccessRoles(externalRolesQuery, GetMeQueryUser);
expect(externalRole).toMatchObject([
{
name: 'accessRole',
description:
'This role access is required by the developers to test and deploy the code also adding few more details to check the description length for the given data and hence check the condition of read more and read less ',
accessType: 'READ',
hasAccess: false,
url: 'https://www.google.com/',
},
]);
});
it('should return empty array', () => {
const externalRolesQuery: GetExternalRolesQuery = {
dataset: {
access: null,
__typename: 'Dataset',
},
};
const GetMeQueryUser: GetMeQuery = {
me: {
corpUser: {
urn: 'urn:li:corpuser:datahub',
username: 'datahub',
info: {
active: true,
displayName: 'DataHub',
title: 'DataHub Root User',
firstName: null,
lastName: null,
fullName: null,
email: null,
__typename: 'CorpUserInfo',
},
editableProperties: {
displayName: null,
title: null,
pictureLink:
'https://raw.githubusercontent.com/datahub-project/datahub/master/datahub-web-react/src/images/default_avatar.png',
teams: [],
skills: [],
__typename: 'CorpUserEditableProperties',
},
settings: {
appearance: {
showSimplifiedHomepage: false,
__typename: 'CorpUserAppearanceSettings',
},
views: null,
__typename: 'CorpUserSettings',
},
__typename: 'CorpUser',
},
platformPrivileges: {
viewAnalytics: true,
managePolicies: true,
manageIdentities: true,
generatePersonalAccessTokens: true,
manageIngestion: true,
manageSecrets: true,
manageDomains: true,
manageTests: true,
manageGlossaries: true,
manageUserCredentials: true,
manageTags: true,
createDomains: true,
createTags: true,
manageGlobalViews: true,
manageOwnershipTypes: true,
__typename: 'PlatformPrivileges',
},
__typename: 'AuthenticatedUser',
},
};
const externalRole = handleAccessRoles(externalRolesQuery, GetMeQueryUser);
expect(externalRole).toMatchObject([]);
});
it('should properly map the externalroles and loggedin user and access true', () => {
const externalRolesQuery: GetExternalRolesQuery = {
dataset: {
access: {
roles: [
{
role: {
id: 'accessRole',
properties: {
name: 'accessRole',
description:
'This role access is required by the developers to test and deploy the code also adding few more details to check the description length for the given data and hence check the condition of read more and read less ',
type: 'READ',
requestUrl: 'https://www.google.com/',
},
urn: 'urn:li:role:accessRole',
actors: {
users: [
{
user: {
urn: 'urn:li:corpuser:datahub',
},
},
],
},
},
},
],
},
__typename: 'Dataset',
},
};
const GetMeQueryUser: GetMeQuery = {
me: {
corpUser: {
urn: 'urn:li:corpuser:datahub',
username: 'datahub',
info: {
active: true,
displayName: 'DataHub',
title: 'DataHub Root User',
firstName: null,
lastName: null,
fullName: null,
email: null,
__typename: 'CorpUserInfo',
},
editableProperties: {
displayName: null,
title: null,
pictureLink:
'https://raw.githubusercontent.com/datahub-project/datahub/master/datahub-web-react/src/images/default_avatar.png',
teams: [],
skills: [],
__typename: 'CorpUserEditableProperties',
},
settings: {
appearance: {
showSimplifiedHomepage: false,
__typename: 'CorpUserAppearanceSettings',
},
views: null,
__typename: 'CorpUserSettings',
},
__typename: 'CorpUser',
},
platformPrivileges: {
viewAnalytics: true,
managePolicies: true,
manageIdentities: true,
generatePersonalAccessTokens: true,
manageIngestion: true,
manageSecrets: true,
manageDomains: true,
manageTests: true,
manageGlossaries: true,
manageUserCredentials: true,
manageTags: true,
createDomains: true,
createTags: true,
manageGlobalViews: true,
manageOwnershipTypes: true,
__typename: 'PlatformPrivileges',
},
__typename: 'AuthenticatedUser',
},
};
const externalRole = handleAccessRoles(externalRolesQuery, GetMeQueryUser);
expect(externalRole).toMatchObject([
{
name: 'accessRole',
description:
'This role access is required by the developers to test and deploy the code also adding few more details to check the description length for the given data and hence check the condition of read more and read less ',
accessType: 'READ',
hasAccess: true,
url: 'https://www.google.com/',
},
]);
});
});

View File

@ -0,0 +1,27 @@
export function handleAccessRoles(externalRoles, loggedInUser) {
const accessRoles = new Array<any>();
if (
externalRoles?.dataset?.access &&
externalRoles?.dataset?.access.roles &&
externalRoles?.dataset?.access.roles.length > 0
) {
externalRoles?.dataset?.access?.roles?.forEach((userRoles) => {
const role = {
name: userRoles?.role?.properties?.name || ' ',
description: userRoles?.role?.properties?.description || ' ',
accessType: userRoles?.role?.properties?.type || ' ',
hasAccess:
(userRoles?.role?.actors?.users &&
userRoles?.role?.actors?.users.length > 0 &&
userRoles?.role?.actors?.users?.some(
(user) => user.user.urn === loggedInUser?.me?.corpUser.urn,
)) ||
false,
url: userRoles?.role?.properties?.requestUrl || undefined,
};
accessRoles.push(role);
});
}
return accessRoles;
}

View File

@ -48,6 +48,7 @@ export const DEFAULT_APP_CONFIG = {
showSearchFiltersV2: true,
showBrowseV2: true,
showAcrylInfo: false,
showAccessManagement: false,
},
};

View File

@ -0,0 +1,8 @@
query getExternalRole($urn: String!) {
role(urn: $urn) {
properties {
name
description
}
}
}

View File

@ -63,6 +63,7 @@ query appConfig {
showSearchFiltersV2
showBrowseV2
showAcrylInfo
showAccessManagement
}
}
}

View File

@ -311,3 +311,34 @@ query getDatasetSchema($urn: String!) {
}
}
}
query getExternalRoles($urn: String!) {
dataset(urn: $urn) {
access {
...getRoles
}
__typename
}
}
fragment getRoles on Access {
roles {
role {
id
properties {
name
description
type
requestUrl
}
urn
actors {
users {
user {
urn
}
}
}
}
}
}

View File

@ -44,6 +44,16 @@ fragment autoCompleteFields on Entity {
}
}
...datasetStatsFields
access {
...getAccess
}
}
... on Role {
id
properties {
name
description
}
}
... on CorpUser {
username
@ -242,6 +252,25 @@ query getAutoCompleteMultipleResults($input: AutoCompleteMultipleInput!) {
}
}
fragment getAccess on Access {
roles {
role {
...getRolesName
}
}
}
fragment getRolesName on Role {
urn
type
id
properties {
name
description
type
}
}
fragment datasetStatsFields on Dataset {
lastProfile: datasetProfiles(limit: 1) {
rowCount
@ -288,6 +317,9 @@ fragment nonSiblingsDatasetSearchFields on Dataset {
editableProperties {
description
}
access {
...getAccess
}
platformNativeType
properties {
name
@ -346,6 +378,13 @@ fragment searchResultFields on Entity {
}
}
}
... on Role {
id
properties {
name
description
}
}
... on CorpUser {
username
properties {

View File

@ -294,6 +294,7 @@ featureFlags:
alwaysEmitChangeLog: ${ALWAYS_EMIT_CHANGE_LOG:false} # Enables always emitting a MCL even when no changes are detected. Used for Time Based Lineage when no changes occur.
searchServiceDiffModeEnabled: ${SEARCH_SERVICE_DIFF_MODE_ENABLED:true} # Enables diff mode for search document writes, reduces amount of writes to ElasticSearch documents for no-ops
readOnlyModeEnabled: ${READ_ONLY_MODE_ENABLED:false} # Enables read only mode for an instance. Right now this only affects ability to edit user profile image URL but can be extended
showAccessManagement: ${SHOW_ACCESS_MANAGEMENT:false} #Whether we should show AccessManagement tab in the datahub UI.
showSearchFiltersV2: ${SHOW_SEARCH_FILTERS_V2:true} # Enables showing the search filters V2 experience.
showBrowseV2: ${SHOW_BROWSE_V2:true} # Enables showing the browse v2 sidebar experience.
preProcessHooks: