[permissions V2] Add integration tests on relations and objectRecord permissions (#12450)

In this PR

1. adding tests on relations and nested relations to make sure that if
any permission is missing, the query fails
2. adding tests on objectRecord permissions to make sure that
permissions granted or restricted by objectPermissions take precedence
on the role's allObjectRecords permissions
This commit is contained in:
Marie
2025-06-10 16:38:38 +02:00
committed by GitHub
parent 78ecb01c90
commit 264861e020
8 changed files with 635 additions and 25 deletions

View File

@ -101,6 +101,7 @@ export abstract class GraphqlQueryBaseResolverService<
} else {
if (!isPermissionsV2Enabled)
await this.validateObjectRecordPermissionsOrThrow({
objectMetadataId: objectMetadataItemWithFieldMaps.id,
operationName,
options,
});
@ -219,9 +220,11 @@ export abstract class GraphqlQueryBaseResolverService<
}
private async validateObjectRecordPermissionsOrThrow({
objectMetadataId,
operationName,
options,
}: {
objectMetadataId: string;
operationName: WorkspaceResolverBuilderMethodNames;
options: WorkspaceQueryRunnerOptions;
}) {
@ -234,6 +237,7 @@ export abstract class GraphqlQueryBaseResolverService<
requiredPermission,
workspaceId: options.authContext.workspace.id,
isExecutedByApiKey: isDefined(options.authContext.apiKey),
objectMetadataId,
});
if (!userHasPermission) {

View File

@ -226,11 +226,13 @@ export class PermissionsService {
workspaceId,
requiredPermission,
isExecutedByApiKey,
objectMetadataId,
}: {
userWorkspaceId?: string;
workspaceId: string;
requiredPermission: PermissionsOnAllObjectRecords;
isExecutedByApiKey: boolean;
objectMetadataId: string;
}): Promise<boolean> {
const isPermissionsV2Enabled =
await this.featureFlagService.isFeatureEnabled(
@ -279,11 +281,10 @@ export class PermissionsService {
const objectPermissionKey =
this.getObjectPermissionKeyForRequiredPermission(requiredPermission);
// until permissions V2 is enabled all objects have the same permission values deriving from role, ex role.canReadAllObjectRecords
const objectPermissionValue =
rolePermissionsForUserWorkspaceRole[
Object.keys(rolePermissionsForUserWorkspaceRole)[0]
]?.[objectPermissionKey];
rolePermissionsForUserWorkspaceRole[objectMetadataId]?.[
objectPermissionKey
];
return objectPermissionValue === true;
}

View File

@ -0,0 +1,196 @@
import { default as request } from 'supertest';
import { createCustomRoleWithObjectPermissions } from 'test/integration/graphql/utils/create-custom-role-with-object-permissions.util';
import { deleteRole } from 'test/integration/graphql/utils/delete-one-role.util';
import { findOneOperationFactory } from 'test/integration/graphql/utils/find-one-operation-factory.util';
import { makeGraphqlAPIRequestWithMemberRole as makeGraphqlAPIRequestWithJony } from 'test/integration/graphql/utils/make-graphql-api-request-with-member-role.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import { updateFeatureFlagFactory } from 'test/integration/graphql/utils/update-feature-flag-factory.util';
import { updateWorkspaceMemberRole } from 'test/integration/graphql/utils/update-workspace-member-role.util';
import { SEED_APPLE_WORKSPACE_ID } from 'src/database/typeorm-seeds/core/workspaces';
import { DEV_SEED_WORKSPACE_MEMBER_IDS } from 'src/database/typeorm-seeds/workspace/workspace-members';
import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { PermissionsExceptionMessage } from 'src/engine/metadata-modules/permissions/permissions.exception';
const client = request(`http://localhost:${APP_PORT}`);
describe('granularObjectRecordsPermissions', () => {
describe('permissions V2 enabled', () => {
let originalMemberRoleId: string;
let customRoleId: string;
beforeAll(async () => {
// Enable Permissions V2
const enablePermissionsQuery = updateFeatureFlagFactory(
SEED_APPLE_WORKSPACE_ID,
'IS_PERMISSIONS_V2_ENABLED',
true,
);
await makeGraphqlAPIRequest(enablePermissionsQuery);
// Get the original Member role ID for restoration later
const getRolesQuery = {
query: `
query GetRoles {
getRoles {
id
label
}
}
`,
};
const rolesResponse = await client
.post('/graphql')
.set('Authorization', `Bearer ${ADMIN_ACCESS_TOKEN}`)
.send(getRolesQuery);
originalMemberRoleId = rolesResponse.body.data.getRoles.find(
(role: any) => role.label === 'Member',
).id;
});
afterAll(async () => {
const restoreMemberRoleQuery = {
query: `
mutation UpdateWorkspaceMemberRole {
updateWorkspaceMemberRole(
workspaceMemberId: "${DEV_SEED_WORKSPACE_MEMBER_IDS.JONY}"
roleId: "${originalMemberRoleId}"
) {
id
}
}
`,
};
await client
.post('/graphql')
.set('Authorization', `Bearer ${ADMIN_ACCESS_TOKEN}`)
.send(restoreMemberRoleQuery);
// Disable Permissions V2
const disablePermissionsQuery = updateFeatureFlagFactory(
SEED_APPLE_WORKSPACE_ID,
'IS_PERMISSIONS_V2_ENABLED',
false,
);
await makeGraphqlAPIRequest(disablePermissionsQuery);
});
afterEach(async () => {
await deleteRole(client, customRoleId);
});
it('should throw permission error when querying person while person reading rights are overriden to false', async () => {
// Arrange
const { roleId } = await createCustomRoleWithObjectPermissions({
label: 'PersonReadRightsExcludedRole',
canReadPerson: false,
});
customRoleId = roleId;
await updateWorkspaceMemberRole({
client,
roleId: customRoleId,
workspaceMemberId: DEV_SEED_WORKSPACE_MEMBER_IDS.JONY,
});
// Act
const graphqlOperation = findOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: `
id
city
jobTitle
`,
filter: { city: { eq: 'Seattle' } },
});
const companyGraphqlOperation = findOneOperationFactory({
objectMetadataSingularName: 'company',
gqlFields: `
id
name
`,
filter: { name: { eq: 'Apple' } },
});
const personResponse =
await makeGraphqlAPIRequestWithJony(graphqlOperation);
const companyResponse = await makeGraphqlAPIRequestWithJony(
companyGraphqlOperation,
);
// Assert
expect(personResponse.body.errors).toBeDefined();
expect(personResponse.body.errors[0].message).toBe(
PermissionsExceptionMessage.PERMISSION_DENIED,
);
expect(personResponse.body.errors[0].extensions.code).toBe(
ErrorCode.FORBIDDEN,
);
expect(companyResponse.body.data).toBeDefined();
expect(companyResponse.body.data.company).toBeDefined();
});
it('should successfully query person when person reading rights are overriden to true', async () => {
// Arrange
const { roleId } = await createCustomRoleWithObjectPermissions({
label: 'PersonRole',
canReadPerson: true,
hasAllObjectRecordsReadPermission: false,
});
customRoleId = roleId;
await updateWorkspaceMemberRole({
client,
roleId: customRoleId,
workspaceMemberId: DEV_SEED_WORKSPACE_MEMBER_IDS.JONY,
});
// Act
const graphqlOperation = findOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: `
id
city
jobTitle
`,
filter: { city: { eq: 'Seattle' } },
});
const companyGraphqlOperation = findOneOperationFactory({
objectMetadataSingularName: 'company',
gqlFields: `
id
name
`,
filter: { name: { eq: 'Apple' } },
});
const personResponse =
await makeGraphqlAPIRequestWithJony(graphqlOperation);
const companyResponse = await makeGraphqlAPIRequestWithJony(
companyGraphqlOperation,
);
// Assert
expect(personResponse.body.data).toBeDefined();
expect(personResponse.body.data.person).toBeDefined();
expect(companyResponse.body.errors).toBeDefined();
expect(companyResponse.body.errors[0].message).toBe(
PermissionsExceptionMessage.PERMISSION_DENIED,
);
expect(companyResponse.body.errors[0].extensions.code).toBe(
ErrorCode.FORBIDDEN,
);
});
});
});

View File

@ -0,0 +1,252 @@
import { randomUUID } from 'crypto';
import { default as request } from 'supertest';
import { PERSON_GQL_FIELDS } from 'test/integration/constants/person-gql-fields.constants';
import { createCustomRoleWithObjectPermissions } from 'test/integration/graphql/utils/create-custom-role-with-object-permissions.util';
import { createOneOperationFactory } from 'test/integration/graphql/utils/create-one-operation-factory.util';
import { deleteRole } from 'test/integration/graphql/utils/delete-one-role.util';
import { findManyOperationFactory } from 'test/integration/graphql/utils/find-many-operation-factory.util';
import { makeGraphqlAPIRequestWithMemberRole as makeGraphqlAPIRequestWithJony } from 'test/integration/graphql/utils/make-graphql-api-request-with-member-role.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import { updateFeatureFlagFactory } from 'test/integration/graphql/utils/update-feature-flag-factory.util';
import { updateWorkspaceMemberRole } from 'test/integration/graphql/utils/update-workspace-member-role.util';
import { SEED_APPLE_WORKSPACE_ID } from 'src/database/typeorm-seeds/core/workspaces';
import { DEV_SEED_WORKSPACE_MEMBER_IDS } from 'src/database/typeorm-seeds/workspace/workspace-members';
import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { PermissionsExceptionMessage } from 'src/engine/metadata-modules/permissions/permissions.exception';
const client = request(`http://localhost:${APP_PORT}`);
describe('permissionsOnRelations', () => {
describe('permissions V2 enabled', () => {
let originalMemberRoleId: string;
let customRoleId: string;
beforeAll(async () => {
// Enable Permissions V2
const enablePermissionsQuery = updateFeatureFlagFactory(
SEED_APPLE_WORKSPACE_ID,
'IS_PERMISSIONS_V2_ENABLED',
true,
);
await makeGraphqlAPIRequest(enablePermissionsQuery);
// Get the original Member role ID for restoration later
const getRolesQuery = {
query: `
query GetRoles {
getRoles {
id
label
}
}
`,
};
const rolesResponse = await client
.post('/graphql')
.set('Authorization', `Bearer ${ADMIN_ACCESS_TOKEN}`)
.send(getRolesQuery);
originalMemberRoleId = rolesResponse.body.data.getRoles.find(
(role: any) => role.label === 'Member',
).id;
// Create a person record
const companyId = randomUUID();
const graphqlOperationForCompanyCreation = createOneOperationFactory({
objectMetadataSingularName: 'company',
gqlFields: `
name
`,
data: {
id: companyId,
name: 'Twenty',
},
});
await makeGraphqlAPIRequest(graphqlOperationForCompanyCreation);
const graphqlOperationForPersonCreation = createOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: PERSON_GQL_FIELDS,
data: {
id: randomUUID(),
name: {
firstName: 'Marie',
},
city: 'Paris',
companyId,
},
});
await makeGraphqlAPIRequest(graphqlOperationForPersonCreation);
});
afterAll(async () => {
const restoreMemberRoleQuery = {
query: `
mutation UpdateWorkspaceMemberRole {
updateWorkspaceMemberRole(
workspaceMemberId: "${DEV_SEED_WORKSPACE_MEMBER_IDS.JONY}"
roleId: "${originalMemberRoleId}"
) {
id
}
}
`,
};
await client
.post('/graphql')
.set('Authorization', `Bearer ${ADMIN_ACCESS_TOKEN}`)
.send(restoreMemberRoleQuery);
// Disable Permissions V2
const disablePermissionsQuery = updateFeatureFlagFactory(
SEED_APPLE_WORKSPACE_ID,
'IS_PERMISSIONS_V2_ENABLED',
false,
);
await makeGraphqlAPIRequest(disablePermissionsQuery);
});
afterEach(async () => {
await deleteRole(client, customRoleId);
});
it('should throw permission error when querying person with company relation without company read permission', async () => {
// Create a role with person read permission but no company read permission
const { roleId } = await createCustomRoleWithObjectPermissions({
label: 'PersonOnlyRole',
canReadPerson: true,
canReadCompany: false,
});
customRoleId = roleId;
await updateWorkspaceMemberRole({
client,
roleId: customRoleId,
workspaceMemberId: DEV_SEED_WORKSPACE_MEMBER_IDS.JONY,
});
// Create GraphQL query that includes company relation
const graphqlOperation = findManyOperationFactory({
objectMetadataSingularName: 'person',
objectMetadataPluralName: 'people',
gqlFields: `
id
city
jobTitle
company {
id
name
}
`,
});
const response = await makeGraphqlAPIRequestWithJony(graphqlOperation);
// The query should fail when trying to access company relation without permission
expect(response.body.errors[0].message).toBe(
PermissionsExceptionMessage.PERMISSION_DENIED,
);
expect(response.body.errors[0].extensions.code).toBe(ErrorCode.FORBIDDEN);
});
it('should successfully query person with company relation when having both permissions', async () => {
// Create a role with both person and company read permissions
const { roleId } = await createCustomRoleWithObjectPermissions({
label: 'PersonAndCompanyRole',
canReadPerson: true,
canReadCompany: true,
});
customRoleId = roleId;
await updateWorkspaceMemberRole({
client,
roleId: customRoleId,
workspaceMemberId: DEV_SEED_WORKSPACE_MEMBER_IDS.JONY,
});
// Create GraphQL query that includes company relation
const graphqlOperation = findManyOperationFactory({
objectMetadataSingularName: 'person',
objectMetadataPluralName: 'people',
gqlFields: `
id
city
jobTitle
company {
id
name
}
`,
});
const response = await makeGraphqlAPIRequestWithJony(graphqlOperation);
// The query should succeed
expect(response.body.data).toBeDefined();
expect(response.body.data.people).toBeDefined();
const person = response.body.data.people.edges[0].node;
expect(person.company).toBeDefined();
expect(response.body.error).toBeUndefined();
});
it('nested relations - should throw permission error when querying nested opportunity relation without opportunity read permission', async () => {
// Where user has person and company read permissions but not opportunity read permission
const { roleId } = await createCustomRoleWithObjectPermissions({
label: 'PersonCompanyOnlyRole',
canReadPerson: true,
canReadCompany: true,
canReadOpportunities: false,
});
customRoleId = roleId;
await updateWorkspaceMemberRole({
client,
roleId: customRoleId,
workspaceMemberId: DEV_SEED_WORKSPACE_MEMBER_IDS.JONY,
});
// Create a query with nested relations
const graphqlOperation = findManyOperationFactory({
objectMetadataSingularName: 'person',
objectMetadataPluralName: 'people',
gqlFields: `
id
city
jobTitle
company {
id
name
opportunities {
edges {
node {
name
}
}
}
}
`,
});
const response = await makeGraphqlAPIRequestWithJony(graphqlOperation);
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toBe(
PermissionsExceptionMessage.PERMISSION_DENIED,
);
expect(response.body.errors[0].extensions.code).toBe(ErrorCode.FORBIDDEN);
});
});
});

View File

@ -3,6 +3,7 @@ import request from 'supertest';
import { deleteOneRoleOperationFactory } from 'test/integration/graphql/utils/delete-one-role-operation-factory.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import { updateFeatureFlagFactory } from 'test/integration/graphql/utils/update-feature-flag-factory.util';
import { updateWorkspaceMemberRole } from 'test/integration/graphql/utils/update-workspace-member-role.util';
import { createOneObjectMetadataQueryFactory } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata-query-factory.util';
import { deleteOneObjectMetadataQueryFactory } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata-query-factory.util';
@ -99,27 +100,11 @@ describe('Granular settings permissions', () => {
.send(upsertSettingPermissionsQuery);
// Assign the custom role to JONY (who uses MEMBER_ACCESS_TOKEN)
const updateMemberRoleQuery = {
query: `
mutation UpdateWorkspaceMemberRole {
updateWorkspaceMemberRole(
workspaceMemberId: "${DEV_SEED_WORKSPACE_MEMBER_IDS.JONY}"
roleId: "${customRoleId}"
) {
id
roles {
id
label
}
}
}
`,
};
await client
.post('/graphql')
.set('Authorization', `Bearer ${ADMIN_ACCESS_TOKEN}`)
.send(updateMemberRoleQuery);
await updateWorkspaceMemberRole({
client,
roleId: customRoleId,
workspaceMemberId: DEV_SEED_WORKSPACE_MEMBER_IDS.JONY,
});
});
afterAll(async () => {

View File

@ -0,0 +1,131 @@
import gql from 'graphql-tag';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
export const createCustomRoleWithObjectPermissions = async (options: {
label: string;
canReadPerson?: boolean;
canReadCompany?: boolean;
canReadOpportunities?: boolean;
hasAllObjectRecordsReadPermission?: boolean;
}) => {
const createRoleOperation = {
query: gql`
mutation CreateOneRole {
createOneRole(createRoleInput: {
label: "${options.label}"
description: "Test role for permission testing"
canUpdateAllSettings: ${options.hasAllObjectRecordsReadPermission ?? true}
canReadAllObjectRecords: ${options.hasAllObjectRecordsReadPermission ?? true}
canUpdateAllObjectRecords: ${options.hasAllObjectRecordsReadPermission ?? true}
canSoftDeleteAllObjectRecords: ${options.hasAllObjectRecordsReadPermission ?? true}
canDestroyAllObjectRecords: ${options.hasAllObjectRecordsReadPermission ?? true}
}) {
id
label
}
}
`,
};
const response = await makeGraphqlAPIRequest(createRoleOperation);
const roleId = response.body.data.createOneRole.id;
// Get object metadata IDs for Person and Company
const getObjectMetadataOperation = {
query: gql`
query {
objects(paging: { first: 1000 }) {
edges {
node {
id
nameSingular
}
}
}
}
`,
};
const objectMetadataResponse = await makeMetadataAPIRequest(
getObjectMetadataOperation,
);
const objects = objectMetadataResponse.body.data.objects.edges;
const personObjectId = objects.find(
(obj: any) => obj.node.nameSingular === 'person',
)?.node.id;
const companyObjectId = objects.find(
(obj: any) => obj.node.nameSingular === 'company',
)?.node.id;
const opportunityObjectId = objects.find(
(obj: any) => obj.node.nameSingular === 'opportunity',
)?.node.id;
// Create object permissions based on the options
const objectPermissions = [];
if (options.canReadPerson !== undefined) {
objectPermissions.push({
objectMetadataId: personObjectId,
canReadObjectRecords: options.canReadPerson,
canUpdateObjectRecords: false,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
});
}
if (options.canReadCompany !== undefined) {
objectPermissions.push({
objectMetadataId: companyObjectId,
canReadObjectRecords: options.canReadCompany,
canUpdateObjectRecords: false,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
});
}
if (options.canReadOpportunities !== undefined) {
objectPermissions.push({
objectMetadataId: opportunityObjectId,
canReadObjectRecords: options.canReadOpportunities,
canUpdateObjectRecords: false,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
});
}
if (objectPermissions.length > 0) {
const upsertObjectPermissionsOperation = {
query: gql`
mutation UpsertObjectPermissions(
$roleId: String!
$objectPermissions: [ObjectPermissionInput!]!
) {
upsertObjectPermissions(
upsertObjectPermissionsInput: {
roleId: $roleId
objectPermissions: $objectPermissions
}
) {
objectMetadataId
canReadObjectRecords
}
}
`,
variables: {
roleId,
objectPermissions,
},
};
await makeGraphqlAPIRequest(upsertObjectPermissionsOperation);
}
return {
roleId,
personObjectId,
companyObjectId,
opportunityObjectId,
};
};

View File

@ -0,0 +1,10 @@
import { deleteOneRoleOperationFactory } from 'test/integration/graphql/utils/delete-one-role-operation-factory.util';
export const deleteRole = async (client: any, roleId: string) => {
const deleteRoleQuery = deleteOneRoleOperationFactory(roleId);
await client
.post('/graphql')
.set('Authorization', `Bearer ${ADMIN_ACCESS_TOKEN}`)
.send(deleteRoleQuery);
};

View File

@ -0,0 +1,31 @@
export const updateWorkspaceMemberRole = async ({
client,
roleId,
workspaceMemberId,
}: {
client: any;
roleId: string;
workspaceMemberId: string;
}) => {
const updateMemberRoleQuery = {
query: `
mutation UpdateWorkspaceMemberRole {
updateWorkspaceMemberRole(
workspaceMemberId: "${workspaceMemberId}"
roleId: "${roleId}"
) {
id
roles {
id
label
}
}
}
`,
};
await client
.post('/graphql')
.set('Authorization', `Bearer ${ADMIN_ACCESS_TOKEN}`)
.send(updateMemberRoleQuery);
};