Skip to content

AuthZInterceptors overwrite each other #443

Description

@jblossey

Describe the bug

The AuthorizerInterceptor stores the current resolver's authorizer on ctx.authorizer — a single property on the shared GraphQL context object. When a GraphQL request contains multiple root-level query fields that use different @Authorize() DTOs (e.g. { workExperiences { ... } projects { ... } }), the interceptors for each field run concurrently. Because they all write to the same ctx.authorizer property, the last interceptor to execute overwrites the authorizer for all fields. The @AuthorizerFilter param decorator then reads this single ctx.authorizer value, meaning some resolvers apply the wrong authorizer's filter to their queries.

In practice this causes completely incorrect query results: for example, entity IDs from a Project authorizer get used to filter WorkExperience rows, returning zero results for work experiences even though matching rows exist in the database.

Have you read the Contributing Guidelines?

Yes.

To Reproduce

  1. Create two DTOs with different @Authorize() implementations that return different filter shapes. For example:

    • ProjectDto with an authorizer that returns { id: { in: [<allowed project IDs>] } }
    • WorkExperienceDto with an authorizer that returns { cvId: { in: [<allowed CV IDs>] } }
  2. Send a single GraphQL query that requests both root fields in the same operation:

    query {
      workExperiences {
        edges { node { id role } }
        totalCount
      }
      projects {
        edges { node { id title } }
        totalCount
      }
    }
  3. Observe that workExperiences returns 0 results even when matching rows exist, because the Project authorizer's filter ({ id: { in: ['<project-id>'] } }) was applied to the WorkExperience query instead of the WorkExperience authorizer's filter ({ cvId: { in: ['<cv-id>'] } }).

  4. Query workExperiences alone (without projects in the same request) — it returns results correctly, confirming the race condition.

Expected behavior

Each root field should use its own DTO's authorizer, regardless of how many root fields are requested in a single GraphQL operation. The authorizer assigned by AuthorizerInterceptor for field A should never leak into or overwrite the authorizer for field B.

Screenshots

SQL generated with the bug (from TypeORM logging) — note the WHERE clause uses a Project ID to filter WorkExperience rows:

SELECT "WorkExperience"."id" AS "WorkExperience_id", ...
FROM "work_experience" "WorkExperience"
WHERE "WorkExperience"."id" IN ('project-id-1')

Desktop (please complete the following information):

  • Node Version: v22.22.0
  • Nestjs-query Version: v9.4.0 (also confirmed on v9.3.0)

Additional context

Root cause is in two files:

  1. src/interceptors/authorizer.interceptor.ts — the intercept method writes to a single shared property:

    intercept(context: ExecutionContext, next: CallHandler) {
      const gqlContext = GqlExecutionContext.create(context);
      const ctx = gqlContext.getContext();
      ctx.authorizer = this.authorizer; // ← overwrites for ALL concurrent fields
      return next.handle();
    }
  2. src/decorators/authorize-filter.decorator.ts — reads back that single shared property:

    function getContext(executionContext: ExecutionContext) {
      const gqlExecutionContext = GqlExecutionContext.create(executionContext);
      return gqlExecutionContext.getContext(); // ← returns ctx with potentially wrong .authorizer
    }

Suggested fix: Store authorizers in a Map keyed by parentType.name + '.' + fieldName on the context, and look up the correct authorizer per-field in the decorator. This is backward-compatible since ctx.authorizer can still be set as a fallback.

In the interceptor:

intercept(context: ExecutionContext, next: CallHandler) {
  const gqlContext = GqlExecutionContext.create(context);
  const ctx = gqlContext.getContext();
  const info = gqlContext.getInfo();
  const key = info.parentType.name + '.' + info.fieldName;
  if (!ctx._authorizers) ctx._authorizers = new Map();
  ctx._authorizers.set(key, this.authorizer);
  ctx.authorizer = this.authorizer; // backward compat
  return next.handle();
}

In the decorator:

function getContextAndAuthorizer(executionContext: ExecutionContext) {
  const gqlExecutionContext = GqlExecutionContext.create(executionContext);
  const ctx = gqlExecutionContext.getContext();
  const info = gqlExecutionContext.getInfo();
  let authorizer;
  if (ctx._authorizers && info) {
    const key = info.parentType.name + '.' + info.fieldName;
    authorizer = ctx._authorizers.get(key);
  }
  if (!authorizer) authorizer = ctx.authorizer;
  return { ctx, authorizer };
}

We have verified this fix resolves the issue in production-like conditions with both v9.3.0 and v9.4.0.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions