diff --git a/frontend/src/__mocks__/mockModelRegistryService.ts b/frontend/src/__mocks__/mockModelRegistryService.ts new file mode 100644 index 0000000000..c90b17e6dc --- /dev/null +++ b/frontend/src/__mocks__/mockModelRegistryService.ts @@ -0,0 +1,38 @@ +import { ServiceKind } from '~/k8sTypes'; + +type MockServiceType = { + name?: string; + namespace?: string; +}; + +export const mockModelRegistryService = ({ + name = 'modelregistry-sample', + namespace = 'odh-model-registries', +}: MockServiceType): ServiceKind => ({ + kind: 'Service', + apiVersion: 'v1', + metadata: { + name, + namespace, + }, + spec: { + selector: { + app: name, + component: 'model-registry', + }, + ports: [ + { + name: 'grpc-api', + protocol: 'TCP', + port: 9090, + targetPort: 9090, + }, + { + name: 'http-api', + protocol: 'TCP', + port: 8080, + targetPort: 8080, + }, + ], + }, +}); 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/__mocks__/mockSelfSubjectRulesReview.ts b/frontend/src/__mocks__/mockSelfSubjectRulesReview.ts new file mode 100644 index 0000000000..16dc9dc07f --- /dev/null +++ b/frontend/src/__mocks__/mockSelfSubjectRulesReview.ts @@ -0,0 +1,238 @@ +import { SelfSubjectRulesReviewKind } from '~/k8sTypes'; + +export const mockSelfSubjectRulesReview = (): SelfSubjectRulesReviewKind => ({ + kind: 'SelfSubjectRulesReview', + apiVersion: 'authorization.k8s.io/v1', + spec: { + namespace: 'odh-model-registries', + }, + status: { + resourceRules: [ + { + verbs: ['create'], + apiGroups: ['', 'project.openshift.io'], + resources: ['projectrequests'], + }, + { + verbs: ['impersonate'], + apiGroups: ['authentication.k8s.io'], + resources: ['userextras/scopes.authorization.openshift.io'], + }, + { + verbs: ['get', 'list', 'watch'], + apiGroups: ['helm.openshift.io'], + resources: ['helmchartrepositories'], + }, + { + verbs: ['create'], + apiGroups: ['authorization.k8s.io'], + resources: ['selfsubjectaccessreviews', 'selfsubjectrulesreviews'], + }, + { + verbs: ['create'], + apiGroups: ['authentication.k8s.io'], + resources: ['selfsubjectreviews'], + }, + { + verbs: ['create'], + apiGroups: ['', 'project.openshift.io'], + resources: ['projectrequests'], + }, + { + verbs: ['get', 'list', 'watch', 'delete'], + apiGroups: ['oauth.openshift.io'], + resources: ['useroauthaccesstokens'], + }, + { + verbs: ['get', 'list', 'watch'], + apiGroups: ['snapshot.storage.k8s.io'], + resources: ['volumesnapshotclasses'], + }, + { + verbs: ['get'], + apiGroups: ['', 'user.openshift.io'], + resources: ['users'], + resourceNames: ['~'], + }, + { + verbs: ['list'], + apiGroups: ['', 'project.openshift.io'], + resources: ['projectrequests'], + }, + { + verbs: ['get', 'list'], + apiGroups: ['', 'authorization.openshift.io'], + resources: ['clusterroles'], + }, + { + verbs: ['get', 'list', 'watch'], + apiGroups: ['rbac.authorization.k8s.io'], + resources: ['clusterroles'], + }, + { + verbs: ['get', 'list'], + apiGroups: ['storage.k8s.io'], + resources: ['storageclasses'], + }, + { + verbs: ['list', 'watch'], + apiGroups: ['', 'project.openshift.io'], + resources: ['projects'], + }, + { + verbs: ['create'], + apiGroups: ['', 'authorization.openshift.io'], + resources: ['selfsubjectrulesreviews'], + }, + { + verbs: ['create'], + apiGroups: ['authorization.k8s.io'], + resources: ['selfsubjectaccessreviews'], + }, + { + verbs: ['delete'], + apiGroups: ['', 'oauth.openshift.io'], + resources: ['oauthaccesstokens', 'oauthauthorizetokens'], + }, + { + verbs: ['create'], + apiGroups: ['', 'build.openshift.io'], + resources: ['builds/source'], + }, + { + verbs: ['create'], + apiGroups: ['', 'authorization.openshift.io'], + resources: ['selfsubjectrulesreviews'], + }, + { + verbs: ['create'], + apiGroups: ['authorization.k8s.io'], + resources: ['selfsubjectaccessreviews'], + }, + { + verbs: ['create', 'get'], + apiGroups: ['', 'build.openshift.io'], + resources: ['buildconfigs/webhooks'], + }, + { + verbs: ['create'], + apiGroups: ['', 'build.openshift.io'], + resources: ['builds/jenkinspipeline'], + }, + { + verbs: ['use'], + apiGroups: ['security.openshift.io'], + resources: ['securitycontextconstraints'], + resourceNames: ['restricted-v2'], + }, + { + verbs: ['get', 'list', 'watch'], + apiGroups: ['console.openshift.io'], + resources: [ + 'consoleclidownloads', + 'consoleexternalloglinks', + 'consolelinks', + 'consolenotifications', + 'consoleplugins', + 'consolequickstarts', + 'consolesamples', + 'consoleyamlsamples', + ], + }, + { + verbs: ['create'], + apiGroups: ['', 'build.openshift.io'], + resources: ['builds/docker', 'builds/optimizeddocker'], + }, + { + verbs: ['get'], + apiGroups: [''], + resources: ['services'], + resourceNames: ['modelregistry-sample'], + }, + { + verbs: ['get'], + apiGroups: [''], + resources: ['services'], + resourceNames: ['dallas-mr'], + }, + ], + nonResourceRules: [ + { + verbs: ['get'], + nonResourceURLs: ['/healthz', '/healthz/'], + }, + { + verbs: ['get'], + nonResourceURLs: [ + '/version', + '/version/*', + '/api', + '/api/*', + '/apis', + '/apis/*', + '/oapi', + '/oapi/*', + '/openapi/v2', + '/swaggerapi', + '/swaggerapi/*', + '/swagger.json', + '/swagger-2.0.0.pb-v1', + '/osapi', + '/osapi/', + '/.well-known', + '/.well-known/oauth-authorization-server', + '/', + ], + }, + { + verbs: ['get'], + nonResourceURLs: [ + '/version', + '/version/*', + '/api', + '/api/*', + '/apis', + '/apis/*', + '/oapi', + '/oapi/*', + '/openapi/v2', + '/swaggerapi', + '/swaggerapi/*', + '/swagger.json', + '/swagger-2.0.0.pb-v1', + '/osapi', + '/osapi/', + '/.well-known', + '/.well-known/oauth-authorization-server', + '/', + ], + }, + { + verbs: ['get'], + nonResourceURLs: [ + '/api', + '/api/*', + '/apis', + '/apis/*', + '/healthz', + '/livez', + '/openapi', + '/openapi/*', + '/readyz', + '/version', + '/version/', + ], + }, + { + verbs: ['get'], + nonResourceURLs: ['/.well-known', '/.well-known/*'], + }, + { + verbs: ['get'], + nonResourceURLs: ['/healthz', '/livez', '/readyz', '/version', '/version/'], + }, + ], + incomplete: false, + }, +}); 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/modelRegistry/modelRegistry.cy.ts b/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/modelRegistry.cy.ts index 24c8dd0a70..0dbfe06f81 100644 --- a/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/modelRegistry.cy.ts +++ b/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/modelRegistry.cy.ts @@ -1,16 +1,23 @@ /* eslint-disable camelcase */ -import { mockK8sResourceList, mockRouteK8sResourceModelRegistry } from '~/__mocks__'; +import { mockK8sResourceList } from '~/__mocks__'; import { mockComponents } from '~/__mocks__/mockComponents'; import { mockDashboardConfig } from '~/__mocks__/mockDashboardConfig'; -import { mockModelRegistry } from '~/__mocks__/mockModelRegistry'; import { mockRegisteredModelList } from '~/__mocks__/mockRegisteredModelsList'; import { labelModal, modelRegistry } from '~/__tests__/cypress/cypress/pages/modelRegistry'; import { be } from '~/__tests__/cypress/cypress/utils/should'; -import { ModelRegistryModel, RouteModel } from '~/__tests__/cypress/cypress/utils/models'; +import { + SelfSubjectAccessReviewModel, + SelfSubjectRulesReviewModel, + ServiceModel, +} from '~/__tests__/cypress/cypress/utils/models'; import { mockModelVersionList } from '~/__mocks__/mockModelVersionList'; import { mockModelVersion } from '~/__mocks__/mockModelVersion'; import type { ModelVersion, RegisteredModel } from '~/concepts/modelRegistry/types'; import { mockRegisteredModel } from '~/__mocks__/mockRegisteredModel'; +import { mockModelRegistryService } from '~/__mocks__/mockModelRegistryService'; +import { asProjectEditUser } from '~/__tests__/cypress/cypress/utils/mockUsers'; +import { mockSelfSubjectRulesReview } from '~/__mocks__/mockSelfSubjectRulesReview'; +import { mockSelfSubjectAccessReview } from '~/__mocks__/mockSelfSubjectAccessReview'; const MODEL_REGISTRY_API_VERSION = 'v1alpha3'; @@ -18,6 +25,7 @@ type HandlersProps = { disableModelRegistryFeature?: boolean; registeredModels?: RegisteredModel[]; modelVersions?: ModelVersion[]; + allowed?: boolean; }; const initIntercepts = ({ @@ -56,6 +64,7 @@ const initIntercepts = ({ mockModelVersion({ author: 'Author 1' }), mockModelVersion({ name: 'model version' }), ], + allowed = true, }: HandlersProps) => { cy.interceptOdh( 'GET /api/config', @@ -65,18 +74,28 @@ const initIntercepts = ({ ); cy.interceptOdh('GET /api/components', { query: { installed: 'true' } }, mockComponents()); + cy.interceptK8s('POST', SelfSubjectRulesReviewModel, mockSelfSubjectRulesReview()); + cy.interceptK8sList( - ModelRegistryModel, - mockK8sResourceList([mockModelRegistry({}), mockModelRegistry({ name: 'test-registry' })]), + ServiceModel, + mockK8sResourceList([ + mockModelRegistryService({ name: 'modelregistry-sample' }), + mockModelRegistryService({ name: 'modelregistry-sample-2' }), + ]), ); - cy.interceptK8s(ModelRegistryModel, mockModelRegistry({})); + cy.interceptK8s(ServiceModel, mockModelRegistryService({ name: 'modelregistry-sample' })); + + cy.interceptK8s(ServiceModel, mockModelRegistryService({ name: 'dallas-mr' })); cy.interceptK8s( - RouteModel, - mockRouteK8sResourceModelRegistry({ - name: 'modelregistry-sample-http', - namespace: 'odh-model-registries', + 'POST', + SelfSubjectAccessReviewModel, + mockSelfSubjectAccessReview({ + verb: 'list', + resource: 'services', + group: 'user.openshift.io', + allowed, }), ); @@ -211,4 +230,16 @@ describe('Register Model button', () => { cy.findByTestId('app-page-title').contains('Register model'); cy.findByText('Model registry - modelregistry-sample').should('exist'); }); + + it('should be accessible for non-admin users', () => { + asProjectEditUser(); + initIntercepts({ + disableModelRegistryFeature: false, + allowed: false, + }); + + modelRegistry.visit(); + modelRegistry.navigate(); + modelRegistry.shouldModelRegistrySelectorExist(); + }); }); diff --git a/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/modelVersionArchive.cy.ts b/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/modelVersionArchive.cy.ts index bc768236a5..ce1fc616a2 100644 --- a/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/modelVersionArchive.cy.ts +++ b/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/modelVersionArchive.cy.ts @@ -1,9 +1,8 @@ /* eslint-disable camelcase */ import { mockK8sResourceList } from '~/__mocks__'; import { mockDashboardConfig } from '~/__mocks__/mockDashboardConfig'; -import { mockModelRegistry } from '~/__mocks__/mockModelRegistry'; import { mockRegisteredModelList } from '~/__mocks__/mockRegisteredModelsList'; -import { ModelRegistryModel } from '~/__tests__/cypress/cypress/utils/models'; +import { ServiceModel } from '~/__tests__/cypress/cypress/utils/models'; import { mockModelVersionList } from '~/__mocks__/mockModelVersionList'; import { mockModelVersion } from '~/__mocks__/mockModelVersion'; import type { ModelVersion } from '~/concepts/modelRegistry/types'; @@ -16,6 +15,7 @@ import { restoreVersionModal, } from '~/__tests__/cypress/cypress/pages/modelRegistry/modelVersionArchive'; import { labelModal, modelRegistry } from '~/__tests__/cypress/cypress/pages/modelRegistry'; +import { mockModelRegistryService } from '~/__mocks__/mockModelRegistryService'; const MODEL_REGISTRY_API_VERSION = 'v1alpha3'; @@ -55,12 +55,13 @@ const initIntercepts = ({ ); cy.interceptK8sList( - ModelRegistryModel, - mockK8sResourceList([mockModelRegistry({}), mockModelRegistry({ name: 'test-registry' })]), + ServiceModel, + mockK8sResourceList([ + mockModelRegistryService({ name: 'modelregistry-sample' }), + mockModelRegistryService({ name: 'modelregistry-sample-2' }), + ]), ); - cy.interceptK8s(ModelRegistryModel, mockModelRegistry({})); - cy.interceptOdh( 'GET /api/service/modelregistry/:serviceName/api/model_registry/:apiVersion/registered_models', { path: { serviceName: 'modelregistry-sample', apiVersion: MODEL_REGISTRY_API_VERSION } }, diff --git a/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/modelVersionDetails.cy.ts b/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/modelVersionDetails.cy.ts index cc1084ecf6..ac956b5520 100644 --- a/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/modelVersionDetails.cy.ts +++ b/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/modelVersionDetails.cy.ts @@ -1,18 +1,14 @@ /* eslint-disable camelcase */ -import { - mockDashboardConfig, - mockK8sResourceList, - mockRouteK8sResourceModelRegistry, -} from '~/__mocks__'; +import { mockDashboardConfig, mockK8sResourceList } from '~/__mocks__'; import { mockComponents } from '~/__mocks__/mockComponents'; -import { mockModelRegistry } from '~/__mocks__/mockModelRegistry'; -import { ModelRegistryModel, RouteModel } from '~/__tests__/cypress/cypress/utils/models'; +import { ServiceModel } from '~/__tests__/cypress/cypress/utils/models'; import { modelVersionDetails } from '~/__tests__/cypress/cypress/pages/modelRegistry/modelVersionDetails'; import { mockRegisteredModel } from '~/__mocks__/mockRegisteredModel'; import { mockModelVersion } from '~/__mocks__/mockModelVersion'; import { mockModelVersionList } from '~/__mocks__/mockModelVersionList'; import { mockModelArtifactList } from '~/__mocks__/mockModelArtifactList'; import { verifyRelativeURL } from '~/__tests__/cypress/cypress/utils/url'; +import { mockModelRegistryService } from '~/__mocks__/mockModelRegistryService'; const MODEL_REGISTRY_API_VERSION = 'v1alpha3'; @@ -25,16 +21,12 @@ const initIntercepts = () => { ); cy.interceptOdh('GET /api/components', { query: { installed: 'true' } }, mockComponents()); - cy.interceptK8sList(ModelRegistryModel, mockK8sResourceList([mockModelRegistry({})])); - - cy.interceptK8s(ModelRegistryModel, mockModelRegistry({})); - - cy.interceptK8s( - RouteModel, - mockRouteK8sResourceModelRegistry({ - name: 'modelregistry-sample-http', - namespace: 'odh-model-registries', - }), + cy.interceptK8sList( + ServiceModel, + mockK8sResourceList([ + mockModelRegistryService({ name: 'modelregistry-sample' }), + mockModelRegistryService({ name: 'modelregistry-sample-2' }), + ]), ); cy.interceptOdh( diff --git a/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/modelVersions.cy.ts b/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/modelVersions.cy.ts index 89e5c30eef..543d5051fb 100644 --- a/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/modelVersions.cy.ts +++ b/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/modelVersions.cy.ts @@ -1,16 +1,16 @@ /* eslint-disable camelcase */ import { mockK8sResourceList } from '~/__mocks__'; import { mockDashboardConfig } from '~/__mocks__/mockDashboardConfig'; -import { mockModelRegistry } from '~/__mocks__/mockModelRegistry'; import { mockModelVersionList } from '~/__mocks__/mockModelVersionList'; import { mockRegisteredModelList } from '~/__mocks__/mockRegisteredModelsList'; import { labelModal, modelRegistry } from '~/__tests__/cypress/cypress/pages/modelRegistry'; import { be } from '~/__tests__/cypress/cypress/utils/should'; -import { ModelRegistryModel } from '~/__tests__/cypress/cypress/utils/models'; +import { ServiceModel } from '~/__tests__/cypress/cypress/utils/models'; import { verifyRelativeURL } from '~/__tests__/cypress/cypress/utils/url'; import { mockRegisteredModel } from '~/__mocks__/mockRegisteredModel'; import type { ModelVersion } from '~/concepts/modelRegistry/types'; import { mockModelVersion } from '~/__mocks__/mockModelVersion'; +import { mockModelRegistryService } from '~/__mocks__/mockModelRegistryService'; const MODEL_REGISTRY_API_VERSION = 'v1alpha3'; @@ -49,12 +49,13 @@ const initIntercepts = ({ ); cy.interceptK8sList( - ModelRegistryModel, - mockK8sResourceList([mockModelRegistry({}), mockModelRegistry({ name: 'test-registry' })]), + ServiceModel, + mockK8sResourceList([ + mockModelRegistryService({ name: 'modelregistry-sample' }), + mockModelRegistryService({ name: 'modelregistry-sample-2' }), + ]), ); - cy.interceptK8s(ModelRegistryModel, mockModelRegistry({})); - cy.interceptOdh( `GET /api/service/modelregistry/:serviceName/api/model_registry/:apiVersion/registered_models`, { path: { serviceName: 'modelregistry-sample', apiVersion: MODEL_REGISTRY_API_VERSION } }, diff --git a/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/registerModel.cy.ts b/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/registerModel.cy.ts index e108df61f0..594f974120 100644 --- a/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/registerModel.cy.ts +++ b/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/registerModel.cy.ts @@ -1,12 +1,11 @@ import { mockDashboardConfig, mockDscStatus, mockK8sResourceList } from '~/__mocks__'; import { mockDsciStatus } from '~/__mocks__/mockDsciStatus'; import { StackCapability, StackComponent } from '~/concepts/areas/types'; -import { ModelRegistryModel } from '~/__tests__/cypress/cypress/utils/models'; +import { ServiceModel } from '~/__tests__/cypress/cypress/utils/models'; import { FormFieldSelector, registerModelPage, } from '~/__tests__/cypress/cypress/pages/modelRegistry/registerModelPage'; -import { mockModelRegistry } from '~/__mocks__/mockModelRegistry'; import { mockRegisteredModel } from '~/__mocks__/mockRegisteredModel'; import { mockModelVersion } from '~/__mocks__/mockModelVersion'; import { mockModelArtifact } from '~/__mocks__/mockModelArtifact'; @@ -17,6 +16,7 @@ import { type ModelVersion, type ModelArtifact, } from '~/concepts/modelRegistry/types'; +import { mockModelRegistryService } from '~/__mocks__/mockModelRegistryService'; const MODEL_REGISTRY_API_VERSION = 'v1alpha3'; @@ -43,12 +43,13 @@ const initIntercepts = () => { }), ); - // TODO replace these with a mock list of services when https://github.com/opendatahub-io/odh-dashboard/pull/3034 is merged cy.interceptK8sList( - ModelRegistryModel, - mockK8sResourceList([mockModelRegistry({ name: 'modelregistry-sample' })]), + ServiceModel, + mockK8sResourceList([ + mockModelRegistryService({ name: 'modelregistry-sample' }), + mockModelRegistryService({ name: 'modelregistry-sample-2' }), + ]), ); - cy.interceptK8s(ModelRegistryModel, mockModelRegistry({ name: 'modelregistry-sample' })); cy.interceptOdh( 'POST /api/service/modelregistry/:serviceName/api/model_registry/:apiVersion/registered_models', diff --git a/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/registeredModelArchive.cy.ts b/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/registeredModelArchive.cy.ts index 97456039e2..8feea1aab0 100644 --- a/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/registeredModelArchive.cy.ts +++ b/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/registeredModelArchive.cy.ts @@ -1,9 +1,8 @@ /* eslint-disable camelcase */ import { mockK8sResourceList } from '~/__mocks__'; import { mockDashboardConfig } from '~/__mocks__/mockDashboardConfig'; -import { mockModelRegistry } from '~/__mocks__/mockModelRegistry'; import { mockRegisteredModelList } from '~/__mocks__/mockRegisteredModelsList'; -import { ModelRegistryModel } from '~/__tests__/cypress/cypress/utils/models'; +import { ServiceModel } from '~/__tests__/cypress/cypress/utils/models'; import { mockModelVersion } from '~/__mocks__/mockModelVersion'; import type { ModelVersion, RegisteredModel } from '~/concepts/modelRegistry/types'; import { ModelState } from '~/concepts/modelRegistry/types'; @@ -16,6 +15,7 @@ import { restoreModelModal, } from '~/__tests__/cypress/cypress/pages/modelRegistry/registeredModelArchive'; import { mockModelVersionList } from '~/__mocks__/mockModelVersionList'; +import { mockModelRegistryService } from '~/__mocks__/mockModelRegistryService'; const MODEL_REGISTRY_API_VERSION = 'v1alpha3'; @@ -58,12 +58,13 @@ const initIntercepts = ({ ); cy.interceptK8sList( - ModelRegistryModel, - mockK8sResourceList([mockModelRegistry({}), mockModelRegistry({ name: 'test-registry' })]), + ServiceModel, + mockK8sResourceList([ + mockModelRegistryService({ name: 'modelregistry-sample' }), + mockModelRegistryService({ name: 'modelregistry-sample-2' }), + ]), ); - cy.interceptK8s(ModelRegistryModel, mockModelRegistry({})); - cy.interceptOdh( `GET /api/service/modelregistry/:serviceName/api/model_registry/:apiVersion/registered_models`, { 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/api/index.ts b/frontend/src/api/index.ts index bb9c85fe40..1e1e791e57 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -45,6 +45,9 @@ export * from './models'; // User access review hook export * from './useAccessReview'; +// Rules access review hook +export * from './useRulesReview'; + // Explainability export * from './trustyai/custom'; export * from './trustyai/rawTypes'; diff --git a/frontend/src/api/modelRegistry/k8s.ts b/frontend/src/api/modelRegistry/k8s.ts index abcaf05c4a..5f4d8b2b29 100644 --- a/frontend/src/api/modelRegistry/k8s.ts +++ b/frontend/src/api/modelRegistry/k8s.ts @@ -1,8 +1,8 @@ import { k8sGetResource, k8sListResource } from '@openshift/dynamic-plugin-sdk-utils'; -import { K8sAPIOptions, ModelRegistryKind } from '~/k8sTypes'; +import { K8sAPIOptions, KnownLabels, ModelRegistryKind, ServiceKind } from '~/k8sTypes'; import { applyK8sAPIOptions } from '~/api/apiMergeUtils'; import { ModelRegistryModel } from '~/api/models/modelRegistry'; -import { MODEL_REGISTRY_DEFAULT_NAMESPACE } from '~/concepts/modelRegistry/const'; +import { ServiceModel } from '~/api/models'; export const getModelRegistryCR = async ( namespace: string, @@ -22,10 +22,11 @@ export const getModelRegistryCR = async ( ), ); -export const listModelRegistries = async (): Promise => - k8sListResource({ - model: ModelRegistryModel, +export const listServices = async (namespace: string): Promise => + k8sListResource({ + model: ServiceModel, queryOptions: { - ns: MODEL_REGISTRY_DEFAULT_NAMESPACE, + ns: namespace, + queryParams: { labelSelector: KnownLabels.LABEL_SELECTOR_MODEL_REGISTRY }, }, }).then((listResource) => listResource.items); diff --git a/frontend/src/api/models/k8s.ts b/frontend/src/api/models/k8s.ts index 3565a1c4ea..ba47097926 100644 --- a/frontend/src/api/models/k8s.ts +++ b/frontend/src/api/models/k8s.ts @@ -64,6 +64,19 @@ export const SelfSubjectAccessReviewModel: K8sModelCommon = { plural: 'selfsubjectaccessreviews', }; +export const SelfSubjectRulesReviewModel: K8sModelCommon = { + apiVersion: 'v1', + apiGroup: 'authorization.k8s.io', + kind: 'SelfSubjectRulesReview', + plural: 'selfsubjectrulesreviews', +}; + +export const ServiceModel: K8sModelCommon = { + apiVersion: 'v1', + kind: 'Service', + plural: 'services', +}; + export const ServiceAccountModel: K8sModelCommon = { apiVersion: 'v1', kind: 'ServiceAccount', diff --git a/frontend/src/api/useRulesReview.ts b/frontend/src/api/useRulesReview.ts new file mode 100644 index 0000000000..8b738d0e28 --- /dev/null +++ b/frontend/src/api/useRulesReview.ts @@ -0,0 +1,42 @@ +import { k8sCreateResource } from '@openshift/dynamic-plugin-sdk-utils'; +import * as React from 'react'; +import { SelfSubjectRulesReviewModel } from '~/api/models'; +import { SelfSubjectRulesReviewKind } from '~/k8sTypes'; + +const checkAccess = (ns: string): Promise => { + const selfSubjectRulesReview: SelfSubjectRulesReviewKind = { + apiVersion: 'authorization.k8s.io/v1', + kind: 'SelfSubjectRulesReview', + spec: { + namespace: ns, + }, + }; + return k8sCreateResource({ + model: SelfSubjectRulesReviewModel, + resource: selfSubjectRulesReview, + }); +}; + +export const useRulesReview = ( + namespace: string, +): [SelfSubjectRulesReviewKind['status'], boolean] => { + const [loaded, setLoaded] = React.useState(false); + const [status, setStatus] = React.useState(undefined); + + React.useEffect(() => { + checkAccess(namespace) + .then((result) => { + if (!result.status?.incomplete && !result.status?.evaluationError) { + setStatus(result.status); + } + setLoaded(true); + }) + .catch((e) => { + // eslint-disable-next-line no-console + console.warn('SelfSubjectRulesReview failed', e); + setLoaded(true); + }); + }, [namespace]); + + return [status, loaded]; +}; diff --git a/frontend/src/concepts/modelRegistry/apiHooks/__tests__/useModelRegistryServices.spec.ts b/frontend/src/concepts/modelRegistry/apiHooks/__tests__/useModelRegistryServices.spec.ts new file mode 100644 index 0000000000..e07936c3a5 --- /dev/null +++ b/frontend/src/concepts/modelRegistry/apiHooks/__tests__/useModelRegistryServices.spec.ts @@ -0,0 +1,147 @@ +import { k8sGetResource } from '@openshift/dynamic-plugin-sdk-utils'; +import { act } from 'react-dom/test-utils'; +import { useAccessReview, useRulesReview, listServices } from '~/api'; +import { ServiceKind } from '~/k8sTypes'; +import { useModelRegistryServices } from '~/concepts/modelRegistry/apiHooks/useModelRegistryServices'; +import { standardUseFetchState, testHook } from '~/__tests__/unit/testUtils/hooks'; +import { mockModelRegistryService } from '~/__mocks__/mockModelRegistryService'; + +jest.mock('@openshift/dynamic-plugin-sdk-utils', () => ({ + k8sListResource: jest.fn(), + k8sGetResource: jest.fn(), +})); + +jest.mock('~/api', () => ({ + useAccessReview: jest.fn(), + useRulesReview: jest.fn(), + listServices: jest.fn(), +})); + +jest.mock('~/concepts/modelRegistry/apiHooks/useModelRegistryServices', () => ({ + ...jest.requireActual('~/concepts/modelRegistry/apiHooks/useModelRegistryServices'), + fetchServices: jest.fn(), +})); + +const mockGetResource = jest.mocked(k8sGetResource); +const mockListServices = jest.mocked(listServices); + +describe('useModelRegistryServices', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('returns loading state initially', () => { + (useAccessReview as jest.Mock).mockReturnValue([false, false]); + (useRulesReview as jest.Mock).mockReturnValue([{}, false]); + const renderResult = testHook(useModelRegistryServices)(); + + expect(renderResult.result.current[1]).toBe(false); + expect(renderResult.result.current[0]).toEqual([]); + }); + + it('returns services when allowList is true', async () => { + (useAccessReview as jest.Mock).mockReturnValue([true, true]); + (useRulesReview as jest.Mock).mockReturnValue([{}, false]); + (useRulesReview as jest.Mock).mockReturnValue([ + { incomplete: false, nonResourceRules: [], resourceRules: [] }, + true, + ] satisfies ReturnType); + mockListServices.mockResolvedValue([ + mockModelRegistryService({ name: 'service-1', namespace: 'test-namespace' }), + ]); + + const renderResult = testHook(useModelRegistryServices)(); + expect(mockListServices).toHaveBeenCalledTimes(1); + expect(renderResult).hookToStrictEqual(standardUseFetchState([])); + expect(renderResult).hookToHaveUpdateCount(1); + + // wait for update + await renderResult.waitForNextUpdate(); + expect(mockListServices).toHaveBeenCalledTimes(1); + expect(renderResult).hookToStrictEqual( + standardUseFetchState( + [mockModelRegistryService({ name: 'service-1', namespace: 'test-namespace' })], + true, + ), + ); + expect(renderResult).hookToHaveUpdateCount(2); + expect(renderResult).hookToBeStable([false, false, true, true]); + + // refresh + mockListServices.mockResolvedValue([ + mockModelRegistryService({ name: 'service-1', namespace: 'test-namespace' }), + ]); + await act(() => renderResult.result.current[3]()); + expect(mockListServices).toHaveBeenCalledTimes(2); + expect(renderResult).hookToHaveUpdateCount(3); + expect(renderResult).hookToBeStable([false, true, true, true]); + }); + + it('returns services fetched by names when allowList is false', async () => { + (useAccessReview as jest.Mock).mockReturnValue([false, true]); + (useRulesReview as jest.Mock).mockReturnValue([ + { + resourceRules: [ + { + resources: ['services'], + verbs: ['get'], + resourceNames: ['service-1'], + }, + ], + }, + true, + ]); + mockGetResource.mockResolvedValue( + mockModelRegistryService({ name: 'service-1', namespace: 'test-namespace' }), + ); + + const renderResult = testHook(useModelRegistryServices)(); + expect(mockGetResource).toHaveBeenCalledTimes(1); + expect(renderResult).hookToStrictEqual(standardUseFetchState([])); + expect(renderResult).hookToHaveUpdateCount(1); + + // wait for update + await renderResult.waitForNextUpdate(); + expect(mockGetResource).toHaveBeenCalledTimes(1); + expect(renderResult).hookToStrictEqual( + standardUseFetchState( + [mockModelRegistryService({ name: 'service-1', namespace: 'test-namespace' })], + true, + ), + ); + expect(renderResult).hookToHaveUpdateCount(2); + expect(renderResult).hookToBeStable([false, false, true, true]); + + // refresh + mockGetResource.mockResolvedValue(mockModelRegistryService({})); + await act(() => renderResult.result.current[3]()); + expect(mockGetResource).toHaveBeenCalledTimes(2); + expect(renderResult).hookToHaveUpdateCount(3); + expect(renderResult).hookToBeStable([false, true, true, true]); + }); + + test('returns empty array if no service names are provided', async () => { + (useAccessReview as jest.Mock).mockReturnValue([false, true]); + (useRulesReview as jest.Mock).mockReturnValue([ + { + resourceRules: [ + { + resources: ['services'], + verbs: ['use'], + resourceNames: ['service-1'], + }, + ], + }, + true, + ]); + mockGetResource.mockResolvedValue( + mockModelRegistryService({ name: 'service-1', namespace: 'test-namespace' }), + ); + const renderResult = testHook(useModelRegistryServices)(); + await renderResult.waitForNextUpdate(); + + expect(renderResult.result.current[0]).toEqual([]); + expect(renderResult.result.current[1]).toBe(true); + expect(mockGetResource).toHaveBeenCalledTimes(0); + }); +}); diff --git a/frontend/src/concepts/modelRegistry/apiHooks/useModelRegistries.ts b/frontend/src/concepts/modelRegistry/apiHooks/useModelRegistries.ts deleted file mode 100644 index 18d94e6f75..0000000000 --- a/frontend/src/concepts/modelRegistry/apiHooks/useModelRegistries.ts +++ /dev/null @@ -1,11 +0,0 @@ -import React from 'react'; -import useFetchState, { FetchState } from '~/utilities/useFetchState'; -import { ModelRegistryKind } from '~/k8sTypes'; -import { listModelRegistries } from '~/api'; - -const useModelRegistries = (): FetchState => { - const getModelRegistries = React.useCallback(() => listModelRegistries(), []); - return useFetchState(getModelRegistries, []); -}; - -export default useModelRegistries; diff --git a/frontend/src/concepts/modelRegistry/apiHooks/useModelRegistryServices.ts b/frontend/src/concepts/modelRegistry/apiHooks/useModelRegistryServices.ts new file mode 100644 index 0000000000..592a9ffb51 --- /dev/null +++ b/frontend/src/concepts/modelRegistry/apiHooks/useModelRegistryServices.ts @@ -0,0 +1,87 @@ +import React from 'react'; +import { k8sGetResource } from '@openshift/dynamic-plugin-sdk-utils'; +import useFetchState, { + FetchState, + FetchStateCallbackPromise, + NotReadyError, +} from '~/utilities/useFetchState'; +import { AccessReviewResourceAttributes, ServiceKind } from '~/k8sTypes'; +import { ServiceModel, useAccessReview, useRulesReview, listServices } from '~/api'; +import { MODEL_REGISTRY_DEFAULT_NAMESPACE } from '~/concepts/modelRegistry/const'; + +const accessReviewResource: AccessReviewResourceAttributes = { + group: 'user.openshift.io', + resource: 'services', + verb: 'list', + namespace: MODEL_REGISTRY_DEFAULT_NAMESPACE, +}; + +const getServiceByName = (name: string, namespace: string): Promise => + k8sGetResource({ + model: ServiceModel, + queryOptions: { name, ns: namespace }, + }); + +const fetchServices = async (names: string[], namespace: string): Promise => { + if (!namespace) { + throw new NotReadyError('No namespace'); + } + + if (names.length === 0) { + return []; + } + + const servicePromises = names.map((name) => getServiceByName(name, namespace)); + const services = await Promise.all(servicePromises); + + return services; +}; + +const listServicesOrFetchThemByNames = async ( + allowList: boolean, + accessReviewLoaded: boolean, + rulesReviewLoaded: boolean, + serviceNames?: string[], +): Promise => { + if (!accessReviewLoaded || !rulesReviewLoaded) { + throw new NotReadyError('Access review or Rules review not loaded'); + } + + const services = allowList + ? await listServices(MODEL_REGISTRY_DEFAULT_NAMESPACE) + : await fetchServices(serviceNames || [], MODEL_REGISTRY_DEFAULT_NAMESPACE); + + return services; +}; + +export const useModelRegistryServices = (): FetchState => { + const [allowList, accessReviewLoaded] = useAccessReview(accessReviewResource); + const [statuses, rulesReviewLoaded] = useRulesReview(MODEL_REGISTRY_DEFAULT_NAMESPACE); + + const serviceNames = React.useMemo(() => { + if (!rulesReviewLoaded) { + return []; + } + return statuses?.resourceRules + .filter( + ({ resources, verbs }) => + resources?.includes('services') && verbs.some((verb) => verb === 'get'), + ) + .flatMap((rule) => rule.resourceNames || []); + }, [rulesReviewLoaded, statuses]); + + const callback = React.useCallback>( + () => + listServicesOrFetchThemByNames( + allowList, + accessReviewLoaded, + rulesReviewLoaded, + serviceNames, + ), + [allowList, accessReviewLoaded, rulesReviewLoaded, serviceNames], + ); + + return useFetchState(callback, [], { + initialPromisePurity: true, + }); +}; diff --git a/frontend/src/concepts/modelRegistry/context/ModelRegistryContext.tsx b/frontend/src/concepts/modelRegistry/context/ModelRegistryContext.tsx index dfde7ebcc8..92d045a96b 100644 --- a/frontend/src/concepts/modelRegistry/context/ModelRegistryContext.tsx +++ b/frontend/src/concepts/modelRegistry/context/ModelRegistryContext.tsx @@ -1,21 +1,9 @@ import * as React from 'react'; -import { Alert, Bullseye } from '@patternfly/react-core'; import { SupportedArea, conditionalArea } from '~/concepts/areas'; -import { MODEL_REGISTRY_DEFAULT_NAMESPACE } from '~/concepts/modelRegistry/const'; import useModelRegistryAPIState, { ModelRegistryAPIState } from './useModelRegistryAPIState'; -import { - hasServerTimedOut, - isModelRegistryAvailable, - useModelRegistryNamespaceCR, -} from './useModelRegistryNamespaceCR'; export type ModelRegistryContextType = { - hasCR: boolean; - crInitializing: boolean; - serverTimedOut: boolean; apiState: ModelRegistryAPIState; - ignoreTimedOut: () => void; - refreshState: () => Promise; refreshAPIState: () => void; }; @@ -25,13 +13,8 @@ type ModelRegistryContextProviderProps = { }; export const ModelRegistryContext = React.createContext({ - hasCR: false, - crInitializing: false, - serverTimedOut: false, // eslint-disable-next-line @typescript-eslint/consistent-type-assertions apiState: { apiAvailable: false, api: null as unknown as ModelRegistryAPIState['api'] }, - ignoreTimedOut: () => undefined, - refreshState: async () => undefined, refreshAPIState: () => undefined, }); @@ -39,44 +22,14 @@ export const ModelRegistryContextProvider = conditionalArea { - const state = useModelRegistryNamespaceCR(MODEL_REGISTRY_DEFAULT_NAMESPACE, modelRegistryName); - const [modelRegistryCR, crLoaded, crLoadError, refreshCR] = state; - const isCRReady = isModelRegistryAvailable(state); - - const [disableTimeout, setDisableTimeout] = React.useState(false); - const serverTimedOut = !disableTimeout && hasServerTimedOut(state, isCRReady); - const ignoreTimedOut = React.useCallback(() => { - setDisableTimeout(true); - }, []); - const hostPath = modelRegistryName ? `/api/service/modelregistry/${modelRegistryName}` : null; const [apiState, refreshAPIState] = useModelRegistryAPIState(hostPath); - const refreshState = React.useCallback( - () => Promise.all([refreshCR()]).then(() => undefined), - [refreshCR], - ); - - if (crLoadError) { - return ( - - - {crLoadError.message} - - - ); - } - return ( diff --git a/frontend/src/concepts/modelRegistry/context/ModelRegistrySelectorContext.tsx b/frontend/src/concepts/modelRegistry/context/ModelRegistrySelectorContext.tsx index ae4863f77e..1ac9f2b7f5 100644 --- a/frontend/src/concepts/modelRegistry/context/ModelRegistrySelectorContext.tsx +++ b/frontend/src/concepts/modelRegistry/context/ModelRegistrySelectorContext.tsx @@ -1,14 +1,14 @@ import * as React from 'react'; -import { ModelRegistryKind } from '~/k8sTypes'; -import useModelRegistries from '~/concepts/modelRegistry/apiHooks/useModelRegistries'; +import { ServiceKind } from '~/k8sTypes'; import useModelRegistryEnabled from '~/concepts/modelRegistry/useModelRegistryEnabled'; +import { useModelRegistryServices } from '~/concepts/modelRegistry/apiHooks/useModelRegistryServices'; export type ModelRegistrySelectorContextType = { - modelRegistriesLoaded: boolean; - modelRegistriesLoadError?: Error; - modelRegistries: ModelRegistryKind[]; - preferredModelRegistry: ModelRegistryKind | undefined; - updatePreferredModelRegistry: (modelRegistry: ModelRegistryKind | undefined) => void; + modelRegistryServicesLoaded: boolean; + modelRegistryServicesLoadError?: Error; + modelRegistryServices: ServiceKind[]; + preferredModelRegistry: ServiceKind | undefined; + updatePreferredModelRegistry: (modelRegistry: ServiceKind | undefined) => void; }; type ModelRegistrySelectorContextProviderProps = { @@ -16,9 +16,9 @@ type ModelRegistrySelectorContextProviderProps = { }; export const ModelRegistrySelectorContext = React.createContext({ - modelRegistriesLoaded: false, - modelRegistriesLoadError: undefined, - modelRegistries: [], + modelRegistryServicesLoaded: false, + modelRegistryServicesLoadError: undefined, + modelRegistryServices: [], preferredModelRegistry: undefined, updatePreferredModelRegistry: () => undefined, }); @@ -39,23 +39,23 @@ export const ModelRegistrySelectorContextProvider: React.FC< const EnabledModelRegistrySelectorContextProvider: React.FC< ModelRegistrySelectorContextProviderProps > = ({ children }) => { - const [modelRegistries, isLoaded, error] = useModelRegistries(); + const [modelRegistryServices, isLoaded, error] = useModelRegistryServices(); const [preferredModelRegistry, setPreferredModelRegistry] = React.useState(undefined); - const firstModelRegistry = modelRegistries.length > 0 ? modelRegistries[0] : null; + const firstModelRegistry = modelRegistryServices.length > 0 ? modelRegistryServices[0] : null; return ( ({ - modelRegistriesLoaded: isLoaded, - modelRegistriesLoadError: error, - modelRegistries, + modelRegistryServicesLoaded: isLoaded, + modelRegistryServicesLoadError: error, + modelRegistryServices, preferredModelRegistry: preferredModelRegistry ?? firstModelRegistry ?? undefined, updatePreferredModelRegistry: setPreferredModelRegistry, }), - [isLoaded, error, modelRegistries, preferredModelRegistry, firstModelRegistry], + [isLoaded, error, modelRegistryServices, preferredModelRegistry, firstModelRegistry], )} > {children} diff --git a/frontend/src/concepts/pipelines/content/pipelinesDetails/pipeline/PipelineDetailsActions.tsx b/frontend/src/concepts/pipelines/content/pipelinesDetails/pipeline/PipelineDetailsActions.tsx index 12e4a0739a..1fb5fc40d9 100644 --- a/frontend/src/concepts/pipelines/content/pipelinesDetails/pipeline/PipelineDetailsActions.tsx +++ b/frontend/src/concepts/pipelines/content/pipelinesDetails/pipeline/PipelineDetailsActions.tsx @@ -1,12 +1,7 @@ import * as React from 'react'; import { useNavigate } from 'react-router-dom'; -import { - Dropdown, - DropdownItem, - DropdownSeparator, - DropdownToggle, -} from '@patternfly/react-core/deprecated'; +import { Divider, Dropdown, DropdownItem, DropdownList, MenuToggle } from '@patternfly/react-core'; import { usePipelinesAPI } from '~/concepts/pipelines/context'; import PipelineVersionImportModal from '~/concepts/pipelines/content/import/PipelineVersionImportModal'; @@ -45,99 +40,111 @@ const PipelineDetailsActions: React.FC = ({ return ( <> setOpen(isOpenChange)} + shouldFocusToggleOnSelect onSelect={() => setOpen(false)} - menuAppendTo={getDashboardMainContainer} - toggle={ - setOpen(!open)}> + popperProps={{ appendTo: getDashboardMainContainer, position: 'right' }} + toggle={(toggleRef) => ( + setOpen(!open)} + isExpanded={open} + > Actions - - } + + )} isOpen={open} - position="right" - dropdownItems={[ - setIsVersionImportModalOpen(true)}> - Upload new version - , - , - - navigate( - pipelineVersionCreateRunRoute( - namespace, - pipeline?.pipeline_id, - pipelineVersion?.pipeline_version_id, - ), - { - state: { lastPipeline: pipeline, lastVersion: pipelineVersion }, - }, - ) - } - > - Create run - , - - navigate( - pipelineVersionCreateRecurringRunRoute( - namespace, - pipeline?.pipeline_id, - pipelineVersion?.pipeline_version_id, - ), - { - state: { lastPipeline: pipeline, lastVersion: pipelineVersion }, - }, - ) - } - > - Create schedule - , - ...(pipeline && pipelineVersion - ? [ - , - - navigate( - pipelineVersionRunsRoute( - namespace, - pipeline.pipeline_id, - pipelineVersion.pipeline_version_id, - ), - ) - } - > - View runs - , - - navigate( - pipelineVersionRecurringRunsRoute( - namespace, - pipeline.pipeline_id, - pipelineVersion.pipeline_version_id, - ), - ) - } - > - View schedules - , - ] - : []), - - , - onDelete()}> - Delete pipeline version - , - ]} - /> + > + + {[ + setIsVersionImportModalOpen(true)}> + Upload new version + , + , + + navigate( + pipelineVersionCreateRunRoute( + namespace, + pipeline?.pipeline_id, + pipelineVersion?.pipeline_version_id, + ), + { + state: { lastPipeline: pipeline, lastVersion: pipelineVersion }, + }, + ) + } + > + Create run + , + + navigate( + pipelineVersionCreateRecurringRunRoute( + namespace, + pipeline?.pipeline_id, + pipelineVersion?.pipeline_version_id, + ), + { + state: { lastPipeline: pipeline, lastVersion: pipelineVersion }, + }, + ) + } + > + Create schedule + , + ...(pipeline && pipelineVersion + ? [ + , + + navigate( + pipelineVersionRunsRoute( + namespace, + pipeline.pipeline_id, + pipelineVersion.pipeline_version_id, + ), + { + state: { lastVersion: pipelineVersion }, + }, + ) + } + > + View runs + , + + navigate( + pipelineVersionRecurringRunsRoute( + namespace, + pipeline.pipeline_id, + pipelineVersion.pipeline_version_id, + ), + ) + } + > + View schedules + , + ] + : []), + , + onDelete()}> + Delete pipeline version + , + ]} + + {isVersionImportModalOpen && ( setOpen(false)} - menuAppendTo={getDashboardMainContainer} - toggle={ - setOpen(!open)}> + onOpenChange={(isOpenChange) => setOpen(isOpenChange)} + shouldFocusToggleOnSelect + toggle={(toggleRef) => ( + setOpen(!open)} + isExpanded={open} + > Actions - - } + + )} isOpen={open} - position="right" - dropdownItems={ - !recurringRun + popperProps={{ position: 'right', appendTo: getDashboardMainContainer() }} + > + + {!recurringRun ? [] : [ ...(isPipelineSupported @@ -113,15 +121,15 @@ const PipelineRecurringRunDetailsActions: React.FC Duplicate , - , + , ] : []), onDelete()}> Delete , - ] - } - /> + ]} + + ); }; diff --git a/frontend/src/concepts/pipelines/content/pipelinesDetails/pipelineRun/PipelineRunDetailsActions.tsx b/frontend/src/concepts/pipelines/content/pipelinesDetails/pipelineRun/PipelineRunDetailsActions.tsx index 2ab3222b31..30439d8df6 100644 --- a/frontend/src/concepts/pipelines/content/pipelinesDetails/pipelineRun/PipelineRunDetailsActions.tsx +++ b/frontend/src/concepts/pipelines/content/pipelinesDetails/pipelineRun/PipelineRunDetailsActions.tsx @@ -1,11 +1,13 @@ import * as React from 'react'; -import { Tooltip } from '@patternfly/react-core'; import { + Tooltip, + Divider, Dropdown, DropdownItem, - DropdownSeparator, - DropdownToggle, -} from '@patternfly/react-core/deprecated'; + MenuToggle, + DropdownList, +} from '@patternfly/react-core'; + import { useNavigate, useParams } from 'react-router-dom'; import { usePipelinesAPI } from '~/concepts/pipelines/context'; import useNotification from '~/utilities/useNotification'; @@ -56,18 +58,26 @@ const PipelineRunDetailsActions: React.FC = ({ return ( setOpen(isOpenChange)} + shouldFocusToggleOnSelect onSelect={() => setOpen(false)} - menuAppendTo={getDashboardMainContainer} - toggle={ - setOpen(!open)}> + toggle={(toggleRef) => ( + setOpen(!open)} + isExpanded={open} + > Actions - - } + + )} isOpen={open} - position="right" - dropdownItems={ - !run + popperProps={{ position: 'right', appendTo: getDashboardMainContainer() }} + > + + {!run ? [] : [ ...(isPipelineSupported @@ -147,7 +157,7 @@ const PipelineRunDetailsActions: React.FC = ({ ) : ( ), - , + , ] : []), !isRunActive ? ( @@ -159,9 +169,9 @@ const PipelineRunDetailsActions: React.FC = ({ onArchive()}>Archive ), - ] - } - /> + ]} + + ); }; diff --git a/frontend/src/concepts/pipelines/content/pipelinesDetails/pipelineRun/runLogs/DownloadDropdown.tsx b/frontend/src/concepts/pipelines/content/pipelinesDetails/pipelineRun/runLogs/DownloadDropdown.tsx index c1b5fb63b3..afa6ab7bed 100644 --- a/frontend/src/concepts/pipelines/content/pipelinesDetails/pipelineRun/runLogs/DownloadDropdown.tsx +++ b/frontend/src/concepts/pipelines/content/pipelinesDetails/pipelineRun/runLogs/DownloadDropdown.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Dropdown, DropdownItem, DropdownToggle } from '@patternfly/react-core/deprecated'; +import { Dropdown, DropdownItem, DropdownList, MenuToggle } from '@patternfly/react-core'; import { DownloadIcon } from '@patternfly/react-icons'; type DownloadDropdownProps = { @@ -17,22 +17,27 @@ const DownloadDropdown: React.FC = ({ return ( setIsDownloadDropdownOpen(isOpenChange)} + shouldFocusToggleOnSelect + toggle={(toggleRef) => ( + setIsDownloadDropdownOpen(!isDownloadDropdownOpen)} + onClick={() => setIsDownloadDropdownOpen(!isDownloadDropdownOpen)} + isExpanded={isDownloadDropdownOpen} > - - } + + )} isOpen={isDownloadDropdownOpen} - dropdownItems={[ + > + = ({ onClick={onDownload} > Download current step log - , + Download all step logs - , - ]} - /> + + + ); }; export default DownloadDropdown; diff --git a/frontend/src/concepts/pipelines/content/tables/PipelineFilterBar.tsx b/frontend/src/concepts/pipelines/content/tables/PipelineFilterBar.tsx index 81c57a2034..19533a328e 100644 --- a/frontend/src/concepts/pipelines/content/tables/PipelineFilterBar.tsx +++ b/frontend/src/concepts/pipelines/content/tables/PipelineFilterBar.tsx @@ -5,8 +5,11 @@ import { ToolbarItem, ToolbarChip, Tooltip, + Dropdown, + DropdownItem, + MenuToggle, + DropdownList, } from '@patternfly/react-core'; -import { Dropdown, DropdownItem, DropdownToggle } from '@patternfly/react-core/deprecated'; import { FilterIcon } from '@patternfly/react-icons'; import { FilterOptions } from '~/concepts/pipelines/content/tables/usePipelineFilter'; @@ -54,28 +57,39 @@ export function FilterToolbar({ setOpen(!open)}> + onOpenChange={(isOpenChange) => setOpen(isOpenChange)} + shouldFocusToggleOnSelect + toggle={(toggleRef) => ( + setOpen(!open)} + isExpanded={open} + > <> {filterOptions[currentFilterType]} - - } + + )} isOpen={open} - dropdownItems={keys.map((filterKey) => ( - { - setOpen(false); - setCurrentFilterType(filterKey); - }} - > - {filterOptions[filterKey]} - - ))} - data-testid={`${testId}-dropdown`} - /> + > + + {keys.map((filterKey) => ( + { + setOpen(false); + setCurrentFilterType(filterKey); + }} + > + {filterOptions[filterKey]} + + ))} + + = ({ const selector = ( setDropdownOpen(isOpenChange)} + shouldFocusToggleOnSelect + toggle={(toggleRef) => ( + setDropdownOpen(!dropdownOpen)} - toggleVariant={primary ? 'primary' : undefined} + ref={toggleRef} aria-label={`Project: ${toggleLabel}`} + variant={primary ? 'primary' : undefined} + onClick={() => setDropdownOpen(!dropdownOpen)} + isExpanded={dropdownOpen} + data-testid="project-selector-dropdown" > {toggleLabel} - - } + + )} isOpen={dropdownOpen} - dropdownItems={[ - ...(selectAllProjects - ? [ - { - setDropdownOpen(false); - onSelection(''); - updatePreferredProject(null); - }} - > - All projects - , - ] - : []), - ...filteredProjects.map((project) => ( - { - setDropdownOpen(false); - onSelection(project.metadata.name); - }} - > - {getDisplayNameFromK8sResource(project)} - - )), - ]} - data-testid="project-selector-dropdown" - /> + > + + {[ + ...(selectAllProjects + ? [ + { + setDropdownOpen(false); + onSelection(''); + updatePreferredProject(null); + }} + > + All projects + , + ] + : []), + ...filteredProjects.map((project) => ( + { + setDropdownOpen(false); + onSelection(project.metadata.name); + }} + > + {getDisplayNameFromK8sResource(project)} + + )), + ]} + + ); if (showTitle) { return ( 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 5c5b918ffc..40c603f63d 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 = @@ -1028,6 +1029,51 @@ export type SelfSubjectAccessReviewKind = K8sResourceCommon & { }; }; +export type SelfSubjectRulesReviewKind = K8sResourceCommon & { + spec: { + namespace: string; + }; + status?: { + incomplete: boolean; + nonResourceRules: { + verbs: string[]; + nonResourceURLs?: string[]; + }[]; + resourceRules: { + verbs: string[]; + apiGroups?: string[]; + resourceNames?: string[]; + resources?: string[]; + }[]; + evaluationError?: string; + }; +}; + +export type ServiceKind = K8sResourceCommon & { + metadata: { + annotations?: DisplayNameAnnotations; + name: string; + namespace: string; + labels?: Partial<{ + 'opendatahub.io/user': string; + component: string; + }>; + }; + spec: { + selector: { + app: string; + component: string; + }; + ports: { + name?: string; + protocol?: string; + appProtocol?: string; + port?: number; + targetPort?: number | string; + }[]; + }; +}; + /** @deprecated - Tekton is no longer used */ export type PipelineRunTaskSpecDigest = { name: string; diff --git a/frontend/src/pages/modelRegistry/ModelRegistryCoreLoader.tsx b/frontend/src/pages/modelRegistry/ModelRegistryCoreLoader.tsx index f4e1d721fd..4a8c3a2828 100644 --- a/frontend/src/pages/modelRegistry/ModelRegistryCoreLoader.tsx +++ b/frontend/src/pages/modelRegistry/ModelRegistryCoreLoader.tsx @@ -28,28 +28,27 @@ const ModelRegistryCoreLoader: React.FC = )(({ getInvalidRedirectPath }) => { const { modelRegistry } = useParams<{ modelRegistry: string }>(); const { - modelRegistriesLoaded, - modelRegistriesLoadError, - modelRegistries, + modelRegistryServicesLoaded, + modelRegistryServicesLoadError, + modelRegistryServices, preferredModelRegistry, } = React.useContext(ModelRegistrySelectorContext); - if (modelRegistriesLoadError) { + if (modelRegistryServicesLoadError) { return ( - {modelRegistriesLoadError.message} + {modelRegistryServicesLoadError.message} ); } - - if (!modelRegistriesLoaded) { + if (!modelRegistryServicesLoaded) { return Loading model registries...; } let renderStateProps: ApplicationPageRenderState & { children?: React.ReactNode }; - if (modelRegistries.length === 0) { + if (modelRegistryServices.length === 0) { renderStateProps = { empty: true, emptyStatePage: ( @@ -65,7 +64,9 @@ const ModelRegistryCoreLoader: React.FC = ), }; } else if (modelRegistry) { - const foundModelRegistry = modelRegistries.find((mr) => mr.metadata.name === modelRegistry); + const foundModelRegistry = modelRegistryServices.find( + (mr) => mr.metadata.name === modelRegistry, + ); if (foundModelRegistry) { // Render the content return ( @@ -82,7 +83,7 @@ const ModelRegistryCoreLoader: React.FC = }; } else { // Redirect the namespace suffix into the URL - const redirectModelRegistry = preferredModelRegistry ?? modelRegistries[0]; + const redirectModelRegistry = preferredModelRegistry ?? modelRegistryServices[0]; return ; } diff --git a/frontend/src/pages/modelRegistry/screens/ModelRegistrySelector.tsx b/frontend/src/pages/modelRegistry/screens/ModelRegistrySelector.tsx index 89b14afd4f..3591b97cff 100644 --- a/frontend/src/pages/modelRegistry/screens/ModelRegistrySelector.tsx +++ b/frontend/src/pages/modelRegistry/screens/ModelRegistrySelector.tsx @@ -27,7 +27,7 @@ import { getDisplayNameFromK8sResource, getResourceNameFromK8sResource, } from '~/concepts/k8s/utils'; -import { ModelRegistryKind } from '~/k8sTypes'; +import { ServiceKind } from '~/k8sTypes'; const MODEL_REGISTRY_FAVORITE_STORAGE_KEY = 'odh.dashboard.model.registry.favorite'; @@ -42,10 +42,10 @@ const ModelRegistrySelector: React.FC = ({ onSelection, primary, }) => { - const { modelRegistries, updatePreferredModelRegistry } = React.useContext( + const { modelRegistryServices, updatePreferredModelRegistry } = React.useContext( ModelRegistrySelectorContext, ); - const selection = modelRegistries.find((mr) => mr.metadata.name === modelRegistry); + const selection = modelRegistryServices.find((mr) => mr.metadata.name === modelRegistry); const [isOpen, setIsOpen] = React.useState(false); const [favorites, setFavorites] = useBrowserStorage( MODEL_REGISTRY_FAVORITE_STORAGE_KEY, @@ -54,9 +54,10 @@ const ModelRegistrySelector: React.FC = ({ const selectionDisplayName = selection ? getDisplayNameFromK8sResource(selection) : modelRegistry; - const toggleLabel = modelRegistries.length === 0 ? 'No model registries' : selectionDisplayName; + const toggleLabel = + modelRegistryServices.length === 0 ? 'No model registries' : selectionDisplayName; - const getMRSelectDescription = (mr: ModelRegistryKind) => { + const getMRSelectDescription = (mr: ServiceKind) => { const desc = getDescriptionFromK8sResource(mr); if (!desc) { return; @@ -82,7 +83,7 @@ const ModelRegistrySelector: React.FC = ({ const options = [ - {modelRegistries.map((mr) => ( + {modelRegistryServices.map((mr) => ( = ({ toggleId="model-registry-selector-dropdown" variant={SelectVariant.single} onToggle={() => setIsOpen(!isOpen)} - isDisabled={modelRegistries.length === 0} + isDisabled={modelRegistryServices.length === 0} onSelect={(_e, value) => { setIsOpen(false); - updatePreferredModelRegistry(modelRegistries.find((obj) => obj.metadata.name === value)); + updatePreferredModelRegistry( + modelRegistryServices.find((obj) => obj.metadata.name === value), + ); if (typeof value === 'string') { onSelection(value); } 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; diff --git a/frontend/src/pages/projects/screens/projects/NotebookStateStatus.tsx b/frontend/src/pages/projects/screens/projects/NotebookStateStatus.tsx index 79da13d57b..c79f4ed910 100644 --- a/frontend/src/pages/projects/screens/projects/NotebookStateStatus.tsx +++ b/frontend/src/pages/projects/screens/projects/NotebookStateStatus.tsx @@ -18,7 +18,7 @@ const NotebookStateStatus: React.FC = ({ return (