mirror of
https://github.com/Unleash/unleash.git
synced 2025-08-13 13:48:59 +02:00
Cherry pick the fix merged in #8084 Co-authored-by: Fredrik Strand Oseberg <fredrikstrandoseberg@Fredrik-sin-MacBook-Pro.local>
This commit is contained in:
parent
b7301dc3dd
commit
36e780bdb9
1
.gitignore
vendored
1
.gitignore
vendored
@ -3,6 +3,7 @@ lerna-debug
|
|||||||
npm-debug
|
npm-debug
|
||||||
.DS_Store
|
.DS_Store
|
||||||
/dist
|
/dist
|
||||||
|
.vscode
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
|
121
src/lib/features/project/can-grant-project-role.test.ts
Normal file
121
src/lib/features/project/can-grant-project-role.test.ts
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
import { canGrantProjectRole } from './can-grant-project-role';
|
||||||
|
|
||||||
|
describe('canGrantProjectRole', () => {
|
||||||
|
test('should return true if the granter has all the permissions the receiver needs', () => {
|
||||||
|
const granterPermissions = [
|
||||||
|
{
|
||||||
|
project: 'test',
|
||||||
|
environment: undefined,
|
||||||
|
permission: 'CREATE_FEATURE',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
project: 'test',
|
||||||
|
environment: 'production',
|
||||||
|
permission: 'UPDATE_FEATURE_ENVIRONMENT',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
project: 'test',
|
||||||
|
environment: 'production',
|
||||||
|
permission: 'APPROVE_CHANGE_REQUEST',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const receiverPermissions = [
|
||||||
|
{
|
||||||
|
id: 28,
|
||||||
|
name: 'UPDATE_FEATURE_ENVIRONMENT',
|
||||||
|
environment: 'production',
|
||||||
|
displayName: 'Enable/disable flags',
|
||||||
|
type: 'environment',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 29,
|
||||||
|
name: 'APPROVE_CHANGE_REQUEST',
|
||||||
|
environment: 'production',
|
||||||
|
displayName: 'Enable/disable flags',
|
||||||
|
type: 'environment',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
canGrantProjectRole(granterPermissions, receiverPermissions);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return false if the granter and receiver permissions have different environments', () => {
|
||||||
|
const granterPermissions = [
|
||||||
|
{
|
||||||
|
project: 'test',
|
||||||
|
environment: 'production',
|
||||||
|
permission: 'UPDATE_FEATURE_ENVIRONMENT',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
project: 'test',
|
||||||
|
environment: 'production',
|
||||||
|
permission: 'APPROVE_CHANGE_REQUEST',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const receiverPermissions = [
|
||||||
|
{
|
||||||
|
id: 28,
|
||||||
|
name: 'UPDATE_FEATURE_ENVIRONMENT',
|
||||||
|
environment: 'development',
|
||||||
|
displayName: 'Enable/disable flags',
|
||||||
|
type: 'environment',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 29,
|
||||||
|
name: 'APPROVE_CHANGE_REQUEST',
|
||||||
|
environment: 'development',
|
||||||
|
displayName: 'Enable/disable flags',
|
||||||
|
type: 'environment',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(
|
||||||
|
canGrantProjectRole(granterPermissions, receiverPermissions),
|
||||||
|
).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return false if the granter does not have all receiver permissions', () => {
|
||||||
|
const granterPermissions = [
|
||||||
|
{
|
||||||
|
project: 'test',
|
||||||
|
environment: 'production',
|
||||||
|
permission: 'UPDATE_FEATURE_ENVIRONMENT',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
project: 'test',
|
||||||
|
environment: 'production',
|
||||||
|
permission: 'APPROVE_CHANGE_REQUEST',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const receiverPermissions = [
|
||||||
|
{
|
||||||
|
id: 28,
|
||||||
|
name: 'UPDATE_FEATURE_ENVIRONMENT',
|
||||||
|
environment: 'production',
|
||||||
|
displayName: 'Enable/disable flags',
|
||||||
|
type: 'environment',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 29,
|
||||||
|
name: 'APPROVE_CHANGE_REQUEST',
|
||||||
|
environment: 'production',
|
||||||
|
displayName: 'Enable/disable flags',
|
||||||
|
type: 'environment',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 26,
|
||||||
|
name: 'UPDATE_FEATURE_STRATEGY',
|
||||||
|
environment: 'production',
|
||||||
|
displayName: 'Update activation strategies',
|
||||||
|
type: 'environment',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(
|
||||||
|
canGrantProjectRole(granterPermissions, receiverPermissions),
|
||||||
|
).toBeFalsy();
|
||||||
|
});
|
||||||
|
});
|
21
src/lib/features/project/can-grant-project-role.ts
Normal file
21
src/lib/features/project/can-grant-project-role.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import type { IPermission } from '../../types';
|
||||||
|
import type { IUserPermission } from '../../types/stores/access-store';
|
||||||
|
|
||||||
|
export const canGrantProjectRole = (
|
||||||
|
granterPermissions: IUserPermission[],
|
||||||
|
receiverPermissions: IPermission[],
|
||||||
|
) => {
|
||||||
|
return receiverPermissions.every((receiverPermission) => {
|
||||||
|
return granterPermissions.some((granterPermission) => {
|
||||||
|
if (granterPermission.environment) {
|
||||||
|
return (
|
||||||
|
granterPermission.permission === receiverPermission.name &&
|
||||||
|
granterPermission.environment ===
|
||||||
|
receiverPermission.environment
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return granterPermission.permission === receiverPermission.name;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
@ -772,7 +772,8 @@ describe('Managing Project access', () => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
test('Users can not assign roles they do not have to a user through explicit roles endpoint', async () => {
|
|
||||||
|
test('Users can not assign roles where they do not hold the same permissions', async () => {
|
||||||
const project = {
|
const project = {
|
||||||
id: 'user_fail_assign_to_user',
|
id: 'user_fail_assign_to_user',
|
||||||
name: 'user_fail_assign_to_user',
|
name: 'user_fail_assign_to_user',
|
||||||
@ -780,64 +781,83 @@ describe('Managing Project access', () => {
|
|||||||
mode: 'open' as const,
|
mode: 'open' as const,
|
||||||
defaultStickiness: 'clientId',
|
defaultStickiness: 'clientId',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const auditUser = extractAuditInfoFromUser(user);
|
||||||
await projectService.createProject(project, user, auditUser);
|
await projectService.createProject(project, user, auditUser);
|
||||||
const projectUser = await stores.userStore.insert({
|
const projectUser = await stores.userStore.insert({
|
||||||
name: 'Some project user',
|
name: 'Some project user',
|
||||||
email: 'fail_assign_role_to_user@example.com',
|
email: 'fail_assign_role_to_user@example.com',
|
||||||
});
|
});
|
||||||
const projectAuditUser = extractAuditInfoFromUser(projectUser);
|
|
||||||
const secondUser = await stores.userStore.insert({
|
const secondUser = await stores.userStore.insert({
|
||||||
name: 'Some other user',
|
name: 'Some other user',
|
||||||
email: 'otheruser_no_roles@example.com',
|
email: 'otheruser_no_roles@example.com',
|
||||||
});
|
});
|
||||||
const customRole = await stores.roleStore.create({
|
|
||||||
name: 'role_that_noone_has',
|
const customRoleUserAccess = await accessService.createRole(
|
||||||
roleType: 'custom',
|
{
|
||||||
description:
|
name: 'Project-permissions-lead',
|
||||||
'Used to prove that you can not assign a role you do not have via setRolesForUser',
|
description: 'Role',
|
||||||
});
|
permissions: [
|
||||||
|
{
|
||||||
|
name: 'PROJECT_USER_ACCESS_WRITE',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
createdByUserId: SYSTEM_USER_ID,
|
||||||
|
},
|
||||||
|
SYSTEM_USER_AUDIT,
|
||||||
|
);
|
||||||
|
|
||||||
|
const customRoleUpdateEnvironments = await accessService.createRole(
|
||||||
|
{
|
||||||
|
name: 'Project Lead',
|
||||||
|
description: 'Role',
|
||||||
|
permissions: [
|
||||||
|
{
|
||||||
|
name: 'UPDATE_FEATURE_ENVIRONMENT',
|
||||||
|
environment: 'production',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'CREATE_FEATURE_STRATEGY',
|
||||||
|
environment: 'production',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
createdByUserId: SYSTEM_USER_ID,
|
||||||
|
},
|
||||||
|
SYSTEM_USER_AUDIT,
|
||||||
|
);
|
||||||
|
|
||||||
|
await projectService.setRolesForUser(
|
||||||
|
project.id,
|
||||||
|
projectUser.id,
|
||||||
|
[customRoleUserAccess.id],
|
||||||
|
auditUser,
|
||||||
|
);
|
||||||
|
|
||||||
|
const auditProjectUser = extractAuditInfoFromUser(projectUser);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
projectService.setRolesForUser(
|
projectService.setRolesForUser(
|
||||||
project.id,
|
project.id,
|
||||||
secondUser.id,
|
secondUser.id,
|
||||||
[customRole.id],
|
[customRoleUpdateEnvironments.id],
|
||||||
projectAuditUser,
|
auditProjectUser,
|
||||||
),
|
),
|
||||||
).rejects.toThrow(
|
).rejects.toThrow(
|
||||||
new InvalidOperationError(
|
new InvalidOperationError(
|
||||||
'User tried to assign a role they did not have access to',
|
'User tried to assign a role they did not have access to',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
|
||||||
test('Users can not assign roles they do not have to a group through explicit roles endpoint', async () => {
|
|
||||||
const project = {
|
|
||||||
id: 'user_fail_assign_to_group',
|
|
||||||
name: 'user_fail_assign_to_group',
|
|
||||||
description: '',
|
|
||||||
mode: 'open' as const,
|
|
||||||
defaultStickiness: 'clientId',
|
|
||||||
};
|
|
||||||
await projectService.createProject(project, user, auditUser);
|
|
||||||
const projectUser = await stores.userStore.insert({
|
|
||||||
name: 'Some project user',
|
|
||||||
email: 'fail_assign_role_to_group@example.com',
|
|
||||||
});
|
|
||||||
const projectAuditUser = extractAuditInfoFromUser(projectUser);
|
|
||||||
const group = await stores.groupStore.create({
|
const group = await stores.groupStore.create({
|
||||||
name: 'Some group_awaiting_role',
|
name: 'Some group_awaiting_role',
|
||||||
});
|
});
|
||||||
const customRole = await stores.roleStore.create({
|
|
||||||
name: 'role_that_noone_has_fail_assign_group',
|
await expect(
|
||||||
roleType: 'custom',
|
|
||||||
description:
|
|
||||||
'Used to prove that you can not assign a role you do not have via setRolesForGroup',
|
|
||||||
});
|
|
||||||
return expect(
|
|
||||||
projectService.setRolesForGroup(
|
projectService.setRolesForGroup(
|
||||||
project.id,
|
project.id,
|
||||||
group.id,
|
group.id,
|
||||||
[customRole.id],
|
[customRoleUpdateEnvironments.id],
|
||||||
projectAuditUser,
|
auditProjectUser,
|
||||||
),
|
),
|
||||||
).rejects.toThrow(
|
).rejects.toThrow(
|
||||||
new InvalidOperationError(
|
new InvalidOperationError(
|
||||||
|
@ -89,6 +89,7 @@ import { throwExceedsLimitError } from '../../error/exceeds-limit-error';
|
|||||||
import type EventEmitter from 'events';
|
import type EventEmitter from 'events';
|
||||||
import type { ApiTokenService } from '../../services/api-token-service';
|
import type { ApiTokenService } from '../../services/api-token-service';
|
||||||
import type { TransitionalProjectData } from './project-read-model-type';
|
import type { TransitionalProjectData } from './project-read-model-type';
|
||||||
|
import { canGrantProjectRole } from './can-grant-project-role';
|
||||||
|
|
||||||
type Days = number;
|
type Days = number;
|
||||||
type Count = number;
|
type Count = number;
|
||||||
@ -940,15 +941,38 @@ export default class ProjectService {
|
|||||||
userAddingAccess.id,
|
userAddingAccess.id,
|
||||||
projectId,
|
projectId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
this.isAdmin(userAddingAccess.id, userRoles) ||
|
this.isAdmin(userAddingAccess.id, userRoles) ||
|
||||||
this.isProjectOwner(userRoles, projectId)
|
this.isProjectOwner(userRoles, projectId)
|
||||||
) {
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return rolesBeingAdded.every((roleId) =>
|
|
||||||
userRoles.some((userRole) => userRole.id === roleId),
|
// Users may have access to multiple projects, so we need to filter out the permissions based on this project.
|
||||||
);
|
// Since the project roles are just collections of permissions that are not tied to a project in the database
|
||||||
|
// not filtering here might lead to false positives as they may have the permission in another project.
|
||||||
|
if (this.flagResolver.isEnabled('projectRoleAssignment')) {
|
||||||
|
const filteredUserPermissions = userPermissions.filter(
|
||||||
|
(permission) => permission.project === projectId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const rolesToBeAssignedData = await Promise.all(
|
||||||
|
rolesBeingAdded.map((role) => this.accessService.getRole(role)),
|
||||||
|
);
|
||||||
|
const rolesToBeAssignedPermissions = rolesToBeAssignedData.flatMap(
|
||||||
|
(role) => role.permissions,
|
||||||
|
);
|
||||||
|
|
||||||
|
return canGrantProjectRole(
|
||||||
|
filteredUserPermissions,
|
||||||
|
rolesToBeAssignedPermissions,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return rolesBeingAdded.every((roleId) =>
|
||||||
|
userRoles.some((userRole) => userRole.id === roleId),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async addAccess(
|
async addAccess(
|
||||||
|
@ -13,7 +13,10 @@ import { createRequestSchema } from '../../../openapi/util/create-request-schema
|
|||||||
import { createResponseSchema } from '../../../openapi/util/create-response-schema';
|
import { createResponseSchema } from '../../../openapi/util/create-response-schema';
|
||||||
import { meSchema, type MeSchema } from '../../../openapi/spec/me-schema';
|
import { meSchema, type MeSchema } from '../../../openapi/spec/me-schema';
|
||||||
import { serializeDates } from '../../../types/serialize-dates';
|
import { serializeDates } from '../../../types/serialize-dates';
|
||||||
import type { IUserPermission } from '../../../types/stores/access-store';
|
import type {
|
||||||
|
IRole,
|
||||||
|
IUserPermission,
|
||||||
|
} from '../../../types/stores/access-store';
|
||||||
import type { PasswordSchema } from '../../../openapi/spec/password-schema';
|
import type { PasswordSchema } from '../../../openapi/spec/password-schema';
|
||||||
import {
|
import {
|
||||||
emptyResponse,
|
emptyResponse,
|
||||||
@ -28,6 +31,7 @@ import {
|
|||||||
rolesSchema,
|
rolesSchema,
|
||||||
type RolesSchema,
|
type RolesSchema,
|
||||||
} from '../../../openapi/spec/roles-schema';
|
} from '../../../openapi/spec/roles-schema';
|
||||||
|
import type { IFlagResolver } from '../../../types';
|
||||||
|
|
||||||
class UserController extends Controller {
|
class UserController extends Controller {
|
||||||
private accessService: AccessService;
|
private accessService: AccessService;
|
||||||
@ -42,6 +46,8 @@ class UserController extends Controller {
|
|||||||
|
|
||||||
private projectService: ProjectService;
|
private projectService: ProjectService;
|
||||||
|
|
||||||
|
private flagResolver: IFlagResolver;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
config: IUnleashConfig,
|
config: IUnleashConfig,
|
||||||
{
|
{
|
||||||
@ -68,6 +74,7 @@ class UserController extends Controller {
|
|||||||
this.userSplashService = userSplashService;
|
this.userSplashService = userSplashService;
|
||||||
this.openApiService = openApiService;
|
this.openApiService = openApiService;
|
||||||
this.projectService = projectService;
|
this.projectService = projectService;
|
||||||
|
this.flagResolver = config.flagResolver;
|
||||||
|
|
||||||
this.route({
|
this.route({
|
||||||
method: 'get',
|
method: 'get',
|
||||||
@ -174,10 +181,15 @@ class UserController extends Controller {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { projectId } = req.query;
|
const { projectId } = req.query;
|
||||||
if (projectId) {
|
if (projectId) {
|
||||||
const roles = await this.accessService.getAllProjectRolesForUser(
|
let roles: IRole[];
|
||||||
req.user.id,
|
if (this.flagResolver.isEnabled('projectRoleAssignment')) {
|
||||||
projectId,
|
roles = await this.accessService.getProjectRoles();
|
||||||
);
|
} else {
|
||||||
|
roles = await this.accessService.getAllProjectRolesForUser(
|
||||||
|
req.user.id,
|
||||||
|
projectId,
|
||||||
|
);
|
||||||
|
}
|
||||||
this.openApiService.respondWithValidation(
|
this.openApiService.respondWithValidation(
|
||||||
200,
|
200,
|
||||||
res,
|
res,
|
||||||
|
@ -62,7 +62,8 @@ export type IFlagKey =
|
|||||||
| 'useProjectReadModel'
|
| 'useProjectReadModel'
|
||||||
| 'addonUsageMetrics'
|
| 'addonUsageMetrics'
|
||||||
| 'onboardingMetrics'
|
| 'onboardingMetrics'
|
||||||
| 'onboardingUI';
|
| 'onboardingUI'
|
||||||
|
| 'projectRoleAssignment';
|
||||||
|
|
||||||
export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>;
|
export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>;
|
||||||
|
|
||||||
@ -307,6 +308,10 @@ const flags: IFlags = {
|
|||||||
process.env.UNLEASH_EXPERIMENTAL_ONBOARDING_UI,
|
process.env.UNLEASH_EXPERIMENTAL_ONBOARDING_UI,
|
||||||
false,
|
false,
|
||||||
),
|
),
|
||||||
|
projectRoleAssignment: parseEnvVarBoolean(
|
||||||
|
process.env.UNLEASH_EXPERIMENTAL_PROJECT_ROLE_ASSIGNMENT,
|
||||||
|
false,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const defaultExperimentalOptions: IExperimentalOptions = {
|
export const defaultExperimentalOptions: IExperimentalOptions = {
|
||||||
|
Loading…
Reference in New Issue
Block a user