Field-level read permission bypass via groupBy queries in twenty-server
reported via email on 22 June 2026, followed up via email on 18 August 2026:
We discovered an authorization bypass in the field-level (row/column) permission system of twenty-server: a non-admin workspace member with a role that restricts read access to a specific field (canReadFieldValue: false) can recover that field's value anyway by querying it through the <object>GroupBy(..., includeRecords) GraphQL resolver instead of the equivalent plain find resolver, which correctly enforces the restriction.
Root cause: GroupByWithRecordsService.addPartitionByToQueryBuilder() (packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/services/group-by-with-records.service.ts) strips the outer/executed query's aliases down to only subquery-flagged ones as a TypeORM workaround, which incidentally makes expressionMap.aliases[0].subQuery true for that outer query. The permission layer (packages/twenty-server/src/engine/twenty-orm/repository/permissions.utils.ts, getTargetEntityAndOperationType) uses exactly that flag to skip all field/row permission validation, under the assumption that "subquery permissions will be evaluated when it is executed" — but for this code path there is no separate outer validated query; this is the only query executed, and it never gets validated.
PoC
Environment: official twentycrm/twenty:latest Docker image via packages/twenty-docker/docker-compose.yml, multi-workspace mode enabled (a supported deployment configuration; the bug itself is in the role/permission engine and is configuration-independent).
- As a workspace admin, create any object/field with a non-trivial value (e.g. a
companywithannualRevenue). - Create a custom role with
canReadObjectRecords:trueon that object butcanReadFieldValue:falseon the specific field (viaupsertObjectPermissions/upsertFieldPermissionson the/metadataendpoint), and assign that role to a non-admin member (e.g. viasendInvitations(emails, roleId)). - As the restricted member, confirm the field is blocked normally:
->query { companies(filter: { name: { eq: "SecretCo" } }) { edges { node { id name annualRevenue { amountMicros currencyCode } } } } }FORBIDDEN/PERMISSION_DENIED. - As the same restricted member, run the bypass:
-> returns the record including the livequery { companiesGroupBy(groupBy: [{ name: true }], filter: { name: { eq: "SecretCo" } }, limit: 10) { groupByDimensionValues edges { node { id name annualRevenue { amountMicros currencyCode } } } } }annualRevenuevalue, no error.
This was reproduced end-to-end against a live container with real HTTP/GraphQL traffic and two independently created accounts (workspace admin + a separately invited, role-restricted member), exactly as described above.
Suggested fix: the groupBy-with-records outer query should be validated against the caller's field/object permissions before execution regardless of its subQuery alias flag, or the alias-stripping workaround should preserve a marker that lets the permission layer distinguish "genuinely nested, will be checked by an enclosing validated query" from "top-level query disguised as a subquery for TypeORM's benefit."
Source: twentyhq/twenty