GraphQL API’da Field-Level Yetkilendirme: Rol Bazlı Erişim Kontrolü

Bir GraphQL API tasarlarken en sık atlanan güvenlik konularından biri, field-level yetkilendirmedir. Çoğu geliştirici kimlik doğrulamayı halleder, belki bazı mutation’lara rol kontrolü ekler ama iş field bazında erişim kontrolüne gelince “şimdilik yeter” moduna geçer. Bu bir süre sonra ciddi güvenlik açıklarına yol açar. Örneğin kullanıcı listesini çekebilen bir API consumer’ı, o listenin içindeki salary veya ssn field’larını da görebiliyor olabilir. Bu yazıda gerçek dünya senaryoları üzerinden, GraphQL’de field-level RBAC’ı nasıl uygulayacağımıza bakacağız.

Neden Field-Level Yetkilendirme Gerekli?

REST API’lerde endpoint bazlı yetkilendirme yapmak nispeten kolaydır. /api/users endpointine admin rolü gereksin, /api/public/posts herkese açık olsun gibi. GraphQL’de ise her şey tek bir endpoint üzerinden geçer ve istemci istediği field’ları seçerek sorgulayabilir. Bu güçlü bir özellik ama güvenlik açısından dikkat istiyor.

Şunu düşünelim: Bir HR uygulamanız var. User tipinizde şu field’lar mevcut:

  • id, name, email – herkes görebilir
  • department, title – şirket içi çalışanlar görebilir
  • salary, bankAccount – sadece HR ve finans ekibi görebilir
  • performanceScore – yöneticiler ve ilgili kişi görebilir
  • ssn – sadece super admin görebilir

Eğer tüm resolver’larınız sadece “kullanıcı giriş yapmış mı?” kontrolü yapıyorsa, muhasebedeki bir çalışan veya bir müşteri temsilcisi potansiyel olarak tüm bu bilgilere ulaşabilir.

Temel Yaklaşımlar

GraphQL’de field-level yetkilendirme için birkaç farklı yaklaşım var. Her birinin artıları ve eksileri mevcut.

1. Resolver İçinde Manuel Kontrol

En basit yaklaşım, her resolver’da context’ten gelen kullanıcı bilgisini kontrol etmek:

const resolvers = {
  User: {
    salary: (parent, args, context) => {
      const { currentUser } = context;
      
      if (!currentUser) {
        throw new AuthenticationError('Giriş yapmanız gerekiyor.');
      }
      
      const allowedRoles = ['HR', 'FINANCE', 'SUPER_ADMIN'];
      const hasAccess = currentUser.roles.some(role => 
        allowedRoles.includes(role)
      );
      
      if (!hasAccess) {
        throw new ForbiddenError('Bu bilgiye erişim yetkiniz yok.');
      }
      
      return parent.salary;
    },
    
    ssn: (parent, args, context) => {
      const { currentUser } = context;
      
      if (!currentUser || !currentUser.roles.includes('SUPER_ADMIN')) {
        throw new ForbiddenError('Bu alana erişim yetkiniz bulunmuyor.');
      }
      
      return parent.ssn;
    }
  }
};

Bu yaklaşım işe yarıyor ama scale etmiyor. 50 tane hassas field’ınız varsa her birinde aynı kontrolleri yazmak hem yorucu hem de hata yapmaya müsait.

2. Directive Tabanlı Yetkilendirme

Çok daha temiz bir yaklaşım, custom directive kullanmak. Schema tanımında doğrudan rolleri belirtebilirsiniz:

directive @auth(
  roles: [String!]!
  message: String
) on FIELD_DEFINITION

type User {
  id: ID!
  name: String!
  email: String!
  department: String @auth(roles: ["EMPLOYEE", "MANAGER", "HR", "SUPER_ADMIN"])
  salary: Float @auth(roles: ["HR", "FINANCE", "SUPER_ADMIN"], message: "Maaş bilgisine erişim yetkiniz yok")
  performanceScore: Float @auth(roles: ["MANAGER", "SUPER_ADMIN"])
  ssn: String @auth(roles: ["SUPER_ADMIN"])
  bankAccount: String @auth(roles: ["FINANCE", "SUPER_ADMIN"])
}

Directive implementasyonunu yapalım:

const { defaultFieldResolver, GraphQLSchema } = require('graphql');
const { SchemaDirectiveVisitor } = require('@graphql-tools/utils');
const { ForbiddenError, AuthenticationError } = require('apollo-server');

class AuthDirective extends SchemaDirectiveVisitor {
  visitFieldDefinition(field) {
    const { resolve = defaultFieldResolver } = field;
    const { roles, message } = this.args;
    
    field.resolve = async function(source, args, context, info) {
      const { currentUser } = context;
      
      if (!currentUser) {
        throw new AuthenticationError(
          'Bu alana erişmek için giriş yapmanız gerekiyor.'
        );
      }
      
      const userRoles = currentUser.roles || [];
      const hasPermission = roles.some(role => userRoles.includes(role));
      
      if (!hasPermission) {
        const errorMessage = message || 
          `Bu alana erişim için gerekli yetkiniz bulunmuyor. Gerekli roller: ${roles.join(', ')}`;
        throw new ForbiddenError(errorMessage);
      }
      
      return resolve.call(this, source, args, context, info);
    };
  }
}

// Apollo Server kurulumunda:
const server = new ApolloServer({
  typeDefs,
  resolvers,
  schemaDirectives: {
    auth: AuthDirective
  },
  context: ({ req }) => ({
    currentUser: extractUserFromToken(req.headers.authorization)
  })
});

Bu yaklaşım çok daha okunabilir ve maintainable. Schema’ya bakarak hangi field’ın hangi role ihtiyaç duyduğunu anlık görebiliyorsunuz.

3. graphql-shield ile Kural Tabanlı Yetkilendirme

Production ortamlarda en güçlü yaklaşımlardan biri graphql-shield kütüphanesi. Kuralları merkezi bir yerden yönetmenizi sağlıyor:

npm install graphql-shield

Kuralları tanımlayalım:

const { rule, shield, and, or, not } = require('graphql-shield');
const { AuthenticationError, ForbiddenError } = require('apollo-server');

// Temel kurallar
const isAuthenticated = rule({ cache: 'contextual' })(
  async (parent, args, context) => {
    return context.currentUser !== null && context.currentUser !== undefined;
  }
);

const isHR = rule({ cache: 'contextual' })(
  async (parent, args, context) => {
    if (!context.currentUser) return false;
    return context.currentUser.roles.includes('HR');
  }
);

const isFinance = rule({ cache: 'contextual' })(
  async (parent, args, context) => {
    if (!context.currentUser) return false;
    return context.currentUser.roles.includes('FINANCE');
  }
);

const isManager = rule({ cache: 'contextual' })(
  async (parent, args, context) => {
    if (!context.currentUser) return false;
    return context.currentUser.roles.includes('MANAGER');
  }
);

const isSuperAdmin = rule({ cache: 'contextual' })(
  async (parent, args, context) => {
    if (!context.currentUser) return false;
    return context.currentUser.roles.includes('SUPER_ADMIN');
  }
);

// Kendi verisine erişim kuralı
const isOwnProfile = rule({ cache: 'strict' })(
  async (parent, args, context) => {
    if (!context.currentUser) return false;
    return parent.id === context.currentUser.id;
  }
);

// Permissions matrix
const permissions = shield({
  Query: {
    me: isAuthenticated,
    users: or(isHR, isManager, isSuperAdmin),
    userById: isAuthenticated
  },
  Mutation: {
    updateUser: or(isHR, isSuperAdmin),
    deleteUser: isSuperAdmin,
    updateSalary: or(isFinance, isSuperAdmin)
  },
  User: {
    name: isAuthenticated,
    email: isAuthenticated,
    department: isAuthenticated,
    salary: or(isHR, isFinance, isSuperAdmin, isOwnProfile),
    performanceScore: or(isManager, isSuperAdmin, isOwnProfile),
    ssn: isSuperAdmin,
    bankAccount: or(isFinance, isSuperAdmin)
  }
}, {
  fallbackError: new ForbiddenError('Bu işlem için yetkiniz bulunmuyor.'),
  allowExternalErrors: true
});

Schema’ya middleware olarak eklemek:

const { makeExecutableSchema } = require('@graphql-tools/schema');
const { applyMiddleware } = require('graphql-middleware');

const schema = makeExecutableSchema({ typeDefs, resolvers });
const schemaWithPermissions = applyMiddleware(schema, permissions);

const server = new ApolloServer({
  schema: schemaWithPermissions,
  context: async ({ req }) => {
    const token = req.headers.authorization?.replace('Bearer ', '');
    const currentUser = token ? await verifyTokenAndGetUser(token) : null;
    return { currentUser };
  }
});

Null vs. Error: Hangisi Doğru?

Field-level yetkilendirmede önemli bir tasarım kararı: yetkisiz erişimde hata mı fırlatalım, yoksa null mı döndürelim? Her ikisinin de kullanım senaryoları var.

Hata fırlatmak: Kullanıcı o field’ın varlığından haberdar olmamalı. Güvenlik açısından daha sıkı bir yaklaşım.

Null döndürmek: Kullanıcı field’ın var olduğunu biliyor ama değerini göremez. UI’da “Gizli” gibi bir gösterim yapılabilir.

const resolvers = {
  User: {
    salary: (parent, args, context) => {
      const { currentUser } = context;
      const allowedRoles = ['HR', 'FINANCE', 'SUPER_ADMIN'];
      
      // Kendi maaşını görebilir
      if (parent.id === currentUser?.id) {
        return parent.salary;
      }
      
      // Yetkili rol varsa görebilir
      if (currentUser?.roles.some(r => allowedRoles.includes(r))) {
        return parent.salary;
      }
      
      // Hata fırlatmak yerine null dön - client "Gizli" gösterebilir
      return null;
    }
  }
};

Hangi yaklaşımı seçeceğiniz uygulamanızın ihtiyacına göre değişir. Bir financial dashboard için null yaklaşımı daha kullanıcı dostu olabilir. Gizli bir field’ın varlığını bile ifşa etmemek istiyorsanız hata fırlatın.

Audit Log: Yetkisiz Erişim Girişimlerini Kaydetmek

Üretim ortamında sadece erişimi engellemek yetmez, kimin neye erişmeye çalıştığını da kayıt altına almak gerekir:

const { rule } = require('graphql-shield');

const createSecureRule = (allowedRoles, fieldName) => {
  return rule({ cache: 'strict' })(
    async (parent, args, context, info) => {
      const { currentUser, auditLogger } = context;
      
      if (!currentUser) {
        await auditLogger.warn({
          event: 'UNAUTHORIZED_FIELD_ACCESS',
          field: fieldName,
          ip: context.req?.ip,
          timestamp: new Date().toISOString()
        });
        return false;
      }
      
      const hasAccess = allowedRoles.some(role => 
        currentUser.roles.includes(role)
      );
      
      if (!hasAccess) {
        await auditLogger.warn({
          event: 'FORBIDDEN_FIELD_ACCESS',
          field: fieldName,
          userId: currentUser.id,
          userRoles: currentUser.roles,
          requiredRoles: allowedRoles,
          ip: context.req?.ip,
          timestamp: new Date().toISOString()
        });
        return false;
      }
      
      // Başarılı erişimi de loglayabiliriz hassas field'lar için
      if (['ssn', 'bankAccount', 'salary'].includes(fieldName)) {
        await auditLogger.info({
          event: 'SENSITIVE_FIELD_ACCESS',
          field: fieldName,
          userId: currentUser.id,
          targetEntityId: parent.id,
          timestamp: new Date().toISOString()
        });
      }
      
      return true;
    }
  );
};

Introspection’ı Unutmayın

GraphQL’in introspection özelliği schema’nın tüm yapısını ifşa eder. Production’da introspection’ı tamamen kapatmak veya kısıtlamak önemlidir. Aksi halde yetkisiz bir kullanıcı hangi field’ların var olduğunu keşfedebilir:

const { ApolloServer } = require('apollo-server');
const { NoSchemaIntrospectionCustomRule } = require('graphql');

const server = new ApolloServer({
  typeDefs,
  resolvers,
  introspection: process.env.NODE_ENV !== 'production',
  validationRules: process.env.NODE_ENV === 'production' 
    ? [NoSchemaIntrospectionCustomRule] 
    : [],
  plugins: [
    {
      requestDidStart: async () => ({
        didResolveOperation: async ({ context, request }) => {
          // Introspection sorgularını loglayabiliriz
          if (request.query?.includes('__schema') || 
              request.query?.includes('__type')) {
            context.auditLogger?.warn({
              event: 'INTROSPECTION_ATTEMPT',
              userId: context.currentUser?.id,
              ip: context.req?.ip
            });
          }
        }
      })
    }
  ]
});

Test Senaryoları

Field-level yetkilendirmeyi doğru test etmek kritik önem taşır. Her rol için pozitif ve negatif testler yazın:

const { createTestClient } = require('apollo-server-testing');

describe('Field-Level Authorization', () => {
  const SALARY_QUERY = `
    query GetUserSalary($id: ID!) {
      userById(id: $id) {
        name
        salary
      }
    }
  `;

  test('HR rolü maaş bilgisini görebilmeli', async () => {
    const server = createServerWithUser({ 
      id: '1', 
      roles: ['HR'] 
    });
    const { query } = createTestClient(server);
    
    const result = await query({ 
      query: SALARY_QUERY, 
      variables: { id: '2' } 
    });
    
    expect(result.errors).toBeUndefined();
    expect(result.data.userById.salary).toBeDefined();
  });

  test('Normal çalışan başkasının maaşını görememeli', async () => {
    const server = createServerWithUser({ 
      id: '1', 
      roles: ['EMPLOYEE'] 
    });
    const { query } = createTestClient(server);
    
    const result = await query({ 
      query: SALARY_QUERY, 
      variables: { id: '2' } 
    });
    
    // Hata almalı veya null dönmeli
    const hasError = result.errors?.some(e => 
      e.extensions?.code === 'FORBIDDEN'
    );
    const salaryIsNull = result.data?.userById?.salary === null;
    
    expect(hasError || salaryIsNull).toBe(true);
  });

  test('Kullanıcı kendi maaşını görebilmeli', async () => {
    const server = createServerWithUser({ 
      id: '2', 
      roles: ['EMPLOYEE'] 
    });
    const { query } = createTestClient(server);
    
    const result = await query({ 
      query: SALARY_QUERY, 
      variables: { id: '2' } // Kendi ID'si
    });
    
    expect(result.data.userById.salary).toBeDefined();
  });
});

Performans: N+1 ve Caching

Her field resolver’da rol kontrolü yapmak performans sorunlarına yol açabilir. graphql-shield‘ın cache seçeneğini doğru kullanmak kritik:

  • contextual: Aynı context içinde kural bir kez hesaplanır, sonuç cache’lenir. Oturum bazlı kontroller için idealdir.
  • strict: Parent objesi de dahil tüm argümanlar aynı olduğunda cache kullanılır. isOwnProfile gibi parent’a bağlı kurallar için kullanın.
  • no_cache: Her seferinde yeniden hesapla. Bunu mümkün olduğunca az kullanın.

Veritabanı sorgularını gerektiren rol kontrollerinde DataLoader kullanmayı ihmal etmeyin. Aksi halde 100 kullanıcı listeleyen bir sorguda 100 ayrı rol doğrulama sorgusu atılabilir.

Sonuç

GraphQL’de field-level yetkilendirme başlangıçta karmaşık görünebilir ama doğru araçları kullandığınızda oldukça yönetilebilir bir hal alıyor. Özetleyecek olursak:

  • Küçük projeler için custom directive yaklaşımı temiz ve anlaşılır bir çözüm sunar.
  • Büyüyen ve karmaşıklaşan projelerde graphql-shield ile merkezi kural yönetimi size çok zaman kazandırır.
  • Hassas field’lara yapılan erişimleri her zaman audit log ile kayıt altına alın.
  • Production’da introspection’ı kapatın veya kısıtlayın.
  • Her rol için hem pozitif hem negatif test senaryoları yazın, güvenlik kontrollerinde test coverage şart.
  • Performansı korumak için cache stratejinizi dikkatli belirleyin.

Field-level yetkilendirme bir “nice to have” değil, üretim ortamına çıkacak her GraphQL API’ın sahip olması gereken temel bir güvenlik katmanıdır. Bunu erken dönemde mimari kararların bir parçası olarak ele almak, ilerleyen aşamalarda yaşayacağınız baş ağrılarını ciddi ölçüde azaltır.

Bir yanıt yazın

E-posta adresiniz yayınlanmayacak. Gerekli alanlar * ile işaretlenmişlerdir