From 5dad9b8aa26bdacd15983212df6f44d9b3d1f305 Mon Sep 17 00:00:00 2001 From: ppadti Date: Thu, 1 Aug 2024 19:57:34 +0530 Subject: [PATCH] Admin - Model Registry RBAC Management Projects --- .../__mocks__/mockRoleBindingK8sResource.ts | 3 + .../cypress/pages/modelRegistryPermissions.ts | 50 +++-- .../cypress/cypress/pages/permissions.ts | 4 +- .../modelRegistryPermissions.cy.ts | 182 ++++++++++++++++-- frontend/src/concepts/projects/utils.ts | 19 ++ .../RoleBindingPermissionsNameInput.tsx | 16 +- .../RoleBindingPermissionsTable.tsx | 9 +- .../RoleBindingPermissionsTableRow.tsx | 36 +++- .../RoleBindingPermissionsTableRowAdd.tsx | 18 +- .../RoleBindingPermissionsTableSection.tsx | 25 ++- frontend/src/concepts/roleBinding/utils.ts | 29 ++- frontend/src/k8sTypes.ts | 1 + .../ModelRegistriesPermissions.tsx | 38 +++- .../ProjectsTab/ProjectsSettingsTab.tsx | 125 ++++++++++++ 14 files changed, 501 insertions(+), 54 deletions(-) create mode 100644 frontend/src/pages/modelRegistrySettings/ProjectsTab/ProjectsSettingsTab.tsx diff --git a/frontend/src/__mocks__/mockRoleBindingK8sResource.ts b/frontend/src/__mocks__/mockRoleBindingK8sResource.ts index 4fec421779..cd2597188b 100644 --- a/frontend/src/__mocks__/mockRoleBindingK8sResource.ts +++ b/frontend/src/__mocks__/mockRoleBindingK8sResource.ts @@ -8,6 +8,7 @@ type MockResourceConfigType = { roleRefName?: string; uid?: string; modelRegistryName?: string; + isProjectSubject?: boolean; }; export const mockRoleBindingK8sResource = ({ @@ -22,6 +23,7 @@ export const mockRoleBindingK8sResource = ({ ], roleRefName = 'view', uid = genUID('rolebinding'), + isProjectSubject = false, modelRegistryName = '', }: MockResourceConfigType): RoleBindingKind => { let labels; @@ -33,6 +35,7 @@ export const mockRoleBindingK8sResource = ({ 'app.kubernetes.io/part-of': 'model-registry', [KnownLabels.DASHBOARD_RESOURCE]: 'true', component: 'model-registry', + ...(isProjectSubject && { [KnownLabels.PROJECT_SUBJECT]: 'true' }), }; } else { labels = { diff --git a/frontend/src/__tests__/cypress/cypress/pages/modelRegistryPermissions.ts b/frontend/src/__tests__/cypress/cypress/pages/modelRegistryPermissions.ts index c93356ce2e..37b8fc2208 100644 --- a/frontend/src/__tests__/cypress/cypress/pages/modelRegistryPermissions.ts +++ b/frontend/src/__tests__/cypress/cypress/pages/modelRegistryPermissions.ts @@ -3,7 +3,35 @@ import { TableRow } from './components/table'; class PermissionsTableRow extends TableRow {} -class UsersTab { +class UsersTab extends Contextual { + findAddUserButton() { + return this.find().findByTestId('add-button user'); + } + + findAddGroupButton() { + return this.find().findByTestId('add-button group'); + } + + getUserTable() { + return new PermissionTable(() => this.find().findByTestId('role-binding-table User')); + } + + getGroupTable() { + return new PermissionTable(() => this.find().findByTestId('role-binding-table Group')); + } +} + +class ProjectsTab extends Contextual { + findAddProjectButton() { + return this.find().findByTestId('add-button project'); + } + + getProjectTable() { + return new PermissionTable(() => this.find().findByTestId('role-binding-table Group')); + } +} + +class MRPermissions { visit(mrName: string, wait = true) { cy.visitWithLogin(`/modelRegistrySettings/permissions/${mrName}`); if (wait) { @@ -16,20 +44,16 @@ class UsersTab { cy.testA11y(); } - findAddUserButton() { - return cy.findByTestId('add-button User'); - } - - findAddGroupButton() { - return cy.findByTestId('add-button Group'); + findProjectTab() { + return cy.findByTestId('projects-tab'); } - getUserTable() { - return new PermissionTable(() => cy.findByTestId('role-binding-table User')); + getUsersContent() { + return new UsersTab(() => cy.findByTestId('users-tab-content')); } - getGroupTable() { - return new PermissionTable(() => cy.findByTestId('role-binding-table Group')); + getProjectsContent() { + return new ProjectsTab(() => cy.findByTestId('projects-tab-content')); } } @@ -46,7 +70,7 @@ class PermissionTable extends Contextual { return this.find().findByTestId(['role-binding-name-input', id]); } - findGroupSelect() { + findNameSelect() { return this.find().get(`[aria-label="Name selection"]`); } @@ -69,4 +93,4 @@ class PermissionTable extends Contextual { } } -export const usersTab = new UsersTab(); +export const modelRegistryPermissions = new MRPermissions(); diff --git a/frontend/src/__tests__/cypress/cypress/pages/permissions.ts b/frontend/src/__tests__/cypress/cypress/pages/permissions.ts index c77aede5ff..9f3bf07437 100644 --- a/frontend/src/__tests__/cypress/cypress/pages/permissions.ts +++ b/frontend/src/__tests__/cypress/cypress/pages/permissions.ts @@ -15,11 +15,11 @@ class PermissionsTab { } findAddUserButton() { - return cy.findByTestId('add-button User'); + return cy.findByTestId('add-button user'); } findAddGroupButton() { - return cy.findByTestId('add-button Group'); + return cy.findByTestId('add-button group'); } getUserTable() { diff --git a/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistrySettings/modelRegistryPermissions.cy.ts b/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistrySettings/modelRegistryPermissions.cy.ts index bb409b0356..ddabf8aaff 100644 --- a/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistrySettings/modelRegistryPermissions.cy.ts +++ b/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistrySettings/modelRegistryPermissions.cy.ts @@ -1,17 +1,18 @@ -import { mockK8sResourceList } from '~/__mocks__'; +import { mockK8sResourceList, mockProjectK8sResource } from '~/__mocks__'; import { mock200Status } from '~/__mocks__/mockK8sStatus'; import { mockRoleBindingK8sResource } from '~/__mocks__/mockRoleBindingK8sResource'; import { be } from '~/__tests__/cypress/cypress/utils/should'; import { GroupModel, ModelRegistryModel, + ProjectModel, RoleBindingModel, } from '~/__tests__/cypress/cypress/utils/models'; import type { RoleBindingSubject } from '~/k8sTypes'; import { asProductAdminUser, asProjectEditUser } from '~/__tests__/cypress/cypress/utils/mockUsers'; import { mockModelRegistry } from '~/__mocks__/mockModelRegistry'; import { mockGroup } from '~/__mocks__/mockGroup'; -import { usersTab } from '~/__tests__/cypress/cypress/pages/modelRegistryPermissions'; +import { modelRegistryPermissions } from '~/__tests__/cypress/cypress/pages/modelRegistryPermissions'; const MODEL_REGISTRY_DEFAULT_NAMESPACE = 'odh-model-registries'; @@ -31,6 +32,14 @@ const groupSubjects: RoleBindingSubject[] = [ }, ]; +const projectSubjects: RoleBindingSubject[] = [ + { + kind: 'Group', + apiGroup: 'rbac.authorization.k8s.io', + name: 'system:serviceaccounts:test-project', + }, +]; + type HandlersProps = { isEmpty?: boolean; hasPermission?: boolean; @@ -52,6 +61,13 @@ const initIntercepts = ({ isEmpty = false, hasPermission = true }: HandlersProps { model: GroupModel }, mockK8sResourceList([mockGroup({ name: 'example-mr-group-option' })]), ); + cy.interceptK8sList( + ProjectModel, + mockK8sResourceList([ + mockProjectK8sResource({}), + mockProjectK8sResource({ k8sName: 'project-name', displayName: 'Project' }), + ]), + ); cy.interceptK8sList( { model: RoleBindingModel, ns: MODEL_REGISTRY_DEFAULT_NAMESPACE }, mockK8sResourceList( @@ -86,31 +102,41 @@ const initIntercepts = ({ isEmpty = false, hasPermission = true }: HandlersProps roleRefName: 'registry-user-example-mr', modelRegistryName: 'example-mr', }), + mockRoleBindingK8sResource({ + namespace: MODEL_REGISTRY_DEFAULT_NAMESPACE, + subjects: projectSubjects, + roleRefName: 'registry-user-example-mr', + modelRegistryName: 'example-mr', + isProjectSubject: true, + }), ], ), ); }; describe('MR Permissions', () => { + const usersTab = modelRegistryPermissions.getUsersContent(); + const projectsTab = modelRegistryPermissions.getProjectsContent(); const userTable = usersTab.getUserTable(); const groupTable = usersTab.getGroupTable(); + const projectTable = projectsTab.getProjectTable(); it('should not be accessible for non-project admins', () => { initIntercepts({ isEmpty: false, hasPermission: false }); - usersTab.visit('example-mr', false); + modelRegistryPermissions.visit('example-mr', false); cy.findByTestId('not-found-page').should('exist'); }); it('redirect if no modelregistry', () => { initIntercepts({ isEmpty: true }); - usersTab.visit('example-mr'); + modelRegistryPermissions.visit('example-mr'); cy.url().should('eq', `${Cypress.config().baseUrl}/modelRegistrySettings`); }); describe('Users table', () => { it('Table sorting for users table', () => { initIntercepts({ isEmpty: false }); - usersTab.visit('example-mr'); + modelRegistryPermissions.visit('example-mr'); userTable.findRows().should('have.length', 2); // by name @@ -139,7 +165,7 @@ describe('MR Permissions', () => { modelRegistryName: 'example-mr', }), ).as('addUser'); - usersTab.visit('example-mr'); + modelRegistryPermissions.visit('example-mr'); usersTab.findAddUserButton().click(); @@ -189,7 +215,7 @@ describe('MR Permissions', () => { mock200Status({}), ).as('deleteUser'); - usersTab.visit('example-mr'); + modelRegistryPermissions.visit('example-mr'); userTable.getTableRow('example-mr-user').findKebabAction('Edit').click(); userTable.findEditInput('example-mr-user').clear().type('edited-user'); @@ -226,7 +252,7 @@ describe('MR Permissions', () => { { model: RoleBindingModel, ns: MODEL_REGISTRY_DEFAULT_NAMESPACE, name: 'example-mr-user' }, mock200Status({}), ).as('deleteUser'); - usersTab.visit('example-mr'); + modelRegistryPermissions.visit('example-mr'); userTable.getTableRow('example-mr-user').findKebabAction('Delete').click(); @@ -238,7 +264,7 @@ describe('MR Permissions', () => { it('Table sorting for groups table', () => { initIntercepts({ isEmpty: false }); - usersTab.visit('example-mr'); + modelRegistryPermissions.visit('example-mr'); groupTable.findTableHeaderButton('Name').click(); groupTable.findTableHeaderButton('Name').should(be.sortDescending); @@ -264,11 +290,11 @@ describe('MR Permissions', () => { modelRegistryName: 'example-mr', }), ).as('addGroup'); - usersTab.visit('example-mr'); + modelRegistryPermissions.visit('example-mr'); usersTab.findAddGroupButton().click(); - groupTable.findGroupSelect().fill('new-example-mr-group'); + groupTable.findNameSelect().fill('new-example-mr-group'); cy.findByText('Create "new-example-mr-group"').click(); groupTable.findSaveNewButton().click(); @@ -319,10 +345,10 @@ describe('MR Permissions', () => { mock200Status({}), ).as('deleteGroup'); - usersTab.visit('example-mr'); + modelRegistryPermissions.visit('example-mr'); groupTable.getTableRow('example-mr-users-2').findKebabAction('Edit').click(); - groupTable.findGroupSelect().clear().type('example-mr-group-opti'); + groupTable.findNameSelect().clear().type('example-mr-group-opti'); cy.findByText('example-mr-group-option').click(); groupTable.findEditSaveButton('example-mr-group-option').click(); @@ -368,7 +394,7 @@ describe('MR Permissions', () => { mock200Status({}), ).as('deleteGroup'); - usersTab.visit('example-mr'); + modelRegistryPermissions.visit('example-mr'); groupTable.getTableRow('example-mr-users-2').findKebabAction('Delete').click(); cy.wait('@deleteGroup'); @@ -376,9 +402,135 @@ describe('MR Permissions', () => { it('Disabled actions on default group', () => { initIntercepts({ isEmpty: false }); - usersTab.visit('example-mr'); + modelRegistryPermissions.visit('example-mr'); groupTable.getTableRow('example-mr-users').findKebab().should('be.disabled'); groupTable.getTableRow('example-mr-users-2').findKebab().should('not.be.disabled'); }); }); + + describe('Projects table', () => { + beforeEach(() => { + initIntercepts({ isEmpty: false }); + modelRegistryPermissions.visit('example-mr'); + modelRegistryPermissions.findProjectTab().click(); + }); + + it('table sorting', () => { + // by name + projectTable.findTableHeaderButton('Name').click(); + projectTable.findTableHeaderButton('Name').should(be.sortDescending); + projectTable.findTableHeaderButton('Name').click(); + projectTable.findTableHeaderButton('Name').should(be.sortAscending); + //by permissions + projectTable.findTableHeaderButton('Permission').click(); + projectTable.findTableHeaderButton('Permission').should(be.sortAscending); + projectTable.findTableHeaderButton('Permission').click(); + projectTable.findTableHeaderButton('Permission').should(be.sortDescending); + //by date added + projectTable.findTableHeaderButton('Date added').click(); + projectTable.findTableHeaderButton('Date added').should(be.sortAscending); + projectTable.findTableHeaderButton('Date added').click(); + projectTable.findTableHeaderButton('Date added').should(be.sortDescending); + }); + + it('Add project', () => { + cy.interceptK8s( + 'POST', + RoleBindingModel, + mockRoleBindingK8sResource({ + namespace: MODEL_REGISTRY_DEFAULT_NAMESPACE, + subjects: projectSubjects, + roleRefName: 'registry-user-example-mr', + modelRegistryName: 'example-mr', + }), + ).as('addProject'); + + projectsTab.findAddProjectButton().click(); + projectTable.findNameSelect().findSelectOption('Project').click(); + projectTable.findSaveNewButton().click(); + + cy.wait('@addProject').then((interception) => { + expect(interception.request.body).to.containSubset({ + metadata: { + namespace: 'odh-model-registries', + }, + roleRef: { + apiGroup: 'rbac.authorization.k8s.io', + kind: 'Role', + name: 'registry-user-example-mr', + }, + subjects: [ + { + apiGroup: 'rbac.authorization.k8s.io', + kind: 'Group', + name: 'system:serviceaccounts:project-name', + }, + ], + }); + }); + }); + + it('Edit project', () => { + cy.interceptK8s( + 'POST', + RoleBindingModel, + mockRoleBindingK8sResource({ + namespace: MODEL_REGISTRY_DEFAULT_NAMESPACE, + subjects: projectSubjects, + roleRefName: 'registry-user-example-mr', + modelRegistryName: 'example-mr', + }), + ).as('editProject'); + cy.interceptK8s( + 'DELETE', + { + model: RoleBindingModel, + ns: MODEL_REGISTRY_DEFAULT_NAMESPACE, + name: 'test-name-view', + }, + mock200Status({}), + ).as('deleteProject'); + + projectTable.getTableRow('Test Project').findKebabAction('Edit').click(); + projectTable.findNameSelect().findSelectOption('Project').click(); + projectTable.findEditSaveButton('Project').click(); + + cy.wait('@editProject').then((interception) => { + expect(interception.request.body).to.containSubset({ + metadata: { + namespace: 'odh-model-registries', + }, + roleRef: { + apiGroup: 'rbac.authorization.k8s.io', + kind: 'Role', + name: 'registry-user-example-mr', + }, + subjects: [ + { + apiGroup: 'rbac.authorization.k8s.io', + kind: 'Group', + name: 'system:serviceaccounts:project-name', + }, + ], + }); + }); + cy.wait('@deleteProject'); + }); + + it('Delete project', () => { + cy.interceptK8s( + 'DELETE', + { + model: RoleBindingModel, + ns: MODEL_REGISTRY_DEFAULT_NAMESPACE, + name: 'test-name-view', + }, + mock200Status({}), + ).as('deleteProject'); + + projectTable.getTableRow('Test Project').findKebabAction('Delete').click(); + + cy.wait('@deleteProject'); + }); + }); }); diff --git a/frontend/src/concepts/projects/utils.ts b/frontend/src/concepts/projects/utils.ts index 50515948a0..96133f6574 100644 --- a/frontend/src/concepts/projects/utils.ts +++ b/frontend/src/concepts/projects/utils.ts @@ -1,4 +1,5 @@ import { ProjectKind } from '~/k8sTypes'; +import { getDisplayNameFromK8sResource } from '~/concepts/k8s/utils'; export const isAvailableProject = (projectName: string, dashboardNamespace: string): boolean => !( @@ -14,3 +15,21 @@ export const getProjectOwner = (project: ProjectKind): string => project.metadata.annotations?.['openshift.io/requester'] || ''; export const getProjectCreationTime = (project: ProjectKind): number => project.metadata.creationTimestamp ? new Date(project.metadata.creationTimestamp).getTime() : 0; + +export const namespaceToProjectDisplayName = ( + namespace: string, + projects: ProjectKind[], +): string => { + const project = projects.find((p) => p.metadata.name === namespace); + return project ? getDisplayNameFromK8sResource(project) : namespace; +}; + +export const projectDisplayNameToNamespace = ( + displayName: string, + projects: ProjectKind[], +): string => { + const project = projects.find( + (p) => p.metadata.annotations?.['openshift.io/display-name'] === displayName, + ); + return project?.metadata.name || displayName; +}; diff --git a/frontend/src/concepts/roleBinding/RoleBindingPermissionsNameInput.tsx b/frontend/src/concepts/roleBinding/RoleBindingPermissionsNameInput.tsx index 3be122a651..33f1deeefa 100644 --- a/frontend/src/concepts/roleBinding/RoleBindingPermissionsNameInput.tsx +++ b/frontend/src/concepts/roleBinding/RoleBindingPermissionsNameInput.tsx @@ -2,6 +2,8 @@ import React from 'react'; import { TextInput } from '@patternfly/react-core'; import { Select, SelectOption, SelectVariant } from '@patternfly/react-core/deprecated'; import { RoleBindingSubject } from '~/k8sTypes'; +import { namespaceToProjectDisplayName } from '~/concepts/projects/utils'; +import { ProjectsContext } from '~/concepts/projects/ProjectsContext'; import { RoleBindingPermissionsRBType } from './types'; type RoleBindingPermissionsNameInputProps = { @@ -11,6 +13,7 @@ type RoleBindingPermissionsNameInputProps = { onClear: () => void; placeholderText: string; typeAhead?: string[]; + isProjectSubject?: boolean; }; const RoleBindingPermissionsNameInput: React.FC = ({ @@ -20,7 +23,9 @@ const RoleBindingPermissionsNameInput: React.FC { + const { projects } = React.useContext(ProjectsContext); const [isOpen, setIsOpen] = React.useState(false); if (!typeAhead) { @@ -32,7 +37,11 @@ const RoleBindingPermissionsNameInput: React.FC onChange(newValue)} /> @@ -60,7 +69,10 @@ const RoleBindingPermissionsNameInput: React.FC {typeAhead.map((option, index) => ( - + ))} ); diff --git a/frontend/src/concepts/roleBinding/RoleBindingPermissionsTable.tsx b/frontend/src/concepts/roleBinding/RoleBindingPermissionsTable.tsx index a40c8d06aa..2e58c9b8be 100644 --- a/frontend/src/concepts/roleBinding/RoleBindingPermissionsTable.tsx +++ b/frontend/src/concepts/roleBinding/RoleBindingPermissionsTable.tsx @@ -16,6 +16,7 @@ type RoleBindingPermissionsTableProps = { roleRefKind: RoleBindingRoleRef['kind']; roleRefName?: RoleBindingRoleRef['name']; labels?: { [key: string]: string }; + isProjectSubject?: boolean; defaultRoleBindingName?: string; permissions: RoleBindingKind[]; permissionOptions: { @@ -40,12 +41,14 @@ const RoleBindingPermissionsTable: React.FC = permissions, permissionOptions, typeAhead, + isProjectSubject, isAdding, onDismissNewRow, onError, refresh, }) => { const [editCell, setEditCell] = React.useState([]); + return ( = { @@ -85,12 +89,15 @@ const RoleBindingPermissionsTable: React.FC = } rowRenderer={(rb) => ( { const newRBObject = generateRoleBindingPermissions( diff --git a/frontend/src/concepts/roleBinding/RoleBindingPermissionsTableRow.tsx b/frontend/src/concepts/roleBinding/RoleBindingPermissionsTableRow.tsx index 94f1515c07..01aad076bc 100644 --- a/frontend/src/concepts/roleBinding/RoleBindingPermissionsTableRow.tsx +++ b/frontend/src/concepts/roleBinding/RoleBindingPermissionsTableRow.tsx @@ -11,8 +11,10 @@ import { Tooltip, } from '@patternfly/react-core'; import { CheckIcon, OutlinedQuestionCircleIcon, TimesIcon } from '@patternfly/react-icons'; -import { RoleBindingKind, RoleBindingSubject } from '~/k8sTypes'; +import { ProjectKind, RoleBindingKind, RoleBindingSubject } from '~/k8sTypes'; import { relativeTime } from '~/utilities/time'; +import { ProjectsContext } from '~/concepts/projects/ProjectsContext'; +import { projectDisplayNameToNamespace } from '~/concepts/projects/utils'; import { castRoleBindingPermissionsRoleType, firstSubject, roleLabel } from './utils'; import { RoleBindingPermissionsRoleType } from './types'; import RoleBindingPermissionsNameInput from './RoleBindingPermissionsNameInput'; @@ -28,13 +30,18 @@ type RoleBindingPermissionsTableRowProps = { description: string; }[]; typeAhead?: string[]; + isProjectSubject?: boolean; onChange: (name: string, roleType: RoleBindingPermissionsRoleType) => void; onCancel: () => void; onEdit: () => void; onDelete: () => void; }; -const defaultValueName = (obj: RoleBindingKind) => firstSubject(obj); +const defaultValueName = ( + obj: RoleBindingKind, + isProjectSubject?: boolean, + projects?: ProjectKind[], +) => firstSubject(obj, isProjectSubject, projects); const defaultValueRole = (obj: RoleBindingKind) => castRoleBindingPermissionsRoleType(obj.roleRef.name); @@ -45,12 +52,16 @@ const RoleBindingPermissionsTableRow: React.FC { - const [roleBindingName, setRoleBindingName] = React.useState(defaultValueName(obj)); + const { projects } = React.useContext(ProjectsContext); + const [roleBindingName, setRoleBindingName] = React.useState( + defaultValueName(obj, isProjectSubject, projects), + ); const [roleBindingRoleRef, setRoleBindingRoleRef] = React.useState(defaultValueRole(obj)); const [isLoading, setIsLoading] = React.useState(false); @@ -69,8 +80,9 @@ const RoleBindingPermissionsTableRow: React.FC setRoleBindingName('')} - placeholderText={roleBindingName} + placeholderText={isProjectSubject ? 'Select or enter a project' : 'Select a group'} typeAhead={typeAhead} + isProjectSubject={isProjectSubject} /> ) : ( @@ -127,7 +139,15 @@ const RoleBindingPermissionsTableRow: React.FC { setIsLoading(true); - onChange(roleBindingName, roleBindingRoleRef); + onChange( + isProjectSubject + ? `system:serviceaccounts:${projectDisplayNameToNamespace( + roleBindingName, + projects, + )}` + : roleBindingName, + roleBindingRoleRef, + ); }} /> @@ -141,7 +161,11 @@ const RoleBindingPermissionsTableRow: React.FC { // TODO: Fix this // This is why you do not store a copy of state - setRoleBindingName(defaultValueName(obj)); + setRoleBindingName( + isProjectSubject + ? defaultValueName(obj, isProjectSubject, projects) + : defaultValueName(obj), + ); setRoleBindingRoleRef(defaultValueRole(obj)); onCancel(); }} diff --git a/frontend/src/concepts/roleBinding/RoleBindingPermissionsTableRowAdd.tsx b/frontend/src/concepts/roleBinding/RoleBindingPermissionsTableRowAdd.tsx index 2268022dc8..99e14380e0 100644 --- a/frontend/src/concepts/roleBinding/RoleBindingPermissionsTableRowAdd.tsx +++ b/frontend/src/concepts/roleBinding/RoleBindingPermissionsTableRowAdd.tsx @@ -3,6 +3,8 @@ import { Tbody, Td, Tr } from '@patternfly/react-table'; import { Button, Split, SplitItem, Text } from '@patternfly/react-core'; import { CheckIcon, TimesIcon } from '@patternfly/react-icons'; import { RoleBindingSubject } from '~/k8sTypes'; +import { projectDisplayNameToNamespace } from '~/concepts/projects/utils'; +import { ProjectsContext } from '~/concepts/projects/ProjectsContext'; import { RoleBindingPermissionsRoleType } from './types'; import RoleBindingPermissionsNameInput from './RoleBindingPermissionsNameInput'; import RoleBindingPermissionsPermissionSelection from './RoleBindingPermissionsPermissionSelection'; @@ -11,6 +13,7 @@ import { roleLabel } from './utils'; type RoleBindingPermissionsTableRowPropsAdd = { typeAhead?: string[]; subjectKind: RoleBindingSubject['kind']; + isProjectSubject?: boolean; permissionOptions: { type: RoleBindingPermissionsRoleType; description: string; @@ -24,9 +27,11 @@ const RoleBindingPermissionsTableRowAdd: React.FC { + const { projects } = React.useContext(ProjectsContext); const [roleBindingName, setRoleBindingName] = React.useState(''); const [roleBindingRoleRef, setRoleBindingRoleRef] = React.useState(permissionOptions[0]?.type); @@ -43,8 +48,9 @@ const RoleBindingPermissionsTableRowAdd: React.FC setRoleBindingName('')} - placeholderText={roleBindingName} + placeholderText={isProjectSubject ? 'Select or enter a project' : 'Select a group'} typeAhead={typeAhead} + isProjectSubject={isProjectSubject} />
@@ -73,7 +79,15 @@ const RoleBindingPermissionsTableRowAdd: React.FC { setIsLoading(true); - onChange(roleBindingName, roleBindingRoleRef); + onChange( + isProjectSubject + ? `system:serviceaccounts:${projectDisplayNameToNamespace( + roleBindingName, + projects, + )}` + : roleBindingName, + roleBindingRoleRef, + ); }} /> diff --git a/frontend/src/concepts/roleBinding/RoleBindingPermissionsTableSection.tsx b/frontend/src/concepts/roleBinding/RoleBindingPermissionsTableSection.tsx index a79143ba52..269229df5d 100644 --- a/frontend/src/concepts/roleBinding/RoleBindingPermissionsTableSection.tsx +++ b/frontend/src/concepts/roleBinding/RoleBindingPermissionsTableSection.tsx @@ -30,9 +30,10 @@ export type RoleBindingPermissionsTableSectionAltProps = { }[]; typeAhead?: string[]; refresh: () => void; - typeModifier?: string; + typeModifier: string; defaultRoleBindingName?: string; labels?: { [key: string]: string }; + isProjectSubject?: boolean; }; const RoleBindingPermissionsTableSection: React.FC = ({ @@ -48,6 +49,7 @@ const RoleBindingPermissionsTableSection: React.FC { const [addField, setAddField] = React.useState(false); const [error, setError] = React.useState(undefined); @@ -63,14 +65,20 @@ const RoleBindingPermissionsTableSection: React.FC - - {subjectKind === RoleBindingPermissionsRBType.USER ? 'Users' : 'Groups'} + <Title id={`user-permission-${typeModifier}`} headingLevel="h2" size="xl"> + {isProjectSubject + ? 'Projects' + : subjectKind === RoleBindingPermissionsRBType.USER + ? 'Users' + : 'Groups'} @@ -84,6 +92,7 @@ const RoleBindingPermissionsTableSection: React.FC diff --git a/frontend/src/concepts/roleBinding/utils.ts b/frontend/src/concepts/roleBinding/utils.ts index 8b3b949d18..b7ab3a0e9f 100644 --- a/frontend/src/concepts/roleBinding/utils.ts +++ b/frontend/src/concepts/roleBinding/utils.ts @@ -1,11 +1,20 @@ import { capitalize } from '@patternfly/react-core'; -import { RoleBindingKind } from '~/k8sTypes'; +import { ProjectKind, RoleBindingKind } from '~/k8sTypes'; +import { namespaceToProjectDisplayName } from '~/concepts/projects/utils'; import { RoleBindingPermissionsRBType, RoleBindingPermissionsRoleType } from './types'; export const filterRoleBindingSubjects = ( roleBindings: RoleBindingKind[], type: RoleBindingPermissionsRBType, -): RoleBindingKind[] => roleBindings.filter((roles) => roles.subjects[0]?.kind === type); + isProjectSubject?: boolean, +): RoleBindingKind[] => + roleBindings.filter( + (roles) => + roles.subjects[0]?.kind === type && + (isProjectSubject + ? roles.metadata.labels?.['opendatahub.io/rb-project-subject'] === 'true' + : !(roles.metadata.labels?.['opendatahub.io/rb-project-subject'] === 'true')), + ); export const castRoleBindingPermissionsRoleType = ( role: string, @@ -19,8 +28,17 @@ export const castRoleBindingPermissionsRoleType = ( return RoleBindingPermissionsRoleType.DEFAULT; }; -export const firstSubject = (roleBinding: RoleBindingKind): string => - roleBinding.subjects[0]?.name || ''; +export const firstSubject = ( + roleBinding: RoleBindingKind, + isProjectSubject?: boolean, + project?: ProjectKind[], +): string => + (isProjectSubject && project + ? namespaceToProjectDisplayName( + roleBinding.subjects[0]?.name.replace(/^system:serviceaccounts:/, ''), + project, + ) + : roleBinding.subjects[0]?.name) || ''; export const roleLabel = (value: RoleBindingPermissionsRoleType): string => { if (value === RoleBindingPermissionsRoleType.EDIT) { @@ -28,3 +46,6 @@ export const roleLabel = (value: RoleBindingPermissionsRoleType): string => { } return capitalize(value); }; + +export const removePrefix = (roleBindings: RoleBindingKind[]): string[] => + roleBindings.map((rb) => rb.subjects[0]?.name.replace(/^system:serviceaccounts:/, '')); diff --git a/frontend/src/k8sTypes.ts b/frontend/src/k8sTypes.ts index 28847a5cc1..503bc3756d 100644 --- a/frontend/src/k8sTypes.ts +++ b/frontend/src/k8sTypes.ts @@ -23,6 +23,7 @@ export enum KnownLabels { MODEL_SERVING_PROJECT = 'modelmesh-enabled', DATA_CONNECTION_AWS = 'opendatahub.io/managed', LABEL_SELECTOR_MODEL_REGISTRY = 'component=model-registry', + PROJECT_SUBJECT = 'opendatahub.io/rb-project-subject', } export type K8sVerb = diff --git a/frontend/src/pages/modelRegistrySettings/ModelRegistriesPermissions.tsx b/frontend/src/pages/modelRegistrySettings/ModelRegistriesPermissions.tsx index 590f4cee68..3680fb3224 100644 --- a/frontend/src/pages/modelRegistrySettings/ModelRegistriesPermissions.tsx +++ b/frontend/src/pages/modelRegistrySettings/ModelRegistriesPermissions.tsx @@ -20,6 +20,7 @@ import { SupportedArea } from '~/concepts/areas'; import { RoleBindingPermissionsRoleType } from '~/concepts/roleBinding/types'; import { useModelRegistryNamespaceCR } from '~/concepts/modelRegistry/context/useModelRegistryNamespaceCR'; import useModelRegistryRoleBindings from './useModelRegistryRoleBindings'; +import ProjectsSettingsTab from './ProjectsTab/ProjectsSettingsTab'; const ModelRegistriesManagePermissions: React.FC = () => { const [activeTabKey, setActiveTabKey] = React.useState('users'); @@ -51,7 +52,7 @@ const ModelRegistriesManagePermissions: React.FC = () => { return ( Settings @@ -76,11 +77,17 @@ const ModelRegistriesManagePermissions: React.FC = () => { eventKey="projects" title="Projects" id="projects-tab" + data-testid="projects-tab" tabContentId="projects-tab-content" />
-
diff --git a/frontend/src/pages/modelRegistrySettings/ProjectsTab/ProjectsSettingsTab.tsx b/frontend/src/pages/modelRegistrySettings/ProjectsTab/ProjectsSettingsTab.tsx new file mode 100644 index 0000000000..b232866247 --- /dev/null +++ b/frontend/src/pages/modelRegistrySettings/ProjectsTab/ProjectsSettingsTab.tsx @@ -0,0 +1,125 @@ +import { K8sResourceCommon } from '@openshift/dynamic-plugin-sdk-utils'; +import { + EmptyState, + EmptyStateBody, + EmptyStateHeader, + EmptyStateIcon, + EmptyStateVariant, + PageSection, + Spinner, + Stack, + StackItem, +} from '@patternfly/react-core'; +import { ExclamationCircleIcon } from '@patternfly/react-icons'; +import React from 'react'; +import { + RoleBindingPermissionsRBType, + RoleBindingPermissionsRoleType, +} from '~/concepts/roleBinding/types'; +import { filterRoleBindingSubjects, removePrefix } from '~/concepts/roleBinding/utils'; +import { RoleBindingKind, RoleBindingRoleRef } from '~/k8sTypes'; +import { ContextResourceData } from '~/types'; +import { ProjectsContext } from '~/concepts/projects/ProjectsContext'; +import { ProjectSectionID } from '~/pages/projects/screens/detail/types'; +import RoleBindingPermissionsTableSection from '~/concepts/roleBinding/RoleBindingPermissionsTableSection'; + +type RoleBindingProjectPermissionsProps = { + ownerReference?: K8sResourceCommon; + roleBindingPermissionsRB: ContextResourceData; + permissionOptions: { + type: RoleBindingPermissionsRoleType; + description: string; + }[]; + projectName: string; + roleRefName?: RoleBindingRoleRef['name']; + labels?: { [key: string]: string }; + isProjectSubject?: boolean; + description: string; +}; + +const ProjectsSettingsTab: React.FC = ({ + ownerReference, + roleBindingPermissionsRB, + permissionOptions, + projectName, + roleRefName, + labels, + isProjectSubject, + description, +}) => { + const { + data: roleBindings, + loaded, + error: loadError, + refresh: refreshRB, + } = roleBindingPermissionsRB; + + const { projects } = React.useContext(ProjectsContext); + const filteredProjects = projects.filter( + (project) => !removePrefix(roleBindings).includes(project.metadata.name), + ); + + if (loadError) { + return ( + + } + headingLevel="h2" + /> + {loadError.message} + + ); + } + + if (!loaded) { + return ( + + + + + ); + } + + return ( + + + {description} + + 0 + ? filteredProjects.map((project) => project.metadata.name) + : undefined + } + refresh={refreshRB} + typeModifier="project" + isProjectSubject={isProjectSubject} + /> + + + + ); +}; + +export default ProjectsSettingsTab;