-
Notifications
You must be signed in to change notification settings - Fork 3
워크스페이스 권한 설계, 구현 과정
이제 문서들을 모아두는 공간, 노드들이 올려져있는 캔버스를 하나의 워크스페이스로 정의할 것이다. 아직은 하나의 워크스페이스밖에 없지만, 이제 사용자가 로그인을 하면 자신만의 워크스페이스를 만들 수 있을 것이고, 워크스페이스를 여러 개 생성하여 문서들을 분류하고 싶을 수도 있을 것이다.
사용자의 회원가입, 로그인, 인증/인가 문제를 어떻게 구현했는지 궁금하다면 밑의 위키들을 참고!
워크스페이스를 모든 사람이 접근 가능하게 해야할까? 협업 툴이기에 일단은 특정 워크스페이스의 url이 있으면 누구나 찾아와 편집이 가능하게 구현해두었지만, 이 공용 워크스페이스로 발표자료를 모아두고 정리하면서 우리 팀은 다른 팀, 혹은 발표를 듣는 캠퍼들이 발표자료를 멋대로 삭제하거나 변경해두면 어떡하나, 라는 두려움에 계속 시달렸다.
생각해보면 옥토독스는 협업 툴이기 이전에 일차적으로 지식관리를 위해 사용하는 문서화 툴이다. 자신의 문서관리 공간이 모두에게 공개되기를 바라지 않는 사람들도 있을 것이다. 또한, 협업에 이 서비스를 사용하는 팀도 팀원 외에는 워크스페이스를 접근하지 못하는 것이 일반적이다.
워크스페이스의 권한을 구현해보자.
일단은 워크스페이스의 보안 정도를 public, private 두 종류로 구체화시켰다.
-
public workspace
워크스페이스의 url이 있는 사용자라면 로그인의 여부와 상관없이 해당 워크스페이스에 접근, 워크스페이스와 관련된 작업을 할 수 있는 워크스페이스를 public workspace라고 하겠다. public이 필요한 이유는 Octodocs를 처음 사용해보는 사용자들이 사이트에 접속했을 때의 메인 워크스페이스는 모두가 사용가능해야하기 때문이다. 또한, 급하게 협업을 할 때는 초대를 하는 과정이 번거로울 수도 있다.
-
private workspace
그와 반대로 private workspace는 일단은 본인만 사용할 수 있는 워크스페이스이다. 하지만 초대 url을 생성해 초대하고 싶은 사람에게 전달하면, 해당 사용자가 로그인 된 상태에서 초대 url에 접근해 워크스페이스의 일원으로 추가될 수 있다. 해당 url의 유효시간 안에 초대url에 접근한 사용자는 자신의 워크스페이스 목록해 해당 워크스페이스를 추가할 수 있다면 워크스페이스의 url에 접근, 작업이 가능하다.
public workspace는 해당 워크스페이스를 만든 사람 외에는 다른 역할이 필요 없지만, private workspace에는 해당 workspace를 만든 사람 외에도 작업할 사람의 역할을 규정할 필요가 있다.
-
owner
public 또는 private workspace를 생성한 사람의 Role이다. 해당 워크스페이스의 메타데이터 (워크스페이스 이름, 설명, 미리보기 이미지 등)를 관리할 수 있으며, 초대 url을 생성할 수 있다.
-
guest
owner에 의해 private workspace로 초대된 사람의 Role이다. 해당 워크스페이스와 관련된 작업을 할 수 있으나 owner처럼 메타데이터 변경, 초대 url 생성등의 작업을 하지는 못한다.
노션과 같은 협업 툴은 특정 워크스페이스에 접근은 가능하지만, 편집은 못하는 역할도 규정하고 있다. 하지만 우리 팀은 더이상의 역할을 두지 않고 Owner와 Guest로만 제한하기로 결정하였다. 그 이유는 이러하다.
- 기술적 복잡성: 다양한 역할을 추가하면 그만큼 권한 관리 로직이 복잡해진다. 예를 들어, 읽기 전용 사용자가 작성 권한이 있는 사용자의 작업을 볼 수 있지만 수정할 수 없도록 제어하려면, 각 API마다 세밀한 권한 검증이 필요하다. 무엇보다 현재 옥토독스는 사용자의 문서, 노드, 엣지 편집등의 작업을 API가 아니라 Socket과 프런트엔드 라이브러리를 사용하여 허용하고 있어서 클라이언트에서도 권한 검증을 작업해야 할 거싱다.
- 보안 관리의 어려움: 무엇보다 현재 옥토독스는 사용자의 문서, 노드, 엣지 편집등의 작업을 API가 아니라 Socket과 프런트엔드 라이브러리를 사용하여 허용하고 있다. 클라이언트에서도 권한 검증을 작업해야 하다보면 보안상의 문제가 생길 것이다.
- 사용자 경험의 단순화: 역할이 많아지면 사용자 입장에서도 혼란이 생길 수 있다.
단순한 구조로 시작함으로써 사용자 경험을 단순화하고, 권한 관리에 대한 부담을 최소화하여 권한 시스템 구축을 시작하겠다.
이제 Owner가 어떠한 방식으로 Guest를 초대할지 결정하겠다. 우리는 url기반 워크스페이스 초대 로직을 사용하기로 하였다. 해당 로직은 이러하다.
(1) 워크스페이스 Owner가 초대 링크를 만들겠다고 서버에 요청
(2) 서버는 해당 워크스페이스 정보 + 초대하려는 Role을 담아 JWT로 암호화하여 링크를 생성
(3) 초대 링크에 로그인한 사용자가 접근하면 서버는 토큰을 검증
(4) 토큰에 담겨있던 정보 기반 DB 업데이트
(5) 클라이언트는 DB 업데이트를 확인 후, 사용자를 워크스페이스로 리디렉션 ⇒ 접근과 편집이 모두 가능하다!
URL 기반 초대 및 권한 관리 시스템을 구현한 이유는 알림 시스템에 비해 상대적으로 구현이 간단하고 관리가 용이하기 때문이다.
알림을 통해 초대하는 시스템을 만들기 위해서는 다음과 같은 기술적 난이도가 추가된다:
- 실시간 알림 시스템 구축: 초대를 수락하거나 거부할 수 있도록 하는 알림을 제공하려면 실시간 알림 시스템이 필요하며, 이는 추가적인 서버 리소스와 인프라 구성이 필요해진다.
- 사용자 식별 및 메시지 전송: 초대 알림을 특정 사용자에게 보내려면 해당 사용자의 고유 식별자를 잘 관리해야 하고, 해당 사용자에게 적절한 채널(예: 이메일, 푸시 알림 등)을 통해 알림을 전송해야 한다. 이 과정에서는 알림 전송 실패나 사용자 네트워크 상태 등 다양한 예외 상황도 처리해야 한다.
- 알림 시스템 상태 관리: 초대와 관련된 알림이 수락되었는지, 거절되었는지, 혹은 보지 않았는지를 관리해야 하는데, 이를 위해 알림의 상태(예: 읽음, 미확인 등)를 서버에서 관리해야 한다. 이는 데이터베이스 스키마와 API 설계에 있어서도 추가적인 복잡성을 유발합니다.
- 구성 및 유지보수의 난이도: 실시간 알림을 구현하면 다양한 상황(예: 모바일 환경, 사용자 접속 중단 등)에서 알림을 정확하게 전달하는 것을 보장하기가 어렵다.
URL 기반 초대 시스템은 사용자가 링크를 클릭하여 수동으로 워크스페이스에 가입하는 방식이기 때문에, 사용자가 직접 초대 URL을 받거나 전달받은 URL을 통해 권한을 얻을 수 있어 간단하고 효율적으로 권한 관리가 가능하다.
- node, page, edge에 column으로 workspaceId (foreign key)를 추가한다
- 워크스페이스 엔티티를 설계, 구현한다
import {
Column,
Entity,
ManyToOne,
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
OneToMany,
Index,
} from 'typeorm';
import { User } from '../user/user.entity';
import { Edge } from '../edge/edge.entity';
import { Page } from '../page/page.entity';
import { Node } from '../node/node.entity';
@Entity()
export class Workspace {
@PrimaryGeneratedColumn()
id: number;
@Column({ unique: true })
@Index()
snowflakeId: string;
@ManyToOne(() => User, { nullable: false })
owner: User;
@Column()
title: string;
@Column({ nullable: true })
description: string;
@Column({ default: 'private' })
visibility: 'public' | 'private';
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
@Column({ nullable: true })
thumbnailUrl: string;
@OneToMany(() => Edge, (edge) => edge.workspace)
edges: Edge[];
@OneToMany(() => Page, (page) => page.workspace)
pages: Page[];
@OneToMany(() => Node, (node) => node.workspace)
nodes: Node[];
}
- Role 엔티티를 설계, 구현한다
import {
Column,
Entity,
ManyToOne,
PrimaryColumn,
CreateDateColumn,
} from 'typeorm';
import { User } from '../user/user.entity';
import { Workspace } from '../workspace/workspace.entity';
@Entity()
export class Role {
@PrimaryColumn()
workspaceId: number;
@PrimaryColumn()
userId: number;
@ManyToOne(() => Workspace, (workspace) => workspace.id, {
onDelete: 'CASCADE',
})
workspace: Workspace;
@ManyToOne(() => User, (user) => user.id, { onDelete: 'CASCADE' })
user: User;
// 'owner' 또는 'guest'
// 저 중 하나로 제한 필요함 -> service에서 관리해야됨
@Column()
role: string;
@CreateDateColumn()
createdAt: Date;
}
-
특정 워크스페이스에 있는 Node, Edge, Page를 모두 가져오는 서비스 코드를 구현한다
-
Workspace관련 기본 API (생성, 삭제, 메타데이터 변경)를 구현한다
⇒ 앞서 구현한 AuthGuard를 사용한 후 DB에서 owner인지 확인하는 과정을 거쳐 권한 검증을 한다
-
Workspace 초대, 즉 권한 관련 API를 구현한다
⇒ JWT에 권한을 넣으려다가 이는 이후에 발전시켜도 좋겠다고 판단, 일단은 DB에서 데이터를 직접 꺼내고 체해 권한 검증을 하게 구현하였다. 예를 들면 특정 워크스페이스의 메타데이터를 전송하기 전, 해당 사용자가 워크스페이스에 접근할 권한이 있는지를 해당 서비스 코드는 이렇게 확인한다.
async getWorkspaceData( userId: string | null, workspaceId: string, ): Promise<UserWorkspaceDto> { // workspace가 존재하는지 확인 const workspace = await this.workspaceRepository.findOne({ where: { snowflakeId: workspaceId }, }); if (!workspace) { throw new WorkspaceNotFoundException(); } // 퍼블릭 워크스페이스인 경우 if (workspace.visibility === 'public') { return { workspaceId: workspace.snowflakeId, title: workspace.title, description: workspace.description, thumbnailUrl: workspace.thumbnailUrl, role: null, visibility: 'public', }; } if (userId === null) { throw new ForbiddenAccessException(); } const user = await this.userRepository.findOneBy({ snowflakeId: userId, }); if (!user) { throw new UserNotFoundException(); } // workspace와 user에 대한 role 확인 const role = await this.roleRepository.findOne({ where: { userId: user.id, workspaceId: workspace.id }, }); if (!role) { // 권한이 없으면 예외 발생 throw new ForbiddenAccessException(); } return { workspaceId: workspace.snowflakeId, title: workspace.title, description: workspace.description, thumbnailUrl: workspace.thumbnailUrl, role: role.role as 'owner' | 'guest', visibility: 'private', }; }
-
완성된 워크스페이스 관련 api는 이러하다
@WebSocketServer()
server: Server;
afterInit() {
if (!this.server) {
this.logger.error('서버 초기화 안됨..!');
this.server = new Server();
}
this.ysocketio = new YSocketIO(this.server, {
gcEnabled: true,
});
this.ysocketio.initialize();
this.ysocketio.on('document-loaded', async (doc: Y.Doc) => {
// Y.Doc에 name이 없어서 새로 만든 CustomDoc
const editorDoc = doc.getXmlFragment('default');
const customDoc = editorDoc.doc as CustomDoc;
// 만약 users document라면 초기화하지 않습니다.
if (customDoc.name === 'users') {
return;
}
// document name이 flow-room이라면 모든 노드들을 볼 수 있는 화면입니다.
// 노드를 클릭해 페이지를 열었을 때만 해당 페이지 값을 가져와서 초기 데이터로 세팅해줍니다.
if (customDoc.name?.startsWith('document-')) {
const pageId = parseInt(customDoc.name.split('-')[1]);
this.initializePage(pageId, editorDoc);
}
if (customDoc.name?.startsWith('flow-room-')) {
const workspaceId = customDoc.name.split('-')[2] ?? 'main';
// 만약 workspace document라면 node, edge 초기 데이터를 세팅해줍니다.
this.initializeWorkspace(workspaceId, doc);
}
});
}
-
클라이언트로부터 Y.Doc을 전달받고 해당 Y.Doc의 정보를 분석하여 지금 사용자가 접근한 워크스페이스의 정보를 취득한다
-
단, Octodocs의 첫번째 화면이기도 한 공용공간은 workspace의 Id가 따로 없을 것이다. 해당 워크스페이스의 아이디를 main으로 규정, 일반적으로 워크스페이스를 생성하는 서비스코드를 사용하지 않고 직접 넣어주는 초기화 함수를 구현하였다(일반적인 워크스페이스를 외부 id를 snowflakeId로 자동생성하고 있기에 따로 구현해야핬다)
async initializeMainWorkspace() { let findOwner = await this.userRepository.findOneBy({ snowflakeId: MainWorkspace.OWNER_SNOWFLAKEID, }); // 존재하지 않을 때만 생성한다. if (!findOwner) { // main workspace owner를 생성한다. const owner = await this.userRepository.save({ snowflakeId: MainWorkspace.OWNER_SNOWFLAKEID, providerId: MainWorkspace.OWNER_PROVIDER_ID, provider: MainWorkspace.OWNER_PROVIDER, email: MainWorkspace.OWNER_EMAIL, }); findOwner = owner; } this.logger.log('main workspace owner가 존재합니다.'); // main workspace를 찾는다. let findWorkspace = await this.workspaceRepository.findOneBy({ snowflakeId: MainWorkspace.WORKSPACE_SNOWFLAKEID, }); // owner는 존재하지만 워크스페이스가 없으면 생성한다. if (!findWorkspace) { findWorkspace = await this.workspaceRepository.save({ snowflakeId: MainWorkspace.WORKSPACE_SNOWFLAKEID, owner: findOwner, title: MainWorkspace.WORKSPACE_TITLE, description: MainWorkspace.WORKSPACE_DESCRIPTION, visibility: MainWorkspace.WORKSPACE_VISIBILITY, }); this.logger.log('main workspace를 생성했습니다.'); } this.logger.log('main workspace가 존재합니다.'); ...
생성된 공용 워크스페이스의 정보는 이러하다
enum MainWorkspace { OWNER_SNOWFLAKEID = 'admin', OWNER_PROVIDER_ID = 'adminProviderId', OWNER_PROVIDER = 'adminProvider', OWNER_EMAIL = '[email protected]', WORKSPACE_SNOWFLAKEID = 'main', WORKSPACE_TITLE = 'main workspace', WORKSPACE_DESCRIPTION = '모든 유저가 접근 가능한 메인 workspace', WORKSPACE_VISIBILITY = 'public', }
⚓️ 사용자 피드백과 버그 기록
👷🏻 기술적 도전
📖 위키와 학습정리
✏️ 에디터
Novel이란?
Novel 스타일링 문제
에디터 저장 및 고려 사항들
📠 실시간 협업, 통신
Yorkie와 Novel editor 연동
YJS, Websocket, React-Flow
YJS, Socket.io
WebSocket과 Socket.io에 대해 간단히 알아보기
YJS 가이드 근데 이제 Socket.io를 곁들인
🏗️ 인프라와 CI/CD
NCloud CI CD 구축
BE 개발 스택과 기술적 고민
private key로 원격 서버 접근
nCloud 서버, VPC 만들고 설정
monorepo로 변경
⌛ 캐시, 최적화
rabbit mq 사용법
🔑 인증, 인가, 보안
passport로 oAuth 로그인 회원가입 구현
FE 로그인 기능 구현
JWT로 인증 인가 구현
JWT 쿠키로 사용하기
refresh token 보완하기
🧸 팀원 소개
⛺️ 그라운드 룰
🍞 커밋 컨벤션
🧈 이슈, PR 컨벤션
🥞 브랜치 전략
🌤️ 데일리 스크럼
📑 회의록
1️⃣ 1주차
킥오프(10/25)
2일차(10/29)
3일차(10/30)
4일차(10/31)
2️⃣ 2주차
8일차(11/04)
9일차(11/05)
11일차(11/07)
13일차(11/09)
3️⃣ 3주차
3주차 주간계획(11/11)
16일차(11/12)
18일차(11/14)
4️⃣ 4주차
4주차 주간계획(11/18)
23일차(11/19)
24일차(11/20)
25일차(11/21)
5️⃣ 5주차
5주차 주간계획(11/25)
29일차(11/25)
32일차(11/28)
34일차(11/30)
6️⃣ 6주차
6주차 주간계획(12/2)
37일차(12/3)