diff --git a/src/components/SubjectWithAvatar/SubjectWithAvatar.scss b/src/components/SubjectWithAvatar/SubjectWithAvatar.scss new file mode 100644 index 0000000000..cdc2518230 --- /dev/null +++ b/src/components/SubjectWithAvatar/SubjectWithAvatar.scss @@ -0,0 +1,11 @@ +.ydb-subject-with-avatar { + &__avatar-wrapper { + position: relative; + } + + &__subject { + overflow: hidden; + + text-overflow: ellipsis; + } +} diff --git a/src/components/SubjectWithAvatar/SubjectWithAvatar.tsx b/src/components/SubjectWithAvatar/SubjectWithAvatar.tsx new file mode 100644 index 0000000000..52861a7cd8 --- /dev/null +++ b/src/components/SubjectWithAvatar/SubjectWithAvatar.tsx @@ -0,0 +1,34 @@ +import {Avatar, Flex, Text} from '@gravity-ui/uikit'; + +import {cn} from '../../utils/cn'; + +export const block = cn('ydb-subject-with-avatar'); + +import './SubjectWithAvatar.scss'; + +interface SubjectProps { + subject: string; + title?: string; + renderIcon?: () => React.ReactNode; +} + +export function SubjectWithAvatar({subject, title, renderIcon}: SubjectProps) { + return ( + +
+ + {renderIcon?.()} +
+ + + {subject} + + {title && ( + + {title} + + )} + +
+ ); +} diff --git a/src/containers/Tenant/Acl/Acl.scss b/src/containers/Tenant/Acl/Acl.scss deleted file mode 100644 index bfd2dce80a..0000000000 --- a/src/containers/Tenant/Acl/Acl.scss +++ /dev/null @@ -1,27 +0,0 @@ -@use '../../../styles/mixins.scss'; - -.ydb-acl { - width: 100%; - &__result { - padding-bottom: var(--g-spacing-4); - padding-left: var(--g-spacing-2); - - &_no-title { - margin-top: var(--g-spacing-3); - } - } - &__definition-content { - display: flex; - flex-direction: column; - align-items: flex-end; - } - &__list-title { - margin: var(--g-spacing-3) 0 var(--g-spacing-5); - - font-weight: 600; - @include mixins.text-subheader-2(); - } - &__group-label { - @include mixins.text-subheader-2(); - } -} diff --git a/src/containers/Tenant/Acl/Acl.tsx b/src/containers/Tenant/Acl/Acl.tsx index 9d85d9a32f..c341b2401c 100644 --- a/src/containers/Tenant/Acl/Acl.tsx +++ b/src/containers/Tenant/Acl/Acl.tsx @@ -1,208 +1,32 @@ -import React from 'react'; +import {Button, Flex, Icon, Text} from '@gravity-ui/uikit'; -import {DefinitionList} from '@gravity-ui/components'; -import type {DefinitionListItem} from '@gravity-ui/components'; -import {SquareCheck} from '@gravity-ui/icons'; -import {Icon} from '@gravity-ui/uikit'; - -import {ResponseError} from '../../../components/Errors/ResponseError'; -import {Loader} from '../../../components/Loader'; -import {schemaAclApi} from '../../../store/reducers/schemaAcl/schemaAcl'; -import type {TACE} from '../../../types/api/acl'; -import {valueIsDefined} from '../../../utils'; -import {cn} from '../../../utils/cn'; +import { + TENANT_DIAGNOSTICS_TABS_IDS, + TENANT_PAGES_IDS, +} from '../../../store/reducers/tenant/constants'; +import {setDiagnosticsTab, setTenantPage} from '../../../store/reducers/tenant/tenant'; +import {useTypedDispatch} from '../../../utils/hooks'; import i18n from './i18n'; -import './Acl.scss'; - -const b = cn('ydb-acl'); - -const prepareLogin = (value: string | undefined) => { - if (value && value.endsWith('@staff') && !value.startsWith('svc_')) { - const login = value.split('@')[0]; - return login; - } - - return value; -}; - -const aclParams = ['access', 'type', 'inheritance'] as const; - -type AclParameter = (typeof aclParams)[number]; - -const aclParamToName: Record = { - access: 'Access', - type: 'Access type', - inheritance: 'Inheritance type', -}; - -const defaultInheritanceType = ['Object', 'Container']; -const defaultAccessType = 'Allow'; - -const defaultInheritanceTypeSet = new Set(defaultInheritanceType); - -function normalizeAcl(acl: TACE[]) { - return acl.map((ace) => { - const {AccessRules = [], AccessRights = [], AccessType, InheritanceType, Subject} = ace; - const access = AccessRules.concat(AccessRights); - //"Allow" is default access type. We want to show it only if it isn't default - const type = AccessType === defaultAccessType ? undefined : AccessType; - let inheritance; - // ['Object', 'Container'] - is default inheritance type. We want to show it only if it isn't default - if ( - InheritanceType?.length !== defaultInheritanceTypeSet.size || - InheritanceType.some((t) => !defaultInheritanceTypeSet.has(t)) - ) { - inheritance = InheritanceType; - } - return { - access: access.length ? access : undefined, - type, - inheritance, - Subject, - }; - }); -} +import ArrowRightFromSquareIcon from '@gravity-ui/icons/svgs/arrow-right-from-square.svg'; -interface DefinitionValueProps { - value: string | string[]; -} +export const Acl = () => { + const dispatch = useTypedDispatch(); -function DefinitionValue({value}: DefinitionValueProps) { - const normalizedValue = typeof value === 'string' ? [value] : value; return ( -
- {normalizedValue.map((el) => ( - {el} - ))} -
- ); -} - -function getAclListItems(acl?: TACE[]): DefinitionListItem[] { - if (!acl || !acl.length) { - return []; - } - - const normalizedAcl = normalizeAcl(acl); - - return normalizedAcl.map(({Subject, ...data}) => { - const definedDataEntries = Object.entries(data).filter(([_key, value]) => - Boolean(value), - ) as [AclParameter, string | string[]][]; - - if (definedDataEntries.length === 1 && definedDataEntries[0][0] === 'access') { - return { - name: Subject, - content: , - multilineName: true, - }; - } - return { - label: {Subject}, - items: aclParams - .map((key) => { - const value = data[key]; - if (value) { - return { - name: aclParamToName[key], - content: , - multilineName: true, - }; - } - return undefined; - }) - .filter(valueIsDefined), - }; - }); -} - -function getOwnerItem(owner?: string): DefinitionListItem[] { - const preparedOwner = prepareLogin(owner); - if (!preparedOwner) { - return []; - } - return [ - { - name: preparedOwner, - content: i18n('title_owner'), - multilineName: true, - }, - ]; -} - -function getInterruptInheritanceItem(flag?: boolean): DefinitionListItem[] { - if (!flag) { - return []; - } - return [ - { - name: i18n('title_interupt-inheritance'), - content: , - multilineName: true, - }, - ]; -} - -export const Acl = ({path, database}: {path: string; database: string}) => { - const {currentData, isFetching, error} = schemaAclApi.useGetSchemaAclQuery({path, database}); - - const loading = isFetching && !currentData; - - const {acl, effectiveAcl, owner, interruptInheritance} = currentData || {}; - - const aclListItems = getAclListItems(acl); - const effectiveAclListItems = getAclListItems(effectiveAcl); - - const ownerItem = getOwnerItem(owner); - - const interruptInheritanceItem = getInterruptInheritanceItem(interruptInheritance); - - if (loading) { - return ; - } - - if (error) { - return ; - } - - if (!acl && !owner && !effectiveAcl) { - return {i18n('description_empty')}; - } - - const accessRightsItems = ownerItem.concat(aclListItems); - - return ( -
- - - -
+ + {i18n('description_section-moved')} + + ); }; - -interface AclDefinitionListProps { - items: DefinitionListItem[]; - title?: string; -} - -function AclDefinitionList({items, title}: AclDefinitionListProps) { - if (!items.length) { - return null; - } - return ( - - {title &&
{title}
} - -
- ); -} diff --git a/src/containers/Tenant/Acl/i18n/en.json b/src/containers/Tenant/Acl/i18n/en.json index f9c3ff9029..32948988d0 100644 --- a/src/containers/Tenant/Acl/i18n/en.json +++ b/src/containers/Tenant/Acl/i18n/en.json @@ -1,7 +1,4 @@ { - "title_rights": "Access Rights", - "title_effective-rights": "Effective Access Rights", - "title_owner": "Owner", - "title_interupt-inheritance": "Interrupt inheritance", - "description_empty": "No Acl data" + "description_section-moved": "Section was moved to Diagnostics", + "action-open-in-diagnostics": "Open in Diagnostics" } diff --git a/src/containers/Tenant/Diagnostics/AccessRights/AccessRights.scss b/src/containers/Tenant/Diagnostics/AccessRights/AccessRights.scss new file mode 100644 index 0000000000..f8c86111ae --- /dev/null +++ b/src/containers/Tenant/Diagnostics/AccessRights/AccessRights.scss @@ -0,0 +1,80 @@ +.ydb-access-rights { + $block: &; + &__header { + position: sticky; + left: 0; + + margin-bottom: var(--g-spacing-3); + } + &__owner-card { + width: max-content; + padding: var(--g-spacing-2) var(--g-spacing-3); + } + + &__icon-wrapper { + position: absolute; + right: -2px; + bottom: -2px; + + height: 16px; + + color: var(--g-color-base-warning-heavy); + border-radius: 50%; + background: var(--g-color-base-background); + aspect-ratio: 1; + } + &__owner-divider { + height: 24px; + } + &__owner-description { + max-width: 391px; + } + &__dialog-content-wrapper { + position: relative; + + height: 46px; + } + &__dialog-error { + position: absolute; + bottom: 0; + left: 0; + + overflow: hidden; + + max-width: 100%; + + white-space: nowrap; + text-overflow: ellipsis; + } + + &__note { + display: flex; + .g-help-mark__button { + display: flex; + } + } + &__rights-wrapper { + position: relative; + + width: 100%; + height: 100%; + } + &__rights-actions { + position: absolute; + right: 0; + + visibility: hidden; + + height: 100%; + padding-left: var(--g-spacing-2); + + background-color: var(--ydb-data-table-color-hover); + } + &__rights-table { + .data-table__row:hover { + #{$block}__rights-actions { + visibility: visible; + } + } + } +} diff --git a/src/containers/Tenant/Diagnostics/AccessRights/AccessRights.tsx b/src/containers/Tenant/Diagnostics/AccessRights/AccessRights.tsx new file mode 100644 index 0000000000..37c5b9b64e --- /dev/null +++ b/src/containers/Tenant/Diagnostics/AccessRights/AccessRights.tsx @@ -0,0 +1,68 @@ +import React from 'react'; + +import {PersonPlus} from '@gravity-ui/icons'; +import {Button, Flex, Icon} from '@gravity-ui/uikit'; + +import {ResponseError} from '../../../../components/Errors/ResponseError'; +import {LoaderWrapper} from '../../../../components/LoaderWrapper/LoaderWrapper'; +import {useEditAccessAvailable} from '../../../../store/reducers/capabilities/hooks'; +import {schemaAclApi} from '../../../../store/reducers/schemaAcl/schemaAcl'; +import {useAutoRefreshInterval} from '../../../../utils/hooks'; +import {useCurrentSchema} from '../../TenantContext'; +import {useTenantQueryParams} from '../../useTenantQueryParams'; + +import {Owner} from './components/Owner'; +import {RightsTable} from './components/RightsTable/RightsTable'; +import i18n from './i18n'; +import {block} from './shared'; + +import './AccessRights.scss'; + +export function AccessRights() { + const {path, database} = useCurrentSchema(); + const editable = useEditAccessAvailable(); + const [autoRefreshInterval] = useAutoRefreshInterval(); + const {currentData, isFetching, error} = schemaAclApi.useGetSchemaAclQuery( + {path, database}, + { + pollingInterval: autoRefreshInterval, + }, + ); + + const {handleShowGrantAccessChange} = useTenantQueryParams(); + + const loading = isFetching && !currentData; + + const renderContent = () => { + if (error) { + return ; + } + + return ( + + + + {editable && ( + + )} + + + + ); + }; + + return {renderContent()}; +} diff --git a/src/containers/Tenant/Diagnostics/AccessRights/components/ChangeOwnerDialog.tsx b/src/containers/Tenant/Diagnostics/AccessRights/components/ChangeOwnerDialog.tsx new file mode 100644 index 0000000000..b53ad38f6b --- /dev/null +++ b/src/containers/Tenant/Diagnostics/AccessRights/components/ChangeOwnerDialog.tsx @@ -0,0 +1,125 @@ +import React from 'react'; + +import NiceModal from '@ebay/nice-modal-react'; +import {Dialog, Text, TextInput} from '@gravity-ui/uikit'; + +import {schemaAclApi} from '../../../../../store/reducers/schemaAcl/schemaAcl'; +import createToast from '../../../../../utils/createToast'; +import {prepareErrorMessage} from '../../../../../utils/prepareErrorMessage'; +import i18n from '../i18n'; +import {block} from '../shared'; + +const CHANGE_OWNER_DIALOG = 'change-owner-dialog'; + +interface GetChangeOwnerDialogProps { + path: string; + database: string; +} + +export async function getChangeOwnerDialog({ + path, + database, +}: GetChangeOwnerDialogProps): Promise { + return await NiceModal.show(CHANGE_OWNER_DIALOG, { + id: CHANGE_OWNER_DIALOG, + path, + database, + }); +} + +const ChangeOwnerDialogNiceModal = NiceModal.create( + ({path, database}: GetChangeOwnerDialogProps) => { + const modal = NiceModal.useModal(); + + const handleClose = () => { + modal.hide(); + modal.remove(); + }; + + return ( + { + modal.resolve(false); + handleClose(); + }} + open={modal.visible} + path={path} + database={database} + /> + ); + }, +); + +NiceModal.register(CHANGE_OWNER_DIALOG, ChangeOwnerDialogNiceModal); + +interface ChangeOwnerDialogProps extends GetChangeOwnerDialogProps { + open: boolean; + onClose: () => void; +} + +function ChangeOwnerDialog({open, onClose, path, database}: ChangeOwnerDialogProps) { + const [newOwner, setNewOwner] = React.useState(''); + const [requestErrorMessage, setRequestErrorMessage] = React.useState(''); + const [updateOwner, updateOwnerResponse] = schemaAclApi.useUpdateAccessMutation(); + + const handleTyping = (value: string) => { + setNewOwner(value); + setRequestErrorMessage(''); + }; + return ( + + +
{ + e.preventDefault(); + updateOwner({path, database, rights: {ChangeOwnership: {Subject: newOwner}}}) + .unwrap() + .then(() => { + onClose(); + createToast({ + name: 'updateOwner', + content: i18n('title_owner-changed'), + autoHiding: 3000, + }); + }) + .catch((error) => { + setRequestErrorMessage(prepareErrorMessage(error)); + }); + }} + > + +
+ + {requestErrorMessage && ( + + {requestErrorMessage} + + )} +
+
+ + +
+ ); +} diff --git a/src/containers/Tenant/Diagnostics/AccessRights/components/Owner.tsx b/src/containers/Tenant/Diagnostics/AccessRights/components/Owner.tsx new file mode 100644 index 0000000000..3c25bb31d4 --- /dev/null +++ b/src/containers/Tenant/Diagnostics/AccessRights/components/Owner.tsx @@ -0,0 +1,59 @@ +import {CrownDiamond, Pencil} from '@gravity-ui/icons'; +import {ActionTooltip, Button, Card, Divider, Flex, Icon, Text} from '@gravity-ui/uikit'; + +import {SubjectWithAvatar} from '../../../../../components/SubjectWithAvatar/SubjectWithAvatar'; +import {useEditAccessAvailable} from '../../../../../store/reducers/capabilities/hooks'; +import {selectSchemaOwner} from '../../../../../store/reducers/schemaAcl/schemaAcl'; +import {useTypedSelector} from '../../../../../utils/hooks'; +import {useCurrentSchema} from '../../../TenantContext'; +import i18n from '../i18n'; +import {block} from '../shared'; + +import {getChangeOwnerDialog} from './ChangeOwnerDialog'; + +export function Owner() { + const editable = useEditAccessAvailable(); + const {path, database} = useCurrentSchema(); + const owner = useTypedSelector((state) => selectSchemaOwner(state, path, database)); + + if (!owner) { + return null; + } + + const renderIcon = () => { + return ( + + + + ); + }; + return ( + + + + + {editable && ( + + + + + + + )} + + + + {i18n('description_owner')} + + + ); +} diff --git a/src/containers/Tenant/Diagnostics/AccessRights/components/RightsTable/Actions.tsx b/src/containers/Tenant/Diagnostics/AccessRights/components/RightsTable/Actions.tsx new file mode 100644 index 0000000000..fc9cfb1b57 --- /dev/null +++ b/src/containers/Tenant/Diagnostics/AccessRights/components/RightsTable/Actions.tsx @@ -0,0 +1,78 @@ +import {CircleXmark, Pencil} from '@gravity-ui/icons'; +import {ActionTooltip, Button, Flex, Icon} from '@gravity-ui/uikit'; + +import {useEditAccessAvailable} from '../../../../../../store/reducers/capabilities/hooks'; +import {selectSubjectExplicitRights} from '../../../../../../store/reducers/schemaAcl/schemaAcl'; +import {useTypedSelector} from '../../../../../../utils/hooks'; +import {useCurrentSchema} from '../../../../TenantContext'; +import {useTenantQueryParams} from '../../../../useTenantQueryParams'; +import i18n from '../../i18n'; +import {block} from '../../shared'; + +import {getRevokeAllRightsDialog} from './RevokeAllRightsDialog'; +export function Actions() { + return null; +} + +interface ActionProps { + subject: string; + className?: string; +} + +export function SubjectActions({subject, className}: ActionProps) { + const editable = useEditAccessAvailable(); + if (!editable) { + return null; + } + + return ( + + + + + ); +} + +function GrantRightsToSubject({subject}: ActionProps) { + const {handleShowGrantAccessChange, handleAclSubjectChange} = useTenantQueryParams(); + const handleClick = () => { + handleShowGrantAccessChange(true); + handleAclSubjectChange(subject); + }; + return ( + + + + ); +} + +function RevokeAllRights({subject}: ActionProps) { + const {path, database} = useCurrentSchema(); + const subjectExplicitRights = useTypedSelector((state) => + selectSubjectExplicitRights(state, subject, path, database), + ); + const noRightsToRevoke = subjectExplicitRights.length === 0; + + const handleClick = async () => { + await getRevokeAllRightsDialog({path, database, subject}); + }; + + return ( + + {/* this wrapper is needed to show tooltip if button is disabled */} +
+ +
+
+ ); +} diff --git a/src/containers/Tenant/Diagnostics/AccessRights/components/RightsTable/RevokeAllRightsDialog.tsx b/src/containers/Tenant/Diagnostics/AccessRights/components/RightsTable/RevokeAllRightsDialog.tsx new file mode 100644 index 0000000000..bde65e5c50 --- /dev/null +++ b/src/containers/Tenant/Diagnostics/AccessRights/components/RightsTable/RevokeAllRightsDialog.tsx @@ -0,0 +1,142 @@ +import React from 'react'; + +import NiceModal from '@ebay/nice-modal-react'; +import {Dialog, Flex, Text} from '@gravity-ui/uikit'; + +import {SubjectWithAvatar} from '../../../../../../components/SubjectWithAvatar/SubjectWithAvatar'; +import { + schemaAclApi, + selectSubjectExplicitRights, +} from '../../../../../../store/reducers/schemaAcl/schemaAcl'; +import createToast from '../../../../../../utils/createToast'; +import {useTypedSelector} from '../../../../../../utils/hooks'; +import {prepareErrorMessage} from '../../../../../../utils/prepareErrorMessage'; +import i18n from '../../i18n'; + +const REVOKE_ALL_RIGHTS_DIALOG = 'revoke-all-rights-dialog'; + +interface GetRevokeAllRightsDialogProps { + path: string; + database: string; + subject: string; +} + +export async function getRevokeAllRightsDialog({ + path, + database, + subject, +}: GetRevokeAllRightsDialogProps): Promise { + return await NiceModal.show(REVOKE_ALL_RIGHTS_DIALOG, { + id: REVOKE_ALL_RIGHTS_DIALOG, + path, + database, + subject, + }); +} + +const RevokeAllRightsDialogNiceModal = NiceModal.create( + ({path, database, subject}: GetRevokeAllRightsDialogProps) => { + const modal = NiceModal.useModal(); + + const handleClose = () => { + modal.hide(); + modal.remove(); + }; + + return ( + { + modal.resolve(false); + handleClose(); + }} + open={modal.visible} + path={path} + database={database} + subject={subject} + /> + ); + }, +); + +NiceModal.register(REVOKE_ALL_RIGHTS_DIALOG, RevokeAllRightsDialogNiceModal); + +interface RevokeAllRightsDialogProps extends GetRevokeAllRightsDialogProps { + open: boolean; + onClose: () => void; +} + +function RevokeAllRightsDialog({ + open, + onClose, + path, + database, + subject, +}: RevokeAllRightsDialogProps) { + const subjectExplicitRights = useTypedSelector((state) => + selectSubjectExplicitRights(state, subject, path, database), + ); + + const [requestErrorMessage, setRequestErrorMessage] = React.useState(''); + const [removeAccess, removeAccessResponse] = schemaAclApi.useUpdateAccessMutation(); + + return ( + + +
{ + e.preventDefault(); + removeAccess({ + path, + database, + rights: { + RemoveAccess: [ + { + Subject: subject, + AccessRights: Array.from(subjectExplicitRights), + AccessType: 'Allow', + }, + ], + }, + }) + .unwrap() + .then(() => { + onClose(); + createToast({ + name: 'revokeAllRights', + content: i18n('description_rights-revoked'), + autoHiding: 3000, + }); + }) + .catch((error) => { + setRequestErrorMessage(prepareErrorMessage(error)); + }); + }} + > + + + {i18n('description_revoke-all-rights')} + + + + + {requestErrorMessage && ( + + {requestErrorMessage} + + )} + +
+
+ ); +} + +export default RevokeAllRightsDialog; diff --git a/src/containers/Tenant/Diagnostics/AccessRights/components/RightsTable/RightsTable.tsx b/src/containers/Tenant/Diagnostics/AccessRights/components/RightsTable/RightsTable.tsx new file mode 100644 index 0000000000..71a7c89828 --- /dev/null +++ b/src/containers/Tenant/Diagnostics/AccessRights/components/RightsTable/RightsTable.tsx @@ -0,0 +1,30 @@ +import type {Settings} from '@gravity-ui/react-data-table'; + +import {ResizeableDataTable} from '../../../../../../components/ResizeableDataTable/ResizeableDataTable'; +import {selectPreparedRights} from '../../../../../../store/reducers/schemaAcl/schemaAcl'; +import {DEFAULT_TABLE_SETTINGS} from '../../../../../../utils/constants'; +import {useTypedSelector} from '../../../../../../utils/hooks'; +import {useCurrentSchema} from '../../../../TenantContext'; +import i18n from '../../i18n'; +import {block} from '../../shared'; + +import {columns} from './columns'; + +const RIGHT_TABLE_COLUMNS_WIDTH_LS_KEY = 'right-table-columns-width'; + +const AccessRightsTableSettings: Settings = {...DEFAULT_TABLE_SETTINGS, dynamicRender: false}; + +export function RightsTable() { + const {path, database} = useCurrentSchema(); + const data = useTypedSelector((state) => selectPreparedRights(state, path, database)); + return ( + + ); +} diff --git a/src/containers/Tenant/Diagnostics/AccessRights/components/RightsTable/columns.tsx b/src/containers/Tenant/Diagnostics/AccessRights/components/RightsTable/columns.tsx new file mode 100644 index 0000000000..aa92c5fe76 --- /dev/null +++ b/src/containers/Tenant/Diagnostics/AccessRights/components/RightsTable/columns.tsx @@ -0,0 +1,91 @@ +import type {Column} from '@gravity-ui/react-data-table'; +import {Flex, HelpMark, Label} from '@gravity-ui/uikit'; + +import {SubjectWithAvatar} from '../../../../../../components/SubjectWithAvatar/SubjectWithAvatar'; +import type {PreparedAccessRights} from '../../../../../../types/api/acl'; +import i18n from '../../i18n'; +import {block} from '../../shared'; + +import {SubjectActions} from './Actions'; + +export const columns: Column[] = [ + { + name: 'subject', + width: 210, + get header() { + return i18n('label_subject'); + }, + render: ({row: {subject}}) => { + return ; + }, + }, + { + name: 'explicit', + width: 400, + get header() { + return ( + + ); + }, + render: ({row: {subject, explicit}}) => { + return ( + + + {explicit.map((right) => ( + + ))} + + + + ); + }, + sortable: false, + }, + { + name: 'effective', + width: 400, + get header() { + return ( + + ); + }, + render: ({row: {effective}}) => { + return ( + + {effective.map((right) => ( + + ))} + + ); + }, + sortable: false, + }, +]; + +interface HeaderWithHelpMarkProps { + header: string; + note: string; +} + +function HeaderWithHelpMark({header, note}: HeaderWithHelpMarkProps) { + return ( + + {header} + {note} + + ); +} diff --git a/src/containers/Tenant/Diagnostics/AccessRights/i18n/en.json b/src/containers/Tenant/Diagnostics/AccessRights/i18n/en.json new file mode 100644 index 0000000000..1ce67b51ac --- /dev/null +++ b/src/containers/Tenant/Diagnostics/AccessRights/i18n/en.json @@ -0,0 +1,22 @@ +{ + "title_owner": "owner", + "action_change-owner": "Change owner", + "description_owner": "The owner has complete management authority over the object and cannot acquire or lose any rights associated with it.", + "action_grant-access": "Grant Access", + "label_explicit-rights": "Explicit rights", + "label_effective-rights": "Effective rights", + "label_subject": "Subject", + "descrtiption_empty-rights": "No access rights", + "decription_enter-subject": "Enter subject", + "action_cancel": "Cancel", + "action_apply": "Apply", + "title_owner-changed": "Owner has been successfully changed", + "description_explicit-rights": "Direct granted permissions", + "description_effective-rights": "Total active permissions from inheritance and direct grants", + "action_revoke": "Revoke", + "label_revoke-all-rights": "Revoke all explicit rights", + "descripition_no-rights-to-revoke": "No rights to revoke", + "description_grant-explicit-rights": "Grant explicit rights", + "description_revoke-all-rights": "Are you sure you want to revoke all explicit rights for:", + "description_rights-revoked": "Explicit rights has been successfully revoked" +} diff --git a/src/containers/Tenant/Diagnostics/AccessRights/i18n/index.ts b/src/containers/Tenant/Diagnostics/AccessRights/i18n/index.ts new file mode 100644 index 0000000000..0600088fea --- /dev/null +++ b/src/containers/Tenant/Diagnostics/AccessRights/i18n/index.ts @@ -0,0 +1,7 @@ +import {registerKeysets} from '../../../../../utils/i18n'; + +import en from './en.json'; + +const COMPONENT = 'ydb-access-rights'; + +export default registerKeysets(COMPONENT, {en}); diff --git a/src/containers/Tenant/Diagnostics/AccessRights/shared.ts b/src/containers/Tenant/Diagnostics/AccessRights/shared.ts new file mode 100644 index 0000000000..29e258546b --- /dev/null +++ b/src/containers/Tenant/Diagnostics/AccessRights/shared.ts @@ -0,0 +1,3 @@ +import {cn} from '../../../../utils/cn'; + +export const block = cn('ydb-access-rights'); diff --git a/src/containers/Tenant/Diagnostics/Diagnostics.tsx b/src/containers/Tenant/Diagnostics/Diagnostics.tsx index d8e76a5a24..f6cfb75921 100644 --- a/src/containers/Tenant/Diagnostics/Diagnostics.tsx +++ b/src/containers/Tenant/Diagnostics/Diagnostics.tsx @@ -13,7 +13,6 @@ import { import {TENANT_DIAGNOSTICS_TABS_IDS} from '../../../store/reducers/tenant/constants'; import {setDiagnosticsTab} from '../../../store/reducers/tenant/tenant'; import type {AdditionalNodesProps, AdditionalTenantsProps} from '../../../types/additionalProps'; -import type {EPathSubType, EPathType} from '../../../types/api/schema'; import {cn} from '../../../utils/cn'; import {useTypedDispatch, useTypedSelector} from '../../../utils/hooks'; import {Heatmap} from '../../Heatmap'; @@ -22,8 +21,10 @@ import {Operations} from '../../Operations'; import {PaginatedStorage} from '../../Storage/PaginatedStorage'; import {Tablets} from '../../Tablets/Tablets'; import {SchemaViewer} from '../Schema/SchemaViewer/SchemaViewer'; +import {useCurrentSchema} from '../TenantContext'; import {isDatabaseEntityType} from '../utils/schema'; +import {AccessRights} from './AccessRights/AccessRights'; import {Configs} from './Configs/Configs'; import {Consumers} from './Consumers'; import Describe from './Describe/Describe'; @@ -39,10 +40,6 @@ import {TopicData} from './TopicData/TopicData'; import './Diagnostics.scss'; interface DiagnosticsProps { - type?: EPathType; - subType?: EPathSubType; - tenantName: string; - path: string; additionalTenantProps?: AdditionalTenantsProps; additionalNodesProps?: AdditionalNodesProps; } @@ -50,6 +47,7 @@ interface DiagnosticsProps { const b = cn('kv-tenant-diagnostics'); function Diagnostics(props: DiagnosticsProps) { + const {path, database, type, subType} = useCurrentSchema(); const containerRef = React.useRef(null); const dispatch = useTypedDispatch(); @@ -59,14 +57,14 @@ function Diagnostics(props: DiagnosticsProps) { const getDiagnosticsPageLink = useDiagnosticsPageLinkGetter(); - const tenantName = isDatabaseEntityType(props.type) ? props.path : props.tenantName; + const tenantName = isDatabaseEntityType(type) ? path : database; const hasFeatureFlags = useFeatureFlagsAvailable(); const hasTopicData = useTopicDataAvailable(); - const pages = getPagesByType(props.type, props.subType, { + const pages = getPagesByType(type, subType, { hasFeatureFlags, hasTopicData, - isTopLevel: props.path === props.tenantName, + isTopLevel: path === database, }); let activeTab = pages.find((el) => el.id === diagnosticsTab); if (!activeTab) { @@ -80,8 +78,6 @@ function Diagnostics(props: DiagnosticsProps) { }, [activeTab, diagnosticsTab, dispatch]); const renderTabContent = () => { - const {type, path} = props; - switch (activeTab?.id) { case TENANT_DIAGNOSTICS_TABS_IDS.overview: { return ( @@ -113,6 +109,9 @@ function Diagnostics(props: DiagnosticsProps) { /> ); } + case TENANT_DIAGNOSTICS_TABS_IDS.access: { + return ; + } case TENANT_DIAGNOSTICS_TABS_IDS.tablets: { return ( diff --git a/src/containers/Tenant/Diagnostics/DiagnosticsPages.ts b/src/containers/Tenant/Diagnostics/DiagnosticsPages.ts index 64ea644caa..79b4f23ba4 100644 --- a/src/containers/Tenant/Diagnostics/DiagnosticsPages.ts +++ b/src/containers/Tenant/Diagnostics/DiagnosticsPages.ts @@ -33,6 +33,10 @@ const topShards = { id: TENANT_DIAGNOSTICS_TABS_IDS.topShards, title: 'Top shards', }; +const access = { + id: TENANT_DIAGNOSTICS_TABS_IDS.access, + title: 'Access', +}; const nodes = { id: TENANT_DIAGNOSTICS_TABS_IDS.nodes, @@ -91,9 +95,9 @@ const operations = { title: 'Operations', }; -const ASYNC_REPLICATION_PAGES = [overview, tablets, describe]; +const ASYNC_REPLICATION_PAGES = [overview, tablets, describe, access]; -const TRANSFER_PAGES = [overview, tablets, describe]; +const TRANSFER_PAGES = [overview, tablets, describe, access]; const DATABASE_PAGES = [ overview, @@ -105,22 +109,23 @@ const DATABASE_PAGES = [ network, describe, configs, + access, operations, ]; -const TABLE_PAGES = [overview, schema, topShards, nodes, graph, tablets, hotKeys, describe]; -const COLUMN_TABLE_PAGES = [overview, schema, topShards, nodes, tablets, describe]; +const TABLE_PAGES = [overview, schema, topShards, nodes, graph, tablets, hotKeys, describe, access]; +const COLUMN_TABLE_PAGES = [overview, schema, topShards, nodes, tablets, describe, access]; -const DIR_PAGES = [overview, topShards, nodes, describe]; +const DIR_PAGES = [overview, topShards, nodes, describe, access]; -const CDC_STREAM_PAGES = [overview, consumers, partitions, nodes, describe]; -const CDC_STREAM_IMPL_PAGES = [overview, nodes, tablets, describe]; -const TOPIC_PAGES = [overview, consumers, partitions, topicData, nodes, tablets, describe]; +const CDC_STREAM_PAGES = [overview, consumers, partitions, nodes, describe, access]; +const CDC_STREAM_IMPL_PAGES = [overview, nodes, tablets, describe, access]; +const TOPIC_PAGES = [overview, consumers, partitions, topicData, nodes, tablets, describe, access]; -const EXTERNAL_DATA_SOURCE_PAGES = [overview, describe]; -const EXTERNAL_TABLE_PAGES = [overview, schema, describe]; +const EXTERNAL_DATA_SOURCE_PAGES = [overview, describe, access]; +const EXTERNAL_TABLE_PAGES = [overview, schema, describe, access]; -const VIEW_PAGES = [overview, schema, describe]; +const VIEW_PAGES = [overview, schema, describe, access]; // verbose mapping to guarantee correct tabs for new path types // TS will error when a new type is added but not mapped here diff --git a/src/containers/Tenant/GrantAccess/GrantAccess.scss b/src/containers/Tenant/GrantAccess/GrantAccess.scss new file mode 100644 index 0000000000..f5982e76f7 --- /dev/null +++ b/src/containers/Tenant/GrantAccess/GrantAccess.scss @@ -0,0 +1,44 @@ +.ydb-grant-access { + padding: 0 var(--g-spacing-4); + &__single-right { + padding: var(--g-spacing-4); + } + &__navigation { + position: sticky; + z-index: calc(var(--gn-drawer-item-z-index) + 1); + top: 54px; + + width: 100%; + padding: var(--g-spacing-4) 0; + + background-color: var(--g-color-base-background); + } + &__footer { + position: sticky; + z-index: calc(var(--gn-drawer-item-z-index) + 1); + bottom: 0; + + padding: var(--g-spacing-4) 0; + + background-color: var(--g-color-base-background); + } + &__footer-button { + min-width: 128px; + } + &__subject-input { + width: 308px; + max-width: 308px; + } + &__subject-input-wrapper { + overflow: hidden; + } + &__input-content { + width: max-content; + padding: 0 var(--g-spacing-half); + } + &__subject-input-label { + width: 150px; + + line-height: 28px; + } +} diff --git a/src/containers/Tenant/GrantAccess/GrantAccess.tsx b/src/containers/Tenant/GrantAccess/GrantAccess.tsx new file mode 100644 index 0000000000..1172f906e3 --- /dev/null +++ b/src/containers/Tenant/GrantAccess/GrantAccess.tsx @@ -0,0 +1,172 @@ +import React from 'react'; + +import {Flex} from '@gravity-ui/uikit'; + +import {LoaderWrapper} from '../../../components/LoaderWrapper/LoaderWrapper'; +import {SubjectWithAvatar} from '../../../components/SubjectWithAvatar/SubjectWithAvatar'; +import { + schemaAclApi, + selectAvailablePermissions, + selectSubjectInheritedRights, +} from '../../../store/reducers/schemaAcl/schemaAcl'; +import createToast from '../../../utils/createToast'; +import {useTypedSelector} from '../../../utils/hooks'; +import {prepareErrorMessage} from '../../../utils/prepareErrorMessage'; +import {useCurrentSchema} from '../TenantContext'; +import {useTenantQueryParams} from '../useTenantQueryParams'; + +import {Footer} from './components/Footer'; +import {Rights} from './components/Rights'; +import {RightsSectionSelector} from './components/RightsSectionSelector'; +import {SubjectInput} from './components/SubjectInput'; +import i18n from './i18n'; +import type {RightsView} from './shared'; +import {block} from './shared'; +import {useRights} from './utils'; + +import './GrantAccess.scss'; + +interface GrantAccessProps { + handleCloseDrawer: () => void; +} + +export function GrantAccess({handleCloseDrawer}: GrantAccessProps) { + const {aclSubject} = useTenantQueryParams(); + const [newSubjects, setNewSubjects] = React.useState([]); + const [rightView, setRightsView] = React.useState('Groups'); + + const {path, database} = useCurrentSchema(); + const {currentRightsMap, setExplicitRightsChanges, rightsToGrant, rightsToRevoke, hasChanges} = + useRights({aclSubject: aclSubject ?? undefined, path, database}); + const {isFetching: aclIsFetching} = schemaAclApi.useGetSchemaAclQuery( + { + path, + database, + }, + {skip: !aclSubject}, + ); + const {isFetching: availableRightsAreFetching} = schemaAclApi.useGetAvailablePermissionsQuery({ + database, + }); + const [updateRights, updateRightsResponse] = schemaAclApi.useUpdateAccessMutation(); + + const [updateRightsError, setUpdateRightsError] = React.useState(''); + + const inheritedRightsSet = useTypedSelector((state) => + selectSubjectInheritedRights(state, aclSubject ?? undefined, path, database), + ); + + const handleDiscardRightsChanges = React.useCallback(() => { + setExplicitRightsChanges(new Map()); + }, [setExplicitRightsChanges]); + + const handleSaveRightsChanges = React.useCallback(() => { + const subjects = aclSubject ? [aclSubject] : newSubjects; + if (!subjects.length) { + return; + } + updateRights({ + path, + database, + rights: { + AddAccess: subjects.map((subj) => ({ + AccessRights: rightsToGrant, + Subject: subj, + AccessType: 'Allow', + })), + RemoveAccess: subjects.map((subj) => ({ + AccessRights: rightsToRevoke, + Subject: subj, + AccessType: 'Allow', + })), + }, + }) + .unwrap() + .then(() => { + handleCloseDrawer(); + createToast({ + name: 'updateAcl', + content: i18n('label_rights-updated'), + autoHiding: 1000, + isClosable: false, + }); + }) + .catch((e) => { + setUpdateRightsError(prepareErrorMessage(e)); + }); + }, [ + updateRights, + path, + database, + rightsToGrant, + aclSubject, + rightsToRevoke, + newSubjects, + handleCloseDrawer, + ]); + + const availablePermissions = useTypedSelector((state) => + selectAvailablePermissions(state, database), + ); + const handleChangeRightGetter = React.useCallback( + (right: string) => { + return (value: boolean) => { + setUpdateRightsError(''); + setExplicitRightsChanges((prev) => new Map(prev).set(right, value)); + }; + }, + [setExplicitRightsChanges], + ); + + const renderSubject = () => { + if (aclSubject) { + return ; + } + return ; + }; + + const subjectSelected = Boolean(aclSubject || newSubjects.length > 0); + + return ( + +
+ + + {renderSubject()} + {/* wrapper to prevent radio button stretch */} + {subjectSelected && ( + + )} + + {subjectSelected && ( +
+ +
+ )} +
+ + {subjectSelected && ( +
+ )} +
+
+ ); +} diff --git a/src/containers/Tenant/GrantAccess/components/Footer.tsx b/src/containers/Tenant/GrantAccess/components/Footer.tsx new file mode 100644 index 0000000000..fc1889bcc0 --- /dev/null +++ b/src/containers/Tenant/GrantAccess/components/Footer.tsx @@ -0,0 +1,57 @@ +import {TriangleExclamationFill} from '@gravity-ui/icons'; +import {ActionTooltip, Button, Flex, Icon, Label} from '@gravity-ui/uikit'; + +import i18n from '../i18n'; +import {block} from '../shared'; + +interface FooterProps { + onCancel: () => void; + onSave: () => void; + onDiscard: () => void; + loading?: boolean; + error?: string; + disabled?: boolean; +} + +export function Footer({onCancel, onSave, onDiscard, loading, error, disabled}: FooterProps) { + return ( + + + + + {error && ( + + + + )} + + + + + ); +} diff --git a/src/containers/Tenant/GrantAccess/components/Rights.tsx b/src/containers/Tenant/GrantAccess/components/Rights.tsx new file mode 100644 index 0000000000..73d0bfd285 --- /dev/null +++ b/src/containers/Tenant/GrantAccess/components/Rights.tsx @@ -0,0 +1,153 @@ +import {Card, Flex, Label, Switch, Text} from '@gravity-ui/uikit'; + +import type {AvailablePermissionsConfig} from '../../../../types/api/acl'; +import i18n from '../i18n'; +import type {CommonRightsProps, RightsView} from '../shared'; +import {RightsDescription, block, isLegacyRight} from '../shared'; + +interface RightsProps extends CommonRightsProps { + availablePermissions?: AvailablePermissionsConfig; + view: RightsView; +} + +export function Rights({ + rights, + availablePermissions, + handleChangeRightGetter, + inheritedRights, + view, +}: RightsProps) { + if (view === 'Groups') { + return ( + + ); + } + return ( + + ); +} + +interface GroupRightsProps extends CommonRightsProps { + availablePermissions: AvailablePermissionsConfig['AccessRules']; +} + +function GroupRights({ + rights, + availablePermissions, + handleChangeRightGetter, + inheritedRights, +}: GroupRightsProps) { + if (!availablePermissions?.length) { + return i18n('description_no-group-permissions'); + } + return ( + + {availablePermissions.map(({Name, Mask, AccessRights = [], AccessRules = []}) => { + const includedRights = AccessRights.concat(AccessRules); + const isLegacy = isLegacyRight(Mask); + const isActive = rights.get(Name); + const isInherited = inheritedRights.has(Name); + + if (isLegacy && !isActive && !isInherited) { + return null; + } + + return ( + + ); + })} + + ); +} + +interface GranularRightsProps extends CommonRightsProps { + availablePermissions: AvailablePermissionsConfig['AccessRights']; +} + +function GranularRights({ + rights, + availablePermissions, + handleChangeRightGetter, + inheritedRights, +}: GranularRightsProps) { + if (!availablePermissions?.length) { + return i18n('description_no-granular-permissions'); + } + return ( + + {availablePermissions.map(({Name, Mask}) => ( + + ))} + + ); +} + +interface SingleRightProps { + right: string; + onUpdate: (value: boolean) => void; + active?: boolean; + includedRights?: string[]; + description?: string; + inherited?: boolean; +} + +function SingleRight({ + right, + onUpdate, + active, + includedRights, + description, + inherited, +}: SingleRightProps) { + const onlyInherited = inherited && !active; + return ( + + + + + {right} + {description && {description}} + + {includedRights?.length ? ( + + {includedRights.map((right) => ( + + ))} + + ) : null} + + + {inherited && } + {!onlyInherited && } + + + + ); +} diff --git a/src/containers/Tenant/GrantAccess/components/RightsSectionSelector.tsx b/src/containers/Tenant/GrantAccess/components/RightsSectionSelector.tsx new file mode 100644 index 0000000000..143a62aee0 --- /dev/null +++ b/src/containers/Tenant/GrantAccess/components/RightsSectionSelector.tsx @@ -0,0 +1,57 @@ +import React from 'react'; + +import {RadioButton, Text} from '@gravity-ui/uikit'; + +import type {AvailablePermissionsConfig} from '../../../../types/api/acl'; +import i18n from '../i18n'; +import type {RightsView} from '../shared'; +import {block} from '../shared'; + +const RightsViewTitle: Record = { + Groups: i18n('label_groups'), + Granular: i18n('label_granular'), +}; + +interface RightsSectionSelectorProps { + value: RightsView; + onUpdate: (value: RightsView) => void; + availablePermissions?: AvailablePermissionsConfig; + rights: Map; +} + +export function RightsSectionSelector({ + value, + onUpdate, + availablePermissions, + rights, +}: RightsSectionSelectorProps) { + const selectedRulesCount = availablePermissions?.AccessRules?.filter((rule) => + rights.get(rule.Name), + )?.length; + const selectedRightsCount = availablePermissions?.AccessRights?.filter((right) => + rights.get(right.Name), + )?.length; + return ( +
+ + + {RightsViewTitle['Groups']} + {selectedRulesCount ? ( + +   + {selectedRulesCount} + + ) : null} + + + {RightsViewTitle['Granular']}  + {selectedRightsCount ? ( + +  {selectedRightsCount} + + ) : null} + + +
+ ); +} diff --git a/src/containers/Tenant/GrantAccess/components/SubjectInput.tsx b/src/containers/Tenant/GrantAccess/components/SubjectInput.tsx new file mode 100644 index 0000000000..343e0a474e --- /dev/null +++ b/src/containers/Tenant/GrantAccess/components/SubjectInput.tsx @@ -0,0 +1,108 @@ +import React from 'react'; + +import type {FlexProps} from '@gravity-ui/uikit'; +import {Flex, Label, Text, TextInput} from '@gravity-ui/uikit'; + +import i18n from '../i18n'; +import {block} from '../shared'; + +const PixelsInLetter = 10; + +interface SubjectInputProps { + newSubjects: string[]; + setNewSubjects: React.Dispatch>; +} + +export function SubjectInput({newSubjects, setNewSubjects}: SubjectInputProps) { + const [renderInline, setRenderInline] = React.useState(true); + const [inputValue, setInputValue] = React.useState(''); + + const subjectInputRef = React.useRef(null); + const labelsRef = React.useRef(null); + + const checkWidth = (additionalWidth = 0) => { + if (!labelsRef.current || !subjectInputRef.current) { + return; + } + if ( + (labelsRef.current.offsetWidth + additionalWidth) * 2 >= + subjectInputRef.current.offsetWidth + ) { + setRenderInline(false); + } else { + setRenderInline(true); + } + }; + + const addNewAclSubject = () => { + if (inputValue) { + setNewSubjects((prev) => [...prev, inputValue]); + setInputValue(''); + checkWidth(inputValue.length * PixelsInLetter); + } + }; + + const handleEnterClick = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + addNewAclSubject(); + } + }; + + const handleRemoveSubjectGetter = (subject: string) => { + return () => { + setNewSubjects((prev) => prev.filter((el) => el !== subject)); + checkWidth(-1 * subject.length * PixelsInLetter); + }; + }; + + const renderLabels = (wrap: FlexProps['wrap'] = 'nowrap') => { + return ( + + {newSubjects.map((subject) => ( + + ))} + + ); + }; + return ( + + + + + + {!renderInline && renderLabels('wrap')} + + + + ); +} diff --git a/src/containers/Tenant/GrantAccess/i18n/en.json b/src/containers/Tenant/GrantAccess/i18n/en.json new file mode 100644 index 0000000000..1ffee78849 --- /dev/null +++ b/src/containers/Tenant/GrantAccess/i18n/en.json @@ -0,0 +1,40 @@ +{ + "description_select-row": "Retrieve data rows from tables using SELECT queries.", + "description_update-row": "Modify existing data rows with UPDATE operations.", + "description_erase-row": "Modify existing data rows with UPDATE operations.", + "description_write-attributes": "Modify object metadata and manage access permissions.", + "description_create-directory": "Create new subdirectories in the database namespace.", + "description_create-table": "Define new tables with specified schemas.", + "description_create-queue": "Set up new message queue instances.", + "description_remove-schema": "Delete database objects (tables, directories, queues).", + "description_alter-schema": "Modify object definitions (add/remove columns, change types).", + "description_create-database": "Initialize new database instances.", + "description_drop-database": "Permanently delete databases and all contained objects.", + "description_read-attributes": "View object metadata and access control lists.", + "description_describe-schema": "Inspect object structure and properties.", + "description_connect-database": "Establish connections and submit requests to the database.", + "description_grant-access-rights": "Delegate permissions to other users (within own privilege scope).", + "description_generic-read": "Read data and metadata. Basic viewing permissions for tables/objects.", + "description_generic-write": "Modify data and schemas. Write/delete data + alter structure.", + "description_generic-manage": "Create/drop databases. Infrastructure-level change.", + "description_generic-list": "View object metadata and schemas. For inspecting structure without data access.", + "description_generic-use": "Complete data operations. Modern full access excluding DB management.", + "description_generic-use-legacy": "[Legacy] Complete data operations. Modern full access excluding DB management.", + "description_generic-full": "Unrestricted system access. All permissions including DB management.", + "description_generic-full-legacy": "[Legacy] Unrestricted system access. All permissions including DB management.", + + "label_groups": "Groups", + "label_granular": "Granular", + "label_inherited": "Inherited", + + "description_no-granular-permissions": "No granular permissions available.", + "description_no-group-permissions": "No group permissions available.", + + "action_discard": "Discard Changes", + "action_cancel": "Cancel", + "action_save": "Apply", + + "label_rights-updated": "Access rights has been successfully changed", + "description_failed": "Failed", + "label_grant-access-to": "Grant access to" +} diff --git a/src/containers/Tenant/GrantAccess/i18n/index.ts b/src/containers/Tenant/GrantAccess/i18n/index.ts new file mode 100644 index 0000000000..19b9361313 --- /dev/null +++ b/src/containers/Tenant/GrantAccess/i18n/index.ts @@ -0,0 +1,7 @@ +import {registerKeysets} from '../../../../utils/i18n'; + +import en from './en.json'; + +const COMPONENT = 'ydb-grant-access'; + +export default registerKeysets(COMPONENT, {en}); diff --git a/src/containers/Tenant/GrantAccess/shared.ts b/src/containers/Tenant/GrantAccess/shared.ts new file mode 100644 index 0000000000..3e17326a93 --- /dev/null +++ b/src/containers/Tenant/GrantAccess/shared.ts @@ -0,0 +1,72 @@ +import {cn} from '../../../utils/cn'; + +import i18n from './i18n'; + +export const block = cn('ydb-grant-access'); + +export const HumanReadableRights: Record = { + selectRow: 1, + updateRow: 2, + eraseRow: 4, + writeAttributes: 16, + createDirectory: 32, + createTable: 64, + createQueue: 128, + removeSchema: 256, + alterSchema: 1024, + createDatabase: 2048, + dropDatabase: 4096, + readAttributes: 8, + describeSchema: 512, + connectDatabase: 32768, + grantAccessRights: 8192, + genericRead: 521, + genericWrite: 17910, + genericManage: 6144, + genericList: 520, + genericUse: 59391, + genericUseLegacy: 26623, + genericFull: 65535, + genericFullLegacy: 32767, +}; + +export const RightsDescription: Record = { + [HumanReadableRights.selectRow]: i18n('description_select-row'), + [HumanReadableRights.updateRow]: i18n('description_update-row'), + [HumanReadableRights.eraseRow]: i18n('description_erase-row'), + [HumanReadableRights.writeAttributes]: i18n('description_write-attributes'), + [HumanReadableRights.createDirectory]: i18n('description_create-directory'), + [HumanReadableRights.createTable]: i18n('description_create-table'), + [HumanReadableRights.createQueue]: i18n('description_create-queue'), + [HumanReadableRights.removeSchema]: i18n('description_remove-schema'), + [HumanReadableRights.alterSchema]: i18n('description_alter-schema'), + [HumanReadableRights.createDatabase]: i18n('description_create-database'), + [HumanReadableRights.dropDatabase]: i18n('description_drop-database'), + [HumanReadableRights.readAttributes]: i18n('description_read-attributes'), + [HumanReadableRights.describeSchema]: i18n('description_describe-schema'), + [HumanReadableRights.connectDatabase]: i18n('description_connect-database'), + [HumanReadableRights.grantAccessRights]: i18n('description_grant-access-rights'), + [HumanReadableRights.genericRead]: i18n('description_generic-read'), + [HumanReadableRights.genericWrite]: i18n('description_generic-write'), + [HumanReadableRights.genericManage]: i18n('description_generic-manage'), + [HumanReadableRights.genericList]: i18n('description_generic-list'), + [HumanReadableRights.genericUse]: i18n('description_generic-use'), + [HumanReadableRights.genericUseLegacy]: i18n('description_generic-use-legacy'), + [HumanReadableRights.genericFull]: i18n('description_generic-full'), + [HumanReadableRights.genericFullLegacy]: i18n('description_generic-full-legacy'), +}; + +export function isLegacyRight(right: number) { + return ( + right === HumanReadableRights.genericFullLegacy || + right === HumanReadableRights.genericUseLegacy + ); +} + +export interface CommonRightsProps { + rights: Map; + handleChangeRightGetter: (right: string) => (value: boolean) => void; + inheritedRights: Set; +} + +export type RightsView = 'Granular' | 'Groups'; diff --git a/src/containers/Tenant/GrantAccess/utils.ts b/src/containers/Tenant/GrantAccess/utils.ts new file mode 100644 index 0000000000..9814b0c049 --- /dev/null +++ b/src/containers/Tenant/GrantAccess/utils.ts @@ -0,0 +1,66 @@ +import React from 'react'; + +import {selectSubjectExplicitRights} from '../../../store/reducers/schemaAcl/schemaAcl'; +import {useTypedSelector} from '../../../utils/hooks'; + +interface UseRightsProps { + aclSubject?: string; + path: string; + database: string; +} + +export function useRights({aclSubject, path, database}: UseRightsProps) { + const subjectExplicitRights = useTypedSelector((state) => + selectSubjectExplicitRights(state, aclSubject ?? undefined, path, database), + ); + const [explicitRightsChanges, setExplicitRightsChanges] = React.useState( + () => new Map(), + ); + const rightsToGrant = React.useMemo(() => { + return Array.from(explicitRightsChanges.entries()).reduce( + (acc, [right, status]) => { + if (status && !subjectExplicitRights.includes(right)) { + acc.push(right); + } + return acc; + }, + [], + ); + }, [explicitRightsChanges, subjectExplicitRights]); + + const rightsToRevoke = React.useMemo(() => { + return Array.from(explicitRightsChanges.entries()).reduce( + (acc, [right, status]) => { + if (!status && subjectExplicitRights.includes(right)) { + acc.push(right); + } + return acc; + }, + [], + ); + }, [explicitRightsChanges, subjectExplicitRights]); + + const subjectExplicitRightsMap = React.useMemo(() => { + return new Map(subjectExplicitRights.map((right) => [right, true])); + }, [subjectExplicitRights]); + + const currentRightsMap = React.useMemo(() => { + const rights = new Map(subjectExplicitRightsMap); + explicitRightsChanges.forEach((value, key) => { + rights.set(key, value); + }); + return rights; + }, [subjectExplicitRightsMap, explicitRightsChanges]); + + const hasChanges = React.useMemo(() => { + return Boolean(rightsToGrant.length || rightsToRevoke.length); + }, [rightsToGrant, rightsToRevoke]); + + return { + currentRightsMap, + setExplicitRightsChanges, + rightsToGrant, + rightsToRevoke, + hasChanges, + }; +} diff --git a/src/containers/Tenant/ObjectGeneral/ObjectGeneral.tsx b/src/containers/Tenant/ObjectGeneral/ObjectGeneral.tsx index c12669e3ed..95b3907306 100644 --- a/src/containers/Tenant/ObjectGeneral/ObjectGeneral.tsx +++ b/src/containers/Tenant/ObjectGeneral/ObjectGeneral.tsx @@ -2,7 +2,6 @@ import {useThemeValue} from '@gravity-ui/uikit'; import {TENANT_PAGES_IDS} from '../../../store/reducers/tenant/constants'; import type {AdditionalNodesProps, AdditionalTenantsProps} from '../../../types/additionalProps'; -import type {EPathSubType, EPathType} from '../../../types/api/schema'; import {cn} from '../../../utils/cn'; import {useTypedSelector} from '../../../utils/hooks'; import Diagnostics from '../Diagnostics/Diagnostics'; @@ -14,10 +13,6 @@ import './ObjectGeneral.scss'; const b = cn('object-general'); interface ObjectGeneralProps { - type?: EPathType; - subType?: EPathSubType; - tenantName: string; - path: string; additionalTenantProps?: AdditionalTenantsProps; additionalNodesProps?: AdditionalNodesProps; } @@ -28,27 +23,14 @@ function ObjectGeneral(props: ObjectGeneralProps) { const {tenantPage} = useTypedSelector((state) => state.tenant); const renderPageContent = () => { - const {type, subType, additionalTenantProps, additionalNodesProps, tenantName, path} = - props; + const {additionalTenantProps, additionalNodesProps} = props; switch (tenantPage) { case TENANT_PAGES_IDS.query: { - return ( - - ); + return ; } default: { return ( diff --git a/src/containers/Tenant/ObjectSummary/ObjectSummary.tsx b/src/containers/Tenant/ObjectSummary/ObjectSummary.tsx index 933eb9d452..a638dfd2b6 100644 --- a/src/containers/Tenant/ObjectSummary/ObjectSummary.tsx +++ b/src/containers/Tenant/ObjectSummary/ObjectSummary.tsx @@ -28,6 +28,7 @@ import {useTypedDispatch, useTypedSelector} from '../../../utils/hooks'; import {Acl} from '../Acl/Acl'; import {EntityTitle} from '../EntityTitle/EntityTitle'; import {SchemaViewer} from '../Schema/SchemaViewer/SchemaViewer'; +import {useCurrentSchema} from '../TenantContext'; import {TENANT_INFO_TABS, TENANT_SCHEMA_TAB, TenantTabsGroups, getTenantPath} from '../TenantPages'; import {getSummaryControls} from '../utils/controls'; import { @@ -58,24 +59,17 @@ const getTenantCommonInfoState = () => { }; interface ObjectSummaryProps { - type?: EPathType; - subType?: EPathSubType; - tenantName: string; - path: string; onCollapseSummary: VoidFunction; onExpandSummary: VoidFunction; isCollapsed: boolean; } export function ObjectSummary({ - type, - subType, - tenantName, - path, onCollapseSummary, onExpandSummary, isCollapsed, }: ObjectSummaryProps) { + const {path, database: tenantName, type, subType} = useCurrentSchema(); const dispatch = useTypedDispatch(); const [, setCurrentPath] = useQueryParam('schema', StringParam); const [commonInfoVisibilityState, dispatchCommonInfoVisibilityState] = React.useReducer( @@ -365,7 +359,7 @@ export function ObjectSummary({ const renderTabContent = () => { switch (summaryTab) { case TENANT_SUMMARY_TABS_IDS.acl: { - return ; + return ; } case TENANT_SUMMARY_TABS_IDS.schema: { return ; diff --git a/src/containers/Tenant/Query/Query.tsx b/src/containers/Tenant/Query/Query.tsx index 5f1bacef96..d643fa946a 100644 --- a/src/containers/Tenant/Query/Query.tsx +++ b/src/containers/Tenant/Query/Query.tsx @@ -4,7 +4,6 @@ import {Helmet} from 'react-helmet-async'; import {changeUserInput} from '../../../store/reducers/query/query'; import {TENANT_QUERY_TABS_ID} from '../../../store/reducers/tenant/constants'; -import type {EPathSubType, EPathType} from '../../../types/api/schema'; import {cn} from '../../../utils/cn'; import {useTypedDispatch, useTypedSelector} from '../../../utils/hooks'; @@ -19,10 +18,6 @@ const b = cn('ydb-query'); interface QueryProps { theme: string; - tenantName: string; - path: string; - type?: EPathType; - subType?: EPathSubType; } export const Query = (props: QueryProps) => { diff --git a/src/containers/Tenant/Query/QueryEditor/QueryEditor.tsx b/src/containers/Tenant/Query/QueryEditor/QueryEditor.tsx index a6ba65b071..d4264bc159 100644 --- a/src/containers/Tenant/Query/QueryEditor/QueryEditor.tsx +++ b/src/containers/Tenant/Query/QueryEditor/QueryEditor.tsx @@ -41,6 +41,7 @@ import { import {useChangedQuerySettings} from '../../../../utils/hooks/useChangedQuerySettings'; import {useLastQueryExecutionSettings} from '../../../../utils/hooks/useLastQueryExecutionSettings'; import {DEFAULT_QUERY_SETTINGS, QUERY_ACTIONS, QUERY_MODES} from '../../../../utils/query'; +import {useCurrentSchema} from '../../TenantContext'; import type {InitialPaneState} from '../../utils/paneVisibilityToggleHelpers'; import { PaneVisibilityActionTypes, @@ -65,17 +66,14 @@ const initialTenantCommonInfoState = { }; interface QueryEditorProps { - tenantName: string; - path: string; changeUserInput: (arg: {input: string}) => void; theme: string; - type?: EPathType; - subType?: EPathSubType; } export default function QueryEditor(props: QueryEditorProps) { const dispatch = useTypedDispatch(); - const {tenantName, path, type, theme, changeUserInput, subType} = props; + const {database: tenantName, path, type, subType} = useCurrentSchema(); + const {theme, changeUserInput} = props; const savedPath = useTypedSelector(selectTenantPath); const result = useTypedSelector(selectResult); const historyQueries = useTypedSelector(selectQueriesHistory); diff --git a/src/containers/Tenant/Tenant.tsx b/src/containers/Tenant/Tenant.tsx index 15117085bc..8666398c81 100644 --- a/src/containers/Tenant/Tenant.tsx +++ b/src/containers/Tenant/Tenant.tsx @@ -16,6 +16,7 @@ import {isAccessError} from '../../utils/response'; import ObjectGeneral from './ObjectGeneral/ObjectGeneral'; import {ObjectSummary} from './ObjectSummary/ObjectSummary'; +import {TenantContextProvider} from './TenantContext'; import {TenantDrawerWrapper} from './TenantDrawerWrappers'; import i18n from './i18n'; import {useTenantQueryParams} from './useTenantQueryParams'; @@ -122,36 +123,35 @@ export function Tenant(props: TenantProps) { /> - - - -
- + + + -
-
-
+
+ +
+ + +
diff --git a/src/containers/Tenant/TenantContext.tsx b/src/containers/Tenant/TenantContext.tsx new file mode 100644 index 0000000000..6ddb964814 --- /dev/null +++ b/src/containers/Tenant/TenantContext.tsx @@ -0,0 +1,50 @@ +import React from 'react'; + +import type {EPathSubType, EPathType} from '../../types/api/schema'; + +export interface TenantContextType { + path: string; + database: string; + type?: EPathType; + subType?: EPathSubType; +} + +const SchemaContext = React.createContext(undefined); + +interface TenantContextProviderProps { + children: React.ReactNode; + path: string; + database: string; + type?: EPathType; + subType?: EPathSubType; +} + +export const TenantContextProvider = ({ + children, + path, + database, + type, + subType, +}: TenantContextProviderProps) => { + const value = React.useMemo( + () => ({ + path, + database, + type, + subType, + }), + [path, database, type, subType], + ); + + return {children}; +}; + +export const useCurrentSchema = () => { + const context = React.useContext(SchemaContext); + + if (context === undefined) { + throw Error('useCurrentSchema must be used within a TenantContextProvider'); + } + + return context; +}; diff --git a/src/containers/Tenant/TenantDrawerHealthcheck.tsx b/src/containers/Tenant/TenantDrawerHealthcheck.tsx new file mode 100644 index 0000000000..0e298f8bfb --- /dev/null +++ b/src/containers/Tenant/TenantDrawerHealthcheck.tsx @@ -0,0 +1,107 @@ +import React from 'react'; + +import {ArrowDownToLine} from '@gravity-ui/icons'; +import {ActionTooltip, Button, Flex, Icon, Text} from '@gravity-ui/uikit'; + +import {DrawerWrapper} from '../../components/Drawer'; +import EnableFullscreenButton from '../../components/EnableFullscreenButton/EnableFullscreenButton'; +import { + selectAllHealthcheckInfo, + selectCheckStatus, +} from '../../store/reducers/healthcheckInfo/healthcheckInfo'; +import type {SelfCheckResult} from '../../types/api/healthcheck'; +import {createAndDownloadJsonFile} from '../../utils/downloadFile'; +import {useTypedSelector} from '../../utils/hooks'; + +import {Healthcheck} from './Healthcheck/Healthcheck'; +import {useCurrentSchema} from './TenantContext'; +import {HEALTHCHECK_RESULT_TO_TEXT} from './constants'; +import i18n from './i18n'; +import {useTenantQueryParams} from './useTenantQueryParams'; + +interface TenantDrawerWrapperProps { + children: React.ReactNode; +} + +export function TenantDrawerHealthcheck({children}: TenantDrawerWrapperProps) { + const {database} = useCurrentSchema(); + const { + handleShowHealthcheckChange, + showHealthcheck, + handleIssuesFilterChange, + handleHealthcheckViewChange, + } = useTenantQueryParams(); + + const healthcheckStatus = useTypedSelector((state) => selectCheckStatus(state, database || '')); + + const healthcheckData = useTypedSelector((state) => + selectAllHealthcheckInfo(state, database || ''), + ); + + const handleCloseDrawer = React.useCallback(() => { + handleShowHealthcheckChange(false); + handleIssuesFilterChange(undefined); + handleHealthcheckViewChange(undefined); + }, [handleShowHealthcheckChange, handleIssuesFilterChange, handleHealthcheckViewChange]); + + const renderDrawerContent = React.useCallback(() => { + return ; + }, [database]); + + return ( + + + + ), + }, + { + type: 'custom', + node: , + key: 'fullscreen', + }, + {type: 'close'}, + ]} + title={} + > + {children} + + ); +} + +interface DrawerTitleProps { + status?: SelfCheckResult; +} + +function DrawerTitle({status}: DrawerTitleProps) { + return ( + + {i18n('label_healthcheck-dashboard')} + {status && HEALTHCHECK_RESULT_TO_TEXT[status]} + + ); +} diff --git a/src/containers/Tenant/TenantDrawerRights.tsx b/src/containers/Tenant/TenantDrawerRights.tsx new file mode 100644 index 0000000000..590b2c824a --- /dev/null +++ b/src/containers/Tenant/TenantDrawerRights.tsx @@ -0,0 +1,59 @@ +import React from 'react'; + +import {Flex, Text} from '@gravity-ui/uikit'; + +import {DrawerWrapper} from '../../components/Drawer'; +import {useEditAccessAvailable} from '../../store/reducers/capabilities/hooks'; + +import {GrantAccess} from './GrantAccess/GrantAccess'; +import i18n from './i18n'; +import {useTenantQueryParams} from './useTenantQueryParams'; + +interface TenantDrawerWrapperProps { + children: React.ReactNode; +} + +export function TenantDrawerRights({children}: TenantDrawerWrapperProps) { + const editable = useEditAccessAvailable(); + const {showGrantAccess, handleShowGrantAccessChange, handleAclSubjectChange} = + useTenantQueryParams(); + + const handleCloseDrawer = React.useCallback(() => { + handleShowGrantAccessChange(false); + handleAclSubjectChange(undefined); + }, [handleShowGrantAccessChange, handleAclSubjectChange]); + + const renderDrawerContent = React.useCallback(() => { + return ; + }, [handleCloseDrawer]); + + if (!editable) { + return children; + } + + return ( + } + > + {children} + + ); +} + +function DrawerTitle() { + return ( + + {i18n('label_grant-access')} + {i18n('context_grant-access')} + + ); +} diff --git a/src/containers/Tenant/TenantDrawerWrappers.tsx b/src/containers/Tenant/TenantDrawerWrappers.tsx index ca0c5d831e..88ce2d86b4 100644 --- a/src/containers/Tenant/TenantDrawerWrappers.tsx +++ b/src/containers/Tenant/TenantDrawerWrappers.tsx @@ -1,109 +1,20 @@ import React from 'react'; -import {ArrowDownToLine} from '@gravity-ui/icons'; -import {ActionTooltip, Button, Flex, Icon, Text} from '@gravity-ui/uikit'; - -import {DrawerWrapper} from '../../components/Drawer'; import {DrawerContextProvider} from '../../components/Drawer/DrawerContext'; -import EnableFullscreenButton from '../../components/EnableFullscreenButton/EnableFullscreenButton'; -import { - selectAllHealthcheckInfo, - selectCheckStatus, -} from '../../store/reducers/healthcheckInfo/healthcheckInfo'; -import type {SelfCheckResult} from '../../types/api/healthcheck'; -import {createAndDownloadJsonFile} from '../../utils/downloadFile'; -import {useTypedSelector} from '../../utils/hooks'; -import {Healthcheck} from './Healthcheck/Healthcheck'; -import {HEALTHCHECK_RESULT_TO_TEXT} from './constants'; -import i18n from './i18n'; -import {useTenantQueryParams} from './useTenantQueryParams'; +import {TenantDrawerHealthcheck} from './TenantDrawerHealthcheck'; +import {TenantDrawerRights} from './TenantDrawerRights'; interface TenantDrawerWrapperProps { children: React.ReactNode; - database: string; } -export function TenantDrawerWrapper({children, database}: TenantDrawerWrapperProps) { - const { - handleShowHealthcheckChange, - showHealthcheck, - handleIssuesFilterChange, - handleHealthcheckViewChange, - } = useTenantQueryParams(); - - const healthcheckStatus = useTypedSelector((state) => selectCheckStatus(state, database || '')); - - const healthcheckData = useTypedSelector((state) => - selectAllHealthcheckInfo(state, database || ''), - ); - - const handleCloseDrawer = React.useCallback(() => { - handleShowHealthcheckChange(false); - handleIssuesFilterChange(undefined); - handleHealthcheckViewChange(undefined); - }, [handleShowHealthcheckChange, handleIssuesFilterChange, handleHealthcheckViewChange]); - - const renderDrawerContent = React.useCallback(() => { - return ; - }, [database]); - +export function TenantDrawerWrapper({children}: TenantDrawerWrapperProps) { return ( - - - - ), - }, - { - type: 'custom', - node: , - key: 'fullscreen', - }, - {type: 'close'}, - ]} - title={} - > - {children} - + + {children} + ); } - -interface DrawerTitleProps { - status?: SelfCheckResult; -} - -function DrawerTitle({status}: DrawerTitleProps) { - return ( - - {i18n('label_healthcheck-dashboard')} - {status && HEALTHCHECK_RESULT_TO_TEXT[status]} - - ); -} diff --git a/src/containers/Tenant/i18n/en.json b/src/containers/Tenant/i18n/en.json index 1f588674a9..1e44ac2f75 100644 --- a/src/containers/Tenant/i18n/en.json +++ b/src/containers/Tenant/i18n/en.json @@ -65,5 +65,8 @@ "description_degraded": "Some issues, but still working.", "description_maintenance": "Working, but needs urgent attention to avoid failure.", "description_emergency": "Something is broken and not working.", - "label_download": "Download healthcheck data" + "label_download": "Download healthcheck data", + + "label_grant-access": "Grant access", + "context_grant-access": "Please note that granular rights can be combined into groups" } diff --git a/src/containers/Tenant/useTenantQueryParams.ts b/src/containers/Tenant/useTenantQueryParams.ts index 04f6204248..1f4c677e81 100644 --- a/src/containers/Tenant/useTenantQueryParams.ts +++ b/src/containers/Tenant/useTenantQueryParams.ts @@ -3,20 +3,36 @@ import React from 'react'; import {BooleanParam, StringParam, useQueryParams} from 'use-query-params'; export function useTenantQueryParams() { - const [{showHealthcheck, database, schema, view, issuesFilter}, setQueryParams] = - useQueryParams({ - showHealthcheck: BooleanParam, - database: StringParam, - schema: StringParam, - view: StringParam, - issuesFilter: StringParam, - }); + const [ + {showHealthcheck, database, schema, view, issuesFilter, showGrantAccess, aclSubject}, + setQueryParams, + ] = useQueryParams({ + showHealthcheck: BooleanParam, + database: StringParam, + schema: StringParam, + view: StringParam, + issuesFilter: StringParam, + showGrantAccess: BooleanParam, + aclSubject: StringParam, + }); const handleShowHealthcheckChange = React.useCallback( (value?: boolean) => { setQueryParams({showHealthcheck: value}, 'replaceIn'); }, [setQueryParams], ); + const handleAclSubjectChange = React.useCallback( + (value?: string) => { + setQueryParams({aclSubject: value}, 'replaceIn'); + }, + [setQueryParams], + ); + const handleShowGrantAccessChange = React.useCallback( + (value?: boolean) => { + setQueryParams({showGrantAccess: value}, 'replaceIn'); + }, + [setQueryParams], + ); const handleDatabaseChange = React.useCallback( (value?: string) => { @@ -49,11 +65,15 @@ export function useTenantQueryParams() { handleShowHealthcheckChange, database, handleDatabaseChange, + showGrantAccess, + handleShowGrantAccessChange, schema, handleSchemaChange, view, handleHealthcheckViewChange, issuesFilter, handleIssuesFilterChange, + aclSubject, + handleAclSubjectChange, }; } diff --git a/src/services/api/viewer.ts b/src/services/api/viewer.ts index ca22f8038b..1ec0370c61 100644 --- a/src/services/api/viewer.ts +++ b/src/services/api/viewer.ts @@ -1,5 +1,9 @@ import type {PlanToSvgQueryParams} from '../../store/reducers/planToSvg'; -import type {TMetaInfo} from '../../types/api/acl'; +import type { + AccessRightsUpdateRequest, + AvailablePermissionsResponse, + TMetaInfo, +} from '../../types/api/acl'; import type {TQueryAutocomplete} from '../../types/api/autocomplete'; import type {CapabilitiesResponse} from '../../types/api/capabilities'; import type {TClusterInfo} from '../../types/api/cluster'; @@ -193,6 +197,43 @@ export class ViewerAPI extends BaseYdbAPI { database, path, merge_rules: true, + dialect: 'ydb-short', + }, + {concurrentId, requestConfig: {signal}}, + ); + } + getAvailablePermissions( + {path, database}: {path: string; database: string}, + {concurrentId, signal}: AxiosOptions = {}, + ) { + return this.get( + this.getPath('/viewer/json/acl'), + { + database, + path, + merge_rules: true, + dialect: 'ydb-short', + list_permissions: true, + }, + {concurrentId, requestConfig: {signal}}, + ); + } + updateAccessRights( + { + path, + database, + rights, + }: {path: string; database: string; rights: AccessRightsUpdateRequest}, + {concurrentId, signal}: AxiosOptions = {}, + ) { + return this.post( + this.getPath('/viewer/json/acl'), + rights, + { + database, + path, + merge_rules: true, + dialect: 'ydb-short', }, {concurrentId, requestConfig: {signal}}, ); diff --git a/src/store/reducers/api.ts b/src/store/reducers/api.ts index cb30066e4a..c5596d060b 100644 --- a/src/store/reducers/api.ts +++ b/src/store/reducers/api.ts @@ -18,6 +18,7 @@ export const api = createApi({ 'Tablet', 'UserData', 'VDiskData', + 'AccessRights', ], }); diff --git a/src/store/reducers/capabilities/hooks.ts b/src/store/reducers/capabilities/hooks.ts index bf2354f71a..eb6fc9d455 100644 --- a/src/store/reducers/capabilities/hooks.ts +++ b/src/store/reducers/capabilities/hooks.ts @@ -77,6 +77,9 @@ export const useClusterDashboardAvailable = () => { export const useStreamingAvailable = () => { return useGetFeatureVersion('/viewer/query') >= 8; }; +export const useEditAccessAvailable = () => { + return useGetFeatureVersion('/viewer/acl') >= 2; +}; export const useTopicDataAvailable = () => { return useGetFeatureVersion('/viewer/topic_data') >= 2; diff --git a/src/store/reducers/schemaAcl/schemaAcl.ts b/src/store/reducers/schemaAcl/schemaAcl.ts index c8278dba94..a431940dbe 100644 --- a/src/store/reducers/schemaAcl/schemaAcl.ts +++ b/src/store/reducers/schemaAcl/schemaAcl.ts @@ -1,3 +1,7 @@ +import {createSelector} from '@reduxjs/toolkit'; + +import type {AccessRightsUpdateRequest} from '../../../types/api/acl'; +import type {RootState} from '../../index'; import {api} from '../api'; export const schemaAclApi = api.injectEndpoints({ @@ -6,6 +10,212 @@ export const schemaAclApi = api.injectEndpoints({ queryFn: async ({path, database}: {path: string; database: string}, {signal}) => { try { const data = await window.api.viewer.getSchemaAcl({path, database}, {signal}); + // Common: { + // Path: '/ru-prestable/access/dev/access-meta', + // Owner: 'robot-yandexdb@staff', + // ACL: [ + // { + // AccessType: 'Allow', + // Subject: 'svc_access@staff', + // InheritanceType: ['Object', 'Container'], + // AccessRules: ['UseLegacy'], + // }, + // { + // AccessType: 'Allow', + // Subject: 'robot-giffany@staff', + // InheritanceType: ['Object', 'Container'], + // AccessRules: [ + // 'UseLegacy', + // 'UseLegacy', + // 'UseLegacy', + // 'UseLegacy', + // 'UseLegacy', + // 'UseLegacy', + // 'UseLegacy', + // 'UseLegacy', + // 'UseLegacy', + // 'UseLegacy', + // 'UseLegacy', + // 'UseLegacy', + // ], + // }, + // { + // AccessType: 'Allow', + // Subject: 'yandex_monetize_market_marketdev_index_4751@staff', + // InheritanceType: ['Object', 'Container'], + // AccessRules: ['UseLegacy'], + // }, + // { + // AccessType: 'Allow', + // AccessRights: ['ConnectDatabase'], + // Subject: 'robot-giffany@staff', + // }, + // { + // AccessType: 'Allow', + // AccessRights: ['ConnectDatabase'], + // Subject: 'robot-ydb-checker@staff', + // }, + // { + // AccessType: 'Allow', + // AccessRights: ['ConnectDatabase'], + // Subject: 'svc_access@staff', + // }, + // { + // AccessType: 'Allow', + // AccessRights: ['ConnectDatabase'], + // Subject: 'yandex_monetize_market_marketdev_index_4751@staff', + // }, + // { + // AccessType: 'Allow', + // Subject: 'svc_access_db_management@staff', + // InheritanceType: ['Object', 'Container'], + // AccessRules: ['Use'], + // }, + // { + // AccessType: 'Allow', + // Subject: 'role_svc_access_db_management@staff', + // InheritanceType: ['Object', 'Container'], + // AccessRules: ['Use'], + // }, + // ], + // EffectiveACL: [ + // { + // AccessType: 'Allow', + // Subject: 'svc_kikimr@staff', + // InheritanceType: ['Object', 'Container'], + // AccessRules: ['Read'], + // }, + // { + // AccessType: 'Allow', + // Subject: 'robot-yandexdb@staff', + // InheritanceType: ['Object', 'Container'], + // AccessRules: ['FullLegacy'], + // }, + // { + // AccessType: 'Allow', + // Subject: 'svc_ydb@staff', + // InheritanceType: ['Object', 'Container'], + // AccessRules: ['UseLegacy'], + // }, + // { + // AccessType: 'Allow', + // Subject: 'robot-ydb-checker@staff', + // InheritanceType: ['Object', 'Container'], + // AccessRules: ['UseLegacy'], + // }, + // { + // AccessType: 'Allow', + // AccessRights: ['ConnectDatabase'], + // Subject: 'robot-ydb-cp@staff', + // InheritanceType: ['Object', 'Container'], + // }, + // { + // AccessType: 'Allow', + // Subject: 'robot-ydb-cp@staff', + // InheritanceType: ['Object', 'Container'], + // AccessRules: ['FullLegacy'], + // }, + // { + // AccessType: 'Allow', + // Subject: 'robot-ydb-cp@staff', + // InheritanceType: ['Object', 'Container'], + // AccessRules: ['Manage'], + // }, + // { + // AccessType: 'Allow', + // Subject: 'svc_ycydbwebui@staff', + // InheritanceType: ['Object', 'Container'], + // AccessRules: ['Read'], + // }, + // { + // AccessType: 'Allow', + // Subject: 'svc_ydbui@staff', + // InheritanceType: ['Object', 'Container'], + // AccessRules: ['Read'], + // }, + // { + // AccessType: 'Allow', + // Subject: 'svc_ydb@staff', + // InheritanceType: ['Object', 'Container'], + // AccessRules: ['Use'], + // }, + // { + // AccessType: 'Allow', + // Subject: 'svc_ydb_support_line_2_dutywork@staff', + // InheritanceType: ['Object', 'Container'], + // AccessRules: ['List'], + // }, + // { + // AccessType: 'Allow', + // AccessRights: ['ReadAttributes'], + // Subject: 'role_svc_ydb_support_line_2_dutywork@staff', + // InheritanceType: ['Object', 'Container'], + // }, + // { + // AccessType: 'Allow', + // AccessRights: ['DescribeSchema'], + // Subject: 'role_svc_ydb_support_line_2_dutywork@staff', + // InheritanceType: ['Object', 'Container'], + // }, + // { + // AccessType: 'Allow', + // Subject: 'svc_kikimr@staff', + // InheritanceType: ['Object', 'Container'], + // AccessRules: ['Full'], + // }, + // { + // AccessType: 'Allow', + // Subject: 'svc_access@staff', + // InheritanceType: ['Object', 'Container'], + // AccessRules: ['UseLegacy'], + // }, + // { + // AccessType: 'Allow', + // Subject: 'robot-giffany@staff', + // InheritanceType: ['Object', 'Container'], + // AccessRules: ['UseLegacy'], + // }, + // { + // AccessType: 'Allow', + // Subject: 'yandex_monetize_market_marketdev_index_4751@staff', + // InheritanceType: ['Object', 'Container'], + // AccessRules: ['UseLegacy'], + // }, + // { + // AccessType: 'Allow', + // AccessRights: ['ConnectDatabase'], + // Subject: 'robot-giffany@staff', + // }, + // { + // AccessType: 'Allow', + // AccessRights: ['ConnectDatabase'], + // Subject: 'robot-ydb-checker@staff', + // }, + // { + // AccessType: 'Allow', + // AccessRights: ['ConnectDatabase'], + // Subject: 'svc_access@staff', + // }, + // { + // AccessType: 'Allow', + // AccessRights: ['ConnectDatabase'], + // Subject: 'yandex_monetize_market_marketdev_index_4751@staff', + // }, + // { + // AccessType: 'Allow', + // Subject: 'svc_access_db_management@staff', + // InheritanceType: ['Object', 'Container'], + // AccessRules: ['Use'], + // }, + // { + // AccessType: 'Allow', + // Subject: 'role_svc_access_db_management@staff', + // InheritanceType: ['Object', 'Container'], + // AccessRules: ['Use'], + // }, + // ], + // }, + // }; return { data: { acl: data.Common.ACL, @@ -18,8 +228,162 @@ export const schemaAclApi = api.injectEndpoints({ return {error}; } }, - providesTags: ['SchemaTree'], + providesTags: ['All', 'AccessRights'], + }), + getAvailablePermissions: build.query({ + queryFn: async ({database}: {database: string}, {signal}) => { + try { + const data = await window.api.viewer.getAvailablePermissions( + {path: database, database}, + {signal}, + ); + + return { + data: data.AvailablePermissions, + }; + } catch (error) { + return {error}; + } + }, + }), + updateAccess: build.mutation({ + queryFn: async (props: { + database: string; + path: string; + rights: AccessRightsUpdateRequest; + }) => { + try { + const data = await window.api.viewer.updateAccessRights(props); + return {data}; + } catch (error) { + return {error}; + } + }, + invalidatesTags: (_, error) => (error ? [] : ['AccessRights']), }), }), overrideExisting: 'throw', }); + +const createGetSchemaAclSelector = createSelector( + (path: string) => path, + (_path: string, database: string) => database, + (path, database) => schemaAclApi.endpoints.getSchemaAcl.select({path, database}), +); + +export const selectSchemaOwner = createSelector( + (state: RootState) => state, + (_state: RootState, path: string, database: string) => + createGetSchemaAclSelector(path, database), + (state, selectGetSchemaAcl) => selectGetSchemaAcl(state).data?.owner, +); + +const selectAccessRights = createSelector( + (state: RootState) => state, + (_state: RootState, path: string, database: string) => + createGetSchemaAclSelector(path, database), + (state, selectGetSchemaAcl) => selectGetSchemaAcl(state).data, +); + +const selectRightsMap = createSelector(selectAccessRights, (data) => { + if (!data) { + return null; + } + const {acl, effectiveAcl} = data; + + const result: Record; effective: Set}> = {}; + + if (acl?.length) { + acl.forEach((aclItem) => { + if (aclItem.Subject) { + if (!result[aclItem.Subject]) { + result[aclItem.Subject] = {explicit: new Set(), effective: new Set()}; + } + aclItem.AccessRules?.forEach((rule) => { + result[aclItem.Subject].explicit.add(rule); + }); + aclItem.AccessRights?.forEach((rule) => { + result[aclItem.Subject].explicit.add(rule); + }); + } + }); + } + + if (effectiveAcl?.length) { + effectiveAcl.forEach((aclItem) => { + if (aclItem.Subject) { + if (!result[aclItem.Subject]) { + result[aclItem.Subject] = {explicit: new Set(), effective: new Set()}; + } + aclItem.AccessRules?.forEach((rule) => { + result[aclItem.Subject].effective.add(rule); + }); + aclItem.AccessRights?.forEach((rule) => { + result[aclItem.Subject].effective.add(rule); + }); + } + }); + } + + return result; +}); + +export const selectPreparedRights = createSelector(selectRightsMap, (data) => { + if (!data) { + return null; + } + return Object.entries(data).map(([subject, value]) => ({ + subject, + explicit: Array.from(value.explicit), + effective: Array.from(value.effective), + })); +}); + +export const selectSubjectExplicitRights = createSelector( + [ + (_state: RootState, subject: string | undefined) => subject, + (state: RootState, _subject: string | undefined, path: string, database: string) => + selectRightsMap(state, path, database), + ], + (subject, rightsMap) => { + if (!subject || !rightsMap) { + return []; + } + + const explicitRights = rightsMap[subject]?.explicit || new Set(); + + return Array.from(explicitRights); + }, +); +export const selectSubjectInheritedRights = createSelector( + [ + (_state: RootState, subject: string | undefined) => subject, + (state: RootState, _subject: string | undefined, path: string, database: string) => + selectRightsMap(state, path, database), + ], + (subject, rightsMap) => { + if (!subject || !rightsMap) { + return new Set(); + } + + const explicitRights = rightsMap[subject]?.explicit || new Set(); + const effectiveRights = rightsMap[subject]?.effective || new Set(); + const inheritedRights = Array.from(effectiveRights).filter( + (right) => !explicitRights.has(right), + ); + + return new Set(inheritedRights); + }, +); + +const createGetAvailablePermissionsSelector = createSelector( + (database: string) => database, + (database) => schemaAclApi.endpoints.getAvailablePermissions.select({database}), +); + +// Then create the main selector that extracts the available permissions data +export const selectAvailablePermissions = createSelector( + (state: RootState) => state, + (_state: RootState, database: string) => createGetAvailablePermissionsSelector(database), + (state, selectGetAvailablePermissions) => selectGetAvailablePermissions(state).data, +); diff --git a/src/store/reducers/tenant/constants.ts b/src/store/reducers/tenant/constants.ts index e90ae507e7..0deb05dd74 100644 --- a/src/store/reducers/tenant/constants.ts +++ b/src/store/reducers/tenant/constants.ts @@ -28,6 +28,7 @@ export const TENANT_DIAGNOSTICS_TABS_IDS = { topicData: 'topicData', configs: 'configs', operations: 'operations', + access: 'access', } as const; export const TENANT_SUMMARY_TABS_IDS = { diff --git a/src/styles/index.scss b/src/styles/index.scss index 7c9b1e3b69..ece50c71b1 100644 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -4,7 +4,8 @@ @forward './illustrations.scss'; body { - --ydb-drawer-veil-z-index: 0; + --ydb-drawer-veil-z-index: 2; + --gn-drawer-item-z-index: calc(var(--ydb-drawer-veil-z-index) + 1); --gn-drawer-veil-z-index: var(--ydb-drawer-veil-z-index); margin: 0; diff --git a/src/types/api/acl.ts b/src/types/api/acl.ts index b9ce220794..d1b10553e4 100644 --- a/src/types/api/acl.ts +++ b/src/types/api/acl.ts @@ -26,3 +26,42 @@ export interface TACE { InheritanceType?: string[]; AccessRule?: string; } + +export type AccessRightsUpdate = Omit; + +export interface AccessRightsUpdateRequest { + AddAccess?: AccessRightsUpdate[]; + RemoveAccess?: AccessRightsUpdate[]; + ChangeOwnership?: {Subject: string}; +} + +export interface PreparedAccessRights { + subject: string; + explicit: string[]; + effective: string[]; +} + +export interface AccessRuleConfig { + AccessRights?: string[]; + AccessRules?: string[]; + Name: string; + Mask: number; +} + +export interface AccessRightConfig { + Name: string; + Mask: number; +} +export interface InheritanceTypeConfig { + Name: string; + Mask: number; +} +export interface AvailablePermissionsConfig { + AccessRights?: AccessRightConfig[]; + AccessRules?: AccessRuleConfig[]; + InheritanceTypes?: InheritanceTypeConfig[]; +} + +export interface AvailablePermissionsResponse { + AvailablePermissions: AvailablePermissionsConfig; +} diff --git a/src/types/api/capabilities.ts b/src/types/api/capabilities.ts index 14738fbf61..0a20d28786 100644 --- a/src/types/api/capabilities.ts +++ b/src/types/api/capabilities.ts @@ -17,6 +17,7 @@ export type Capability = | '/viewer/feature_flags' | '/viewer/cluster' | '/viewer/nodes' + | '/viewer/acl' | '/viewer/topic_data'; export type SecuritySetting = 'UseLoginProvider' | 'DomainLoginOnly'; diff --git a/src/utils/createToast.tsx b/src/utils/createToast.tsx index efc31eb14d..d6af856da4 100644 --- a/src/utils/createToast.tsx +++ b/src/utils/createToast.tsx @@ -3,11 +3,18 @@ import {toaster} from '@gravity-ui/uikit/toaster-singleton-react-18'; export {toaster}; -function createToast({name, title, theme, isClosable, autoHiding, ...restProps}: ToastProps) { +function createToast({ + name, + title = 'Request succeeded', + theme = 'success', + isClosable, + autoHiding, + ...restProps +}: ToastProps) { return toaster.add({ - name: name ?? 'Request succeeded', - title: title ?? 'Request succeeded', - theme: theme ?? 'success', + name: name, + title: title, + theme: theme, isClosable: isClosable ?? true, autoHiding: autoHiding ?? (theme === 'success' ? 5000 : false), ...restProps, diff --git a/tests/suites/tenant/diagnostics/Diagnostics.ts b/tests/suites/tenant/diagnostics/Diagnostics.ts index 876cd8f42c..04ede054a6 100644 --- a/tests/suites/tenant/diagnostics/Diagnostics.ts +++ b/tests/suites/tenant/diagnostics/Diagnostics.ts @@ -17,6 +17,7 @@ export enum DiagnosticsTab { HotKeys = 'Hot keys', Describe = 'Describe', Storage = 'Storage', + Access = 'Access', } export class Table { @@ -230,6 +231,7 @@ export class Diagnostics { storage: StoragePage; nodes: NodesPage; memoryViewer: MemoryViewer; + private page: Page; private tabs: Locator; private schemaViewer: Locator; @@ -245,8 +247,12 @@ export class Diagnostics { private tableRadioButton: Locator; private fixedHeightQueryElements: Locator; private copyLinkButton: Locator; + private ownerCard: Locator; + private changeOwnerButton: Locator; + private grantAccessButton: Locator; constructor(page: Page) { + this.page = page; this.storage = new StoragePage(page); this.nodes = new NodesPage(page); this.memoryViewer = new MemoryViewer(page); @@ -269,6 +275,9 @@ export class Diagnostics { this.storageCard = page.locator('.metrics-cards__tab:has-text("Storage")'); this.memoryCard = page.locator('.metrics-cards__tab:has-text("Memory")'); this.healthcheckCard = page.locator('.ydb-healthcheck-preview'); + this.ownerCard = page.locator('.ydb-access-rights__owner-card'); + this.changeOwnerButton = page.locator('.ydb-access-rights__owner-card button'); + this.grantAccessButton = page.locator('button:has-text("Grant Access")'); } async isSchemaViewerVisible() { @@ -423,4 +432,103 @@ export class Diagnostics { const rowElementClass = await rowElement.getAttribute('class'); return rowElementClass?.includes('kv-top-queries__row_active') || false; } + + async isOwnerCardVisible(): Promise { + await this.ownerCard.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); + return true; + } + + async getOwnerName(): Promise { + const ownerNameElement = this.ownerCard.locator('.ydb-subject-with-avatar__subject'); + + return await ownerNameElement.innerText(); + } + + async clickChangeOwnerButton(): Promise { + await this.changeOwnerButton.click(); + } + + async changeOwner(newOwnerName: string): Promise { + await this.clickChangeOwnerButton(); + + // Wait for the dialog to appear + const dialog = this.page.locator('.g-dialog'); + await dialog.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); + + // Wait for the owner input field to appear + const ownerInput = dialog.locator('input[placeholder="Enter subject"]'); + await ownerInput.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); + + // Clear the input and type the new owner name + await ownerInput.clear(); + await ownerInput.fill(newOwnerName); + + // Click the Apply button + const applyButton = dialog.locator('button:has-text("Apply")'); + + // Wait for the button to be enabled + await this.page.waitForTimeout(500); + // Wait for the button to become enabled + await this.page.waitForSelector('.g-dialog button:has-text("Apply"):not([disabled])', { + timeout: VISIBILITY_TIMEOUT, + }); + await applyButton.click(); + + // Wait for the dialog to close and changes to be applied + await dialog.waitFor({state: 'hidden', timeout: VISIBILITY_TIMEOUT}); + await this.page.waitForTimeout(500); + } + + async clickGrantAccessButton(): Promise { + await this.grantAccessButton.click(); + } + + async isGrantAccessDrawerVisible(): Promise { + const grantAccessDialog = this.page.locator('.ydb-grant-access'); + await grantAccessDialog.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); + return true; + } + + async enterSubjectInGrantAccessDialog(subject: string): Promise { + const subjectInput = this.page.locator('.ydb-grant-access input[name="subjectInput"]'); + await subjectInput.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); + await subjectInput.fill(subject); + await subjectInput.press('Enter'); + } + + async isRightsWrapperVisible(): Promise { + const rightsWrapper = this.page.locator('.ydb-grant-access__rights-wrapper'); + return rightsWrapper.isVisible(); + } + + async isApplyButtonDisabled(): Promise { + const applyButton = this.page.locator('.ydb-grant-access__footer-button:has-text("Apply")'); + await applyButton.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); + return await applyButton.isDisabled(); + } + + async clickFullAccessSwitch(): Promise { + const fullAccessCard = this.page.locator('.ydb-grant-access__single-right').first(); + await fullAccessCard.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); + const switchElement = fullAccessCard.locator('.g-switch__indicator'); + await switchElement.click(); + } + + async clickApplyButton(): Promise { + const applyButton = this.page.locator('button:has-text("Apply")'); + await applyButton.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); + await applyButton.click(); + } + + async isSubjectInRightsTable(subject: string): Promise { + const rightsTable = this.page.locator('.ydb-access-rights__rights-table'); + await rightsTable.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); + + const rows = rightsTable.locator('.data-table__row'); + + await rows + .filter({hasText: subject}) + .waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); + return true; + } } diff --git a/tests/suites/tenant/diagnostics/tabs/access.test.ts b/tests/suites/tenant/diagnostics/tabs/access.test.ts new file mode 100644 index 0000000000..75cb1884e6 --- /dev/null +++ b/tests/suites/tenant/diagnostics/tabs/access.test.ts @@ -0,0 +1,130 @@ +import {expect, test} from '@playwright/test'; + +import {TenantPage} from '../../TenantPage'; +import {Diagnostics, DiagnosticsTab} from '../Diagnostics'; + +const newSubject = 'foo'; + +test.describe('Diagnostics Access tab', async () => { + test('Access tab shows owner card', async ({page}) => { + const pageQueryParams = { + schema: '/local/.sys_health', + database: '/local', + tenantPage: 'diagnostics', + diagnosticsTab: 'access', + }; + const tenantPage = new TenantPage(page); + await tenantPage.goto(pageQueryParams); + + const diagnostics = new Diagnostics(page); + + // Verify owner card is visible + await expect(diagnostics.isOwnerCardVisible()).resolves.toBe(true); + }); + + test('Can change owner on access tab', async ({page}) => { + const pageQueryParams = { + schema: '/local/.sys_health', + database: '/local', + tenantPage: 'diagnostics', + diagnosticsTab: 'access', + }; + const tenantPage = new TenantPage(page); + await tenantPage.goto(pageQueryParams); + + const diagnostics = new Diagnostics(page); + + // Get the current owner name + const initialOwnerName = await diagnostics.getOwnerName(); + + // Change the owner to "John Dow" + const newOwnerName = 'John Dow'; + await diagnostics.changeOwner(newOwnerName); + + // Verify the owner has been changed + const updatedOwnerName = await diagnostics.getOwnerName(); + expect(updatedOwnerName).toBe(newOwnerName); + expect(updatedOwnerName).not.toBe(initialOwnerName); + }); + + test('Owner card is visible after navigating to access tab', async ({page}) => { + const pageQueryParams = { + schema: '/dev02/home/xenoxeno/db1/my_row_table', + database: '/dev02/home/xenoxeno/db1', + tenantPage: 'diagnostics', + }; + const tenantPage = new TenantPage(page); + await tenantPage.goto(pageQueryParams); + + const diagnostics = new Diagnostics(page); + + // Navigate to the Access tab + await diagnostics.clickTab(DiagnosticsTab.Access); + + // Verify owner card is visible + await expect(diagnostics.isOwnerCardVisible()).resolves.toBe(true); + }); + + test('Grant Access button opens grant access drawer', async ({page}) => { + const pageQueryParams = { + schema: '/local/.sys_health', + database: '/local', + tenantPage: 'diagnostics', + diagnosticsTab: 'access', + summaryTab: 'acl', + }; + const tenantPage = new TenantPage(page); + await tenantPage.goto(pageQueryParams); + + const diagnostics = new Diagnostics(page); + + // Click on the Grant Access button + await diagnostics.clickGrantAccessButton(); + + await expect(diagnostics.isGrantAccessDrawerVisible()).resolves.toBe(true); + }); + + test.only('Can grant full access to a new subject', async ({page}) => { + const pageQueryParams = { + schema: '/local/.sys_health', + database: '/local', + tenantPage: 'diagnostics', + diagnosticsTab: 'access', + summaryTab: 'acl', + }; + const tenantPage = new TenantPage(page); + await tenantPage.goto(pageQueryParams); + + const diagnostics = new Diagnostics(page); + + // Verify that the rights wrapper is not visible initially + await expect(diagnostics.isRightsWrapperVisible()).resolves.toBe(false); + + // Click on the Grant Access button + await diagnostics.clickGrantAccessButton(); + + // Verify that the grant access dialog appears + await expect(diagnostics.isGrantAccessDrawerVisible()).resolves.toBe(true); + + // Enter "foo" in the subject input and press Enter + await diagnostics.enterSubjectInGrantAccessDialog(newSubject); + + // Verify that the rights wrapper appears + await expect(diagnostics.isRightsWrapperVisible()).resolves.toBe(true); + + // Verify that the Apply button is disabled initially + await expect(diagnostics.isApplyButtonDisabled()).resolves.toBe(true); + + // Click on the switch for the "full" access right + await diagnostics.clickFullAccessSwitch(); + + // Verify that the Apply button is now enabled + await expect(diagnostics.isApplyButtonDisabled()).resolves.toBe(false); + + // Click the Apply button + await diagnostics.clickApplyButton(); + + // Verify that "foo" appears in the rights table + await expect(diagnostics.isSubjectInRightsTable(newSubject)).resolves.toBe(true); + }); +}); diff --git a/tests/suites/tenant/summary/ObjectSummary.ts b/tests/suites/tenant/summary/ObjectSummary.ts index 7e3b02da5c..31a23b9507 100644 --- a/tests/suites/tenant/summary/ObjectSummary.ts +++ b/tests/suites/tenant/summary/ObjectSummary.ts @@ -20,11 +20,6 @@ export class ObjectSummary { private treeLoaders: Locator; private primaryKeys: Locator; private actionsMenu: ActionsMenu; - private aclWrapper: Locator; - private aclListWrapper: Locator; - private effectiveAclListWrapper: Locator; - private aclList: Locator; - private effectiveAclList: Locator; private createDirectoryModal: Locator; private createDirectoryInput: Locator; private createDirectoryButton: Locator; @@ -44,13 +39,6 @@ export class ObjectSummary { this.schemaViewer = page.locator('.schema-viewer'); this.primaryKeys = page.locator('.schema-viewer__keys_type_primary'); this.actionsMenu = new ActionsMenu(page.locator('.g-popup.g-popup_open')); - this.aclWrapper = page.locator('.ydb-acl'); - this.aclListWrapper = this.aclWrapper.locator('.gc-definition-list').first(); - this.aclList = this.aclListWrapper.locator('dl.gc-definition-list__list').first(); - this.effectiveAclListWrapper = this.aclWrapper.locator('.gc-definition-list').last(); - this.effectiveAclList = this.effectiveAclListWrapper - .locator('dl.gc-definition-list__list') - .first(); this.createDirectoryModal = page.locator('.g-modal.g-modal_open'); this.createDirectoryInput = page.locator( '.g-text-input__control[placeholder="Relative path"]', @@ -126,43 +114,34 @@ export class ObjectSummary { } async waitForAclVisible() { - await this.aclWrapper.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); + // In the new UI, the ACL tab shows a redirect message instead of the actual ACL content + const redirectMessage = this.page.locator('text=Section was moved to Diagnostics'); + await redirectMessage.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); return true; } - async getAccessRights(): Promise<{user: string; rights: string}[]> { - await this.waitForAclVisible(); - const items = await this.aclList.locator('.gc-definition-list__item').all(); - const result = []; - - for (const item of items) { - const user = - (await item.locator('.gc-definition-list__term-wrapper span').textContent()) || ''; - const definitionContent = await item.locator('.gc-definition-list__definition').first(); - const rights = (await definitionContent.textContent()) || ''; - result.push({user: user.trim(), rights: rights.trim()}); + async getRedirectMessage(): Promise { + const redirectMessage = this.page.locator('text=Section was moved to Diagnostics'); + if (await redirectMessage.isVisible()) { + return redirectMessage.textContent(); } - - return result; + return null; } - async getEffectiveAccessRights(): Promise<{group: string; permissions: string[]}[]> { - await this.waitForAclVisible(); - const items = await this.effectiveAclList.locator('.gc-definition-list__item').all(); - const result = []; - - for (const item of items) { - const group = - (await item.locator('.gc-definition-list__term-wrapper span').textContent()) || ''; - const definitionContent = await item.locator('.gc-definition-list__definition').first(); - const permissionElements = await definitionContent.locator('span').all(); - const permissions = await Promise.all( - permissionElements.map(async (el) => ((await el.textContent()) || '').trim()), - ); - result.push({group: group.trim(), permissions}); + async hasOpenInDiagnosticsButton(): Promise { + try { + const diagnosticsButton = this.page.getByRole('button', {name: 'Open in Diagnostics'}); + await diagnosticsButton.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); + return true; + } catch (error) { + console.error('Open in Diagnostics button not visible:', error); + return false; } + } - return result; + async clickOpenInDiagnosticsButton(): Promise { + const diagnosticsButton = this.page.getByRole('button', {name: 'Open in Diagnostics'}); + await diagnosticsButton.click(); } async isTreeVisible() { diff --git a/tests/suites/tenant/summary/objectSummary.test.ts b/tests/suites/tenant/summary/objectSummary.test.ts index a44ee7d819..4cb97aa27a 100644 --- a/tests/suites/tenant/summary/objectSummary.test.ts +++ b/tests/suites/tenant/summary/objectSummary.test.ts @@ -158,31 +158,6 @@ test.describe('Object Summary', async () => { expect(vslotsColumns).not.toEqual(storagePoolsColumns); }); - test('ACL tab shows correct access rights', async ({page}) => { - const pageQueryParams = { - schema: '/local/.sys_health', - database: '/local', - summaryTab: 'acl', - tenantPage: 'query', - }; - const tenantPage = new TenantPage(page); - await tenantPage.goto(pageQueryParams); - - const objectSummary = new ObjectSummary(page); - await objectSummary.waitForAclVisible(); - - // Check Access Rights - const accessRights = await objectSummary.getAccessRights(); - expect(accessRights).toEqual([{user: 'root@builtin', rights: 'Owner'}]); - - // Check Effective Access Rights - const effectiveRights = await objectSummary.getEffectiveAccessRights(); - expect(effectiveRights).toEqual([ - {group: 'Access', permissions: ['Manage']}, - {group: 'Inheritance type', permissions: ['Inherit']}, - ]); - }); - test('Copy path copies correct path to clipboard', async ({page}) => { const pageQueryParams = { schema: dsVslotsSchema, @@ -294,4 +269,57 @@ test.describe('Object Summary', async () => { await objectSummary.expandSummary(); await expect(objectSummary.isSummaryCollapsed()).resolves.toBe(false); }); + + test('ACL tab shows redirect message and link to Diagnostics', async ({page}) => { + // Define the URL parameters + const pageQueryParams = { + schema: '/local/.sys_health', + database: '/local', + summaryTab: 'acl', + tenantPage: 'query', + }; + + // Navigate to the page + const tenantPage = new TenantPage(page); + await tenantPage.goto(pageQueryParams); + + // Get the ObjectSummary instance + const objectSummary = new ObjectSummary(page); + + // Verify the ACL tab is selected + await objectSummary.clickTab(ObjectSummaryTab.ACL); + + // Wait for the ACL content to be visible + await objectSummary.waitForAclVisible(); + + // Check for the redirect message + const redirectMessage = await objectSummary.getRedirectMessage(); + expect(redirectMessage).toContain('Section was moved to Diagnostics'); + + // Check for the "Open in Diagnostics" button + const hasButton = await objectSummary.hasOpenInDiagnosticsButton(); + expect(hasButton).toBe(true); + + // Click the button and verify the URL + await objectSummary.clickOpenInDiagnosticsButton(); + + // Verify the URL contains the expected parameters + const expectedUrlParams = new URLSearchParams({ + tenantPage: 'diagnostics', + diagnosticsTab: 'access', + summaryTab: 'acl', + schema: '/local/.sys_health', + database: '/local', + }); + + // Get the current URL and parse its parameters + const currentUrl = page.url(); + const currentUrlObj = new URL(currentUrl); + const currentParams = currentUrlObj.searchParams; + + // Verify each expected parameter is in the URL + for (const [key, value] of expectedUrlParams.entries()) { + expect(currentParams.get(key)).toBe(value); + } + }); });