Skip to content

[permissions] Add permissions check layer in entityManager #11818

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 15 commits into from
May 5, 2025
Merged

Conversation

ijreilly
Copy link
Collaborator

@ijreilly ijreilly commented Apr 30, 2025

First and main step of twentyhq/core-team-issues#747

We are implementing a permission check layer in our custom WorkspaceEntityManager by overriding all the db-executing methods (this PR only overrides some as a POC, the rest will be done in the next PR).
Our custom repositories call entity managers under the hood to interact with the db so this solves the repositories case too.
This is still behind the feature flag IsPermissionsV2Enabled.

In the next PR

  • finish overriding all the methods required in WorkspaceEntityManager
  • add tests

Copy link
Contributor

github-actions bot commented Apr 30, 2025

🚀 Preview Environment Ready!

Your preview environment is available at: http://bore.pub:17301

This environment will automatically shut down when the PR is closed or after 5 hours.

this.featureFlagMap = featureFlagMap;
this.featureFlagMapVersion = featureFlagMapVersion;
// Recreate manager after internalContext has been initialized
this.manager = this.createEntityManager();
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

order matters, otherwise this.featureFlagMap is empty

@@ -68,6 +81,88 @@ export class WorkspaceEntityManager extends EntityManager {
return newRepository;
}

override createQueryBuilder<Entity extends ObjectLiteral>(
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is currently only used in seeds

@ijreilly ijreilly changed the title Perm repo [permissions] Add permissions check layer in entityManager May 2, 2025
@ijreilly ijreilly marked this pull request as ready for review May 2, 2025 12:54
Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Summary

This PR implements a comprehensive permission check layer in the WorkspaceEntityManager by overriding database-executing methods. Here are the key changes:

  • Replaced TypeORM's EntityManager with custom WorkspaceEntityManager across all database operations to enforce permission checks
  • Added shouldBypassPermissionChecks flag to allow seeding operations to bypass permission validation
  • Implemented permission validation utilities and query builders with proper checks before executing operations
  • Feature is controlled by IsPermissionsV2Enabled flag for gradual rollout

Key points to review:

  • Permission validation is only implemented for some methods as proof of concept, with more to come in next PR
  • Tests are deferred to a future PR which could be risky for such a fundamental change
  • Raw SQL queries in some repositories bypass the new permission layer
  • Transaction management changes could impact data consistency in some services

The PR appears to be incorrectly linked to issue #747 about probability picker UI, as this is a backend permissions infrastructure change.

59 file(s) reviewed, 8 comment(s)
Edit PR Review Bot Settings | Greptile


validateOperationIsPermittedOrThrow({
entityName: this.extractTargetNameSingularFromEntityTarget(target),
operationType: 'update',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: operationType is hardcoded as 'update' for both insert and upsert operations. This is incorrect and could lead to wrong permission checks.

Suggested change
operationType: 'update',
operationType: 'create',

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is it expected?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oops no, this should not be hardcoded indeed ! (didn't matter too much here as insert and update are behind the same permission gate, canUpdate, but definitely required for what's next)

Comment on lines +87 to +93
expect(response.body.data).toStrictEqual({ createPerson: null });
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);
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: Test could be more specific by checking error array length is exactly 1 to ensure no unexpected additional errors

Copy link
Member

@Weiko Weiko left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is great!! Good job @ijreilly 👏

mode = 'master' as ReplicationMode,
): WorkspaceQueryRunner {
const queryRunner = this.driver.createQueryRunner(mode);
const manager = this.createEntityManager(queryRunner);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we use this.manager here?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think we need to pass our queryRunner to keep the same across different operations led by different managers

import { WorkspaceDataSource } from 'src/engine/twenty-orm/datasource/workspace.datasource';
import { validateOperationIsPermittedOrThrow } from 'src/engine/twenty-orm/repository/permissions.utils';
import { WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-select-query-builder';
import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';

export class WorkspaceEntityManager extends EntityManager {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just realised this but could we rename the file accordingly?

@@ -60,12 +61,13 @@ export class SeederService {
const schemaName =
this.workspaceDataSourceService.getSchemaName(workspaceId);

const workspaceDataSource =
const workspaceDataSource: DataSource =
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not your fault and note for myself but I've just realised that this one is very misleading since it returns the main datasource from TypeORMService (which is not the workspace datasource (also why your type is DataSource here)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know, initially I had left two comments around workspaceDataSource and the below entityManager to raise attention around that, but thought that was maybe too much and added explicit types instead. wdyt ?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be fine for now but I think our next goal should be:

  • Removing DataSource table, move schema name to Workspace table and we don't need the rest.
  • connectedToWorkspaceDataSourceAndReturnMetadata should be removed, this is only called during seeding or internal code and it's actually returning the mainDataSource as stated above. We should use typeorm service in those places directly and have a getMainDataSource() instead of connectToDataSource()

return queryRunner as any as WorkspaceQueryRunner;
}

override transaction<T>(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this override needed? Looks like the implementation from typeorm already with no particular changes or maybe I'm missing something?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't remember why i did it in the first place indeed, doesn't look necessary yes, removing it !


validateOperationIsPermittedOrThrow({
entityName: this.extractTargetNameSingularFromEntityTarget(target),
operationType: 'update',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is it expected?

},
transactionManager,
);
const existingCompanies = await companyRepository.find({
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@@ -14,7 +12,6 @@ export class BlocklistRepository {
public async getById(
id: string,
workspaceId: string,
transactionManager?: EntityManager,
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

checked - was never used

@@ -45,7 +45,6 @@ export class CreateCompanyAndContactService {
contactsToCreate: Contact[],
workspaceId: string,
source: FieldActorSource,
transactionManager?: EntityManager,
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

checked - was not used

@@ -74,7 +74,6 @@ export class CreateContactService {
public async createPeople(
contactsToCreate: ContactToCreate[],
workspaceId: string,
transactionManager?: EntityManager,
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

checked - was not used

@@ -159,7 +157,6 @@ export class TimelineActivityRepository {
linkedObjectMetadataId: string | undefined;
}[],
workspaceId: string,
transactionManager?: EntityManager,
Copy link
Collaborator Author

@ijreilly ijreilly May 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member

@Weiko Weiko left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@@ -144,7 +144,7 @@ export class GraphqlQueryFindManyResolverService extends GraphqlQueryBaseResolve
}

const parentObjectRecordsAggregatedValues =
await aggregateQueryBuilder.getRawOne();
await aggregateQueryBuilder.getRawOne<any>();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

<Record<string, any>> might be a little bit more accurate here but that's probably the best we can do in terms of typing for now

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually I've just realised you have this typing error everywhere, maybe it's better to implement a default type in the class itself (see my other comment)

@@ -124,6 +124,7 @@ export class WorkspaceDataSourceService {
workspaceId: string,
transactionManager?: EntityManager,
): Promise<any> {
// TODO
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could be more explicit here if you want to leave a TODO?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oops, that was just something for me to think of but i will remove it for now

return super.getRawOne();
}

override getRawMany<U>(): Promise<U[]> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
override getRawMany<U>(): Promise<U[]> {
override getRawMany<U = any>(): Promise<U[]> {

to avoid having to pass any everywhere anyway (or casting)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes right ! actually that's what they do in typeORM's SelectQueryBuilder. thanks

@ijreilly ijreilly enabled auto-merge (squash) May 5, 2025 13:59
@ijreilly ijreilly merged commit a9e73c6 into main May 5, 2025
37 checks passed
@ijreilly ijreilly deleted the perm--repo branch May 5, 2025 14:06
Copy link

sentry-io bot commented May 13, 2025

Suspect Issues

This pull request was deployed and Sentry observed the following issues:

  • ‼️ QueryFailedError: deadlock detected {WorkspaceRepository#2}.save(workspace.repository) View Issue

Did you find this useful? React with a 👍 or 👎

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants