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
Expand Up @@ -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'

Expand Down
29 changes: 15 additions & 14 deletions apps/server-nestjs/src/modules/keycloak/keycloak.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,47 +214,48 @@ 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([])
})

keycloak.getSubGroups.mockImplementation(async function* () { /* empty */ })

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,
Expand Down
11 changes: 5 additions & 6 deletions apps/server-nestjs/src/modules/keycloak/keycloak.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand Down
14 changes: 11 additions & 3 deletions apps/server-nestjs/src/modules/keycloak/keycloak.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProjectWithDetails, 'slug'>,
role: Pick<ProjectWithDetails['roles'][number], 'oidcGroup'>,
): boolean {
return role.oidcGroup === `/${project.slug}/${CONSOLE_GROUP_NAME}/owner`
}

export function isAdminRole(
project: Pick<ProjectWithDetails, 'slug'>,
role: Pick<ProjectWithDetails['roles'][number], 'oidcGroup'>,
Expand Down
2 changes: 1 addition & 1 deletion apps/server-nestjs/src/modules/nexus/nexus.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 12 additions & 5 deletions apps/server-nestjs/src/modules/project/project.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
Expand All @@ -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',
},
Expand All @@ -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',
},
Expand All @@ -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',
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
2 changes: 1 addition & 1 deletion apps/server-nestjs/src/modules/vault/vault.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }`,
Expand Down
3 changes: 3 additions & 0 deletions apps/server-nestjs/src/modules/vault/vault.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =>
Expand Down
Original file line number Diff line number Diff line change
@@ -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 = '/<project.slug>/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'
);
Loading