diff --git a/frontend/src/__mocks__/mockRoleBindingK8sResource.ts b/frontend/src/__mocks__/mockRoleBindingK8sResource.ts index b67c63a4a7..a98da66fd7 100644 --- a/frontend/src/__mocks__/mockRoleBindingK8sResource.ts +++ b/frontend/src/__mocks__/mockRoleBindingK8sResource.ts @@ -8,6 +8,7 @@ type MockResourceConfigType = { subjects?: RoleBindingSubject[]; roleRefName?: string; uid?: string; + modelRegistryName?: string; }; export const mockRoleBindingK8sResource = ({ @@ -22,22 +23,38 @@ export const mockRoleBindingK8sResource = ({ ], roleRefName = 'view', uid = genUID('rolebinding'), -}: MockResourceConfigType): RoleBindingKind => ({ - kind: 'RoleBinding', - apiVersion: 'rbac.authorization.k8s.io/v1', - metadata: { - name, - namespace, - uid, - creationTimestamp: '2023-02-14T21:43:59Z', - labels: { + modelRegistryName = '', +}: MockResourceConfigType): RoleBindingKind => { + let labels; + if (modelRegistryName) { + labels = { + 'app.kubernetes.io/name': modelRegistryName, + app: modelRegistryName, + 'app.kubernetes.io/component': 'model-registry', + 'app.kubernetes.io/part-of': 'model-registry', [KnownLabels.DASHBOARD_RESOURCE]: 'true', + component: 'model-registry', + }; + } else { + labels = { + [KnownLabels.DASHBOARD_RESOURCE]: 'true', + }; + } + return { + kind: 'RoleBinding', + apiVersion: 'rbac.authorization.k8s.io/v1', + metadata: { + name, + namespace, + uid, + creationTimestamp: '2023-02-14T21:43:59Z', + labels, + }, + subjects, + roleRef: { + apiGroup: 'rbac.authorization.k8s.io', + kind: 'ClusterRole', + name: roleRefName, }, - }, - subjects, - roleRef: { - apiGroup: 'rbac.authorization.k8s.io', - kind: 'ClusterRole', - name: roleRefName, - }, -}); + }; +}; diff --git a/frontend/src/__tests__/cypress/cypress/pages/components/table.ts b/frontend/src/__tests__/cypress/cypress/pages/components/table.ts index 4fbc7d0aa1..7915e8d252 100644 --- a/frontend/src/__tests__/cypress/cypress/pages/components/table.ts +++ b/frontend/src/__tests__/cypress/cypress/pages/components/table.ts @@ -19,4 +19,8 @@ export class TableRow extends Contextual { findKebabAction(name: string): Cypress.Chainable> { return this.find().findKebabAction(name); } + + findKebab(): Cypress.Chainable> { + return this.find().findKebab(); + } } diff --git a/frontend/src/__tests__/cypress/cypress/pages/modelRegistryPermissions.ts b/frontend/src/__tests__/cypress/cypress/pages/modelRegistryPermissions.ts new file mode 100644 index 0000000000..c93356ce2e --- /dev/null +++ b/frontend/src/__tests__/cypress/cypress/pages/modelRegistryPermissions.ts @@ -0,0 +1,72 @@ +import { Contextual } from './components/Contextual'; +import { TableRow } from './components/table'; + +class PermissionsTableRow extends TableRow {} + +class UsersTab { + visit(mrName: string, wait = true) { + cy.visitWithLogin(`/modelRegistrySettings/permissions/${mrName}`); + if (wait) { + this.wait(); + } + } + + private wait() { + cy.findByTestId('app-page-title'); + cy.testA11y(); + } + + findAddUserButton() { + return cy.findByTestId('add-button User'); + } + + findAddGroupButton() { + return cy.findByTestId('add-button Group'); + } + + getUserTable() { + return new PermissionTable(() => cy.findByTestId('role-binding-table User')); + } + + getGroupTable() { + return new PermissionTable(() => cy.findByTestId('role-binding-table Group')); + } +} + +class PermissionTable extends Contextual { + findRows() { + return this.find().find(`[data-label=Username]`); + } + + findAddInput() { + return this.find().findByTestId('role-binding-name-input'); + } + + findEditInput(id: string) { + return this.find().findByTestId(['role-binding-name-input', id]); + } + + findGroupSelect() { + return this.find().get(`[aria-label="Name selection"]`); + } + + getTableRow(name: string) { + return new PermissionsTableRow(() => + this.find().find(`[data-label=Username]`).contains(name).parents('tr'), + ); + } + + findTableHeaderButton(name: string) { + return this.find().find('thead').findByRole('button', { name }); + } + + findSaveNewButton() { + return this.find().findByTestId(['save-new-button']); + } + + findEditSaveButton(id: string) { + return this.find().findByTestId(['save-button', id]); + } +} + +export const usersTab = new UsersTab(); diff --git a/frontend/src/__tests__/cypress/cypress/pages/permissions.ts b/frontend/src/__tests__/cypress/cypress/pages/permissions.ts index b52cea50fe..c77aede5ff 100644 --- a/frontend/src/__tests__/cypress/cypress/pages/permissions.ts +++ b/frontend/src/__tests__/cypress/cypress/pages/permissions.ts @@ -23,11 +23,11 @@ class PermissionsTab { } getUserTable() { - return new PermissionTable(() => cy.findByTestId('project-sharing-table User')); + return new PermissionTable(() => cy.findByTestId('role-binding-table User')); } getGroupTable() { - return new PermissionTable(() => cy.findByTestId('project-sharing-table Group')); + return new PermissionTable(() => cy.findByTestId('role-binding-table Group')); } } @@ -37,11 +37,11 @@ class PermissionTable extends Contextual { } findAddInput() { - return this.find().findByTestId('project-sharing-name-input'); + return this.find().findByTestId('role-binding-name-input'); } findEditInput(id: string) { - return this.find().findByTestId(['project-sharing-name-input', id]); + return this.find().findByTestId(['role-binding-name-input', id]); } getTableRow(name: string) { @@ -56,7 +56,7 @@ class PermissionTable extends Contextual { selectPermission(id: string, name: string) { return this.find() - .findByTestId(['project-sharing-name-input', id]) + .findByTestId(['role-binding-name-input', id]) .parents('tr') .findByRole('button', { name: 'Options menu' }) .findSelectOption(name) 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 new file mode 100644 index 0000000000..77a4dc0a31 --- /dev/null +++ b/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistrySettings/modelRegistryPermissions.cy.ts @@ -0,0 +1,391 @@ +import { mockK8sResourceList } from '~/__mocks__'; +import { mock200Status } from '~/__mocks__/mockK8sStatus'; +import { mockRoleBindingK8sResource } from '~/__mocks__/mockRoleBindingK8sResource'; +import { be } from '~/__tests__/cypress/cypress/utils/should'; +import { + GroupModel, + ModelRegistryModel, + RoleBindingModel, +} from '~/__tests__/cypress/cypress/utils/models'; +import { RoleBindingSubject } from '~/types'; +import { asProductAdminUser, asProjectEditUser } from '~/__tests__/cypress/cypress/utils/users'; +import { MODEL_REGISTRY_DEFAULT_NAMESPACE } from '~/concepts/modelRegistry/const'; +import { mockModelRegistry } from '~/__mocks__/mockModelRegistry'; +import { mockGroup } from '~/__mocks__/mockGroup'; +import { usersTab } from '~/__tests__/cypress/cypress/pages/modelRegistryPermissions'; + +const userSubjects: RoleBindingSubject[] = [ + { + kind: 'User', + apiGroup: 'rbac.authorization.k8s.io', + name: 'example-mr-user', + }, +]; + +const groupSubjects: RoleBindingSubject[] = [ + { + kind: 'Group', + apiGroup: 'rbac.authorization.k8s.io', + name: 'example-mr-users', + }, +]; + +type HandlersProps = { + isEmpty?: boolean; + hasPermission?: boolean; +}; + +const initIntercepts = ({ isEmpty = false, hasPermission = true }: HandlersProps) => { + if (!hasPermission) { + asProjectEditUser(); + } else { + asProductAdminUser(); + } + cy.interceptK8sList( + ModelRegistryModel, + mockK8sResourceList([ + mockModelRegistry({ name: 'example-mr', namespace: MODEL_REGISTRY_DEFAULT_NAMESPACE }), + ]), + ); + cy.interceptK8sList( + { model: GroupModel }, + mockK8sResourceList([mockGroup({ name: 'example-mr-group-option' })]), + ); + cy.interceptK8sList( + { model: RoleBindingModel, ns: MODEL_REGISTRY_DEFAULT_NAMESPACE }, + mockK8sResourceList( + isEmpty + ? [] + : [ + mockRoleBindingK8sResource({ + namespace: MODEL_REGISTRY_DEFAULT_NAMESPACE, + name: 'example-mr-user', + subjects: userSubjects, + roleRefName: 'registry-user-example-mr', + modelRegistryName: 'example-mr', + }), + mockRoleBindingK8sResource({ + namespace: MODEL_REGISTRY_DEFAULT_NAMESPACE, + name: 'example-mr-user-2', + subjects: [{ ...userSubjects[0], name: 'example-mr-user-2' }], + roleRefName: 'registry-user-example-mr', + modelRegistryName: 'example-mr', + }), + mockRoleBindingK8sResource({ + namespace: MODEL_REGISTRY_DEFAULT_NAMESPACE, + name: 'example-mr-users', + subjects: groupSubjects, + roleRefName: 'registry-user-example-mr', + modelRegistryName: 'example-mr', + }), + mockRoleBindingK8sResource({ + namespace: MODEL_REGISTRY_DEFAULT_NAMESPACE, + name: 'example-mr-users-2', + subjects: [{ ...groupSubjects[0], name: 'example-mr-users-2' }], + roleRefName: 'registry-user-example-mr', + modelRegistryName: 'example-mr', + }), + ], + ), + ); +}; + +describe('MR Permissions', () => { + const userTable = usersTab.getUserTable(); + const groupTable = usersTab.getGroupTable(); + + it('should not be accessible for non-project admins', () => { + initIntercepts({ isEmpty: false, hasPermission: false }); + usersTab.visit('example-mr', false); + cy.findByTestId('not-found-page').should('exist'); + }); + + it('Empty table for groups and users', () => { + initIntercepts({ isEmpty: true }); + usersTab.visit('example-mr'); + cy.url().should('include', '/modelRegistrySettings/permissions/example-mr'); + + //User table + userTable.findRows().should('have.length', 0); + usersTab.findAddUserButton().should('be.enabled'); + + //Group table + groupTable.findRows().should('have.length', 0); + usersTab.findAddGroupButton().should('be.enabled'); + }); + + describe('Users table', () => { + it('Table sorting for users table', () => { + initIntercepts({ isEmpty: false }); + usersTab.visit('example-mr'); + userTable.findRows().should('have.length', 2); + + // by name + userTable.findTableHeaderButton('Name').click(); + userTable.findTableHeaderButton('Name').should(be.sortDescending); + userTable.findTableHeaderButton('Name').click(); + userTable.findTableHeaderButton('Name').should(be.sortAscending); + + //by date added + userTable.findTableHeaderButton('Date added').click(); + userTable.findTableHeaderButton('Date added').should(be.sortAscending); + userTable.findTableHeaderButton('Date added').click(); + userTable.findTableHeaderButton('Date added').should(be.sortDescending); + }); + + it('Add user', () => { + initIntercepts({ isEmpty: true }); + cy.interceptK8s( + 'POST', + RoleBindingModel, + mockRoleBindingK8sResource({ + namespace: MODEL_REGISTRY_DEFAULT_NAMESPACE, + name: 'example-mr-user', + subjects: userSubjects, + roleRefName: 'registry-user-example-mr', + modelRegistryName: 'example-mr', + }), + ).as('addUser'); + usersTab.visit('example-mr'); + + usersTab.findAddUserButton().click(); + + userTable.findAddInput().fill('example-mr-user'); + userTable.findSaveNewButton().click(); + + cy.wait('@addUser').then((interception) => { + expect(interception.request.body).to.containSubset({ + metadata: { + labels: { + app: 'example-mr', + 'app.kubernetes.io/component': 'model-registry', + 'app.kubernetes.io/part-of': 'model-registry', + 'app.kubernetes.io/name': 'example-mr', + 'opendatahub.io/dashboard': 'true', + component: 'model-registry', + }, + }, + roleRef: { + apiGroup: 'rbac.authorization.k8s.io', + kind: 'ClusterRole', + name: 'registry-user-example-mr', + }, + subjects: [ + { apiGroup: 'rbac.authorization.k8s.io', kind: 'User', name: 'example-mr-user' }, + ], + }); + }); + }); + + it('Edit user', () => { + initIntercepts({ isEmpty: false }); + cy.interceptK8s( + 'POST', + RoleBindingModel, + mockRoleBindingK8sResource({ + namespace: MODEL_REGISTRY_DEFAULT_NAMESPACE, + name: 'edited-user', + subjects: [{ ...userSubjects[0], name: 'edited-user' }], + roleRefName: 'registry-user-example-mr', + modelRegistryName: 'example-mr', + }), + ).as('editUser'); + cy.interceptK8s( + 'DELETE', + { model: RoleBindingModel, ns: MODEL_REGISTRY_DEFAULT_NAMESPACE, name: 'example-mr-user' }, + mock200Status({}), + ).as('deleteUser'); + + usersTab.visit('example-mr'); + + userTable.getTableRow('example-mr-user').findKebabAction('Edit').click(); + userTable.findEditInput('example-mr-user').clear().type('edited-user'); + userTable.findEditSaveButton('edited-user').click(); + + cy.wait('@editUser').then((interception) => { + expect(interception.request.body).to.containSubset({ + metadata: { + labels: { + app: 'example-mr', + 'app.kubernetes.io/component': 'model-registry', + 'app.kubernetes.io/part-of': 'model-registry', + 'app.kubernetes.io/name': 'example-mr', + 'opendatahub.io/dashboard': 'true', + component: 'model-registry', + }, + }, + roleRef: { + apiGroup: 'rbac.authorization.k8s.io', + kind: 'ClusterRole', + name: 'registry-user-example-mr', + }, + subjects: [{ apiGroup: 'rbac.authorization.k8s.io', kind: 'User', name: 'edited-user' }], + }); + }); + cy.wait('@deleteUser'); + }); + + it('Delete user', () => { + initIntercepts({ isEmpty: false }); + + cy.interceptK8s( + 'DELETE', + { model: RoleBindingModel, ns: MODEL_REGISTRY_DEFAULT_NAMESPACE, name: 'example-mr-user' }, + mock200Status({}), + ).as('deleteUser'); + usersTab.visit('example-mr'); + + userTable.getTableRow('example-mr-user').findKebabAction('Delete').click(); + + cy.wait('@deleteUser'); + }); + }); + + describe('Groups table', () => { + it('Table sorting for groups table', () => { + initIntercepts({ isEmpty: false }); + + usersTab.visit('example-mr'); + + groupTable.findTableHeaderButton('Name').click(); + groupTable.findTableHeaderButton('Name').should(be.sortDescending); + groupTable.findTableHeaderButton('Name').click(); + groupTable.findTableHeaderButton('Name').should(be.sortAscending); + + groupTable.findTableHeaderButton('Date added').click(); + groupTable.findTableHeaderButton('Date added').should(be.sortAscending); + groupTable.findTableHeaderButton('Date added').click(); + groupTable.findTableHeaderButton('Date added').should(be.sortDescending); + }); + + it('Add group', () => { + initIntercepts({ isEmpty: true }); + cy.interceptK8s( + 'POST', + RoleBindingModel, + mockRoleBindingK8sResource({ + namespace: MODEL_REGISTRY_DEFAULT_NAMESPACE, + name: 'example-mr-user', + subjects: groupSubjects, + roleRefName: 'registry-user-example-mr', + modelRegistryName: 'example-mr', + }), + ).as('addGroup'); + usersTab.visit('example-mr'); + + usersTab.findAddGroupButton().click(); + + groupTable.findGroupSelect().fill('example-mr-users'); + cy.findByText('Create "example-mr-users"').click(); + groupTable.findSaveNewButton().click(); + + cy.wait('@addGroup').then((interception) => { + expect(interception.request.body).to.containSubset({ + metadata: { + labels: { + app: 'example-mr', + 'app.kubernetes.io/component': 'model-registry', + 'app.kubernetes.io/part-of': 'model-registry', + 'app.kubernetes.io/name': 'example-mr', + 'opendatahub.io/dashboard': 'true', + component: 'model-registry', + }, + }, + roleRef: { + apiGroup: 'rbac.authorization.k8s.io', + kind: 'ClusterRole', + name: 'registry-user-example-mr', + }, + subjects: [ + { apiGroup: 'rbac.authorization.k8s.io', kind: 'Group', name: 'example-mr-users' }, + ], + }); + }); + }); + + it('Edit group', () => { + initIntercepts({ isEmpty: false }); + cy.interceptK8s( + 'POST', + RoleBindingModel, + mockRoleBindingK8sResource({ + namespace: MODEL_REGISTRY_DEFAULT_NAMESPACE, + name: 'example-mr-group-option', + subjects: [{ ...groupSubjects[0], name: 'example-mr-group-option' }], + roleRefName: 'registry-user-example-mr', + modelRegistryName: 'example-mr', + }), + ).as('editGroup'); + cy.interceptK8s( + 'DELETE', + { + model: RoleBindingModel, + ns: MODEL_REGISTRY_DEFAULT_NAMESPACE, + name: 'example-mr-users-2', + }, + mock200Status({}), + ).as('deleteGroup'); + + usersTab.visit('example-mr'); + + groupTable.getTableRow('example-mr-users-2').findKebabAction('Edit').click(); + groupTable.findGroupSelect().clear().type('example-mr-group-opti'); + cy.findByText('example-mr-group-option').click(); + groupTable.findEditSaveButton('example-mr-group-option').click(); + + cy.wait('@editGroup').then((interception) => { + expect(interception.request.body).to.containSubset({ + metadata: { + labels: { + 'opendatahub.io/dashboard': 'true', + app: 'example-mr', + 'app.kubernetes.io/component': 'model-registry', + 'app.kubernetes.io/part-of': 'model-registry', + 'app.kubernetes.io/name': 'example-mr', + component: 'model-registry', + }, + }, + roleRef: { + apiGroup: 'rbac.authorization.k8s.io', + kind: 'ClusterRole', + name: 'registry-user-example-mr', + }, + subjects: [ + { + apiGroup: 'rbac.authorization.k8s.io', + kind: 'Group', + name: 'example-mr-group-option', + }, + ], + }); + }); + cy.wait('@deleteGroup'); + }); + + it('Delete group', () => { + initIntercepts({ isEmpty: false }); + + cy.interceptK8s( + 'DELETE', + { + model: RoleBindingModel, + ns: MODEL_REGISTRY_DEFAULT_NAMESPACE, + name: 'example-mr-users-2', + }, + mock200Status({}), + ).as('deleteGroup'); + + usersTab.visit('example-mr'); + groupTable.getTableRow('example-mr-users-2').findKebabAction('Delete').click(); + + cy.wait('@deleteGroup'); + }); + + it('Disabled actions on default group', () => { + initIntercepts({ isEmpty: false }); + usersTab.visit('example-mr'); + groupTable.getTableRow('example-mr-users').findKebab().should('be.disabled'); + groupTable.getTableRow('example-mr-users-2').findKebab().should('not.be.disabled'); + }); + }); +}); diff --git a/frontend/src/api/k8s/__tests__/roleBindings.spec.ts b/frontend/src/api/k8s/__tests__/roleBindings.spec.ts index 7d79ff4f77..6d4d9ce3d3 100644 --- a/frontend/src/api/k8s/__tests__/roleBindings.spec.ts +++ b/frontend/src/api/k8s/__tests__/roleBindings.spec.ts @@ -10,20 +10,20 @@ import { mockRoleBindingK8sResource } from '~/__mocks__/mockRoleBindingK8sResour import { mockK8sResourceList } from '~/__mocks__/mockK8sResourceList'; import { mock200Status, mock404Error } from '~/__mocks__/mockK8sStatus'; import { KnownLabels, RoleBindingKind, RoleBindingSubject } from '~/k8sTypes'; -import { - ProjectSharingRBType, - ProjectSharingRoleType, -} from '~/pages/projects/projectSharing/types'; import { createRoleBinding, deleteRoleBinding, - generateRoleBindingProjectSharing, + generateRoleBindingPermissions, generateRoleBindingServingRuntime, getRoleBinding, listRoleBindings, patchRoleBindingOwnerRef, } from '~/api/k8s/roleBindings'; import { RoleBindingModel } from '~/api/models/k8s'; +import { + RoleBindingPermissionsRBType, + RoleBindingPermissionsRoleType, +} from '~/concepts/roleBinding/types'; jest.mock('@openshift/dynamic-plugin-sdk-utils', () => ({ k8sListResource: jest.fn(), @@ -84,30 +84,30 @@ describe('generateRoleBindingServingRuntime', () => { }); }); -describe('generateRoleBindingProjectSharing', () => { +describe('generateRoleBindingPermissions', () => { it('should generate project sharing role binding when RB type is USER and role type is EDIT', () => { - const result = generateRoleBindingProjectSharing( + const result = generateRoleBindingPermissions( namespace, - ProjectSharingRBType.USER, + RoleBindingPermissionsRBType.USER, 'rbSubjectName', - ProjectSharingRoleType.EDIT, + RoleBindingPermissionsRoleType.EDIT, ); const subjects = [ { apiGroup: 'rbac.authorization.k8s.io', - kind: ProjectSharingRBType.USER, + kind: RoleBindingPermissionsRBType.USER, name: 'rbSubjectName', }, ]; - createRoleBindingObject(ProjectSharingRoleType.EDIT, subjects); + createRoleBindingObject(RoleBindingPermissionsRoleType.EDIT, subjects); expect(result.apiVersion).toStrictEqual( - createRoleBindingObject(ProjectSharingRoleType.EDIT, subjects).apiVersion, + createRoleBindingObject(RoleBindingPermissionsRoleType.EDIT, subjects).apiVersion, ); expect(result.subjects).toStrictEqual( - createRoleBindingObject(ProjectSharingRoleType.EDIT, subjects).subjects, + createRoleBindingObject(RoleBindingPermissionsRoleType.EDIT, subjects).subjects, ); expect(result.roleRef).toStrictEqual( - createRoleBindingObject(ProjectSharingRoleType.EDIT, subjects).roleRef, + createRoleBindingObject(RoleBindingPermissionsRoleType.EDIT, subjects).roleRef, ); expect(result.metadata.name).toMatch(/^dashboard-permissions-[a-zA-Z0-9]+$/); expect(result.metadata.labels).toStrictEqual({ @@ -117,28 +117,28 @@ describe('generateRoleBindingProjectSharing', () => { }); it('should generate project sharing role binding when RB type is USER and role type is ADMIN', () => { - const result = generateRoleBindingProjectSharing( + const result = generateRoleBindingPermissions( namespace, - ProjectSharingRBType.USER, + RoleBindingPermissionsRBType.USER, 'rbSubjectName', - ProjectSharingRoleType.ADMIN, + RoleBindingPermissionsRoleType.ADMIN, ); const subjects = [ { apiGroup: 'rbac.authorization.k8s.io', - kind: ProjectSharingRBType.USER, + kind: RoleBindingPermissionsRBType.USER, name: 'rbSubjectName', }, ]; - createRoleBindingObject(ProjectSharingRoleType.ADMIN, subjects); + createRoleBindingObject(RoleBindingPermissionsRoleType.ADMIN, subjects); expect(result.apiVersion).toStrictEqual( - createRoleBindingObject(ProjectSharingRoleType.ADMIN, subjects).apiVersion, + createRoleBindingObject(RoleBindingPermissionsRoleType.ADMIN, subjects).apiVersion, ); expect(result.subjects).toStrictEqual( - createRoleBindingObject(ProjectSharingRoleType.ADMIN, subjects).subjects, + createRoleBindingObject(RoleBindingPermissionsRoleType.ADMIN, subjects).subjects, ); expect(result.roleRef).toStrictEqual( - createRoleBindingObject(ProjectSharingRoleType.ADMIN, subjects).roleRef, + createRoleBindingObject(RoleBindingPermissionsRoleType.ADMIN, subjects).roleRef, ); expect(result.metadata.name).toMatch(/^dashboard-permissions-[a-zA-Z0-9]+$/); expect(result.metadata.labels).toStrictEqual({ @@ -148,28 +148,28 @@ describe('generateRoleBindingProjectSharing', () => { }); it('should generate project sharing role binding when RB type is GROUP and role type is EDIT', () => { - const result = generateRoleBindingProjectSharing( + const result = generateRoleBindingPermissions( namespace, - ProjectSharingRBType.GROUP, + RoleBindingPermissionsRBType.GROUP, 'rbSubjectName', - ProjectSharingRoleType.EDIT, + RoleBindingPermissionsRoleType.EDIT, ); const subjects = [ { apiGroup: 'rbac.authorization.k8s.io', - kind: ProjectSharingRBType.GROUP, + kind: RoleBindingPermissionsRBType.GROUP, name: 'rbSubjectName', }, ]; - createRoleBindingObject(ProjectSharingRoleType.EDIT, subjects); + createRoleBindingObject(RoleBindingPermissionsRoleType.EDIT, subjects); expect(result.apiVersion).toStrictEqual( - createRoleBindingObject(ProjectSharingRoleType.EDIT, subjects).apiVersion, + createRoleBindingObject(RoleBindingPermissionsRoleType.EDIT, subjects).apiVersion, ); expect(result.subjects).toStrictEqual( - createRoleBindingObject(ProjectSharingRoleType.EDIT, subjects).subjects, + createRoleBindingObject(RoleBindingPermissionsRoleType.EDIT, subjects).subjects, ); expect(result.roleRef).toStrictEqual( - createRoleBindingObject(ProjectSharingRoleType.EDIT, subjects).roleRef, + createRoleBindingObject(RoleBindingPermissionsRoleType.EDIT, subjects).roleRef, ); expect(result.metadata.name).toMatch(/^dashboard-permissions-[a-zA-Z0-9]+$/); expect(result.metadata.labels).toStrictEqual({ @@ -179,28 +179,28 @@ describe('generateRoleBindingProjectSharing', () => { }); it('should generate project sharing role binding when RB type is GROUP and role type is ADMIN', () => { - const result = generateRoleBindingProjectSharing( + const result = generateRoleBindingPermissions( namespace, - ProjectSharingRBType.GROUP, + RoleBindingPermissionsRBType.GROUP, 'rbSubjectName', - ProjectSharingRoleType.ADMIN, + RoleBindingPermissionsRoleType.ADMIN, ); const subjects = [ { apiGroup: 'rbac.authorization.k8s.io', - kind: ProjectSharingRBType.GROUP, + kind: RoleBindingPermissionsRBType.GROUP, name: 'rbSubjectName', }, ]; - createRoleBindingObject(ProjectSharingRoleType.ADMIN, subjects); + createRoleBindingObject(RoleBindingPermissionsRoleType.ADMIN, subjects); expect(result.apiVersion).toStrictEqual( - createRoleBindingObject(ProjectSharingRoleType.ADMIN, subjects).apiVersion, + createRoleBindingObject(RoleBindingPermissionsRoleType.ADMIN, subjects).apiVersion, ); expect(result.subjects).toStrictEqual( - createRoleBindingObject(ProjectSharingRoleType.ADMIN, subjects).subjects, + createRoleBindingObject(RoleBindingPermissionsRoleType.ADMIN, subjects).subjects, ); expect(result.roleRef).toStrictEqual( - createRoleBindingObject(ProjectSharingRoleType.ADMIN, subjects).roleRef, + createRoleBindingObject(RoleBindingPermissionsRoleType.ADMIN, subjects).roleRef, ); expect(result.metadata.name).toMatch(/^dashboard-permissions-[a-zA-Z0-9]+$/); expect(result.metadata.labels).toStrictEqual({ diff --git a/frontend/src/api/k8s/roleBindings.ts b/frontend/src/api/k8s/roleBindings.ts index d1ff629b08..fa712121a8 100644 --- a/frontend/src/api/k8s/roleBindings.ts +++ b/frontend/src/api/k8s/roleBindings.ts @@ -9,12 +9,12 @@ import { } from '@openshift/dynamic-plugin-sdk-utils'; import { K8sAPIOptions, KnownLabels, RoleBindingKind } from '~/k8sTypes'; import { RoleBindingModel } from '~/api/models'; -import { - ProjectSharingRBType, - ProjectSharingRoleType, -} from '~/pages/projects/projectSharing/types'; import { genRandomChars } from '~/utilities/string'; import { applyK8sAPIOptions } from '~/api/apiMergeUtils'; +import { + RoleBindingPermissionsRBType, + RoleBindingPermissionsRoleType, +} from '~/concepts/roleBinding/types'; export const generateRoleBindingServingRuntime = ( name: string, @@ -46,11 +46,15 @@ export const generateRoleBindingServingRuntime = ( return roleBindingObject; }; -export const generateRoleBindingProjectSharing = ( +export const generateRoleBindingPermissions = ( namespace: string, - rbSubjectType: ProjectSharingRBType, + rbSubjectType: RoleBindingPermissionsRBType, rbSubjectName: string, - rbRoleRefType: ProjectSharingRoleType, + rbRoleRefType: RoleBindingPermissionsRoleType | string, //string because with MR this can include MR name + rbLabels: { [key: string]: string } = { + [KnownLabels.DASHBOARD_RESOURCE]: 'true', + [KnownLabels.PROJECT_SHARING]: 'true', + }, ): RoleBindingKind => { const roleBindingObject: RoleBindingKind = { apiVersion: 'rbac.authorization.k8s.io/v1', @@ -58,10 +62,7 @@ export const generateRoleBindingProjectSharing = ( metadata: { name: `dashboard-permissions-${genRandomChars()}`, namespace, - labels: { - [KnownLabels.DASHBOARD_RESOURCE]: 'true', - [KnownLabels.PROJECT_SHARING]: 'true', - }, + labels: rbLabels, }, roleRef: { apiGroup: 'rbac.authorization.k8s.io', diff --git a/frontend/src/app/AppRoutes.tsx b/frontend/src/app/AppRoutes.tsx index 412cb0cdcb..0c626cbfa5 100644 --- a/frontend/src/app/AppRoutes.tsx +++ b/frontend/src/app/AppRoutes.tsx @@ -14,6 +14,7 @@ import { import { useCheckJupyterEnabled } from '~/utilities/notebookControllerUtils'; import { SupportedArea } from '~/concepts/areas'; import useIsAreaAvailable from '~/concepts/areas/useIsAreaAvailable'; +import ModelRegistrySettingsRoutes from '~/pages/modelRegistrySettings/ModelRegistrySettingsRoutes'; const HomePage = React.lazy(() => import('../pages/home/Home')); @@ -53,9 +54,6 @@ const ClusterSettingsPage = React.lazy(() => import('../pages/clusterSettings/Cl const CustomServingRuntimeRoutes = React.lazy( () => import('../pages/modelServing/customServingRuntimes/CustomServingRuntimeRoutes'), ); -const ModelRegistrySettingsPage = React.lazy( - () => import('../pages/modelRegistrySettings/ModelRegistrySettings'), -); const GroupSettingsPage = React.lazy(() => import('../pages/groupSettings/GroupSettings')); const LearningCenterPage = React.lazy(() => import('../pages/learningCenter/LearningCenter')); const BYONImagesPage = React.lazy(() => import('../pages/BYONImages/BYONImages')); @@ -130,7 +128,7 @@ const AppRoutes: React.FC = () => { } /> } /> } /> - } /> + } /> } /> )} diff --git a/frontend/src/concepts/roleBinding/RoleBindingPermissions.tsx b/frontend/src/concepts/roleBinding/RoleBindingPermissions.tsx new file mode 100644 index 0000000000..5124bb8170 --- /dev/null +++ b/frontend/src/concepts/roleBinding/RoleBindingPermissions.tsx @@ -0,0 +1,129 @@ +import * as React from 'react'; +import { + EmptyState, + EmptyStateBody, + EmptyStateIcon, + EmptyStateVariant, + PageSection, + Spinner, + Stack, + StackItem, + EmptyStateHeader, +} from '@patternfly/react-core'; +import { ExclamationCircleIcon } from '@patternfly/react-icons'; +import { GroupKind, RoleBindingKind } from '~/k8sTypes'; +import { ProjectSectionID } from '~/pages/projects/screens/detail/types'; +import { ContextResourceData } from '~/types'; +import { MODEL_REGISTRY_DEFAULT_NAMESPACE } from '~/concepts/modelRegistry/const'; +import RoleBindingPermissionsTableSection from './RoleBindingPermissionsTableSection'; +import { RoleBindingPermissionsRBType, RoleBindingPermissionsRoleType } from './types'; +import { filterRoleBindingSubjects } from './utils'; + +type RoleBindingPermissionsProps = { + roleBindingPermissionsRB: ContextResourceData; + permissionOptions?: { + type: RoleBindingPermissionsRoleType; + description: string; + }[]; + projectName: string; + roleRef?: string; + labels?: { [key: string]: string }; + description: React.ReactElement | string; + groups: GroupKind[]; +}; + +const RoleBindingPermissions: React.FC = ({ + roleBindingPermissionsRB, + permissionOptions, + projectName, + roleRef, + labels, + description, + groups, +}) => { + const { + data: roleBindings, + loaded, + error: loadError, + refresh: refreshRB, + } = roleBindingPermissionsRB; + if (loadError) { + return ( + + } + headingLevel="h2" + /> + {loadError.message} + + ); + } + + if (!loaded) { + return ( + + + + + ); + } + + const userTable = ( + + ); + + const groupTable = ( + 0 ? groups.map((group: GroupKind) => group.metadata.name) : undefined + } + typeModifier="group" + /> + ); + + return ( + + + {description} + + {projectName === MODEL_REGISTRY_DEFAULT_NAMESPACE ? groupTable : userTable} + + + {projectName === MODEL_REGISTRY_DEFAULT_NAMESPACE ? userTable : groupTable} + + + + ); +}; + +export default RoleBindingPermissions; diff --git a/frontend/src/pages/projects/projectSharing/ProjectSharingNameInput.tsx b/frontend/src/concepts/roleBinding/RoleBindingPermissionsNameInput.tsx similarity index 71% rename from frontend/src/pages/projects/projectSharing/ProjectSharingNameInput.tsx rename to frontend/src/concepts/roleBinding/RoleBindingPermissionsNameInput.tsx index 0c6c6d1fe5..1167dcd032 100644 --- a/frontend/src/pages/projects/projectSharing/ProjectSharingNameInput.tsx +++ b/frontend/src/concepts/roleBinding/RoleBindingPermissionsNameInput.tsx @@ -1,10 +1,10 @@ import React from 'react'; import { TextInput } from '@patternfly/react-core'; import { Select, SelectOption, SelectVariant } from '@patternfly/react-core/deprecated'; -import { ProjectSharingRBType } from '~/pages/projects/projectSharing/types'; +import { RoleBindingPermissionsRBType } from './types'; -type ProjectSharingNameInputProps = { - type: ProjectSharingRBType; +type RoleBindingPermissionsNameInputProps = { + type: RoleBindingPermissionsRBType; value: string; onChange: (selection: string) => void; onClear: () => void; @@ -12,7 +12,7 @@ type ProjectSharingNameInputProps = { typeAhead?: string[]; }; -const ProjectSharingNameInput: React.FC = ({ +const RoleBindingPermissionsNameInput: React.FC = ({ type, value, onChange, @@ -25,12 +25,14 @@ const ProjectSharingNameInput: React.FC = ({ if (!typeAhead) { return ( onChange(newValue)} /> ); @@ -63,4 +65,4 @@ const ProjectSharingNameInput: React.FC = ({ ); }; -export default ProjectSharingNameInput; +export default RoleBindingPermissionsNameInput; diff --git a/frontend/src/concepts/roleBinding/RoleBindingPermissionsPermissionSelection.tsx b/frontend/src/concepts/roleBinding/RoleBindingPermissionsPermissionSelection.tsx new file mode 100644 index 0000000000..8f990f4655 --- /dev/null +++ b/frontend/src/concepts/roleBinding/RoleBindingPermissionsPermissionSelection.tsx @@ -0,0 +1,56 @@ +import { Select, SelectOption } from '@patternfly/react-core/deprecated'; +import React from 'react'; +import { RoleBindingPermissionsRoleType } from './types'; +import { castRoleBindingPermissionsRoleType, roleLabel } from './utils'; + +type RoleBindingPermissionsPermissionSelectionProps = { + selection: string; + permissionOptions?: { + type: RoleBindingPermissionsRoleType; + description: string; + }[]; + onSelect: (roleType: RoleBindingPermissionsRoleType) => void; +}; + +const RoleBindingPermissionsPermissionSelection: React.FC< + RoleBindingPermissionsPermissionSelectionProps +> = ({ + selection, + onSelect, + permissionOptions = [ + { + type: RoleBindingPermissionsRoleType.EDIT, + description: 'View and edit the project components', + }, + { + type: RoleBindingPermissionsRoleType.ADMIN, + description: 'Edit the project and manage user access', + }, + ], +}) => { + const [isOpen, setIsOpen] = React.useState(false); + + return ( + + ); +}; + +export default RoleBindingPermissionsPermissionSelection; diff --git a/frontend/src/pages/projects/projectSharing/ProjectSharingTable.tsx b/frontend/src/concepts/roleBinding/RoleBindingPermissionsTable.tsx similarity index 64% rename from frontend/src/pages/projects/projectSharing/ProjectSharingTable.tsx rename to frontend/src/concepts/roleBinding/RoleBindingPermissionsTable.tsx index e68f33dcd2..5d01adde81 100644 --- a/frontend/src/pages/projects/projectSharing/ProjectSharingTable.tsx +++ b/frontend/src/concepts/roleBinding/RoleBindingPermissionsTable.tsx @@ -1,17 +1,23 @@ import * as React from 'react'; import { Table } from '~/components/table'; import { RoleBindingKind } from '~/k8sTypes'; -import { ProjectDetailsContext } from '~/pages/projects/ProjectDetailsContext'; -import { deleteRoleBinding, generateRoleBindingProjectSharing, createRoleBinding } from '~/api'; -import ProjectSharingTableRow from './ProjectSharingTableRow'; -import { columnsProjectSharing } from './data'; -import { ProjectSharingRBType } from './types'; +import { deleteRoleBinding, generateRoleBindingPermissions, createRoleBinding } from '~/api'; +import RoleBindingPermissionsTableRow from './RoleBindingPermissionsTableRow'; +import { columnsRoleBindingPermissions } from './data'; +import { RoleBindingPermissionsRBType, RoleBindingPermissionsRoleType } from './types'; import { firstSubject } from './utils'; -import ProjectSharingTableRowAdd from './ProjectSharingTableRowAdd'; +import RoleBindingPermissionsTableRowAdd from './RoleBindingPermissionsTableRowAdd'; -type ProjectSharingTableProps = { - type: ProjectSharingRBType; +type RoleBindingPermissionsTableProps = { + type: RoleBindingPermissionsRBType; + projectName: string; + roleRef?: string; + labels?: { [key: string]: string }; permissions: RoleBindingKind[]; + permissionOptions?: { + type: RoleBindingPermissionsRoleType; + description: string; + }[]; isAdding: boolean; typeAhead?: string[]; onDismissNewRow: () => void; @@ -19,38 +25,41 @@ type ProjectSharingTableProps = { refresh: () => void; }; -const ProjectSharingTable: React.FC = ({ +const RoleBindingPermissionsTable: React.FC = ({ type, + projectName, + roleRef, + labels, permissions, + permissionOptions, typeAhead, isAdding, onDismissNewRow, onError, refresh, }) => { - const { currentProject } = React.useContext(ProjectDetailsContext); - const [editCell, setEditCell] = React.useState([]); - return ( isAdding ? ( - { - const newRBObject = generateRoleBindingProjectSharing( - currentProject.metadata.name, + const newRBObject = generateRoleBindingPermissions( + projectName, type, name, - roleType, + roleRef || roleType, + labels, ); createRoleBinding(newRBObject) .then(() => { @@ -66,18 +75,19 @@ const ProjectSharingTable: React.FC = ({ ) : null } rowRenderer={(rb) => ( - { - const newRBObject = generateRoleBindingProjectSharing( - currentProject.metadata.name, + const newRBObject = generateRoleBindingPermissions( + projectName, type, name, - roleType, + roleRef || roleType, + labels, ); createRoleBinding(newRBObject) .then(() => @@ -108,4 +118,4 @@ const ProjectSharingTable: React.FC = ({ /> ); }; -export default ProjectSharingTable; +export default RoleBindingPermissionsTable; diff --git a/frontend/src/pages/projects/projectSharing/ProjectSharingTableRow.tsx b/frontend/src/concepts/roleBinding/RoleBindingPermissionsTableRow.tsx similarity index 67% rename from frontend/src/pages/projects/projectSharing/ProjectSharingTableRow.tsx rename to frontend/src/concepts/roleBinding/RoleBindingPermissionsTableRow.tsx index e000cba1a0..30f2e05ceb 100644 --- a/frontend/src/pages/projects/projectSharing/ProjectSharingTableRow.tsx +++ b/frontend/src/concepts/roleBinding/RoleBindingPermissionsTableRow.tsx @@ -2,35 +2,39 @@ import * as React from 'react'; import { ActionsColumn, Tbody, Td, Tr } from '@patternfly/react-table'; import { Button, + Icon, Split, SplitItem, Text, Timestamp, TimestampTooltipVariant, + Tooltip, } from '@patternfly/react-core'; -import { CheckIcon, TimesIcon } from '@patternfly/react-icons'; +import { CheckIcon, OutlinedQuestionCircleIcon, TimesIcon } from '@patternfly/react-icons'; import { RoleBindingKind } from '~/k8sTypes'; import { relativeTime } from '~/utilities/time'; -import { castProjectSharingRoleType, firstSubject, roleLabel } from './utils'; -import { ProjectSharingRBType, ProjectSharingRoleType } from './types'; -import ProjectSharingNameInput from './ProjectSharingNameInput'; -import ProjectSharingPermissionSelection from './ProjectSharingPermissionSelection'; +import { MODEL_REGISTRY_DEFAULT_NAMESPACE } from '~/concepts/modelRegistry/const'; +import { castRoleBindingPermissionsRoleType, firstSubject, roleLabel } from './utils'; +import { RoleBindingPermissionsRBType, RoleBindingPermissionsRoleType } from './types'; +import RoleBindingPermissionsNameInput from './RoleBindingPermissionsNameInput'; +import RoleBindingPermissionsPermissionSelection from './RoleBindingPermissionsPermissionSelection'; -type ProjectSharingTableRowProps = { +type RoleBindingPermissionsTableRowProps = { obj: RoleBindingKind; - type: ProjectSharingRBType; + type: RoleBindingPermissionsRBType; isEditing: boolean; typeAhead?: string[]; - onChange: (name: string, roleType: ProjectSharingRoleType) => void; + onChange: (name: string, roleType: RoleBindingPermissionsRoleType) => void; onCancel: () => void; onEdit: () => void; onDelete: () => void; }; const defaultValueName = (obj: RoleBindingKind) => firstSubject(obj); -const defaultValueRole = (obj: RoleBindingKind) => castProjectSharingRoleType(obj.roleRef.name); +const defaultValueRole = (obj: RoleBindingKind) => + castRoleBindingPermissionsRoleType(obj.roleRef.name); -const ProjectSharingTableRow: React.FC = ({ +const RoleBindingPermissionsTableRow: React.FC = ({ obj, type, isEditing, @@ -41,18 +45,20 @@ const ProjectSharingTableRow: React.FC = ({ onDelete, }) => { const [roleBindingName, setRoleBindingName] = React.useState(defaultValueName(obj)); - const [roleBindingRoleRef, setRoleBindingRoleRef] = React.useState( - defaultValueRole(obj), - ); + const [roleBindingRoleRef, setRoleBindingRoleRef] = + React.useState(defaultValueRole(obj)); const [isLoading, setIsLoading] = React.useState(false); const createdDate = new Date(obj.metadata.creationTimestamp || ''); + const isModelRegistry = obj.metadata.namespace === MODEL_REGISTRY_DEFAULT_NAMESPACE; + const isDefaultModelRegistryGroup = + isModelRegistry && obj.metadata.name === `${obj.metadata.labels?.app}-users`; return ( - setIsPermissionsModalOpen(false)} - refresh={refresh} - /> ( + + } /> + } /> + } /> + +); + +export default ModelRegistrySettingsRoutes; diff --git a/frontend/src/pages/modelRegistrySettings/useRoleBindings.ts b/frontend/src/pages/modelRegistrySettings/useRoleBindings.ts new file mode 100644 index 0000000000..0c3f9de8e2 --- /dev/null +++ b/frontend/src/pages/modelRegistrySettings/useRoleBindings.ts @@ -0,0 +1,21 @@ +import * as React from 'react'; +import { listRoleBindings } from '~/api'; +import { RoleBindingKind } from '~/k8sTypes'; +import useFetchState, { FetchState } from '~/utilities/useFetchState'; + +const useRoleBindings = (): FetchState => { + const getRoleBindings = React.useCallback( + () => + listRoleBindings('odh-model-registries', 'component=model-registry').catch((e) => { + if (e.statusObject?.code === 404) { + throw new Error('No rolebindings found.'); + } + throw e; + }), + [], + ); + + return useFetchState(getRoleBindings, []); +}; + +export default useRoleBindings; diff --git a/frontend/src/pages/projects/projectSharing/ProjectSharing.tsx b/frontend/src/pages/projects/projectSharing/ProjectSharing.tsx index 9163c11ca8..f12e4fd7f4 100644 --- a/frontend/src/pages/projects/projectSharing/ProjectSharing.tsx +++ b/frontend/src/pages/projects/projectSharing/ProjectSharing.tsx @@ -1,89 +1,21 @@ import * as React from 'react'; -import { - EmptyState, - EmptyStateBody, - EmptyStateIcon, - EmptyStateVariant, - PageSection, - Spinner, - Stack, - StackItem, - EmptyStateHeader, -} from '@patternfly/react-core'; -import { ExclamationCircleIcon } from '@patternfly/react-icons'; import { ProjectDetailsContext } from '~/pages/projects/ProjectDetailsContext'; -import { GroupKind } from '~/k8sTypes'; -import { ProjectSectionID } from '~/pages/projects/screens/detail/types'; -import ProjectSharingTableSection from './ProjectSharingTableSection'; -import { ProjectSharingRBType } from './types'; -import { filterRoleBindingSubjects } from './utils'; +import RoleBindingPermissions from '~/concepts/roleBinding/RoleBindingPermissions'; const ProjectSharing: React.FC = () => { const { - projectSharingRB: { data: roleBindings, loaded, error: loadError, refresh: refreshRB }, + currentProject, + projectSharingRB, groups: [groups], } = React.useContext(ProjectDetailsContext); - if (loadError) { - return ( - - } - headingLevel="h2" - /> - {loadError.message} - - ); - } - - if (!loaded) { - return ( - - - - - ); - } - return ( - - - Add users and groups that can access the project. - - - - - 0 ? groups.map((group: GroupKind) => group.metadata.name) : undefined - } - typeModifier="group" - /> - - - + ); }; diff --git a/frontend/src/pages/projects/projectSharing/ProjectSharingPermissionSelection.tsx b/frontend/src/pages/projects/projectSharing/ProjectSharingPermissionSelection.tsx deleted file mode 100644 index 8934096811..0000000000 --- a/frontend/src/pages/projects/projectSharing/ProjectSharingPermissionSelection.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { Select, SelectOption } from '@patternfly/react-core/deprecated'; -import React from 'react'; -import { ProjectSharingRoleType } from './types'; -import { castProjectSharingRoleType, roleLabel } from './utils'; - -type ProjectSharingPermissionSelectionProps = { - selection: string; - onSelect: (roleType: ProjectSharingRoleType) => void; -}; - -const ProjectSharingPermissions = [ - { - type: ProjectSharingRoleType.EDIT, - description: 'View and edit the project components', - }, - { - type: ProjectSharingRoleType.ADMIN, - description: 'Edit the project and manage user access', - }, -]; - -const ProjectSharingPermissionSelection: React.FC = ({ - selection, - onSelect, -}) => { - const [isOpen, setIsOpen] = React.useState(false); - - return ( - - ); -}; - -export default ProjectSharingPermissionSelection; diff --git a/frontend/src/pages/projects/projectSharing/utils.ts b/frontend/src/pages/projects/projectSharing/utils.ts deleted file mode 100644 index 72effd0662..0000000000 --- a/frontend/src/pages/projects/projectSharing/utils.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { RoleBindingKind } from '~/k8sTypes'; -import { ProjectSharingRBType, ProjectSharingRoleType } from './types'; - -export const filterRoleBindingSubjects = ( - roleBindings: RoleBindingKind[], - type: ProjectSharingRBType, -): RoleBindingKind[] => roleBindings.filter((roles) => roles.subjects[0]?.kind === type); - -export const castProjectSharingRoleType = (role: string): ProjectSharingRoleType => - role === ProjectSharingRoleType.ADMIN - ? ProjectSharingRoleType.ADMIN - : ProjectSharingRoleType.EDIT; - -export const firstSubject = (roleBinding: RoleBindingKind): string => - roleBinding.subjects[0]?.name || ''; - -export const roleLabel = (value: ProjectSharingRoleType): string => - value === ProjectSharingRoleType.ADMIN ? 'Admin' : 'Contributor';
{isEditing ? ( - { @@ -63,12 +69,15 @@ const ProjectSharingTableRow: React.FC = ({ typeAhead={typeAhead} /> ) : ( - {roleBindingName} + + {roleBindingName}{` `} + {isDefaultModelRegistryGroup && This group is created by default. You can add users to this group via the API.}>} + )} - {isEditing ? ( - { setRoleBindingRoleRef(selection); @@ -123,6 +132,7 @@ const ProjectSharingTableRow: React.FC = ({ ) : ( = ({ ); }; -export default ProjectSharingTableRow; +export default RoleBindingPermissionsTableRow; diff --git a/frontend/src/pages/projects/projectSharing/ProjectSharingTableRowAdd.tsx b/frontend/src/concepts/roleBinding/RoleBindingPermissionsTableRowAdd.tsx similarity index 66% rename from frontend/src/pages/projects/projectSharing/ProjectSharingTableRowAdd.tsx rename to frontend/src/concepts/roleBinding/RoleBindingPermissionsTableRowAdd.tsx index 88a6d16bef..2a1d475aea 100644 --- a/frontend/src/pages/projects/projectSharing/ProjectSharingTableRowAdd.tsx +++ b/frontend/src/concepts/roleBinding/RoleBindingPermissionsTableRowAdd.tsx @@ -2,35 +2,41 @@ import * as React from 'react'; import { Tbody, Td, Tr } from '@patternfly/react-table'; import { Button, Split, SplitItem } from '@patternfly/react-core'; import { CheckIcon, TimesIcon } from '@patternfly/react-icons'; -import { ProjectSharingRBType, ProjectSharingRoleType } from './types'; -import ProjectSharingNameInput from './ProjectSharingNameInput'; -import ProjectSharingPermissionSelection from './ProjectSharingPermissionSelection'; +import { RoleBindingPermissionsRBType, RoleBindingPermissionsRoleType } from './types'; +import RoleBindingPermissionsNameInput from './RoleBindingPermissionsNameInput'; +import RoleBindingPermissionsPermissionSelection from './RoleBindingPermissionsPermissionSelection'; -type ProjectSharingTableRowPropsAdd = { +type RoleBindingPermissionsTableRowPropsAdd = { typeAhead?: string[]; - type: ProjectSharingRBType; - onChange: (name: string, roleType: ProjectSharingRoleType) => void; + type: RoleBindingPermissionsRBType; + permissionOptions?: { + type: RoleBindingPermissionsRoleType; + description: string; + }[]; + onChange: (name: string, roleType: RoleBindingPermissionsRoleType) => void; onCancel: () => void; }; -/** @deprecated - this should use ProjectSharingTableRow */ -const ProjectSharingTableRowAdd: React.FC = ({ +/** @deprecated - this should use RoleBindingPermissionsTableRow */ +const RoleBindingPermissionsTableRowAdd: React.FC = ({ typeAhead, type, + permissionOptions, onChange, onCancel, }) => { const [roleBindingName, setRoleBindingName] = React.useState(''); - const [roleBindingRoleRef, setRoleBindingRoleRef] = React.useState( - ProjectSharingRoleType.EDIT, - ); + const [roleBindingRoleRef, setRoleBindingRoleRef] = + React.useState( + permissionOptions?.[0]?.type || RoleBindingPermissionsRoleType.EDIT, + ); const [isLoading, setIsLoading] = React.useState(false); return (
- ) => { @@ -42,7 +48,8 @@ const ProjectSharingTableRowAdd: React.FC = ({ /> - { setRoleBindingRoleRef(selection); @@ -85,4 +92,4 @@ const ProjectSharingTableRowAdd: React.FC = ({ ); }; -export default ProjectSharingTableRowAdd; +export default RoleBindingPermissionsTableRowAdd; diff --git a/frontend/src/pages/projects/projectSharing/ProjectSharingTableSection.tsx b/frontend/src/concepts/roleBinding/RoleBindingPermissionsTableSection.tsx similarity index 59% rename from frontend/src/pages/projects/projectSharing/ProjectSharingTableSection.tsx rename to frontend/src/concepts/roleBinding/RoleBindingPermissionsTableSection.tsx index d0a0f0162d..1554f6ff38 100644 --- a/frontend/src/pages/projects/projectSharing/ProjectSharingTableSection.tsx +++ b/frontend/src/concepts/roleBinding/RoleBindingPermissionsTableSection.tsx @@ -13,23 +13,34 @@ import { import { RoleBindingKind } from '~/k8sTypes'; import HeaderIcon from '~/concepts/design/HeaderIcon'; import { ProjectObjectType } from '~/concepts/design/utils'; -import ProjectSharingTable from './ProjectSharingTable'; -import { ProjectSharingRBType } from './types'; +import RoleBindingPermissionsTable from './RoleBindingPermissionsTable'; +import { RoleBindingPermissionsRBType, RoleBindingPermissionsRoleType } from './types'; -export type ProjectSharingTableSectionAltProps = { +export type RoleBindingPermissionsTableSectionAltProps = { roleBindings: RoleBindingKind[]; - projectSharingTableType: ProjectSharingRBType; + projectName: string; + roleRef?: string; + roleBindingPermissionsTableType: RoleBindingPermissionsRBType; + permissionOptions?: { + type: RoleBindingPermissionsRoleType; + description: string; + }[]; typeAhead?: string[]; refresh: () => void; typeModifier?: string; + labels?: { [key: string]: string }; }; -const ProjectSharingTableSection: React.FC = ({ +const RoleBindingPermissionsTableSection: React.FC = ({ roleBindings, - projectSharingTableType, + projectName, + roleRef, + roleBindingPermissionsTableType, + permissionOptions, typeAhead, refresh, typeModifier, + labels, }) => { const [addField, setAddField] = React.useState(false); const [error, setError] = React.useState(undefined); @@ -45,22 +56,32 @@ const ProjectSharingTableSection: React.FC = > - - {projectSharingTableType === ProjectSharingRBType.USER ? 'Users' : 'Groups'} + <Title + id={`user-permission-${roleBindingPermissionsTableType}`} + headingLevel="h2" + size="xl" + > + {roleBindingPermissionsTableType === RoleBindingPermissionsRBType.USER + ? 'Users' + : 'Groups'} - { @@ -88,7 +109,7 @@ const ProjectSharingTableSection: React.FC = )} ); }; -export default ProjectSharingTableSection; +export default RoleBindingPermissionsTableSection; diff --git a/frontend/src/pages/projects/projectSharing/data.ts b/frontend/src/concepts/roleBinding/data.ts similarity index 88% rename from frontend/src/pages/projects/projectSharing/data.ts rename to frontend/src/concepts/roleBinding/data.ts index a09532f7c9..4cf5a83059 100644 --- a/frontend/src/pages/projects/projectSharing/data.ts +++ b/frontend/src/concepts/roleBinding/data.ts @@ -2,7 +2,7 @@ import { RoleBindingKind } from '~/k8sTypes'; import { SortableData } from '~/components/table'; import { firstSubject } from './utils'; -export const columnsProjectSharing: SortableData[] = [ +export const columnsRoleBindingPermissions: SortableData[] = [ { field: 'username', label: 'Name', diff --git a/frontend/src/pages/projects/projectSharing/types.ts b/frontend/src/concepts/roleBinding/types.ts similarity index 62% rename from frontend/src/pages/projects/projectSharing/types.ts rename to frontend/src/concepts/roleBinding/types.ts index ad009193f9..db9015875d 100644 --- a/frontend/src/pages/projects/projectSharing/types.ts +++ b/frontend/src/concepts/roleBinding/types.ts @@ -1,17 +1,18 @@ import { RoleBindingSubject } from '~/k8sTypes'; -export enum ProjectSharingRBType { +export enum RoleBindingPermissionsRBType { USER = 'User', GROUP = 'Group', } -export enum ProjectSharingRoleType { +export enum RoleBindingPermissionsRoleType { EDIT = 'edit', ADMIN = 'admin', + DEFAULT = 'default', } export type RoleBindingSubjectWithRole = RoleBindingSubject & { - role: ProjectSharingRoleType; + role: RoleBindingPermissionsRoleType; roleBindingName: string; roleBindingNamespace: string; }; diff --git a/frontend/src/concepts/roleBinding/utils.ts b/frontend/src/concepts/roleBinding/utils.ts new file mode 100644 index 0000000000..c72ac46cfe --- /dev/null +++ b/frontend/src/concepts/roleBinding/utils.ts @@ -0,0 +1,30 @@ +import { capitalize } from '@patternfly/react-core'; +import { RoleBindingKind } from '~/k8sTypes'; +import { RoleBindingPermissionsRBType, RoleBindingPermissionsRoleType } from './types'; + +export const filterRoleBindingSubjects = ( + roleBindings: RoleBindingKind[], + type: RoleBindingPermissionsRBType, +): RoleBindingKind[] => roleBindings.filter((roles) => roles.subjects[0]?.kind === type); + +export const castRoleBindingPermissionsRoleType = ( + role: string, +): RoleBindingPermissionsRoleType => { + if (role === RoleBindingPermissionsRoleType.ADMIN) { + return RoleBindingPermissionsRoleType.ADMIN; + } + if (role === RoleBindingPermissionsRoleType.EDIT) { + return RoleBindingPermissionsRoleType.EDIT; + } + return RoleBindingPermissionsRoleType.DEFAULT; +}; + +export const firstSubject = (roleBinding: RoleBindingKind): string => + roleBinding.subjects[0]?.name || ''; + +export const roleLabel = (value: RoleBindingPermissionsRoleType): string => { + if (value === RoleBindingPermissionsRoleType.EDIT) { + return 'Contributer'; + } + return capitalize(value); +}; diff --git a/frontend/src/k8sTypes.ts b/frontend/src/k8sTypes.ts index 3702550dfe..829db6c1dd 100644 --- a/frontend/src/k8sTypes.ts +++ b/frontend/src/k8sTypes.ts @@ -22,6 +22,7 @@ export enum KnownLabels { PROJECT_SHARING = 'opendatahub.io/project-sharing', MODEL_SERVING_PROJECT = 'modelmesh-enabled', DATA_CONNECTION_AWS = 'opendatahub.io/managed', + MODEL_REGISTRY_SHARING = 'opendatahub.io/model-registry-sharing', } export type K8sVerb = diff --git a/frontend/src/pages/modelRegistrySettings/ManagePermissionsModal.tsx b/frontend/src/pages/modelRegistrySettings/ManagePermissionsModal.tsx deleted file mode 100644 index 1e81f77edf..0000000000 --- a/frontend/src/pages/modelRegistrySettings/ManagePermissionsModal.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import React from 'react'; -import { Modal } from '@patternfly/react-core'; -import { ModelRegistryKind } from '~/k8sTypes'; -import DashboardModalFooter from '~/concepts/dashboard/DashboardModalFooter'; - -type ManagePermissionsModalProps = { - modelRegistry: ModelRegistryKind; - isOpen: boolean; - onClose: () => void; - refresh: () => Promise; -}; - -const ManagePermissionsModal: React.FC = ({ - modelRegistry: mr, - isOpen, - onClose, - refresh, -}) => ( - { - // TODO submit changes, then... - refresh(); - }} - onCancel={onClose} - isSubmitDisabled // TODO - error={undefined} // TODO - alertTitle="Error saving permissions" - /> - } - > - TODO: This feature is not yet implemented - -); - -export default ManagePermissionsModal; diff --git a/frontend/src/pages/modelRegistrySettings/ModelRegistriesPermissions.tsx b/frontend/src/pages/modelRegistrySettings/ModelRegistriesPermissions.tsx new file mode 100644 index 0000000000..c77ec4ad2f --- /dev/null +++ b/frontend/src/pages/modelRegistrySettings/ModelRegistriesPermissions.tsx @@ -0,0 +1,106 @@ +import React from 'react'; +import { + Breadcrumb, + BreadcrumbItem, + ClipboardCopy, + Tab, + TabContent, + TabContentBody, + Tabs, +} from '@patternfly/react-core'; +import { Link } from 'react-router-dom'; +import { useParams } from 'react-router'; +import { KnownLabels, RoleBindingKind } from '~/k8sTypes'; +import { useGroups } from '~/api'; +import RoleBindingPermissions from '~/concepts/roleBinding/RoleBindingPermissions'; +import { useContextResourceData } from '~/utilities/useContextResourceData'; +import ApplicationsPage from '~/pages/ApplicationsPage'; +import { MODEL_REGISTRY_DEFAULT_NAMESPACE } from '~/concepts/modelRegistry/const'; +import { SupportedArea } from '~/concepts/areas'; +import { RoleBindingPermissionsRoleType } from '~/concepts/roleBinding/types'; +import useRoleBindings from './useRoleBindings'; + +const ModelRegistriesManagePermissions: React.FC = () => { + const [activeTabKey, setActiveTabKey] = React.useState('users'); + const [groups] = useGroups(); + const roleBindings = useContextResourceData(useRoleBindings()); + const { mrName } = useParams(); + const filteredRoleBindings = roleBindings.data.filter( + (rb) => rb.metadata.labels?.['app.kubernetes.io/name'] === mrName, + ); + return ( + + Settings + Model registry settings} + /> + Manage Permissions + + } + loaded + empty={false} + provideChildrenPadding + > + { + setActiveTabKey(tabKey.toString()); + }} + > + + + +
+ + +
+
+ ); +}; + +export default ModelRegistriesManagePermissions; diff --git a/frontend/src/pages/modelRegistrySettings/ModelRegistriesTableRow.tsx b/frontend/src/pages/modelRegistrySettings/ModelRegistriesTableRow.tsx index efcf80da8d..d4b33c8f84 100644 --- a/frontend/src/pages/modelRegistrySettings/ModelRegistriesTableRow.tsx +++ b/frontend/src/pages/modelRegistrySettings/ModelRegistriesTableRow.tsx @@ -1,8 +1,7 @@ import React from 'react'; import { ActionsColumn, Td, Tr } from '@patternfly/react-table'; -import { Button } from '@patternfly/react-core'; +import { Link } from 'react-router-dom'; import { ModelRegistryKind } from '~/k8sTypes'; -import ManagePermissionsModal from './ManagePermissionsModal'; import ViewDatabaseConfigModal from './ViewDatabaseConfigModal'; import DeleteModelRegistryModal from './DeleteModelRegistryModal'; @@ -15,7 +14,6 @@ const ModelRegistriesTableRow: React.FC = ({ modelRegistry: mr, refresh, }) => { - const [isPermissionsModalOpen, setIsPermissionsModalOpen] = React.useState(false); const [isDatabaseConfigModalOpen, setIsDatabaseConfigModalOpen] = React.useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = React.useState(false); return ( @@ -23,13 +21,12 @@ const ModelRegistriesTableRow: React.FC = ({
{mr.metadata.name} - + = ({ />