[RedwoodJS] (8) RedwoodJS 기초: 역할 기반 접근 제어 (Role-Based Access Control)

winluck·2024년 2월 29일
0

RedwoodJS

목록 보기
8/9
post-thumbnail

Java/Spring에서 Spring Security를 통한 RBAC에 대해 이론적으로 접해본 적은 있으나 실전에서 적용해본 경험은 없지만, RedwoodJS에선 RBAC가 어떻게 이루어지는지 개략적으로 살펴보자.

배경 지식

인증(Authentication)

유저나 디바이스의 신원을 확인하는 과정이다.
주로 로그인을 가리킨다.

인가(Authorization)

사용자에게 특정 리소스나 기능에 접근할 수 있는 권한을 부여하는 일련의 프로세스를 의미한다.

RBAC란?

Role-Based Access Control의 줄임말이며,
클라우드플레어에 따르면, RBAC란 조직(시스템) 내에서의 사용자의 역할(Role)을 기반으로 데이터에 대한 사용자 접근을 허용하거나 제한하는 일련의 과정이다.

RBAC를 활용하면 권한이 개인마다 천차만별로 부여되지 않기 때문에 유저 권한을 비교적 간단하게 관리할 수 있다. 그러나 수많은 상호작용과 수많은 역할이 존재하는 대형 서비스에서는 RBAC가 당연히 복잡해지고 변화 및 위험을 추적하기 어려워진다.

Front-End

RedwoodJS의 Front-End에서 RBAC가 이루어지는 과정을 살펴보자.

prisma의 User 스키마에 roles String을 추가한다.
(기본적으로 부여되는 Role은 "moderator"이다.)

yarn rw prisma migrate dev

api/src/lib/auth.ts

User의 currentUserRoles는 항상 string이기에 else if문이 필요없다.

Routes.tsx

우리는 역할 기반 접근 제어가 목적이기에, 로그인 된 유저 역시 admin Role을 가져야만 /admin에 접근하도록 수정하였다.

뭔가 이상한게 하나 있는 것 같지만 일단 넘어가자

잠깐! 이미 유저들이 DB에 있는데 여기서 특정 유저의 권한을 바꿔야할 때는 어떡할까?

yarn rw console

이후 아래 명령어를 실행하면 된다.

db.user.update({ where: { id: 1 } , data: { roles: 'admin' } })

전체 페이지를 잠그는 것도 가능하고, 당연히 특정 컴포넌트에 대한 제어 시도를 통제하는 것 역시 가능하다.

Comment.tsx

import { useMutation } from '@redwoodjs/web'

import { useAuth } from 'src/auth'

import { QUERY as CommentsQuery } from 'src/components/CommentsCell'

import type { Comment as IComment } from 'types/graphql'

const DELETE = gql`
  mutation DeleteCommentMutation($id: Int!) {
    deleteComment(id: $id) {
      postId
    }
  }
`

const formattedDate = (datetime: ConstructorParameters<typeof Date>[0]) => {
  const parsedDate = new Date(datetime)
  const month = parsedDate.toLocaleString('default', { month: 'long' })
  return `${parsedDate.getDate()} ${month} ${parsedDate.getFullYear()}`
}

interface Props {
  comment: Pick<IComment, 'postId' | 'id' | 'name' | 'createdAt' | 'body'>
}

const Comment = ({ comment }: Props) => {
  const { hasRole } = useAuth()
  const [deleteComment] = useMutation(DELETE, {
    refetchQueries: [
      {
        query: CommentsQuery,
        variables: { postId: comment.postId },
      },
    ],
  })

  const moderate = () => {
    if (confirm('Are you sure?')) {
      deleteComment({
        variables: { id: comment.id },
      })
    }
  }

  return (
    <div className="bg-gray-200 p-8 rounded-lg relative">
      <header className="flex justify-between">
        <h2 className="font-semibold text-gray-700">{comment.name}</h2>
        <time className="text-xs text-gray-500" dateTime={comment.createdAt}>
          {formattedDate(comment.createdAt)}
        </time>
      </header>
      <p className="text-sm mt-2">{comment.body}</p>
      {hasRole('moderator') && (
        <button
          type="button"
          onClick={moderate}
          className="absolute bottom-2 right-2 bg-red-500 text-xs rounded text-white px-2 py-1"
        >
          Delete
        </button>
      )}
    </div>
  )
}

export default Comment

우리는 useAuth()로부터 hasRole 기능을 활용할 수 있고, 이를 통해 현재 유저가 조건의 Role에 해당한다면 특정 컴포넌트(삭제 버튼 등)를 활성화할 수 있다.

근데 Storybook에선 안 뜨는데.. 어떻게 권한을 주죠? -> 아래에서 설명하겠다.
(Storybook은 훌륭한 도구다. Android 경험했던 입장에서 참담한 심정이다..)

참고로 댓글 삭제 기능 후 콜되는 refetchQuery가 postId를 요구하기 때문에, CommentsCell에서 postId 역시 반환하도록 수정해줘야 한다. CommentsCell.mock.ts 파일 역시 마찬가지다.

Role in Jest/Storybook

인증과 인가 역시 Jest와 Storybook에서 가짜로 주입할 수 있다!

Comment.stories.tsx

import Comment from './Comment'

export const defaultView = () => {
  return (
    <Comment
      comment={{
        id: 1,
        name: 'Rob Cameron',
        body: 'This is the first comment!',
        createdAt: '2020-01-01T12:34:56Z',
        postId: 1,
      }}
    />
  )
}

export const moderatorView = () => {
  mockCurrentUser({
    id: 1,
    email: 'moderator@moderator.com',
    roles: 'moderator',
  })

  return (
    <Comment
      comment={{
        id: 1,
        name: 'Rob Cameron',
        body: 'This is the first comment!',
        createdAt: '2020-01-01T12:34:56Z',
        postId: 1,
      }}
    />
  )
}

export default { title: 'Components/Comment' }

참고로 mockCurrentUser() 은 Storybook에서 자동으로 제공한다.

위와 같이 storybook에서도 mocking을 통해 delete 버튼을 확인할 수 있다.

Comment.test.tsx

import { render, screen, waitFor } from '@redwoodjs/testing'

import Comment from './Comment'

const COMMENT = {
  id: 1,
  name: 'John Doe',
  body: 'This is my comment',
  createdAt: '2020-01-02T12:34:56Z',
  postId: 1,
}

describe('Comment', () => {
  it('renders successfully', () => {
    render(<Comment comment={COMMENT} />)

    expect(screen.getByText(COMMENT.name)).toBeInTheDocument()
    expect(screen.getByText(COMMENT.body)).toBeInTheDocument()
    const dateExpect = screen.getByText('2 January 2020')
    expect(dateExpect).toBeInTheDocument()
    expect(dateExpect.nodeName).toEqual('TIME')
    expect(dateExpect).toHaveAttribute('datetime', COMMENT.createdAt)
  })

  it('does not render a delete button if user is logged out', async () => {
    render(<Comment comment={COMMENT} />)

    await waitFor(() =>
      expect(screen.queryByText('Delete')).not.toBeInTheDocument()
    )
  })

  it('renders a delete button if the user is a moderator', async () => {
    mockCurrentUser({
      id: 1,
      email: 'moderator@moderator.com',
      roles: 'moderator',
    })

    render(<Comment comment={COMMENT} />)

    await waitFor(() => expect(screen.getByText('Delete')).toBeInTheDocument())
  })
})

당연히 Jest 기반 테스트코드에서도 mocking이 가능하다.

이렇게 프런트엔드 진영에서의 RBAC 및 Storybook/Jest에서의 Mocking에 대해 간단히 알아보았다.

그렇다면 RedwoodJS의 백엔드 진영에서는 RBAC에 어떤 형태로 기여할까?

Back-End

comments.sdl.ts

이제 GraphQL 쿼리의 댓글 삭제 뮤테이션은 요청 유저의 Role이 moderator일 때만 기능한다.

만약 Service 로직 자체에 RBAC가 필요하다면 아래와 같이 처리할 수 있다.

comments.ts

src/lib/auth의 requireAuth 메서드를 통해 유저의 역할을 받아와 비즈니스 로직 실행 전 검증할 수 있다.

comments.scenarios.ts

import type { Prisma } from '@prisma/client'

export const standard = defineScenario<Prisma.CommentCreateArgs>({
  comment: {
    jane: {
      data: {
        name: 'Jane Doe',
        body: 'I like trees',
        post: {
          create: {
            title: 'Redwood Leaves',
            body: 'The quick brown fox jumped over the lazy dog.'
          }
        }
      }
    },
    john: {
      data: {
        name: 'John Doe',
        body: 'Hug a tree today',
        post: {
          create: {
            title: 'Root Systems',
            body: 'The five boxing wizards jump quickly.',
          }
        }
      }
    }
  }
})

export const postOnly = defineScenario<Prisma.PostCreateArgs>({
  post: {
    bark: {
      data: {
        title: 'Bark',
        body: "A tree's bark is worse than its bite",
      }
    }
  }
})

export type StandardScenario = typeof standard
export type PostOnlyScenario = typeof postOnly

comments.test.ts

import { AuthenticationError, ForbiddenError } from '@redwoodjs/graphql-server'

import { db } from 'src/lib/db'

import { comments, createComment, deleteComment } from './comments'

import type { PostOnlyScenario, StandardScenario } from './comments.scenarios'

describe('comments', () => {
  scenario(
    'returns all comments for a single post from the database',
    async (scenario) => {
      const result = await comments({ postId: scenario.comment.jane.postId })
      const post = await db.post.findUnique({
        where: { id: scenario.comment.jane.postId },
        include: { comments: true },
      })
      expect(result.length).toEqual(post.comments.length)
    }
  )

  scenario(
    'postOnly',
    'creates a new comment',
    async (scenario: PostOnlyScenario) => {
      const comment = await createComment({
        input: {
          name: 'Billy Bob',
          body: 'What is your favorite tree bark?',
          postId: scenario.post.bark.id,
        },
      })

      expect(comment.name).toEqual('Billy Bob')
      expect(comment.body).toEqual('What is your favorite tree bark?')
      expect(comment.postId).toEqual(scenario.post.bark.id)
      expect(comment.createdAt).not.toEqual(null)
    }
  )

  scenario(
    'allows a moderator to delete a comment',
    async (scenario: StandardScenario) => {
      mockCurrentUser({
        roles: 'moderator',
        id: 1,
        email: 'moderator@moderator.com',
      })

      const comment = await deleteComment({
        id: scenario.comment.jane.id,
      })
      expect(comment.id).toEqual(scenario.comment.jane.id)

      const result = await comments({ postId: scenario.comment.jane.postId })
      expect(result.length).toEqual(0)
    }
  )

  scenario(
    'does not allow a non-moderator to delete a comment',
    async (scenario: StandardScenario) => {
      mockCurrentUser({ roles: 'user', id: 1, email: 'user@user.com' })

      expect(() =>
        deleteComment({
          id: scenario.comment.jane.id,
        })
      ).toThrow(ForbiddenError)
    }
  )

  scenario(
    'does not allow a logged out user to delete a comment',
    async (scenario: StandardScenario) => {
      mockCurrentUser(null)

      expect(() =>
        deleteComment({
          id: scenario.comment.jane.id,
        })
      ).toThrow(AuthenticationError)
    }
  )
})

문제 없이 테스트가 통과되었다.

현재 Role은 단일 String이지만, 문자열의 배열로 구분하여 여러 Role을 부여하기도 한다. (A라는 권한을 부여받아 마땅하지만 그렇다고 B까지 모두 줘야하는 것은 아니기 때문이다. 유저에게 부여되는 권한은 항상 최소를 지향해야 한다.)

당장 Spring Security의 UserDetails 객체 역시 요구하는 Role의 자료형을 List으로 채택하고 있다.

상황에 따라 다양한 Role과 Role에 대응하는 권한을 효과적으로 관리하는 전략을 세우는 데 고민할 시간을 투자하는 것은 충분히 가치가 있다.

profile
Discover Tomorrow

0개의 댓글