🔷 GraphQL7. 보안 & 거버넌스04 — Authentication & Authorization

04 — Authentication & Authorization

한 줄 답: GraphQL에서 인증(누구냐?)은 transport 층(HTTP header)에서 context에 user를 주입하는 일이고, 권한(무엇을 할 수 있나?)은 필드 단위로 강제해야 한다. 셋 중 어디서 강제할지 — middleware vs directive vs library — 가 코드 가시성과 schema 가시성의 trade-off를 만든다.


Why — 왜 인증·권한을 분리하나

REST에서 둘은 자주 path 단위로 합쳐진다 — /admin/users라는 path가 둘 다를 암시. GraphQL은 endpoint가 하나이므로 분리가 명시적이어야 한다.

답하는 질문위치
Authentication (AuthN)누구냐?HTTP middleware → context.user
Authorization (AuthZ)이 사용자가 이 필드에 권한 있나?resolver / directive / library

이 분리가 흐려지면 흔한 사고 두 가지가 난다.

안티패턴사고
AuthN만 끼우고 AuthZ를 빠뜨림로그인한 사용자가 남의 데이터를 본다 (IDOR — Insecure Direct Object Reference)
AuthZ를 최상위 resolver에만 끼움user.email은 막혔지만 user.posts.author.email우회 노출

GraphQL의 resolver-per-field 구조는 권한 검사도 필드별로 짜야 한다는 뜻이다.


How — 어떻게 강제하나

1) AuthN — context에 user 주입

가장 표준적인 패턴.

// Apollo Server / Yoga 공통
const server = new ApolloServer({
  schema,
  context: async ({ req }) => {
    const token = req.headers.authorization?.replace("Bearer ", "");
    if (!token) return { user: null };
    try {
      const user = await verifyJWT(token);  // 또는 session lookup
      return { user };
    } catch {
      return { user: null };
    }
  },
});

resolver 어디서든 context.user로 접근. 인증 실패 시 throw하지 않고 null로. 그 이유는 익명 쿼리 일부는 통과시키고, 권한 검사 단계에서 거절하기 위해서다 (예: viewer는 nullable, me는 non-null).

2) AuthZ 패턴 4가지

권한 강제 위치는 4가지 자리다. 각자 가시성-유연성 trade-off가 다르다.

패턴어디에 권한 검사가 적히나장점단점
(a) Resolver inline각 resolver 함수 안가장 유연 — 임의 로직분산 — 빠뜨리기 쉬움
(b) Directive @authschema의 type/field 옆Schema에 보임정적 — 동적 조건은 한계
(c) Middleware / shieldresolver 외부 wrapping코드 분리schema에 안 보임
(d) External policy engineOPA · casbin · oso정책이 별도 언어호출 비용

(a) Resolver inline — 가장 기본

const resolvers = {
  Query: {
    user: async (_, { id }, ctx) => {
      if (!ctx.user) throw new GraphQLError("UNAUTHENTICATED");
      if (ctx.user.id !== id && !ctx.user.isAdmin)
        throw new GraphQLError("FORBIDDEN");
      return User.findById(id);
    },
  },
  User: {
    email: (parent, _, ctx) => {
      if (ctx.user?.id !== parent.id && !ctx.user?.isAdmin) return null;
      return parent.email;
    },
  },
};

직관적이지만 민감 필드마다 빠뜨릴 위험. 큰 schema에서 coverage가 떨어진다.

(b) @auth directive — schema에 보이는 권한

directive @auth(requires: Role = USER) on FIELD_DEFINITION | OBJECT
enum Role { USER ADMIN }
 
type Query {
  me: User @auth
  adminPanel: AdminPanel @auth(requires: ADMIN)
}
 
type User {
  id: ID!
  name: String!
  email: String @auth(requires: ADMIN)
  internalNotes: String @auth(requires: ADMIN)
}

schema 자체에 권한 정책이 보인다. introspection으로도 보이고 (켜져 있다면), 코드리뷰에서도 한눈에 잡힌다.

구현은 mapSchema (graphql-tools)로 schema를 변형한다.

import { mapSchema, MapperKind, getDirective } from "@graphql-tools/utils";
 
function authDirective(schema) {
  return mapSchema(schema, {
    [MapperKind.OBJECT_FIELD]: (fieldConfig) => {
      const auth = getDirective(schema, fieldConfig, "auth")?.[0];
      if (!auth) return;
      const required = auth.requires ?? "USER";
      const original = fieldConfig.resolve;
      fieldConfig.resolve = async (src, args, ctx, info) => {
        if (!ctx.user) throw new GraphQLError("UNAUTHENTICATED");
        if (required === "ADMIN" && !ctx.user.isAdmin)
          throw new GraphQLError("FORBIDDEN");
        return original ? original(src, args, ctx, info) : src[info.fieldName];
      };
      return fieldConfig;
    },
  });
}

→ Apollo Server, Yoga, Hot Chocolate 모두 directive를 first-class로 지원. AWS AppSync는 @aws_cognito_user_pools·@aws_iam 같은 공식 directive가 있다.

(c) graphql-shield — middleware로 분리

graphql-shield권한을 schema에서 분리해 코드 객체로 둔다.

import { shield, rule, and, or } from "graphql-shield";
 
const isAuthenticated = rule()((parent, args, ctx) => ctx.user !== null);
const isAdmin = rule()((parent, args, ctx) => ctx.user?.isAdmin === true);
const isSelf = rule()((parent, args, ctx) => ctx.user?.id === args.id);
 
const permissions = shield({
  Query: {
    me: isAuthenticated,
    user: or(isSelf, isAdmin),
    adminPanel: isAdmin,
  },
  User: {
    email: or(isSelf, isAdmin),
  },
}, { fallbackError: "FORBIDDEN" });
 
// schema에 middleware로 적용
import { applyMiddleware } from "graphql-middleware";
const protectedSchema = applyMiddleware(schema, permissions);

장점 — 권한 로직이 한 파일에 모인다. coverage가 한눈에 보임. 단점 — schema에는 권한이 안 보인다. introspection으로도 안 보이고, schema 리뷰에선 코드와 같이 봐야 한다.

(d) 외부 policy engine — OPA / casbin / oso

복잡한 권한 (RBAC + ABAC + 멀티 테넌시) 에서는 별도 언어로 정책을 표현하는 게 낫다.

# OPA (Open Policy Agent) — Rego 언어
package graphql.authz

default allow = false

allow {
  input.user.role == "admin"
}
allow {
  input.field == "User.email"
  input.user.id == input.parent.id
}
// resolver에서 OPA 질의
async function checkPolicy(ctx, info, parent) {
  const result = await fetch("http://opa:8181/v1/data/graphql/authz/allow", {
    method: "POST",
    body: JSON.stringify({
      input: { user: ctx.user, field: `${info.parentType.name}.${info.fieldName}`, parent },
    }),
  }).then(r => r.json());
  if (!result.result) throw new GraphQLError("FORBIDDEN");
}

osoPolar라는 DSL을, casbinACL/RBAC 모델을 정의한 후 라이브러리로 호출한다.

→ 외부 엔진의 장점 — 정책이 별도 git repo, 별도 팀이 관리, audit log 강함. 단점 — 호출 비용복잡도.

3) 패턴 선택 가이드

4) 핵심 함정 — list 필드에서의 권한

{ posts { id title author { email } } }

posts는 통과해도 author.email은 사용자마다 다르다. N개 author 각각에 대해 권한 검사가 돌아야 한다 — N+1 권한 검사 문제. DataLoader와 결합해서 user permission도 batch로 가져오는 것이 정석.

const permissionLoader = new DataLoader(async (userIds) => {
  return getPermissionsForUsers(userIds);  // batch
});

What — 구체 사양

@auth directive 매트릭스

서버빌트인 directive외부 directive 정의
Apollo Server없음 (직접 정의)@graphql-tools/utils mapSchema
AWS AppSync@aws_cognito_user_pools, @aws_iam, @aws_api_key, @aws_oidc빌트인
Hasura@auth_role (자동 생성)metadata 기반
Hot Chocolate (.NET)[Authorize] (C# attribute)@authorize SDL
Strawberry (Python)@strawberry.permission_classesdata class

graphql-shield 핵심 API

rule({ cache: "contextual" })  // request-scope cache
rule({ cache: "strict" })       // 같은 args면 1회만
 
and(rule1, rule2)
or(rule1, rule2)
not(rule)
chain(rule1, rule2)             // 순차 (성능 위해)
race(rule1, rule2)              // 병렬 (한 개 통과면 ok)

cache가 중요한 이유 — DB lookup이 필요한 rule이 필드마다 반복 호출되면 N+1.

error code 컨벤션

상황codeHTTP 상응
인증 누락/실패UNAUTHENTICATED401
권한 부족FORBIDDEN403
리소스 없음NOT_FOUND404
입력 잘못BAD_USER_INPUT400
서버 에러INTERNAL_SERVER_ERROR500
throw new GraphQLError("Forbidden", {
  extensions: { code: "FORBIDDEN", http: { status: 403 } },
});

(GraphQL은 일반적으로 200 OK를 반환하지만, Apollo는 errors 안에 status를 noting할 수 있게 한다.)

Apollo의 @authenticated & @requiresScopes (Federation 2)

Federation 2부터 표준 directive가 추가됐다.

type Query {
  me: User @authenticated
  adminPanel: AdminPanel @requiresScopes(scopes: [["admin:read"]])
}

router 단에서 검사 — gateway에서 막혀서 subgraph까지 안 간다. 즉 권한 검사가 Federation의 첫 hop에서 끝남.


What-if — 잘못 이해하면

1) 권한을 최상위에만 두면

Query.user에는 @auth가 있지만 Post.author.email열려 있다. relation 통과로 노출. 대응: 민감 필드는 type 단위@auth를 매기거나 shield의 User.email 룰을 두 곳에 모두 등록.

2) if (!ctx.user) return null조용히 막으면

→ 클라이언트는 권한 부족인지 데이터 없음인지 구분 못 한다. UX와 디버깅 모두 망가짐. 대응: throw로 명시. extensions.code로 분류.

3) Mutation에 권한을 안 매기면

→ Query에만 @auth 깔고 Mutation은 그대로 열어 두는 사고가 흔하다. 대응: Mutation은 디폴트로 deny, 명시적으로 공개 Mutation만 allow.

4) Permission lookup이 N+1을 부르면

→ 권한 검사가 DB 한 번씩. 100명의 user를 보면 100번 DB 조회. 대응: permission도 DataLoader로 batch.

5) @auth directive를 코드 변형 없이 정의만 하면

→ schema에는 있는데 실제 검사가 안 돈다. SDL만 적고 mapSchema를 안 한 사고. 대응: directive는 반드시 실행기가 필요. unit test로 검증.

6) Subscription에 권한이 없으면

→ subscription은 오랫동안 떠 있다. 권한이 연결 시점에만 검사되고 변경되어도 안 끊긴다. 대응: subscription은 connection 시점 + event마다 권한 재검사. 사용자 role 변경 시 강제 disconnect.

7) 외부 OPA/oso 호출이 resolver마다

→ network round-trip이 N번. p99가 망가짐. 대응: OPA는 batch API 사용 (POST /v1/data with array input), 또는 embedded (Rego를 Go에 임베드).


Insight — 흥미로운 이야기

”GitHub의 @requireAdmin은 schema에 명시되어 있다”

GitHub v4의 SDL을 introspection으로 받아 보면 수십 개 directive명시되어 있다 — @requireAdmin, @preview (early access feature), @deprecated. directive는 GitHub 입장에서 문서이자 코드다. 외부 개발자가 어떤 필드에 권한이 필요한지를 introspection만으로 안다.

→ 교훈: directive의 진짜 가치는 실행이 아니라 공개다.

”AWS AppSync는 directive를 유일한 권한 방식으로 만들었다”

AWS AppSync는 코드 작성 없이 스키마와 directive만으로 권한이 끝난다.

type Post @aws_cognito_user_pools {
  id: ID!
  title: String!
  internalNote: String @aws_iam   # IAM 호출자만 (서버간)
}

→ AppSync resolver는 VTL/JavaScript고, directive는 컴파일러가 검사. 코드에 권한이 새지 않는다 — 모든 권한이 schema와 IAM policy에만 산다.

→ 교훈: 권한을 코드 밖으로 빼면 audit이 schema diff로 가능해진다.

”graphql-shield의 ‘cache: contextual’은 왜 있나”

graphql-shield의 cache 옵션 contextualrequest-scope다 — 같은 user에 대해 같은 rule이 한 요청 안에서 여러 번 호출되어도 DB는 1번만 다녀온다. 이 한 옵션이 대규모 schema에서 graphql-shield를 쓸 수 있게 만든 결정타다. cache 없이 100개 필드에 rule을 깔면 100번의 권한 lookup.

→ 교훈: GraphQL 권한 검사는 resolver만큼 자주 호출된다. cache가 기능이 아니라 생존 조건.

”oso는 GitHub 권한 모델을 그대로 베꼈다”

oso의 예시 정책의 첫 페이지가 GitHub의 organization-repo-team-user 4-level 권한이다. 이 4-level은 Polar 10줄로 표현된다.

allow(user, "read", repo: Repository) if
  role in user.roles and
  role.repository = repo and
  role.name in ["admin", "maintainer", "writer", "reader"];

→ 같은 정책을 GraphQL resolver 안에 inline으로 짜면 수백 줄이 된다. 정책 언어의 가치는 표현력 압축에 있다.

→ 교훈: 권한이 데이터 모델만큼 복잡해지면 DSL이 답이다.


요약 + 다이어그램

인증은 context에 user 주입, 권한은 필드 단위 검사. 권한 위치 4 — resolver inline, @auth directive, graphql-shield middleware, 외부 policy engine. directive는 schema에 보이고, middleware는 코드에 숨고, policy engine은 별도 언어에 산다. 다음 문서는 허용된 사용자가 너무 많이 보낼 때 — rate limit & cost.

다음 문서: 05-rate-limiting-and-cost-analysis.mdx — 허용된 사용자도 너무 많이 보내면 어떻게 막을까.