#25911·twenty

Field-level read permission bypass via groupBy queries in twenty-server

Author: geo-chenCreated Sep 14, 2026Updated Sep 15, 2026
Labelsprio: critical

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).

  1. As a workspace admin, create any object/field with a non-trivial value (e.g. a company with annualRevenue).
  2. Create a custom role with canReadObjectRecords:true on that object but canReadFieldValue:false on the specific field (via upsertObjectPermissions / upsertFieldPermissions on the /metadata endpoint), and assign that role to a non-admin member (e.g. via sendInvitations(emails, roleId)).
  3. 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.
  4. As the same restricted member, run the bypass:
    query {
      companiesGroupBy(groupBy: [{ name: true }], filter: { name: { eq: "SecretCo" } }, limit: 10) {
        groupByDimensionValues
        edges { node { id name annualRevenue { amountMicros currencyCode } } }
      }
    }
    
    -> returns the record including the live annualRevenue value, 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."