Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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'
Original file line number Diff line number Diff line change
Expand Up @@ -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 }],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,15 @@ import {
generateGrafanaProdRbacGroupPaths,
generateKeycloakRootGroupPath,
generateObservabilityProject,
generateProjectRbacGroupPath,
getListPerms,
getRbacPerms,
grafanaRbacMembershipMappings,
grafanaRbacSubGroupNames,
isPluginDisabled,
observabilityChartContent,
observabilityTemplateContent,
projectRbacRoles,
} from './observability.utils'

@Injectable()
Expand Down Expand Up @@ -88,6 +91,7 @@ export class ObservabilityService {

await Promise.all([
this.deleteKeycloakGroups(project),
this.deleteRbacGroups(project),
this.deleteProjectConfig(project),
])

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -216,23 +255,18 @@ export class ObservabilityService {
subgroups: Record<GrafanaSubGroupName, { id: string, members: { id: string }[] }>,
listPerms: ListPerms,
): Promise<void> {
const promises: Promise<void>[] = []
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<void> {
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)),
])
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 /<slug>/console/<role>', () => {
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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -83,18 +86,52 @@ function resolveUserPerms(
rolesById: Record<string, ProjectWithDetails['roles'][number]>,
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<ProjectRbacRole, string[]>

// ADR 014: hierarchical group path matching the other fine-grained modules (/<slug>/console/<role>)
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)
Expand Down Expand Up @@ -126,6 +163,7 @@ export function generateObservabilityProject(
repositoryUrl: string
tenantRbacProd: [string, string]
tenantRbacHProd: [string, string]
rbacGroups?: string[]
},
): ObservabilityProject {
const projectValue: ObservabilityProject = {
Expand All @@ -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: {},
},
},
Expand Down