From ff96d4591b5b5af64c453809ac9ff07aa099e2c8 Mon Sep 17 00:00:00 2001 From: Darryl Irvine Date: Wed, 13 May 2026 12:07:03 +0200 Subject: [PATCH] Fix project membership tab showing missing role bindings and duplicate prevention - Scope Norman PRTB fetch to current cluster's projects using repeated projectId_in query parameters. Norman's _in filter requires repeated params; comma-separated values are parsed as a single literal string by Go's net/url.ParseQuery and match nothing. - Set depaginate: true for Norman PRTBs in explorer.js. The fetch is now bounded by the cluster scope so full pagination is safe. The previous conditional (maxResourceCount: 5000) suppressed pagination at exactly the scale where scoping is needed most. - Fix rowsWithFakeProjects dropping projects with no role bindings. Placeholder rows were spread into the reduce input but immediately discarded (no userId/groupPrincipalId). They are now appended to the return value outside the reduce. - Prevent duplicate role assignments in AddProjectMemberDialog. Before creating a PRTB, filter selected roleTemplateIds against existing store entries keyed on projectId + roleTemplateId + principalId. Show an error if all roles are duplicates; a warning if some are. --- shell/assets/translations/en-us.yaml | 2 ++ shell/components/ExplorerMembers.vue | 45 +++++++++++++++++++------ shell/config/product/explorer.js | 2 +- shell/dialog/AddProjectMemberDialog.vue | 37 ++++++++++++++++++-- 4 files changed, 72 insertions(+), 14 deletions(-) diff --git a/shell/assets/translations/en-us.yaml b/shell/assets/translations/en-us.yaml index 7d037bb18b0..802aa696054 100644 --- a/shell/assets/translations/en-us.yaml +++ b/shell/assets/translations/en-us.yaml @@ -483,6 +483,8 @@ addonConfigConfirmation: addProjectMemberDialog: title: Add Project Member + duplicateRolesError: This member already has all of the selected roles in this project. No new assignments were created. + duplicateRolesSkipped: "One or more selected roles were skipped because they are already assigned to this member in this project." authConfig: slo: diff --git a/shell/components/ExplorerMembers.vue b/shell/components/ExplorerMembers.vue index 0ba95cd155c..2b002c2fb4a 100644 --- a/shell/components/ExplorerMembers.vue +++ b/shell/components/ExplorerMembers.vue @@ -62,17 +62,40 @@ export default { }); } + // Fetch projects first so we can scope the PRTB request to this cluster's projects only. + // Fetching all PRTBs globally without a filter causes the Norman API's page limit to be + // hit before cluster-specific results are loaded, resulting in most bindings being invisible + // after client-side filtering. + const allProjects = await this.$store.dispatch('management/findAll', { type: MANAGEMENT.PROJECT }); + + this['projects'] = allProjects; + if (projectRoleTemplateBindingSchema) { - this.$store.dispatch('rancher/findAll', { type: NORMAN.PROJECT_ROLE_TEMPLATE_BINDING, opt: { force: true } }, { root: true }) - .then((bindings) => { - this['projectRoleTemplateBindings'] = bindings; - this.loadingProjectBindings = false; - }); + const clusterId = this.$store.getters['currentCluster'].id; + // Norman projectId format is 'clusterId:projectNamespace' (e.g. 'local:p-v679w'). + // Management project id format is 'clusterId/projectNamespace' — replace '/' with ':'. + // projectId is a proper Norman reference field and is safe to filter on; namespaceId is + // a mapper-moved field that may not be indexed for filtering. + const projectIds = allProjects + .filter((p) => p?.spec?.clusterName === clusterId) + .map((p) => p.id.replace('/', ':')) + .filter(Boolean); + + if (projectIds.length > 0) { + // Norman's _in filter does not split comma-separated values — each value must be + // a separate repeated query parameter (e.g. ?projectId_in=a&projectId_in=b). + const url = `/v3/projectroletemplatebindings?${ projectIds.map((id) => `projectId_in=${ encodeURIComponent(id) }`).join('&') }`; + + this.$store.dispatch('rancher/findAll', { type: NORMAN.PROJECT_ROLE_TEMPLATE_BINDING, opt: { force: true, url } }, { root: true }) + .then((bindings) => { + this['projectRoleTemplateBindings'] = bindings; + this.loadingProjectBindings = false; + }); + } else { + this.loadingProjectBindings = false; + } } - this.$store.dispatch('management/findAll', { type: MANAGEMENT.PROJECT }) - .then((projects) => (this['projects'] = projects)); - const hydration = { normanPrincipals: this.$store.dispatch('rancher/findAll', { type: NORMAN.PRINCIPAL }), mgmt: this.$store.dispatch(`management/findAll`, { type: MANAGEMENT.USER }), @@ -186,7 +209,7 @@ export default { }); // We need to group each of the TemplateRoleBindings by the user + project - const userRoles = [...fakeRows, ...this.filteredProjectRoleTemplateBindings].reduce((rows, curr) => { + const userRoles = this.filteredProjectRoleTemplateBindings.reduce((rows, curr) => { const { userId, groupPrincipalId, roleTemplate, projectId } = curr; @@ -211,7 +234,9 @@ export default { return rows; }, {}); - return Object.values(userRoles); + // fakeRows must be added outside the reduce — they have no userId/groupPrincipalId + // and would be silently dropped if included in the reduce input. + return [...Object.values(userRoles), ...fakeRows]; }, canManageMembers() { return canViewClusterPermissionsEditor(this.$store); diff --git a/shell/config/product/explorer.js b/shell/config/product/explorer.js index 5e8b33e5091..fbb45b7b4ab 100644 --- a/shell/config/product/explorer.js +++ b/shell/config/product/explorer.js @@ -191,7 +191,7 @@ export function init(store) { configureType(MANAGEMENT.PROJECT_ROLE_TEMPLATE_BINDING, { isEditable: false, depaginate: dePaginateBindings }); configureType(MANAGEMENT.PROJECT, { displayName: store.getters['i18n/t']('namespace.project.label') }); configureType(NORMAN.CLUSTER_ROLE_TEMPLATE_BINDING, { depaginate: dePaginateNormanBindings }); - configureType(NORMAN.PROJECT_ROLE_TEMPLATE_BINDING, { depaginate: dePaginateNormanBindings }); + configureType(NORMAN.PROJECT_ROLE_TEMPLATE_BINDING, { depaginate: true }); configureType(SNAPSHOT, { depaginate: true }); configureType(SECRET, { showListMasthead: false }); diff --git a/shell/dialog/AddProjectMemberDialog.vue b/shell/dialog/AddProjectMemberDialog.vue index f013750d411..eac555c0239 100644 --- a/shell/dialog/AddProjectMemberDialog.vue +++ b/shell/dialog/AddProjectMemberDialog.vue @@ -45,7 +45,8 @@ export default { principalId: '', roleTemplateIds: [] }, - error: null + error: null, + duplicateWarning: null }; }, @@ -77,12 +78,35 @@ export default { this.close(); }, + // Returns the subset of roleTemplateIds that already exist for this principal+project. + existingRoleTemplateIds(principalProperty, principalId) { + const all = this.$store.getters['rancher/all'](NORMAN.PROJECT_ROLE_TEMPLATE_BINDING); + + return this.member.roleTemplateIds.filter((roleTemplateId) => all.some( + (b) => b.projectId === this.projectId && + b.roleTemplateId === roleTemplateId && + b[principalProperty] === principalId + )); + }, + async createBindings() { const principalProperty = await this.principalProperty(); - const promises = this.member.roleTemplateIds.map((roleTemplateId) => this.$store.dispatch(`rancher/create`, { + const principalId = this.member.principalId; + const duplicates = this.existingRoleTemplateIds(principalProperty, principalId); + const newRoleTemplateIds = this.member.roleTemplateIds.filter((id) => !duplicates.includes(id)); + + if (newRoleTemplateIds.length === 0) { + throw new Error(this.t('addProjectMemberDialog.duplicateRolesError')); + } + + if (duplicates.length > 0) { + this.duplicateWarning = this.t('addProjectMemberDialog.duplicateRolesSkipped'); + } + + const promises = newRoleTemplateIds.map((roleTemplateId) => this.$store.dispatch(`rancher/create`, { type: NORMAN.PROJECT_ROLE_TEMPLATE_BINDING, roleTemplateId, - [principalProperty]: this.member.principalId, + [principalProperty]: principalId, projectId: this.projectId, })); @@ -91,6 +115,7 @@ export default { saveBindings(btnCB) { this.error = null; + this.duplicateWarning = null; this.createBindings() .then((bindings) => { return Promise.all(bindings.map((b) => b.save())); @@ -129,6 +154,12 @@ export default { > {{ error }} + + {{ duplicateWarning }} +