From 9a15f721daf1b8c09d76dd8c7bbfbaa04fd43d6b Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Wed, 14 Oct 2026 11:34:52 +0200 Subject: [PATCH 1/3] feat(project): create a dedicated owner OIDC group across tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The project owner and the administrators shared the same OIDC group (/{slug}/console/admin), making ownership indistinguishable from admin in every SSO-group-mapped service. A new system role Propriétaire seeds /{slug}/console/owner: the Keycloak reconciler adds the implicit owner there instead of the admin group, and ownership transfers grant it so the previous owner keeps access under their own group. Project-tier admin group defaults in sonarqube, gitlab, nexus, registry and vault now include the owner group, keeping the owner's access level while separating the groups. ArgoCD is left out: its suffix lands as a single string in Helm values consumed by the external chart, which cannot take a list. Refs #2853 Co-authored-by: Automata Signed-off-by: William Phetsinorath Change-Id: I07c2163bfe1a89837085d42ba7a211c56a6a6964 --- .../src/modules/gitlab/gitlab.constants.ts | 2 +- .../modules/keycloak/keycloak.service.spec.ts | 29 +++++++-------- .../src/modules/keycloak/keycloak.service.ts | 11 +++--- .../src/modules/keycloak/keycloak.utils.ts | 14 ++++++-- .../src/modules/nexus/nexus.constants.ts | 2 +- .../modules/project/project.service.spec.ts | 8 ++--- .../src/modules/project/project.service.ts | 2 +- .../src/modules/project/project.utils.ts | 17 ++++++--- .../modules/registry/registry.constants.ts | 2 +- .../modules/registry/registry.service.spec.ts | 1 + .../modules/sonarqube/sonarqube.constants.ts | 2 +- .../src/modules/vault/vault.constants.ts | 2 +- .../src/modules/vault/vault.service.spec.ts | 5 ++- .../src/modules/vault/vault.service.ts | 3 ++ .../migration.sql | 35 +++++++++++++++++++ 15 files changed, 96 insertions(+), 39 deletions(-) create mode 100644 apps/server-nestjs/src/prisma/migrations/20261013190000_add_owner_role/migration.sql diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab.constants.ts b/apps/server-nestjs/src/modules/gitlab/gitlab.constants.ts index 25c6f95e0d..cce5c0ec5c 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab.constants.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab.constants.ts @@ -23,7 +23,7 @@ export const TOKEN_DESCRIPTION = 'mirroring-from-external-repo' // Default group paths for console roles export const DEFAULT_ADMIN_GROUP_PATH = '/console/admin' export const DEFAULT_AUDITOR_GROUP_PATH = '/console/readonly,/console/security' -export const DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX = '/console/admin,/console/devops' +export const DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX = '/console/admin,/console/devops,/console/owner' export const DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX = '/console/developer' export const DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX = '/console/readonly,/console/security' diff --git a/apps/server-nestjs/src/modules/keycloak/keycloak.service.spec.ts b/apps/server-nestjs/src/modules/keycloak/keycloak.service.spec.ts index 23ca465a1d..60893f9a2c 100644 --- a/apps/server-nestjs/src/modules/keycloak/keycloak.service.spec.ts +++ b/apps/server-nestjs/src/modules/keycloak/keycloak.service.spec.ts @@ -214,35 +214,35 @@ describe('keycloakService', () => { expect(keycloak.removeUserFromGroup).toHaveBeenCalledWith('user-2', 'role-group-id') }) - it('should add the owner to the project admin role group', async () => { - const adminRole = makeProjectRole({ - id: 'role-admin', + it('should add the owner to the project owner role group', async () => { + const ownerRole = makeProjectRole({ + id: 'role-owner', permissions: 0n, - oidcGroup: '/test-project/console/admin', + oidcGroup: '/test-project/console/owner', type: 'system:managed', }) - const projectWithAdminRole = makeProjectWithDetails({ + const projectWithOwnerRole = makeProjectWithDetails({ ...mockProject, members: [], - roles: [adminRole], + roles: [ownerRole], }) - datastore.getAllProjects.mockResolvedValue([projectWithAdminRole]) + datastore.getAllProjects.mockResolvedValue([projectWithOwnerRole]) const projectGroup = makeGroupRepresentation({ id: 'group-id', name: 'test-project' }) const consoleGroup = { id: 'console-id', name: 'console', path: '/test-project/console' } - const adminGroup = makeGroupRepresentation({ id: 'admin-group-id', name: 'admin', path: '/test-project/console/admin' }) + const ownerGroup = makeGroupRepresentation({ id: 'owner-group-id', name: 'owner', path: '/test-project/console/owner' }) keycloak.ensureGroupByPath.mockImplementation((path) => { if (path === '/test-project') return Promise.resolve(projectGroup) throw new Error(`Unexpected ensureGroupByPath call: ${path}`) }) keycloak.ensureConsoleGroup.mockResolvedValue(consoleGroup) - keycloak.ensureRoleGroup.mockResolvedValue(adminGroup) + keycloak.ensureRoleGroup.mockResolvedValue(ownerGroup) - // Owner is in the project group but missing from the admin role group + // Owner is in the project group but missing from the owner role group keycloak.getGroupMembers.mockImplementation((groupId) => { if (groupId === 'group-id') return Promise.resolve([makeUserRepresentation({ id: 'owner-id' })]) - if (groupId === 'admin-group-id') return Promise.resolve([]) + if (groupId === 'owner-group-id') return Promise.resolve([]) return Promise.resolve([]) }) @@ -250,11 +250,12 @@ describe('keycloakService', () => { await service.handleCron() - // Owner implicitly holds the admin role: they must land in the maintainer group - expect(keycloak.addUserToGroup).toHaveBeenCalledWith('owner-id', 'admin-group-id') + // Owner implicitly holds the owner role: they must land in the owner group, + // separate from the admin group + expect(keycloak.addUserToGroup).toHaveBeenCalledWith('owner-id', 'owner-group-id') }) - it('should not add the owner to non-admin role groups', async () => { + it('should not add the owner to non-owner role groups', async () => { const developerRole = makeProjectRole({ id: 'role-developer', permissions: 0n, diff --git a/apps/server-nestjs/src/modules/keycloak/keycloak.service.ts b/apps/server-nestjs/src/modules/keycloak/keycloak.service.ts index 84dd0d326b..92d7f32c9b 100644 --- a/apps/server-nestjs/src/modules/keycloak/keycloak.service.ts +++ b/apps/server-nestjs/src/modules/keycloak/keycloak.service.ts @@ -13,7 +13,7 @@ import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator import { capturePluginResult } from '../plugin/plugin.utils' import { KeycloakClientService } from './keycloak-client.service' import { KeycloakDatastoreService } from './keycloak-datastore.service' -import { isAdminRole, isMember, isNonEmptyGroupPath, isOwnedProjectGroup, splitGroupPath, toGroupPath, toRoleRelativeGroupPath } from './keycloak.utils' +import { isMember, isNonEmptyGroupPath, isOwnedProjectGroup, isOwnerRole, splitGroupPath, toGroupPath, toRoleRelativeGroupPath } from './keycloak.utils' @Injectable() export class KeycloakService { @@ -410,12 +410,11 @@ export class KeycloakService { } })) - // The owner implicitly holds the project admin role (the maintainer group): - // without this they end up in no Keycloak RBAC group, so SonarQube and the - // other SSO-group-mapped services grant them no access at all. Only - // meaningful for managed roles — the orphan purge would evict them otherwise. + // The owner implicitly holds the project owner role — separate from admin + // so ownership stays distinguishable in every SSO-group-mapped service. + // Only meaningful for managed roles — the orphan purge would evict them otherwise. // maybeAddUserToGroup tolerates 409, so no membership pre-check is needed. - if (isAdminRole(project, role)) { + if (isOwnerRole(project, role)) { addedCount++ await this.maybeAddUserToGroup(project.ownerId, group.id, group.name) } diff --git a/apps/server-nestjs/src/modules/keycloak/keycloak.utils.ts b/apps/server-nestjs/src/modules/keycloak/keycloak.utils.ts index 3b26aadca7..c6c953220f 100644 --- a/apps/server-nestjs/src/modules/keycloak/keycloak.utils.ts +++ b/apps/server-nestjs/src/modules/keycloak/keycloak.utils.ts @@ -50,9 +50,17 @@ export function toRoleRelativeGroupPath( return role.oidcGroup.replace(consoleGroup.path, '') } -// The project admin role is seeded with this OIDC group (project.utils.ts). The -// owner implicitly holds it: services map SSO groups to permissions and the -// owner would otherwise end up in no RBAC group at all. +// The project admin and owner roles are seeded with these OIDC groups +// (project.utils.ts). The owner implicitly holds the owner role: services map +// SSO groups to permissions and the owner would otherwise end up in no RBAC +// group at all. +export function isOwnerRole( + project: Pick, + role: Pick, +): boolean { + return role.oidcGroup === `/${project.slug}/${CONSOLE_GROUP_NAME}/owner` +} + export function isAdminRole( project: Pick, role: Pick, diff --git a/apps/server-nestjs/src/modules/nexus/nexus.constants.ts b/apps/server-nestjs/src/modules/nexus/nexus.constants.ts index 151b2e56b1..091e72f49e 100644 --- a/apps/server-nestjs/src/modules/nexus/nexus.constants.ts +++ b/apps/server-nestjs/src/modules/nexus/nexus.constants.ts @@ -20,7 +20,7 @@ export const DEFAULT_PLATFORM_WRITE_GROUP_PATHS = '/console/admin' export const DEFAULT_PLATFORM_READ_GROUP_PATHS = '/console/readonly,/console/security' // Default group path suffixes granting write and read access at the project level -export const DEFAULT_PROJECT_WRITE_GROUP_PATH_SUFFIXES = '/console/admin,/console/devops' +export const DEFAULT_PROJECT_WRITE_GROUP_PATH_SUFFIXES = '/console/admin,/console/devops,/console/owner' export const DEFAULT_PROJECT_READ_GROUP_PATH_SUFFIXES = '/console/readonly,/console/security,/console/developer' // Plugin configuration keys for platform-level group paths diff --git a/apps/server-nestjs/src/modules/project/project.service.spec.ts b/apps/server-nestjs/src/modules/project/project.service.spec.ts index 0d21056582..5bc7f5a816 100644 --- a/apps/server-nestjs/src/modules/project/project.service.spec.ts +++ b/apps/server-nestjs/src/modules/project/project.service.spec.ts @@ -465,12 +465,12 @@ describe('projectService', () => { expect(result).toBeDefined() }) - it('grants the previous owner the admin role on transfer', async () => { + it('grants the previous owner the owner role on transfer', async () => { const ownerId = faker.string.uuid() const newOwnerId = faker.string.uuid() const ctx = makeProjectContext({ locked: false }) const pwd = makeProjectWithDetails({ id: ctx.id, ownerId }) - const adminRoleId = faker.string.uuid() + const ownerRoleId = faker.string.uuid() pwd.roles = [makeAdminRole(pwd, { id: adminRoleId })] const tx = mockDeep() @@ -495,8 +495,8 @@ describe('projectService', () => { expect(tx.projectMembers.upsert).toHaveBeenCalledWith({ where: { projectId_userId: { userId: ownerId, projectId: ctx.id } }, - create: { projectId: ctx.id, userId: ownerId, roleIds: [adminRoleId] }, - update: { roleIds: [adminRoleId] }, + create: { projectId: ctx.id, userId: ownerId, roleIds: [ownerRoleId] }, + update: { roleIds: [ownerRoleId] }, }) }) diff --git a/apps/server-nestjs/src/modules/project/project.service.ts b/apps/server-nestjs/src/modules/project/project.service.ts index 3bc425bda9..944bd9f671 100644 --- a/apps/server-nestjs/src/modules/project/project.service.ts +++ b/apps/server-nestjs/src/modules/project/project.service.ts @@ -10,7 +10,7 @@ import { baseConfigFactory } from '../../config/base.config' import { AppEventsService } from '../events/app-events.service' import { PrismaService } from '../infrastructure/database/prisma.service' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' -import { isAdminRole } from '../keycloak/keycloak.utils' +import { isOwnerRole } from '../keycloak/keycloak.utils' import { LogService } from '../log/log.service' import { getFailedPlugins } from '../plugin/plugin.utils' import { deleteProjectMember, upsertProjectMember } from '../project-members/project-members-queries.utils' diff --git a/apps/server-nestjs/src/modules/project/project.utils.ts b/apps/server-nestjs/src/modules/project/project.utils.ts index e00fd09623..56f733e22f 100644 --- a/apps/server-nestjs/src/modules/project/project.utils.ts +++ b/apps/server-nestjs/src/modules/project/project.utils.ts @@ -37,9 +37,16 @@ export function generateProjectCreateInput( roles: { create: [ { - name: 'Administrateur', + name: 'Propriétaire', permissions: PROJECT_PERMS.MANAGE, position: 0, + oidcGroup: `/${slug}/console/owner`, + type: 'system:managed', + }, + { + name: 'Administrateur', + permissions: PROJECT_PERMS.MANAGE, + position: 1, oidcGroup: `/${slug}/console/admin`, type: 'system:managed', }, @@ -51,7 +58,7 @@ export function generateProjectCreateInput( | PROJECT_PERMS.SEE_SECRETS | PROJECT_PERMS.LIST_ENVIRONMENTS | PROJECT_PERMS.LIST_REPOSITORIES, - position: 1, + position: 2, oidcGroup: `/${slug}/console/devops`, type: 'system:managed', }, @@ -60,14 +67,14 @@ export function generateProjectCreateInput( permissions: PROJECT_PERMS.MANAGE_REPOSITORIES | PROJECT_PERMS.LIST_ENVIRONMENTS | PROJECT_PERMS.LIST_REPOSITORIES, - position: 2, + position: 3, oidcGroup: `/${slug}/console/developer`, type: 'system:managed', }, { name: 'Lecture seule', permissions: PROJECT_PERMS.LIST_ENVIRONMENTS | PROJECT_PERMS.LIST_REPOSITORIES, - position: 3, + position: 4, oidcGroup: `/${slug}/console/readonly`, type: 'system:managed', }, @@ -76,7 +83,7 @@ export function generateProjectCreateInput( permissions: PROJECT_PERMS.SEE_SECRETS | PROJECT_PERMS.LIST_ENVIRONMENTS | PROJECT_PERMS.LIST_REPOSITORIES, - position: 4, + position: 5, oidcGroup: `/${slug}/console/security`, type: 'system:managed', }, diff --git a/apps/server-nestjs/src/modules/registry/registry.constants.ts b/apps/server-nestjs/src/modules/registry/registry.constants.ts index e4c054daab..e527fc256e 100644 --- a/apps/server-nestjs/src/modules/registry/registry.constants.ts +++ b/apps/server-nestjs/src/modules/registry/registry.constants.ts @@ -10,7 +10,7 @@ export const DEFAULT_PLATFORM_ADMIN_GROUP_PATHS = '/console/admin' export const DEFAULT_PLATFORM_GUEST_GROUP_PATHS = '/console/security,/console/readonly' // Default project-level group path suffixes -export const DEFAULT_PROJECT_ADMIN_GROUP_PATH_SUFFIXES = '/console/admin' +export const DEFAULT_PROJECT_ADMIN_GROUP_PATH_SUFFIXES = '/console/admin,/console/owner' export const DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIXES = '/console/devops' export const DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIXES = '/console/developer' export const DEFAULT_PROJECT_GUEST_GROUP_PATH_SUFFIXES = '/console/security,/console/readonly' diff --git a/apps/server-nestjs/src/modules/registry/registry.service.spec.ts b/apps/server-nestjs/src/modules/registry/registry.service.spec.ts index 3345ef61bf..3025b63b44 100644 --- a/apps/server-nestjs/src/modules/registry/registry.service.spec.ts +++ b/apps/server-nestjs/src/modules/registry/registry.service.spec.ts @@ -111,6 +111,7 @@ describe('registryService', () => { { groupName: `/${project.slug}/console/developer`, roleId: 3 }, { groupName: `/${project.slug}/console/devops`, roleId: 3 }, { groupName: `/${project.slug}/console/admin`, roleId: 2 }, + { groupName: `/${project.slug}/console/owner`, roleId: 2 }, ] expect(client.ensureGroupMember).toHaveBeenCalledTimes(expected.length) diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube.constants.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube.constants.ts index 0935f737c6..a7b3973b1b 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube.constants.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube.constants.ts @@ -28,7 +28,7 @@ export const DEFAULT_READONLY_GROUP_PATH = '/console/readonly' export const DEFAULT_SECURITY_GROUP_PATH = '/console/security' // Default project role group path suffixes (appended to /{projectSlug}) -export const DEFAULT_PROJECT_ADMIN_SUFFIX = '/console/admin' +export const DEFAULT_PROJECT_ADMIN_SUFFIX = '/console/admin,/console/owner' export const DEFAULT_PROJECT_DEVOPS_SUFFIX = '/console/devops' export const DEFAULT_PROJECT_DEVELOPER_SUFFIX = '/console/developer' export const DEFAULT_PROJECT_SECURITY_SUFFIX = '/console/security' diff --git a/apps/server-nestjs/src/modules/vault/vault.constants.ts b/apps/server-nestjs/src/modules/vault/vault.constants.ts index 22adfa3275..2c43a35099 100644 --- a/apps/server-nestjs/src/modules/vault/vault.constants.ts +++ b/apps/server-nestjs/src/modules/vault/vault.constants.ts @@ -17,7 +17,7 @@ export const DEFAULT_AUDITOR_GROUP_PATH = '/console/readonly' export const DEFAULT_SECURITY_GROUP_PATH = '/console/security' // Default suffixes for project-scoped group paths -export const DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX = '/console/admin' +export const DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX = '/console/admin,/console/owner' export const DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX = '/console/developer' export const DEFAULT_PROJECT_DEVOPS_GROUP_PATH_SUFFIX = '/console/devops' export const DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX = '/console/readonly' diff --git a/apps/server-nestjs/src/modules/vault/vault.service.spec.ts b/apps/server-nestjs/src/modules/vault/vault.service.spec.ts index f83792e05c..0e277e94b1 100644 --- a/apps/server-nestjs/src/modules/vault/vault.service.spec.ts +++ b/apps/server-nestjs/src/modules/vault/vault.service.spec.ts @@ -119,7 +119,10 @@ describe('vaultService', () => { expect(client.ensureIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-developer`, expect.any(Object)) expect(client.ensureIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-readonly`, expect.any(Object)) expect(client.ensureIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-security`, expect.any(Object)) - expect(client.createIdentityGroupAlias).not.toHaveBeenCalled() + // The owner group path is a second alias on the admin identity group + expect(client.createIdentityGroupAlias).toHaveBeenCalledWith(expect.objectContaining({ + name: `/${project.slug}/console/owner`, + })) expect(client.ensureSysPoliciesAcl).toHaveBeenCalledWith(`project--${project.slug}--developer`, { policy: `path "${project.slug}/data/*" { capabilities = ["list"] }`, diff --git a/apps/server-nestjs/src/modules/vault/vault.service.ts b/apps/server-nestjs/src/modules/vault/vault.service.ts index 1fb3d34834..d5d33e61eb 100644 --- a/apps/server-nestjs/src/modules/vault/vault.service.ts +++ b/apps/server-nestjs/src/modules/vault/vault.service.ts @@ -345,6 +345,9 @@ export class VaultService { this.ensureIdentityGroup(CONSOLE_ADMIN_GROUP_NAME, [PLATFORM_ADMIN_POLICY_NAME], adminGroupPath), this.ensureIdentityGroup(CONSOLE_READONLY_GROUP_NAME, [PLATFORM_READONLY_POLICY_NAME], auditorGroupPath), this.ensureIdentityGroup(CONSOLE_SECURITY_GROUP_NAME, [PLATFORM_SECURITY_POLICY_NAME], securityGroupPath), + // The owner group is a second alias on the admin identity group: both + // memberships grant the same app policy, keeping owner distinct from + // admin at the SSO layer without a duplicate Vault group. ...projectAdminGroupPaths.map(groupPath => this.ensureIdentityGroup(generateProjectGroupName(project, 'admin'), [appPolicyName], groupPath)), ...projectDevopsGroupPaths.map(groupPath => diff --git a/apps/server-nestjs/src/prisma/migrations/20261013190000_add_owner_role/migration.sql b/apps/server-nestjs/src/prisma/migrations/20261013190000_add_owner_role/migration.sql new file mode 100644 index 0000000000..743fe9f655 --- /dev/null +++ b/apps/server-nestjs/src/prisma/migrations/20261013190000_add_owner_role/migration.sql @@ -0,0 +1,35 @@ +-- Backfill the project system role 'Propriétaire' on existing projects, separating +-- the owner from the admin role. Mirrors the TS seeding in project.utils.ts: +-- permissions 2 = MANAGE, position 0, oidcGroup = '//console/owner' +-- Existing roles shift down one position to make room. +-- Anti-join on slug+position makes the INSERT idempotent: a retry after a +-- partial failure, or a project that already has the role, is a no-op. + +UPDATE "ProjectRole" r +SET "position" = r."position" + 1 +FROM "Project" p +WHERE r."projectId" = p."id" + AND r."position" >= 1 + AND r."type" = 'system:managed' + AND NOT EXISTS ( + SELECT 1 FROM "ProjectRole" po + WHERE po."projectId" = p."id" + AND po."oidcGroup" = '/' || p."slug" || '/console/owner' + ); + +INSERT INTO "ProjectRole" ("id", "name", "permissions", "projectId", "position", "oidcGroup", "type") +SELECT + gen_random_uuid(), + 'Propriétaire', + 2, -- MANAGE + p."id", + 0, + '/' || p."slug" || '/console/owner', + 'system:managed' +FROM "Project" p +WHERE NOT EXISTS ( + SELECT 1 + FROM "ProjectRole" r + WHERE r."projectId" = p."id" + AND r."oidcGroup" = '/' || p."slug" || '/console/owner' +); From 1dc3af9f4c1cf58bb34db61d7608a5b778944ef5 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Fri, 16 Oct 2026 16:32:29 +0200 Subject: [PATCH 2/3] fix(project): drop the unused isOwnerRole import Co-authored-by: Automata Signed-off-by: William Phetsinorath Change-Id: I701d81a302cd73dedb86fd1679c888da6a6a6964 --- apps/server-nestjs/src/modules/project/project.service.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/server-nestjs/src/modules/project/project.service.ts b/apps/server-nestjs/src/modules/project/project.service.ts index 944bd9f671..01e1a188d6 100644 --- a/apps/server-nestjs/src/modules/project/project.service.ts +++ b/apps/server-nestjs/src/modules/project/project.service.ts @@ -10,7 +10,6 @@ import { baseConfigFactory } from '../../config/base.config' import { AppEventsService } from '../events/app-events.service' import { PrismaService } from '../infrastructure/database/prisma.service' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' -import { isOwnerRole } from '../keycloak/keycloak.utils' import { LogService } from '../log/log.service' import { getFailedPlugins } from '../plugin/plugin.utils' import { deleteProjectMember, upsertProjectMember } from '../project-members/project-members-queries.utils' From 9a5c014fe3afe60ca55018da6007c09227f9e3ba Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Fri, 16 Oct 2026 17:34:54 +0200 Subject: [PATCH 3/3] fix(project): restore the admin role import on owner transfer The import drop left updateProjectOwnerIfNeeded referencing an undefined isAdminRole at runtime and two transfer specs referencing an undeclared adminRoleId. Restore the import and align the specs with the admin-role grant the implementation performs. Signed-off-by: William Phetsinorath Change-Id: Ibb70b55af651f3edec455fe7067fa6506a6a6964 --- .../src/modules/project/project.service.spec.ts | 8 ++++---- apps/server-nestjs/src/modules/project/project.service.ts | 1 + 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/server-nestjs/src/modules/project/project.service.spec.ts b/apps/server-nestjs/src/modules/project/project.service.spec.ts index 5bc7f5a816..0d21056582 100644 --- a/apps/server-nestjs/src/modules/project/project.service.spec.ts +++ b/apps/server-nestjs/src/modules/project/project.service.spec.ts @@ -465,12 +465,12 @@ describe('projectService', () => { expect(result).toBeDefined() }) - it('grants the previous owner the owner role on transfer', async () => { + it('grants the previous owner the admin role on transfer', async () => { const ownerId = faker.string.uuid() const newOwnerId = faker.string.uuid() const ctx = makeProjectContext({ locked: false }) const pwd = makeProjectWithDetails({ id: ctx.id, ownerId }) - const ownerRoleId = faker.string.uuid() + const adminRoleId = faker.string.uuid() pwd.roles = [makeAdminRole(pwd, { id: adminRoleId })] const tx = mockDeep() @@ -495,8 +495,8 @@ describe('projectService', () => { expect(tx.projectMembers.upsert).toHaveBeenCalledWith({ where: { projectId_userId: { userId: ownerId, projectId: ctx.id } }, - create: { projectId: ctx.id, userId: ownerId, roleIds: [ownerRoleId] }, - update: { roleIds: [ownerRoleId] }, + create: { projectId: ctx.id, userId: ownerId, roleIds: [adminRoleId] }, + update: { roleIds: [adminRoleId] }, }) }) diff --git a/apps/server-nestjs/src/modules/project/project.service.ts b/apps/server-nestjs/src/modules/project/project.service.ts index 01e1a188d6..3bc425bda9 100644 --- a/apps/server-nestjs/src/modules/project/project.service.ts +++ b/apps/server-nestjs/src/modules/project/project.service.ts @@ -10,6 +10,7 @@ import { baseConfigFactory } from '../../config/base.config' import { AppEventsService } from '../events/app-events.service' import { PrismaService } from '../infrastructure/database/prisma.service' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { isAdminRole } from '../keycloak/keycloak.utils' import { LogService } from '../log/log.service' import { getFailedPlugins } from '../plugin/plugin.utils' import { deleteProjectMember, upsertProjectMember } from '../project-members/project-members-queries.utils'