권한 게이트(CASL) — 라우트 이관 가이드
상태: Phase 1 / PR #2 산출물. 목적: 모든 라우트가 동일한 패턴(
ability.can)으로 권한을 묻도록 통일. 범위: 신규 워크스페이스/멤버/초대/ACL/세션 라우트는 처음부터 본 패턴 사용. 기존 라우트는 점진 이관.
1. 기본 사용 패턴
const authenticate = require('../middleware/auth');
const { withWorkspace, requireAbility } = require('../middleware/workspace');
// 단순 일반 권한 체크
router.get(
'/workspaces/:workspaceId/members',
authenticate,
withWorkspace(), // req.workspaceId, req.ability 부착
requireAbility('read', 'Membership'),
handler
);
// 동적 subject (특정 도큐먼트 단위 권한 평가)
router.patch(
'/workspaces/:workspaceId/sessions/:id',
authenticate,
withWorkspace(),
async (req, res) => {
const session = await ArchitectureSession.findById(req.params.id);
const acl = await ResourceAcl.findOne({ resourceType: 'session', resourceId: session._id });
if (!req.ability.canActOnSession('update', { session, acl })) {
return res.status(403).json({ success: false, message: '권한 없음' });
}
// ... 업데이트 ...
}
);withWorkspace(opts) 옵션
| opt | 효과 |
|---|---|
| (없음) | workspaceId를 라우트 파라미터/헤더/쿼리에서 해석. 못 찾으면 ability는 워크스페이스-무관 권한만 갖는다. |
fallbackPersonal: true | 위에서 못 찾으면 사용자의 personal workspace로 채움. 개인 사용 흐름(기존 라우트 이관)에 적합. |
workspaceId 해석 우선순위
:workspaceId라우트 파라미터x-workspace-id헤더?workspaceId=...쿼리- (옵션) personal workspace fallback
2. Subject 표현
CASL은 "행위(action)"를 "주체(subject)"에 묻는다. 본 프로젝트의 표준 subject:
| Subject | 용도 |
|---|---|
'Workspace' | 워크스페이스 메타 자체 |
'Membership' | 멤버 목록/역할 변경 |
'Invitation' | 초대 발송/취소/조회 |
'Billing' | 결제·플랜·seat 변경 |
'AuditLog' | 감사 로그 조회 |
'Session' | ArchitectureSession (캔버스 포함) |
동적 subject가 필요하면 헬퍼 사용:
const { subjectOnSession, subjectOnWorkspace } = require('../services/ability');
ability.can('update', subjectOnSession({ session, acl }));
ability.can('delete', subjectOnWorkspace(workspace));ACL이 부착된 Session은 반드시 canActOnSession(action, { session, acl }) 을 사용한다 (일반 ability.can은 ACL 상속을 평가하지 못함).
canActOnSession action | 의미 |
|---|---|
'read' | viewer 이상 |
'comment' | commenter 이상 |
'update' | editor 이상 |
'manage' | manager (ACL/공유/삭제) |
3. 기존 라우트 이관 패턴
A. 단순 "본인 소유 리소스만" 라우트
현재(예시):
const session = await ArchitectureSession.findOne({ _id, user: req.user.userId });
if (!session) return res.status(404)...이관 후:
const session = await ArchitectureSession.findById(_id);
if (!session) return res.status(404)...
const acl = await ResourceAcl.findOne({ resourceType: 'session', resourceId: _id });
if (!req.ability.canActOnSession('read', { session, acl })) return res.status(403)...→ 같은 라우트가 자동으로 "본인 + 워크스페이스 멤버 + ACL 공유받은 사람" 모두 처리.
B. requireAdmin (이메일 화이트리스트) 라우트
즉시 옮길 필요 없음. 글로벌 운영자 권한은 withWorkspace()가 isAdminEmail을 보고 자동으로 can('manage','all') 부여하므로, 신규 라우트에서는 requireAbility(...) 만으로 admin도 통과한다.
기존 requireAdmin 라우트는 그대로 두되, 신규 라우트는 본 패턴으로 작성.
C. 구독 tier 기반 게이트 (getEntitlements)
권한 게이트가 아니라 요금제 기능 게이트다. CASL과 별개 레이어로 유지한다.
- 권한(누구냐) = CASL
- 요금제(무엇을 살 수 있느냐) = entitlements
4. 응답 표준
requireAbility 거부 시:
{ "success": false, "message": "권한이 없습니다.", "action": "update", "subject": "Workspace" }라우트 핸들러 내부에서 동적 평가 후 거부할 때도 같은 포맷을 권장.
5. 이관 우선순위 (점진)
| 우선순위 | 대상 라우트 군 | 이유 |
|---|---|---|
| 🟢 즉시 (신규) | /workspaces/*, /invitations/*, /sessions/:id/acl | PR #3~#5에서 신규 작성 |
| 🟡 곧 (PR #5 머지 후) | routes/architecture.js 세션 CRUD | 캔버스 공유 활성화의 핵심 |
| 🔵 나중 | routes/admin.js, routes/subscription.js | 어드민/결제 — 글로벌 admin은 자동 통과되므로 급하지 않음 |
| ⚪ 보류 | routes/quest-hub/*, XP rank 게이트 | 권한이 아닌 게임화 로직 |
6. 글로벌 admin 화이트리스트 중앙화 (TODO)
현재 middleware/workspace.js와 routes/admin.js 양쪽에 ADMIN_EMAILS가 중복 정의되어 있다. PR #6(워크스페이스 설정 페이지) 사이클에 config/admin.js로 중앙화 예정.