From 93a0de9e0f8856d2aa369d7f6b3bf236a8592df8 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Fri, 7 Aug 2026 12:33:39 +0200 Subject: [PATCH] =?UTF-8?q?feat(server-nestjs):=20impl=C3=A9menter=20le=20?= =?UTF-8?q?RBAC=20fins=20d'observability?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - getRbacPerms(project) calcule, depuis la matrice de permissions bitmask (@cpn-console/shared), l'appartenance d'un utilisateur a un role fin : MANAGE -> admin, MANAGE_ENVIRONMENTS -> devops, LIST_ENVIRONMENTS -> readonly (le proprietaire est admin) - generateProjectRbacGroupPath(project, role) produit le chemin de groupe Keycloak hierarchique //console/ (ADR 014, coherent avec les autres modules a droits fins : vault, project, sonarqube) - Lors des events create/update/delete, le service cree/met a jour/supprime ces groupes dans Keycloak en parallele des sous-groupes Grafana legacy (etape 1 de migration : coexistence) - Les nouveaux groupes sont publies dans les values Grafana generees (generateObservabilityProject) - Tests unitaires Vitest : getRbacPerms (mapping des permissions, exclusion des membres sans permission) et generateProjectRbacGroupPath Signed-off-by: William Phetsinorath Change-Id: Icf82862003a3794164bbe4cef5d2fca26a6a6964 --- .../observability/observability.constants.ts | 9 ++- .../observability.service.spec.ts | 9 +++ .../observability/observability.service.ts | 66 ++++++++++++++----- .../observability/observability.utils.spec.ts | 53 +++++++++++++++ .../observability/observability.utils.ts | 46 +++++++++++-- 5 files changed, 161 insertions(+), 22 deletions(-) diff --git a/apps/server-nestjs/src/modules/observability/observability.constants.ts b/apps/server-nestjs/src/modules/observability/observability.constants.ts index 3a4ecdbe4..792a9e762 100644 --- a/apps/server-nestjs/src/modules/observability/observability.constants.ts +++ b/apps/server-nestjs/src/modules/observability/observability.constants.ts @@ -1,10 +1,10 @@ -// Observability plugin name (matches @cpn-console/hooks PluginName) +// Observability plugin name export const PLUGIN_NAME = 'observability' // Project-scoped repository for custom dashboards and alerts export const OBSERVABILITY_REPOSITORY = 'infra-observability' -// Global GitLab group + repo for Helm values (scanned by ArgoCD) +// Global GitLab group + repo for Helm values export const OBSERVABILITY_GROUP_NAME = 'observability' export const OBSERVABILITY_REPO_NAME = 'observability' export const OBSERVABILITY_VALUES_PATH = 'helm/values.yaml' @@ -21,6 +21,11 @@ export const GRAFANA_SUBGROUP_HPROD_RO = 'hprod-RO' export const GRAFANA_SUBGROUP_PROD_RW = 'prod-RW' export const GRAFANA_SUBGROUP_PROD_RO = 'prod-RO' +// Fine-grained RBAC Keycloak groups +export const PROJECT_RBAC_ROLE_ADMIN = 'admin' +export const PROJECT_RBAC_ROLE_DEVOPS = 'devops' +export const PROJECT_RBAC_ROLE_READONLY = 'readonly' + // Plugin configuration keys export const ENABLED_PLUGIN_KEY = 'enabled' export const INSTANCES_PLUGIN_KEY = 'instances' diff --git a/apps/server-nestjs/src/modules/observability/observability.service.spec.ts b/apps/server-nestjs/src/modules/observability/observability.service.spec.ts index c2842a093..926b0245a 100644 --- a/apps/server-nestjs/src/modules/observability/observability.service.spec.ts +++ b/apps/server-nestjs/src/modules/observability/observability.service.spec.ts @@ -84,6 +84,15 @@ describe('observabilityService', () => { expect(client.deleteProjectConfig).toHaveBeenCalled() }) + it('deletes the fine-grained RBAC groups', async () => { + keycloak.getGroupByPath.mockResolvedValue({ id: 'group-1' }) + await service.handleDelete(makeProject({ slug: 'my-proj' })) + expect(keycloak.getGroupByPath).toHaveBeenCalledWith('/my-proj/console/admin') + expect(keycloak.getGroupByPath).toHaveBeenCalledWith('/my-proj/console/devops') + expect(keycloak.getGroupByPath).toHaveBeenCalledWith('/my-proj/console/readonly') + expect(keycloak.deleteGroup).toHaveBeenCalled() + }) + it('skips cleanup when plugin disabled', async () => { const project = makeProject({ plugins: [{ pluginName: 'observability', key: ENABLED_PLUGIN_KEY, value: DISABLED }], diff --git a/apps/server-nestjs/src/modules/observability/observability.service.ts b/apps/server-nestjs/src/modules/observability/observability.service.ts index 278791e81..f115e46a6 100644 --- a/apps/server-nestjs/src/modules/observability/observability.service.ts +++ b/apps/server-nestjs/src/modules/observability/observability.service.ts @@ -25,12 +25,15 @@ import { generateGrafanaProdRbacGroupPaths, generateKeycloakRootGroupPath, generateObservabilityProject, + generateProjectRbacGroupPath, getListPerms, + getRbacPerms, grafanaRbacMembershipMappings, grafanaRbacSubGroupNames, isPluginDisabled, observabilityChartContent, observabilityTemplateContent, + projectRbacRoles, } from './observability.utils' @Injectable() @@ -88,6 +91,7 @@ export class ObservabilityService { await Promise.all([ this.deleteKeycloakGroups(project), + this.deleteRbacGroups(project), this.deleteProjectConfig(project), ]) @@ -133,6 +137,7 @@ export class ObservabilityService { repositoryUrl: `${repositoryUrl}/${OBSERVABILITY_REPOSITORY}`, tenantRbacProd: generateGrafanaProdRbacGroupPaths(projectGroupPath), tenantRbacHProd: generateGrafanaHprodRbacGroupPaths(projectGroupPath), + rbacGroups: projectRbacRoles().map(role => generateProjectRbacGroupPath(project, role)), }) await this.client.updateProjectConfig(valuesRepo, project, projectValue) @@ -162,6 +167,40 @@ export class ObservabilityService { const subgroups = await this.ensureGrafanaSubGroups(projectGroup.id) await this.reconcileGroupMembership(subgroups, listPerms) + await this.syncRbacGroups(project) + } + + @StartActiveSpan() + private async syncRbacGroups(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.verbose(`Syncing fine-grained RBAC Keycloak groups for ${project.slug}`) + + const rbacPerms = getRbacPerms(project) + for (const role of projectRbacRoles()) { + const name = generateProjectRbacGroupPath(project, role) + const group = await this.keycloak.getOrCreateGroupByPath(name) + if (!group.id) { + throw new Error(`Unable to resolve RBAC Keycloak group ${name}`) + } + const members = await this.keycloak.getGroupMembers(group.id) + await this.reconcileMembers(group.id, members.flatMap(m => m.id ? [m.id] : []), rbacPerms[role]) + } + } + + @StartActiveSpan() + private async deleteRbacGroups(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.verbose(`Deleting fine-grained RBAC Keycloak groups for ${project.slug}`) + + for (const role of projectRbacRoles()) { + const name = generateProjectRbacGroupPath(project, role) + const group = await this.keycloak.getGroupByPath(name) + if (!group?.id) continue + await this.keycloak.deleteGroup(group.id) + this.logger.log(`Deleted RBAC Keycloak group ${name} (project=${project.slug})`) + } } @StartActiveSpan() @@ -216,23 +255,18 @@ export class ObservabilityService { subgroups: Record, listPerms: ListPerms, ): Promise { - const promises: Promise[] = [] - for (const { subgroup, desired } of grafanaRbacMembershipMappings(listPerms)) { + await Promise.all(grafanaRbacMembershipMappings(listPerms).map(({ subgroup, desired }) => { const group = subgroups[subgroup] - const desiredSet = new Set(desired) - - for (const userId of desired) { - if (!group.members.some(m => m.id === userId)) { - promises.push(this.keycloak.addUserToGroup(userId, group.id).then(() => undefined)) - } - } + return this.reconcileMembers(group.id, group.members.map(m => m.id), desired) + })) + } - for (const member of group.members) { - if (!desiredSet.has(member.id)) { - promises.push(this.keycloak.removeUserFromGroup(member.id, group.id).then(() => undefined)) - } - } - } - await Promise.all(promises) + private async reconcileMembers(groupId: string, current: string[], desired: string[]): Promise { + const desiredSet = new Set(desired) + const currentSet = new Set(current) + await Promise.all([ + ...desired.filter(userId => !currentSet.has(userId)).map(userId => this.keycloak.addUserToGroup(userId, groupId)), + ...current.filter(userId => !desiredSet.has(userId)).map(userId => this.keycloak.removeUserFromGroup(userId, groupId)), + ]) } } diff --git a/apps/server-nestjs/src/modules/observability/observability.utils.spec.ts b/apps/server-nestjs/src/modules/observability/observability.utils.spec.ts index bf9f38e7e..93590f1a1 100644 --- a/apps/server-nestjs/src/modules/observability/observability.utils.spec.ts +++ b/apps/server-nestjs/src/modules/observability/observability.utils.spec.ts @@ -7,11 +7,64 @@ import { generateGrafanaHprodRbacGroupPaths, generateGrafanaProdRbacGroupPaths, generateObservabilityProject, + generateProjectRbacGroupPath, generateTenantId, getListPerms, + getRbacPerms, isPluginDisabled, } from './observability.utils' +describe('getRbacPerms', () => { + it('puts owner in the admin group only', () => { + const perms = getRbacPerms(makeProject({ ownerId: 'owner-1' })) + expect(perms.admin).toContain('owner-1') + expect(perms.devops).not.toContain('owner-1') + expect(perms.readonly).not.toContain('owner-1') + }) + + it('maps MANAGE_ENVIRONMENTS to devops and LIST_ENVIRONMENTS to readonly', () => { + const project = makeProject({ + ownerId: 'owner-1', + members: [ + { roleIds: ['role-rw'], user: { id: 'user-rw', email: 'rw@test.com' } }, + { roleIds: ['role-ro'], user: { id: 'user-ro', email: 'ro@test.com' } }, + ], + roles: [ + { id: 'role-rw', permissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS, oidcGroup: '', type: 'managed' }, + { id: 'role-ro', permissions: PROJECT_PERMS.LIST_ENVIRONMENTS, oidcGroup: '', type: 'managed' }, + ], + }) + const perms = getRbacPerms(project) + expect(perms.devops).toEqual(['user-rw']) + expect(perms.readonly).toEqual(['user-ro']) + }) + + it('maps PROJECT_PERMS.MANAGE to admin', () => { + const project = makeProject({ + ownerId: 'owner-1', + members: [{ roleIds: ['role-manage'], user: { id: 'user-admin', email: 'a@test.com' } }], + roles: [{ id: 'role-manage', permissions: PROJECT_PERMS.MANAGE, oidcGroup: '', type: 'managed' }], + }) + expect(getRbacPerms(project).admin).toContain('user-admin') + }) + + it('excludes members without any environment permission', () => { + const project = makeProject({ + ownerId: 'owner-1', + members: [{ roleIds: ['role-none'], user: { id: 'user-none', email: 'n@test.com' } }], + roles: [{ id: 'role-none', permissions: 0n, oidcGroup: '', type: 'managed' }], + }) + const perms = getRbacPerms(project) + expect([...perms.admin, ...perms.devops, ...perms.readonly]).not.toContain('user-none') + }) +}) + +describe('generateProjectRbacGroupPath', () => { + it('builds a hierarchical group path //console/', () => { + expect(generateProjectRbacGroupPath({ slug: 'my-proj' }, 'devops')).toBe('/my-proj/console/devops') + }) +}) + const PROD_STAGE = { name: 'prod' } as const const HPROD_STAGE = { name: 'hprod' } as const diff --git a/apps/server-nestjs/src/modules/observability/observability.utils.ts b/apps/server-nestjs/src/modules/observability/observability.utils.ts index 52bd18494..23c92d72d 100644 --- a/apps/server-nestjs/src/modules/observability/observability.utils.ts +++ b/apps/server-nestjs/src/modules/observability/observability.utils.ts @@ -11,6 +11,9 @@ import { GRAFANA_SUBGROUP_PROD_RO, GRAFANA_SUBGROUP_PROD_RW, PLUGIN_NAME, + PROJECT_RBAC_ROLE_ADMIN, + PROJECT_RBAC_ROLE_DEVOPS, + PROJECT_RBAC_ROLE_READONLY, } from './observability.constants' export type GrafanaSubGroupName @@ -83,18 +86,52 @@ function resolveUserPerms( rolesById: Record, userId: string, ) { - if (userId === project.ownerId) return { ro: true, rw: true } + if (userId === project.ownerId) return { ro: true, rw: true, manage: true } const member = project.members.find(m => m.user.id === userId) - if (!member) return { ro: false, rw: false } + if (!member) return { ro: false, rw: false, manage: false } const projectPermissions = getPermsByUserRoles(member.roleIds, rolesById, project.everyonePerms) return { ro: ProjectAuthorized.ListEnvironments({ adminPermissions: 0n, projectPermissions }), rw: ProjectAuthorized.ManageEnvironments({ adminPermissions: 0n, projectPermissions }), + manage: ProjectAuthorized.Manage({ adminPermissions: 0n, projectPermissions }), } } +export type ProjectRbacRole + = | typeof PROJECT_RBAC_ROLE_ADMIN + | typeof PROJECT_RBAC_ROLE_DEVOPS + | typeof PROJECT_RBAC_ROLE_READONLY + +export type RbacPerms = Record + +// ADR 014: hierarchical group path matching the other fine-grained modules (//console/) +export function generateProjectRbacGroupPath(project: { slug: string }, role: ProjectRbacRole): string { + return `/${project.slug}/console/${role}` +} + +export function projectRbacRoles(): ProjectRbacRole[] { + return [PROJECT_RBAC_ROLE_ADMIN, PROJECT_RBAC_ROLE_DEVOPS, PROJECT_RBAC_ROLE_READONLY] +} + +// Grafana mapping (ADR 014): admin/devops -> Editor, readonly -> Viewer +export function getRbacPerms(project: ProjectWithDetails): RbacPerms { + const rolesById = Object.fromEntries(project.roles.map(r => [r.id, r])) + const projectUserIds = new Set([project.ownerId, ...project.members.map(m => m.user.id)]) + + const perms: RbacPerms = { admin: [], devops: [], readonly: [] } + + for (const userId of projectUserIds) { + const { ro, rw, manage } = resolveUserPerms(project, rolesById, userId) + if (manage) perms.admin.push(userId) + else if (rw) perms.devops.push(userId) + else if (ro) perms.readonly.push(userId) + } + + return perms +} + export function generateGrafanaGroupPath(keycloakRootGroupPath: string, subGroupName: GrafanaSubGroupName): string { const normalizedRoot = keycloakRootGroupPath.endsWith('/') ? keycloakRootGroupPath.slice(0, -1) @@ -126,6 +163,7 @@ export function generateObservabilityProject( repositoryUrl: string tenantRbacProd: [string, string] tenantRbacHProd: [string, string] + rbacGroups?: string[] }, ): ObservabilityProject { const projectValue: ObservabilityProject = { @@ -136,11 +174,11 @@ export function generateObservabilityProject( }, envs: { hprod: { - groups: options.tenantRbacHProd, + groups: [...options.tenantRbacHProd, ...options.rbacGroups ?? []], tenants: {}, }, prod: { - groups: options.tenantRbacProd, + groups: [...options.tenantRbacProd, ...options.rbacGroups ?? []], tenants: {}, }, },